awacke1 commited on
Commit
c910728
·
verified ·
1 Parent(s): a9aa314

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +315 -150
app.py CHANGED
@@ -5,24 +5,32 @@ import os
5
  import json
6
  import pandas as pd
7
  import uuid
8
- import math # For floor function
9
- # from PIL import Image, ImageDraw # No longer needed for minimap
10
- from streamlit_js_eval import streamlit_js_eval # For JS communication
11
 
12
  # --- Constants ---
13
  SAVE_DIR = "saved_worlds"
14
- PLOT_WIDTH = 50.0 # Width of each plot in 3D space
15
- PLOT_DEPTH = 50.0 # Depth of each plot (can be same as width)
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
- @st.cache_data(ttl=3600) # Cache plot list
 
 
 
 
 
24
  def load_plot_metadata():
25
  """Scans save dir for plot_X*_Z*.csv, sorts, calculates metadata."""
 
 
26
  plots = []
27
  plot_files = []
28
  try:
@@ -34,235 +42,386 @@ def load_plot_metadata():
34
  st.error(f"Error listing save directory '{SAVE_DIR}': {e}")
35
  return []
36
 
37
- # Parse filenames to get grid coordinates
38
  parsed_plots = []
39
  for filename in plot_files:
40
  try:
41
- parts = filename[:-4].split('_') # Remove .csv
42
- grid_x = int(parts[1][1:]) # Extract number after X
43
- grid_z = int(parts[2][1:]) # Extract number after Z
44
- # Extract name if present (parts after Z coordinate)
45
  plot_name = " ".join(parts[3:]) if len(parts) > 3 else f"Plot ({grid_x},{grid_z})"
46
 
47
  parsed_plots.append({
48
- 'id': filename[:-4], # Use filename base as unique ID
49
  'filename': filename,
50
  'grid_x': grid_x,
51
  'grid_z': grid_z,
52
  'name': plot_name,
53
  'x_offset': grid_x * PLOT_WIDTH,
54
- 'z_offset': grid_z * PLOT_DEPTH # Use PLOT_DEPTH for Z offset
55
  })
56
  except (IndexError, ValueError):
57
  st.warning(f"Could not parse grid coordinates from filename: {filename}. Skipping.")
58
  continue
59
 
60
- # Sort primarily by X, then by Z
61
  parsed_plots.sort(key=lambda p: (p['grid_x'], p['grid_z']))
62
-
63
  return parsed_plots
64
 
65
- def load_plot_objects(filename, x_offset, z_offset):
66
- """Loads objects from a CSV, applying the plot's world offsets."""
67
- file_path = os.path.join(SAVE_DIR, filename)
68
- objects = []
69
- try:
70
- df = pd.read_csv(file_path)
71
- # Check required columns
72
- if not all(col in df.columns for col in ['type', 'pos_x', 'pos_y', 'pos_z']):
73
- st.warning(f"CSV '{filename}' missing essential columns. Skipping.")
74
- return []
75
- # Add defaults for optional columns
76
- df['obj_id'] = df.get('obj_id', pd.Series([str(uuid.uuid4()) for _ in range(len(df))]))
77
- for col, default in [('rot_x', 0.0), ('rot_y', 0.0), ('rot_z', 0.0), ('rot_order', 'XYZ')]:
78
- if col not in df.columns: df[col] = default
79
-
80
- for _, row in df.iterrows():
81
- obj_data = row.to_dict()
82
- # Apply world offset
83
- obj_data['pos_x'] += x_offset
84
- obj_data['pos_z'] += z_offset # Apply Z offset too
85
- objects.append(obj_data)
86
- return objects
87
- except FileNotFoundError:
88
- st.error(f"File not found during object load: {filename}")
89
- return []
90
- except pd.errors.EmptyDataError:
91
- return [] # Empty file is valid
92
- except Exception as e:
93
- st.error(f"Error loading objects from {filename}: {e}")
94
- return []
95
 
96
- def save_plot_data(filename, objects_data_list, plot_x_offset, plot_z_offset):
97
- """Saves object data list to a CSV, making positions relative to plot origin."""
98
- file_path = os.path.join(SAVE_DIR, filename)
99
- relative_objects = []
100
- if not isinstance(objects_data_list, list):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  st.error("Invalid data format received for saving (expected a list).")
102
  return False
103
 
104
- for obj in objects_data_list:
105
- pos = obj.get('position', {})
106
- rot = obj.get('rotation', {})
107
- obj_type = obj.get('type', 'Unknown')
108
- obj_id = obj.get('obj_id', str(uuid.uuid4()))
 
 
 
109
 
110
- if not all(k in pos for k in ['x', 'y', 'z']) or obj_type == 'Unknown':
111
- print(f"Skipping malformed object during save prep: {obj}")
112
  continue
113
 
 
 
 
 
 
 
 
 
 
114
  relative_obj = {
115
- 'obj_id': obj_id, 'type': obj_type,
116
- 'pos_x': pos.get('x', 0.0) - plot_x_offset, # Make relative X
 
117
  'pos_y': pos.get('y', 0.0),
118
- 'pos_z': pos.get('z', 0.0) - plot_z_offset, # Make relative Z
119
- 'rot_x': rot.get('_x', 0.0), 'rot_y': rot.get('_y', 0.0), 'rot_z': rot.get('_z', 0.0),
 
 
120
  'rot_order': rot.get('_order', 'XYZ')
121
  }
122
- relative_objects.append(relative_obj)
123
 
124
- try:
125
- df = pd.DataFrame(relative_objects, columns=CSV_COLUMNS)
126
- df.to_csv(file_path, index=False)
127
- st.success(f"Saved {len(relative_objects)} objects to {filename}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  return True
129
- except Exception as e:
130
- st.error(f"Failed to save plot data to {filename}: {e}")
 
 
 
131
  return False
132
 
 
133
  # --- Page Config ---
134
- st.set_page_config( page_title="Infinite World Builder", layout="wide")
135
 
136
  # --- Initialize Session State ---
137
- if 'selected_object' not in st.session_state: st.session_state.selected_object = 'None'
138
- if 'new_plot_name' not in st.session_state: st.session_state.new_plot_name = "" # No longer used for filename
139
- if 'js_save_data_result' not in st.session_state: st.session_state.js_save_data_result = None
140
-
141
- # --- Load Plot Metadata ---
142
- # This is now the source of truth for saved plots
 
 
 
 
 
 
143
  plots_metadata = load_plot_metadata()
144
-
145
- # --- Load ALL Objects for Rendering ---
146
- all_initial_objects = []
147
- for plot in plots_metadata:
148
- all_initial_objects.extend(load_plot_objects(plot['filename'], plot['x_offset'], plot['z_offset']))
149
 
150
  # --- Sidebar ---
151
  with st.sidebar:
152
  st.title("🏗️ World Controls")
153
 
 
154
  st.header("Navigation (Plots)")
155
  st.caption("Click to teleport player to a plot.")
156
- max_cols = 2 # Adjust columns for potentially more buttons
157
  cols = st.columns(max_cols)
158
  col_idx = 0
159
- # Sort buttons by grid coords for logical layout
160
  sorted_plots_for_nav = sorted(plots_metadata, key=lambda p: (p['grid_x'], p['grid_z']))
161
  for plot in sorted_plots_for_nav:
162
  button_label = f"➡️ {plot.get('name', plot['id'])} ({plot['grid_x']},{plot['grid_z']})"
163
  if cols[col_idx].button(button_label, key=f"nav_{plot['id']}"):
164
- target_x = plot['x_offset']
165
- target_z = plot['z_offset'] # Use Z offset too
166
  try:
167
- # Tell JS where to teleport (center of plot approx)
168
- js_code = f"teleportPlayer({target_x + PLOT_WIDTH/2}, {target_z + PLOT_DEPTH/2});"
169
  streamlit_js_eval(js_code=js_code, key=f"teleport_{plot['id']}")
170
  except Exception as e:
171
- st.error(f"Failed to send teleport command: {e}")
172
  col_idx = (col_idx + 1) % max_cols
173
 
174
  st.markdown("---")
175
 
176
- # --- Object Placement ---
177
  st.header("Place Objects")
178
  object_types = ["None", "Simple House", "Tree", "Rock", "Fence Post"]
179
- current_object_index = object_types.index(st.session_state.selected_object) if st.session_state.selected_object in object_types else 0
 
 
 
 
 
 
180
  selected_object_type_widget = st.selectbox(
181
  "Select Object:", options=object_types, index=current_object_index, key="selected_object_widget"
182
  )
 
183
  if selected_object_type_widget != st.session_state.selected_object:
184
- st.session_state.selected_object = selected_object_type_widget
185
- # Rerun will happen, JS reloads state via sessionStorage, Python injects new selection
 
 
 
 
 
 
 
186
 
187
  st.markdown("---")
188
 
189
- # --- Saving ---
190
  st.header("Save Work")
191
- st.caption("Saves newly placed objects to the plot the player is currently in. If it's a new area, a new plot file is created.")
192
- if st.button("💾 Save Current Work", key="save_button"):
193
- # Trigger JS to get data AND player position
194
- js_get_data_code = "getSaveDataAndPosition();" # JS function needs update
195
- streamlit_js_eval(js_code=js_get_data_code, key="js_save_processor")
196
- st.rerun() # Rerun to process result
197
-
198
-
199
- # --- Process Save Data ---
200
- save_data_from_js = st.session_state.get("js_save_processor", None)
 
 
 
 
 
201
 
202
  if save_data_from_js is not None:
 
203
  st.info("Received save data from client...")
204
  save_processed_successfully = False
205
  try:
206
- # Expecting { playerPosition: {x,y,z}, objectsToSave: [...] }
207
- payload = json.loads(save_data_from_js) if isinstance(save_data_from_js, str) else save_data_from_js
208
-
209
- if isinstance(payload, dict) and 'playerPosition' in payload and 'objectsToSave' in payload:
210
- player_pos = payload['playerPosition']
211
- objects_to_save = payload['objectsToSave']
212
 
213
- if isinstance(objects_to_save, list): # Allow saving empty list (clears new objects)
214
- # Determine target plot based on player position
215
- target_grid_x = math.floor(player_pos.get('x', 0.0) / PLOT_WIDTH)
216
- target_grid_z = math.floor(player_pos.get('z', 0.0) / PLOT_DEPTH) # Use Z pos too
217
-
218
- target_filename = f"plot_X{target_grid_x}_Z{target_grid_z}.csv"
219
- target_plot_x_offset = target_grid_x * PLOT_WIDTH
220
- target_plot_z_offset = target_grid_z * PLOT_DEPTH
221
-
222
- st.write(f"Attempting to save plot: {target_filename} (Player at: x={player_pos.get('x', 0):.1f}, z={player_pos.get('z', 0):.1f})")
223
-
224
- # Check if this plot already exists in metadata (for logging/future logic)
225
- is_new_plot_file = not os.path.exists(os.path.join(SAVE_DIR, target_filename))
226
-
227
- save_ok = save_plot_data(target_filename, objects_to_save, target_plot_x_offset, target_plot_z_offset)
228
 
229
  if save_ok:
230
- load_plot_metadata.clear() # Clear cache to force reload metadata
231
- try: # Tell JS to clear its unsaved state
232
- streamlit_js_eval(js_code="resetNewlyPlacedObjects();", key="reset_js_state")
 
 
 
 
233
  except Exception as js_e:
234
- st.warning(f"Could not reset JS state after save: {js_e}")
235
-
236
- if is_new_plot_file:
237
- st.success(f"New plot created and saved: {target_filename}")
238
- else:
239
- st.success(f"Updated existing plot: {target_filename}")
240
- save_processed_successfully = True
241
  else:
242
- st.error(f"Failed to save plot data to file: {target_filename}")
243
- else:
244
- st.error("Invalid 'objectsToSave' format received (expected list).")
245
  else:
246
- st.error("Invalid save payload structure received from client.")
247
- print("Received payload:", payload) # Log for debugging
248
 
249
  except json.JSONDecodeError:
250
- st.error("Failed to decode save data from client.")
251
  print("Received raw data:", save_data_from_js)
252
  except Exception as e:
253
  st.error(f"Error processing save: {e}")
254
  st.exception(e)
255
 
256
- # Clear the trigger data from session state
257
- st.session_state.js_save_processor = None
258
- # Rerun after processing to reflect changes
259
  if save_processed_successfully:
260
- st.rerun()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
261
 
262
 
263
  # --- Main Area ---
264
  st.header("Infinite Shared 3D World")
265
- st.caption("Move to empty areas to expand the world. Use sidebar 'Save' to save work to current plot.")
266
 
267
  # --- Load and Prepare HTML ---
268
  html_file_path = 'index.html'
@@ -273,22 +432,28 @@ try:
273
  html_template = f.read()
274
 
275
  # --- Inject Python state into JavaScript ---
 
276
  js_injection_script = f"""
277
  <script>
278
- window.ALL_INITIAL_OBJECTS = {json.dumps(all_initial_objects)};
279
- window.PLOTS_METADATA = {json.dumps(plots_metadata)}; // Send plot info to JS
 
280
  window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)};
281
  window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)};
282
  window.PLOT_DEPTH = {json.dumps(PLOT_DEPTH)};
283
- console.log("Streamlit State Injected:", {{
 
 
284
  selectedObject: window.SELECTED_OBJECT_TYPE,
285
- initialObjectsCount: window.ALL_INITIAL_OBJECTS ? window.ALL_INITIAL_OBJECTS.length : 0,
286
  plotCount: window.PLOTS_METADATA ? window.PLOTS_METADATA.length : 0,
287
  plotWidth: window.PLOT_WIDTH,
288
- plotDepth: window.PLOT_DEPTH
 
289
  }});
290
  </script>
291
  """
 
292
  html_content_with_state = html_template.replace('</head>', js_injection_script + '\n</head>', 1)
293
 
294
  # --- Embed HTML Component ---
 
5
  import json
6
  import pandas as pd
7
  import uuid
8
+ import math
9
+ from streamlit_js_eval import streamlit_js_eval, DUMPS_FUNC # For JS communication and better serialization
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:
 
42
  st.error(f"Error listing save directory '{SAVE_DIR}': {e}")
43
  return []
44
 
 
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});"
 
294
  streamlit_js_eval(js_code=js_code, key=f"teleport_{plot['id']}")
295
  except Exception as e:
296
+ st.error(f"Failed to send teleport command: {e}")
297
  col_idx = (col_idx + 1) % max_cols
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
  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
  """
456
+ # Find the closing </head> tag and insert the script before it
457
  html_content_with_state = html_template.replace('</head>', js_injection_script + '\n</head>', 1)
458
 
459
  # --- Embed HTML Component ---