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
@@ -4,16 +4,7 @@ import { useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import { exportSceneToGlb } from '../../lib/glb-export'
/** Resolve after the next couple of animation frames, giving React/R3F time to
* commit and mount export-only geometry (e.g. instanced kinds' real meshes)
* before the exporter clones the scene graph. */
function nextFrames(): Promise<void> {
return new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
})
}
import { exportSceneToGlb, nextFrames } from '../../lib/glb-export'
export function BakeExporter({
active,
@@ -7,7 +7,7 @@ import { useEffect } from 'react'
import * as THREE from 'three'
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
import { exportSceneToGlb, prepareSceneForExport } from '../../lib/glb-export'
import { exportSceneToGlb, nextFrames, prepareSceneForExport } from '../../lib/glb-export'
// prepareSceneForExport neutralises container meshes (door/window hitbox roots,
// material-less renderables) with an attribute-less geometry — GLTFExporter
@@ -46,41 +46,52 @@ export function ExportManager() {
const date = new Date().toISOString().split('T')[0]
if (format === 'glb') {
const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes)
const blob = new Blob([buffer], { type: 'model/gltf-binary' })
downloadBlob(blob, `model_${date}.glb`)
return
}
// Hide editor affordances that live on the scene layer (selection handles,
// ceiling/site brackets) and let wall-cutout reveal all walls — the same
// synchronous capture path thumbnails use. We clone the scene inside the
// window, so the export snapshots the clean building, then restore.
emitter.emit('thumbnail:before-capture', undefined)
let prepared: ReturnType<typeof prepareSceneForExport>
// Signal export so instanced kinds (trees/flowers/grass) swap their
// invisible proxy for real, exportable geometry, then wait for the
// commit before cloning the scene graph (same dance as BakeExporter —
// without it every plant exports as its raycast collider, a white box).
useViewer.getState().setExporting(true)
try {
prepared = prepareSceneForExport(sceneGroup, useScene.getState().nodes)
await nextFrames()
if (format === 'glb') {
const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes)
const blob = new Blob([buffer], { type: 'model/gltf-binary' })
downloadBlob(blob, `model_${date}.glb`)
return
}
// Hide editor affordances that live on the scene layer (selection handles,
// ceiling/site brackets) and let wall-cutout reveal all walls — the same
// synchronous capture path thumbnails use. We clone the scene inside the
// window, so the export snapshots the clean building, then restore.
emitter.emit('thumbnail:before-capture', undefined)
let prepared: ReturnType<typeof prepareSceneForExport>
try {
prepared = prepareSceneForExport(sceneGroup, useScene.getState().nodes)
} finally {
emitter.emit('thumbnail:after-capture', undefined)
}
const { scene: exportScene } = prepared
ensurePositionAttributes(exportScene)
if (format === 'stl') {
const exporter = new STLExporter()
const result = exporter.parse(exportScene, { binary: true })
const blob = new Blob([result], { type: 'model/stl' })
downloadBlob(blob, `model_${date}.stl`)
return
}
if (format === 'obj') {
const exporter = new OBJExporter()
const result = exporter.parse(exportScene)
const blob = new Blob([result], { type: 'model/obj' })
downloadBlob(blob, `model_${date}.obj`)
return
}
} finally {
emitter.emit('thumbnail:after-capture', undefined)
}
const { scene: exportScene } = prepared
ensurePositionAttributes(exportScene)
if (format === 'stl') {
const exporter = new STLExporter()
const result = exporter.parse(exportScene, { binary: true })
const blob = new Blob([result], { type: 'model/stl' })
downloadBlob(blob, `model_${date}.stl`)
return
}
if (format === 'obj') {
const exporter = new OBJExporter()
const result = exporter.parse(exportScene)
const blob = new Blob([result], { type: 'model/obj' })
downloadBlob(blob, `model_${date}.obj`)
return
useViewer.getState().setExporting(false)
}
}
@@ -8935,21 +8935,14 @@ export function FloorplanPanel({
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
angleSnap,
})
let snappedPoint = fallbackPoint
if (isSlabBuildActive) {
snappedPoint = resolveSlabPlanPointSnap({
rawPoint: planPoint,
fallbackPoint,
levelId,
align: !angleSnap,
}).point
} else if (angleSnap) {
useAlignmentGuides.getState().clear()
} else {
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, {
applySnap: isMagneticSnapActive(),
})
}
// Zone shares the slab surface snap (wall corners / midpoints /
// crossings + alignment) — it's the same polygon-on-a-level draw.
const snappedPoint = resolveSlabPlanPointSnap({
rawPoint: planPoint,
fallbackPoint,
levelId,
align: !angleSnap,
}).point
// Emit `grid:move` so the registry-driven slab tool also tracks
// the cursor (its 3D preview needs it).
@@ -9647,15 +9640,15 @@ export function FloorplanPanel({
return
}
const snappedPoint = resolveSlabPlanPointSnap({
rawPoint: planPoint,
fallbackPoint,
levelId,
align: !angleSnap,
}).point
if (isZoneBuildActive) {
handleZonePlacementConfirm(fallbackPoint)
handleZonePlacementConfirm(snappedPoint)
} else {
const snappedPoint = resolveSlabPlanPointSnap({
rawPoint: planPoint,
fallbackPoint,
levelId,
align: !angleSnap,
}).point
// Slab is registry-driven: forward the double-click so the 3D tool
// commits the node (zone has no registry tool, so it commits locally).
emitFloorplanGridEvent('double-click', snappedPoint, event)
@@ -275,21 +275,15 @@ export function useFloorplanBackgroundPlacement({
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
angleSnap,
})
let snappedPoint = fallbackPoint
if (isSlabBuildActive) {
snappedPoint = resolveSlabPlanPointSnap({
rawPoint: planPoint,
fallbackPoint,
levelId,
altKey: event.altKey,
align: !angleSnap,
}).point
} else if (!angleSnap) {
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, {
applySnap: isMagneticSnapActive(),
bypass: event.altKey,
})
}
// Zone shares the slab surface snap (wall corners / midpoints /
// crossings + alignment) — it's the same polygon-on-a-level draw.
const snappedPoint = resolveSlabPlanPointSnap({
rawPoint: planPoint,
fallbackPoint,
levelId,
altKey: event.altKey,
align: !angleSnap,
}).point
// Emit the grid event so the registry-driven slab tool also
// sees the click (parity with ceiling / fence / roof branches
@@ -316,6 +316,12 @@ export function ZoneLabelEditorSystem() {
.map((n) => n.id as ZoneNode['id']),
),
)
// The zone renderer unmounts its <Html> label when zones are hidden —
// unmount the portals with it, or each editor would hold (and rAF-poll for)
// a detached label element.
const showZones = useViewer((s) => s.showZones)
if (!showZones) return null
return (
<>
@@ -1,6 +1,7 @@
import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import { useEffect } from 'react'
import { type Group, MathUtils, type Mesh } from 'three'
import type { MeshBasicNodeMaterial } from 'three/webgpu'
import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy'
@@ -12,7 +13,21 @@ import useInteractionScope from '../../../store/use-interaction-scope'
const noopRaycast = () => {}
export const ZoneSystem = () => {
// Outside the zones layer (or during snapshot capture) zones unmount
// entirely — meshes AND drei <Html> labels, which cost per-frame matrix work
// + live DOM even at opacity 0. The renderer reads this viewer flag; the
// unmount cleanup restores the default so preview / first-person surfaces
// (which swap this system for ViewerZoneSystem) keep their labels.
const structureLayerState = useEditor((s) => s.structureLayer)
const isCaptureModeState = useEditor((s) => s.isCaptureMode)
useEffect(() => {
useViewer.getState().setShowZones(structureLayerState === 'zones' && !isCaptureModeState)
return () => useViewer.getState().setShowZones(true)
}, [structureLayerState, isCaptureModeState])
useFrame((_, delta) => {
if (!useViewer.getState().showZones) return
const structureLayer = useEditor.getState().structureLayer
const editorMode = useEditor.getState().mode
const selectedLevelId = useViewer.getState().selection.levelId
@@ -54,9 +69,13 @@ export const ZoneSystem = () => {
? 1
: 0
// Raycast is re-disabled per frame (not once per group): the meshes
// remount whenever the zones layer toggles, so a one-shot flag on the
// persistent group would leave fresh meshes clickable.
const walls = (obj as Group).getObjectByName('walls') as Mesh | undefined
if (walls) {
walls.visible = meshVisible
walls.raycast = noopRaycast
const material = walls.material as MeshBasicNodeMaterial
if (material?.userData?.uOpacity) {
material.userData.uOpacity.value = MathUtils.lerp(
@@ -70,6 +89,7 @@ export const ZoneSystem = () => {
const floor = (obj as Group).getObjectByName('floor') as Mesh | undefined
if (floor) {
floor.visible = meshVisible
floor.raycast = noopRaycast
const material = floor.material as MeshBasicNodeMaterial
if (material?.userData?.uOpacity) {
material.userData.uOpacity.value = MathUtils.lerp(
@@ -80,15 +100,6 @@ export const ZoneSystem = () => {
}
}
// Disable raycasting once per zone object so geometry never intercepts clicks
if (!obj.userData.__raycastDisabled) {
obj.raycast = noopRaycast
obj.traverse((child) => {
child.raycast = noopRaycast
})
obj.userData.__raycastDisabled = true
}
// Labels: visible on the current level (regardless of mode), but never
// during snapshot capture.
const showLabel =
@@ -12,6 +12,10 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
import { EDITOR_LAYER } from './../../../lib/constants'
import { sfxEmitter } from './../../../lib/sfx-bus'
import {
clearSurfacePlanSnapFeedback,
resolveSurfacePlanPointSnap,
} from './../../../lib/surface-plan-snap'
import { snapWorldXZForActiveBuilding } from './../../../lib/world-grid-snap'
import useEditor, { isAngleSnapActive, isGridSnapActive } from './../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
@@ -79,26 +83,35 @@ export const ZoneTool: React.FC = () => {
if (!currentLevelId) return
let cursorPosition: [number, number] = [0, 0]
let rawCursorPosition: [number, number] = [0, 0]
let snappedCursorPosition: [number, number] | null = null
// Initialize line geometries
mainLineRef.current.geometry = new BufferGeometry()
closingLineRef.current.geometry = new BufferGeometry()
// Snapping follows the active mode (zone resolves to the 'wall' context):
// `angles` locks the ray to 15° from the last vertex, `grid` quantizes the
// distance along it, `lines` / `off` leave the raw cursor. No held-Shift
// bypass — Shift cycles the mode (see interaction-scope.md).
// Snapping follows the active mode: `angles` locks the ray to 15° from the
// last vertex (grid quantizes the distance along it), otherwise `grid`
// quantizes to the world-aligned grid; the shared surface snap then layers
// wall-corner/midpoint/crossing magnetics and alignment guides on top —
// the same pipeline slab/ceiling drafting uses. No held-Shift bypass —
// Shift cycles the mode (see interaction-scope.md).
const snapDraftPoint = (
lastPoint: [number, number],
_gridPoint: [number, number],
lastPoint: [number, number] | undefined,
gridPoint: [number, number],
rawPoint: [number, number],
altKey: boolean,
): [number, number] => {
const angleStep = isAngleSnapActive() ? DEFAULT_ANGLE_STEP : 0
const gridStep = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
if (angleStep === 0 && gridStep === 0) return rawPoint
const [x, z] = snapPointAlongAngleRay(lastPoint, rawPoint, angleStep, gridStep)
return [x, z]
const orthoPoint: [number, number] =
isAngleSnapActive() && lastPoint
? [...snapPointAlongAngleRay(lastPoint, rawPoint, DEFAULT_ANGLE_STEP, gridStep)]
: gridPoint
return resolveSurfacePlanPointSnap({
rawPoint,
fallbackPoint: orthoPoint,
levelId: currentLevelId,
altKey,
}).point
}
const updateLines = () => {
@@ -116,11 +129,9 @@ export const ZoneTool: React.FC = () => {
// Add cursor point
const lastPoint = points[points.length - 1]
if (lastPoint) {
const snapped = snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition)
if (isValidPoint(snapped)) {
linePoints.push(new Vector3(snapped[0], y, snapped[1]))
}
const snapped = snappedCursorPosition ?? cursorPosition
if (lastPoint && isValidPoint(snapped)) {
linePoints.push(new Vector3(snapped[0], y, snapped[1]))
}
// Update main line geometry
@@ -134,17 +145,14 @@ export const ZoneTool: React.FC = () => {
// Update closing line (from cursor back to first point)
const firstPoint = points[0]
if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) {
const snapped = snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition)
if (isValidPoint(snapped)) {
const closingPoints = [
new Vector3(snapped[0], y, snapped[1]),
new Vector3(firstPoint[0], y, firstPoint[1]),
]
closingLineRef.current.geometry.dispose()
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
closingLineRef.current.visible = true
}
if (points.length >= 2 && lastPoint && isValidPoint(firstPoint) && isValidPoint(snapped)) {
const closingPoints = [
new Vector3(snapped[0], y, snapped[1]),
new Vector3(firstPoint[0], y, firstPoint[1]),
]
closingLineRef.current.geometry.dispose()
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
closingLineRef.current.visible = true
} else {
closingLineRef.current.visible = false
}
@@ -152,42 +160,42 @@ export const ZoneTool: React.FC = () => {
const updatePreview = () => {
const points = pointsRef.current
const lastPoint = points[points.length - 1]
const cursorPt = snappedCursorPosition ?? cursorPosition
let cursorPt: [number, number] | null = null
if (lastPoint) {
cursorPt = snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition)
} else if (points.length === 0) {
cursorPt = cursorPosition
}
setPreview({ points: [...points], cursorPoint: cursorPt, levelY: levelYRef.current })
setPreview({
points: [...points],
cursorPoint: isValidPoint(cursorPt) ? cursorPt : null,
levelY: levelYRef.current,
})
updateLines()
}
const onGridMove = (event: GridEvent) => {
if (!cursorRef.current) return
// World-grid snap projected into building-local; rotated buildings
// used to pull the snap off the visible grid lines. Grid quantize only
// in grid mode; off / lines / angles leave the raw cursor for the first
// vertex (later vertices snap along the ray in `snapDraftPoint`).
const [gridX, gridZ] = isGridSnapActive()
// World-grid snap projected into building-local; rotated buildings
// used to pull the snap off the visible grid lines. Grid quantize only
// in grid mode; off / lines / angles leave the raw cursor.
const gridPointOf = (event: GridEvent): [number, number] =>
isGridSnapActive()
? snapWorldXZForActiveBuilding(
event.position[0],
event.position[2],
useEditor.getState().gridSnapStep,
).local
: [event.localPosition[0], event.localPosition[2]]
cursorPosition = [gridX, gridZ]
rawCursorPosition = [event.localPosition[0], event.localPosition[2]]
const onGridMove = (event: GridEvent) => {
if (!cursorRef.current) return
cursorPosition = gridPointOf(event)
levelYRef.current = event.localPosition[1]
// If we have points, snap to the 15° ray from the last point
const lastPoint = pointsRef.current[pointsRef.current.length - 1]
const displayPoint = lastPoint
? snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition)
: cursorPosition
const displayPoint = snapDraftPoint(
lastPoint,
cursorPosition,
[event.localPosition[0], event.localPosition[2]],
event.nativeEvent?.altKey === true,
)
snappedCursorPosition = displayPoint
// Play snap sound when the snapped position changes during drawing — only
// when a quantizing mode is active (off / lines move continuously).
@@ -210,23 +218,13 @@ export const ZoneTool: React.FC = () => {
const onGridClick = (event: GridEvent) => {
if (!currentLevelId) return
const [gridX, gridZ] = isGridSnapActive()
? snapWorldXZForActiveBuilding(
event.position[0],
event.position[2],
useEditor.getState().gridSnapStep,
).local
: [event.localPosition[0], event.localPosition[2]]
let clickPoint: [number, number] = [gridX, gridZ]
// Snap to the 15° ray from the last point
const lastPoint = pointsRef.current[pointsRef.current.length - 1]
if (lastPoint) {
clickPoint = snapDraftPoint(lastPoint, clickPoint, [
event.localPosition[0],
event.localPosition[2],
])
}
const clickPoint = snapDraftPoint(
lastPoint,
gridPointOf(event),
[event.localPosition[0], event.localPosition[2]],
event.nativeEvent?.altKey === true,
)
// Check if clicking on the first point to close the shape
const firstPoint = pointsRef.current[0]
@@ -280,6 +278,7 @@ export const ZoneTool: React.FC = () => {
// Reset state on unmount
pointsRef.current = []
clearSurfacePlanSnapFeedback()
}
}, [currentLevelId])
+16 -3
View File
@@ -40,6 +40,16 @@ export type GlbExport = {
animations: THREE.AnimationClip[]
}
/** Resolve after the next couple of animation frames, giving React/R3F time to
* commit and mount export-only geometry (e.g. instanced kinds' real meshes)
* before the exporter clones the scene graph. Callers must set
* `useViewer.setExporting(true)` first and reset it after the export. */
export function nextFrames(): Promise<void> {
return new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
})
}
export async function exportSceneToGlb(
sceneGroup: Object3D,
nodes: Record<string, AnyNode>,
@@ -296,9 +306,12 @@ function isRenderableMesh(mesh: THREE.Mesh): boolean {
const position = mesh.geometry?.getAttribute('position')
if (!position || position.count === 0) return false
const material = mesh.material
return Array.isArray(material)
? material.some((m) => m?.visible !== false)
: material?.visible !== false
// `colorWrite: false` is how raycast-only colliders (e.g. instanced plants'
// proxy boxes) hide on the GPU — glTF has no equivalent, so exporting one
// yields an opaque white box. Treat it as non-renderable.
const renders = (m: THREE.Material | null | undefined) =>
m?.visible !== false && m?.colorWrite !== false
return Array.isArray(material) ? material.some(renders) : renders(material)
}
// --- Material conversion -------------------------------------------------