Spaces:
Sleeping
Sleeping
File size: 15,317 Bytes
41c1a97 80d0af3 dfe769e 41c1a97 dfe769e 80d0af3 41c1a97 dfe769e 41c1a97 dfe769e 41c1a97 dfe769e 41c1a97 dfe769e 41c1a97 dfe769e 80d0af3 dfe769e 41c1a97 dfe769e 41c1a97 dfe769e 41c1a97 dfe769e 41c1a97 dfe769e 41c1a97 dfe769e 41c1a97 dfe769e 41c1a97 dfe769e 41c1a97 dfe769e 41c1a97 dfe769e 41c1a97 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 80d0af3 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 80d0af3 7825ef7 80d0af3 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 80d0af3 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e 7825ef7 dfe769e |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 |
# app.py
import streamlit as st
import streamlit.components.v1 as components
import os
import json
import pandas as pd
import uuid
from PIL import Image, ImageDraw # Not used for minimap anymore, but kept just in case
from streamlit_js_eval import streamlit_js_eval # For JS communication
# --- Constants ---
SAVE_DIR = "saved_worlds"
PLOT_WIDTH = 50.0 # Width of each plot in 3D space (adjust as needed)
CSV_COLUMNS = ['obj_id', 'type', 'pos_x', 'pos_y', 'pos_z', 'rot_x', 'rot_y', 'rot_z', 'rot_order']
# --- Ensure Save Directory Exists ---
os.makedirs(SAVE_DIR, exist_ok=True)
# --- Helper Functions ---
@st.cache_data(ttl=3600) # Cache plot list for an hour, or clear manually
def load_plot_metadata():
"""Scans save dir, sorts plots, calculates metadata."""
plots = []
# Ensure consistent sorting, e.g., alphabetically which often aligns with plot_001, plot_002 etc.
try:
plot_files = sorted([f for f in os.listdir(SAVE_DIR) if f.endswith(".csv")])
except FileNotFoundError:
st.error(f"Save directory '{SAVE_DIR}' not found.")
return [], 0.0 # Return empty if dir doesn't exist
except Exception as e:
st.error(f"Error listing save directory '{SAVE_DIR}': {e}")
return [], 0.0
current_x_offset = 0.0
for i, filename in enumerate(plot_files):
# Extract name - assumes format like 'plot_001_MyName.csv' or just 'plot_001.csv'
parts = filename[:-4].split('_') # Remove .csv and split by underscore
plot_id = filename[:-4] # Use filename (without ext) as a unique ID for now
plot_name = " ".join(parts[1:]) if len(parts) > 1 else f"Plot {i+1}" # Try to extract name
plots.append({
'id': plot_id, # Use filename as ID
'name': plot_name,
'filename': filename,
'x_offset': current_x_offset
})
current_x_offset += PLOT_WIDTH
# Also return the offset where the next plot would start
return plots, current_x_offset
def load_plot_objects(filename, x_offset):
"""Loads objects from a CSV, applying the plot's x_offset."""
file_path = os.path.join(SAVE_DIR, filename)
objects = []
try:
df = pd.read_csv(file_path)
# Check if required columns exist, handle gracefully if not
if not all(col in df.columns for col in ['type', 'pos_x', 'pos_y', 'pos_z']):
st.warning(f"CSV '{filename}' missing essential columns (type, pos_x/y/z). Skipping.")
return []
# Ensure optional columns default to something sensible if missing
# Use vectorized operations for defaults where possible
df['obj_id'] = df.get('obj_id', pd.Series([str(uuid.uuid4()) for _ in range(len(df))]))
for col, default in [('rot_x', 0.0), ('rot_y', 0.0), ('rot_z', 0.0), ('rot_order', 'XYZ')]:
if col not in df.columns: df[col] = default
for _, row in df.iterrows():
obj_data = row.to_dict()
# Apply world offset accumulated during loading
obj_data['pos_x'] += x_offset
objects.append(obj_data)
return objects
except FileNotFoundError:
# This shouldn't happen if called via load_plot_metadata results, but handle anyway
st.error(f"File not found during object load: {filename}")
return []
except pd.errors.EmptyDataError:
# An empty file is valid, represents an empty plot
return []
except Exception as e:
st.error(f"Error loading objects from {filename}: {e}")
return []
def save_plot_data(filename, objects_data_list, plot_x_offset):
"""Saves object data list to a new CSV file, making positions relative."""
file_path = os.path.join(SAVE_DIR, filename)
relative_objects = []
# Ensure objects_data_list is actually a list
if not isinstance(objects_data_list, list):
st.error("Invalid data format received for saving (expected a list).")
print("Invalid save data:", objects_data_list) # Log for debugging
return False
for obj in objects_data_list:
# Validate incoming object structure more carefully
pos = obj.get('position', {})
rot = obj.get('rotation', {})
obj_type = obj.get('type', 'Unknown')
obj_id = obj.get('obj_id', str(uuid.uuid4())) # Generate ID if missing from JS
if not all(k in pos for k in ['x', 'y', 'z']) or obj_type == 'Unknown':
print(f"Skipping malformed object during save prep: {obj}")
continue
relative_obj = {
'obj_id': obj_id,
'type': obj_type,
'pos_x': pos.get('x', 0.0) - plot_x_offset, # Make relative to plot start
'pos_y': pos.get('y', 0.0),
'pos_z': pos.get('z', 0.0),
'rot_x': rot.get('_x', 0.0),
'rot_y': rot.get('_y', 0.0),
'rot_z': rot.get('_z', 0.0),
'rot_order': rot.get('_order', 'XYZ')
}
relative_objects.append(relative_obj)
try:
# Only save if there are objects to save
if relative_objects:
df = pd.DataFrame(relative_objects, columns=CSV_COLUMNS)
df.to_csv(file_path, index=False)
st.success(f"Saved {len(relative_objects)} objects to {filename}")
else:
# Create an empty file with headers if nothing new was placed
pd.DataFrame(columns=CSV_COLUMNS).to_csv(file_path, index=False)
st.info(f"Saved empty plot file: {filename}")
return True
except Exception as e:
st.error(f"Failed to save plot data to {filename}: {e}")
return False
# --- Page Config ---
st.set_page_config(
page_title="Shared World Builder",
layout="wide"
)
# --- Initialize Session State ---
if 'selected_object' not in st.session_state:
st.session_state.selected_object = 'None'
if 'new_plot_name' not in st.session_state:
st.session_state.new_plot_name = ""
# Use a more descriptive key for clarity
if 'js_save_data_result' not in st.session_state:
st.session_state.js_save_data_result = None
# --- Load Plot Metadata ---
# Cached function returns list of plots and the next starting x_offset
plots_metadata, next_plot_x_offset = load_plot_metadata()
# --- Load ALL Objects for Rendering ---
# This could be slow with many plots!
all_initial_objects = []
for plot in plots_metadata:
all_initial_objects.extend(load_plot_objects(plot['filename'], plot['x_offset']))
# --- Sidebar ---
with st.sidebar:
st.title("🏗️ World Controls")
st.header("Navigation (Plots)")
st.caption("Click to teleport player to the start of a plot.")
# Use columns for a horizontal button layout if desired
max_cols = 3 # Adjust number of columns
cols = st.columns(max_cols)
col_idx = 0
for plot in plots_metadata:
# Use an emoji + name for the button
button_label = f"➡️ {plot.get('name', plot['id'])}" # Fallback to id if name missing
# Use plot filename (unique) as key
if cols[col_idx].button(button_label, key=f"nav_{plot['filename']}"):
# Send command to JS to move the player
target_x = plot['x_offset']
try:
streamlit_js_eval(js_code=f"teleportPlayer({target_x});", key=f"teleport_{plot['filename']}")
except Exception as e:
st.error(f"Failed to send teleport command: {e}")
# No rerun needed here, JS handles the move instantly
col_idx = (col_idx + 1) % max_cols # Cycle through columns
st.markdown("---")
# --- Object Placement ---
st.header("Place Objects")
object_types = ["None", "Simple House", "Tree", "Rock", "Fence Post"]
current_object_index = 0
try:
# Ensure robustness if selected_object is somehow not in list
current_object_index = object_types.index(st.session_state.selected_object)
except ValueError:
st.session_state.selected_object = "None" # Reset to default
current_object_index = 0
selected_object_type_widget = st.selectbox(
"Select Object:",
options=object_types,
index=current_object_index,
key="selected_object_widget" # Use a distinct key for the widget
)
# Update session state only if the widget's value actually changes
# This change WILL trigger a rerun because Streamlit tracks widget state.
# The JS side now handles preserving its state across the resulting reload via sessionStorage.
if selected_object_type_widget != st.session_state.selected_object:
st.session_state.selected_object = selected_object_type_widget
# We don't *need* to force a rerun here, Streamlit handles it.
# The important part is that the NEXT run will inject the new selected type,
# and the JS will restore placed objects from sessionStorage.
st.markdown("---")
# --- Saving ---
st.header("Save New Plot")
# Ensure text input reflects current state value
st.session_state.new_plot_name = st.text_input(
"Name for New Plot:",
value=st.session_state.new_plot_name,
placeholder="My Awesome Creation",
key="new_plot_name_input" # Use distinct key
)
if st.button("💾 Save Current Work as New Plot", key="save_button"):
# 1. Trigger JS function `getSaveData()` defined in index.html
# This function collects data for newly placed objects and returns a JSON string.
# The key argument ('js_save_processor') stores the JS result in session_state.
streamlit_js_eval(
js_code="getSaveData();",
key="js_save_processor" # Store result under this key
)
# Small delay MAY sometimes help ensure the value is set before rerun, but usually not needed
# import time
# time.sleep(0.1)
st.rerun() # Rerun to process the result in the next step
# --- Process Save Data (if received from JS via the key) ---
# Check the session state key set by the streamlit_js_eval call
save_data_from_js = st.session_state.get("js_save_processor", None)
if save_data_from_js is not None: # Process only if data is present
st.info("Received save data from client...")
save_processed_successfully = False
try:
# Ensure data is treated as a string before loading json
if isinstance(save_data_from_js, str):
objects_to_save = json.loads(save_data_from_js)
else:
# Handle case where it might already be parsed by chance (less likely)
objects_to_save = save_data_from_js
# Proceed only if we have a list (even an empty one is ok now)
if isinstance(objects_to_save, list):
# Determine filename for the new plot
new_plot_index = len(plots_metadata) # 0-based index -> number of plots
# Sanitize name: replace spaces, keep only alphanumeric/underscore
plot_name_sanitized = "".join(c for c in st.session_state.new_plot_name if c.isalnum() or c in (' ')).strip().replace(' ', '_')
if not plot_name_sanitized: # Ensure there is a name part
plot_name_sanitized = f"Plot_{new_plot_index + 1}"
new_filename = f"plot_{new_plot_index:03d}_{plot_name_sanitized}.csv"
# Save the data, converting world coords to relative coords inside the func
save_ok = save_plot_data(new_filename, objects_to_save, next_plot_x_offset)
if save_ok:
# Clear the plot metadata cache so it reloads with the new file
load_plot_metadata.clear()
# Reset the new plot name field for next time
st.session_state.new_plot_name = ""
# Reset newly placed objects in JS AFTER successful save
try:
# This call tells JS to clear its internal 'newlyPlacedObjects' array AND sessionStorage
streamlit_js_eval(js_code="resetNewlyPlacedObjects();", key="reset_js_state")
except Exception as js_e:
st.warning(f"Could not reset JS state after save: {js_e}")
st.success(f"New plot '{plot_name_sanitized}' saved!")
save_processed_successfully = True
else:
st.error("Failed to save plot data to file.")
else:
st.error(f"Received invalid save data format from client (expected list): {type(objects_to_save)}")
except json.JSONDecodeError:
st.error("Failed to decode save data from client. Data might be corrupted or empty.")
print("Received raw data:", save_data_from_js) # Log raw data
except Exception as e:
st.error(f"Error processing save: {e}")
st.exception(e)
# IMPORTANT: Clear the session state key regardless of success/failure
# to prevent reprocessing on the next rerun unless the button is clicked again.
st.session_state.js_save_processor = None
# Rerun AGAIN after processing save to reflect changes (new plot loaded, cache cleared etc.)
if save_processed_successfully:
st.rerun()
# --- Main Area ---
st.header("Shared 3D World")
st.caption("Build side-by-side with others. Saving adds a new plot to the right.")
# --- Load and Prepare HTML ---
html_file_path = 'index.html'
html_content_with_state = None # Initialize
try:
# --- Read the HTML template file ---
with open(html_file_path, 'r', encoding='utf-8') as f:
html_template = f.read()
# --- Prepare JavaScript code to inject state ---
# Ensure all state variables are correctly serialized as JSON
js_injection_script = f"""
<script>
// Set global variables BEFORE the main script runs
window.ALL_INITIAL_OBJECTS = {json.dumps(all_initial_objects)}; // All objects from all plots
window.SELECTED_OBJECT_TYPE = {json.dumps(st.session_state.selected_object)};
window.PLOT_WIDTH = {json.dumps(PLOT_WIDTH)};
window.NEXT_PLOT_X_OFFSET = {json.dumps(next_plot_x_offset)}; // Needed for save calculation & ground size
// Basic logging to verify state in browser console
console.log("Streamlit State Injected:", {{
selectedObject: window.SELECTED_OBJECT_TYPE,
initialObjectsCount: window.ALL_INITIAL_OBJECTS ? window.ALL_INITIAL_OBJECTS.length : 0,
plotWidth: window.PLOT_WIDTH,
nextPlotX: window.NEXT_PLOT_X_OFFSET
}});
</script>
"""
# --- Inject the script into the HTML template ---
# Replacing just before </head> is generally safe
html_content_with_state = html_template.replace('</head>', js_injection_script + '\n</head>', 1)
# --- Embed HTML Component (ONLY if HTML loading and preparation succeeded) ---
components.html(
html_content_with_state,
height=750, # Adjust height as needed
scrolling=False
)
# --- Error Handling ---
except FileNotFoundError:
st.error(f"CRITICAL ERROR: Could not find the file '{html_file_path}'.")
st.warning(f"Please make sure `{html_file_path}` is in the same directory as `app.py` and that the `{SAVE_DIR}` directory exists.")
except Exception as e:
st.error(f"An critical error occurred during HTML preparation or component rendering: {e}")
st.exception(e) # Show full traceback for debugging |