diff --git a/apps/editor/public/hdri/venice_sunset_1k.hdr b/apps/editor/public/hdri/venice_sunset_1k.hdr new file mode 100644 index 00000000..048bb13a Binary files /dev/null and b/apps/editor/public/hdri/venice_sunset_1k.hdr differ diff --git a/packages/core/src/store/use-scene-window-migration.test.ts b/packages/core/src/store/use-scene-window-migration.test.ts new file mode 100644 index 00000000..6911d86b --- /dev/null +++ b/packages/core/src/store/use-scene-window-migration.test.ts @@ -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, + ['site_test'] as never, + ) + + const window = useScene.getState().nodes.window_test as Extract + 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') + }) +}) diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index 3d1bf03a..643065ed 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -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) { 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) { + const parsed = WindowNodeSchema.safeParse(node) + return parsed.success ? { ...node, ...parsed.data } : null +} + function normalizeShelfNode(node: Record) { const sanitized = { ...node, @@ -640,6 +649,13 @@ function migrateNodes(nodes: Record): { } } + if (node.type === 'window') { + const normalized = normalizeWindowNode(node) + if (normalized) { + patchedNodes[id] = normalized + } + } + if (node.type === 'stair') { const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node)) if (normalized) { diff --git a/packages/editor/src/components/editor/bake-exporter.tsx b/packages/editor/src/components/editor/bake-exporter.tsx index 5dca8963..d58c4cbe 100644 --- a/packages/editor/src/components/editor/bake-exporter.tsx +++ b/packages/editor/src/components/editor/bake-exporter.tsx @@ -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 { - return new Promise((resolve) => { - requestAnimationFrame(() => requestAnimationFrame(() => resolve())) - }) -} +import { exportSceneToGlb, nextFrames } from '../../lib/glb-export' export function BakeExporter({ active, diff --git a/packages/editor/src/components/editor/export-manager.tsx b/packages/editor/src/components/editor/export-manager.tsx index 66340864..1368bcbe 100644 --- a/packages/editor/src/components/editor/export-manager.tsx +++ b/packages/editor/src/components/editor/export-manager.tsx @@ -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 + // 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 + 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) } } diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index c685550c..5aaf03f6 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -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) diff --git a/packages/editor/src/components/editor/use-floorplan-background-placement.ts b/packages/editor/src/components/editor/use-floorplan-background-placement.ts index 35e87b53..2ee3925d 100644 --- a/packages/editor/src/components/editor/use-floorplan-background-placement.ts +++ b/packages/editor/src/components/editor/use-floorplan-background-placement.ts @@ -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 diff --git a/packages/editor/src/components/systems/zone/zone-label-editor-system.tsx b/packages/editor/src/components/systems/zone/zone-label-editor-system.tsx index 00d85481..4e0ab606 100644 --- a/packages/editor/src/components/systems/zone/zone-label-editor-system.tsx +++ b/packages/editor/src/components/systems/zone/zone-label-editor-system.tsx @@ -316,6 +316,12 @@ export function ZoneLabelEditorSystem() { .map((n) => n.id as ZoneNode['id']), ), ) + // The zone renderer unmounts its 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 ( <> diff --git a/packages/editor/src/components/systems/zone/zone-system.tsx b/packages/editor/src/components/systems/zone/zone-system.tsx index 30bb0930..8ee276da 100644 --- a/packages/editor/src/components/systems/zone/zone-system.tsx +++ b/packages/editor/src/components/systems/zone/zone-system.tsx @@ -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 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 = diff --git a/packages/editor/src/components/tools/zone/zone-tool.tsx b/packages/editor/src/components/tools/zone/zone-tool.tsx index 74fe3899..ade1a880 100644 --- a/packages/editor/src/components/tools/zone/zone-tool.tsx +++ b/packages/editor/src/components/tools/zone/zone-tool.tsx @@ -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]) diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index 7ff12a78..fb16a1ae 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -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 { + return new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) +} + export async function exportSceneToGlb( sceneGroup: Object3D, nodes: Record, @@ -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 ------------------------------------------------- diff --git a/packages/nodes/src/zone/renderer.tsx b/packages/nodes/src/zone/renderer.tsx index d72a11eb..4f2ae3b7 100644 --- a/packages/nodes/src/zone/renderer.tsx +++ b/packages/nodes/src/zone/renderer.tsx @@ -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 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 ( - -
-
- {node.name} -
-
+
-
-
-
- + > +
+ {node.name} +
+
+
+
+
+
+ - {/* Floor fill */} - - - + {/* Floor fill */} + + + - {/* Wall borders with gradient */} - + {/* Wall borders with gradient */} + + + )} ) } diff --git a/packages/viewer/src/components/viewer/scene-environment.tsx b/packages/viewer/src/components/viewer/scene-environment.tsx index 1834c8f3..c17df444 100644 --- a/packages/viewer/src/components/viewer/scene-environment.tsx +++ b/packages/viewer/src/components/viewer/scene-environment.tsx @@ -12,11 +12,16 @@ import { Suspense } from 'react' * do alone. Intensity is dialled below the preset default so it complements * the scene lights rather than washing them out. Only visible in `rendered` * 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() { return ( - + ) } diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index a2bd4ba6..79b52757 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -87,6 +87,14 @@ type ViewerState = { showGrid: boolean setShowGrid: (show: boolean) => void + // Presentation flag for parametric zones. When false the zone renderer + // unmounts its meshes AND its drei label (an 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 setTransparentBackground: (transparent: boolean) => void @@ -308,6 +316,9 @@ const useViewer = create()( return { showGrid: show, projectPreferences } }), + showZones: true, + setShowZones: (show) => set({ showZones: show }), + transparentBackground: false, setTransparentBackground: (transparent) => set({ transparentBackground: transparent }),