awacke1 commited on
Commit
26cbb1c
·
verified ·
1 Parent(s): 9447266

Update index.html

Browse files
Files changed (1) hide show
  1. index.html +196 -311
index.html CHANGED
@@ -1,7 +1,7 @@
1
  <!DOCTYPE html>
2
  <html>
3
  <head>
4
- <title>Three.js Shared World</title>
5
  <style>
6
  body { margin: 0; overflow: hidden; }
7
  canvas { display: block; }
@@ -19,7 +19,6 @@
19
 
20
  <script type="module">
21
  import * as THREE from 'three';
22
- // No OrbitControls needed if using custom player movement
23
 
24
  let scene, camera, renderer, playerMesh;
25
  let raycaster, mouse;
@@ -28,18 +27,18 @@
28
 
29
  // --- State Management ---
30
  let newlyPlacedObjects = []; // Track objects added THIS client, THIS session, NOT YET SAVED
31
- const serverObjectsMap = new Map(); // Map<obj_id, THREE.Object3D> - Objects loaded from server
32
- let pollIntervalId = null; // To store the interval timer
33
 
 
 
34
 
35
- // --- Access State from Streamlit (Injected) ---
36
- const initialWorldState = window.INITIAL_WORLD_STATE || []; // List of authoritative objects on load
37
  const plotsMetadata = window.PLOTS_METADATA || []; // List of saved plot info
38
- let selectedObjectType = window.SELECTED_OBJECT_TYPE || "None"; // Can be updated by Python
39
  const plotWidth = window.PLOT_WIDTH || 50.0;
40
  const plotDepth = window.PLOT_DEPTH || 50.0;
41
- const pollIntervalMs = window.STATE_POLL_INTERVAL_MS || 5000;
42
-
43
 
44
  const groundMaterial = new THREE.MeshStandardMaterial({
45
  color: 0x55aa55, roughness: 0.9, metalness: 0.1, side: THREE.DoubleSide, name: "ground_saved"
@@ -47,7 +46,6 @@
47
  const placeholderGroundMaterial = new THREE.MeshStandardMaterial({
48
  color: 0x448844, roughness: 0.95, metalness: 0.1, side: THREE.DoubleSide, name: "ground_placeholder"
49
  });
50
- const groundMeshes = {}; // Store references to ground meshes: 'x_z' string key -> mesh
51
 
52
 
53
  function init() {
@@ -56,12 +54,12 @@
56
 
57
  const aspect = window.innerWidth / window.innerHeight;
58
  camera = new THREE.PerspectiveCamera(60, aspect, 0.1, 4000);
59
- camera.position.set(plotWidth / 2, 15, plotDepth / 2 + 20); // Start looking at center of 0,0
60
  camera.lookAt(plotWidth/2, 0, plotDepth/2);
61
  scene.add(camera);
62
 
63
  setupLighting();
64
- setupInitialGround(); // Setup ground based on PLOTS_METADATA
65
  setupPlayer();
66
 
67
  raycaster = new THREE.Raycaster();
@@ -70,14 +68,21 @@
70
  renderer = new THREE.WebGLRenderer({ antialias: true });
71
  renderer.setSize(window.innerWidth, window.innerHeight);
72
  renderer.shadowMap.enabled = true;
73
- renderer.shadowMap.type = THREE.PCFSoftShadowMap; // Softer shadows
74
  document.body.appendChild(renderer.domElement);
75
 
76
- // --- Load Initial Objects provided by Server ---
77
- console.log(`Loading ${initialWorldState.length} initial objects from Python.`);
78
- synchronizeWorldState(initialWorldState); // Use the sync function for initial load too
 
 
 
 
 
 
79
 
80
- // restoreUnsavedState(); // Maybe not needed if polling handles everything? Or keep for page reloads? Let's disable for now.
 
81
 
82
  // Event Listeners
83
  document.addEventListener('mousemove', onMouseMove, false);
@@ -88,34 +93,30 @@
88
 
89
  // --- Define global functions needed by Python ---
90
  window.teleportPlayer = teleportPlayer;
91
- window.getNewlyPlacedObjectsForSave = getNewlyPlacedObjectsForSave; // Function for save button
92
  window.resetNewlyPlacedObjects = resetNewlyPlacedObjects; // Called by Python after successful save
93
- window.updateSelectedObjectType = updateSelectedObjectType; // Called by Python when selectbox changes
94
-
95
- // --- Start Polling for State Updates ---
96
- startPolling();
97
 
98
- console.log("Three.js Initialized. World ready. Starting state polling.");
99
  animate();
100
  }
101
 
102
  function setupLighting() {
103
- const ambientLight = new THREE.AmbientLight(0xffffff, 0.6); // Slightly brighter ambient
104
  scene.add(ambientLight);
105
- const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2); // Stronger directional
106
- directionalLight.position.set(75, 150, 100); // Angled light
107
  directionalLight.castShadow = true;
108
- directionalLight.shadow.mapSize.width = 2048; // Balance performance and quality
109
  directionalLight.shadow.mapSize.height = 2048;
110
- directionalLight.shadow.camera.near = 10; // Adjust frustum based on typical scene scale
111
  directionalLight.shadow.camera.far = 400;
112
- directionalLight.shadow.camera.left = -150; // Wider shadow area
113
  directionalLight.shadow.camera.right = 150;
114
  directionalLight.shadow.camera.top = 150;
115
  directionalLight.shadow.camera.bottom = -150;
116
- directionalLight.shadow.bias = -0.002; // Adjust shadow bias carefully
117
  scene.add(directionalLight);
118
- // Add a hemisphere light for softer fill
119
  const hemiLight = new THREE.HemisphereLight( 0xabcdef, 0x55aa55, 0.5 );
120
  scene.add( hemiLight );
121
  }
@@ -123,33 +124,32 @@
123
  function setupInitialGround() {
124
  console.log(`Setting up initial ground for ${plotsMetadata.length} saved plots.`);
125
  plotsMetadata.forEach(plot => {
126
- createGroundPlane(plot.grid_x, plot.grid_z, false); // false = not a placeholder
127
  });
128
- // Ensure ground at 0,0 exists if no plots are saved yet
129
  if (!groundMeshes['0_0']) {
130
- createGroundPlane(0, 0, false); // Create the starting ground if needed
131
  }
 
 
132
  }
133
 
134
  function createGroundPlane(gridX, gridZ, isPlaceholder) {
135
  const gridKey = `${gridX}_${gridZ}`;
136
- if (groundMeshes[gridKey]) return groundMeshes[gridKey]; // Don't recreate
137
 
138
- // console.log(`Creating ${isPlaceholder ? 'placeholder' : 'initial'} ground at ${gridX}, ${gridZ}`);
139
  const groundGeometry = new THREE.PlaneGeometry(plotWidth, plotDepth);
140
  const material = isPlaceholder ? placeholderGroundMaterial : groundMaterial;
141
  const groundMesh = new THREE.Mesh(groundGeometry, material);
142
 
143
  groundMesh.rotation.x = -Math.PI / 2;
144
- groundMesh.position.y = -0.05; // Slightly below 0
145
  groundMesh.position.x = gridX * plotWidth + plotWidth / 2.0;
146
  groundMesh.position.z = gridZ * plotDepth + plotDepth / 2.0;
147
-
148
  groundMesh.receiveShadow = true;
149
  groundMesh.userData.gridKey = gridKey;
150
  groundMesh.userData.isPlaceholder = isPlaceholder;
151
  scene.add(groundMesh);
152
- groundMeshes[gridKey] = groundMesh; // Store reference
153
  return groundMesh;
154
  }
155
 
@@ -157,207 +157,141 @@
157
  const playerGeo = new THREE.CapsuleGeometry(0.4, 0.8, 4, 8);
158
  const playerMat = new THREE.MeshStandardMaterial({ color: 0x0055ff, roughness: 0.6 });
159
  playerMesh = new THREE.Mesh(playerGeo, playerMat);
160
- playerMesh.position.set(plotWidth / 2, 0.8, plotDepth / 2); // Start Y pos based on capsule height/2 + radius maybe?
161
  playerMesh.castShadow = true;
162
- playerMesh.receiveShadow = false; // Player shouldn't receive shadows on itself much
163
  scene.add(playerMesh);
164
  }
165
 
166
- // --- Core State Synchronization Logic ---
167
- function synchronizeWorldState(serverStateList) {
168
- console.log(`Synchronizing state. Received ${serverStateList.length} objects from server.`);
169
- const receivedIds = new Set();
170
- let updatedCount = 0;
171
- let addedCount = 0;
172
- let removedCount = 0;
173
-
174
- // 1. Add new objects and Update existing ones
175
- serverStateList.forEach(objData => {
176
- if (!objData || !objData.obj_id) {
177
- console.warn("Received invalid object data during sync:", objData);
178
- return;
179
- }
180
- receivedIds.add(objData.obj_id);
181
- const existingMesh = serverObjectsMap.get(objData.obj_id);
182
-
183
- if (existingMesh) {
184
- // Object exists, check if update needed (simple position/rotation check)
185
- let needsUpdate = false;
186
- if (existingMesh.position.distanceToSquared(objData.position) > 0.01) { // Use squared distance, epsilon check
187
- existingMesh.position.set(objData.position.x, objData.position.y, objData.position.z);
188
- needsUpdate = true;
189
- }
190
- // TODO: Add more robust rotation check if needed (e.g., quaternion comparison)
191
- if (objData.rotation &&
192
- (Math.abs(existingMesh.rotation.x - objData.rotation._x) > 0.01 ||
193
- Math.abs(existingMesh.rotation.y - objData.rotation._y) > 0.01 ||
194
- Math.abs(existingMesh.rotation.z - objData.rotation._z) > 0.01 )) {
195
- existingMesh.rotation.set(objData.rotation._x, objData.rotation._y, objData.rotation._z, objData.rotation._order || 'XYZ');
196
- needsUpdate = true;
197
- }
198
- if(needsUpdate) updatedCount++;
199
-
200
- } else {
201
- // Object is new, create and add it
202
- const newMesh = createAndPlaceObject(objData, false); // false = not a "newly placed" local object
203
- if (newMesh) {
204
- serverObjectsMap.set(objData.obj_id, newMesh); // Add to our server map
205
- addedCount++;
206
- } else {
207
- console.warn("Failed to create object mesh for:", objData);
208
- }
209
- }
210
- });
211
-
212
- // 2. Remove objects that are in our scene but NOT in the server list
213
- const idsToRemove = [];
214
- for (const [obj_id, mesh] of serverObjectsMap.entries()) {
215
- if (!receivedIds.has(obj_id)) {
216
- // This object was removed on the server
217
- scene.remove(mesh);
218
- // Dispose geometry/material? Important for larger scenes
219
- if (mesh.geometry) mesh.geometry.dispose();
220
- if (mesh.material) {
221
- if (Array.isArray(mesh.material)) {
222
- mesh.material.forEach(m => m.dispose());
223
- } else {
224
- mesh.material.dispose();
225
- }
226
- }
227
- idsToRemove.push(obj_id);
228
- removedCount++;
229
- }
230
  }
231
-
232
- // Remove from the map after iteration
233
- idsToRemove.forEach(id => serverObjectsMap.delete(id));
234
-
235
- if(addedCount > 0 || removedCount > 0 || updatedCount > 0) {
236
- console.log(`Sync complete: ${addedCount} added, ${updatedCount} updated, ${removedCount} removed.`);
237
- }
238
- // else { console.log("Sync complete: No changes detected."); }
239
-
240
- // 3. Update ground planes based on latest plot metadata (if it changes - less frequent)
241
- // This could be done less often than object sync if metadata is stable.
242
- updateGroundFromMetadata();
243
- }
244
-
245
- function updateGroundFromMetadata() {
246
- const currentMetadata = window.PLOTS_METADATA || []; // Get potentially updated metadata
247
- const metadataKeys = new Set();
248
-
249
- // Ensure ground exists for all saved plots and is not a placeholder
250
- currentMetadata.forEach(plot => {
251
- const gridKey = `${plot.grid_x}_${plot.grid_z}`;
252
- metadataKeys.add(gridKey);
253
- const existingGround = groundMeshes[gridKey];
254
- if (existingGround) {
255
- // If it exists but was a placeholder, upgrade it
256
- if (existingGround.userData.isPlaceholder) {
257
- console.log(`Upgrading placeholder ground at ${gridKey} to saved.`);
258
- existingGround.material = groundMaterial;
259
- existingGround.userData.isPlaceholder = false;
260
- }
261
- } else {
262
- // If it doesn't exist, create it as a saved plot
263
- console.log(`Creating missing saved ground at ${gridKey}.`);
264
- createGroundPlane(plot.grid_x, plot.grid_z, false);
265
- }
266
- });
267
-
268
- // Optional: Remove placeholder ground if a plot was deleted externally?
269
- // Or just let them persist visually until player moves away.
270
- }
271
 
272
 
273
- function createAndPlaceObject(objData, isNewlyPlaced) {
274
- let loadedObject = null;
275
  const objType = objData.type;
276
 
277
- // Use a factory pattern for cleaner object creation
278
  switch (objType) {
279
- case "Simple House": loadedObject = createSimpleHouse(); break;
280
- case "Tree": loadedObject = createTree(); break;
281
- case "Rock": loadedObject = createRock(); break;
282
- case "Fence Post": loadedObject = createFencePost(); break;
283
  default: console.warn("Unknown object type:", objType); return null;
284
  }
285
 
286
- if (loadedObject) {
287
- // Set common properties
288
- loadedObject.userData.obj_id = objData.obj_id || THREE.MathUtils.generateUUID(); // Ensure ID exists
289
- loadedObject.userData.type = objType;
290
- loadedObject.userData.isNewlyPlaced = isNewlyPlaced; // Flag if local & unsaved
291
-
292
- // Set position (handle both incoming formats)
293
- if (objData.position && objData.position.x !== undefined) {
294
- loadedObject.position.set(objData.position.x, objData.position.y, objData.position.z);
295
- } else if (objData.pos_x !== undefined) { // Fallback for older format if needed
296
- loadedObject.position.set(objData.pos_x, objData.pos_y, objData.pos_z);
297
  }
298
 
299
- // Set rotation (handle both incoming formats)
300
- if (objData.rotation && objData.rotation._x !== undefined) {
301
- loadedObject.rotation.set(objData.rotation._x, objData.rotation._y, objData.rotation._z, objData.rotation._order || 'XYZ');
302
- } else if (objData.rot_x !== undefined) { // Fallback
303
- loadedObject.rotation.set(objData.rot_x, objData.rot_y, objData.rot_z, objData.rot_order || 'XYZ');
304
- }
305
-
306
- // Add to scene
307
- scene.add(loadedObject);
308
-
309
- // Track if it's a new local object needing save
310
- if (isNewlyPlaced) {
311
- newlyPlacedObjects.push(loadedObject);
312
- console.log(`Tracked new local object: ${objType} (${loadedObject.userData.obj_id}). Total unsaved: ${newlyPlacedObjects.length}`);
313
- // No session storage saving needed now, rely on polling/save button
314
  }
315
- return loadedObject;
316
  }
317
  return null;
318
  }
319
 
 
320
  // --- Object Creation Functions (Factories) ---
321
- // Add castShadow = true to all relevant parts
322
- function createObjectBase(type) {
323
- return { userData: { type: type } }; // obj_id added later
324
- }
325
- function createSimpleHouse() {
326
- const base = createObjectBase("Simple House"); const group = new THREE.Group(); Object.assign(group, base);
327
- const mat1=new THREE.MeshStandardMaterial({color:0xffccaa,roughness:0.8}), mat2=new THREE.MeshStandardMaterial({color:0xaa5533,roughness:0.7});
328
- const m1=new THREE.Mesh(new THREE.BoxGeometry(2,1.5,2.5),mat1); m1.position.y=1.5/2; m1.castShadow=true; m1.receiveShadow=true; group.add(m1);
329
- const m2=new THREE.Mesh(new THREE.ConeGeometry(1.8,1,4),mat2); m2.position.y=1.5+1/2; m2.rotation.y=Math.PI/4; m2.castShadow=true; m2.receiveShadow=false; group.add(m2); return group;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
330
  }
331
- function createTree() {
332
- const base=createObjectBase("Tree"); const group=new THREE.Group(); Object.assign(group,base);
333
- const mat1=new THREE.MeshStandardMaterial({color:0x8B4513,roughness:0.9}), mat2=new THREE.MeshStandardMaterial({color:0x228B22,roughness:0.8});
334
- const m1=new THREE.Mesh(new THREE.CylinderGeometry(0.3,0.4,2,8),mat1); m1.position.y=1; m1.castShadow=true; m1.receiveShadow=true; group.add(m1);
335
- const m2=new THREE.Mesh(new THREE.IcosahedronGeometry(1.2,0),mat2); m2.position.y=2.8; m2.castShadow=true; m2.receiveShadow=false; group.add(m2); return group;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
336
  }
337
- function createRock() {
338
- const base=createObjectBase("Rock"); const mat=new THREE.MeshStandardMaterial({color:0xaaaaaa,roughness:0.8,metalness:0.1});
339
- const geo = new THREE.IcosahedronGeometry(0.7, 1); // Slightly more detail
340
- geo.positionData = geo.attributes.position.array; // Deform geometry slightly
341
- for (let i = 0; i < geo.positionData.length; i += 3) {
342
- const noise = Math.random() * 0.15 - 0.075; // Small random displacement
343
- geo.positionData[i] *= (1 + noise);
344
- geo.positionData[i+1] *= (1 + noise);
345
- geo.positionData[i+2] *= (1 + noise);
 
 
346
  }
347
- geo.computeVertexNormals(); // Recalculate normals after deformation
348
- const rock=new THREE.Mesh(geo,mat); Object.assign(rock,base);
349
- rock.position.y=0.35; rock.rotation.set(Math.random()*Math.PI, Math.random()*Math.PI, Math.random()*Math.PI); rock.castShadow=true; rock.receiveShadow=true; return rock;
350
- }
351
- function createFencePost() {
352
- const base=createObjectBase("Fence Post"); const mat=new THREE.MeshStandardMaterial({color:0xdeb887,roughness:0.9});
353
- const post=new THREE.Mesh(new THREE.BoxGeometry(0.2,1.5,0.2),mat); Object.assign(post,base);
354
- post.position.y=0.75; post.castShadow=true; post.receiveShadow=true; return post;
355
  }
356
 
357
 
358
  // --- Event Handlers ---
359
  function onMouseMove(event) {
360
- // Calculate mouse position in normalized device coordinates (-1 to +1)
361
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
362
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
363
  }
@@ -365,36 +299,36 @@
365
  function onDocumentClick(event) {
366
  if (selectedObjectType === "None") return;
367
 
368
- // Find all ground meshes (saved and placeholder)
369
  const groundCandidates = Object.values(groundMeshes);
370
- if (groundCandidates.length === 0) return; // No ground to place on
371
 
372
  raycaster.setFromCamera(mouse, camera);
373
- const intersects = raycaster.intersectObjects(groundCandidates); // Intersect with all ground planes
374
 
375
  if (intersects.length > 0) {
376
- const intersectPoint = intersects[0].point; // Use the closest intersection point
377
- const clickedGroundMesh = intersects[0].object; // The specific ground mesh clicked
378
 
379
- // Create the object data structure first
380
  const newObjData = {
381
- obj_id: THREE.MathUtils.generateUUID(), // Generate ID immediately
382
  type: selectedObjectType,
383
- position: { x: intersectPoint.x, y: intersectPoint.y, z: intersectPoint.z }, // Y will be adjusted
384
- rotation: { _x: 0, _y: Math.random() * Math.PI * 2, _z: 0, _order: 'XYZ' } // Random Y rotation
385
  };
386
 
387
- // Adjust Y position based on object type after creation, before adding to scene
388
- // (Example: ensures base is near y=0)
389
- // This logic might need refinement based on your object origins
390
- if(selectedObjectType === "Simple House") newObjData.position.y = 0;
391
- else if(selectedObjectType === "Tree") newObjData.position.y = 0;
392
- else if(selectedObjectType === "Rock") newObjData.position.y = 0; // Rock origin is center, adjust later maybe?
393
- else if(selectedObjectType === "Fence Post") newObjData.position.y = 0;
394
- // else default y = intersectPoint.y (which is ~0 on flat ground)
395
-
396
- // Now create the mesh and add it to the scene, marking as newly placed
397
- createAndPlaceObject(newObjData, true); // true = is newly placed
 
 
398
  }
399
  }
400
 
@@ -412,91 +346,53 @@
412
  if (playerMesh) {
413
  playerMesh.position.x = targetX;
414
  playerMesh.position.z = targetZ;
415
- // Force camera update immediately after teleport
416
- const offset = new THREE.Vector3(0, 15, 20); // Keep consistent offset
417
- const targetPosition = playerMesh.position.clone().add(offset);
418
- camera.position.copy(targetPosition);
419
- camera.lookAt(playerMesh.position);
420
  console.log("Player teleported to:", playerMesh.position);
421
  } else { console.error("Player mesh not found for teleport."); }
422
  }
423
 
424
  // Called by Python's save button
425
- function getNewlyPlacedObjectsForSave() {
426
- console.log(`JS getNewlyPlacedObjectsForSave called. Found ${newlyPlacedObjects.length} objects.`);
427
- // IMPORTANT: Send a *copy* of the data, not the live Three.js objects.
428
- // Use WORLD coordinates as they are in the scene.
429
- const dataToSend = newlyPlacedObjects.map(obj => {
430
  if (!obj.userData || !obj.userData.type || !obj.userData.obj_id) return null;
431
  return {
432
- obj_id: obj.userData.obj_id,
433
- type: obj.userData.type,
434
  position: { x: obj.position.x, y: obj.position.y, z: obj.position.z },
435
  rotation: { _x: obj.rotation.x, _y: obj.rotation.y, _z: obj.rotation.z, _order: obj.rotation.order }
436
  };
437
- }).filter(obj => obj !== null); // Filter out any potential nulls
 
 
438
 
439
- console.log("Prepared data for saving:", dataToSend);
440
- // Return as JSON string for streamlit_js_eval
441
- return JSON.stringify(dataToSend);
 
 
 
442
  }
443
 
444
- // Called by Python AFTER successful save and cache clear
445
  function resetNewlyPlacedObjects() {
446
  console.log(`JS resetNewlyPlacedObjects called.`);
447
- // Objects just saved are now part of the authoritative state.
448
- // They should NOT be tracked as 'newly placed' anymore.
449
- // They will be handled by the regular sync process.
450
- // We don't remove them from the scene here, sync will keep them.
451
- newlyPlacedObjects.forEach(obj => {
452
- if(obj.userData) obj.userData.isNewlyPlaced = false; // Mark them as not-new locally
453
- });
454
- newlyPlacedObjects = []; // Clear the local tracking array
455
- // No need to clear session storage as we aren't using it now.
456
- console.log("Local 'newly placed' object list cleared.");
457
  }
458
 
459
  // Called by Python when the selectbox changes
460
  function updateSelectedObjectType(newType) {
461
  console.log("JS updateSelectedObjectType received:", newType);
462
  selectedObjectType = newType;
463
- // Optionally provide visual feedback (e.g., change cursor)
464
  }
465
 
466
 
467
- // --- Polling Logic ---
468
- function startPolling() {
469
- if (pollIntervalId) {
470
- clearInterval(pollIntervalId); // Clear existing interval if any
471
- }
472
- console.log(`Starting state polling every ${pollIntervalMs}ms`);
473
-
474
- // Initial immediate poll request? Or wait for first interval? Wait seems fine.
475
- pollIntervalId = setInterval(async () => {
476
- // Check if the request function exists (it's set up by Python)
477
- if (typeof window.requestStateUpdate === 'function') {
478
- try {
479
- console.log("Polling server for state update...");
480
- const latestState = await window.requestStateUpdate(); // Calls the Python function
481
-
482
- if (latestState && Array.isArray(latestState)) {
483
- // We got the state, synchronize the scene
484
- synchronizeWorldState(latestState);
485
- } else {
486
- console.warn("Polling received invalid state:", latestState);
487
- }
488
- } catch (error) {
489
- console.error("Error during state polling:", error);
490
- // Optional: Implement backoff or stop polling after too many errors
491
- }
492
- } else {
493
- console.warn("window.requestStateUpdate function not available for polling yet.");
494
- // It might take a moment for streamlit_js_eval to set up the function
495
- }
496
- }, pollIntervalMs);
497
- }
498
-
499
-
500
  // --- Animation Loop ---
501
  function updatePlayerMovement() {
502
  if (!playerMesh) return;
@@ -507,17 +403,15 @@
507
  if (keysPressed['KeyD'] || keysPressed['ArrowRight']) moveDirection.x += 1;
508
 
509
  if (moveDirection.lengthSq() > 0) {
510
- // Camera-relative movement
511
  const forward = new THREE.Vector3(); camera.getWorldDirection(forward); forward.y = 0; forward.normalize();
512
  const right = new THREE.Vector3().crossVectors(camera.up, forward).normalize();
513
  const worldMove = new THREE.Vector3();
514
- worldMove.add(forward.multiplyScalar(-moveDirection.z)); // W/S maps to camera forward/backward
515
- worldMove.add(right.multiplyScalar(-moveDirection.x)); // A/D maps to camera left/right
516
  worldMove.normalize().multiplyScalar(playerSpeed);
517
 
518
  playerMesh.position.add(worldMove);
519
- // Basic ground clamping (replace with better physics later if needed)
520
- playerMesh.position.y = Math.max(playerMesh.position.y, 0.8); // Adjust based on player geometry origin
521
 
522
  // Check if we need to create *placeholder* ground nearby
523
  checkAndExpandGroundVisuals();
@@ -533,37 +427,28 @@
533
 
534
  for (let dx = -viewDistanceGrids; dx <= viewDistanceGrids; dx++) {
535
  for (let dz = -viewDistanceGrids; dz <= viewDistanceGrids; dz++) {
536
- // if (dx === 0 && dz === 0) continue; // Check current cell too
537
-
538
  const checkX = currentGridX + dx;
539
  const checkZ = currentGridZ + dz;
540
  const gridKey = `${checkX}_${checkZ}`;
541
 
542
- // If ground doesn't exist AT ALL for this grid key, create a placeholder
543
  if (!groundMeshes[gridKey]) {
544
- // Check if it's actually a *saved* plot according to metadata
545
  const isSaved = plotsMetadata.some(p => p.grid_x === checkX && p.grid_z === checkZ);
546
  if (!isSaved) {
547
- // Only create placeholder if it's NOT a known saved plot
548
- // console.log(`Creating placeholder ground at ${gridKey}`); // Can be noisy
549
- createGroundPlane(checkX, checkZ, true); // true = is placeholder
550
  }
551
- // If it IS saved but missing, the synchronizeWorldState/updateGround should handle it
552
  }
553
  }
554
  }
555
- // Optional: Add logic here to *remove* distant placeholder ground meshes for performance
556
  }
557
 
558
 
559
  function updateCamera() {
560
  if (!playerMesh) return;
561
- // Smooth follow camera
562
- const offset = new THREE.Vector3(0, 12, 18); // Adjust offset for desired view
563
  const targetPosition = playerMesh.position.clone().add(offset);
564
- // Use lerp for smooth camera movement
565
- camera.position.lerp(targetPosition, 0.08); // Adjust lerp factor for smoothness (lower is smoother)
566
- // Always look at the player's approximate head position
567
  const lookAtTarget = playerMesh.position.clone().add(new THREE.Vector3(0, 0.5, 0));
568
  camera.lookAt(lookAtTarget);
569
  }
 
1
  <!DOCTYPE html>
2
  <html>
3
  <head>
4
+ <title>Three.js Shared World (v2)</title>
5
  <style>
6
  body { margin: 0; overflow: hidden; }
7
  canvas { display: block; }
 
19
 
20
  <script type="module">
21
  import * as THREE from 'three';
 
22
 
23
  let scene, camera, renderer, playerMesh;
24
  let raycaster, mouse;
 
27
 
28
  // --- State Management ---
29
  let newlyPlacedObjects = []; // Track objects added THIS client, THIS session, NOT YET SAVED
30
+ const allLoadedObjects = new Map(); // Map<obj_id, THREE.Object3D> - ALL objects in the scene (initial + new)
31
+ const groundMeshes = {}; // Store references to ground meshes: 'x_z' string key -> mesh
32
 
33
+ // --- Session Storage Key for unsaved local changes ---
34
+ const SESSION_STORAGE_KEY = 'unsavedInfiniteWorldState_v2';
35
 
36
+ // --- Access State from Streamlit (Injected on page load/rerun) ---
37
+ const allInitialObjects = window.ALL_INITIAL_OBJECTS || []; // Authoritative state from server on load
38
  const plotsMetadata = window.PLOTS_METADATA || []; // List of saved plot info
39
+ let selectedObjectType = window.SELECTED_OBJECT_TYPE || "None"; // User's current tool
40
  const plotWidth = window.PLOT_WIDTH || 50.0;
41
  const plotDepth = window.PLOT_DEPTH || 50.0;
 
 
42
 
43
  const groundMaterial = new THREE.MeshStandardMaterial({
44
  color: 0x55aa55, roughness: 0.9, metalness: 0.1, side: THREE.DoubleSide, name: "ground_saved"
 
46
  const placeholderGroundMaterial = new THREE.MeshStandardMaterial({
47
  color: 0x448844, roughness: 0.95, metalness: 0.1, side: THREE.DoubleSide, name: "ground_placeholder"
48
  });
 
49
 
50
 
51
  function init() {
 
54
 
55
  const aspect = window.innerWidth / window.innerHeight;
56
  camera = new THREE.PerspectiveCamera(60, aspect, 0.1, 4000);
57
+ camera.position.set(plotWidth / 2, 15, plotDepth / 2 + 20);
58
  camera.lookAt(plotWidth/2, 0, plotDepth/2);
59
  scene.add(camera);
60
 
61
  setupLighting();
62
+ setupInitialGround(); // Based on plotsMetadata
63
  setupPlayer();
64
 
65
  raycaster = new THREE.Raycaster();
 
68
  renderer = new THREE.WebGLRenderer({ antialias: true });
69
  renderer.setSize(window.innerWidth, window.innerHeight);
70
  renderer.shadowMap.enabled = true;
71
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
72
  document.body.appendChild(renderer.domElement);
73
 
74
+ // --- Load Initial Objects from Server Injection ---
75
+ console.log(`Loading ${allInitialObjects.length} initial objects from Python.`);
76
+ allInitialObjects.forEach(objData => {
77
+ const mesh = createAndPlaceObject(objData, false); // false = not newly placed by this client session
78
+ if(mesh) {
79
+ allLoadedObjects.set(mesh.userData.obj_id, mesh); // Track all objects
80
+ }
81
+ });
82
+ console.log("Finished loading initial objects.");
83
 
84
+ // --- Restore locally unsaved objects from sessionStorage ---
85
+ restoreUnsavedState(); // Add objects that were placed locally but not saved before a refresh
86
 
87
  // Event Listeners
88
  document.addEventListener('mousemove', onMouseMove, false);
 
93
 
94
  // --- Define global functions needed by Python ---
95
  window.teleportPlayer = teleportPlayer;
96
+ window.getSaveDataAndPosition = getSaveDataAndPosition; // For save button
97
  window.resetNewlyPlacedObjects = resetNewlyPlacedObjects; // Called by Python after successful save
98
+ window.updateSelectedObjectType = updateSelectedObjectType; // Called by Python selectbox change
 
 
 
99
 
100
+ console.log("Three.js Initialized. World ready.");
101
  animate();
102
  }
103
 
104
  function setupLighting() {
105
+ const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
106
  scene.add(ambientLight);
107
+ const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2);
108
+ directionalLight.position.set(75, 150, 100);
109
  directionalLight.castShadow = true;
110
+ directionalLight.shadow.mapSize.width = 2048;
111
  directionalLight.shadow.mapSize.height = 2048;
112
+ directionalLight.shadow.camera.near = 10;
113
  directionalLight.shadow.camera.far = 400;
114
+ directionalLight.shadow.camera.left = -150;
115
  directionalLight.shadow.camera.right = 150;
116
  directionalLight.shadow.camera.top = 150;
117
  directionalLight.shadow.camera.bottom = -150;
118
+ directionalLight.shadow.bias = -0.002;
119
  scene.add(directionalLight);
 
120
  const hemiLight = new THREE.HemisphereLight( 0xabcdef, 0x55aa55, 0.5 );
121
  scene.add( hemiLight );
122
  }
 
124
  function setupInitialGround() {
125
  console.log(`Setting up initial ground for ${plotsMetadata.length} saved plots.`);
126
  plotsMetadata.forEach(plot => {
127
+ createGroundPlane(plot.grid_x, plot.grid_z, false);
128
  });
 
129
  if (!groundMeshes['0_0']) {
130
+ createGroundPlane(0, 0, false);
131
  }
132
+ // Also create placeholders around initial player position
133
+ checkAndExpandGroundVisuals();
134
  }
135
 
136
  function createGroundPlane(gridX, gridZ, isPlaceholder) {
137
  const gridKey = `${gridX}_${gridZ}`;
138
+ if (groundMeshes[gridKey]) return groundMeshes[gridKey];
139
 
 
140
  const groundGeometry = new THREE.PlaneGeometry(plotWidth, plotDepth);
141
  const material = isPlaceholder ? placeholderGroundMaterial : groundMaterial;
142
  const groundMesh = new THREE.Mesh(groundGeometry, material);
143
 
144
  groundMesh.rotation.x = -Math.PI / 2;
145
+ groundMesh.position.y = -0.05;
146
  groundMesh.position.x = gridX * plotWidth + plotWidth / 2.0;
147
  groundMesh.position.z = gridZ * plotDepth + plotDepth / 2.0;
 
148
  groundMesh.receiveShadow = true;
149
  groundMesh.userData.gridKey = gridKey;
150
  groundMesh.userData.isPlaceholder = isPlaceholder;
151
  scene.add(groundMesh);
152
+ groundMeshes[gridKey] = groundMesh;
153
  return groundMesh;
154
  }
155
 
 
157
  const playerGeo = new THREE.CapsuleGeometry(0.4, 0.8, 4, 8);
158
  const playerMat = new THREE.MeshStandardMaterial({ color: 0x0055ff, roughness: 0.6 });
159
  playerMesh = new THREE.Mesh(playerGeo, playerMat);
160
+ playerMesh.position.set(plotWidth / 2, 0.8, plotDepth / 2);
161
  playerMesh.castShadow = true;
162
+ playerMesh.receiveShadow = false;
163
  scene.add(playerMesh);
164
  }
165
 
166
+ // Function to create object mesh based on data
167
+ // Returns the created mesh or null
168
+ function createAndPlaceObject(objData, isNewUnsavedObject) {
169
+ if (!objData || !objData.type || !objData.obj_id) {
170
+ console.warn("Skipping object creation - Missing type or obj_id:", objData);
171
+ return null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  }
173
+ // Avoid recreating objects that already exist in the scene (e.g., from initial load + sessionStorage restore)
174
+ if (allLoadedObjects.has(objData.obj_id)) {
175
+ console.log(`Object ${objData.obj_id} already exists. Skipping creation.`);
176
+ // If restoring, ensure it's tracked in newlyPlacedObjects if it should be
177
+ const existingMesh = allLoadedObjects.get(objData.obj_id);
178
+ if (isNewUnsavedObject && !newlyPlacedObjects.some(o => o === existingMesh)) {
179
+ newlyPlacedObjects.push(existingMesh);
180
+ existingMesh.userData.isNewlyPlaced = true; // Ensure flag is set
181
+ }
182
+ return existingMesh;
183
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
 
185
 
186
+ let mesh = null;
 
187
  const objType = objData.type;
188
 
 
189
  switch (objType) {
190
+ case "Simple House": mesh = createSimpleHouse(); break;
191
+ case "Tree": mesh = createTree(); break;
192
+ case "Rock": mesh = createRock(); break;
193
+ case "Fence Post": mesh = createFencePost(); break;
194
  default: console.warn("Unknown object type:", objType); return null;
195
  }
196
 
197
+ if (mesh) {
198
+ mesh.userData.obj_id = objData.obj_id; // Assign the ID from data
199
+ mesh.userData.type = objType;
200
+ mesh.userData.isNewlyPlaced = isNewUnsavedObject; // Mark if local & unsaved
201
+
202
+ if (objData.position) {
203
+ mesh.position.set(objData.position.x, objData.position.y, objData.position.z);
204
+ }
205
+ if (objData.rotation) {
206
+ mesh.rotation.set(objData.rotation._x, objData.rotation._y, objData.rotation._z, objData.rotation._order || 'XYZ');
 
207
  }
208
 
209
+ scene.add(mesh);
210
+ allLoadedObjects.set(objData.obj_id, mesh); // Track it
211
+
212
+ if (isNewUnsavedObject) {
213
+ newlyPlacedObjects.push(mesh);
214
+ // console.log(`Tracked new local object: ${objType} (${mesh.userData.obj_id}). Total unsaved: ${newlyPlacedObjects.length}`);
 
 
 
 
 
 
 
 
 
215
  }
216
+ return mesh;
217
  }
218
  return null;
219
  }
220
 
221
+
222
  // --- Object Creation Functions (Factories) ---
223
+ // (Keep your createSimpleHouse, createTree, createRock, createFencePost functions here)
224
+ // ... (same as previous versions) ...
225
+ function createObjectBase(type) { return { userData: { type: type } }; }
226
+ function createSimpleHouse() { const base = createObjectBase("Simple House"); const group = new THREE.Group(); Object.assign(group, base); const mat1=new THREE.MeshStandardMaterial({color:0xffccaa,roughness:0.8}), mat2=new THREE.MeshStandardMaterial({color:0xaa5533,roughness:0.7}); const m1=new THREE.Mesh(new THREE.BoxGeometry(2,1.5,2.5),mat1); m1.position.y=1.5/2; m1.castShadow=true; m1.receiveShadow=true; group.add(m1); const m2=new THREE.Mesh(new THREE.ConeGeometry(1.8,1,4),mat2); m2.position.y=1.5+1/2; m2.rotation.y=Math.PI/4; m2.castShadow=true; m2.receiveShadow=false; group.add(m2); return group; }
227
+ function createTree() { const base=createObjectBase("Tree"); const group=new THREE.Group(); Object.assign(group,base); const mat1=new THREE.MeshStandardMaterial({color:0x8B4513,roughness:0.9}), mat2=new THREE.MeshStandardMaterial({color:0x228B22,roughness:0.8}); const m1=new THREE.Mesh(new THREE.CylinderGeometry(0.3,0.4,2,8),mat1); m1.position.y=1; m1.castShadow=true; m1.receiveShadow=true; group.add(m1); const m2=new THREE.Mesh(new THREE.IcosahedronGeometry(1.2,0),mat2); m2.position.y=2.8; m2.castShadow=true; m2.receiveShadow=false; group.add(m2); return group; }
228
+ function createRock() { const base=createObjectBase("Rock"); const mat=new THREE.MeshStandardMaterial({color:0xaaaaaa,roughness:0.8,metalness:0.1}); const geo = new THREE.IcosahedronGeometry(0.7, 1); geo.positionData = geo.attributes.position.array; for (let i = 0; i < geo.positionData.length; i += 3) { const noise = Math.random() * 0.15 - 0.075; geo.positionData[i] *= (1 + noise); geo.positionData[i+1] *= (1 + noise); geo.positionData[i+2] *= (1 + noise); } geo.computeVertexNormals(); const rock=new THREE.Mesh(geo,mat); Object.assign(rock,base); rock.position.y=0.35; rock.rotation.set(Math.random()*Math.PI, Math.random()*Math.PI, Math.random()*Math.PI); rock.castShadow=true; rock.receiveShadow=true; return rock; }
229
+ function createFencePost() { const base=createObjectBase("Fence Post"); const mat=new THREE.MeshStandardMaterial({color:0xdeb887,roughness:0.9}); const post=new THREE.Mesh(new THREE.BoxGeometry(0.2,1.5,0.2),mat); Object.assign(post,base); post.position.y=0.75; post.castShadow=true; post.receiveShadow=true; return post; }
230
+
231
+
232
+ // --- Session Storage for Local Unsaved Changes ---
233
+ function saveUnsavedState() {
234
+ try {
235
+ // Only save objects marked as newly placed
236
+ const stateToSave = newlyPlacedObjects.map(obj => {
237
+ if (!obj.userData || !obj.userData.type || !obj.userData.obj_id) return null;
238
+ // Make sure we have the necessary info even if object creation failed partially
239
+ if (!obj.position || !obj.rotation) return null;
240
+ return {
241
+ obj_id: obj.userData.obj_id, type: obj.userData.type,
242
+ position: { x: obj.position.x, y: obj.position.y, z: obj.position.z },
243
+ rotation: { _x: obj.rotation.x, _y: obj.rotation.y, _z: obj.rotation.z, _order: obj.rotation.order }
244
+ };
245
+ }).filter(obj => obj !== null); // Filter out any nulls from mapping failures
246
+
247
+ sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(stateToSave));
248
+ console.log(`Saved ${stateToSave.length} unsaved objects to sessionStorage.`);
249
+ } catch (e) {
250
+ console.error("Error saving state to sessionStorage:", e);
251
+ // Avoid breaking the app if sessionStorage fails (e.g., storage full, security settings)
252
+ }
253
  }
254
+
255
+ function restoreUnsavedState() {
256
+ try {
257
+ const savedState = sessionStorage.getItem(SESSION_STORAGE_KEY);
258
+ if (savedState) {
259
+ console.log("Found unsaved state in sessionStorage. Restoring...");
260
+ const objectsToRestore = JSON.parse(savedState);
261
+ if (Array.isArray(objectsToRestore)) {
262
+ let count = 0;
263
+ objectsToRestore.forEach(objData => {
264
+ // Create object, mark as 'isNewUnsavedObject = true'
265
+ const mesh = createAndPlaceObject(objData, true);
266
+ if(mesh) count++;
267
+ });
268
+ console.log(`Restored ${count} unsaved objects from sessionStorage.`);
269
+ }
270
+ } else {
271
+ console.log("No unsaved state found in sessionStorage.");
272
+ }
273
+ } catch (e) {
274
+ console.error("Error restoring state from sessionStorage:", e);
275
+ sessionStorage.removeItem(SESSION_STORAGE_KEY); // Clear potentially corrupted data
276
+ }
277
  }
278
+
279
+ function clearUnsavedState() {
280
+ try {
281
+ sessionStorage.removeItem(SESSION_STORAGE_KEY);
282
+ // Clear the tracking array
283
+ newlyPlacedObjects = [];
284
+ // Optionally reset the flag on meshes that were in the array (though they might be gone after rerun)
285
+ // For safety, iterate the map and reset flags? Or rely on full reload. Let's rely on reload.
286
+ console.log("Cleared unsaved state from memory and sessionStorage.");
287
+ } catch (e) {
288
+ console.error("Error clearing sessionStorage:", e);
289
  }
 
 
 
 
 
 
 
 
290
  }
291
 
292
 
293
  // --- Event Handlers ---
294
  function onMouseMove(event) {
 
295
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
296
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
297
  }
 
299
  function onDocumentClick(event) {
300
  if (selectedObjectType === "None") return;
301
 
 
302
  const groundCandidates = Object.values(groundMeshes);
303
+ if (groundCandidates.length === 0) return;
304
 
305
  raycaster.setFromCamera(mouse, camera);
306
+ const intersects = raycaster.intersectObjects(groundCandidates);
307
 
308
  if (intersects.length > 0) {
309
+ const intersectPoint = intersects[0].point;
 
310
 
311
+ // Prepare object data with a new unique ID
312
  const newObjData = {
313
+ obj_id: THREE.MathUtils.generateUUID(), // New ID for every placement
314
  type: selectedObjectType,
315
+ position: { x: intersectPoint.x, y: 0, z: intersectPoint.z }, // Start at y=0 on the ground plane
316
+ rotation: { _x: 0, _y: Math.random() * Math.PI * 2, _z: 0, _order: 'XYZ' }
317
  };
318
 
319
+ // Adjust Y based on object type (move this logic inside createAndPlaceObject?)
320
+ // If object's origin isn't its base, adjust here or in the factory.
321
+ // Let's assume factories place objects with base near Y=0 for simplicity.
322
+ // Example: If rock center is at 0,0,0 in its local space, need to lift it.
323
+ if(selectedObjectType === "Rock") newObjData.position.y = 0.35; // Lift rock slightly
324
+ else if (selectedObjectType === "Fence Post") newObjData.position.y = 0; // Origin is likely base
325
+ // Add other adjustments if needed
326
+
327
+ const newMesh = createAndPlaceObject(newObjData, true); // true = is newly placed & unsaved
328
+
329
+ if (newMesh) {
330
+ saveUnsavedState(); // Save to sessionStorage immediately after placing
331
+ }
332
  }
333
  }
334
 
 
346
  if (playerMesh) {
347
  playerMesh.position.x = targetX;
348
  playerMesh.position.z = targetZ;
349
+ const offset = new THREE.Vector3(0, 15, 20);
350
+ const targetPosition = playerMesh.position.clone().add(offset);
351
+ camera.position.copy(targetPosition);
352
+ camera.lookAt(playerMesh.position);
 
353
  console.log("Player teleported to:", playerMesh.position);
354
  } else { console.error("Player mesh not found for teleport."); }
355
  }
356
 
357
  // Called by Python's save button
358
+ function getSaveDataAndPosition() {
359
+ console.log(`JS getSaveDataAndPosition called. Found ${newlyPlacedObjects.length} new local objects.`);
360
+ // Prepare data for the objects marked as 'newlyPlaced'
361
+ const objectsToSave = newlyPlacedObjects.map(obj => {
 
362
  if (!obj.userData || !obj.userData.type || !obj.userData.obj_id) return null;
363
  return {
364
+ obj_id: obj.userData.obj_id, type: obj.userData.type,
 
365
  position: { x: obj.position.x, y: obj.position.y, z: obj.position.z },
366
  rotation: { _x: obj.rotation.x, _y: obj.rotation.y, _z: obj.rotation.z, _order: obj.rotation.order }
367
  };
368
+ }).filter(obj => obj !== null); // Filter out any nulls
369
+
370
+ const playerPos = playerMesh ? { x: playerMesh.position.x, y: playerMesh.position.y, z: playerMesh.position.z } : {x:0, y:0, z:0};
371
 
372
+ const payload = {
373
+ playerPosition: playerPos,
374
+ objectsToSave: objectsToSave // This list contains only the locally added, unsaved objects
375
+ };
376
+ console.log("Prepared payload for saving:", payload);
377
+ return JSON.stringify(payload); // Return as JSON string
378
  }
379
 
380
+ // Called by Python AFTER successful save and merge
381
  function resetNewlyPlacedObjects() {
382
  console.log(`JS resetNewlyPlacedObjects called.`);
383
+ // Clear the tracking array AND the sessionStorage backup
384
+ clearUnsavedState();
385
+ // The objects themselves remain in the scene until the next Python rerun/refresh
386
+ // At which point they will be loaded as part of ALL_INITIAL_OBJECTS
 
 
 
 
 
 
387
  }
388
 
389
  // Called by Python when the selectbox changes
390
  function updateSelectedObjectType(newType) {
391
  console.log("JS updateSelectedObjectType received:", newType);
392
  selectedObjectType = newType;
 
393
  }
394
 
395
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  // --- Animation Loop ---
397
  function updatePlayerMovement() {
398
  if (!playerMesh) return;
 
403
  if (keysPressed['KeyD'] || keysPressed['ArrowRight']) moveDirection.x += 1;
404
 
405
  if (moveDirection.lengthSq() > 0) {
 
406
  const forward = new THREE.Vector3(); camera.getWorldDirection(forward); forward.y = 0; forward.normalize();
407
  const right = new THREE.Vector3().crossVectors(camera.up, forward).normalize();
408
  const worldMove = new THREE.Vector3();
409
+ worldMove.add(forward.multiplyScalar(-moveDirection.z));
410
+ worldMove.add(right.multiplyScalar(-moveDirection.x));
411
  worldMove.normalize().multiplyScalar(playerSpeed);
412
 
413
  playerMesh.position.add(worldMove);
414
+ playerMesh.position.y = Math.max(playerMesh.position.y, 0.8); // Ground clamp
 
415
 
416
  // Check if we need to create *placeholder* ground nearby
417
  checkAndExpandGroundVisuals();
 
427
 
428
  for (let dx = -viewDistanceGrids; dx <= viewDistanceGrids; dx++) {
429
  for (let dz = -viewDistanceGrids; dz <= viewDistanceGrids; dz++) {
 
 
430
  const checkX = currentGridX + dx;
431
  const checkZ = currentGridZ + dz;
432
  const gridKey = `${checkX}_${checkZ}`;
433
 
 
434
  if (!groundMeshes[gridKey]) {
 
435
  const isSaved = plotsMetadata.some(p => p.grid_x === checkX && p.grid_z === checkZ);
436
  if (!isSaved) {
437
+ createGroundPlane(checkX, checkZ, true);
 
 
438
  }
439
+ // If it IS saved but missing, Python rerun should inject it later
440
  }
441
  }
442
  }
443
+ // Optional: Remove distant placeholders
444
  }
445
 
446
 
447
  function updateCamera() {
448
  if (!playerMesh) return;
449
+ const offset = new THREE.Vector3(0, 12, 18);
 
450
  const targetPosition = playerMesh.position.clone().add(offset);
451
+ camera.position.lerp(targetPosition, 0.08);
 
 
452
  const lookAtTarget = playerMesh.position.clone().add(new THREE.Vector3(0, 0.5, 0));
453
  camera.lookAt(lookAtTarget);
454
  }