fix: bug pass — legacy window crash, plant exports, zone snapping/visibility, HDR (#473)

* fix(core): apply window schema defaults on scene load

Windows saved before a schema field existed (columnRatios/rowRatios/
frameThickness/…) loaded with those fields missing; the window mesh
builder reads them unconditionally and threw every frame, crashing the
viewer on legacy scenes. Zod-parse windows on load like doors so the
schema defaults land.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): mount plant geometry during client GLB/STL/OBJ export

The client export never set isExporting, so instanced kinds (trees/
flowers/grass) kept their colorWrite:false raycast collider mounted and
exported it as an opaque white box, while the real geometry (which only
mounts while exporting) was never captured. Reuse BakeExporter's
flag + frame-wait dance in ExportManager, and harden isRenderableMesh
to drop colorWrite:false materials from exports entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(editor): route zone drawing through the shared surface snap

The zone tool quantized later vertices by distance along a free ray
instead of snapping to the grid, and never joined the magnetic
wall-corner/midpoint/crossing + alignment-guide pipeline that walls,
slabs and ceilings use. Give zone the slab treatment in the 3D tool and
both 2D floorplan branches (move + click + double-click commit).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(viewer): self-host the scene environment HDR

drei's preset="sunset" fetches venice_sunset_1k.hdr from
raw.githack.com, which intermittently fails ("Could not load
venice_sunset_1k.hdr: Failed to fetch"). Point Environment at
/hdri/venice_sunset_1k.hdr and ship the file in the app's public/ —
same mirroring convention as /audios/sfx; consuming apps must carry
the file too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): unmount zones entirely outside the zones layer

Zone labels showed in every editing mode (visual noise), and each zone
kept a drei <Html> mounted at opacity 0 — an <Html> costs per-frame
matrix work and live DOM even when invisible. A new viewer presentation
flag (showZones, default true) lets the editor unmount zone meshes and
labels whenever the structure layer isn't 'zones' (and during snapshot
capture); the registered group stays so zones keep their scene identity
for selection and GLB export. Preview / first-person / viewer surfaces
are untouched. Raycast-disable moved from a one-shot group flag to
per-frame on the meshes, which now remount on layer toggles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-07-08 10:19:27 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent 51fddc2d9c
commit 717c2c5c0a
14 changed files with 378 additions and 226 deletions
@@ -0,0 +1,92 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import type { AnyNode } from '../schema'
import useScene from './use-scene'
describe('scene window migrations', () => {
beforeEach(() => {
useScene.setState({
nodes: {},
rootNodeIds: [],
dirtyNodes: new Set(),
collections: {},
} as never)
useScene.temporal.getState().clear()
})
test('fills schema defaults on windows saved before a field existed', () => {
// Mirrors real legacy scenes (e.g. windows persisted without
// columnRatios/rowRatios/frameThickness): the mesh builder reads those
// unconditionally, so a missing array crashed the viewer every frame.
useScene.getState().setScene(
{
site_test: {
object: 'node',
id: 'site_test',
type: 'site',
parentId: null,
visible: true,
metadata: {},
children: ['building_test'],
},
building_test: {
object: 'node',
id: 'building_test',
type: 'building',
parentId: 'site_test',
visible: true,
metadata: {},
children: ['level_test'],
},
level_test: {
object: 'node',
id: 'level_test',
type: 'level',
parentId: 'building_test',
visible: true,
metadata: {},
children: ['wall_test'],
level: 0,
},
wall_test: {
object: 'node',
id: 'wall_test',
type: 'wall',
parentId: 'level_test',
visible: true,
metadata: {},
children: ['window_test'],
start: [0, 0],
end: [4, 0],
height: 2.5,
thickness: 0.2,
},
window_test: {
object: 'node',
id: 'window_test',
type: 'window',
parentId: 'wall_test',
visible: true,
metadata: {},
wallId: 'wall_test',
position: [1, 1, 0],
width: 1.2,
height: 1.5,
windowType: 'fixed',
},
} as unknown as Record<string, AnyNode>,
['site_test'] as never,
)
const window = useScene.getState().nodes.window_test as Extract<AnyNode, { type: 'window' }>
expect(window).toBeDefined()
// Schema defaults land on load…
expect(window.columnRatios).toEqual([1])
expect(window.rowRatios).toEqual([1])
expect(window.frameThickness).toBe(0.05)
expect(window.sill).toBe(true)
// …and authored fields survive.
expect(window.width).toBe(1.2)
expect(window.height).toBe(1.5)
expect(window.wallId).toBe('wall_test')
})
})
+16
View File
@@ -25,6 +25,7 @@ import {
} from '../schema/nodes/stair'
import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment'
import { getEffectiveWallSurfaceMaterial, type WallSurfaceSide } from '../schema/nodes/wall'
import { WindowNode as WindowNodeSchema } from '../schema/nodes/window'
import {
generateSceneMaterialId,
type SceneMaterial,
@@ -132,6 +133,14 @@ function normalizeDoorNode(node: Record<string, unknown>) {
return parsed.success ? { ...node, ...parsed.data } : null
}
// Windows saved before a schema field existed (e.g. `columnRatios`/`rowRatios`/
// `frameThickness`) load without it; the mesh builder then reads undefined and
// throws every frame. Zod-parse on load so schema defaults land, like doors.
function normalizeWindowNode(node: Record<string, unknown>) {
const parsed = WindowNodeSchema.safeParse(node)
return parsed.success ? { ...node, ...parsed.data } : null
}
function normalizeShelfNode(node: Record<string, unknown>) {
const sanitized = {
...node,
@@ -640,6 +649,13 @@ function migrateNodes(nodes: Record<string, any>): {
}
}
if (node.type === 'window') {
const normalized = normalizeWindowNode(node)
if (normalized) {
patchedNodes[id] = normalized
}
}
if (node.type === 'stair') {
const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node))
if (normalized) {