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
Binary file not shown.
@@ -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' } from '../schema/nodes/stair'
import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment' import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment'
import { getEffectiveWallSurfaceMaterial, type WallSurfaceSide } from '../schema/nodes/wall' import { getEffectiveWallSurfaceMaterial, type WallSurfaceSide } from '../schema/nodes/wall'
import { WindowNode as WindowNodeSchema } from '../schema/nodes/window'
import { import {
generateSceneMaterialId, generateSceneMaterialId,
type SceneMaterial, type SceneMaterial,
@@ -132,6 +133,14 @@ function normalizeDoorNode(node: Record<string, unknown>) {
return parsed.success ? { ...node, ...parsed.data } : null 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>) { function normalizeShelfNode(node: Record<string, unknown>) {
const sanitized = { const sanitized = {
...node, ...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') { if (node.type === 'stair') {
const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node)) const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node))
if (normalized) { if (normalized) {
@@ -4,16 +4,7 @@ import { useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber' import { useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { exportSceneToGlb } from '../../lib/glb-export' import { exportSceneToGlb, nextFrames } 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()))
})
}
export function BakeExporter({ export function BakeExporter({
active, active,
@@ -7,7 +7,7 @@ import { useEffect } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js' import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.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, // prepareSceneForExport neutralises container meshes (door/window hitbox roots,
// material-less renderables) with an attribute-less geometry — GLTFExporter // material-less renderables) with an attribute-less geometry — GLTFExporter
@@ -46,41 +46,52 @@ export function ExportManager() {
const date = new Date().toISOString().split('T')[0] const date = new Date().toISOString().split('T')[0]
if (format === 'glb') { // Signal export so instanced kinds (trees/flowers/grass) swap their
const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes) // invisible proxy for real, exportable geometry, then wait for the
const blob = new Blob([buffer], { type: 'model/gltf-binary' }) // commit before cloning the scene graph (same dance as BakeExporter —
downloadBlob(blob, `model_${date}.glb`) // without it every plant exports as its raycast collider, a white box).
return useViewer.getState().setExporting(true)
}
// 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 { 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 { } finally {
emitter.emit('thumbnail:after-capture', undefined) useViewer.getState().setExporting(false)
}
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
} }
} }
@@ -8935,21 +8935,14 @@ export function FloorplanPanel({
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1], start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
angleSnap, angleSnap,
}) })
let snappedPoint = fallbackPoint // Zone shares the slab surface snap (wall corners / midpoints /
if (isSlabBuildActive) { // crossings + alignment) — it's the same polygon-on-a-level draw.
snappedPoint = resolveSlabPlanPointSnap({ const snappedPoint = resolveSlabPlanPointSnap({
rawPoint: planPoint, rawPoint: planPoint,
fallbackPoint, fallbackPoint,
levelId, levelId,
align: !angleSnap, align: !angleSnap,
}).point }).point
} else if (angleSnap) {
useAlignmentGuides.getState().clear()
} else {
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, {
applySnap: isMagneticSnapActive(),
})
}
// Emit `grid:move` so the registry-driven slab tool also tracks // Emit `grid:move` so the registry-driven slab tool also tracks
// the cursor (its 3D preview needs it). // the cursor (its 3D preview needs it).
@@ -9647,15 +9640,15 @@ export function FloorplanPanel({
return return
} }
const snappedPoint = resolveSlabPlanPointSnap({
rawPoint: planPoint,
fallbackPoint,
levelId,
align: !angleSnap,
}).point
if (isZoneBuildActive) { if (isZoneBuildActive) {
handleZonePlacementConfirm(fallbackPoint) handleZonePlacementConfirm(snappedPoint)
} else { } else {
const snappedPoint = resolveSlabPlanPointSnap({
rawPoint: planPoint,
fallbackPoint,
levelId,
align: !angleSnap,
}).point
// Slab is registry-driven: forward the double-click so the 3D tool // Slab is registry-driven: forward the double-click so the 3D tool
// commits the node (zone has no registry tool, so it commits locally). // commits the node (zone has no registry tool, so it commits locally).
emitFloorplanGridEvent('double-click', snappedPoint, event) emitFloorplanGridEvent('double-click', snappedPoint, event)
@@ -275,21 +275,15 @@ export function useFloorplanBackgroundPlacement({
start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1], start: activePolygonDraftPoints[activePolygonDraftPoints.length - 1],
angleSnap, angleSnap,
}) })
let snappedPoint = fallbackPoint // Zone shares the slab surface snap (wall corners / midpoints /
if (isSlabBuildActive) { // crossings + alignment) — it's the same polygon-on-a-level draw.
snappedPoint = resolveSlabPlanPointSnap({ const snappedPoint = resolveSlabPlanPointSnap({
rawPoint: planPoint, rawPoint: planPoint,
fallbackPoint, fallbackPoint,
levelId, levelId,
altKey: event.altKey, altKey: event.altKey,
align: !angleSnap, align: !angleSnap,
}).point }).point
} else if (!angleSnap) {
snappedPoint = alignFloorplanDraftPoint(fallbackPoint, {
applySnap: isMagneticSnapActive(),
bypass: event.altKey,
})
}
// Emit the grid event so the registry-driven slab tool also // Emit the grid event so the registry-driven slab tool also
// sees the click (parity with ceiling / fence / roof branches // sees the click (parity with ceiling / fence / roof branches
@@ -316,6 +316,12 @@ export function ZoneLabelEditorSystem() {
.map((n) => n.id as ZoneNode['id']), .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 ( return (
<> <>
@@ -1,6 +1,7 @@
import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core' import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { useEffect } from 'react'
import { type Group, MathUtils, type Mesh } from 'three' import { type Group, MathUtils, type Mesh } from 'three'
import type { MeshBasicNodeMaterial } from 'three/webgpu' import type { MeshBasicNodeMaterial } from 'three/webgpu'
import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy' import { resolveOverlayPolicy } from '../../../lib/interaction/overlay-policy'
@@ -12,7 +13,21 @@ import useInteractionScope from '../../../store/use-interaction-scope'
const noopRaycast = () => {} const noopRaycast = () => {}
export const ZoneSystem = () => { 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) => { useFrame((_, delta) => {
if (!useViewer.getState().showZones) return
const structureLayer = useEditor.getState().structureLayer const structureLayer = useEditor.getState().structureLayer
const editorMode = useEditor.getState().mode const editorMode = useEditor.getState().mode
const selectedLevelId = useViewer.getState().selection.levelId const selectedLevelId = useViewer.getState().selection.levelId
@@ -54,9 +69,13 @@ export const ZoneSystem = () => {
? 1 ? 1
: 0 : 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 const walls = (obj as Group).getObjectByName('walls') as Mesh | undefined
if (walls) { if (walls) {
walls.visible = meshVisible walls.visible = meshVisible
walls.raycast = noopRaycast
const material = walls.material as MeshBasicNodeMaterial const material = walls.material as MeshBasicNodeMaterial
if (material?.userData?.uOpacity) { if (material?.userData?.uOpacity) {
material.userData.uOpacity.value = MathUtils.lerp( material.userData.uOpacity.value = MathUtils.lerp(
@@ -70,6 +89,7 @@ export const ZoneSystem = () => {
const floor = (obj as Group).getObjectByName('floor') as Mesh | undefined const floor = (obj as Group).getObjectByName('floor') as Mesh | undefined
if (floor) { if (floor) {
floor.visible = meshVisible floor.visible = meshVisible
floor.raycast = noopRaycast
const material = floor.material as MeshBasicNodeMaterial const material = floor.material as MeshBasicNodeMaterial
if (material?.userData?.uOpacity) { if (material?.userData?.uOpacity) {
material.userData.uOpacity.value = MathUtils.lerp( 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 // Labels: visible on the current level (regardless of mode), but never
// during snapshot capture. // during snapshot capture.
const showLabel = 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 { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
import { EDITOR_LAYER } from './../../../lib/constants' import { EDITOR_LAYER } from './../../../lib/constants'
import { sfxEmitter } from './../../../lib/sfx-bus' import { sfxEmitter } from './../../../lib/sfx-bus'
import {
clearSurfacePlanSnapFeedback,
resolveSurfacePlanPointSnap,
} from './../../../lib/surface-plan-snap'
import { snapWorldXZForActiveBuilding } from './../../../lib/world-grid-snap' import { snapWorldXZForActiveBuilding } from './../../../lib/world-grid-snap'
import useEditor, { isAngleSnapActive, isGridSnapActive } from './../../../store/use-editor' import useEditor, { isAngleSnapActive, isGridSnapActive } from './../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
@@ -79,26 +83,35 @@ export const ZoneTool: React.FC = () => {
if (!currentLevelId) return if (!currentLevelId) return
let cursorPosition: [number, number] = [0, 0] let cursorPosition: [number, number] = [0, 0]
let rawCursorPosition: [number, number] = [0, 0] let snappedCursorPosition: [number, number] | null = null
// Initialize line geometries // Initialize line geometries
mainLineRef.current.geometry = new BufferGeometry() mainLineRef.current.geometry = new BufferGeometry()
closingLineRef.current.geometry = new BufferGeometry() closingLineRef.current.geometry = new BufferGeometry()
// Snapping follows the active mode (zone resolves to the 'wall' context): // Snapping follows the active mode: `angles` locks the ray to 15° from the
// `angles` locks the ray to 15° from the last vertex, `grid` quantizes the // last vertex (grid quantizes the distance along it), otherwise `grid`
// distance along it, `lines` / `off` leave the raw cursor. No held-Shift // quantizes to the world-aligned grid; the shared surface snap then layers
// bypass — Shift cycles the mode (see interaction-scope.md). // 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 = ( const snapDraftPoint = (
lastPoint: [number, number], lastPoint: [number, number] | undefined,
_gridPoint: [number, number], gridPoint: [number, number],
rawPoint: [number, number], rawPoint: [number, number],
altKey: boolean,
): [number, number] => { ): [number, number] => {
const angleStep = isAngleSnapActive() ? DEFAULT_ANGLE_STEP : 0
const gridStep = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0 const gridStep = isGridSnapActive() ? useEditor.getState().gridSnapStep : 0
if (angleStep === 0 && gridStep === 0) return rawPoint const orthoPoint: [number, number] =
const [x, z] = snapPointAlongAngleRay(lastPoint, rawPoint, angleStep, gridStep) isAngleSnapActive() && lastPoint
return [x, z] ? [...snapPointAlongAngleRay(lastPoint, rawPoint, DEFAULT_ANGLE_STEP, gridStep)]
: gridPoint
return resolveSurfacePlanPointSnap({
rawPoint,
fallbackPoint: orthoPoint,
levelId: currentLevelId,
altKey,
}).point
} }
const updateLines = () => { const updateLines = () => {
@@ -116,11 +129,9 @@ export const ZoneTool: React.FC = () => {
// Add cursor point // Add cursor point
const lastPoint = points[points.length - 1] const lastPoint = points[points.length - 1]
if (lastPoint) { const snapped = snappedCursorPosition ?? cursorPosition
const snapped = snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition) if (lastPoint && isValidPoint(snapped)) {
if (isValidPoint(snapped)) { linePoints.push(new Vector3(snapped[0], y, snapped[1]))
linePoints.push(new Vector3(snapped[0], y, snapped[1]))
}
} }
// Update main line geometry // Update main line geometry
@@ -134,17 +145,14 @@ export const ZoneTool: React.FC = () => {
// Update closing line (from cursor back to first point) // Update closing line (from cursor back to first point)
const firstPoint = points[0] const firstPoint = points[0]
if (points.length >= 2 && lastPoint && isValidPoint(firstPoint)) { if (points.length >= 2 && lastPoint && isValidPoint(firstPoint) && isValidPoint(snapped)) {
const snapped = snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition) const closingPoints = [
if (isValidPoint(snapped)) { new Vector3(snapped[0], y, snapped[1]),
const closingPoints = [ new Vector3(firstPoint[0], y, firstPoint[1]),
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.geometry.dispose() closingLineRef.current.visible = true
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
closingLineRef.current.visible = true
}
} else { } else {
closingLineRef.current.visible = false closingLineRef.current.visible = false
} }
@@ -152,42 +160,42 @@ export const ZoneTool: React.FC = () => {
const updatePreview = () => { const updatePreview = () => {
const points = pointsRef.current const points = pointsRef.current
const lastPoint = points[points.length - 1] const cursorPt = snappedCursorPosition ?? cursorPosition
let cursorPt: [number, number] | null = null setPreview({
if (lastPoint) { points: [...points],
cursorPt = snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition) cursorPoint: isValidPoint(cursorPt) ? cursorPt : null,
} else if (points.length === 0) { levelY: levelYRef.current,
cursorPt = cursorPosition })
}
setPreview({ points: [...points], cursorPoint: cursorPt, levelY: levelYRef.current })
updateLines() updateLines()
} }
const onGridMove = (event: GridEvent) => { // World-grid snap projected into building-local; rotated buildings
if (!cursorRef.current) return // used to pull the snap off the visible grid lines. Grid quantize only
// in grid mode; off / lines / angles leave the raw cursor.
// World-grid snap projected into building-local; rotated buildings const gridPointOf = (event: GridEvent): [number, number] =>
// used to pull the snap off the visible grid lines. Grid quantize only isGridSnapActive()
// 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()
? snapWorldXZForActiveBuilding( ? snapWorldXZForActiveBuilding(
event.position[0], event.position[0],
event.position[2], event.position[2],
useEditor.getState().gridSnapStep, useEditor.getState().gridSnapStep,
).local ).local
: [event.localPosition[0], event.localPosition[2]] : [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] 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 lastPoint = pointsRef.current[pointsRef.current.length - 1]
const displayPoint = lastPoint const displayPoint = snapDraftPoint(
? snapDraftPoint(lastPoint, cursorPosition, rawCursorPosition) lastPoint,
: cursorPosition cursorPosition,
[event.localPosition[0], event.localPosition[2]],
event.nativeEvent?.altKey === true,
)
snappedCursorPosition = displayPoint
// Play snap sound when the snapped position changes during drawing — only // Play snap sound when the snapped position changes during drawing — only
// when a quantizing mode is active (off / lines move continuously). // when a quantizing mode is active (off / lines move continuously).
@@ -210,23 +218,13 @@ export const ZoneTool: React.FC = () => {
const onGridClick = (event: GridEvent) => { const onGridClick = (event: GridEvent) => {
if (!currentLevelId) return 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] const lastPoint = pointsRef.current[pointsRef.current.length - 1]
if (lastPoint) { const clickPoint = snapDraftPoint(
clickPoint = snapDraftPoint(lastPoint, clickPoint, [ lastPoint,
event.localPosition[0], gridPointOf(event),
event.localPosition[2], [event.localPosition[0], event.localPosition[2]],
]) event.nativeEvent?.altKey === true,
} )
// Check if clicking on the first point to close the shape // Check if clicking on the first point to close the shape
const firstPoint = pointsRef.current[0] const firstPoint = pointsRef.current[0]
@@ -280,6 +278,7 @@ export const ZoneTool: React.FC = () => {
// Reset state on unmount // Reset state on unmount
pointsRef.current = [] pointsRef.current = []
clearSurfacePlanSnapFeedback()
} }
}, [currentLevelId]) }, [currentLevelId])
+16 -3
View File
@@ -40,6 +40,16 @@ export type GlbExport = {
animations: THREE.AnimationClip[] 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( export async function exportSceneToGlb(
sceneGroup: Object3D, sceneGroup: Object3D,
nodes: Record<string, AnyNode>, nodes: Record<string, AnyNode>,
@@ -296,9 +306,12 @@ function isRenderableMesh(mesh: THREE.Mesh): boolean {
const position = mesh.geometry?.getAttribute('position') const position = mesh.geometry?.getAttribute('position')
if (!position || position.count === 0) return false if (!position || position.count === 0) return false
const material = mesh.material const material = mesh.material
return Array.isArray(material) // `colorWrite: false` is how raycast-only colliders (e.g. instanced plants'
? material.some((m) => m?.visible !== false) // proxy boxes) hide on the GPU — glTF has no equivalent, so exporting one
: material?.visible !== false // 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 ------------------------------------------------- // --- Material conversion -------------------------------------------------
+76 -66
View File
@@ -1,7 +1,7 @@
'use client' 'use client'
import { useRegistry, type ZoneNode } from '@pascal-app/core' 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 { Html } from '@react-three/drei'
import { useMemo, useRef } from 'react' import { useMemo, useRef } from 'react'
import { BufferGeometry, Color, DoubleSide, Float32BufferAttribute, type Group, Shape } from 'three' 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) 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 // Create floor shape from polygon
const floorShape = useMemo(() => { const floorShape = useMemo(() => {
if (!node?.polygon || node.polygon.length < 3) return null if (!node?.polygon || node.polygon.length < 3) return null
@@ -180,77 +186,81 @@ export const ZoneRenderer = ({ node }: { node: ZoneNode }) => {
return ( return (
<group ref={ref} {...handlers} userData={{ labelPosition: [centroid[0], 1, centroid[1]] }}> <group ref={ref} {...handlers} userData={{ labelPosition: [centroid[0], 1, centroid[1]] }}>
<Html {showZones && (
name="label" <>
position={[centroid[0], 1, centroid[1]]} <Html
style={{ pointerEvents: 'none' }} name="label"
zIndexRange={[10, 0]} position={[centroid[0], 1, centroid[1]]}
> style={{ pointerEvents: 'none' }}
<div zIndexRange={[10, 0]}
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',
}}
> >
<div <div
id={`${node.id}-label`}
style={{ style={{
width: '2px', display: 'flex',
height: '40px', flexDirection: 'column',
backgroundColor: node.color, alignItems: 'center',
transform: 'translate3d(-50%, -50%, 0)',
opacity: 0,
transition: 'opacity 0.3s ease-in-out',
}} }}
/> >
<div <div
style={{ style={{
width: '10px', width: 'max-content',
height: '10px', color: 'white',
borderRadius: '50%', textShadow: `-1px -1px 0 ${node.color}, 1px -1px 0 ${node.color}, -1px 1px 0 ${node.color}, 1px 1px 0 ${node.color}`,
backgroundColor: node.color, textAlign: 'center',
border: '1px solid white', }}
}} >
/> <span>{node.name}</span>
</div> </div>
</div> <div
</Html> 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 */} {/* Floor fill */}
<mesh <mesh
layers={ZONE_LAYER} layers={ZONE_LAYER}
material={floorMaterial} material={floorMaterial}
name="floor" name="floor"
position={[0, Y_OFFSET, 0]} position={[0, Y_OFFSET, 0]}
rotation={[-Math.PI / 2, 0, 0]} rotation={[-Math.PI / 2, 0, 0]}
> >
<shapeGeometry args={[floorShape]} /> <shapeGeometry args={[floorShape]} />
</mesh> </mesh>
{/* Wall borders with gradient */} {/* Wall borders with gradient */}
<mesh geometry={wallGeometry} layers={ZONE_LAYER} material={wallMaterial} name="walls" /> <mesh geometry={wallGeometry} layers={ZONE_LAYER} material={wallMaterial} name="walls" />
</>
)}
</group> </group>
) )
} }
@@ -12,11 +12,16 @@ import { Suspense } from 'react'
* do alone. Intensity is dialled below the preset default so it complements * do alone. Intensity is dialled below the preset default so it complements
* the scene lights rather than washing them out. Only visible in `rendered` * the scene lights rather than washing them out. Only visible in `rendered`
* shading. * shading.
*
* The HDR is self-hosted (drei's `preset="sunset"` resolves to the same
* `venice_sunset_1k.hdr` on raw.githack.com, which intermittently fails).
* Every app that mounts this — like `/audios/sfx` — must ship the file in its
* own `public/hdri/`.
*/ */
export function SceneEnvironment() { export function SceneEnvironment() {
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
<Environment preset="sunset" environmentIntensity={0.6} /> <Environment environmentIntensity={0.6} files="/hdri/venice_sunset_1k.hdr" />
</Suspense> </Suspense>
) )
} }
+11
View File
@@ -87,6 +87,14 @@ type ViewerState = {
showGrid: boolean showGrid: boolean
setShowGrid: (show: boolean) => void setShowGrid: (show: boolean) => void
// Presentation flag for parametric zones. When false the zone renderer
// unmounts its meshes AND its drei <Html> label (an <Html> costs per-frame
// matrix work + live DOM even at opacity 0, so hiding is not enough). The
// editor drives this from its structure layer; viewer surfaces keep the
// default. Not persisted — derived state, not a user preference.
showZones: boolean
setShowZones: (show: boolean) => void
transparentBackground: boolean transparentBackground: boolean
setTransparentBackground: (transparent: boolean) => void setTransparentBackground: (transparent: boolean) => void
@@ -308,6 +316,9 @@ const useViewer = create<ViewerState>()(
return { showGrid: show, projectPreferences } return { showGrid: show, projectPreferences }
}), }),
showZones: true,
setShowZones: (show) => set({ showZones: show }),
transparentBackground: false, transparentBackground: false,
setTransparentBackground: (transparent) => set({ transparentBackground: transparent }), setTransparentBackground: (transparent) => set({ transparentBackground: transparent }),