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
+76 -66
View File
@@ -1,7 +1,7 @@
'use client'
import { useRegistry, type ZoneNode } from '@pascal-app/core'
import { useNodeEvents, ZONE_LAYER } from '@pascal-app/viewer'
import { useNodeEvents, useViewer, ZONE_LAYER } from '@pascal-app/viewer'
import { Html } from '@react-three/drei'
import { useMemo, useRef } from 'react'
import { BufferGeometry, Color, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three'
@@ -109,6 +109,12 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
useRegistry(node.id, 'zone', ref)
// When zones are toggled off (editor outside the zones layer) the visuals
// unmount entirely — a drei <Html> keeps costing per-frame matrix work and
// live DOM even at opacity 0. The registered group stays so the zone keeps
// its scene identity (selection registry, GLB export polygon stamping).
const showZones = useViewer((s) => s.showZones)
// Create floor shape from polygon
const floorShape = useMemo(() => {
if (!node?.polygon || node.polygon.length < 3) return null
@@ -180,77 +186,81 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
return (
<group ref={ref} {...handlers} userData={{ labelPosition: [centroid[0], 1, centroid[1]] }}>
<Html
name="label"
position={[centroid[0], 1, centroid[1]]}
style={{ pointerEvents: 'none' }}
zIndexRange={[10, 0]}
>
<div
id={`${node.id}-label`}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
transform: 'translate3d(-50%, -50%, 0)',
opacity: 0,
transition: 'opacity 0.3s ease-in-out',
}}
>
<div
style={{
width: 'max-content',
color: 'white',
textShadow: `-1px -1px 0 ${node.color}, 1px -1px 0 ${node.color}, -1px 1px 0 ${node.color}, 1px 1px 0 ${node.color}`,
textAlign: 'center',
}}
>
<span>{node.name}</span>
</div>
<div
className="label-pin"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginTop: '2px',
opacity: 0,
transition: 'opacity 0.5s ease-in-out',
}}
{showZones && (
<>
<Html
name="label"
position={[centroid[0], 1, centroid[1]]}
style={{ pointerEvents: 'none' }}
zIndexRange={[10, 0]}
>
<div
id={`${node.id}-label`}
style={{
width: '2px',
height: '40px',
backgroundColor: node.color,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
transform: 'translate3d(-50%, -50%, 0)',
opacity: 0,
transition: 'opacity 0.3s ease-in-out',
}}
/>
<div
style={{
width: '10px',
height: '10px',
borderRadius: '50%',
backgroundColor: node.color,
border: '1px solid white',
}}
/>
</div>
</div>
</Html>
>
<div
style={{
width: 'max-content',
color: 'white',
textShadow: `-1px -1px 0 ${node.color}, 1px -1px 0 ${node.color}, -1px 1px 0 ${node.color}, 1px 1px 0 ${node.color}`,
textAlign: 'center',
}}
>
<span>{node.name}</span>
</div>
<div
className="label-pin"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginTop: '2px',
opacity: 0,
transition: 'opacity 0.5s ease-in-out',
}}
>
<div
style={{
width: '2px',
height: '40px',
backgroundColor: node.color,
}}
/>
<div
style={{
width: '10px',
height: '10px',
borderRadius: '50%',
backgroundColor: node.color,
border: '1px solid white',
}}
/>
</div>
</div>
</Html>
{/* Floor fill */}
<mesh
layers={ZONE_LAYER}
material={floorMaterial}
name="floor"
position={[0, Y_OFFSET, 0]}
rotation={[-Math.PI / 2, 0, 0]}
>
<shapeGeometry args={[floorShape]} />
</mesh>
{/* Floor fill */}
<mesh
layers={ZONE_LAYER}
material={floorMaterial}
name="floor"
position={[0, Y_OFFSET, 0]}
rotation={[-Math.PI / 2, 0, 0]}
>
<shapeGeometry args={[floorShape]} />
</mesh>
{/* Wall borders with gradient */}
<mesh geometry={wallGeometry} layers={ZONE_LAYER} material={wallMaterial} name="walls" />
{/* Wall borders with gradient */}
<mesh geometry={wallGeometry} layers={ZONE_LAYER} material={wallMaterial} name="walls" />
</>
)}
</group>
)
}