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:
co-authored by
Claude Fable 5
parent
51fddc2d9c
commit
717c2c5c0a
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')
|
||||
})
|
||||
})
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from '../schema/nodes/stair'
|
||||
import { StairSegmentNode as StairSegmentNodeSchema } from '../schema/nodes/stair-segment'
|
||||
import { getEffectiveWallSurfaceMaterial, type WallSurfaceSide } from '../schema/nodes/wall'
|
||||
import { WindowNode as WindowNodeSchema } from '../schema/nodes/window'
|
||||
import {
|
||||
generateSceneMaterialId,
|
||||
type SceneMaterial,
|
||||
@@ -132,6 +133,14 @@ function normalizeDoorNode(node: Record<string, unknown>) {
|
||||
return parsed.success ? { ...node, ...parsed.data } : null
|
||||
}
|
||||
|
||||
// Windows saved before a schema field existed (e.g. `columnRatios`/`rowRatios`/
|
||||
// `frameThickness`) load without it; the mesh builder then reads undefined and
|
||||
// throws every frame. Zod-parse on load so schema defaults land, like doors.
|
||||
function normalizeWindowNode(node: Record<string, unknown>) {
|
||||
const parsed = WindowNodeSchema.safeParse(node)
|
||||
return parsed.success ? { ...node, ...parsed.data } : null
|
||||
}
|
||||
|
||||
function normalizeShelfNode(node: Record<string, unknown>) {
|
||||
const sanitized = {
|
||||
...node,
|
||||
@@ -640,6 +649,13 @@ function migrateNodes(nodes: Record<string, any>): {
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'window') {
|
||||
const normalized = normalizeWindowNode(node)
|
||||
if (normalized) {
|
||||
patchedNodes[id] = normalized
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'stair') {
|
||||
const normalized = normalizeStairNode(migrateStairSurfaceMaterials(node))
|
||||
if (normalized) {
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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 -------------------------------------------------
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<Suspense fallback={null}>
|
||||
<Environment preset="sunset" environmentIntensity={0.6} />
|
||||
<Environment environmentIntensity={0.6} files="/hdri/venice_sunset_1k.hdr" />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 <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
|
||||
setTransparentBackground: (transparent: boolean) => void
|
||||
|
||||
@@ -308,6 +316,9 @@ const useViewer = create<ViewerState>()(
|
||||
return { showGrid: show, projectPreferences }
|
||||
}),
|
||||
|
||||
showZones: true,
|
||||
setShowZones: (show) => set({ showZones: show }),
|
||||
|
||||
transparentBackground: false,
|
||||
setTransparentBackground: (transparent) => set({ transparentBackground: transparent }),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user