awacke1 commited on
Commit
9447266
·
verified ·
1 Parent(s): 08066b1

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +256 -289
app.py CHANGED
@@ -6,32 +6,25 @@ import json
6
  import pandas as pd
7
  import uuid
8
  import math
9
- from streamlit_js_eval import streamlit_js_eval # For JS communication
10
- import time # For potential throttling if needed
11
 
12
  # --- Constants ---
13
  SAVE_DIR = "saved_worlds"
14
  PLOT_WIDTH = 50.0
15
  PLOT_DEPTH = 50.0
16
  CSV_COLUMNS = ['obj_id', 'type', 'pos_x', 'pos_y', 'pos_z', 'rot_x', 'rot_y', 'rot_z', 'rot_order']
17
- STATE_POLL_INTERVAL_MS = 5000 # How often clients ask for updates (milliseconds)
18
 
19
  # --- Ensure Save Directory Exists ---
20
  os.makedirs(SAVE_DIR, exist_ok=True)
21
 
22
- # --- Server-Side State Management ---
23
 
24
- # Global lock could be useful for more complex state modification,
25
- # but for simple file writes + cache clear, Python's GIL might suffice.
26
- # Add `import threading` if using the lock.
27
- # state_lock = threading.Lock()
28
-
29
- @st.cache_data(ttl=3600) # Cache plot list - relatively static
30
  def load_plot_metadata():
31
  """Scans save dir for plot_X*_Z*.csv, sorts, calculates metadata."""
32
- # (Keep your existing load_plot_metadata function code here)
33
- # ... (same as your original code) ...
34
- plots = []
35
  plot_files = []
36
  try:
37
  plot_files = [f for f in os.listdir(SAVE_DIR) if f.endswith(".csv") and f.startswith("plot_X")]
@@ -45,249 +38,248 @@ def load_plot_metadata():
45
  parsed_plots = []
46
  for filename in plot_files:
47
  try:
 
 
 
 
 
 
 
48
  parts = filename[:-4].split('_')
49
  grid_x = int(parts[1][1:])
50
  grid_z = int(parts[2][1:])
51
  plot_name = " ".join(parts[3:]) if len(parts) > 3 else f"Plot ({grid_x},{grid_z})"
52
-
53
  parsed_plots.append({
54
- 'id': filename[:-4],
55
- 'filename': filename,
56
- 'grid_x': grid_x,
57
- 'grid_z': grid_z,
58
- 'name': plot_name,
59
- 'x_offset': grid_x * PLOT_WIDTH,
60
- 'z_offset': grid_z * PLOT_DEPTH
61
  })
62
- except (IndexError, ValueError):
63
- st.warning(f"Could not parse grid coordinates from filename: {filename}. Skipping.")
 
 
 
 
64
  continue
65
 
66
  parsed_plots.sort(key=lambda p: (p['grid_x'], p['grid_z']))
 
67
  return parsed_plots
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
 
70
- # --- Use cache_data to hold the authoritative world state ---
71
- # This function loads *all* objects from *all* known plots.
72
- # It gets re-run automatically by Streamlit if its internal state changes
73
- # OR if we manually clear its cache after saving.
74
- @st.cache_data(show_spinner=False) # Show spinner might be annoying for frequent polls
75
- def get_authoritative_world_state():
76
- """Loads ALL objects from ALL saved plot files."""
77
- print("--- Reloading Authoritative World State from Files ---")
78
- all_objects = {} # Use dict keyed by obj_id for efficient lookup/update
79
- plots_meta = load_plot_metadata() # Get the list of plots first
80
 
81
  for plot in plots_meta:
82
- file_path = os.path.join(SAVE_DIR, plot['filename'])
83
- try:
84
- # Check if file is empty before reading
85
- if os.path.getsize(file_path) == 0:
86
- print(f"Skipping empty plot file: {plot['filename']}")
87
- continue
88
-
89
- df = pd.read_csv(file_path)
90
-
91
- # Basic validation (adjust as needed)
92
- if df.empty:
93
- continue
94
- if not all(col in df.columns for col in ['obj_id', 'type', 'pos_x', 'pos_y', 'pos_z']):
95
- st.warning(f"CSV '{plot['filename']}' missing essential columns. Skipping some objects.")
96
- # Attempt to process valid rows anyway? Or skip file entirely?
97
- df = df.dropna(subset=['obj_id', 'type', 'pos_x', 'pos_y', 'pos_z']) # Drop rows missing essential info
98
-
99
- # Add defaults for optional columns if they don't exist at all
100
- for col, default in [('rot_x', 0.0), ('rot_y', 0.0), ('rot_z', 0.0), ('rot_order', 'XYZ')]:
101
- if col not in df.columns: df[col] = default
102
-
103
- # Ensure obj_id is string
104
- df['obj_id'] = df['obj_id'].astype(str)
105
- # Fill missing optional values *per row*
106
- df.fillna({'rot_x': 0.0, 'rot_y': 0.0, 'rot_z': 0.0, 'rot_order': 'XYZ'}, inplace=True)
107
-
108
-
109
- for _, row in df.iterrows():
110
- obj_data = row.to_dict()
111
- obj_id = obj_data.get('obj_id')
112
- if not obj_id: # Should have obj_id now, but check anyway
113
- st.warning(f"Skipping object with missing ID in {plot['filename']}")
114
- continue
115
-
116
- # Apply world offset (positions in CSV are relative to plot origin)
117
- obj_data['pos_x'] += plot['x_offset']
118
- obj_data['pos_z'] += plot['z_offset']
119
-
120
- # Standardize structure for JS
121
- world_obj = {
122
- 'obj_id': obj_id,
123
- 'type': obj_data['type'],
124
- 'position': {'x': obj_data['pos_x'], 'y': obj_data['pos_y'], 'z': obj_data['pos_z']},
125
- 'rotation': {'_x': obj_data['rot_x'], '_y': obj_data['rot_y'], '_z': obj_data['rot_z'], '_order': obj_data['rot_order']}
126
  }
127
- all_objects[obj_id] = world_obj # Store using obj_id as key
128
-
129
- except FileNotFoundError:
130
- st.error(f"File not found during object load: {plot['filename']}")
131
- except pd.errors.EmptyDataError:
132
- print(f"Plot file is empty (valid): {plot['filename']}") # Normal case
133
- except Exception as e:
134
- st.error(f"Error loading objects from {plot['filename']}: {e}")
135
- st.exception(e) # Print traceback for debugging
136
 
137
- print(f"--- Loaded {len(all_objects)} objects into authoritative state ---")
138
- # Return as a list for easier JSON serialization if needed, but dict is good for server
139
- return all_objects # Return the dictionary
140
 
141
 
142
- def save_new_objects_to_plots(objects_to_save):
143
  """
144
- Saves a list of NEW objects (with world coordinates) to their
145
- respective plot CSV files. Updates existing files or creates new ones.
146
- Returns True if successful, False otherwise.
147
  """
148
- if not isinstance(objects_to_save, list):
149
- st.error("Invalid data format received for saving (expected a list).")
150
- return False
151
-
152
- # Group objects by the plot they belong to
153
- objects_by_plot = {} # Key: (grid_x, grid_z), Value: list of relative objects
154
-
155
- for obj in objects_to_save:
156
- pos = obj.get('position')
157
- obj_id = obj.get('obj_id')
158
- obj_type = obj.get('type')
159
- rot = obj.get('rotation', {'_x': 0.0, '_y': 0.0, '_z': 0.0, '_order': 'XYZ'}) # Add default rotation
160
-
161
- if not pos or not obj_id or not obj_type:
162
- st.warning(f"Skipping malformed object during save prep: {obj}")
 
 
 
163
  continue
164
 
165
- # Determine target plot
166
- grid_x = math.floor(pos.get('x', 0.0) / PLOT_WIDTH)
167
- grid_z = math.floor(pos.get('z', 0.0) / PLOT_DEPTH)
168
- plot_key = (grid_x, grid_z)
169
-
170
- # Calculate relative position
171
- relative_x = pos['x'] - (grid_x * PLOT_WIDTH)
172
- relative_z = pos['z'] - (grid_z * PLOT_DEPTH)
173
-
174
  relative_obj = {
175
  'obj_id': obj_id,
176
  'type': obj_type,
177
- 'pos_x': relative_x,
178
  'pos_y': pos.get('y', 0.0),
179
- 'pos_z': relative_z,
180
  'rot_x': rot.get('_x', 0.0),
181
  'rot_y': rot.get('_y', 0.0),
182
  'rot_z': rot.get('_z', 0.0),
183
  'rot_order': rot.get('_order', 'XYZ')
184
  }
 
 
185
 
186
- if plot_key not in objects_by_plot:
187
- objects_by_plot[plot_key] = []
188
- objects_by_plot[plot_key].append(relative_obj)
189
-
190
- # --- Save each plot ---
191
- save_successful = True
192
- saved_files_count = 0
193
- new_files_created = 0
194
 
195
- # with state_lock: # Optional lock if race conditions become an issue
196
- for (grid_x, grid_z), relative_objects in objects_by_plot.items():
197
- filename = f"plot_X{grid_x}_Z{grid_z}.csv"
198
- file_path = os.path.join(SAVE_DIR, filename)
199
- is_new_file = not os.path.exists(file_path)
200
-
201
- try:
202
- new_df = pd.DataFrame(relative_objects, columns=CSV_COLUMNS)
203
-
204
- if is_new_file:
205
- # Create new file
206
- new_df.to_csv(file_path, index=False)
207
- st.info(f"Created new plot file: {filename} with {len(relative_objects)} objects.")
208
- new_files_created += 1
209
- else:
210
- # Append to existing file (or overwrite if merging is complex)
211
- # Safest way is often read, concat, drop duplicates, write.
212
- try:
213
- existing_df = pd.read_csv(file_path)
214
- # Ensure obj_id is string for comparison
215
- existing_df['obj_id'] = existing_df['obj_id'].astype(str)
216
- new_df['obj_id'] = new_df['obj_id'].astype(str)
217
-
218
- # Combine, keeping the newly added one if obj_id conflicts
219
- combined_df = pd.concat([existing_df, new_df]).drop_duplicates(subset=['obj_id'], keep='last')
220
-
221
- except (FileNotFoundError, pd.errors.EmptyDataError):
222
- # If file vanished or became empty between check and read, treat as new
223
- print(f"Warning: File {filename} was empty or missing on read, creating.")
224
- combined_df = new_df
225
- except Exception as read_e:
226
- st.error(f"Error reading existing file {filename} for merge: {read_e}")
227
- save_successful = False
228
- continue # Skip this plot
229
-
230
- combined_df.to_csv(file_path, index=False)
231
- st.info(f"Updated plot file: {filename}. Total objects now: {len(combined_df)}")
232
-
233
- saved_files_count += 1
234
-
235
- except Exception as e:
236
- st.error(f"Failed to save plot data to {filename}: {e}")
237
- st.exception(e)
238
- save_successful = False
239
-
240
- if save_successful and saved_files_count > 0:
241
- st.success(f"Saved {len(objects_to_save)} objects across {saved_files_count} plot file(s).")
242
- # --- CRITICAL: Clear caches so other users/next poll get the update ---
243
- get_authoritative_world_state.clear()
244
- load_plot_metadata.clear() # Also update plot list if new files were made
245
- print("--- Server caches cleared after successful save ---")
246
  return True
247
- elif saved_files_count == 0 and len(objects_to_save) > 0:
248
- st.warning("Save requested, but no valid objects were processed.")
249
- return False # Indicate nothing was actually saved
250
- else:
251
- # Errors occurred during saving
252
  return False
253
 
254
-
255
  # --- Page Config ---
256
- st.set_page_config(page_title="Shared World Builder", layout="wide")
257
 
258
  # --- Initialize Session State ---
259
- # Keep track of the *selected* object type for placement (per user)
260
- if 'selected_object' not in st.session_state:
261
- st.session_state.selected_object = 'None'
262
- # Store the result from the JS save call
263
- if 'js_save_payload' not in st.session_state:
264
- st.session_state.js_save_payload = None
265
- # Store the result from the JS polling call (less critical to persist)
266
- # if 'js_poll_result' not in st.session_state:
267
- # st.session_state.js_poll_result = None # Might not need this server-side
268
-
269
- # --- Load Initial Data for THIS Client ---
270
- # Load metadata for sidebar navigation
271
  plots_metadata = load_plot_metadata()
272
- # Get the current authoritative state for initial injection
273
- initial_world_state_dict = get_authoritative_world_state()
274
- initial_world_state_list = list(initial_world_state_dict.values()) # Convert to list for JS
275
 
276
  # --- Sidebar ---
277
  with st.sidebar:
278
  st.title("🏗️ World Controls")
279
 
 
 
 
 
 
 
 
 
 
 
 
 
280
  # Navigation (using cached metadata)
281
  st.header("Navigation (Plots)")
282
  st.caption("Click to teleport player to a plot.")
283
  max_cols = 2
284
  cols = st.columns(max_cols)
285
  col_idx = 0
 
286
  sorted_plots_for_nav = sorted(plots_metadata, key=lambda p: (p['grid_x'], p['grid_z']))
287
  for plot in sorted_plots_for_nav:
288
  button_label = f"➡️ {plot.get('name', plot['id'])} ({plot['grid_x']},{plot['grid_z']})"
289
  if cols[col_idx].button(button_label, key=f"nav_{plot['id']}"):
290
- target_x = plot['x_offset'] + PLOT_WIDTH / 2 # Center of plot
291
  target_z = plot['z_offset'] + PLOT_DEPTH / 2
292
  try:
293
  js_code = f"teleportPlayer({target_x}, {target_z});"
@@ -298,130 +290,107 @@ with st.sidebar:
298
 
299
  st.markdown("---")
300
 
301
- # Object Placement (per-user selection)
302
  st.header("Place Objects")
303
  object_types = ["None", "Simple House", "Tree", "Rock", "Fence Post"]
304
- # Ensure current state selection is valid, default to None if not
305
  current_selection = st.session_state.selected_object
306
- if current_selection not in object_types:
307
- current_selection = "None"
308
- st.session_state.selected_object = "None" # Correct invalid state
309
  current_object_index = object_types.index(current_selection)
310
 
311
  selected_object_type_widget = st.selectbox(
312
  "Select Object:", options=object_types, index=current_object_index, key="selected_object_widget"
313
  )
314
- # Update session state ONLY if the widget's value changes
315
  if selected_object_type_widget != st.session_state.selected_object:
316
  st.session_state.selected_object = selected_object_type_widget
317
- # No rerun needed here, JS will pick up the change via injected state on next interaction/poll
318
- # Or we can force a JS update immediately:
319
  try:
320
  js_update_selection = f"updateSelectedObjectType({json.dumps(st.session_state.selected_object)});"
321
  streamlit_js_eval(js_code=js_update_selection, key="update_selection_js")
322
  except Exception as e:
323
  st.warning(f"Could not push selection update to JS: {e}")
 
 
324
 
325
 
326
  st.markdown("---")
327
 
328
- # Saving (triggers JS to send data)
329
  st.header("Save Work")
330
- st.caption("Saves objects you've placed since your last save.")
331
  if st.button("💾 Save My New Objects", key="save_button"):
332
- # Trigger JS to get ONLY the newly placed objects data
333
- # We don't need player position here anymore, save logic handles it based on obj pos
334
- js_get_data_code = "getNewlyPlacedObjectsForSave();"
335
- # Use 'want_result=True' to get the data back into python state
336
- st.session_state.js_save_payload = streamlit_js_eval(
337
- js_code=js_get_data_code,
338
- key="js_save_processor",
339
- want_result=True # Make sure we get the return value
340
- )
341
- # No automatic rerun here - we process the result below
342
-
343
- # --- Process Save Data (if triggered) ---
344
- save_data_from_js = st.session_state.get("js_save_payload", None)
345
 
346
  if save_data_from_js is not None:
347
- st.session_state.js_save_payload = None # Consume the trigger
348
  st.info("Received save data from client...")
349
  save_processed_successfully = False
350
  try:
351
- # Expecting a JSON string representing a LIST of new objects
352
- new_objects = json.loads(save_data_from_js)
353
-
354
- if isinstance(new_objects, list):
355
- if not new_objects:
356
- st.warning("Save clicked, but there were no new objects to save.")
 
 
 
 
 
 
357
  else:
358
- # Call the function to save these objects to their plots
359
- save_ok = save_new_objects_to_plots(new_objects)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
 
361
  if save_ok:
362
- # Tell JS to clear its local list of newly placed objects
 
 
363
  try:
364
  streamlit_js_eval(js_code="resetNewlyPlacedObjects();", key="reset_js_state_after_save")
365
- st.success("Changes saved successfully and client state reset.")
366
- save_processed_successfully = True
367
- # Short delay maybe? To allow caches to potentially clear before rerun?
368
- # time.sleep(0.1)
369
  except Exception as js_e:
370
  st.warning(f"Save successful, but could not reset JS state: {js_e}")
371
- # State might be slightly off until next poll/refresh
372
  else:
373
- st.error("Failed to save new objects to plot files. Check logs.")
374
  else:
375
- st.error(f"Invalid save payload structure received (expected list): {type(new_objects)}")
376
- print("Received payload:", save_data_from_js)
377
 
378
  except json.JSONDecodeError:
379
- st.error("Failed to decode save data from client (was not valid JSON).")
380
  print("Received raw data:", save_data_from_js)
381
  except Exception as e:
382
  st.error(f"Error processing save: {e}")
383
  st.exception(e)
384
 
385
- # Rerun if save was processed (successfully or not) to update sidebar/messages
386
- # and potentially reload data if caches were cleared
387
- if save_processed_successfully:
388
- # Force rerun to ensure the client gets updated state eventually
389
- st.rerun()
390
- # No rerun if save failed, keep message onscreen
391
-
392
- # --- Provide Endpoint for JS Polling ---
393
- # This uses streamlit_js_eval in reverse: JS calls a Python function.
394
- # We define a key that JS will use to trigger this.
395
- # The function returns the *current* authoritative state.
396
- poll_data = streamlit_js_eval(
397
- js_code="""
398
- // Define function in JS global scope if not already defined
399
- if (typeof window.requestStateUpdate !== 'function') {
400
- window.requestStateUpdate = () => {
401
- // This returns a Promise that resolves with the Python return value
402
- return streamlit_js_eval("get_authoritative_world_state()", want_result=True, key="get_world_state_poll");
403
- }
404
- }
405
- // Return something small just to indicate setup is done, or null
406
- null;
407
- """,
408
- key="setup_poll_function" # Unique key for this setup code
409
- )
410
-
411
- # This part *executes* the Python function when JS calls it via the key "get_world_state_poll"
412
- # We use DUMPS_FUNC for potentially large JSON payloads
413
- if 'get_world_state_poll' in st.session_state:
414
- print(f"Polling request received at {time.time()}")
415
- world_state_dict = get_authoritative_world_state()
416
- # Convert dict back to list for sending to JS
417
- world_state_list = list(world_state_dict.values())
418
- st.session_state.get_world_state_poll = world_state_list # Set the result for JS to pick up
419
- print(f"Responding to poll with {len(world_state_list)} objects.")
420
 
421
 
422
  # --- Main Area ---
423
  st.header("Infinite Shared 3D World")
424
- st.caption(f"World state updates every {STATE_POLL_INTERVAL_MS / 1000}s. Use sidebar 'Save' to commit your new objects.")
425
 
426
  # --- Load and Prepare HTML ---
427
  html_file_path = 'index.html'
@@ -432,24 +401,22 @@ try:
432
  html_template = f.read()
433
 
434
  # --- Inject Python state into JavaScript ---
435
- # Send initial state, plot metadata, selected tool, and constants
436
  js_injection_script = f"""
437
  <script>
438
- // Initial state (authoritative at the time of page load)
439
- window.INITIAL_WORLD_STATE = {json.dumps(initial_world_state_list)};
440
  window.PLOTS_METADATA = {json.dumps(plots_metadata)}; // Plot info for ground generation etc.
441
- window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)};
442
  window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)};
443
  window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)};
444
- window.STATE_POLL_INTERVAL_MS = {json.dumps(STATE_POLL_INTERVAL_MS)};
445
 
446
- console.log("Streamlit Initial State Injected:", {{
447
  selectedObject: window.SELECTED_OBJECT_TYPE,
448
- initialObjectsCount: window.INITIAL_WORLD_STATE ? window.INITIAL_WORLD_STATE.length : 0,
449
  plotCount: window.PLOTS_METADATA ? window.PLOTS_METADATA.length : 0,
450
  plotWidth: window.PLOT_WIDTH,
451
- plotDepth: window.PLOT_DEPTH,
452
- pollInterval: window.STATE_POLL_INTERVAL_MS
453
  }});
454
  </script>
455
  """
@@ -459,7 +426,7 @@ try:
459
  # --- Embed HTML Component ---
460
  components.html(
461
  html_content_with_state,
462
- height=750,
463
  scrolling=False
464
  )
465
 
 
6
  import pandas as pd
7
  import uuid
8
  import math
9
+ from streamlit_js_eval import streamlit_js_eval
10
+ import time # For debugging or potential delays
11
 
12
  # --- Constants ---
13
  SAVE_DIR = "saved_worlds"
14
  PLOT_WIDTH = 50.0
15
  PLOT_DEPTH = 50.0
16
  CSV_COLUMNS = ['obj_id', 'type', 'pos_x', 'pos_y', 'pos_z', 'rot_x', 'rot_y', 'rot_z', 'rot_order']
 
17
 
18
  # --- Ensure Save Directory Exists ---
19
  os.makedirs(SAVE_DIR, exist_ok=True)
20
 
21
+ # --- Helper Functions ---
22
 
23
+ # --- Caching Plot Metadata ---
24
+ @st.cache_data(ttl=3600) # Cache plot list for an hour
 
 
 
 
25
  def load_plot_metadata():
26
  """Scans save dir for plot_X*_Z*.csv, sorts, calculates metadata."""
27
+ print(f"[{time.time():.2f}] Loading plot metadata...")
 
 
28
  plot_files = []
29
  try:
30
  plot_files = [f for f in os.listdir(SAVE_DIR) if f.endswith(".csv") and f.startswith("plot_X")]
 
38
  parsed_plots = []
39
  for filename in plot_files:
40
  try:
41
+ # Check if file is empty or just header before parsing filename
42
+ file_path = os.path.join(SAVE_DIR, filename)
43
+ if os.path.getsize(file_path) <= len(",".join(CSV_COLUMNS)) + 2: # Check if smaller/equal to header size + newline
44
+ print(f"Skipping empty or header-only file: {filename}")
45
+ # Optionally delete empty files? os.remove(file_path)
46
+ continue
47
+
48
  parts = filename[:-4].split('_')
49
  grid_x = int(parts[1][1:])
50
  grid_z = int(parts[2][1:])
51
  plot_name = " ".join(parts[3:]) if len(parts) > 3 else f"Plot ({grid_x},{grid_z})"
 
52
  parsed_plots.append({
53
+ 'id': filename[:-4], 'filename': filename,
54
+ 'grid_x': grid_x, 'grid_z': grid_z, 'name': plot_name,
55
+ 'x_offset': grid_x * PLOT_WIDTH, 'z_offset': grid_z * PLOT_DEPTH
 
 
 
 
56
  })
57
+ except FileNotFoundError:
58
+ # Should not happen if listed above, but safety check
59
+ st.warning(f"File {filename} not found during metadata parsing.")
60
+ continue
61
+ except (IndexError, ValueError, OSError) as e:
62
+ st.warning(f"Error parsing metadata from filename '{filename}': {e}. Skipping.")
63
  continue
64
 
65
  parsed_plots.sort(key=lambda p: (p['grid_x'], p['grid_z']))
66
+ print(f"[{time.time():.2f}] Found {len(parsed_plots)} valid plots.")
67
  return parsed_plots
68
 
69
+ # --- Loading Objects (No Cache on individual plots, handled by get_all_world_objects) ---
70
+ def load_single_plot_objects_relative(filename):
71
+ """Loads objects from a specific CSV file, keeping coordinates relative."""
72
+ file_path = os.path.join(SAVE_DIR, filename)
73
+ try:
74
+ # Check for empty file BEFORE reading CSV to avoid pandas error
75
+ if not os.path.exists(file_path) or os.path.getsize(file_path) == 0:
76
+ # print(f"Plot file is empty or missing (normal): {filename}")
77
+ return [] # Return empty list for non-existent or empty files
78
+
79
+ df = pd.read_csv(file_path)
80
+ if df.empty:
81
+ # print(f"Plot file read but DataFrame is empty: {filename}")
82
+ return []
83
+
84
+ # --- Data Cleaning & Defaulting ---
85
+ # Ensure obj_id exists and is unique enough for merging
86
+ if 'obj_id' not in df.columns:
87
+ df['obj_id'] = [str(uuid.uuid4()) for _ in range(len(df))]
88
+ else:
89
+ # Fill potential NaNs in obj_id and ensure string type
90
+ df['obj_id'] = df['obj_id'].fillna(pd.Series([str(uuid.uuid4()) for _ in range(len(df))])).astype(str)
91
+
92
+ # Ensure essential columns exist
93
+ for col in ['type', 'pos_x', 'pos_y', 'pos_z']:
94
+ if col not in df.columns:
95
+ st.warning(f"CSV '{filename}' missing essential column '{col}'. Skipping file.")
96
+ return [] # Skip file if essential geometry is missing
97
+
98
+ # Add defaults for optional columns if they don't exist
99
+ for col, default in [('rot_x', 0.0), ('rot_y', 0.0), ('rot_z', 0.0), ('rot_order', 'XYZ')]:
100
+ if col not in df.columns: df[col] = default
101
+ # Fill NaNs in optional columns with defaults
102
+ df.fillna({'rot_x': 0.0, 'rot_y': 0.0, 'rot_z': 0.0, 'rot_order': 'XYZ'}, inplace=True)
103
+
104
+ # Basic type validation (optional but good)
105
+ for col in ['pos_x', 'pos_y', 'pos_z', 'rot_x', 'rot_y', 'rot_z']:
106
+ df[col] = pd.to_numeric(df[col], errors='coerce') # Convert to number, turn errors into NaN
107
+ df.dropna(subset=['pos_x', 'pos_y', 'pos_z'], inplace=True) # Drop rows where position failed conversion
108
+
109
+ # Ensure 'type' is string
110
+ df['type'] = df['type'].astype(str)
111
+
112
+ # Convert to list of dicts using the final columns
113
+ return df[CSV_COLUMNS].to_dict('records')
114
+
115
+ except pd.errors.EmptyDataError:
116
+ # print(f"Plot file is empty (caught by pandas): {filename}") # Normal case
117
+ return []
118
+ except FileNotFoundError:
119
+ # This case should be handled by the os.path check above, but good fallback.
120
+ # print(f"Plot file not found: {filename}")
121
+ return []
122
+ except Exception as e:
123
+ st.error(f"Error loading objects from {filename}: {e}")
124
+ st.exception(e) # Show full traceback in logs/console
125
+ return []
126
+
127
 
128
+ # --- Cache the combined world state ---
129
+ @st.cache_data(show_spinner="Loading world objects...")
130
+ def get_all_world_objects():
131
+ """Loads ALL objects from ALL known plots into world coordinates."""
132
+ print(f"[{time.time():.2f}] Reloading ALL world objects from files...")
133
+ all_objects = {} # Use dict keyed by obj_id for auto-deduplication during load
134
+ plots_meta = load_plot_metadata() # Get the list of valid plots
 
 
 
135
 
136
  for plot in plots_meta:
137
+ relative_objects = load_single_plot_objects_relative(plot['filename'])
138
+ for obj in relative_objects:
139
+ obj_id = obj.get('obj_id')
140
+ if not obj_id: continue # Should have ID from load_single_plot now
141
+
142
+ # Convert to world coordinates
143
+ world_obj = {
144
+ 'obj_id': obj_id,
145
+ 'type': obj.get('type', 'Unknown'),
146
+ 'position': {
147
+ 'x': obj.get('pos_x', 0.0) + plot['x_offset'],
148
+ 'y': obj.get('pos_y', 0.0),
149
+ 'z': obj.get('pos_z', 0.0) + plot['z_offset']
150
+ },
151
+ 'rotation': {
152
+ '_x': obj.get('rot_x', 0.0),
153
+ '_y': obj.get('rot_y', 0.0),
154
+ '_z': obj.get('rot_z', 0.0),
155
+ '_order': obj.get('rot_order', 'XYZ')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  }
157
+ }
158
+ # If obj_id already exists, this will overwrite. Assumes later plot file wins?
159
+ # Or maybe first loaded wins? Dict behavior. Let's assume last wins.
160
+ all_objects[obj_id] = world_obj
 
 
 
 
 
161
 
162
+ world_list = list(all_objects.values())
163
+ print(f"[{time.time():.2f}] Loaded {len(world_list)} total objects.")
164
+ return world_list
165
 
166
 
167
+ def save_plot_data_merged(filename, new_objects_world_coords, plot_x_offset, plot_z_offset):
168
  """
169
+ Loads existing data, merges new objects (world coords), saves back relative.
170
+ Handles de-duplication based on obj_id (new objects overwrite).
171
+ Returns True on success, False otherwise.
172
  """
173
+ file_path = os.path.join(SAVE_DIR, filename)
174
+ print(f"[{time.time():.2f}] Merging and saving plot: {filename}")
175
+
176
+ # 1. Load existing objects (relative coordinates)
177
+ existing_relative_objects = load_single_plot_objects_relative(filename)
178
+ existing_objects_dict = {obj['obj_id']: obj for obj in existing_relative_objects if obj.get('obj_id')}
179
+ print(f"Found {len(existing_objects_dict)} existing objects in {filename}.")
180
+
181
+ # 2. Convert new objects to relative coordinates and add/overwrite in dict
182
+ new_object_count = 0
183
+ for obj_world in new_objects_world_coords:
184
+ obj_id = obj_world.get('obj_id')
185
+ pos = obj_world.get('position')
186
+ rot = obj_world.get('rotation')
187
+ obj_type = obj_world.get('type')
188
+
189
+ if not all([obj_id, pos, rot, obj_type]):
190
+ st.warning(f"Skipping malformed new object during merge: {obj_world}")
191
  continue
192
 
 
 
 
 
 
 
 
 
 
193
  relative_obj = {
194
  'obj_id': obj_id,
195
  'type': obj_type,
196
+ 'pos_x': pos.get('x', 0.0) - plot_x_offset,
197
  'pos_y': pos.get('y', 0.0),
198
+ 'pos_z': pos.get('z', 0.0) - plot_z_offset,
199
  'rot_x': rot.get('_x', 0.0),
200
  'rot_y': rot.get('_y', 0.0),
201
  'rot_z': rot.get('_z', 0.0),
202
  'rot_order': rot.get('_order', 'XYZ')
203
  }
204
+ existing_objects_dict[obj_id] = relative_obj # Add or overwrite based on ID
205
+ new_object_count += 1
206
 
207
+ print(f"Added/updated {new_object_count} objects for {filename}.")
 
 
 
 
 
 
 
208
 
209
+ # 3. Convert final dictionary back to list and save
210
+ final_relative_list = list(existing_objects_dict.values())
211
+ try:
212
+ if not final_relative_list:
213
+ # If the merge results in an empty list, maybe delete the file?
214
+ # Or save an empty file (or just header?)
215
+ if os.path.exists(file_path):
216
+ print(f"Resulting object list for {filename} is empty. Deleting file.")
217
+ os.remove(file_path)
218
+ else:
219
+ print(f"Resulting object list for {filename} is empty. No file to save/delete.")
220
+ # Ensure metadata cache gets cleared even if file deleted
221
+ load_plot_metadata.clear()
222
+ # Clear main object cache too
223
+ get_all_world_objects.clear()
224
+ return True # Considered success?
225
+
226
+ df = pd.DataFrame(final_relative_list, columns=CSV_COLUMNS)
227
+ # Ensure required columns aren't accidentally empty after merge/conversion
228
+ df.dropna(subset=['obj_id','type', 'pos_x', 'pos_y', 'pos_z'], inplace=True)
229
+ df.to_csv(file_path, index=False)
230
+ st.success(f"Saved {len(df)} total objects to {filename}")
231
+ # --- CRITICAL: Clear caches after successful save ---
232
+ load_plot_metadata.clear()
233
+ get_all_world_objects.clear()
234
+ print(f"[{time.time():.2f}] Caches cleared after saving {filename}.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  return True
236
+ except Exception as e:
237
+ st.error(f"Failed to save merged plot data to {filename}: {e}")
238
+ st.exception(e)
 
 
239
  return False
240
 
 
241
  # --- Page Config ---
242
+ st.set_page_config(page_title="Shared World Builder (v2)", layout="wide")
243
 
244
  # --- Initialize Session State ---
245
+ if 'selected_object' not in st.session_state: st.session_state.selected_object = 'None'
246
+ if 'js_save_payload' not in st.session_state: st.session_state.js_save_payload = None # Renamed from js_save_data_result for clarity
247
+
248
+
249
+ # --- Load Initial Data for Page Load / Rerun ---
250
+ # Use the cached function to get all objects
251
+ initial_world_state_list = get_all_world_objects()
252
+ # Metadata also uses its cache
 
 
 
 
253
  plots_metadata = load_plot_metadata()
 
 
 
254
 
255
  # --- Sidebar ---
256
  with st.sidebar:
257
  st.title("🏗️ World Controls")
258
 
259
+ # --- Refresh Button ---
260
+ st.header("World View")
261
+ if st.button("🔄 Refresh World View", key="refresh_button"):
262
+ st.info("Reloading world data...")
263
+ load_plot_metadata.clear()
264
+ get_all_world_objects.clear()
265
+ # Clear potential JS payload trigger if refresh clicked mid-save?
266
+ st.session_state.js_save_payload = None
267
+ st.rerun()
268
+
269
+ st.markdown("---")
270
+
271
  # Navigation (using cached metadata)
272
  st.header("Navigation (Plots)")
273
  st.caption("Click to teleport player to a plot.")
274
  max_cols = 2
275
  cols = st.columns(max_cols)
276
  col_idx = 0
277
+ # Use the potentially updated plots_metadata
278
  sorted_plots_for_nav = sorted(plots_metadata, key=lambda p: (p['grid_x'], p['grid_z']))
279
  for plot in sorted_plots_for_nav:
280
  button_label = f"➡️ {plot.get('name', plot['id'])} ({plot['grid_x']},{plot['grid_z']})"
281
  if cols[col_idx].button(button_label, key=f"nav_{plot['id']}"):
282
+ target_x = plot['x_offset'] + PLOT_WIDTH / 2
283
  target_z = plot['z_offset'] + PLOT_DEPTH / 2
284
  try:
285
  js_code = f"teleportPlayer({target_x}, {target_z});"
 
290
 
291
  st.markdown("---")
292
 
293
+ # Object Placement
294
  st.header("Place Objects")
295
  object_types = ["None", "Simple House", "Tree", "Rock", "Fence Post"]
 
296
  current_selection = st.session_state.selected_object
297
+ if current_selection not in object_types: current_selection = "None"
 
 
298
  current_object_index = object_types.index(current_selection)
299
 
300
  selected_object_type_widget = st.selectbox(
301
  "Select Object:", options=object_types, index=current_object_index, key="selected_object_widget"
302
  )
 
303
  if selected_object_type_widget != st.session_state.selected_object:
304
  st.session_state.selected_object = selected_object_type_widget
305
+ # Update JS immediately without needing full rerun if possible
 
306
  try:
307
  js_update_selection = f"updateSelectedObjectType({json.dumps(st.session_state.selected_object)});"
308
  streamlit_js_eval(js_code=js_update_selection, key="update_selection_js")
309
  except Exception as e:
310
  st.warning(f"Could not push selection update to JS: {e}")
311
+ # Optional: Trigger a rerun if immediate JS update isn't enough
312
+ # st.rerun()
313
 
314
 
315
  st.markdown("---")
316
 
317
+ # --- Saving ---
318
  st.header("Save Work")
319
+ st.caption("Merges your newly placed objects into the shared world plot file.")
320
  if st.button("💾 Save My New Objects", key="save_button"):
321
+ # Trigger JS to get data AND player position (needed to determine target plot)
322
+ js_get_data_code = "getSaveDataAndPosition();"
323
+ # Store the result in session state, process below
324
+ st.session_state.js_save_payload = streamlit_js_eval(js_code=js_get_data_code, key="js_save_processor", want_result=True)
325
+ # No automatic rerun here, processing happens below
326
+
327
+ # --- Process Save Data ---
328
+ save_data_from_js = st.session_state.pop("js_save_payload", None) # Use pop to consume
 
 
 
 
 
329
 
330
  if save_data_from_js is not None:
 
331
  st.info("Received save data from client...")
332
  save_processed_successfully = False
333
  try:
334
+ # Expecting { playerPosition: {x,y,z}, objectsToSave: [...] }
335
+ payload = json.loads(save_data_from_js)
336
+
337
+ if isinstance(payload, dict) and 'playerPosition' in payload and 'objectsToSave' in payload:
338
+ player_pos = payload['playerPosition']
339
+ # These are the NEW objects placed by the user, with WORLD coordinates
340
+ objects_to_save_world_coords = payload['objectsToSave']
341
+
342
+ if not isinstance(objects_to_save_world_coords, list):
343
+ st.error("Invalid 'objectsToSave' format received (expected list).")
344
+ elif not objects_to_save_world_coords:
345
+ st.warning("Save clicked, but there were no new objects reported by the client.")
346
  else:
347
+ # Determine target plot based on player position
348
+ target_grid_x = math.floor(player_pos.get('x', 0.0) / PLOT_WIDTH)
349
+ target_grid_z = math.floor(player_pos.get('z', 0.0) / PLOT_DEPTH)
350
+
351
+ target_filename = f"plot_X{target_grid_x}_Z{target_grid_z}.csv"
352
+ target_plot_x_offset = target_grid_x * PLOT_WIDTH
353
+ target_plot_z_offset = target_grid_z * PLOT_DEPTH
354
+
355
+ st.write(f"Saving {len(objects_to_save_world_coords)} new object(s) to plot: {target_filename} (Player at: x={player_pos.get('x', 0):.1f}, z={player_pos.get('z', 0):.1f})")
356
+
357
+ # --- Call the MERGE save function ---
358
+ save_ok = save_plot_data_merged(
359
+ target_filename,
360
+ objects_to_save_world_coords,
361
+ target_plot_x_offset,
362
+ target_plot_z_offset
363
+ )
364
 
365
  if save_ok:
366
+ save_processed_successfully = True
367
+ # Caches are cleared inside save_plot_data_merged now
368
+ # Tell JS to clear its local unsaved state (newlyPlacedObjects + sessionStorage)
369
  try:
370
  streamlit_js_eval(js_code="resetNewlyPlacedObjects();", key="reset_js_state_after_save")
371
+ st.success("Changes saved and merged successfully. Client state reset.")
 
 
 
372
  except Exception as js_e:
373
  st.warning(f"Save successful, but could not reset JS state: {js_e}")
 
374
  else:
375
+ st.error(f"Failed to save merged plot data to file: {target_filename}")
376
  else:
377
+ st.error("Invalid save payload structure received from client.")
378
+ print("Received payload structure:", type(payload), "Keys:", payload.keys() if isinstance(payload, dict) else "N/A")
379
 
380
  except json.JSONDecodeError:
381
+ st.error("Failed to decode save data from client (invalid JSON).")
382
  print("Received raw data:", save_data_from_js)
383
  except Exception as e:
384
  st.error(f"Error processing save: {e}")
385
  st.exception(e)
386
 
387
+ # Rerun after processing save attempt to reflect changes / clear messages / reload data
388
+ st.rerun()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
 
390
 
391
  # --- Main Area ---
392
  st.header("Infinite Shared 3D World")
393
+ st.caption("Place objects, then 'Save My New Objects'. Use 'Refresh World View' to see others' saved changes.")
394
 
395
  # --- Load and Prepare HTML ---
396
  html_file_path = 'index.html'
 
401
  html_template = f.read()
402
 
403
  # --- Inject Python state into JavaScript ---
404
+ # Use the data loaded (potentially from cache) at the start of the script run
405
  js_injection_script = f"""
406
  <script>
407
+ // Use the global state loaded at the start of this Streamlit script run
408
+ window.ALL_INITIAL_OBJECTS = {json.dumps(initial_world_state_list)};
409
  window.PLOTS_METADATA = {json.dumps(plots_metadata)}; // Plot info for ground generation etc.
410
+ window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)}; // Current user's tool
411
  window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)};
412
  window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)};
 
413
 
414
+ console.log("Streamlit State Injected:", {{
415
  selectedObject: window.SELECTED_OBJECT_TYPE,
416
+ initialObjectsCount: window.ALL_INITIAL_OBJECTS ? window.ALL_INITIAL_OBJECTS.length : 0,
417
  plotCount: window.PLOTS_METADATA ? window.PLOTS_METADATA.length : 0,
418
  plotWidth: window.PLOT_WIDTH,
419
+ plotDepth: window.PLOT_DEPTH
 
420
  }});
421
  </script>
422
  """
 
426
  # --- Embed HTML Component ---
427
  components.html(
428
  html_content_with_state,
429
+ height=750, # Adjust as needed
430
  scrolling=False
431
  )
432