Spaces:
Sleeping
Sleeping
# 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 --- | |
# 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""" | |
<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 | |
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> | |
""" | |
html_content_with_state = html_template.replace('</head>', js_injection_script + '\n</head>', 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) |