# 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 # For minimap (using buttons instead now) from streamlit_js_eval import streamlit_js_eval, sync # 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 = [] plot_files = sorted([f for f in os.listdir(SAVE_DIR) if f.endswith(".csv")]) 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 = parts[0] # Assume first part is ID/order plot_name = " ".join(parts[1:]) if len(parts) > 1 else f"Plot {i+1}" plots.append({ 'id': plot_id, 'name': plot_name, 'filename': filename, 'x_offset': current_x_offset }) current_x_offset += PLOT_WIDTH return plots, current_x_offset # Return plots and the next available 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) if not all(col in df.columns for col in CSV_COLUMNS): st.warning(f"CSV '{filename}' missing expected columns. Skipping.") return [] for _, row in df.iterrows(): obj_data = row.to_dict() # Apply world offset obj_data['pos_x'] += x_offset objects.append(obj_data) return objects except FileNotFoundError: st.error(f"File not found during object load: {filename}") return [] except pd.errors.EmptyDataError: st.info(f"Plot file '{filename}' is empty.") return [] # Empty file is valid 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 = [] for obj in objects_data_list: # Ensure required fields exist before proceeding if not all(k in obj for k in ['position', 'rotation', 'type', 'obj_id']): print(f"Skipping malformed object during save: {obj}") continue relative_obj = { 'obj_id': obj.get('obj_id', str(uuid.uuid4())), # Generate ID if missing 'type': obj.get('type', 'Unknown'), 'pos_x': obj['position'].get('x', 0.0) - plot_x_offset, # Make relative 'pos_y': obj['position'].get('y', 0.0), 'pos_z': obj['position'].get('z', 0.0), 'rot_x': obj['rotation'].get('_x', 0.0), 'rot_y': obj['rotation'].get('_y', 0.0), 'rot_z': obj['rotation'].get('_z', 0.0), 'rot_order': obj['rotation'].get('_order', 'XYZ') } relative_objects.append(relative_obj) try: df = pd.DataFrame(relative_objects, columns=CSV_COLUMNS) df.to_csv(file_path, index=False) st.success(f"Saved plot data to {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 = "" if 'save_request_data' not in st.session_state: # To store data back from JS st.session_state.save_request_data = 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! Consider optimization later. 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 cols = st.columns(3) col_idx = 0 for plot in plots_metadata: # Use an emoji + name for the button button_label = f"➡️ {plot.get('name', plot['id'])}" if cols[col_idx].button(button_label, key=f"nav_{plot['id']}"): # Send command to JS to move the player target_x = plot['x_offset'] streamlit_js_eval(js_code=f"teleportPlayer({target_x});") # No rerun needed here, JS handles the move col_idx = (col_idx + 1) % 3 # 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: current_object_index = object_types.index(st.session_state.selected_object) except ValueError: st.session_state.selected_object = "None" # Reset if invalid selected_object_type_widget = st.selectbox( "Select Object:", options=object_types, index=current_object_index, key="selected_object_widget" ) if selected_object_type_widget != st.session_state.selected_object: st.session_state.selected_object = selected_object_type_widget # No rerun needed just for selection, JS will read state on next placement st.markdown("---") # --- Saving --- st.header("Save New Plot") st.session_state.new_plot_name = st.text_input( "Name for New Plot:", value=st.session_state.new_plot_name, placeholder="My Awesome Creation" ) if st.button("💾 Save Current Work as New Plot"): # 1. Trigger JS function to get data # This function `getSaveData` should be defined in index.html # It should collect data for objects considered "new" # and return them as a JSON string. # The positions should be WORLD positions. js_get_data_code = "getSaveData();" # Use streamlit_js_eval to run JS and get data back into session_state st.session_state.save_request_data = streamlit_js_eval( js_code=js_get_data_code, key="get_save_data" # Unique key for this eval call # Removed want_output=True, value goes to key if specified ) # Need to trigger a rerun to process the data AFTER it arrives # Using sync() might help ensure the value is available immediately after? Let's try without first. st.rerun() # Rerun to process the returned data below # --- Process Save Data (if received from JS via key) --- # This runs AFTER the rerun triggered by the save button save_data_from_js = st.session_state.get("save_request_data", None) if save_data_from_js: st.info("Received save data from client...") try: objects_to_save = json.loads(save_data_from_js) # Parse JSON string from JS if isinstance(objects_to_save, list) and len(objects_to_save) > 0: # Determine filename for the new plot new_plot_index = len(plots_metadata) + 1 plot_name_sanitized = "".join(c for c in st.session_state.new_plot_name if c.isalnum() or c in (' ', '_')).rstrip() or f"Plot_{new_plot_index}" new_filename = f"plot_{new_plot_index:03d}_{plot_name_sanitized.replace(' ','_')}.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: # IMPORTANT: Clear the plot metadata cache so it reloads with the new file load_plot_metadata.clear() # Clear the JS data request state st.session_state.save_request_data = None # Reset the new plot name field st.session_state.new_plot_name = "" # Reset newly placed objects in JS? Needs another call. try: 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!") # Rerun AGAIN to reload plots list, redraw minimap, and update JS injection st.rerun() else: st.error("Failed to save plot data to file.") # Clear the request data even if save failed to prevent retry loop st.session_state.save_request_data = None elif isinstance(objects_to_save, list) and len(objects_to_save) == 0: st.warning("Nothing new to save.") st.session_state.save_request_data = None # Clear request else: st.error(f"Received invalid save data format from client: {type(objects_to_save)}") st.session_state.save_request_data = None # Clear request 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 for debugging st.session_state.save_request_data = None # Clear request except Exception as e: st.error(f"Error processing save: {e}") st.exception(e) st.session_state.save_request_data = None # Clear request # --- Main Area --- st.header("Shared 3D World") st.caption("Build side-by-side with others. Changes load on refresh/save.") # --- Load and Prepare HTML --- html_file_path = 'index.html' html_content_with_state = None # Initialize try: with open(html_file_path, 'r', encoding='utf-8') as f: html_template = f.read() # --- Inject Python state into JavaScript --- js_injection_script = f""" """ html_content_with_state = html_template.replace('', js_injection_script + '\n', 1) # --- Embed HTML Component --- components.html( html_content_with_state, height=750, scrolling=False ) except FileNotFoundError: st.error(f"CRITICAL ERROR: Could not find the file '{html_file_path}'.") st.warning(f"Make sure `{html_file_path}` is in the same directory as `app.py` and `{SAVE_DIR}` exists.") except Exception as e: st.error(f"An critical error occurred during HTML preparation or component rendering: {e}") st.exception(e)