diff --git a/packages/core/package.json b/packages/core/package.json index 7419a884..ea949cd3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -10,6 +10,11 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js", "default": "./dist/index.js" + }, + "./clone-scene-graph": { + "types": "./dist/utils/clone-scene-graph.d.ts", + "import": "./dist/utils/clone-scene-graph.js", + "default": "./dist/utils/clone-scene-graph.js" } }, "files": [ diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index 09eab853..d6b2c0f0 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -1,3 +1,5 @@ +'use client' + import { useLayoutEffect } from 'react' import type * as THREE from 'three' diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5a37f19d..98f66d5a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,3 @@ -// Store - export type { BuildingEvent, CameraControlEvent, @@ -20,9 +18,7 @@ export type { WindowEvent, ZoneEvent, } from './events/bus' -// Events export { emitter, eventSuffixes } from './events/bus' -// Hooks export { sceneRegistry, useRegistry, @@ -33,24 +29,22 @@ export { resolveLevelId, } from './hooks/spatial-grid/spatial-grid-sync' export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' -// Asset storage export { loadAssetUrl, saveAsset } from './lib/asset-storage' -// Space detection export { detectSpacesForLevel, initSpaceDetectionSync, type Space, wallTouchesOthers, } from './lib/space-detection' -// Schema +export { baseMaterial, glassMaterial } from './materials' export * from './schema' export { type ControlValue, type ItemInteractiveState, useInteractive, } from './store/use-interactive' +export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' export { clearSceneHistory, default as useScene } from './store/use-scene' -// Systems export { CeilingSystem } from './systems/ceiling/ceiling-system' export { DoorSystem } from './systems/door/door-system' export { ItemSystem } from './systems/item/item-system' @@ -71,5 +65,6 @@ export { } from './systems/wall/wall-mitering' export { WallSystem } from './systems/wall/wall-system' export { WindowSystem } from './systems/window/window-system' +export type { SceneGraph } from './utils/clone-scene-graph' export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph' export { isObject } from './utils/types' diff --git a/packages/core/src/materials.ts b/packages/core/src/materials.ts new file mode 100644 index 00000000..089d2677 --- /dev/null +++ b/packages/core/src/materials.ts @@ -0,0 +1,24 @@ +import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu' + +/** + * Shared base material for structural elements: walls, frames, slabs, roof. + */ +export const baseMaterial = new MeshStandardNodeMaterial({ + color: '#f2f0ed', + roughness: 0.5, + metalness: 0, +}) + +/** + * Shared glass material for windows, glazed door panels, and glass items. + */ +export const glassMaterial = new MeshStandardNodeMaterial({ + name: 'glass', + color: 'lightblue', + roughness: 0.05, + metalness: 0.1, + transparent: true, + opacity: 0.35, + side: DoubleSide, + depthWrite: false, +}) diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 1430dd4a..927c48a5 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -1,5 +1,5 @@ // Base -export { BaseNode, generateId, nodeType, objectId } from './base' +export { BaseNode, generateId, Material, nodeType, objectId } from './base' // Camera export { CameraSchema } from './camera' // Collections diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts index 82c2a1af..6078f4fd 100644 --- a/packages/core/src/store/actions/node-actions.ts +++ b/packages/core/src/store/actions/node-actions.ts @@ -64,7 +64,6 @@ export const updateNodesAction = ( ) => { if (get().readOnly) return const parentsToUpdate = new Set() - const idsToMarkDirty = new Set() set((state) => { const nextNodes = { ...state.nodes } @@ -105,26 +104,19 @@ export const updateNodesAction = ( return { nodes: nextNodes } }) - // Collect all IDs that need to be marked dirty + // Batch dirty-marking into a single RAF to avoid redundant callbacks during rapid updates for (const u of updates) { - idsToMarkDirty.add(u.id) + pendingUpdates.add(u.id) } for (const pId of parentsToUpdate) { - idsToMarkDirty.add(pId) + pendingUpdates.add(pId) } - // Add to pending updates set - for (const id of idsToMarkDirty) { - pendingUpdates.add(id) - } - - // Cancel any pending RAF and schedule a new one if (pendingRafId !== null) { cancelAnimationFrame(pendingRafId) } pendingRafId = requestAnimationFrame(() => { - // Mark all pending updates as dirty pendingUpdates.forEach((id) => { get().markDirty(id) }) @@ -146,32 +138,26 @@ export const deleteNodesAction = ( const nextCollections = { ...state.collections } let nextRootIds = [...state.rootNodeIds] - // Collect all IDs to delete (including descendants) in a first pass - // This avoids issues with recursive calls during state mutation - const allIdsToDelete = new Set() - const collectDescendants = (id: AnyNodeId) => { + // Collect all ids to delete (the requested ids + all their descendants) before + // mutating anything, so the recursive walk reads consistent state. + const allIds = new Set() + const collect = (id: AnyNodeId) => { + if (allIds.has(id)) return + allIds.add(id) const node = nextNodes[id] - if (!node) return - allIdsToDelete.add(id) - if ('children' in node && node.children) { - for (const childId of node.children as AnyNodeId[]) { - collectDescendants(childId) - } + if (node && 'children' in node) { + for (const cid of node.children as AnyNodeId[]) collect(cid) } } + for (const id of ids) collect(id) - for (const id of ids) { - collectDescendants(id) - } - - // Now process all nodes for deletion - for (const id of allIdsToDelete) { + for (const id of allIds) { const node = nextNodes[id] if (!node) continue - // 1. Remove reference from Parent + // 1. Remove reference from parent — only if the parent itself is NOT also being deleted const parentId = node.parentId as AnyNodeId | null - if (parentId && nextNodes[parentId]) { + if (parentId && nextNodes[parentId] && !allIds.has(parentId)) { const parent = nextNodes[parentId] as AnyContainerNode if (parent.children) { nextNodes[parent.id] = { @@ -182,7 +168,7 @@ export const deleteNodesAction = ( } } - // 2. Remove from Root list + // 2. Remove from root list nextRootIds = nextRootIds.filter((rid) => rid !== id) // 3. Remove from any collections it belongs to diff --git a/packages/core/src/store/use-live-transforms.ts b/packages/core/src/store/use-live-transforms.ts new file mode 100644 index 00000000..b2aef7dd --- /dev/null +++ b/packages/core/src/store/use-live-transforms.ts @@ -0,0 +1,38 @@ +// Ephemeral live transform state for nodes being actively dragged/moved. +// This decouples 2D (floorplan) and 3D (viewer) so neither needs to peek +// into the other's scene graph during drag operations. + +import { create } from 'zustand' + +export type LiveTransform = { + position: [number, number, number] + rotation: number // Y-axis rotation (plan-view rotation) +} + +type LiveTransformState = { + transforms: Map + set(nodeId: string, transform: LiveTransform): void + get(nodeId: string): LiveTransform | undefined + clear(nodeId: string): void + clearAll(): void +} + +const useLiveTransforms = create((set, get) => ({ + transforms: new Map(), + set: (nodeId, transform) => + set((state) => { + const next = new Map(state.transforms) + next.set(nodeId, transform) + return { transforms: next } + }), + get: (nodeId) => get().transforms.get(nodeId), + clear: (nodeId) => + set((state) => { + const next = new Map(state.transforms) + next.delete(nodeId) + return { transforms: next } + }), + clearAll: () => set({ transforms: new Map() }), +})) + +export default useLiveTransforms diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index f55fac59..d9cdc2d2 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -123,11 +123,6 @@ const useScene: UseSceneStore = create()( setReadOnly: (readOnly: boolean) => set({ readOnly }), unloadScene: () => { - // Clear temporal tracking to prevent memory leaks from stale node references - prevPastLength = 0 - prevFutureLength = 0 - prevNodesSnapshot = null - set({ nodes: {}, rootNodeIds: [], @@ -145,14 +140,29 @@ const useScene: UseSceneStore = create()( // Apply backward compatibility migrations const patchedNodes = migrateNodes(nodes) + // Remove orphans: nodes whose parentId points to a non-existent node + const cleanedNodes = { ...patchedNodes } + for (const node of Object.values(cleanedNodes)) { + if (node.parentId && !cleanedNodes[node.parentId]) { + console.warn( + '[Scene] Removing orphan node', + node.id, + '(parentId', + node.parentId, + 'not found)', + ) + delete cleanedNodes[node.id] + } + } + set({ - nodes: patchedNodes, + nodes: cleanedNodes, rootNodeIds, dirtyNodes: new Set(), collections: {}, }) // Mark all nodes as dirty to trigger re-validation - Object.values(patchedNodes).forEach((node) => { + Object.values(cleanedNodes).forEach((node) => { get().markDirty(node.id) }) }, @@ -292,7 +302,7 @@ const useScene: UseSceneStore = create()( if (!col) return state const nextCollections = { ...state.collections, - [id]: { ...col, nodeIds: col.nodeIds.filter((n: AnyNodeId) => n !== nodeId) }, + [id]: { ...col, nodeIds: col.nodeIds.filter((n) => n !== nodeId) }, } const node = state.nodes[nodeId] if (!(node && 'collectionIds' in node)) return { collections: nextCollections } @@ -324,21 +334,13 @@ let prevPastLength = 0 let prevFutureLength = 0 let prevNodesSnapshot: Record | null = null -/** - * Clears temporal history tracking variables to prevent memory leaks. - * Should be called when unloading a scene to release node references. - */ -export function clearTemporalTracking() { +export function clearSceneHistory() { + useScene.temporal.getState().clear() prevPastLength = 0 prevFutureLength = 0 prevNodesSnapshot = null } -export function clearSceneHistory() { - useScene.temporal.getState().clear() - clearTemporalTracking() -} - // Subscribe to the temporal store (Undo/Redo events) useScene.temporal.subscribe((state) => { const currentPastLength = state.pastStates.length diff --git a/packages/core/src/systems/door/door-system.tsx b/packages/core/src/systems/door/door-system.tsx index 7304e3c2..24dacaa1 100644 --- a/packages/core/src/systems/door/door-system.tsx +++ b/packages/core/src/systems/door/door-system.tsx @@ -1,28 +1,10 @@ import { useFrame } from '@react-three/fiber' import * as THREE from 'three' -import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu' import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' +import { baseMaterial, glassMaterial } from '../../materials' import type { AnyNodeId, DoorNode } from '../../schema' import useScene from '../../store/use-scene' -const baseMaterial = new MeshStandardNodeMaterial({ - name: 'door-base', - color: '#f2f0ed', - roughness: 0.5, - metalness: 0, -}) - -const glassMaterial = new MeshStandardNodeMaterial({ - name: 'door-glass', - color: 'lightblue', - roughness: 0.05, - metalness: 0.1, - transparent: true, - opacity: 0.35, - side: DoubleSide, - depthWrite: false, -}) - // Invisible material for root mesh — used as selection hitbox only const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) diff --git a/packages/core/src/systems/roof/roof-system.tsx b/packages/core/src/systems/roof/roof-system.tsx index 45184b49..0e23364b 100644 --- a/packages/core/src/systems/roof/roof-system.tsx +++ b/packages/core/src/systems/roof/roof-system.tsx @@ -10,8 +10,15 @@ import useScene from '../../store/use-scene' const csgEvaluator = new Evaluator() csgEvaluator.useGroups = true +;(csgEvaluator as any).consolidateGroups = false // shared dummyMats across brushes causes consolidation to misalign groupIndices vs groupOrder indices → crash csgEvaluator.attributes = ['position', 'normal'] +function prepareBrushForCSG(brush: Brush) { + brush.geometry.computeBoundsTree = computeBoundsTree + brush.geometry.computeBoundsTree({ maxLeafSize: 10 }) + brush.updateMatrixWorld() +} + // Pooled objects to avoid per-frame allocation in updateMergedRoofGeometry const _matrix = new THREE.Matrix4() const _position = new THREE.Vector3() @@ -78,6 +85,8 @@ export const RoofSystem = () => { mesh.rotation.y = node.rotation } clearDirty(id as AnyNodeId) + } else { + clearDirty(id as AnyNodeId) } // Queue the parent roof for a merged geometry update if (node.parentId) { @@ -179,6 +188,7 @@ function updateMergedRoofGeometry( const next: Brush = csgEvaluator.evaluate(totalShinSlab, brushes.shinSlab, ADDITION) as Brush totalShinSlab.geometry.dispose() brushes.shinSlab.geometry.dispose() + prepareBrushForCSG(next) totalShinSlab = next } else { totalShinSlab = brushes.shinSlab @@ -188,6 +198,7 @@ function updateMergedRoofGeometry( const next: Brush = csgEvaluator.evaluate(totalDeckSlab, brushes.deckSlab, ADDITION) as Brush totalDeckSlab.geometry.dispose() brushes.deckSlab.geometry.dispose() + prepareBrushForCSG(next) totalDeckSlab = next } else { totalDeckSlab = brushes.deckSlab @@ -197,6 +208,7 @@ function updateMergedRoofGeometry( const next: Brush = csgEvaluator.evaluate(totalWall, brushes.wallBrush, ADDITION) as Brush totalWall.geometry.dispose() brushes.wallBrush.geometry.dispose() + prepareBrushForCSG(next) totalWall = next } else { totalWall = brushes.wallBrush @@ -206,6 +218,7 @@ function updateMergedRoofGeometry( const next: Brush = csgEvaluator.evaluate(totalInner, brushes.innerBrush, ADDITION) as Brush totalInner.geometry.dispose() brushes.innerBrush.geometry.dispose() + prepareBrushForCSG(next) totalInner = next } else { totalInner = brushes.innerBrush @@ -505,6 +518,10 @@ export function getRoofSegmentBrushes( const toBrush = (geo: THREE.BufferGeometry): Brush | null => { if (!geo?.attributes.position || geo.attributes.position.count === 0) return null if (!geo.index) return null + // Strip zero-count groups — three-bvh-csg crashes with groupIndices[i] undefined + // when a group exists but covers no triangles (can happen after mergeVertices) + geo.groups = geo.groups.filter((g) => g.count > 0) + if (geo.groups.length === 0) return null geo.computeBoundsTree = computeBoundsTree geo.computeBoundsTree({ maxLeafSize: 10 }) const brush = new Brush(geo, dummyMats) diff --git a/packages/core/src/systems/slab/slab-system.tsx b/packages/core/src/systems/slab/slab-system.tsx index 4dfe7489..8e43df7b 100644 --- a/packages/core/src/systems/slab/slab-system.tsx +++ b/packages/core/src/systems/slab/slab-system.tsx @@ -42,6 +42,11 @@ function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) { mesh.geometry.dispose() mesh.geometry = newGeo + + // For negative elevation, shift the mesh down so the top face sits at Y=elevation + // rather than at Y=0. Positive elevation stays at Y=0 (slab sits at floor level). + const elevation = node.elevation ?? 0.05 + mesh.position.y = elevation < 0 ? elevation : 0 } /** Half of default wall thickness — used to extend slab geometry under walls */ @@ -102,54 +107,94 @@ function outsetPolygon(polygon: Array<[number, number]>, amount: number): Array< * Generates extruded slab geometry from polygon */ export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry { + const elevation = slabNode.elevation ?? 0.05 + return elevation < 0 ? generatePoolGeometry(slabNode) : generatePositiveSlabGeometry(slabNode) +} + +/** + * Standard slab: flat extrusion upward from Y=0 by elevation thickness. + */ +function generatePositiveSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry { const polygon = outsetPolygon(slabNode.polygon, SLAB_OUTSET) const elevation = slabNode.elevation ?? 0.05 - if (polygon.length < 3) { - return new THREE.BufferGeometry() - } + if (polygon.length < 3) return new THREE.BufferGeometry() - // Create shape from polygon - // Shape is in X-Y plane, we'll rotate to X-Z plane after extrusion const shape = new THREE.Shape() - const firstPt = polygon[0]! - - // Negate Y (which becomes Z) to get correct orientation after rotation - shape.moveTo(firstPt[0], -firstPt[1]) - - for (let i = 1; i < polygon.length; i++) { - const pt = polygon[i]! - shape.lineTo(pt[0], -pt[1]) - } + shape.moveTo(polygon[0]![0], -polygon[0]![1]) + for (let i = 1; i < polygon.length; i++) shape.lineTo(polygon[i]![0], -polygon[i]![1]) shape.closePath() - // Add holes to the shape - const holes = slabNode.holes || [] - for (const holePolygon of holes) { + for (const holePolygon of slabNode.holes ?? []) { if (holePolygon.length < 3) continue - const holePath = new THREE.Path() - const holeFirstPt = holePolygon[0]! - holePath.moveTo(holeFirstPt[0], -holeFirstPt[1]) - - for (let i = 1; i < holePolygon.length; i++) { - const pt = holePolygon[i]! - holePath.lineTo(pt[0], -pt[1]) - } + holePath.moveTo(holePolygon[0]![0], -holePolygon[0]![1]) + for (let i = 1; i < holePolygon.length; i++) + holePath.lineTo(holePolygon[i]![0], -holePolygon[i]![1]) holePath.closePath() - shape.holes.push(holePath) } - // Extrude the shape by elevation - const geometry = new THREE.ExtrudeGeometry(shape, { - depth: elevation, - bevelEnabled: false, - }) - - // Rotate so extrusion direction (Z) becomes height direction (Y) + const geometry = new THREE.ExtrudeGeometry(shape, { depth: elevation, bevelEnabled: false }) geometry.rotateX(-Math.PI / 2) geometry.computeVertexNormals() - return geometry } + +/** + * Pool / recessed slab: floor cap at Y=0 (local) + inner walls up to Y=|elevation|. + * No top cap — the opening at ground level is handled by the ground occluder hole. + * mesh.position.y must be set to elevation so the floor sits at the correct world Y. + * + * Geometry is built directly in 3D (Y-up) to avoid rotation confusion: + * - floor in XZ plane at Y=0, normals pointing +Y (visible when looking down into pool) + * - walls from Y=0 to Y=depth, inward-facing normals (visible from inside pool) + */ +function generatePoolGeometry(slabNode: SlabNode): THREE.BufferGeometry { + const polygon = outsetPolygon(slabNode.polygon, SLAB_OUTSET) + const depth = Math.abs(slabNode.elevation ?? 0.05) + + if (polygon.length < 3) return new THREE.BufferGeometry() + + const positions: number[] = [] + const indices: number[] = [] + const n = polygon.length + + // --- Floor at Y=0 --- + for (const [x, z] of polygon) positions.push(x!, 0, z!) + + const pts2d = polygon.map(([x, z]) => new THREE.Vector2(x!, z!)) + const holesPts2d = (slabNode.holes ?? []).map((h) => h.map(([x, z]) => new THREE.Vector2(x!, z!))) + for (const hole of slabNode.holes ?? []) { + for (const [x, z] of hole) positions.push(x!, 0, z!) + } + + const floorTris = THREE.ShapeUtils.triangulateShape(pts2d, holesPts2d) + for (const tri of floorTris) { + // Reversed winding → normals point +Y (upward) in XZ plane + indices.push(tri[0]!, tri[2]!, tri[1]!) + } + + // --- Inner walls (no top cap at Y=depth) --- + // Standard winding on a CCW polygon in XZ gives inward-facing normals. + for (let i = 0; i < n; i++) { + const j = (i + 1) % n + const [x0, z0] = polygon[i]! + const [x1, z1] = polygon[j]! + const vBase = positions.length / 3 + + positions.push(x0!, 0, z0!) // v0 — floor level + positions.push(x1!, 0, z1!) // v1 — floor level + positions.push(x1!, depth, z1!) // v2 — ground level + positions.push(x0!, depth, z0!) // v3 — ground level + + indices.push(vBase, vBase + 1, vBase + 2) + indices.push(vBase, vBase + 2, vBase + 3) + } + + const geo = new THREE.BufferGeometry() + geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) + geo.setIndex(indices) + geo.computeVertexNormals() + return geo +} diff --git a/packages/core/src/systems/wall/wall-system.tsx b/packages/core/src/systems/wall/wall-system.tsx index 2147edc1..50c60e4b 100644 --- a/packages/core/src/systems/wall/wall-system.tsx +++ b/packages/core/src/systems/wall/wall-system.tsx @@ -22,6 +22,7 @@ const csgEvaluator = new Evaluator() // WALL SYSTEM // ============================================================================ +let useFrameNb = 0 export const WallSystem = () => { const dirtyNodes = useScene((state) => state.dirtyNodes) const clearDirty = useScene((state) => state.clearDirty) @@ -34,6 +35,7 @@ export const WallSystem = () => { // Collect dirty walls and their levels const dirtyWallsByLevel = new Map>() + useFrameNb += 1 dirtyNodes.forEach((id) => { const node = nodes[id] if (!node || node.type !== 'wall') return diff --git a/packages/core/src/systems/window/window-system.tsx b/packages/core/src/systems/window/window-system.tsx index 38e1aa8e..d27e2857 100644 --- a/packages/core/src/systems/window/window-system.tsx +++ b/packages/core/src/systems/window/window-system.tsx @@ -1,28 +1,10 @@ import { useFrame } from '@react-three/fiber' import * as THREE from 'three' -import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu' import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' +import { baseMaterial, glassMaterial } from '../../materials' import type { AnyNodeId, WindowNode } from '../../schema' import useScene from '../../store/use-scene' -const glassMaterial = new MeshStandardNodeMaterial({ - name: 'glass', - color: 'lightblue', - roughness: 0.05, - metalness: 0.1, - transparent: true, - opacity: 0.3, - side: DoubleSide, - depthWrite: false, -}) - -const frameMaterial = new MeshStandardNodeMaterial({ - name: 'window-frame', - color: '#e8e8e8', - roughness: 0.6, - metalness: 0, -}) - // Invisible material for root mesh — used as selection hitbox only const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) @@ -108,7 +90,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { // Top / bottom — full width addBox( mesh, - frameMaterial, + baseMaterial, width, frameThickness, frameDepth, @@ -118,7 +100,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { ) addBox( mesh, - frameMaterial, + baseMaterial, width, frameThickness, frameDepth, @@ -129,7 +111,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { // Left / right — inner height to avoid corner overlap addBox( mesh, - frameMaterial, + baseMaterial, frameThickness, innerH, frameDepth, @@ -139,7 +121,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { ) addBox( mesh, - frameMaterial, + baseMaterial, frameThickness, innerH, frameDepth, @@ -184,7 +166,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { cx += colWidths[c]! addBox( mesh, - frameMaterial, + baseMaterial, columnDividerThickness, innerH, frameDepth, @@ -203,7 +185,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { for (let c = 0; c < numCols; c++) { addBox( mesh, - frameMaterial, + baseMaterial, colWidths[c]!, rowDividerThickness, frameDepth, @@ -239,7 +221,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) { const sillZ = frameDepth / 2 + sillDepth / 2 addBox( mesh, - frameMaterial, + baseMaterial, sillW, sillThickness, sillDepth, diff --git a/packages/core/src/utils/clone-scene-graph.ts b/packages/core/src/utils/clone-scene-graph.ts index 87111aed..3776e1d4 100644 --- a/packages/core/src/utils/clone-scene-graph.ts +++ b/packages/core/src/utils/clone-scene-graph.ts @@ -21,8 +21,8 @@ function extractIdPrefix(id: string): string { * parent-child relationships and other internal references. * * This is useful for: + * - Duplicating a project (host app creates a new project record, then loads the cloned scene) * - Copying nodes between different projects - * - Duplicating a subset of a scene within the same project * - Multi-scene in-memory scenarios */ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { @@ -42,7 +42,6 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { for (const [oldId, node] of Object.entries(nodes)) { const newId = idMap.get(oldId)! as AnyNodeId - // structuredClone to avoid shared references between original and clone const clonedNode = structuredClone({ ...node, id: newId }) as AnyNode // Remap parentId @@ -50,10 +49,23 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { clonedNode.parentId = (idMap.get(clonedNode.parentId) ?? null) as AnyNodeId | null } - // Remap children array (walls, levels, buildings, sites, items can have children) + // Remap children array (buildings, levels, walls, items, etc.) + // Children can be either string IDs or embedded node objects (with an `id` property). + // Normalize both forms to remapped string IDs. if ('children' in clonedNode && Array.isArray(clonedNode.children)) { - ;(clonedNode as Record).children = (clonedNode.children as string[]) - .map((childId) => idMap.get(childId)) + ;(clonedNode as Record).children = (clonedNode.children as unknown[]) + .map((child) => { + if (typeof child === 'string') return idMap.get(child) + if ( + child && + typeof child === 'object' && + 'id' in child && + typeof (child as any).id === 'string' + ) { + return idMap.get((child as any).id) + } + return undefined + }) .filter((id): id is string => id !== undefined) } @@ -78,7 +90,6 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { clonedCollections = {} as Record const collectionIdMap = new Map() - // Generate new collection IDs for (const collectionId of Object.keys(collections)) { collectionIdMap.set(collectionId, generateId('collection')) } @@ -166,6 +177,9 @@ export function cloneLevelSubtree( const newLevelId = idMap.get(levelId)! as AnyNodeId // Clone each node with remapped references. + // Use JSON roundtrip instead of structuredClone because live runtime nodes may + // carry non-serializable properties (Three.js Object3D refs, functions, etc.) + // that structuredClone would throw on. const clonedNodes: AnyNode[] = [] for (const oldId of subtreeIds) { const node = nodes[oldId] @@ -178,6 +192,7 @@ export function cloneLevelSubtree( ;(cloned as Record).id = newId // Remap parentId — but only for descendants, not the level node itself + // (the level's parentId points to the building, which is outside the subtree) if (oldId !== levelId && cloned.parentId && typeof cloned.parentId === 'string') { cloned.parentId = (idMap.get(cloned.parentId) ?? cloned.parentId) as AnyNodeId | null } @@ -219,6 +234,7 @@ export function cloneLevelSubtree( export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph { const { nodes, rootNodeIds, collections } = sceneGraph + // First, identify scan and guide node IDs to exclude (user-uploaded imagery) const excludedNodeIds = new Set() for (const [nodeId, node] of Object.entries(nodes)) { if (node.type === 'scan' || node.type === 'guide') { @@ -226,12 +242,15 @@ export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph { } } + // Build a filtered scene graph without scan nodes const filteredNodes = {} as Record for (const [nodeId, node] of Object.entries(nodes)) { if (excludedNodeIds.has(nodeId)) continue const clonedNode = structuredClone(node) as AnyNode + // Remove scan children from any parent that references them. + // Children can be string IDs or embedded node objects. if ('children' in clonedNode && Array.isArray(clonedNode.children)) { ;(clonedNode as Record).children = (clonedNode.children as unknown[]).filter( (child) => { @@ -251,6 +270,7 @@ export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph { const filteredRootNodeIds = rootNodeIds.filter((id) => !excludedNodeIds.has(id)) + // Filter collections to remove references to scan nodes let filteredCollections: Record | undefined if (collections) { filteredCollections = {} as Record @@ -269,6 +289,7 @@ export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph { } } + // Now clone the filtered graph with new IDs return cloneSceneGraph({ nodes: filteredNodes, rootNodeIds: filteredRootNodeIds, diff --git a/packages/editor/package.json b/packages/editor/package.json index 1606a7ed..bc97aa50 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -4,7 +4,8 @@ "description": "Pascal building editor component", "type": "module", "exports": { - ".": "./src/index.tsx" + ".": "./src/index.tsx", + "./catalog": "./src/components/ui/item-catalog/catalog-items.tsx" }, "scripts": { "check-types": "tsc --noEmit" diff --git a/packages/editor/src/components/editor/custom-camera-controls.tsx b/packages/editor/src/components/editor/custom-camera-controls.tsx index 4744187a..0bee7583 100644 --- a/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -1,387 +1,387 @@ -'use client' - -import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core' -import { useViewer, ZONE_LAYER } from '@pascal-app/viewer' -import { CameraControls, CameraControlsImpl } from '@react-three/drei' -import { useThree } from '@react-three/fiber' -import { useCallback, useEffect, useMemo, useRef } from 'react' -import { Box3, Vector3 } from 'three' -import { EDITOR_LAYER } from '../../lib/constants' -import useEditor from '../../store/use-editor' - -const currentTarget = new Vector3() -const tempBox = new Box3() -const tempCenter = new Vector3() -const tempDelta = new Vector3() -const tempPosition = new Vector3() -const tempSize = new Vector3() -const tempTarget = new Vector3() -const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1 -const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05 - -export const CustomCameraControls = () => { - const controls = useRef(null!) - const isPreviewMode = useEditor((s) => s.isPreviewMode) - const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) - const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera) - const selection = useViewer((s) => s.selection) - const currentLevelId = selection.levelId - const firstLoad = useRef(true) - const maxPolarAngle = - !isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE - - const camera = useThree((state) => state.camera) - const raycaster = useThree((state) => state.raycaster) - useEffect(() => { - camera.layers.enable(EDITOR_LAYER) - raycaster.layers.enable(EDITOR_LAYER) - raycaster.layers.enable(ZONE_LAYER) - }, [camera, raycaster]) - - useEffect(() => { - if (isPreviewMode || isFirstPersonMode) return - let targetY = 0 - if (currentLevelId) { - const levelMesh = sceneRegistry.nodes.get(currentLevelId) - if (levelMesh) { - targetY = levelMesh.position.y - } - } - if (firstLoad.current) { - firstLoad.current = false - ;(controls.current as CameraControlsImpl).setLookAt(20, 20, 20, 0, 0, 0, true) - } - ;(controls.current as CameraControlsImpl).getTarget(currentTarget) - ;(controls.current as CameraControlsImpl).moveTo( - currentTarget.x, - targetY, - currentTarget.z, - true, - ) - }, [currentLevelId, isPreviewMode, isFirstPersonMode]) - - useEffect(() => { - if (!controls.current || isFirstPersonMode) return - - controls.current.maxPolarAngle = maxPolarAngle - controls.current.minPolarAngle = 0 - - if (controls.current.polarAngle > maxPolarAngle) { - controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true) - } - }, [maxPolarAngle, isFirstPersonMode]) - - const focusNode = useCallback( - (nodeId: string) => { - if (isPreviewMode || !controls.current) return - - const object3D = sceneRegistry.nodes.get(nodeId) - if (!object3D) return - - tempBox.setFromObject(object3D) - if (tempBox.isEmpty()) return - - tempBox.getCenter(tempCenter) - controls.current.getPosition(tempPosition) - controls.current.getTarget(tempTarget) - tempDelta.copy(tempCenter).sub(tempTarget) - - controls.current.setLookAt( - tempPosition.x + tempDelta.x, - tempPosition.y + tempDelta.y, - tempPosition.z + tempDelta.z, - tempCenter.x, - tempCenter.y, - tempCenter.z, - true, - ) - }, - [isPreviewMode], - ) - - // Configure mouse buttons based on control mode and camera mode - const cameraMode = useViewer((state) => state.cameraMode) - const mouseButtons = useMemo(() => { - // Use ZOOM for orthographic camera, DOLLY for perspective camera - const wheelAction = - cameraMode === 'orthographic' - ? CameraControlsImpl.ACTION.ZOOM - : CameraControlsImpl.ACTION.DOLLY - - return { - left: isPreviewMode ? CameraControlsImpl.ACTION.SCREEN_PAN : CameraControlsImpl.ACTION.NONE, - middle: CameraControlsImpl.ACTION.SCREEN_PAN, - right: CameraControlsImpl.ACTION.ROTATE, - wheel: wheelAction, - } - }, [cameraMode, isPreviewMode]) - - useEffect(() => { - if (isFirstPersonMode) return - - const keyState = { - shiftRight: false, - shiftLeft: false, - controlRight: false, - controlLeft: false, - space: false, - } - - const updateConfig = () => { - if (!controls.current) return - - const shift = keyState.shiftRight || keyState.shiftLeft - const control = keyState.controlRight || keyState.controlLeft - const space = keyState.space - - const wheelAction = - cameraMode === 'orthographic' - ? CameraControlsImpl.ACTION.ZOOM - : CameraControlsImpl.ACTION.DOLLY - controls.current.mouseButtons.wheel = wheelAction - controls.current.mouseButtons.middle = CameraControlsImpl.ACTION.SCREEN_PAN - controls.current.mouseButtons.right = CameraControlsImpl.ACTION.ROTATE - if (isPreviewMode) { - // In preview mode, left-click is always pan (viewer-style) - controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN - } else if (space) { - controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN - } else { - controls.current.mouseButtons.left = CameraControlsImpl.ACTION.NONE - } - } - - const onKeyDown = (event: KeyboardEvent) => { - if (event.code === 'Space') { - keyState.space = true - document.body.style.cursor = 'grab' - } - if (event.code === 'ShiftRight') { - keyState.shiftRight = true - } - if (event.code === 'ShiftLeft') { - keyState.shiftLeft = true - } - if (event.code === 'ControlRight') { - keyState.controlRight = true - } - if (event.code === 'ControlLeft') { - keyState.controlLeft = true - } - updateConfig() - } - - const onKeyUp = (event: KeyboardEvent) => { - if (event.code === 'Space') { - keyState.space = false - document.body.style.cursor = '' - } - if (event.code === 'ShiftRight') { - keyState.shiftRight = false - } - if (event.code === 'ShiftLeft') { - keyState.shiftLeft = false - } - if (event.code === 'ControlRight') { - keyState.controlRight = false - } - if (event.code === 'ControlLeft') { - keyState.controlLeft = false - } - updateConfig() - } - - document.addEventListener('keydown', onKeyDown) - document.addEventListener('keyup', onKeyUp) - updateConfig() - - return () => { - document.removeEventListener('keydown', onKeyDown) - document.removeEventListener('keyup', onKeyUp) - } - }, [cameraMode, isPreviewMode, isFirstPersonMode]) - - // Preview mode: auto-navigate camera to selected node (viewer behavior) - const previewTargetNodeId = isPreviewMode - ? (selection.zoneId ?? selection.levelId ?? selection.buildingId) - : null - - useEffect(() => { - if (!(isPreviewMode && controls.current)) return - - const nodes = useScene.getState().nodes - let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null - - if (!previewTargetNodeId) { - const site = Object.values(nodes).find((n) => n.type === 'site') - node = site || null - } - if (!node) return - - // Check if node has a saved camera - if (node.camera) { - const { position, target } = node.camera - requestAnimationFrame(() => { - if (!controls.current) return - controls.current.setLookAt( - position[0], - position[1], - position[2], - target[0], - target[1], - target[2], - true, - ) - }) - return - } - - if (!previewTargetNodeId) return - - // Calculate camera position from bounding box - const object3D = sceneRegistry.nodes.get(previewTargetNodeId) - if (!object3D) return - - tempBox.setFromObject(object3D) - tempBox.getCenter(tempCenter) - tempBox.getSize(tempSize) - - const maxDim = Math.max(tempSize.x, tempSize.y, tempSize.z) - const distance = Math.max(maxDim * 2, 15) - - controls.current.setLookAt( - tempCenter.x + distance * 0.7, - tempCenter.y + distance * 0.5, - tempCenter.z + distance * 0.7, - tempCenter.x, - tempCenter.y, - tempCenter.z, - true, - ) - }, [isPreviewMode, previewTargetNodeId]) - - useEffect(() => { - if (isFirstPersonMode) return - - const handleNodeCapture = ({ nodeId }: CameraControlEvent) => { - if (!controls.current) return - - const position = new Vector3() - const target = new Vector3() - controls.current.getPosition(position) - controls.current.getTarget(target) - - const state = useScene.getState() - - state.updateNode(nodeId, { - camera: { - position: [position.x, position.y, position.z], - target: [target.x, target.y, target.z], - mode: useViewer.getState().cameraMode, - }, - }) - } - const handleNodeView = ({ nodeId }: CameraControlEvent) => { - if (!controls.current) return - - const node = useScene.getState().nodes[nodeId] - if (!node?.camera) return - const { position, target } = node.camera - - controls.current.setLookAt( - position[0], - position[1], - position[2], - target[0], - target[1], - target[2], - true, - ) - } - - const handleTopView = () => { - if (!controls.current) return - - const currentPolarAngle = controls.current.polarAngle - - // Toggle: if already near top view (< 0.1 radians ≈ 5.7°), go back to 45° - // Otherwise, go to top view (0°) - const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0 - - controls.current.rotatePolarTo(targetAngle, true) - } - - const handleOrbitCW = () => { - if (!controls.current) return - - const currentAzimuth = controls.current.azimuthAngle - const currentPolar = controls.current.polarAngle - // Round to nearest 90° increment, then rotate 90° clockwise - const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2) - const target = rounded - Math.PI / 2 - - controls.current.rotateTo(target, currentPolar, true) - } - - const handleOrbitCCW = () => { - if (!controls.current) return - - const currentAzimuth = controls.current.azimuthAngle - const currentPolar = controls.current.polarAngle - // Round to nearest 90° increment, then rotate 90° counter-clockwise - const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2) - const target = rounded + Math.PI / 2 - - controls.current.rotateTo(target, currentPolar, true) - } - - const handleNodeFocus = ({ nodeId }: CameraControlEvent) => { - focusNode(nodeId) - } - - emitter.on('camera-controls:capture', handleNodeCapture) - emitter.on('camera-controls:focus', handleNodeFocus) - emitter.on('camera-controls:view', handleNodeView) - emitter.on('camera-controls:top-view', handleTopView) - emitter.on('camera-controls:orbit-cw', handleOrbitCW) - emitter.on('camera-controls:orbit-ccw', handleOrbitCCW) - - return () => { - emitter.off('camera-controls:capture', handleNodeCapture) - emitter.off('camera-controls:focus', handleNodeFocus) - emitter.off('camera-controls:view', handleNodeView) - emitter.off('camera-controls:top-view', handleTopView) - emitter.off('camera-controls:orbit-cw', handleOrbitCW) - emitter.off('camera-controls:orbit-ccw', handleOrbitCCW) - } - }, [focusNode, isFirstPersonMode]) - - const onTransitionStart = useCallback(() => { - useViewer.getState().setCameraDragging(true) - }, []) - - const onRest = useCallback(() => { - useViewer.getState().setCameraDragging(false) - }, []) - - // In first-person mode, don't render orbit controls — FirstPersonControls takes over - if (isFirstPersonMode) { - return null - } - - return ( - - ) -} +'use client' + +import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core' +import { useViewer, WalkthroughControls, ZONE_LAYER } from '@pascal-app/viewer' +import { CameraControls, CameraControlsImpl } from '@react-three/drei' +import { useThree } from '@react-three/fiber' +import { useCallback, useEffect, useMemo, useRef } from 'react' +import { Box3, Vector3 } from 'three' +import { EDITOR_LAYER } from '../../lib/constants' +import useEditor from '../../store/use-editor' + +const currentTarget = new Vector3() +const tempBox = new Box3() +const tempCenter = new Vector3() +const tempDelta = new Vector3() +const tempPosition = new Vector3() +const tempSize = new Vector3() +const tempTarget = new Vector3() +const DEFAULT_MAX_POLAR_ANGLE = Math.PI / 2 - 0.1 +const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05 + +export const CustomCameraControls = () => { + const controls = useRef(null!) + const isPreviewMode = useEditor((s) => s.isPreviewMode) + const walkthroughMode = useViewer((s) => s.walkthroughMode) + const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera) + const selection = useViewer((s) => s.selection) + const currentLevelId = selection.levelId + const firstLoad = useRef(true) + const maxPolarAngle = + !isPreviewMode && allowUndergroundCamera ? DEBUG_MAX_POLAR_ANGLE : DEFAULT_MAX_POLAR_ANGLE + + const camera = useThree((state) => state.camera) + const raycaster = useThree((state) => state.raycaster) + useEffect(() => { + camera.layers.enable(EDITOR_LAYER) + raycaster.layers.enable(EDITOR_LAYER) + raycaster.layers.enable(ZONE_LAYER) + }, [camera, raycaster]) + + useEffect(() => { + if (isPreviewMode) return // Preview mode uses auto-navigate instead + let targetY = 0 + if (currentLevelId) { + const levelMesh = sceneRegistry.nodes.get(currentLevelId) + if (levelMesh) { + targetY = levelMesh.position.y + } + } + if (!controls.current) return + if (firstLoad.current) { + firstLoad.current = false + controls.current.setLookAt(20, 20, 20, 0, 0, 0, true) + } + controls.current.getTarget(currentTarget) + controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true) + }, [currentLevelId, isPreviewMode]) + + useEffect(() => { + if (!controls.current) return + + controls.current.maxPolarAngle = maxPolarAngle + controls.current.minPolarAngle = 0 + + if (controls.current.polarAngle > maxPolarAngle) { + controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true) + } + }, [maxPolarAngle]) + + const focusNode = useCallback( + (nodeId: string) => { + if (isPreviewMode || !controls.current) return + + const object3D = sceneRegistry.nodes.get(nodeId) + if (!object3D) return + + tempBox.setFromObject(object3D) + if (tempBox.isEmpty()) return + + tempBox.getCenter(tempCenter) + controls.current.getPosition(tempPosition) + controls.current.getTarget(tempTarget) + tempDelta.copy(tempCenter).sub(tempTarget) + + controls.current.setLookAt( + tempPosition.x + tempDelta.x, + tempPosition.y + tempDelta.y, + tempPosition.z + tempDelta.z, + tempCenter.x, + tempCenter.y, + tempCenter.z, + true, + ) + }, + [isPreviewMode], + ) + + // Configure mouse buttons based on control mode and camera mode + const cameraMode = useViewer((state) => state.cameraMode) + const mouseButtons = useMemo(() => { + // Use ZOOM for orthographic camera, DOLLY for perspective camera + const wheelAction = + cameraMode === 'orthographic' + ? CameraControlsImpl.ACTION.ZOOM + : CameraControlsImpl.ACTION.DOLLY + + return { + left: isPreviewMode ? CameraControlsImpl.ACTION.SCREEN_PAN : CameraControlsImpl.ACTION.NONE, + middle: CameraControlsImpl.ACTION.SCREEN_PAN, + right: CameraControlsImpl.ACTION.ROTATE, + wheel: wheelAction, + } + }, [cameraMode, isPreviewMode]) + + useEffect(() => { + const keyState = { + shiftRight: false, + shiftLeft: false, + controlRight: false, + controlLeft: false, + space: false, + } + + const updateConfig = () => { + if (!controls.current) return + + const shift = keyState.shiftRight || keyState.shiftLeft + const control = keyState.controlRight || keyState.controlLeft + const space = keyState.space + + const wheelAction = + cameraMode === 'orthographic' + ? CameraControlsImpl.ACTION.ZOOM + : CameraControlsImpl.ACTION.DOLLY + controls.current.mouseButtons.wheel = wheelAction + controls.current.mouseButtons.middle = CameraControlsImpl.ACTION.SCREEN_PAN + controls.current.mouseButtons.right = CameraControlsImpl.ACTION.ROTATE + if (isPreviewMode) { + // In preview mode, left-click is always pan (viewer-style) + controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN + } else if (space) { + controls.current.mouseButtons.left = CameraControlsImpl.ACTION.SCREEN_PAN + } else { + controls.current.mouseButtons.left = CameraControlsImpl.ACTION.NONE + } + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.code === 'Space') { + keyState.space = true + document.body.style.cursor = 'grab' + } + if (event.code === 'ShiftRight') { + keyState.shiftRight = true + } + if (event.code === 'ShiftLeft') { + keyState.shiftLeft = true + } + if (event.code === 'ControlRight') { + keyState.controlRight = true + } + if (event.code === 'ControlLeft') { + keyState.controlLeft = true + } + updateConfig() + } + + const onKeyUp = (event: KeyboardEvent) => { + if (event.code === 'Space') { + keyState.space = false + document.body.style.cursor = '' + } + if (event.code === 'ShiftRight') { + keyState.shiftRight = false + } + if (event.code === 'ShiftLeft') { + keyState.shiftLeft = false + } + if (event.code === 'ControlRight') { + keyState.controlRight = false + } + if (event.code === 'ControlLeft') { + keyState.controlLeft = false + } + updateConfig() + } + + document.addEventListener('keydown', onKeyDown) + document.addEventListener('keyup', onKeyUp) + updateConfig() + + return () => { + document.removeEventListener('keydown', onKeyDown) + document.removeEventListener('keyup', onKeyUp) + } + }, [cameraMode, isPreviewMode]) + + // Preview mode: auto-navigate camera to selected node (viewer behavior) + const previewTargetNodeId = isPreviewMode + ? (selection.zoneId ?? selection.levelId ?? selection.buildingId) + : null + + useEffect(() => { + if (!(isPreviewMode && controls.current)) return + + const nodes = useScene.getState().nodes + let node = previewTargetNodeId ? nodes[previewTargetNodeId] : null + + if (!previewTargetNodeId) { + const site = Object.values(nodes).find((n) => n.type === 'site') + node = site || null + } + if (!node) return + + // Check if node has a saved camera + if (node.camera) { + const { position, target } = node.camera + if ( + position && + target && + position.length >= 3 && + target.length >= 3 && + position.every((v) => v !== null && v !== undefined) && + target.every((v) => v !== null && v !== undefined) + ) { + requestAnimationFrame(() => { + if (!controls.current) return + controls.current.setLookAt( + position[0], + position[1], + position[2], + target[0], + target[1], + target[2], + true, + ) + }) + } + return + } + + if (!previewTargetNodeId) return + + // Calculate camera position from bounding box + const object3D = sceneRegistry.nodes.get(previewTargetNodeId) + if (!object3D) return + + tempBox.setFromObject(object3D) + tempBox.getCenter(tempCenter) + tempBox.getSize(tempSize) + + const maxDim = Math.max(tempSize.x, tempSize.y, tempSize.z) + const distance = Math.max(maxDim * 2, 15) + + controls.current.setLookAt( + tempCenter.x + distance * 0.7, + tempCenter.y + distance * 0.5, + tempCenter.z + distance * 0.7, + tempCenter.x, + tempCenter.y, + tempCenter.z, + true, + ) + }, [isPreviewMode, previewTargetNodeId]) + + useEffect(() => { + const handleNodeCapture = ({ nodeId }: CameraControlEvent) => { + if (!controls.current) return + + const position = new Vector3() + const target = new Vector3() + controls.current.getPosition(position) + controls.current.getTarget(target) + + const state = useScene.getState() + + state.updateNode(nodeId, { + camera: { + position: [position.x, position.y, position.z], + target: [target.x, target.y, target.z], + mode: useViewer.getState().cameraMode, + }, + }) + } + const handleNodeView = ({ nodeId }: CameraControlEvent) => { + if (!controls.current) return + + const node = useScene.getState().nodes[nodeId] + if (!node?.camera) return + const { position, target } = node.camera + + controls.current.setLookAt( + position[0], + position[1], + position[2], + target[0], + target[1], + target[2], + true, + ) + } + + const handleTopView = () => { + if (!controls.current) return + + const currentPolarAngle = controls.current.polarAngle + + // Toggle: if already near top view (< 0.1 radians ≈ 5.7°), go back to 45° + // Otherwise, go to top view (0°) + const targetAngle = currentPolarAngle < 0.1 ? Math.PI / 4 : 0 + + controls.current.rotatePolarTo(targetAngle, true) + } + + const handleOrbitCW = () => { + if (!controls.current) return + + const currentAzimuth = controls.current.azimuthAngle + const currentPolar = controls.current.polarAngle + // Round to nearest 90° increment, then rotate 90° clockwise + const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2) + const target = rounded - Math.PI / 2 + + controls.current.rotateTo(target, currentPolar, true) + } + + const handleOrbitCCW = () => { + if (!controls.current) return + + const currentAzimuth = controls.current.azimuthAngle + const currentPolar = controls.current.polarAngle + // Round to nearest 90° increment, then rotate 90° counter-clockwise + const rounded = Math.round(currentAzimuth / (Math.PI / 2)) * (Math.PI / 2) + const target = rounded + Math.PI / 2 + + controls.current.rotateTo(target, currentPolar, true) + } + + const handleNodeFocus = ({ nodeId }: CameraControlEvent) => { + focusNode(nodeId) + } + + emitter.on('camera-controls:capture', handleNodeCapture) + emitter.on('camera-controls:focus', handleNodeFocus) + emitter.on('camera-controls:view', handleNodeView) + emitter.on('camera-controls:top-view', handleTopView) + emitter.on('camera-controls:orbit-cw', handleOrbitCW) + emitter.on('camera-controls:orbit-ccw', handleOrbitCCW) + + return () => { + emitter.off('camera-controls:capture', handleNodeCapture) + emitter.off('camera-controls:focus', handleNodeFocus) + emitter.off('camera-controls:view', handleNodeView) + emitter.off('camera-controls:top-view', handleTopView) + emitter.off('camera-controls:orbit-cw', handleOrbitCW) + emitter.off('camera-controls:orbit-ccw', handleOrbitCCW) + } + }, [focusNode]) + + const onTransitionStart = useCallback(() => { + useViewer.getState().setCameraDragging(true) + }, []) + + const onRest = useCallback(() => { + useViewer.getState().setCameraDragging(false) + }, []) + + if (walkthroughMode) { + return + } + + return ( + + ) +} diff --git a/packages/editor/src/components/editor/editor-layout-v2.tsx b/packages/editor/src/components/editor/editor-layout-v2.tsx old mode 100644 new mode 100755 index a2fc0d26..3ca46933 --- a/packages/editor/src/components/editor/editor-layout-v2.tsx +++ b/packages/editor/src/components/editor/editor-layout-v2.tsx @@ -14,9 +14,11 @@ const SIDEBAR_COLLAPSE_THRESHOLD = 220 function LeftColumn({ tabs, renderTabContent, + sidebarOverlay, }: { tabs: SidebarTab[] renderTabContent: (tabId: string) => ReactNode + sidebarOverlay?: ReactNode }) { const width = useSidebarStore((s) => s.width) const isCollapsed = useSidebarStore((s) => s.isCollapsed) @@ -108,7 +110,10 @@ function LeftColumn({ }} > -
{renderTabContent(activePanel)}
+
+ {renderTabContent(activePanel)} + {sidebarOverlay &&
{sidebarOverlay}
} +
{/* Resize handle + hit area */}
ReactNode + sidebarOverlay?: ReactNode viewerToolbarLeft?: ReactNode viewerToolbarRight?: ReactNode viewerContent: ReactNode @@ -181,6 +187,7 @@ export function EditorLayoutV2({ navbarSlot, sidebarTabs = [], renderTabContent, + sidebarOverlay, viewerToolbarLeft, viewerToolbarRight, viewerContent, @@ -194,7 +201,11 @@ export function EditorLayoutV2({ {/* Main content: left column + right column */}
{sidebarTabs.length > 0 && ( - + )} = [ - { direction: 'n', className: 'absolute top-0 left-4 right-4 z-20 h-2 cursor-ns-resize' }, - { direction: 's', className: 'absolute right-4 bottom-0 left-4 z-20 h-2 cursor-ns-resize' }, - { direction: 'e', className: 'absolute top-4 right-0 bottom-4 z-20 w-2 cursor-ew-resize' }, - { direction: 'w', className: 'absolute top-4 bottom-4 left-0 z-20 w-2 cursor-ew-resize' }, - { direction: 'ne', className: 'absolute top-0 right-0 z-20 h-4 w-4 cursor-nesw-resize' }, - { direction: 'nw', className: 'absolute top-0 left-0 z-20 h-4 w-4 cursor-nwse-resize' }, - { direction: 'se', className: 'absolute right-0 bottom-0 z-20 h-4 w-4 cursor-nwse-resize' }, - { direction: 'sw', className: 'absolute bottom-0 left-0 z-20 h-4 w-4 cursor-nesw-resize' }, + { + direction: 'n', + className: 'absolute top-0 left-4 right-4 z-20 h-2 cursor-ns-resize', + }, + { + direction: 's', + className: 'absolute right-4 bottom-0 left-4 z-20 h-2 cursor-ns-resize', + }, + { + direction: 'e', + className: 'absolute top-4 right-0 bottom-4 z-20 w-2 cursor-ew-resize', + }, + { + direction: 'w', + className: 'absolute top-4 bottom-4 left-0 z-20 w-2 cursor-ew-resize', + }, + { + direction: 'ne', + className: 'absolute top-0 right-0 z-20 h-4 w-4 cursor-nesw-resize', + }, + { + direction: 'nw', + className: 'absolute top-0 left-0 z-20 h-4 w-4 cursor-nwse-resize', + }, + { + direction: 'se', + className: 'absolute right-0 bottom-0 z-20 h-4 w-4 cursor-nwse-resize', + }, + { + direction: 'sw', + className: 'absolute bottom-0 left-0 z-20 h-4 w-4 cursor-nesw-resize', + }, ] const guideCornerSigns: Record = { @@ -422,6 +524,10 @@ function toPlanPointFromSvgPoint(svgPoint: SvgPoint): WallPlanPoint { return [toSvgX(svgPoint.x), toSvgY(svgPoint.y)] } +function getSnappedFloorplanPoint(point: WallPlanPoint): WallPlanPoint { + return [snapToHalf(point[0]), snapToHalf(point[1])] +} + function rotateVector([x, y]: WallPlanPoint, angle: number): WallPlanPoint { const cos = Math.cos(angle) const sin = Math.sin(angle) @@ -1068,6 +1174,688 @@ function toFloorplanPolygon(points: Array<[number, number]>): Point2D[] { return points.map(([x, y]) => ({ x, y })) } +function rotatePlanVector(x: number, y: number, rotation: number): [number, number] { + const cos = Math.cos(rotation) + const sin = Math.sin(rotation) + return [x * cos + y * sin, -x * sin + y * cos] +} + +function getPolygonBounds(points: Point2D[]) { + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + + for (const point of points) { + minX = Math.min(minX, point.x) + maxX = Math.max(maxX, point.x) + minY = Math.min(minY, point.y) + maxY = Math.max(maxY, point.y) + } + + return { + minX, + maxX, + minY, + maxY, + width: maxX - minX, + height: maxY - minY, + } +} + +function getFloorplanActionMenuPosition( + points: Point2D[], + viewBox: { minX: number; minY: number; width: number; height: number }, + surfaceSize: { width: number; height: number }, +) { + if (points.length === 0) { + return null + } + + let minX = Number.POSITIVE_INFINITY + let maxX = Number.NEGATIVE_INFINITY + let minY = Number.POSITIVE_INFINITY + let maxY = Number.NEGATIVE_INFINITY + + for (const point of points) { + const svgPoint = toSvgPoint(point) + minX = Math.min(minX, svgPoint.x) + maxX = Math.max(maxX, svgPoint.x) + minY = Math.min(minY, svgPoint.y) + maxY = Math.max(maxY, svgPoint.y) + } + + if ( + !( + Number.isFinite(minX) && + Number.isFinite(maxX) && + Number.isFinite(minY) && + Number.isFinite(maxY) + ) + ) { + return null + } + + if ( + maxX < viewBox.minX || + minX > viewBox.minX + viewBox.width || + maxY < viewBox.minY || + minY > viewBox.minY + viewBox.height + ) { + return null + } + + const anchorX = (((minX + maxX) / 2 - viewBox.minX) / viewBox.width) * surfaceSize.width + const anchorY = ((minY - viewBox.minY) / viewBox.height) * surfaceSize.height + + return { + x: Math.min( + Math.max(anchorX, FLOORPLAN_ACTION_MENU_HORIZONTAL_PADDING), + surfaceSize.width - FLOORPLAN_ACTION_MENU_HORIZONTAL_PADDING, + ), + y: Math.max(anchorY, FLOORPLAN_ACTION_MENU_MIN_ANCHOR_Y), + } +} + +function getRotatedRectanglePolygon( + center: Point2D, + width: number, + depth: number, + rotation: number, +): Point2D[] { + const halfWidth = width / 2 + const halfDepth = depth / 2 + const corners: Array<[number, number]> = [ + [-halfWidth, -halfDepth], + [halfWidth, -halfDepth], + [halfWidth, halfDepth], + [-halfWidth, halfDepth], + ] + + return corners.map(([localX, localY]) => { + const [offsetX, offsetY] = rotatePlanVector(localX, localY, rotation) + return { + x: center.x + offsetX, + y: center.y + offsetY, + } + }) +} + +function interpolatePlanPoint(start: Point2D, end: Point2D, t: number): Point2D { + return { + x: start.x + (end.x - start.x) * t, + y: start.y + (end.y - start.y) * t, + } +} + +function getPlanPointDistance(start: Point2D, end: Point2D): number { + return Math.hypot(end.x - start.x, end.y - start.y) +} + +function movePlanPointTowards(start: Point2D, end: Point2D, distance: number): Point2D { + const totalDistance = getPlanPointDistance(start, end) + if (totalDistance <= Number.EPSILON || distance <= 0) { + return start + } + + return interpolatePlanPoint(start, end, Math.min(1, distance / totalDistance)) +} + +function getFloorplanStairSegmentCenterLine(polygon: Point2D[]): FloorplanLineSegment | null { + if (polygon.length < 4) { + return null + } + + const [backLeft, backRight, frontRight, frontLeft] = polygon + + return { + start: interpolatePlanPoint(backLeft!, backRight!, 0.5), + end: interpolatePlanPoint(frontLeft!, frontRight!, 0.5), + } +} + +function getFloorplanStairInnerPolygon(polygon: Point2D[]): Point2D[] { + if (polygon.length < 4) { + return polygon + } + + const [backLeft, backRight, frontRight, frontLeft] = polygon + const outerWidth = getPlanPointDistance(backLeft!, backRight!) + const outerLength = getPlanPointDistance(backLeft!, frontLeft!) + const widthInset = Math.min( + FLOORPLAN_STAIR_OUTLINE_BAND_THICKNESS, + outerWidth * FLOORPLAN_STAIR_OUTLINE_MAX_FRACTION, + ) + const lengthInset = Math.min( + FLOORPLAN_STAIR_OUTLINE_BAND_THICKNESS, + outerLength * FLOORPLAN_STAIR_OUTLINE_MAX_FRACTION, + ) + + const insetBackLeft = movePlanPointTowards(backLeft!, frontLeft!, lengthInset) + const insetBackRight = movePlanPointTowards(backRight!, frontRight!, lengthInset) + const insetFrontLeft = movePlanPointTowards(frontLeft!, backLeft!, lengthInset) + const insetFrontRight = movePlanPointTowards(frontRight!, backRight!, lengthInset) + + const innerPolygon = [ + movePlanPointTowards(insetBackLeft, insetBackRight, widthInset), + movePlanPointTowards(insetBackRight, insetBackLeft, widthInset), + movePlanPointTowards(insetFrontRight, insetFrontLeft, widthInset), + movePlanPointTowards(insetFrontLeft, insetFrontRight, widthInset), + ] + + const innerWidth = getPlanPointDistance(innerPolygon[0]!, innerPolygon[1]!) + const innerLength = getPlanPointDistance(innerPolygon[0]!, innerPolygon[3]!) + + return innerWidth > 0.06 && innerLength > 0.06 ? innerPolygon : polygon +} + +function getFloorplanStairTreadLines( + segment: StairSegmentNode, + innerPolygon: Point2D[], +): FloorplanLineSegment[] { + if (segment.segmentType !== 'stair' || segment.stepCount <= 1 || innerPolygon.length < 4) { + return [] + } + + const [backLeft, backRight, frontRight, frontLeft] = innerPolygon + const treadLines: FloorplanLineSegment[] = [] + + for (let stepIndex = 1; stepIndex < segment.stepCount; stepIndex += 1) { + const t = stepIndex / segment.stepCount + treadLines.push({ + start: interpolatePlanPoint(backLeft!, frontLeft!, t), + end: interpolatePlanPoint(backRight!, frontRight!, t), + }) + } + + return treadLines +} + +function getThickPlanLinePolygon(line: FloorplanLineSegment, thickness: number): Point2D[] { + const dx = line.end.x - line.start.x + const dy = line.end.y - line.start.y + const length = Math.hypot(dx, dy) + + if (length <= Number.EPSILON || thickness <= 0) { + return [line.start, line.end, line.end, line.start] + } + + const halfThickness = thickness / 2 + const normalX = (-dy / length) * halfThickness + const normalY = (dx / length) * halfThickness + + return [ + { x: line.start.x + normalX, y: line.start.y + normalY }, + { x: line.end.x + normalX, y: line.end.y + normalY }, + { x: line.end.x - normalX, y: line.end.y - normalY }, + { x: line.start.x - normalX, y: line.start.y - normalY }, + ] +} + +function getPolylineBandPolygons(points: Point2D[], thickness: number): FloorplanPolygonEntry[] { + if (points.length < 2 || thickness <= 0) { + return [] + } + + const polygons: FloorplanPolygonEntry[] = [] + + for (let pointIndex = 1; pointIndex < points.length; pointIndex += 1) { + const start = points[pointIndex - 1]! + const end = points[pointIndex]! + + if (getPlanPointDistance(start, end) <= Number.EPSILON) { + continue + } + + const polygon = getThickPlanLinePolygon({ start, end }, thickness) + polygons.push({ + points: formatPolygonPoints(polygon), + polygon, + }) + } + + return polygons +} + +function getFloorplanStairTreadThickness(segment: StairSegmentNode, innerPolygon: Point2D[]) { + if (segment.segmentType !== 'stair' || segment.stepCount <= 1 || innerPolygon.length < 4) { + return 0 + } + + const innerWidth = getPlanPointDistance(innerPolygon[0]!, innerPolygon[1]!) + const innerLength = getPlanPointDistance(innerPolygon[0]!, innerPolygon[3]!) + const treadRun = innerLength / Math.max(segment.stepCount, 1) + return clamp( + Math.min(FLOORPLAN_STAIR_TREAD_BAND_THICKNESS, innerWidth * 0.12, treadRun * 0.44), + FLOORPLAN_STAIR_TREAD_MIN_THICKNESS, + FLOORPLAN_STAIR_TREAD_BAND_THICKNESS, + ) +} + +function getFloorplanStairTreadBars( + segment: StairSegmentNode, + innerPolygon: Point2D[], + treadThickness = getFloorplanStairTreadThickness(segment, innerPolygon), +): FloorplanPolygonEntry[] { + const treadLines = getFloorplanStairTreadLines(segment, innerPolygon) + if (treadLines.length === 0 || treadThickness <= 0) { + return [] + } + + return treadLines.map((line) => { + const polygon = getThickPlanLinePolygon(line, treadThickness) + return { + points: formatPolygonPoints(polygon), + polygon, + } + }) +} + +type FloorplanStairArrowSide = 'back' | 'front' | 'left' | 'right' + +function getFloorplanStairSegmentCenterPoint(segment: FloorplanStairSegmentEntry): Point2D | null { + if (segment.centerLine) { + return interpolatePlanPoint(segment.centerLine.start, segment.centerLine.end, 0.5) + } + + if (segment.polygon.length < 4) { + return null + } + + const [backLeft, backRight, frontRight, frontLeft] = segment.polygon + + return { + x: (backLeft!.x + backRight!.x + frontRight!.x + frontLeft!.x) / 4, + y: (backLeft!.y + backRight!.y + frontRight!.y + frontLeft!.y) / 4, + } +} + +function getFloorplanStairSegmentSidePoint( + segment: FloorplanStairSegmentEntry, + side: FloorplanStairArrowSide, +): Point2D | null { + if (segment.polygon.length < 4) { + return null + } + + const [backLeft, backRight, frontRight, frontLeft] = segment.polygon + + switch (side) { + case 'back': + return interpolatePlanPoint(backLeft!, backRight!, 0.5) + case 'front': + return interpolatePlanPoint(frontLeft!, frontRight!, 0.5) + case 'left': + return interpolatePlanPoint(backLeft!, frontLeft!, 0.5) + case 'right': + return interpolatePlanPoint(backRight!, frontRight!, 0.5) + } +} + +function getFloorplanStairExitSide( + nextSegment: StairSegmentNode | undefined, +): FloorplanStairArrowSide { + if (!nextSegment) { + return 'front' + } + + // `attachmentSide` describes the next segment's turn direction. The floorplan transform + // attaches `left` turns to the previous segment's positive local X edge and `right` turns + // to the negative local X edge, so the arrow needs to mirror that convention here. + if (nextSegment.attachmentSide === 'left') { + return 'right' + } + if (nextSegment.attachmentSide === 'right') { + return 'left' + } + + return 'front' +} + +function appendUniquePlanPoint(points: Point2D[], point: Point2D | null) { + if (!point) { + return + } + + const lastPoint = points[points.length - 1] + if (lastPoint && getPlanPointDistance(lastPoint, point) <= 0.001) { + return + } + + points.push(point) +} + +function buildFloorplanStairArrow( + segments: FloorplanStairSegmentEntry[], +): FloorplanStairArrowEntry | null { + const rawPoints: Point2D[] = [] + + for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex += 1) { + const segment = segments[segmentIndex]! + const nextSegment = segments[segmentIndex + 1]?.segment + const entryPoint = getFloorplanStairSegmentSidePoint(segment, 'back') + const exitPoint = getFloorplanStairSegmentSidePoint( + segment, + getFloorplanStairExitSide(nextSegment), + ) + + if (!(entryPoint && exitPoint)) { + continue + } + + appendUniquePlanPoint(rawPoints, entryPoint) + + const isStraightSegment = getPlanPointDistance(entryPoint, exitPoint) <= 0.001 + if (isStraightSegment) { + continue + } + + const exitSide = getFloorplanStairExitSide(nextSegment) + if (exitSide === 'front') { + appendUniquePlanPoint(rawPoints, exitPoint) + continue + } + + appendUniquePlanPoint(rawPoints, getFloorplanStairSegmentCenterPoint(segment)) + appendUniquePlanPoint(rawPoints, exitPoint) + } + + if (rawPoints.length < 2) { + return null + } + + const firstPoint = rawPoints[0]! + const secondPoint = rawPoints[1]! + const beforeLastPoint = rawPoints[rawPoints.length - 2]! + const lastPoint = rawPoints[rawPoints.length - 1]! + const firstLength = getPlanPointDistance(firstPoint, secondPoint) + const lastLength = getPlanPointDistance(beforeLastPoint, lastPoint) + + if (firstLength <= Number.EPSILON || lastLength <= Number.EPSILON) { + return null + } + + const polyline = [ + movePlanPointTowards(firstPoint, secondPoint, Math.min(0.24, firstLength * 0.18)), + ...rawPoints.slice(1, -1), + movePlanPointTowards(lastPoint, beforeLastPoint, Math.min(0.3, lastLength * 0.22)), + ] + const arrowTailPoint = polyline[polyline.length - 2] + const arrowTip = polyline[polyline.length - 1] + + if (!(arrowTailPoint && arrowTip)) { + return null + } + + const arrowBodyLength = getPlanPointDistance(arrowTailPoint, arrowTip) + if (arrowBodyLength <= Number.EPSILON) { + return null + } + + const arrowHeadLength = clamp( + arrowBodyLength * 0.72, + FLOORPLAN_STAIR_ARROW_HEAD_MIN_SIZE, + FLOORPLAN_STAIR_ARROW_HEAD_MAX_SIZE, + ) + const arrowHeadBase = movePlanPointTowards(arrowTip, arrowTailPoint, arrowHeadLength) + const directionX = arrowTip.x - arrowHeadBase.x + const directionY = arrowTip.y - arrowHeadBase.y + const directionLength = Math.hypot(directionX, directionY) + + if (directionLength <= Number.EPSILON) { + return null + } + + const normalX = -directionY / directionLength + const normalY = directionX / directionLength + const arrowHeadHalfWidth = arrowHeadLength * 0.34 + + return { + head: [ + arrowTip, + { + x: arrowHeadBase.x + normalX * arrowHeadHalfWidth, + y: arrowHeadBase.y + normalY * arrowHeadHalfWidth, + }, + { + x: arrowHeadBase.x - normalX * arrowHeadHalfWidth, + y: arrowHeadBase.y - normalY * arrowHeadHalfWidth, + }, + ], + polyline, + } +} + +function collectLevelDescendants(levelNode: LevelNode, nodes: Record): AnyNode[] { + const descendants: AnyNode[] = [] + const stack = [...levelNode.children].reverse() as AnyNodeId[] + + while (stack.length > 0) { + const nodeId = stack.pop() + if (!nodeId) { + continue + } + + const node = nodes[nodeId] + if (!node) { + continue + } + + descendants.push(node) + + if ('children' in node && Array.isArray(node.children) && node.children.length > 0) { + for (let index = node.children.length - 1; index >= 0; index -= 1) { + stack.push(node.children[index] as AnyNodeId) + } + } + } + + return descendants +} + +function getItemFloorplanTransform( + item: ItemNode, + nodeById: ReadonlyMap, + cache: Map, +): FloorplanNodeTransform | null { + const cached = cache.get(item.id) + if (cached !== undefined) { + return cached + } + + const localRotation = item.rotation[1] ?? 0 + let result: FloorplanNodeTransform | null = null + const itemMetadata = + typeof item.metadata === 'object' && item.metadata !== null && !Array.isArray(item.metadata) + ? (item.metadata as Record) + : null + + if (itemMetadata?.isTransient === true) { + const live = useLiveTransforms.getState().get(item.id) + if (live) { + result = { + position: { + x: live.position[0], + y: live.position[2], + }, + rotation: live.rotation, + } + + cache.set(item.id, result) + return result + } + } + + if (item.parentId) { + const parentNode = nodeById.get(item.parentId as AnyNodeId) + + if (parentNode?.type === 'wall') { + const wallRotation = -Math.atan2( + parentNode.end[1] - parentNode.start[1], + parentNode.end[0] - parentNode.start[0], + ) + const wallLocalZ = + item.asset.attachTo === 'wall-side' + ? ((parentNode.thickness ?? 0.1) / 2) * (item.side === 'back' ? -1 : 1) + : item.position[2] + const [offsetX, offsetY] = rotatePlanVector(item.position[0], wallLocalZ, wallRotation) + + result = { + position: { + x: parentNode.start[0] + offsetX, + y: parentNode.start[1] + offsetY, + }, + rotation: wallRotation + localRotation, + } + } else if (parentNode?.type === 'item') { + const parentTransform = getItemFloorplanTransform(parentNode, nodeById, cache) + if (parentTransform) { + const [offsetX, offsetY] = rotatePlanVector( + item.position[0], + item.position[2], + parentTransform.rotation, + ) + result = { + position: { + x: parentTransform.position.x + offsetX, + y: parentTransform.position.y + offsetY, + }, + rotation: parentTransform.rotation + localRotation, + } + } + } else { + result = { + position: { x: item.position[0], y: item.position[2] }, + rotation: localRotation, + } + } + } else { + result = { + position: { x: item.position[0], y: item.position[2] }, + rotation: localRotation, + } + } + + cache.set(item.id, result) + return result +} + +type StairSegmentTransform = { + position: [number, number, number] + rotation: number +} + +function computeFloorplanStairSegmentTransforms( + segments: StairSegmentNode[], +): StairSegmentTransform[] { + const transforms: StairSegmentTransform[] = [] + let currentX = 0 + let currentY = 0 + let currentZ = 0 + let currentRotation = 0 + + for (let index = 0; index < segments.length; index += 1) { + const segment = segments[index]! + + if (index === 0) { + transforms.push({ + position: [currentX, currentY, currentZ], + rotation: currentRotation, + }) + continue + } + + const previousSegment = segments[index - 1]! + let attachX = 0 + let attachY = previousSegment.height + let attachZ = previousSegment.length + let rotationDelta = 0 + + if (segment.attachmentSide === 'left') { + attachX = previousSegment.width / 2 + attachZ = previousSegment.length / 2 + rotationDelta = Math.PI / 2 + } else if (segment.attachmentSide === 'right') { + attachX = -previousSegment.width / 2 + attachZ = previousSegment.length / 2 + rotationDelta = -Math.PI / 2 + } + + const [rotatedAttachX, rotatedAttachZ] = rotatePlanVector(attachX, attachZ, currentRotation) + currentX += rotatedAttachX + currentY += attachY + currentZ += rotatedAttachZ + currentRotation += rotationDelta + + transforms.push({ + position: [currentX, currentY, currentZ], + rotation: currentRotation, + }) + } + + return transforms +} + +function getFloorplanStairSegmentPolygon( + stair: StairNode, + segment: StairSegmentNode, + transform: StairSegmentTransform, +): Point2D[] { + const halfWidth = segment.width / 2 + const localCorners: Array<[number, number]> = [ + [-halfWidth, 0], + [halfWidth, 0], + [halfWidth, segment.length], + [-halfWidth, segment.length], + ] + + return localCorners.map(([localX, localY]) => { + const [segmentX, segmentY] = rotatePlanVector(localX, localY, transform.rotation) + const groupX = transform.position[0] + segmentX + const groupY = transform.position[2] + segmentY + const [worldOffsetX, worldOffsetY] = rotatePlanVector(groupX, groupY, stair.rotation) + + return { + x: stair.position[0] + worldOffsetX, + y: stair.position[2] + worldOffsetY, + } + }) +} + +function buildFloorplanStairEntry( + stair: StairNode, + segments: StairSegmentNode[], +): FloorplanStairEntry | null { + if (segments.length === 0) { + return null + } + + const transforms = computeFloorplanStairSegmentTransforms(segments) + const segmentEntries = segments.map((segment, index) => { + const polygon = getFloorplanStairSegmentPolygon(stair, segment, transforms[index]!) + const centerLine = getFloorplanStairSegmentCenterLine(polygon) + const innerPolygon = getFloorplanStairInnerPolygon(polygon) + const treadThickness = getFloorplanStairTreadThickness(segment, innerPolygon) + + return { + centerLine, + innerPoints: formatPolygonPoints(innerPolygon), + innerPolygon, + segment, + points: formatPolygonPoints(polygon), + polygon, + treadBars: getFloorplanStairTreadBars(segment, innerPolygon, treadThickness), + treadThickness, + } + }) + + return { + arrow: buildFloorplanStairArrow(segmentEntries), + stair, + segments: segmentEntries, + } +} + function isPointInsidePolygonWithHoles( point: Point2D, polygon: Point2D[], @@ -1188,6 +1976,13 @@ function pointsEqual(a: WallPlanPoint, b: WallPlanPoint): boolean { return a[0] === b[0] && a[1] === b[1] } +function haveSameIds(currentIds: string[], nextIds: string[]): boolean { + return ( + currentIds.length === nextIds.length && + currentIds.every((currentId, index) => currentId === nextIds[index]) + ) +} + function polygonsEqual(a: WallPlanPoint[], b: Array<[number, number]>): boolean { return ( a.length === b.length && @@ -1508,10 +2303,22 @@ function getOpeningFootprint(wall: WallNode, node: WindowNode | DoorNode): Point const halfDepth = depth / 2 return [ - { x: cx - dirX * halfWidth + perpX * halfDepth, y: cz - dirZ * halfWidth + perpZ * halfDepth }, - { x: cx + dirX * halfWidth + perpX * halfDepth, y: cz + dirZ * halfWidth + perpZ * halfDepth }, - { x: cx + dirX * halfWidth - perpX * halfDepth, y: cz + dirZ * halfWidth - perpZ * halfDepth }, - { x: cx - dirX * halfWidth - perpX * halfDepth, y: cz - dirZ * halfWidth - perpZ * halfDepth }, + { + x: cx - dirX * halfWidth + perpX * halfDepth, + y: cz - dirZ * halfWidth + perpZ * halfDepth, + }, + { + x: cx + dirX * halfWidth + perpX * halfDepth, + y: cz + dirZ * halfWidth + perpZ * halfDepth, + }, + { + x: cx + dirX * halfWidth - perpX * halfDepth, + y: cz + dirZ * halfWidth - perpZ * halfDepth, + }, + { + x: cx - dirX * halfWidth - perpX * halfDepth, + y: cz - dirZ * halfWidth - perpZ * halfDepth, + }, ] } @@ -1619,7 +2426,12 @@ function findClosestWallPoint( point: WallPlanPoint, walls: WallNode[], maxDistance = 0.5, -): { wall: WallNode; point: WallPlanPoint; t: number; normal: [number, number, number] } | null { +): { + wall: WallNode + point: WallPlanPoint + t: number + normal: [number, number, number] +} | null { let best: { wall: WallNode point: WallPlanPoint @@ -2065,11 +2877,16 @@ function FloorplanGuideHandleHint({ } const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ - canSelectSlabs, + canFocusGeometry, canSelectGeometry, + canSelectSlabs, + highlightedIdSet, + hoveredSlabId, hoveredOpeningId, hoveredWallId, + isDeleteMode, onSlabDoubleClick, + onSlabHoverChange, onSlabSelect, onOpeningDoubleClick, onOpeningHoverChange, @@ -2085,10 +2902,15 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ wallPolygons, unit, }: { + canFocusGeometry: boolean canSelectSlabs: boolean canSelectGeometry: boolean + highlightedIdSet: ReadonlySet + hoveredSlabId: SlabNode['id'] | null hoveredOpeningId: OpeningNode['id'] | null + isDeleteMode: boolean onSlabDoubleClick: (slab: SlabNode) => void + onSlabHoverChange: (slabId: SlabNode['id'] | null) => void onSlabSelect: (slabId: SlabNode['id'], event: ReactMouseEvent) => void onOpeningDoubleClick: (opening: OpeningNode) => void onOpeningHoverChange: (openingId: OpeningNode['id'] | null) => void @@ -2129,6 +2951,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ <> {slabPolygons.map(({ slab, polygon, holes, path }) => { const isSelected = selectedIdSet.has(slab.id) + const isHighlighted = highlightedIdSet.has(slab.id) + const isDeleteHovered = isDeleteMode && hoveredSlabId === slab.id let slabLabel = null if (isSelected) { @@ -2163,7 +2987,13 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ { event.stopPropagation() onSlabDoubleClick(slab) } : undefined } + onPointerEnter={canSelectSlabs ? () => onSlabHoverChange(slab.id) : undefined} + onPointerLeave={canSelectSlabs ? () => onSlabHoverChange(null) : undefined} pointerEvents={canSelectSlabs ? undefined : 'none'} - stroke={isSelected ? palette.selectedStroke : palette.slabStroke} - strokeOpacity={isSelected ? 0.92 : 0.84} + stroke={ + isDeleteHovered + ? palette.deleteStroke + : isHighlighted + ? palette.selectedStroke + : palette.slabStroke + } + strokeOpacity={isDeleteHovered || isHighlighted ? 0.92 : 0.84} strokeWidth="0.05" style={canSelectSlabs ? { cursor: EDITOR_CURSOR } : undefined} vectorEffect="non-scaling-stroke" @@ -2195,8 +3033,16 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ {wallPolygons.map(({ wall, polygon, points }) => { const isSelected = selectedIdSet.has(wall.id) + const isHighlighted = highlightedIdSet.has(wall.id) const isHovered = canSelectGeometry && hoveredWallId === wall.id - const hoverStroke = isSelected ? palette.selectedStroke : palette.wallHoverStroke + const isDeleteHovered = isDeleteMode && isHovered + const hoverStroke = isDeleteHovered + ? palette.deleteWallHoverStroke + : isHighlighted + ? palette.selectedStroke + : palette.wallHoverStroke + const hoverGlowOpacity = isDeleteHovered ? 0.14 : isHighlighted ? 0.22 : 0.16 + const hoverRingOpacity = isDeleteHovered ? 0.38 : isHighlighted ? 0.6 : 0.48 const hoverSidePaths = getWallHoverSidePaths(polygon, wall) return ( @@ -2214,7 +3060,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ stroke={hoverStroke} strokeLinecap="round" strokeLinejoin="round" - strokeOpacity={isSelected ? 0.22 : 0.16} + strokeOpacity={hoverGlowOpacity} strokeWidth={FLOORPLAN_WALL_HOVER_GLOW_STROKE_WIDTH} style={{ opacity: isHovered ? 1 : 0, @@ -2232,7 +3078,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ stroke={hoverStroke} strokeLinecap="round" strokeLinejoin="round" - strokeOpacity={isSelected ? 0.6 : 0.48} + strokeOpacity={hoverRingOpacity} strokeWidth={FLOORPLAN_WALL_HOVER_RING_STROKE_WIDTH} style={{ opacity: isHovered ? 1 : 0, @@ -2264,7 +3110,13 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ /> )} { @@ -2282,7 +3134,9 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ : undefined } points={points} - stroke={isSelected ? 'none' : palette.wallStroke} + stroke={ + isDeleteHovered ? palette.deleteStroke : isHighlighted ? 'none' : palette.wallStroke + } strokeOpacity={1} strokeWidth="0.06" style={{ cursor: EDITOR_CURSOR }} @@ -2294,10 +3148,20 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ {openingsPolygons.map(({ opening, polygon, points }) => { const isSelected = selectedIdSet.has(opening.id) + const isSelectionHighlighted = highlightedIdSet.has(opening.id) const isHovered = canSelectGeometry && hoveredOpeningId === opening.id - const isHighlighted = isHovered || isSelected - const highlightStroke = isSelected ? palette.selectedStroke : palette.wallHoverStroke - const detailStroke = isSelected ? palette.surface : palette.openingStroke + const isDeleteHovered = isDeleteMode && isHovered + const isHighlighted = isHovered || isSelectionHighlighted + const highlightStroke = isDeleteHovered + ? palette.deleteStroke + : isSelectionHighlighted + ? palette.selectedStroke + : palette.wallHoverStroke + const detailStroke = isDeleteHovered + ? palette.deleteStroke + : isSelectionHighlighted + ? palette.surface + : palette.openingStroke const centerLine = getOpeningCenterLine(polygon) if (opening.type === 'window') { @@ -2320,7 +3184,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ : undefined } onDoubleClick={ - canSelectGeometry + canFocusGeometry ? (event) => { event.stopPropagation() onOpeningDoubleClick(opening) @@ -2328,7 +3192,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ : undefined } onPointerDown={ - canSelectGeometry && isSelected + canFocusGeometry && isSelected ? (event) => { if (event.button === 0) { onOpeningPointerDown(opening.id, event) @@ -2366,7 +3230,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ points={points} stroke={highlightStroke} strokeLinejoin="round" - strokeOpacity={isSelected ? 0.22 : 0.16} + strokeOpacity={isDeleteHovered || isSelectionHighlighted ? 0.22 : 0.16} strokeWidth={FLOORPLAN_WALL_HOVER_GLOW_STROKE_WIDTH} style={{ opacity: isHighlighted ? 1 : 0, @@ -2380,7 +3244,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ points={points} stroke={highlightStroke} strokeLinejoin="round" - strokeOpacity={isSelected ? 0.6 : 0.48} + strokeOpacity={isDeleteHovered || isSelectionHighlighted ? 0.6 : 0.48} strokeWidth={FLOORPLAN_WALL_HOVER_RING_STROKE_WIDTH} style={{ opacity: isHighlighted ? 1 : 0, @@ -2391,12 +3255,24 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ { event.stopPropagation() onOpeningDoubleClick(opening) @@ -2468,7 +3344,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ : undefined } onPointerDown={ - canSelectGeometry && isSelected + canFocusGeometry && isSelected ? (event) => { if (event.button === 0) { onOpeningPointerDown(opening.id, event) @@ -2506,7 +3382,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ points={points} stroke={highlightStroke} strokeLinejoin="round" - strokeOpacity={isSelected ? 0.22 : 0.16} + strokeOpacity={isDeleteHovered || isSelectionHighlighted ? 0.22 : 0.16} strokeWidth={FLOORPLAN_WALL_HOVER_GLOW_STROKE_WIDTH} style={{ opacity: isHighlighted ? 1 : 0, @@ -2520,7 +3396,7 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ points={points} stroke={highlightStroke} strokeLinejoin="round" - strokeOpacity={isSelected ? 0.6 : 0.48} + strokeOpacity={isDeleteHovered || isSelectionHighlighted ? 0.6 : 0.48} strokeWidth={FLOORPLAN_WALL_HOVER_RING_STROKE_WIDTH} style={{ opacity: isHighlighted ? 1 : 0, @@ -2531,12 +3407,24 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ @@ -2614,6 +3508,362 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({ ) }) +const FloorplanNodeLayer = memo(function FloorplanNodeLayer({ + canFocusItems, + canFocusStairs, + canSelectItems, + canSelectStairs, + highlightedIdSet, + hoveredItemId, + hoveredStairId, + isDeleteMode, + isFurnishContextActive, + itemEntries, + onItemDoubleClick, + onItemHoverChange, + onItemHoverEnter, + onItemPointerDown, + onItemSelect, + onStairDoubleClick, + onStairHoverChange, + onStairHoverEnter, + onStairSelect, + palette, + selectedIdSet, + stairEntries, +}: { + canFocusItems: boolean + canFocusStairs: boolean + canSelectItems: boolean + canSelectStairs: boolean + highlightedIdSet: ReadonlySet + hoveredItemId: ItemNode['id'] | null + hoveredStairId: StairNode['id'] | null + isDeleteMode: boolean + isFurnishContextActive: boolean + itemEntries: FloorplanItemEntry[] + onItemDoubleClick: (item: ItemNode, event: ReactMouseEvent) => void + onItemHoverChange: (itemId: ItemNode['id'] | null) => void + onItemHoverEnter: (itemId: ItemNode['id']) => void + onItemPointerDown: (itemId: ItemNode['id'], event: ReactPointerEvent) => void + onItemSelect: (itemId: ItemNode['id'], event: ReactMouseEvent) => void + onStairDoubleClick: (stair: StairNode, event: ReactMouseEvent) => void + onStairHoverChange: (stairId: StairNode['id'] | null) => void + onStairHoverEnter: (stairId: StairNode['id']) => void + onStairSelect: (stairId: StairNode['id'], event: ReactMouseEvent) => void + palette: FloorplanPalette + selectedIdSet: ReadonlySet + stairEntries: FloorplanStairEntry[] +}) { + if (itemEntries.length === 0 && stairEntries.length === 0) { + return null + } + + const stairNodes = stairEntries.map(({ arrow, stair, segments }) => { + const stairSelected = selectedIdSet.has(stair.id) + const stairHighlighted = highlightedIdSet.has(stair.id) + const segmentSelected = segments.some(({ segment }) => selectedIdSet.has(segment.id)) + const segmentHighlighted = segments.some(({ segment }) => highlightedIdSet.has(segment.id)) + const isHovered = hoveredStairId === stair.id + const isDeleteHovered = isDeleteMode && isHovered + const isSelectionActive = + stairSelected || stairHighlighted || segmentSelected || segmentHighlighted + const showHighlight = isHovered || isDeleteHovered || isSelectionActive + const outlineStroke = isDeleteHovered + ? palette.deleteStroke + : isSelectionActive + ? palette.selectedStroke + : palette.openingStroke + const highlightStroke = isDeleteHovered + ? palette.deleteStroke + : isSelectionActive + ? palette.selectedStroke + : palette.wallHoverStroke + const overlayFill = isDeleteHovered + ? palette.deleteFill + : isSelectionActive + ? palette.selectedFill + : null + const arrowThickness = segments.reduce( + (maxThickness, segmentEntry) => Math.max(maxThickness, segmentEntry.treadThickness), + FLOORPLAN_STAIR_ARROW_BAND_THICKNESS, + ) + const arrowBodyBands = arrow ? getPolylineBandPolygons(arrow.polyline, arrowThickness) : [] + + return ( + { + event.stopPropagation() + onStairSelect(stair.id, event) + } + : undefined + } + onDoubleClick={ + canFocusStairs + ? (event) => { + event.stopPropagation() + onStairDoubleClick(stair, event) + } + : undefined + } + onPointerEnter={canSelectStairs ? () => onStairHoverEnter(stair.id) : undefined} + onPointerLeave={canSelectStairs ? () => onStairHoverChange(null) : undefined} + pointerEvents={canSelectStairs ? undefined : 'none'} + style={canSelectStairs ? { cursor: EDITOR_CURSOR } : undefined} + > + {stair.name || 'Staircase'} + {segments.map(({ innerPoints, points, segment, treadBars }) => { + return ( + + + + + + {overlayFill && innerPoints && ( + + )} + {treadBars.map((treadBar, treadIndex) => ( + + ))} + + ) + })} + {arrow && ( + <> + {arrowBodyBands.map((band, bandIndex) => ( + + ))} + + + + )} + + ) + }) + + const itemNodes = itemEntries.map(({ item, points, polygon }) => { + const isSelected = selectedIdSet.has(item.id) + const isHighlighted = highlightedIdSet.has(item.id) + const isHovered = hoveredItemId === item.id + const isDeleteHovered = isDeleteMode && isHovered + const isSelectionActive = isSelected || isHighlighted + const showHighlight = isDeleteHovered || isSelectionActive || isHovered + const stroke = isDeleteHovered + ? palette.deleteStroke + : isSelectionActive + ? palette.selectedStroke + : palette.openingStroke + const highlightStroke = isDeleteHovered + ? palette.deleteStroke + : isSelectionActive + ? palette.selectedStroke + : palette.wallHoverStroke + const fill = isDeleteHovered + ? palette.deleteFill + : isSelectionActive + ? palette.selectedFill + : palette.openingFill + const crossStrokeOpacity = isDeleteHovered + ? 0.76 + : isSelectionActive + ? 0.72 + : isHovered + ? 0.58 + : 0.52 + const diagonalAStart = polygon[0] + const diagonalAEnd = polygon[2] + const diagonalBStart = polygon[1] + const diagonalBEnd = polygon[3] + + return ( + { + event.stopPropagation() + onItemSelect(item.id, event) + } + : undefined + } + onDoubleClick={ + canFocusItems + ? (event) => { + event.stopPropagation() + onItemDoubleClick(item, event) + } + : undefined + } + onPointerDown={ + canFocusItems && isSelected + ? (event) => { + if (event.button === 0) { + onItemPointerDown(item.id, event) + } + } + : undefined + } + onPointerEnter={canSelectItems ? () => onItemHoverEnter(item.id) : undefined} + onPointerLeave={canSelectItems ? () => onItemHoverChange(null) : undefined} + pointerEvents={canSelectItems ? undefined : 'none'} + style={canSelectItems ? { cursor: EDITOR_CURSOR } : undefined} + > + {item.name || item.asset.name} + + + + {diagonalAStart && diagonalAEnd && ( + + )} + {diagonalBStart && diagonalBEnd && ( + + )} + + ) + }) + + return ( + <>{isFurnishContextActive ? [...stairNodes, ...itemNodes] : [...itemNodes, ...stairNodes]} + ) +}) + const FloorplanSiteLayer = memo(function FloorplanSiteLayer({ isEditing, sitePolygon, @@ -2643,12 +3893,18 @@ const FloorplanSiteLayer = memo(function FloorplanSiteLayer({ const FloorplanZoneLayer = memo(function FloorplanZoneLayer({ canSelectZones, + hoveredZoneId, + isDeleteMode, + onZoneHoverChange, onZoneSelect, palette, selectedZoneId, zonePolygons, }: { canSelectZones: boolean + hoveredZoneId: ZoneNodeType['id'] | null + isDeleteMode: boolean + onZoneHoverChange: (zoneId: ZoneNodeType['id'] | null) => void onZoneSelect: (zoneId: ZoneNodeType['id'], event: ReactMouseEvent) => void palette: FloorplanPalette selectedZoneId: ZoneNodeType['id'] | null @@ -2658,18 +3914,26 @@ const FloorplanZoneLayer = memo(function FloorplanZoneLayer({ <> {zonePolygons.map(({ zone, points }) => { const isSelected = selectedZoneId === zone.id + const isHovered = hoveredZoneId === zone.id + const isDeleteHovered = isDeleteMode && isHovered return ( {canSelectZones && ( @@ -2679,6 +3943,8 @@ const FloorplanZoneLayer = memo(function FloorplanZoneLayer({ event.stopPropagation() onZoneSelect(zone.id, event) }} + onPointerEnter={() => onZoneHoverChange(zone.id)} + onPointerLeave={() => onZoneHoverChange(null)} pointerEvents="stroke" points={points} stroke="transparent" @@ -2695,6 +3961,282 @@ const FloorplanZoneLayer = memo(function FloorplanZoneLayer({ ) }) +const FLOORPLAN_ZONE_LABEL_FONT_SIZE = 0.2 + +/** Compute polygon centroid using the shoelace formula */ +const polygonCentroid = (polygon: Point2D[]): { x: number; y: number } => { + let signedArea = 0 + let cx = 0 + let cy = 0 + + for (let i = 0; i < polygon.length; i++) { + const p0 = polygon[i]! + const p1 = polygon[(i + 1) % polygon.length]! + const cross = p0.x * p1.y - p1.x * p0.y + signedArea += cross + cx += (p0.x + p1.x) * cross + cy += (p0.y + p1.y) * cross + } + + signedArea /= 2 + const factor = 1 / (6 * signedArea) + return { x: cx * factor, y: cy * factor } +} + +function FloorplanZoneLabelInput({ + centroid, + svgRef, + viewBox, + zone, + onDone, +}: { + centroid: { x: number; y: number } + svgRef: React.RefObject + viewBox: { minX: number; minY: number; width: number; height: number } + zone: ZoneNodeType + onDone: () => void +}) { + const updateNode = useScene((s) => s.updateNode) + const [value, setValue] = useState(zone.name) + const inputRef = useRef(null) + + useEffect(() => { + requestAnimationFrame(() => { + inputRef.current?.focus() + inputRef.current?.select() + }) + }, []) + + const save = useCallback(() => { + const trimmed = value.trim() + if (trimmed && trimmed !== zone.name) { + updateNode(zone.id, { name: trimmed }) + } + onDone() + }, [value, zone.id, zone.name, updateNode, onDone]) + + // Convert SVG coordinates to screen pixel position + const svgEl = svgRef.current + if (!svgEl) return null + const rect = svgEl.getBoundingClientRect() + const screenX = ((centroid.x - viewBox.minX) / viewBox.width) * rect.width + rect.left + const screenY = ((centroid.y - viewBox.minY) / viewBox.height) * rect.height + rect.top + + return createPortal( + setValue(e.target.value)} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Enter') { + e.preventDefault() + save() + } + if (e.key === 'Escape') { + e.preventDefault() + onDone() + } + }} + onMouseDown={(e) => e.stopPropagation()} + onPointerDown={(e) => e.stopPropagation()} + ref={inputRef} + style={{ + position: 'fixed', + left: screenX, + top: screenY, + transform: 'translate(-50%, -50%)', + border: 'none', + borderBottom: `1px solid ${zone.color}`, + background: 'transparent', + color: 'white', + textShadow: `-1px -1px 0 ${zone.color}, 1px -1px 0 ${zone.color}, -1px 1px 0 ${zone.color}, 1px 1px 0 ${zone.color}`, + outline: 'none', + textAlign: 'center', + fontSize: '14px', + fontFamily: 'system-ui, -apple-system, sans-serif', + padding: '2px 4px', + margin: 0, + zIndex: 100, + width: `${Math.max((value || zone.name || '').length + 2, 6)}ch`, + }} + type="text" + value={value} + />, + document.body, + ) +} + +// Pencil icon as an SVG path (Lucide pencil simplified), rendered relative to the label +const PENCIL_ICON_SIZE = FLOORPLAN_ZONE_LABEL_FONT_SIZE * 0.6 + +function FloorplanZoneLabel({ + centroid, + onHoverChange, + onLabelClick, + zone, +}: { + centroid: { x: number; y: number } + onHoverChange: (zoneId: ZoneNodeType['id'] | null) => void + onLabelClick: (zoneId: ZoneNodeType['id'], event: ReactMouseEvent) => void + zone: ZoneNodeType +}) { + const [hovered, setHovered] = useState(false) + const textRef = useRef(null) + const [textWidth, setTextWidth] = useState(0) + const mode = useEditor((s) => s.mode) + const deleteNode = useScene((s) => s.deleteNode) + const setSelection = useViewer((s) => s.setSelection) + + useEffect(() => { + if (textRef.current) { + setTextWidth(textRef.current.getComputedTextLength()) + } + }, [zone.name]) + + const isDeleteMode = mode === 'delete' + + return ( + { + e.stopPropagation() + if (isDeleteMode) { + sfxEmitter.emit('sfx:structure-delete') + deleteNode(zone.id as AnyNodeId) + setSelection({ zoneId: null }) + return + } + onLabelClick(zone.id, e) + }} + onPointerEnter={() => { + setHovered(true) + onHoverChange(zone.id) + }} + onPointerLeave={() => { + setHovered(false) + onHoverChange(null) + }} + pointerEvents="auto" + style={{ userSelect: 'none' }} + > + + {zone.name} + + {/* Pencil icon — visible on hover */} + {hovered && textWidth > 0 && ( + + + + + + + )} + + ) +} + +const FloorplanZoneLabelLayer = memo(function FloorplanZoneLabelLayer({ + onLabelHoverChange, + onZoneLabelClick, + selectedZoneId, + svgRef, + viewBox, + zonePolygons, +}: { + onLabelHoverChange: (zoneId: ZoneNodeType['id'] | null) => void + onZoneLabelClick: (zoneId: ZoneNodeType['id'], event: ReactMouseEvent) => void + selectedZoneId: ZoneNodeType['id'] | null + svgRef: React.RefObject + viewBox: { minX: number; minY: number; width: number; height: number } + zonePolygons: ZonePolygonEntry[] +}) { + const [editingZoneId, setEditingZoneId] = useState(null) + + // Listen for edit-label events (from 2D label click or external triggers) + useEffect(() => { + const handler = (event: { zoneId: string }) => { + setEditingZoneId(event.zoneId as ZoneNodeType['id']) + } + emitter.on('zone:edit-label' as any, handler as any) + return () => { + emitter.off('zone:edit-label' as any, handler as any) + } + }, []) + + // Clear editing when selection changes away + useEffect(() => { + if (editingZoneId && selectedZoneId !== editingZoneId) { + setEditingZoneId(null) + } + }, [selectedZoneId, editingZoneId]) + + return ( + <> + {zonePolygons.map(({ zone, polygon }) => { + if (polygon.length < 3) return null + const rawCentroid = polygonCentroid(polygon) + const centroid = toSvgPoint(rawCentroid) + const isEditing = editingZoneId === zone.id + + if (isEditing) { + return ( + setEditingZoneId(null)} + svgRef={svgRef} + viewBox={viewBox} + zone={zone} + /> + ) + } + + return ( + + ) + })} + + ) +}) + const FloorplanWallEndpointLayer = memo(function FloorplanWallEndpointLayer({ endpointHandles, hoveredEndpointId, @@ -3011,11 +4553,14 @@ export function FloorplanPanel() { const containerRef = useRef(null) const hasUserAdjustedViewportRef = useRef(false) const previousLevelIdRef = useRef(null) + const floorplanMarqueeSnapPointRef = useRef(null) const levelId = useViewer((state) => state.selection.levelId) const buildingId = useViewer((state) => state.selection.buildingId) const selectedZoneId = useViewer((state) => state.selection.zoneId) const selectedIds = useViewer((state) => state.selection.selectedIds) + const previewSelectedIds = useViewer((state) => state.previewSelectedIds) const setSelection = useViewer((state) => state.setSelection) + const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds) const theme = useViewer((state) => state.theme) const unit = useViewer((state) => state.unit) const showGrid = useViewer((state) => state.showGrid) @@ -3023,6 +4568,7 @@ export function FloorplanPanel() { const setShowGuides = useViewer((state) => state.setShowGuides) const catalogCategory = useEditor((state) => state.catalogCategory) const setCatalogCategory = useEditor((state) => state.setCatalogCategory) + const selectedItem = useEditor((state) => state.selectedItem) const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered) const setFloorplanHovered = useEditor((state) => state.setFloorplanHovered) @@ -3160,6 +4706,20 @@ export function FloorplanPanel() { .filter((node): node is ZoneNodeType => node?.type === 'zone') }), ) + const levelDescendantNodes = useScene( + useShallow((state) => { + if (!levelId) { + return [] as AnyNode[] + } + + const nextLevelNode = state.nodes[levelId] + if (!nextLevelNode || nextLevelNode.type !== 'level') { + return [] as AnyNode[] + } + + return collectLevelDescendants(nextLevelNode, state.nodes as Record) + }), + ) const [draftStart, setDraftStart] = useState(null) const [draftEnd, setDraftEnd] = useState(null) @@ -3177,6 +4737,10 @@ export function FloorplanPanel() { const [wallEndpointDraft, setWallEndpointDraft] = useState(null) const [hoveredOpeningId, setHoveredOpeningId] = useState(null) const [hoveredWallId, setHoveredWallId] = useState(null) + const [hoveredSlabId, setHoveredSlabId] = useState(null) + const [hoveredItemId, setHoveredItemId] = useState(null) + const [hoveredStairId, setHoveredStairId] = useState(null) + const [hoveredZoneId, setHoveredZoneId] = useState(null) const [hoveredEndpointId, setHoveredEndpointId] = useState(null) const [hoveredSiteHandleId, setHoveredSiteHandleId] = useState(null) const [hoveredSlabHandleId, setHoveredSlabHandleId] = useState(null) @@ -3189,6 +4753,9 @@ export function FloorplanPanel() { ) const [shiftPressed, setShiftPressed] = useState(false) const [rotationModifierPressed, setRotationModifierPressed] = useState(false) + const [movingFloorplanNodeRevision, setMovingFloorplanNodeRevision] = useState(0) + const [stairBuildPreviewPoint, setStairBuildPreviewPoint] = useState(null) + const [stairBuildPreviewRotation, setStairBuildPreviewRotation] = useState(0) const [isPanning, setIsPanning] = useState(false) const [isDraggingPanel, setIsDraggingPanel] = useState(false) const [isMacPlatform, setIsMacPlatform] = useState(true) @@ -3267,15 +4834,29 @@ export function FloorplanPanel() { return structureTools.find((entry) => entry.id === tool) ?? null }, [catalogCategory, mode, movingOpeningType, tool]) const activeFloorplanCursorIndicator = useMemo(() => { - if (!activeFloorplanToolConfig) { - return null + if (activeFloorplanToolConfig) { + return { + kind: 'asset', + iconSrc: activeFloorplanToolConfig.iconSrc, + } } - return { - kind: 'asset', - iconSrc: activeFloorplanToolConfig.iconSrc, + if (mode === 'select' && floorplanSelectionTool === 'marquee' && structureLayer !== 'zones') { + return { + kind: 'icon', + icon: 'mdi:select-drag', + } } - }, [activeFloorplanToolConfig]) + + if (mode === 'delete') { + return { + kind: 'icon', + icon: 'mdi:trash-can-outline', + } + } + + return null + }, [activeFloorplanToolConfig, floorplanSelectionTool, mode, structureLayer]) const visibleGuides = useMemo(() => { if (!showGuides) { return [] @@ -3492,6 +5073,89 @@ export function FloorplanPanel() { : entry, ) }, [zoneBoundaryDraft, zonePolygons]) + const levelDescendantNodeById = useMemo( + () => new Map(levelDescendantNodes.map((node) => [node.id, node] as const)), + [levelDescendantNodes], + ) + const floorplanItems = useMemo( + () => + levelDescendantNodes.filter( + (node): node is ItemNode => + node.type === 'item' && + node.visible !== false && + node.asset.category !== 'door' && + node.asset.category !== 'window', + ), + [levelDescendantNodes], + ) + const floorplanStairs = useMemo( + () => + levelDescendantNodes.filter( + (node): node is StairNode => node.type === 'stair' && node.visible !== false, + ), + [levelDescendantNodes], + ) + const floorplanItemEntries = useMemo(() => { + const transformCache = new Map() + + return floorplanItems.flatMap((item) => { + const transform = getItemFloorplanTransform(item, levelDescendantNodeById, transformCache) + if (!transform) { + return [] + } + + const [width, , depth] = getScaledDimensions(item) + const polygon = getRotatedRectanglePolygon( + transform.position, + width, + depth, + transform.rotation, + ) + + return [ + { + item, + points: formatPolygonPoints(polygon), + polygon, + }, + ] + }) + }, [cursorPoint, floorplanItems, levelDescendantNodeById, movingFloorplanNodeRevision]) + const floorplanStairEntries = useMemo( + () => + floorplanStairs.flatMap((stair) => { + const displayStair = + movingNode?.type === 'stair' && movingNode.id === stair.id + ? (() => { + const live = useLiveTransforms.getState().get(stair.id) + const liveX = cursorPoint?.[0] ?? live?.position[0] ?? stair.position[0] + const liveZ = cursorPoint?.[1] ?? live?.position[2] ?? stair.position[2] + const liveRotation = live?.rotation ?? stair.rotation + + return { + ...stair, + position: [liveX, stair.position[1], liveZ] as StairNode['position'], + rotation: liveRotation, + } + })() + : stair + const segments = (displayStair.children ?? []) + .map((childId) => levelDescendantNodeById.get(childId as AnyNodeId)) + .filter( + (node): node is StairSegmentNode => + node?.type === 'stair-segment' && node.visible !== false, + ) + const entry = buildFloorplanStairEntry(displayStair, segments) + return entry ? [entry] : [] + }), + [ + cursorPoint, + floorplanStairs, + levelDescendantNodeById, + movingFloorplanNodeRevision, + movingNode, + ], + ) const selectedOpeningEntry = useMemo(() => { if (selectedIds.length !== 1) { return null @@ -3499,6 +5163,20 @@ export function FloorplanPanel() { return openingsPolygons.find(({ opening }) => opening.id === selectedIds[0]) ?? null }, [openingsPolygons, selectedIds]) + const selectedItemEntry = useMemo(() => { + if (selectedIds.length !== 1) { + return null + } + + return floorplanItemEntries.find(({ item }) => item.id === selectedIds[0]) ?? null + }, [floorplanItemEntries, selectedIds]) + const selectedStairEntry = useMemo(() => { + if (selectedIds.length !== 1) { + return null + } + + return floorplanStairEntries.find(({ stair }) => stair.id === selectedIds[0]) ?? null + }, [floorplanStairEntries, selectedIds]) const slabById = useMemo(() => new Map(slabs.map((slab) => [slab.id, slab] as const)), [slabs]) const zoneById = useMemo(() => new Map(zones.map((zone) => [zone.id, zone] as const)), [zones]) const selectedSlabEntry = useMemo(() => { @@ -3526,6 +5204,59 @@ export function FloorplanPanel() { const isOpeningBuildActive = isDoorBuildActive || isWindowBuildActive const isOpeningMoveActive = movingOpeningType !== null const isOpeningPlacementActive = isOpeningBuildActive || isOpeningMoveActive + const isStairBuildActive = phase === 'structure' && mode === 'build' && tool === 'stair' + const isStairMoveActive = movingNode?.type === 'stair' + const isItemPlacementPreviewActive = + (mode === 'build' && tool === 'item') || movingNode?.type === 'item' + const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo + const isFloorItemMoveActive = movingNode?.type === 'item' && !movingNode.asset.attachTo + const isFloorplanGridInteractionActive = + isStairBuildActive || isStairMoveActive || isFloorItemBuildActive || isFloorItemMoveActive + const floorplanPreviewStairSegment = useMemo( + () => + StairSegmentNodeSchema.parse({ + id: 'sseg_floorplan_preview', + segmentType: 'stair', + width: DEFAULT_STAIR_WIDTH, + length: DEFAULT_STAIR_LENGTH, + height: DEFAULT_STAIR_HEIGHT, + stepCount: DEFAULT_STAIR_STEP_COUNT, + attachmentSide: DEFAULT_STAIR_ATTACHMENT_SIDE, + fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR, + thickness: DEFAULT_STAIR_THICKNESS, + position: [0, 0, 0], + metadata: { isTransient: true, isFloorplanPreview: true }, + }), + [], + ) + const floorplanPreviewStairEntry = useMemo(() => { + if (!(isStairBuildActive && stairBuildPreviewPoint)) { + return null + } + + const previewStair = StairNodeSchema.parse({ + id: 'stair_floorplan_preview', + name: 'Staircase preview', + position: [stairBuildPreviewPoint[0], 0, stairBuildPreviewPoint[1]], + rotation: stairBuildPreviewRotation, + children: [floorplanPreviewStairSegment.id], + metadata: { isTransient: true, isFloorplanPreview: true }, + }) + + return buildFloorplanStairEntry(previewStair, [floorplanPreviewStairSegment]) + }, [ + floorplanPreviewStairSegment, + isStairBuildActive, + stairBuildPreviewPoint, + stairBuildPreviewRotation, + ]) + const renderedFloorplanStairEntries = useMemo( + () => + floorplanPreviewStairEntry + ? [...floorplanStairEntries, floorplanPreviewStairEntry] + : floorplanStairEntries, + [floorplanPreviewStairEntry, floorplanStairEntries], + ) const floorplanOpeningLocalY = useMemo(() => { if (movingNode?.type === 'door' || movingNode?.type === 'window') { return snapToHalf(movingNode.position[1]) @@ -3543,14 +5274,47 @@ export function FloorplanPanel() { floorplanSelectionTool === 'marquee' && !movingNode && structureLayer !== 'zones' + const isDeleteMode = mode === 'delete' && !movingNode const canSelectElementFloorplanGeometry = - mode === 'select' && floorplanSelectionTool === 'click' && !movingNode + mode === 'select' && + floorplanSelectionTool === 'click' && + !movingNode && + structureLayer !== 'zones' + const canInteractElementFloorplanGeometry = isDeleteMode || canSelectElementFloorplanGeometry + const canInteractFloorplanSlabs = isDeleteMode || canSelectElementFloorplanGeometry const canInteractWithGuides = showGuides && canSelectElementFloorplanGeometry const canSelectFloorplanZones = mode === 'select' && floorplanSelectionTool === 'click' && !movingNode && structureLayer === 'zones' + const canInteractFloorplanZones = isDeleteMode || canSelectFloorplanZones + const isFloorplanStructureContextActive = phase === 'structure' && structureLayer !== 'zones' + const isFloorplanFurnishContextActive = phase === 'furnish' + const isFloorplanItemContextActive = + isFloorplanFurnishContextActive || isFloorplanStructureContextActive + const canSelectFloorplanStairs = + (mode === 'select' && + floorplanSelectionTool === 'click' && + !movingNode && + isFloorplanStructureContextActive) || + isDeleteMode + const canSelectFloorplanItems = + (mode === 'select' && + floorplanSelectionTool === 'click' && + !movingNode && + isFloorplanItemContextActive) || + isDeleteMode + const canFocusFloorplanStairs = + mode === 'select' && + floorplanSelectionTool === 'click' && + !movingNode && + isFloorplanStructureContextActive + const canFocusFloorplanItems = + mode === 'select' && + floorplanSelectionTool === 'click' && + !movingNode && + isFloorplanItemContextActive const visibleSitePolygon = phase === 'site' ? displaySitePolygon : null const shouldShowSiteBoundaryHandles = isSiteEditActive && visibleSitePolygon !== null const shouldShowPersistentWallEndpointHandles = mode === 'select' && !movingNode @@ -3560,13 +5324,13 @@ export function FloorplanPanel() { floorplanSelectionTool === 'click' && selectedSlabEntry !== null const shouldShowZoneBoundaryHandles = canSelectFloorplanZones && selectedZoneEntry !== null - const showZonePolygons = - phase === 'structure' && (structureLayer === 'zones' || isZoneBuildActive) - const visibleZonePolygons = useMemo( - () => (showZonePolygons ? displayZonePolygons : []), - [displayZonePolygons, showZonePolygons], - ) + const showZonePolygons = true // Zone polygons always visible (labels always clickable) + const visibleZonePolygons = displayZonePolygons const selectedIdSet = useMemo(() => new Set(selectedIds), [selectedIds]) + const highlightedFloorplanIdSet = useMemo( + () => new Set([...selectedIds, ...previewSelectedIds]), + [previewSelectedIds, selectedIds], + ) const activeMarqueeBounds = useMemo(() => { if (!floorplanMarqueeState) { return null @@ -3785,6 +5549,10 @@ export function FloorplanPanel() { const allPoints = [ ...(visibleSitePolygon ? visibleSitePolygon.polygon : []), ...displaySlabPolygons.flatMap((entry) => entry.polygon), + ...floorplanItemEntries.flatMap((entry) => entry.polygon), + ...floorplanStairEntries.flatMap((entry) => + entry.segments.flatMap((segmentEntry) => segmentEntry.polygon), + ), ...visibleZonePolygons.flatMap((entry) => entry.polygon), ...wallPolygons.flatMap((entry) => entry.polygon), ] @@ -3823,7 +5591,15 @@ export function FloorplanPanel() { centerY, width, } - }, [displaySlabPolygons, svgAspectRatio, visibleSitePolygon, visibleZonePolygons, wallPolygons]) + }, [ + displaySlabPolygons, + floorplanItemEntries, + floorplanStairEntries, + svgAspectRatio, + visibleSitePolygon, + visibleZonePolygons, + wallPolygons, + ]) useEffect(() => { const host = viewportHostRef.current @@ -3854,7 +5630,12 @@ export function FloorplanPanel() { if (!el) return const update = () => { const rect = el.getBoundingClientRect() - setPanelRect({ x: rect.left, y: rect.top, width: rect.width, height: rect.height }) + setPanelRect({ + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }) setIsPanelReady(true) } const observer = new ResizeObserver(update) @@ -3916,55 +5697,56 @@ export function FloorplanPanel() { () => floorplanWorldUnitsPerPixel * (FLOORPLAN_OPENING_HIT_STROKE_WIDTH / 2), [floorplanWorldUnitsPerPixel], ) - const selectedOpeningActionMenuPosition = useMemo(() => { - if (!selectedOpeningEntry) { - return null - } - - let minX = Number.POSITIVE_INFINITY - let maxX = Number.NEGATIVE_INFINITY - let minY = Number.POSITIVE_INFINITY - let maxY = Number.NEGATIVE_INFINITY - - for (const point of selectedOpeningEntry.polygon) { - const svgPoint = toSvgPoint(point) - minX = Math.min(minX, svgPoint.x) - maxX = Math.max(maxX, svgPoint.x) - minY = Math.min(minY, svgPoint.y) - maxY = Math.max(maxY, svgPoint.y) - } - + const selectedOpeningActionMenuPosition = useMemo( + () => + selectedOpeningEntry + ? getFloorplanActionMenuPosition(selectedOpeningEntry.polygon, viewBox, surfaceSize) + : null, + [selectedOpeningEntry, surfaceSize, viewBox], + ) + const selectedItemActionMenuPosition = useMemo( + () => + selectedItemEntry + ? getFloorplanActionMenuPosition(selectedItemEntry.polygon, viewBox, surfaceSize) + : null, + [selectedItemEntry, surfaceSize, viewBox], + ) + const selectedStairActionMenuPosition = useMemo( + () => + selectedStairEntry + ? getFloorplanActionMenuPosition( + selectedStairEntry.segments.flatMap((segmentEntry) => segmentEntry.polygon), + viewBox, + surfaceSize, + ) + : null, + [selectedStairEntry, surfaceSize, viewBox], + ) + const floorplanCursorAnchorPosition = useMemo(() => { if ( - !( - Number.isFinite(minX) && - Number.isFinite(maxX) && - Number.isFinite(minY) && - Number.isFinite(maxY) - ) + cursorPoint && + surfaceSize.width > 0 && + surfaceSize.height > 0 && + viewBox.width > 0 && + viewBox.height > 0 ) { - return null + const svgPoint = toSvgPlanPoint(cursorPoint) + + if ( + svgPoint.x >= viewBox.minX && + svgPoint.x <= viewBox.minX + viewBox.width && + svgPoint.y >= viewBox.minY && + svgPoint.y <= viewBox.minY + viewBox.height + ) { + return { + x: ((svgPoint.x - viewBox.minX) / viewBox.width) * surfaceSize.width, + y: ((svgPoint.y - viewBox.minY) / viewBox.height) * surfaceSize.height, + } + } } - if ( - maxX < viewBox.minX || - minX > viewBox.minX + viewBox.width || - maxY < viewBox.minY || - minY > viewBox.minY + viewBox.height - ) { - return null - } - - const anchorX = (((minX + maxX) / 2 - viewBox.minX) / viewBox.width) * surfaceSize.width - const anchorY = ((minY - viewBox.minY) / viewBox.height) * surfaceSize.height - - return { - x: Math.min( - Math.max(anchorX, FLOORPLAN_ACTION_MENU_HORIZONTAL_PADDING), - surfaceSize.width - FLOORPLAN_ACTION_MENU_HORIZONTAL_PADDING, - ), - y: Math.max(anchorY, FLOORPLAN_ACTION_MENU_MIN_ANCHOR_Y), - } - }, [selectedOpeningEntry, surfaceSize.height, surfaceSize.width, viewBox]) + return floorplanCursorPosition + }, [cursorPoint, floorplanCursorPosition, surfaceSize.height, surfaceSize.width, viewBox]) useEffect(() => { setHoveredGuideCorner(null) @@ -4071,6 +5853,10 @@ export function FloorplanPanel() { wallFill: '#fafafa', wallStroke: '#38bdf8', wallHoverStroke: '#a1a1aa', + deleteFill: '#f87171', + deleteStroke: '#ef4444', + deleteWallFill: '#ef4444', + deleteWallHoverStroke: '#fca5a5', selectedFill: '#8381ed', selectedStroke: '#8381ed', draftFill: '#818cf8', @@ -4099,6 +5885,10 @@ export function FloorplanPanel() { wallFill: '#171717', wallStroke: '#0284c7', wallHoverStroke: '#71717a', + deleteFill: '#fca5a5', + deleteStroke: '#dc2626', + deleteWallFill: '#ef4444', + deleteWallHoverStroke: '#f87171', selectedFill: '#8381ed', selectedStroke: '#8381ed', draftFill: '#6366f1', @@ -4493,12 +6283,87 @@ export function FloorplanPanel() { [levelId, setSelection], ) + useEffect(() => { + if (!isStairBuildActive) { + setStairBuildPreviewPoint(null) + setStairBuildPreviewRotation(0) + return + } + + const handleGridMove = (event: GridEvent) => { + setStairBuildPreviewPoint(getSnappedFloorplanPoint([event.position[0], event.position[2]])) + } + + emitter.on('grid:move', handleGridMove) + + return () => { + emitter.off('grid:move', handleGridMove) + } + }, [isStairBuildActive]) + + useEffect(() => { + if (!isItemPlacementPreviewActive) { + return + } + + const refreshFloorplanItemPreview = () => { + setMovingFloorplanNodeRevision((current) => current + 1) + } + + emitter.on('grid:move', refreshFloorplanItemPreview) + emitter.on('wall:enter', refreshFloorplanItemPreview as any) + emitter.on('wall:move', refreshFloorplanItemPreview as any) + emitter.on('wall:leave', refreshFloorplanItemPreview as any) + emitter.on('ceiling:enter', refreshFloorplanItemPreview as any) + emitter.on('ceiling:move', refreshFloorplanItemPreview as any) + emitter.on('ceiling:leave', refreshFloorplanItemPreview as any) + emitter.on('item:enter', refreshFloorplanItemPreview as any) + emitter.on('item:move', refreshFloorplanItemPreview as any) + emitter.on('item:leave', refreshFloorplanItemPreview as any) + + return () => { + emitter.off('grid:move', refreshFloorplanItemPreview) + emitter.off('wall:enter', refreshFloorplanItemPreview as any) + emitter.off('wall:move', refreshFloorplanItemPreview as any) + emitter.off('wall:leave', refreshFloorplanItemPreview as any) + emitter.off('ceiling:enter', refreshFloorplanItemPreview as any) + emitter.off('ceiling:move', refreshFloorplanItemPreview as any) + emitter.off('ceiling:leave', refreshFloorplanItemPreview as any) + emitter.off('item:enter', refreshFloorplanItemPreview as any) + emitter.off('item:move', refreshFloorplanItemPreview as any) + emitter.off('item:leave', refreshFloorplanItemPreview as any) + } + }, [isItemPlacementPreviewActive]) + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null + const isEditableTarget = + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + Boolean(target?.isContentEditable) + + if (isEditableTarget) { + return + } + if (event.key === 'Shift') { setShiftPressed(true) } + if (isStairBuildActive && (event.key === 'r' || event.key === 'R')) { + setStairBuildPreviewRotation((current) => current + Math.PI / 4) + } else if (isStairBuildActive && (event.key === 't' || event.key === 'T')) { + setStairBuildPreviewRotation((current) => current - Math.PI / 4) + } + + if ( + (movingNode?.type === 'stair' || movingNode?.type === 'item') && + (event.key === 'r' || event.key === 'R' || event.key === 't' || event.key === 'T') + ) { + setMovingFloorplanNodeRevision((current) => current + 1) + } + setRotationModifierPressed( event.key === 'Meta' || event.key === 'Control' || event.metaKey || event.ctrlKey, ) @@ -4524,7 +6389,7 @@ export function FloorplanPanel() { window.removeEventListener('keyup', handleKeyUp) window.removeEventListener('blur', handleBlur) } - }, []) + }, [isStairBuildActive, movingNode]) useEffect(() => { const handleWindowPointerMove = (event: PointerEvent) => { @@ -5095,6 +6960,25 @@ export function FloorplanPanel() { stopPropagation: () => {}, } as any) }, []) + const emitFloorplanGridEvent = useCallback( + ( + eventType: 'move' | 'click', + planPoint: WallPlanPoint, + nativeEvent: ReactMouseEvent | ReactPointerEvent, + ) => { + const snappedPoint = getSnappedFloorplanPoint(planPoint) + const worldY = + movingNode?.type === 'stair' || movingNode?.type === 'item' ? movingNode.position[1] : 0 + + emitter.emit(`grid:${eventType}` as any, { + nativeEvent: nativeEvent.nativeEvent as any, + position: [snappedPoint[0], worldY, snappedPoint[1]], + }) + + return snappedPoint + }, + [movingNode], + ) const handlePointerMove = useCallback( (event: ReactPointerEvent) => { @@ -5144,6 +7028,14 @@ export function FloorplanPanel() { return } + if (isFloorplanGridInteractionActive) { + const snappedPoint = emitFloorplanGridEvent('move', planPoint, event) + setCursorPoint((previousPoint) => + previousPoint && pointsEqual(previousPoint, snappedPoint) ? previousPoint : snappedPoint, + ) + return + } + if (isPolygonBuildActive) { const snappedPoint = snapPolygonDraftPoint({ point: planPoint, @@ -5193,6 +7085,16 @@ export function FloorplanPanel() { return } + if (isMarqueeSelectionToolActive) { + setCursorPoint((previousPoint) => { + const snappedPoint = getSnappedFloorplanPoint(planPoint) + return previousPoint && pointsEqual(previousPoint, snappedPoint) + ? previousPoint + : snappedPoint + }) + return + } + if (!isWallBuildActive) { setCursorPoint(null) return @@ -5226,10 +7128,13 @@ export function FloorplanPanel() { [ draftStart, emitFloorplanWallLeave, + emitFloorplanGridEvent, floorplanOpeningLocalY, fittedViewport, getPlanPointFromClientPoint, activePolygonDraftPoints, + isFloorplanGridInteractionActive, + isMarqueeSelectionToolActive, isOpeningPlacementActive, isPolygonBuildActive, isWallBuildActive, @@ -5387,6 +7292,12 @@ export function FloorplanPanel() { return } + if (isFloorplanGridInteractionActive) { + const snappedPoint = emitFloorplanGridEvent('click', planPoint, event) + setCursorPoint(snappedPoint) + return + } + if (isPolygonBuildActive) { const snappedPoint = snapPolygonDraftPoint({ point: planPoint, @@ -5417,6 +7328,9 @@ export function FloorplanPanel() { if (structureLayer === 'zones') { setSelectedReferenceId(null) setSelection({ zoneId: null }) + // Return to structure select (same as 3D grid click) + useEditor.getState().setStructureLayer('elements') + useEditor.getState().setMode('select') } else { setSelectedReferenceId(null) setSelection({ selectedIds: [] }) @@ -5435,6 +7349,7 @@ export function FloorplanPanel() { }, [ draftStart, + emitFloorplanGridEvent, floorplanOpeningLocalY, getPlanPointFromClientPoint, activePolygonDraftPoints, @@ -5442,6 +7357,7 @@ export function FloorplanPanel() { handleSlabPlacementPoint, handleZonePlacementPoint, handleWallPlacementPoint, + isFloorplanGridInteractionActive, isOpeningPlacementActive, isPolygonBuildActive, isWallBuildActive, @@ -5559,57 +7475,91 @@ export function FloorplanPanel() { (planPoint: WallPlanPoint) => { const point = toPoint2D(planPoint) - const openingHit = openingsPolygons.find(({ polygon }) => { - if (isPointInsidePolygon(point, polygon)) { - return true + const getItemHitId = () => { + if (!isFloorplanItemContextActive) { + return null } - const centerLine = getOpeningCenterLine(polygon) - if (!centerLine) { - return false - } - - return ( - getDistanceToWallSegment( - point, - [centerLine.start.x, centerLine.start.y], - [centerLine.end.x, centerLine.end.y], - ) <= floorplanOpeningHitTolerance + const itemHit = floorplanItemEntries.find(({ polygon }) => + isPointInsidePolygon(point, polygon), ) - }) - if (openingHit) { - return openingHit.opening.id + return itemHit?.item.id ?? null } - const wallHit = displayWallPolygons.find( - ({ wall, polygon }) => - isPointInsidePolygon(point, polygon) || - getDistanceToWallSegment(point, wall.start, wall.end) <= floorplanWallHitTolerance, - ) - if (wallHit) { - return wallHit.wall.id + if (phase === 'structure') { + const openingHit = openingsPolygons.find(({ polygon }) => { + if (isPointInsidePolygon(point, polygon)) { + return true + } + + const centerLine = getOpeningCenterLine(polygon) + if (!centerLine) { + return false + } + + return ( + getDistanceToWallSegment( + point, + [centerLine.start.x, centerLine.start.y], + [centerLine.end.x, centerLine.end.y], + ) <= floorplanOpeningHitTolerance + ) + }) + if (openingHit) { + return openingHit.opening.id + } + + const stairHit = floorplanStairEntries.find(({ segments }) => + segments.some(({ polygon }) => isPointInsidePolygon(point, polygon)), + ) + if (stairHit) { + return stairHit.stair.id + } + + const wallHit = displayWallPolygons.find( + ({ wall, polygon }) => + isPointInsidePolygon(point, polygon) || + getDistanceToWallSegment(point, wall.start, wall.end) <= floorplanWallHitTolerance, + ) + if (wallHit) { + return wallHit.wall.id + } + + const slabHit = displaySlabPolygons.find(({ polygon, holes }) => + isPointInsidePolygonWithHoles(point, polygon, holes), + ) + if (slabHit) { + return slabHit.slab.id + } } - const slabHit = displaySlabPolygons.find(({ polygon, holes }) => - isPointInsidePolygonWithHoles(point, polygon, holes), - ) - if (slabHit) { - return slabHit.slab.id - } - - return null + return getItemHitId() }, [ displaySlabPolygons, displayWallPolygons, + floorplanItemEntries, floorplanOpeningHitTolerance, + floorplanStairEntries, floorplanWallHitTolerance, openingsPolygons, + phase, + isFloorplanItemContextActive, ], ) const getFloorplanSelectionIdsInBounds = useCallback( (bounds: FloorplanSelectionBounds) => { + const itemIds = isFloorplanItemContextActive + ? floorplanItemEntries + .filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds)) + .map(({ item }) => item.id) + : [] + + if (phase !== 'structure') { + return itemIds + } + const wallIds = displayWallPolygons .filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds)) .map(({ wall }) => wall.id) @@ -5619,10 +7569,130 @@ export function FloorplanPanel() { const slabIds = displaySlabPolygons .filter(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds)) .map(({ slab }) => slab.id) + const stairIds = floorplanStairEntries + .filter(({ segments }) => + segments.some(({ polygon }) => doesPolygonIntersectSelectionBounds(polygon, bounds)), + ) + .map(({ stair }) => stair.id) - return Array.from(new Set([...wallIds, ...openingIds, ...slabIds])) + return Array.from(new Set([...itemIds, ...wallIds, ...openingIds, ...slabIds, ...stairIds])) }, - [displaySlabPolygons, displayWallPolygons, openingsPolygons], + [ + displaySlabPolygons, + displayWallPolygons, + floorplanItemEntries, + floorplanStairEntries, + isFloorplanItemContextActive, + openingsPolygons, + phase, + ], + ) + + const syncPreviewSelectedIds = useCallback( + (nextSelectedIds: string[]) => { + const currentPreviewSelectedIds = useViewer.getState().previewSelectedIds + if (haveSameIds(currentPreviewSelectedIds, nextSelectedIds)) { + return + } + + setPreviewSelectedIds(nextSelectedIds) + }, + [setPreviewSelectedIds], + ) + + const syncDeleteHoveredId = useCallback( + (nodeId: string | null) => { + if (!isDeleteMode) { + return + } + + useViewer.getState().setHoveredId(nodeId as AnyNodeId | null) + }, + [isDeleteMode], + ) + + const handleWallHoverChange = useCallback( + (wallId: WallNode['id'] | null) => { + setHoveredWallId(wallId) + syncDeleteHoveredId(wallId) + }, + [syncDeleteHoveredId], + ) + + const handleOpeningHoverChange = useCallback( + (openingId: OpeningNode['id'] | null) => { + setHoveredOpeningId(openingId) + syncDeleteHoveredId(openingId) + }, + [syncDeleteHoveredId], + ) + + const handleSlabHoverChange = useCallback( + (slabId: SlabNode['id'] | null) => { + setHoveredSlabId(slabId) + syncDeleteHoveredId(slabId) + }, + [syncDeleteHoveredId], + ) + + const handleItemHoverChange = useCallback( + (itemId: ItemNode['id'] | null) => { + setHoveredItemId(itemId) + syncDeleteHoveredId(itemId) + }, + [syncDeleteHoveredId], + ) + + const handleStairHoverChange = useCallback( + (stairId: StairNode['id'] | null) => { + setHoveredStairId(stairId) + syncDeleteHoveredId(stairId) + }, + [syncDeleteHoveredId], + ) + + const handleZoneHoverChange = useCallback( + (zoneId: ZoneNodeType['id'] | null) => { + setHoveredZoneId(zoneId) + syncDeleteHoveredId(zoneId) + }, + [syncDeleteHoveredId], + ) + const handleFloorplanItemHoverEnter = useCallback( + (itemId: ItemNode['id']) => { + handleOpeningHoverChange(null) + handleWallHoverChange(null) + handleSlabHoverChange(null) + handleStairHoverChange(null) + handleZoneHoverChange(null) + handleItemHoverChange(itemId) + }, + [ + handleItemHoverChange, + handleOpeningHoverChange, + handleSlabHoverChange, + handleStairHoverChange, + handleWallHoverChange, + handleZoneHoverChange, + ], + ) + const handleFloorplanStairHoverEnter = useCallback( + (stairId: StairNode['id']) => { + handleItemHoverChange(null) + handleOpeningHoverChange(null) + handleSlabHoverChange(null) + handleWallHoverChange(null) + handleZoneHoverChange(null) + handleStairHoverChange(stairId) + }, + [ + handleItemHoverChange, + handleOpeningHoverChange, + handleSlabHoverChange, + handleStairHoverChange, + handleWallHoverChange, + handleZoneHoverChange, + ], ) const handleWallSelect = useCallback( @@ -5670,7 +7740,13 @@ export function FloorplanPanel() { ) const emitFloorplanNodeClick = useCallback( ( - nodeId: SlabNode['id'] | OpeningNode['id'] | ZoneNodeType['id'], + nodeId: + | ItemNode['id'] + | OpeningNode['id'] + | SlabNode['id'] + | StairNode['id'] + | ZoneNodeType['id'], + eventType: 'click' | 'double-click', event: ReactMouseEvent, ) => { const node = useScene.getState().nodes[nodeId as AnyNodeId] @@ -5680,6 +7756,8 @@ export function FloorplanPanel() { (node.type === 'slab' || node.type === 'door' || node.type === 'window' || + node.type === 'item' || + node.type === 'stair' || node.type === 'zone') ) ) { @@ -5688,7 +7766,7 @@ export function FloorplanPanel() { setSelectedReferenceId(null) emitter.emit( - `${node.type}:click` as any, + `${node.type}:${eventType}` as any, { localPosition: [0, 0, 0], nativeEvent: event.nativeEvent as any, @@ -5823,7 +7901,7 @@ export function FloorplanPanel() { const handleOpeningSelect = useCallback( (openingId: OpeningNode['id'], event: ReactMouseEvent) => { - emitFloorplanNodeClick(openingId, event) + emitFloorplanNodeClick(openingId, 'click', event) }, [emitFloorplanNodeClick], ) @@ -5861,22 +7939,156 @@ export function FloorplanPanel() { ) const handleSlabSelect = useCallback( (slabId: SlabNode['id'], event: ReactMouseEvent) => { - emitFloorplanNodeClick(slabId, event) + emitFloorplanNodeClick(slabId, 'click', event) }, [emitFloorplanNodeClick], ) const handleZoneSelect = useCallback( (zoneId: ZoneNodeType['id'], event: ReactMouseEvent) => { - emitFloorplanNodeClick(zoneId, event) + emitFloorplanNodeClick(zoneId, 'click', event) }, [emitFloorplanNodeClick], ) + const handleItemSelect = useCallback( + (itemId: ItemNode['id'], event: ReactMouseEvent) => { + emitFloorplanNodeClick(itemId, 'click', event) + }, + [emitFloorplanNodeClick], + ) + const handleStairSelect = useCallback( + (stairId: StairNode['id'], event: ReactMouseEvent) => { + emitFloorplanNodeClick(stairId, 'click', event) + }, + [emitFloorplanNodeClick], + ) + const handleZoneLabelClick = useCallback( + (zoneId: ZoneNodeType['id'], _event: ReactMouseEvent) => { + const currentZoneId = useViewer.getState().selection.zoneId + if (currentZoneId === zoneId) { + // Already selected → enter text editing (second click) + emitter.emit('zone:edit-label' as any, { zoneId }) + return + } + // Not selected → select zone + switch to zone mode + useEditor.getState().setPhase('structure') + useEditor.getState().setStructureLayer('zones') + useEditor.getState().setMode('select') + setSelection({ zoneId }) + }, + [setSelection], + ) const handleSlabDoubleClick = useCallback((slab: SlabNode) => { emitter.emit('camera-controls:focus', { nodeId: slab.id }) }, []) const handleOpeningDoubleClick = useCallback((opening: OpeningNode) => { emitter.emit('camera-controls:focus', { nodeId: opening.id }) }, []) + const handleItemDoubleClick = useCallback( + (item: ItemNode, event: ReactMouseEvent) => { + emitFloorplanNodeClick(item.id, 'double-click', event) + emitter.emit('camera-controls:focus', { nodeId: item.id }) + }, + [emitFloorplanNodeClick], + ) + const handleItemPointerDown = useCallback( + (itemId: ItemNode['id'], event: ReactPointerEvent) => { + if (event.button !== 0) { + return + } + + const item = selectedItemEntry?.item + if (!item || item.id !== itemId) { + return + } + + event.preventDefault() + event.stopPropagation() + + // Suppress the click event that follows this pointer interaction so it + // doesn't re-select or interfere with placement. + const suppressClick = (clickEvent: MouseEvent) => { + clickEvent.stopImmediatePropagation() + clickEvent.preventDefault() + window.removeEventListener('click', suppressClick, true) + } + window.addEventListener('click', suppressClick, true) + requestAnimationFrame(() => { + window.removeEventListener('click', suppressClick, true) + }) + + sfxEmitter.emit('sfx:item-pick') + setMovingNode(item) + setSelection({ selectedIds: [] }) + }, + [selectedItemEntry, setMovingNode, setSelection], + ) + const handleSelectedItemMove = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const item = selectedItemEntry?.item + if (!item) { + return + } + + sfxEmitter.emit('sfx:item-pick') + setMovingNode(item) + setSelection({ selectedIds: [] }) + }, + [selectedItemEntry, setMovingNode, setSelection], + ) + const duplicateSelectedItem = useCallback(() => { + const item = selectedItemEntry?.item + if (!item) { + return + } + + sfxEmitter.emit('sfx:item-pick') + + const cloned = structuredClone(item) as Record + delete cloned.id + cloned.metadata = { + ...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}), + isNew: true, + } + + try { + const duplicate = ItemNodeSchema.parse(cloned) + setMovingNode(duplicate) + setSelection({ selectedIds: [] }) + } catch (error) { + console.error('Failed to duplicate item', error) + } + }, [selectedItemEntry, setMovingNode, setSelection]) + const handleSelectedItemDuplicate = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + duplicateSelectedItem() + }, + [duplicateSelectedItem], + ) + const handleSelectedItemDelete = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const item = selectedItemEntry?.item + if (!item) { + return + } + + sfxEmitter.emit('sfx:item-delete') + deleteNode(item.id as AnyNodeId) + setSelection({ selectedIds: [] }) + }, + [deleteNode, selectedItemEntry, setSelection], + ) + const handleStairDoubleClick = useCallback( + (stair: StairNode, event: ReactMouseEvent) => { + emitFloorplanNodeClick(stair.id, 'double-click', event) + emitter.emit('camera-controls:focus', { nodeId: stair.id }) + }, + [emitFloorplanNodeClick], + ) const handleSelectedOpeningMove = useCallback( (event: ReactMouseEvent) => { event.stopPropagation() @@ -5939,6 +8151,103 @@ export function FloorplanPanel() { }, [deleteNode, selectedOpeningEntry, setSelection], ) + const handleSelectedStairMove = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const stair = selectedStairEntry?.stair + if (!stair) { + return + } + + sfxEmitter.emit('sfx:item-pick') + setMovingNode(stair) + setSelection({ selectedIds: [] }) + }, + [selectedStairEntry, setMovingNode, setSelection], + ) + const duplicateSelectedStair = useCallback(() => { + const stair = selectedStairEntry?.stair + if (!stair?.parentId) { + return + } + + sfxEmitter.emit('sfx:item-pick') + useScene.temporal.getState().pause() + + const cloned = structuredClone(stair) as Record + delete cloned.id + cloned.metadata = { + ...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}), + isNew: true, + } + + const nextPosition = + Array.isArray(cloned.position) && cloned.position.length >= 3 + ? [ + Number(cloned.position[0]) + 1, + Number(cloned.position[1]), + Number(cloned.position[2]) + 1, + ] + : [stair.position[0] + 1, stair.position[1], stair.position[2] + 1] + + cloned.position = nextPosition + + try { + const duplicate = StairNodeSchema.parse(cloned) + useScene.getState().createNode(duplicate, stair.parentId as AnyNodeId) + + const nodesState = useScene.getState().nodes + for (const childId of stair.children ?? []) { + const childNode = nodesState[childId] + if (childNode?.type !== 'stair-segment') { + continue + } + + const childClone = structuredClone(childNode) as Record + delete childClone.id + childClone.metadata = { + ...(typeof childClone.metadata === 'object' && childClone.metadata !== null + ? childClone.metadata + : {}), + isNew: true, + } + + const childDuplicate = StairSegmentNodeSchema.parse(childClone) + useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId) + } + + setMovingNode(duplicate) + setSelection({ selectedIds: [] }) + } catch (error) { + console.error('Failed to duplicate stair', error) + } + }, [selectedStairEntry, setMovingNode, setSelection]) + const handleSelectedStairDuplicate = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + duplicateSelectedStair() + }, + [duplicateSelectedStair], + ) + const handleSelectedStairDelete = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation() + + const stair = selectedStairEntry?.stair + if (!stair) { + return + } + + sfxEmitter.emit('sfx:item-delete') + deleteNode(stair.id as AnyNodeId) + if (stair.parentId) { + useScene.getState().dirtyNodes.add(stair.parentId as AnyNodeId) + } + setSelection({ selectedIds: [] }) + }, + [deleteNode, selectedStairEntry, setSelection], + ) const handleWallEndpointPointerDown = useCallback( (wall: WallNode, endpoint: WallEndpoint, event: ReactPointerEvent) => { @@ -6298,8 +8607,12 @@ export function FloorplanPanel() { ) { setCursorPoint(null) } - setHoveredOpeningId(null) - setHoveredWallId(null) + handleOpeningHoverChange(null) + handleItemHoverChange(null) + handleWallHoverChange(null) + handleSlabHoverChange(null) + handleStairHoverChange(null) + handleZoneHoverChange(null) setHoveredEndpointId(null) setHoveredSiteHandleId(null) setHoveredSlabHandleId(null) @@ -6308,7 +8621,18 @@ export function FloorplanPanel() { emitFloorplanWallLeave(hoveredWallIdRef.current) hoveredWallIdRef.current = null } - }, [emitFloorplanWallLeave, siteVertexDragState, slabVertexDragState, zoneVertexDragState]) + }, [ + emitFloorplanWallLeave, + handleItemHoverChange, + handleOpeningHoverChange, + handleSlabHoverChange, + handleStairHoverChange, + handleWallHoverChange, + handleZoneHoverChange, + siteVertexDragState, + slabVertexDragState, + zoneVertexDragState, + ]) const handleSvgPointerMove = useCallback( (event: ReactPointerEvent) => { @@ -6357,6 +8681,7 @@ export function FloorplanPanel() { if (!planPoint) { return } + const snappedPoint = getSnappedFloorplanPoint(planPoint) event.preventDefault() event.stopPropagation() @@ -6367,20 +8692,36 @@ export function FloorplanPanel() { y: event.clientY - rect.top, }) } - setHoveredOpeningId(null) - setHoveredWallId(null) + setCursorPoint(snappedPoint) + handleItemHoverChange(null) + handleOpeningHoverChange(null) + handleWallHoverChange(null) + handleSlabHoverChange(null) + handleStairHoverChange(null) + handleZoneHoverChange(null) setHoveredEndpointId(null) + floorplanMarqueeSnapPointRef.current = snappedPoint + syncPreviewSelectedIds([]) setFloorplanMarqueeState({ pointerId: event.pointerId, startClientX: event.clientX, startClientY: event.clientY, - startPlanPoint: planPoint, - currentPlanPoint: planPoint, + startPlanPoint: snappedPoint, + currentPlanPoint: snappedPoint, }) event.currentTarget.setPointerCapture(event.pointerId) }, - [getPlanPointFromClientPoint], + [ + getPlanPointFromClientPoint, + handleItemHoverChange, + handleOpeningHoverChange, + handleSlabHoverChange, + handleStairHoverChange, + handleWallHoverChange, + handleZoneHoverChange, + syncPreviewSelectedIds, + ], ) const handleMarqueePointerMove = useCallback( @@ -6401,9 +8742,35 @@ export function FloorplanPanel() { if (!planPoint) { return } + const snappedPoint = getSnappedFloorplanPoint(planPoint) event.preventDefault() event.stopPropagation() + setCursorPoint(snappedPoint) + + const dragDistance = Math.hypot( + event.clientX - floorplanMarqueeState.startClientX, + event.clientY - floorplanMarqueeState.startClientY, + ) + + if ( + dragDistance >= FLOORPLAN_MARQUEE_DRAG_THRESHOLD_PX && + floorplanMarqueeSnapPointRef.current && + !pointsEqual(floorplanMarqueeSnapPointRef.current, snappedPoint) + ) { + sfxEmitter.emit('sfx:grid-snap') + } + floorplanMarqueeSnapPointRef.current = snappedPoint + + if (dragDistance >= FLOORPLAN_MARQUEE_DRAG_THRESHOLD_PX) { + const bounds = getFloorplanSelectionBounds( + floorplanMarqueeState.startPlanPoint, + snappedPoint, + ) + syncPreviewSelectedIds(getFloorplanSelectionIdsInBounds(bounds)) + } else { + syncPreviewSelectedIds([]) + } setFloorplanMarqueeState((currentState) => { if (!currentState || currentState.pointerId !== event.pointerId) { @@ -6412,11 +8779,16 @@ export function FloorplanPanel() { return { ...currentState, - currentPlanPoint: planPoint, + currentPlanPoint: snappedPoint, } }) }, - [floorplanMarqueeState?.pointerId, getPlanPointFromClientPoint], + [ + floorplanMarqueeState, + getFloorplanSelectionIdsInBounds, + getPlanPointFromClientPoint, + syncPreviewSelectedIds, + ], ) const handleMarqueePointerUp = useCallback( @@ -6426,8 +8798,9 @@ export function FloorplanPanel() { return } - const endPlanPoint = + const rawEndPlanPoint = getPlanPointFromClientPoint(event.clientX, event.clientY) ?? marqueeState.currentPlanPoint + const endPlanPoint = getSnappedFloorplanPoint(rawEndPlanPoint) const modifierKeys = getSelectionModifierKeys(event) const dragDistance = Math.hypot( event.clientX - marqueeState.startClientX, @@ -6446,7 +8819,7 @@ export function FloorplanPanel() { const nextSelectedIds = getFloorplanSelectionIdsInBounds(bounds) addFloorplanSelection(nextSelectedIds, modifierKeys) } else { - const hitId = getFloorplanHitIdAtPoint(endPlanPoint) + const hitId = getFloorplanHitIdAtPoint(rawEndPlanPoint) if (hitId) { toggleFloorplanSelection(hitId, modifierKeys) @@ -6455,7 +8828,9 @@ export function FloorplanPanel() { } } + syncPreviewSelectedIds([]) setFloorplanMarqueeState(null) + floorplanMarqueeSnapPointRef.current = null }, [ addFloorplanSelection, @@ -6464,6 +8839,7 @@ export function FloorplanPanel() { getFloorplanHitIdAtPoint, getFloorplanSelectionIdsInBounds, getPlanPointFromClientPoint, + syncPreviewSelectedIds, toggleFloorplanSelection, ], ) @@ -6480,21 +8856,45 @@ export function FloorplanPanel() { setFloorplanMarqueeState(null) setFloorplanCursorPosition(null) + floorplanMarqueeSnapPointRef.current = null + syncPreviewSelectedIds([]) + setCursorPoint(null) }, - [floorplanMarqueeState?.pointerId], + [floorplanMarqueeState?.pointerId, syncPreviewSelectedIds], ) useEffect(() => { if (!isMarqueeSelectionToolActive) { setFloorplanMarqueeState(null) + floorplanMarqueeSnapPointRef.current = null + syncPreviewSelectedIds([]) + if (mode === 'select') { + setCursorPoint(null) + } return } setFloorplanCursorPosition(null) - setHoveredOpeningId(null) - setHoveredWallId(null) + handleOpeningHoverChange(null) + handleWallHoverChange(null) + handleSlabHoverChange(null) + handleZoneHoverChange(null) setHoveredEndpointId(null) - }, [isMarqueeSelectionToolActive]) + }, [ + handleOpeningHoverChange, + handleSlabHoverChange, + handleWallHoverChange, + handleZoneHoverChange, + isMarqueeSelectionToolActive, + mode, + syncPreviewSelectedIds, + ]) + + useEffect(() => { + if (mode !== 'delete') { + useViewer.getState().setHoveredId(null) + } + }, [mode]) useEffect(() => { const svg = svgRef.current @@ -6550,8 +8950,12 @@ export function FloorplanPanel() { } svg.addEventListener('wheel', handleNativeWheel, { passive: false }) - svg.addEventListener('gesturestart', handleGestureStart, { passive: false }) - svg.addEventListener('gesturechange', handleGestureChange, { passive: false }) + svg.addEventListener('gesturestart', handleGestureStart, { + passive: false, + }) + svg.addEventListener('gesturechange', handleGestureChange, { + passive: false, + }) svg.addEventListener('gestureend', handleGestureEnd, { passive: false }) return () => { @@ -6660,7 +9064,9 @@ export function FloorplanPanel() { return } - if (!(isFloorplanHovered && selectedOpeningEntry)) { + if ( + !(isFloorplanHovered && (selectedItemEntry || selectedOpeningEntry || selectedStairEntry)) + ) { return } @@ -6675,7 +9081,19 @@ export function FloorplanPanel() { } event.preventDefault() - duplicateSelectedOpening() + if (selectedOpeningEntry) { + duplicateSelectedOpening() + return + } + + if (selectedItemEntry) { + duplicateSelectedItem() + return + } + + if (selectedStairEntry) { + duplicateSelectedStair() + } } window.addEventListener('keydown', handleKeyDown, true) @@ -6683,13 +9101,26 @@ export function FloorplanPanel() { return () => { window.removeEventListener('keydown', handleKeyDown, true) } - }, [duplicateSelectedOpening, isFloorplanHovered, selectedOpeningEntry]) + }, [ + duplicateSelectedItem, + duplicateSelectedOpening, + duplicateSelectedStair, + isFloorplanHovered, + selectedItemEntry, + selectedOpeningEntry, + selectedStairEntry, + ]) const activeDraftAnchorPoint = draftStart ?? activePolygonDraftPoints[0] ?? null - const floorplanCursorColor = wallEndpointDraft - ? palette.editCursor - : activeDraftAnchorPoint - ? palette.draftStroke - : palette.cursor + const floorplanCursorColor = + mode === 'delete' + ? palette.deleteStroke + : wallEndpointDraft + ? palette.editCursor + : activeDraftAnchorPoint + ? palette.draftStroke + : palette.cursor + const activeCursorIndicatorPosition = + mode === 'delete' ? floorplanCursorPosition : floorplanCursorAnchorPosition return (
- {activeFloorplanCursorIndicator && floorplanCursorPosition && !isPanning && ( + {activeFloorplanCursorIndicator && activeCursorIndicatorPosition && !isPanning && ( ) : ( <> + + + {!isVersionPreviewMode && ( +
+ +
+ )} + {!isVersionPreviewMode && ( +
+ +
+ )} +
+ +
+ {viewerBanner} + + } + renderTabContent={renderTabContent} + sidebarOverlay={sidebarOverlay} + sidebarTabs={tabBarTabs} + viewerContent={viewerCanvas} + viewerToolbarLeft={viewerToolbarLeft} + viewerToolbarRight={viewerToolbarRight} + /> {/* First-person overlay — rendered on top of normal layout */} {isFirstPersonMode && (
@@ -749,28 +861,6 @@ export default function Editor({ />
)} - - -
- -
-
- -
-
- -
- - } - renderTabContent={renderTabContent} - sidebarTabs={tabBarTabs} - viewerContent={viewerCanvas} - viewerToolbarLeft={viewerToolbarLeft} - viewerToolbarRight={viewerToolbarRight} - /> diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx old mode 100644 new mode 100755 index f35631f5..af806bda --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -11,7 +11,8 @@ import { } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useRef } from 'react' +import { useCallback, useEffect, useRef } from 'react' +import { Color, type Material, type Mesh, type Object3D } from 'three' import { sfxEmitter } from '../../lib/sfx-bus' import useEditor, { type Phase, type StructureLayer } from './../../store/use-editor' import { boxSelectHandled } from '../tools/select/box-select-tool' @@ -66,6 +67,88 @@ export const resolveBuildingId = ( return null } +const HIGHLIGHT_PROFILES = { + delete: { + color: new Color('#dc2626'), + blend: 0.76, + emissiveBlend: 0.92, + emissiveIntensity: 0.46, + }, + selection: { + color: new Color('#818cf8'), + blend: 0.32, + emissiveBlend: 0.7, + emissiveIntensity: 0.42, + }, +} as const + +type HighlightKind = keyof typeof HIGHLIGHT_PROFILES + +type HighlightableMaterial = Material & { + color?: Color + emissive?: Color + emissiveIntensity?: number + opacity?: number + transparent?: boolean + needsUpdate?: boolean +} + +function isHighlightableMesh(object: Object3D): object is Mesh { + return Boolean( + (object as Mesh).isMesh && + (object as Mesh).material && + object.visible && + object.name !== 'collision-mesh', + ) +} + +function createHighlightedMaterial(material: Material, kind: HighlightKind): Material { + const highlightedMaterial = material.clone() as HighlightableMaterial + const profile = HIGHLIGHT_PROFILES[kind] + + if (highlightedMaterial.color instanceof Color) { + highlightedMaterial.color = highlightedMaterial.color.clone().lerp(profile.color, profile.blend) + } + + if (highlightedMaterial.emissive instanceof Color) { + highlightedMaterial.emissive = highlightedMaterial.emissive + .clone() + .lerp(profile.color, profile.emissiveBlend) + highlightedMaterial.emissiveIntensity = Math.max( + highlightedMaterial.emissiveIntensity ?? 0, + profile.emissiveIntensity, + ) + } + + if (typeof highlightedMaterial.opacity === 'number' && highlightedMaterial.opacity < 1) { + highlightedMaterial.transparent = true + highlightedMaterial.opacity = Math.min(1, highlightedMaterial.opacity + 0.08) + } + + highlightedMaterial.needsUpdate = true + return highlightedMaterial +} + +function createHighlightedMaterials( + material: Material | Material[], + kind: HighlightKind, +): Material | Material[] { + if (Array.isArray(material)) { + return material.map((entry) => createHighlightedMaterial(entry, kind)) + } + + return createHighlightedMaterial(material, kind) +} + +function disposeHighlightedMaterials(material: Material | Material[]) { + if (Array.isArray(material)) { + material.forEach((entry) => entry.dispose()) + return + } + + material.dispose() +} + const computeNextIds = ( node: AnyNode, selectedIds: string[], @@ -99,7 +182,19 @@ const SELECTION_STRATEGIES: Record = { }, structure: { - types: ['wall', 'item', 'zone', 'slab', 'ceiling', 'roof', 'roof-segment', 'window', 'door'], + types: [ + 'wall', + 'item', + 'zone', + 'slab', + 'ceiling', + 'roof', + 'roof-segment', + 'stair', + 'stair-segment', + 'window', + 'door', + ], handleSelect: (node, nativeEvent, modifierKeys) => { const { selection, setSelection } = useViewer.getState() const nodes = useScene.getState().nodes @@ -144,7 +239,9 @@ const SELECTION_STRATEGIES: Record = { node.type === 'slab' || node.type === 'ceiling' || node.type === 'roof' || - node.type === 'roof-segment' + node.type === 'roof-segment' || + node.type === 'stair' || + node.type === 'stair-segment' ) return true if (node.type === 'item') { @@ -204,6 +301,8 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => { node.type === 'ceiling' || node.type === 'roof' || node.type === 'roof-segment' || + node.type === 'stair' || + node.type === 'stair-segment' || node.type === 'window' || node.type === 'door' ) { @@ -233,6 +332,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => { export const SelectionManager = () => { const phase = useEditor((s) => s.phase) const mode = useEditor((s) => s.mode) + const setHoverHighlightMode = useViewer((s) => s.setHoverHighlightMode) const modifierKeysRef = useRef({ meta: false, ctrl: false, @@ -241,6 +341,14 @@ export const SelectionManager = () => { const movingNode = useEditor((s) => s.movingNode) + useEffect(() => { + setHoverHighlightMode(mode === 'delete' ? 'delete' : 'default') + + return () => { + setHoverHighlightMode('default') + } + }, [mode, setHoverHighlightMode]) + useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Meta') modifierKeysRef.current.meta = true @@ -314,6 +422,12 @@ export const SelectionManager = () => { nodeToSelect = parentNode } } + if (node.type === 'stair-segment' && node.parentId) { + const parentNode = useScene.getState().nodes[node.parentId as AnyNodeId] + if (parentNode && parentNode.type === 'stair') { + nodeToSelect = parentNode + } + } activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current) @@ -333,6 +447,8 @@ export const SelectionManager = () => { 'ceiling', 'roof', 'roof-segment', + 'stair', + 'stair-segment', 'window', 'door', ] @@ -343,8 +459,15 @@ export const SelectionManager = () => { const onGridClick = () => { if (clickHandledRef.current) return if (boxSelectHandled) return - const activeStrategy = SELECTION_STRATEGIES[useEditor.getState().phase] + const { phase, structureLayer } = useEditor.getState() + const activeStrategy = SELECTION_STRATEGIES[phase] if (activeStrategy) activeStrategy.handleDeselect() + + // When deselecting from zone mode, return to structure select + if (phase === 'structure' && structureLayer === 'zones') { + useEditor.getState().setStructureLayer('elements') + useEditor.getState().setMode('select') + } } emitter.on('grid:click', onGridClick) @@ -415,6 +538,8 @@ export const SelectionManager = () => { node.type === 'ceiling' || node.type === 'roof' || node.type === 'roof-segment' || + node.type === 'stair' || + node.type === 'stair-segment' || node.type === 'window' || node.type === 'door' ) { @@ -422,6 +547,9 @@ export const SelectionManager = () => { if (node.type === 'roof-segment' && currentPhase === 'structure') { forceSelect = true // allow double click to dive into roof-segment even if already in structure phase } + if (node.type === 'stair-segment' && currentPhase === 'structure') { + forceSelect = true // allow double click to dive into stair-segment even if already in structure phase + } } else if (node.type === 'item') { const item = node as ItemNode if (item.asset.category === 'door' || item.asset.category === 'window') { @@ -461,6 +589,8 @@ export const SelectionManager = () => { 'ceiling', 'roof', 'roof-segment', + 'stair', + 'stair-segment', 'window', 'door', 'zone', @@ -529,6 +659,8 @@ export const SelectionManager = () => { 'ceiling', 'roof', 'roof-segment', + 'stair', + 'stair-segment', 'window', 'door', 'zone', @@ -553,6 +685,7 @@ export const SelectionManager = () => { return ( <> + ) @@ -590,9 +723,127 @@ const SelectionStateSync = () => { return null } +const SelectionMaterialSync = () => { + const selectedIds = useViewer((s) => s.selection.selectedIds) + const previewSelectedIds = useViewer((s) => s.previewSelectedIds) + const hoveredId = useViewer((s) => s.hoveredId) + const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode) + const activeHighlightKindsRef = useRef(new Map()) + const highlightedMaterialsRef = useRef( + new Map< + Mesh, + { + originalMaterial: Material | Material[] + highlightedMaterial: Material | Material[] + kind: HighlightKind + } + >(), + ) + + const syncSelectionMaterials = useCallback(() => { + const activeMeshes = new Set() + + for (const [id, kind] of activeHighlightKindsRef.current.entries()) { + const node = useScene.getState().nodes[id as AnyNodeId] + if (node?.type === 'wall') { + continue + } + + const rootObject = sceneRegistry.nodes.get(id) + if (!rootObject) { + continue + } + + rootObject.traverse((child) => { + if (!isHighlightableMesh(child)) { + return + } + + activeMeshes.add(child) + const existingEntry = highlightedMaterialsRef.current.get(child) + if (existingEntry) { + const materialWasOverwritten = child.material !== existingEntry.highlightedMaterial + if (materialWasOverwritten || existingEntry.kind !== kind) { + disposeHighlightedMaterials(existingEntry.highlightedMaterial) + const originalMaterial = materialWasOverwritten + ? child.material + : existingEntry.originalMaterial + const highlightedMaterial = createHighlightedMaterials(originalMaterial, kind) + child.material = highlightedMaterial + highlightedMaterialsRef.current.set(child, { + originalMaterial, + highlightedMaterial, + kind, + }) + } + return + } + + const originalMaterial = child.material + const highlightedMaterial = createHighlightedMaterials(originalMaterial, kind) + child.material = highlightedMaterial + highlightedMaterialsRef.current.set(child, { + originalMaterial, + highlightedMaterial, + kind, + }) + }) + } + + for (const [mesh, entry] of highlightedMaterialsRef.current.entries()) { + if (activeMeshes.has(mesh)) { + continue + } + + if (mesh.material === entry.highlightedMaterial) { + mesh.material = entry.originalMaterial + } + disposeHighlightedMaterials(entry.highlightedMaterial) + highlightedMaterialsRef.current.delete(mesh) + } + }, []) + + useEffect(() => { + const nextHighlightKinds = new Map() + + for (const id of new Set([...selectedIds, ...previewSelectedIds])) { + nextHighlightKinds.set(id, 'selection') + } + + if (hoverHighlightMode === 'delete' && hoveredId) { + nextHighlightKinds.set(hoveredId, 'delete') + } + + activeHighlightKindsRef.current = nextHighlightKinds + syncSelectionMaterials() + }, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials]) + + useEffect(() => { + return useScene.subscribe(() => { + syncSelectionMaterials() + }) + }, [syncSelectionMaterials]) + + useEffect(() => { + return () => { + for (const [mesh, entry] of highlightedMaterialsRef.current.entries()) { + if (mesh.material === entry.highlightedMaterial) { + mesh.material = entry.originalMaterial + } + disposeHighlightedMaterials(entry.highlightedMaterial) + } + + highlightedMaterialsRef.current.clear() + } + }, []) + + return null +} + const EditorOutlinerSync = () => { const phase = useEditor((s) => s.phase) const selection = useViewer((s) => s.selection) + const previewSelectedIds = useViewer((s) => s.previewSelectedIds) const hoveredId = useViewer((s) => s.hoveredId) const outliner = useViewer((s) => s.outliner) @@ -609,19 +860,23 @@ const EditorOutlinerSync = () => { case 'structure': // Highlight selected items (walls/slabs) // We IGNORE buildingId even if it's set in the store - idsToHighlight = selection.selectedIds + idsToHighlight = Array.from(new Set([...selection.selectedIds, ...previewSelectedIds])) break case 'furnish': // Highlight selected furniture/items - idsToHighlight = selection.selectedIds + idsToHighlight = Array.from(new Set([...selection.selectedIds, ...previewSelectedIds])) break default: // Pure Viewer mode: Highlight based on the "deepest" selection - if (selection.selectedIds.length > 0) idsToHighlight = selection.selectedIds - else if (selection.levelId) idsToHighlight = [selection.levelId] - else if (selection.buildingId) idsToHighlight = [selection.buildingId] + if (selection.selectedIds.length > 0 || previewSelectedIds.length > 0) { + idsToHighlight = Array.from(new Set([...selection.selectedIds, ...previewSelectedIds])) + } else if (selection.levelId) { + idsToHighlight = [selection.levelId] + } else if (selection.buildingId) { + idsToHighlight = [selection.buildingId] + } } // 2. Sync with the imperative outliner arrays (mutate in place to keep references) @@ -636,7 +891,7 @@ const EditorOutlinerSync = () => { const obj = sceneRegistry.nodes.get(hoveredId) if (obj) outliner.hoveredObjects.push(obj) } - }, [phase, selection, hoveredId, outliner]) + }, [phase, previewSelectedIds, selection, hoveredId, outliner]) return null } diff --git a/packages/editor/src/components/editor/wall-measurement-label.tsx b/packages/editor/src/components/editor/wall-measurement-label.tsx old mode 100644 new mode 100755 index 37ad795a..b598b911 --- a/packages/editor/src/components/editor/wall-measurement-label.tsx +++ b/packages/editor/src/components/editor/wall-measurement-label.tsx @@ -58,7 +58,6 @@ export function WallMeasurementLabel() { const [wallObject, setWallObject] = useState(null) - // biome-ignore lint/correctness/useExhaustiveDependencies: reset cached object when selection changes useEffect(() => { setWallObject(null) }, [selectedId]) 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 old mode 100644 new mode 100755 index f293185a..9d2e4e36 --- a/packages/editor/src/components/systems/zone/zone-label-editor-system.tsx +++ b/packages/editor/src/components/systems/zone/zone-label-editor-system.tsx @@ -1,11 +1,12 @@ 'use client' -import { useScene, type ZoneNode } from '@pascal-app/core' +import { type AnyNodeId, emitter, useScene, type ZoneNode } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { Check, Pencil } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { useShallow } from 'zustand/react/shallow' +import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' // ─── Per-zone label editor ──────────────────────────────────────────────────── @@ -13,7 +14,13 @@ import useEditor from '../../../store/use-editor' function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) { const zone = useScene((s) => s.nodes[zoneId] as ZoneNode | undefined) const updateNode = useScene((s) => s.updateNode) + const deleteNode = useScene((s) => s.deleteNode) const setSelection = useViewer((s) => s.setSelection) + const selectedZoneId = useViewer((s) => s.selection.zoneId) + const hoveredId = useViewer((s) => s.hoveredId) + const mode = useEditor((s) => s.mode) + const isSelected = selectedZoneId === zoneId + const isDeleteHovered = mode === 'delete' && hoveredId === zoneId const [editing, setEditing] = useState(false) const [value, setValue] = useState('') const inputRef = useRef(null) @@ -27,15 +34,26 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) { // Setup: find the label element, enable pointer events, and hide the // zone-renderer's own text node (children[0]) — we replace it via portal. + // Retries via rAF because the element from drei may not exist yet at mount time. useEffect(() => { - const el = document.getElementById(`${zoneId}-label`) - if (!el) return - setLabelEl(el) + let cancelled = false + let textEl: HTMLElement | undefined - const textEl = el.children[0] as HTMLElement | undefined - if (textEl) textEl.style.display = 'none' + const tryFind = () => { + const el = document.getElementById(`${zoneId}-label`) + if (!el) { + if (!cancelled) requestAnimationFrame(tryFind) + return + } + setLabelEl(el) + textEl = el.children[0] as HTMLElement | undefined + if (textEl) textEl.style.display = 'none' + } + + tryFind() return () => { + cancelled = true if (textEl) textEl.style.display = '' } }, [zoneId]) @@ -48,6 +66,29 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) { } }, [editing]) + // Tint the label pin red when delete-hovered + useEffect(() => { + if (!labelEl) return + const pin = labelEl.querySelector('.label-pin') as HTMLElement | null + if (!pin) return + const line = pin.children[0] as HTMLElement | undefined + const circle = pin.children[1] as HTMLElement | undefined + const color = isDeleteHovered ? '#dc2626' : (zone?.color ?? '#6366f1') + if (line) line.style.backgroundColor = color + if (circle) { + circle.style.backgroundColor = color + } + if (isDeleteHovered) { + pin.style.opacity = '1' + } + return () => { + // Restore zone color + const originalColor = zone?.color ?? '#6366f1' + if (line) line.style.backgroundColor = originalColor + if (circle) circle.style.backgroundColor = originalColor + } + }, [isDeleteHovered, labelEl, zone?.color]) + const save = useCallback(() => { const trimmed = value.trim() if (trimmed !== (zone?.name ?? '')) { @@ -61,9 +102,38 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) { setEditing(false) }, [zone?.name]) + // Select zone + switch to zone mode from any mode + const selectZone = useCallback(() => { + useEditor.getState().setPhase('structure') + useEditor.getState().setStructureLayer('zones') + useEditor.getState().setMode('select') + setSelection({ zoneId }) + }, [zoneId, setSelection]) + + // Enter text editing + const enterTextEditing = useCallback(() => { + selectZone() + setValue(zoneNameRef.current) + setEditing(true) + }, [selectZone]) + + // Listen for edit-label events from the 2D floorplan (double-click on zone label) + useEffect(() => { + const handler = (event: { zoneId: string }) => { + if (event.zoneId === zoneId) { + setValue(zoneNameRef.current) + setEditing(true) + } + } + emitter.on('zone:edit-label' as any, handler as any) + return () => { + emitter.off('zone:edit-label' as any, handler as any) + } + }, [zoneId]) + if (!labelEl) return null - const shadowColor = zone?.color ?? '#6366f1' + const shadowColor = isDeleteHovered ? '#dc2626' : (zone?.color ?? '#6366f1') const textShadow = [ `-1px -1px 0 ${shadowColor}`, ` 1px -1px 0 ${shadowColor}`, @@ -151,18 +221,78 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) { ), labelEl, @@ -179,10 +309,6 @@ export function ZoneLabelEditorSystem() { .map((n) => n.id as ZoneNode['id']), ), ) - const structureLayer = useEditor((s) => s.structureLayer) - const mode = useEditor((s) => s.mode) - - if (structureLayer !== 'zones' || mode !== 'select') 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 old mode 100644 new mode 100755 index 87d1461d..e42ac385 --- a/packages/editor/src/components/systems/zone/zone-system.tsx +++ b/packages/editor/src/components/systems/zone/zone-system.tsx @@ -1,17 +1,26 @@ import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useFrame } from '@react-three/fiber' +import { type Group, MathUtils, type Mesh } from 'three' +import type { MeshBasicNodeMaterial } from 'three/webgpu' import useEditor from '../../../store/use-editor' -export const ZoneSystem = () => { - useFrame(() => { - const structureLayer = useEditor.getState().structureLayer - const levelMode = useViewer.getState().levelMode - const selectedLevelId = useViewer.getState().selection.levelId +// Disable raycasting on zone geometry so clicks pass through to items underneath. +// Zone selection in the editor is handled exclusively via the HTML label overlay. +const noopRaycast = () => {} - const visible = structureLayer === 'zones' +export const ZoneSystem = () => { + useFrame((_, delta) => { + const structureLayer = useEditor.getState().structureLayer + const editorMode = useEditor.getState().mode + const selectedLevelId = useViewer.getState().selection.levelId + const selectedZoneId = useViewer.getState().selection.zoneId + const hoveredId = useViewer.getState().hoveredId + + const zoneGeometryVisible = structureLayer === 'zones' const zones = sceneRegistry.byType.zone || new Set() const nodes = useScene.getState().nodes + const lerpSpeed = 10 * delta zones.forEach((zoneId) => { const obj = sceneRegistry.nodes.get(zoneId) @@ -19,20 +28,57 @@ export const ZoneSystem = () => { const zone = nodes[zoneId as ZoneNode['id']] as ZoneNode | undefined - // In solo mode, hide labels for zones not on the current level const isOnSelectedLevel = zone?.parentId === selectedLevelId - const hideInSoloMode = levelMode === 'solo' && selectedLevelId && !isOnSelectedLevel + const isSelected = zoneId === selectedZoneId + const isDeleteHovered = editorMode === 'delete' && hoveredId === zoneId - if (obj.visible !== visible) { - obj.visible = visible + // Keep group visible (so labels stay active), hide/show meshes only. + // Show meshes when: in zone mode, selected, or delete-hovered. + if (!obj.visible) obj.visible = true + const meshVisible = zoneGeometryVisible || isSelected || isDeleteHovered + const targetOpacity = isSelected || isDeleteHovered ? 1 : zoneGeometryVisible ? 1 : 0 + + const walls = (obj as Group).getObjectByName('walls') as Mesh | undefined + if (walls) { + walls.visible = meshVisible + const material = walls.material as MeshBasicNodeMaterial + if (material?.userData?.uOpacity) { + material.userData.uOpacity.value = MathUtils.lerp( + material.userData.uOpacity.value, + targetOpacity, + lerpSpeed, + ) + } } - // Hide label if zone layer is off OR if in solo mode on a different level - const showLabel = visible && !hideInSoloMode - const targetOpacity = showLabel ? '1' : '0' + const floor = (obj as Group).getObjectByName('floor') as Mesh | undefined + if (floor) { + floor.visible = meshVisible + const material = floor.material as MeshBasicNodeMaterial + if (material?.userData?.uOpacity) { + material.userData.uOpacity.value = MathUtils.lerp( + material.userData.uOpacity.value, + targetOpacity, + lerpSpeed, + ) + } + } + + // 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: always visible on the current level (regardless of mode) + const showLabel = !!selectedLevelId && isOnSelectedLevel + const labelOpacity = showLabel ? '1' : '0' const labelEl = document.getElementById(`${zoneId}-label`) - if (labelEl && labelEl.style.opacity !== targetOpacity) { - labelEl.style.opacity = targetOpacity + if (labelEl && labelEl.style.opacity !== labelOpacity) { + labelEl.style.opacity = labelOpacity } }) }) diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 2a01a38e..e4fc607e 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -1,4 +1,12 @@ -import type { DoorNode, ItemNode, RoofNode, RoofSegmentNode, WindowNode } from '@pascal-app/core' +import type { + DoorNode, + ItemNode, + RoofNode, + RoofSegmentNode, + StairNode, + StairSegmentNode, + WindowNode, +} from '@pascal-app/core' import { Vector3 } from 'three' import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' @@ -76,5 +84,7 @@ export const MoveTool: React.FC = () => { if (movingNode.type === 'window') return if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') return + if (movingNode.type === 'stair' || movingNode.type === 'stair-segment') + return return } diff --git a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx index d889ea89..4ae114b8 100644 --- a/packages/editor/src/components/tools/item/use-placement-coordinator.tsx +++ b/packages/editor/src/components/tools/item/use-placement-coordinator.tsx @@ -9,6 +9,7 @@ import { resolveLevelId, sceneRegistry, spatialGridManager, + useLiveTransforms, useScene, useSpatialQuery, type WallEvent, @@ -219,6 +220,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const draft = draftNode.current if (draft) draft.position = result.gridPosition + // Publish live transform for 2D floorplan + if (draft) { + useLiveTransforms.getState().set(draft.id, { + position: result.gridPosition, + rotation: cursorGroupRef.current.rotation.y, + }) + } + revalidate() } @@ -229,6 +238,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea // Preserve cursor rotation for the next draft const currentRotation: [number, number, number] = [0, cursorGroupRef.current.rotation.y, 0] + // Clear live transform before commit + if (draftNode.current) { + useLiveTransforms.getState().clear(draftNode.current.id) + } + draftNode.commit(result.nodeUpdate) if (configRef.current.onCommitted()) { draftNode.create(gridPosition.current, asset, currentRotation) @@ -353,6 +367,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (result.dirtyNodeId && posChanged) { useScene.getState().dirtyNodes.add(result.dirtyNodeId) } + + // Publish live transform for 2D floorplan + useLiveTransforms.getState().set(draft.id, { + position: result.cursorPosition, + rotation: result.cursorRotationY, + }) } } @@ -361,6 +381,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!result) return event.stopPropagation() + // Clear live transform before commit + if (draftNode.current) { + useLiveTransforms.getState().clear(draftNode.current.id) + } draftNode.commit(result.nodeUpdate) if (result.dirtyNodeId) { useScene.getState().dirtyNodes.add(result.dirtyNodeId) @@ -470,6 +494,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea draft.position = result.gridPosition const mesh = sceneRegistry.nodes.get(draft.id) if (mesh) mesh.position.set(...result.gridPosition) + + // Publish live transform for 2D floorplan + useLiveTransforms.getState().set(draft.id, { + position: result.cursorPosition, + rotation: result.cursorRotationY, + }) } revalidate() @@ -508,6 +538,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!result) return event.stopPropagation() + // Clear live transform before commit + if (draftNode.current) { + useLiveTransforms.getState().clear(draftNode.current.id) + } draftNode.commit(result.nodeUpdate) if (configRef.current.onCommitted()) { @@ -578,6 +612,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea draft.position = result.gridPosition const mesh = sceneRegistry.nodes.get(draft.id) if (mesh) mesh.position.copy(gridPosition.current) + + // Publish live transform for 2D floorplan + useLiveTransforms.getState().set(draft.id, { + position: result.cursorPosition, + rotation: cursorGroupRef.current.rotation.y, + }) } } @@ -586,6 +626,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!result) return event.stopPropagation() + // Clear live transform before commit + if (draftNode.current) { + useLiveTransforms.getState().clear(draftNode.current.id) + } draftNode.commit(result.nodeUpdate) if (configRef.current.onCommitted()) { @@ -657,6 +701,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea cursorGroupRef.current.rotation.y = newRotationY const mesh = sceneRegistry.nodes.get(draft.id) if (mesh) mesh.rotation.y = newRotationY + + // Update live transform rotation for 2D floorplan + const currentLive = useLiveTransforms.getState().get(draft.id) + if (currentLive) { + useLiveTransforms.getState().set(draft.id, { + ...currentLive, + rotation: newRotationY, + }) + } + revalidate() } } @@ -693,7 +747,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const draft = draftNode.current const dims = draft ? getScaledDimensions(draft) : (asset.dimensions ?? DEFAULT_DIMENSIONS) const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2]) - boxGeometry.translate(0, dims[1] / 2, 0) + const wallSideZOffset = asset.attachTo === 'wall-side' ? -dims[2] / 2 : 0 + boxGeometry.translate(0, dims[1] / 2, wallSideZOffset) const edgesGeometry = new EdgesGeometry(boxGeometry) edgesRef.current.geometry = edgesGeometry @@ -715,6 +770,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea emitter.on('ceiling:leave', onCeilingLeave) return () => { + // Clear live transform for any remaining draft + if (draftNode.current) { + useLiveTransforms.getState().clear(draftNode.current.id) + } draftNode.destroy() useScene.temporal.getState().resume() emitter.off('grid:move', onGridMove) @@ -793,16 +852,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea ? getScaledDimensions(initialDraft) : (config.asset.dimensions ?? DEFAULT_DIMENSIONS) const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2]) - initialBoxGeometry.translate(0, dims[1] / 2, 0) + const wallSideZOffset = config.asset.attachTo === 'wall-side' ? -dims[2] / 2 : 0 + initialBoxGeometry.translate(0, dims[1] / 2, wallSideZOffset) // Base plane geometry (colored rectangle on the ground) const basePlaneGeometry = new PlaneGeometry(dims[0], dims[2]) basePlaneGeometry.rotateX(-Math.PI / 2) // Make it horizontal - basePlaneGeometry.translate(0, 0.01, 0) // Slightly above ground to avoid z-fighting + basePlaneGeometry.translate(0, 0.01, wallSideZOffset) // Slightly above ground to avoid z-fighting return ( - + ) diff --git a/packages/editor/src/components/tools/roof/move-roof-tool.tsx b/packages/editor/src/components/tools/roof/move-roof-tool.tsx index 4128c7d6..1f246bff 100644 --- a/packages/editor/src/components/tools/roof/move-roof-tool.tsx +++ b/packages/editor/src/components/tools/roof/move-roof-tool.tsx @@ -4,7 +4,10 @@ import { type GridEvent, type RoofNode, type RoofSegmentNode, + type StairNode, + type StairSegmentNode, sceneRegistry, + useLiveTransforms, useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' @@ -14,9 +17,9 @@ import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' -export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({ - node: movingNode, -}) => { +export const MoveRoofTool: React.FC<{ + node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode +}> = ({ node: movingNode }) => { const exitMoveMode = useCallback(() => { useEditor.getState().setMovingNode(null) }, []) @@ -31,7 +34,10 @@ export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({ return [pos.x, pos.y, pos.z] } // Fallback if not registered (e.g. newly created duplicate without mesh yet) - if (movingNode.type === 'roof-segment' && movingNode.parentId) { + if ( + (movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') && + movingNode.parentId + ) { const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId] if (parentNode && 'position' in parentNode && 'rotation' in parentNode) { const parentAngle = parentNode.rotation as number @@ -95,13 +101,14 @@ export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({ // user sees the individual segment tracking the cursor. let segmentWrapperGroup: THREE.Object3D | null = null let mergedRoofMesh: THREE.Object3D | null = null - if (movingNode.type === 'roof-segment') { + if (movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') { const segmentMesh = sceneRegistry.nodes.get(movingNode.id) if (segmentMesh?.parent) { - // segmentMesh.parent = wrapper in RoofRenderer - // segmentMesh.parent.parent = the registered roof group + // segmentMesh.parent = wrapper in Roof/StairRenderer + // segmentMesh.parent.parent = the registered roof/stair group segmentWrapperGroup = segmentMesh.parent - mergedRoofMesh = segmentMesh.parent.parent?.getObjectByName('merged-roof') ?? null + const mergedName = movingNode.type === 'stair-segment' ? 'merged-stair' : 'merged-roof' + mergedRoofMesh = segmentMesh.parent.parent?.getObjectByName(mergedName) ?? null segmentWrapperGroup.visible = true if (mergedRoofMesh) mergedRoofMesh.visible = false } @@ -111,7 +118,10 @@ export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({ let localX = gridX let localZ = gridZ - if (movingNode.type === 'roof-segment' && movingNode.parentId) { + if ( + (movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') && + movingNode.parentId + ) { const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId] if (parentNode && 'position' in parentNode && 'rotation' in parentNode) { const parentObj = sceneRegistry.nodes.get(movingNode.parentId) @@ -156,6 +166,12 @@ export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({ mesh.position.x = localX mesh.position.z = localZ } + + // Publish world-space position so the 2D floorplan can track the drag + useLiveTransforms.getState().set(movingNode.id, { + position: [gridX, y, gridZ], + rotation: pendingRotation, + }) } const onGridClick = (event: GridEvent) => { @@ -181,11 +197,13 @@ export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({ sfxEmitter.emit('sfx:item-place') useViewer.getState().setSelection({ selectedIds: [movingNode.id] }) + useLiveTransforms.getState().clear(movingNode.id) exitMoveMode() event.nativeEvent?.stopPropagation?.() } const onCancel = () => { + useLiveTransforms.getState().clear(movingNode.id) if (isNew) { useScene.getState().deleteNode(movingNode.id) } else { @@ -218,6 +236,15 @@ export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({ // Directly update the Three.js mesh — no store update during drag const mesh = sceneRegistry.nodes.get(movingNode.id) if (mesh) mesh.rotation.y = pendingRotation + + // Update live transform rotation for 2D floorplan + const currentLive = useLiveTransforms.getState().get(movingNode.id) + if (currentLive) { + useLiveTransforms.getState().set(movingNode.id, { + ...currentLive, + rotation: pendingRotation, + }) + } } } @@ -231,6 +258,9 @@ export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({ if (segmentWrapperGroup) segmentWrapperGroup.visible = false if (mergedRoofMesh) mergedRoofMesh.visible = true + // Clear ephemeral live transform + useLiveTransforms.getState().clear(movingNode.id) + if (!wasCommitted) { if (isNew) { useScene.getState().deleteNode(movingNode.id) diff --git a/packages/editor/src/components/tools/select/box-select-tool.tsx b/packages/editor/src/components/tools/select/box-select-tool.tsx index 739cc3bc..5166ca31 100644 --- a/packages/editor/src/components/tools/select/box-select-tool.tsx +++ b/packages/editor/src/components/tools/select/box-select-tool.tsx @@ -16,6 +16,7 @@ import { useViewer } from '@pascal-app/viewer' import { useThree } from '@react-three/fiber' import { useEffect, useRef } from 'react' import { + Box3, BufferAttribute, BufferGeometry, DoubleSide, @@ -151,6 +152,7 @@ function pointInPolygon(x: number, z: number, polygon: [number, number][]): bool // ── Node-in-bounds checks ─────────────────────────────────────────────────── const _tempVec = new Vector3() +const _tempBox = new Box3() function getNodeWorldXZ(nodeId: string): [number, number] | null { const obj = sceneRegistry.nodes.get(nodeId) @@ -159,6 +161,26 @@ function getNodeWorldXZ(nodeId: string): [number, number] | null { return [_tempVec.x, _tempVec.z] } +function objectBoundsIntersectsBounds(nodeId: string, bounds: Bounds): boolean { + const obj = sceneRegistry.nodes.get(nodeId) + if (!obj) return false + + obj.updateWorldMatrix(true, true) + _tempBox.setFromObject(obj) + + if (_tempBox.isEmpty()) { + const xz = getNodeWorldXZ(nodeId) + return Boolean(xz && pointInBounds(xz[0], xz[1], bounds)) + } + + return !( + _tempBox.max.x < bounds.minX || + _tempBox.min.x > bounds.maxX || + _tempBox.max.z < bounds.minZ || + _tempBox.min.z > bounds.maxZ + ) +} + function collectNodeIdsInBounds(bounds: Bounds): string[] { const { levelId } = useViewer.getState().selection const { nodes } = useScene.getState() @@ -214,6 +236,10 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] { if (xz && pointInBounds(xz[0], xz[1], bounds)) { result.push(node.id) } + } else if (node.type === 'stair') { + if (objectBoundsIntersectsBounds(node.id, bounds)) { + result.push(node.id) + } } } } else if (phase === 'structure' && structureLayer === 'zones') { @@ -243,6 +269,13 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] { return result } +function haveSameIds(currentIds: string[], nextIds: string[]): boolean { + return ( + currentIds.length === nextIds.length && + currentIds.every((currentId, index) => currentId === nextIds[index]) + ) +} + // ── Visual helpers ────────────────────────────────────────────────────────── function updateRectVisuals( @@ -300,11 +333,11 @@ function createOutlineSegments(): LineSegments { geo.setAttribute('position', new BufferAttribute(positions, 3)) const mat = new LineBasicMaterial({ - color: '#818cf8', + color: BOX_SELECT_ACCENT_COLOR, depthTest: false, depthWrite: false, transparent: true, - opacity: 0.6, + opacity: 0.85, }) const segments = new LineSegments(geo, mat) @@ -318,8 +351,18 @@ function createOutlineSegments(): LineSegments { // ── Drag threshold (pixels) ───────────────────────────────────────────────── +const BOX_SELECT_ACCENT_COLOR = '#818cf8' const DRAG_THRESHOLD_PX = 4 +function getSnappedGridPosition(x: number, z: number): [number, number] { + return [Math.round(x * 2) / 2, Math.round(z * 2) / 2] +} + +function setSnappedPoint(target: Vector3, x: number, y: number, z: number) { + const [snappedX, snappedZ] = getSnappedGridPosition(x, z) + target.set(snappedX, y, snappedZ) +} + // ── Component ─────────────────────────────────────────────────────────────── export const BoxSelectTool: React.FC = () => { @@ -344,6 +387,7 @@ const BOX_SELECT_TOOLTIP = ( const BoxSelectToolInner: React.FC = () => { const { camera, gl } = useThree() + const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds) const cursorRef = useRef(null) const rectFillRef = useRef(null!) const outlineRef = useRef(createOutlineSegments()) @@ -354,7 +398,8 @@ const BoxSelectToolInner: React.FC = () => { const startClientX = useRef(0) const startClientY = useRef(0) const gridY = useRef(0) - const prevHitCount = useRef(0) + const previousGridPosition = useRef<[number, number] | null>(null) + const previewSelectedIdsRef = useRef([]) // Raycasting helpers (same technique as useGridEvents) const raycasterRef = useRef(new Raycaster()) @@ -366,10 +411,21 @@ const BoxSelectToolInner: React.FC = () => { useEffect(() => { const outline = outlineRef.current return () => { + previewSelectedIdsRef.current = [] + setPreviewSelectedIds([]) outline.geometry.dispose() ;(outline.material as LineBasicMaterial).dispose() } - }, []) + }, [setPreviewSelectedIds]) + + const syncPreviewSelectedIds = (nextIds: string[]) => { + if (haveSameIds(previewSelectedIdsRef.current, nextIds)) { + return + } + + previewSelectedIdsRef.current = nextIds + setPreviewSelectedIds(nextIds) + } // Sync ground plane Y with the current level useEffect(() => { @@ -409,14 +465,15 @@ const BoxSelectToolInner: React.FC = () => { const point = raycastToGround(e) if (!point) return - startPoint.current.copy(point) - currentPoint.current.copy(point) + setSnappedPoint(startPoint.current, point.x, point.y, point.z) + setSnappedPoint(currentPoint.current, point.x, point.y, point.z) gridY.current = point.y pointerDown.current = true isDragging.current = false - prevHitCount.current = 0 + previousGridPosition.current = getSnappedGridPosition(point.x, point.z) startClientX.current = e.clientX startClientY.current = e.clientY + syncPreviewSelectedIds([]) } const onCanvasPointerUp = (e: PointerEvent) => { @@ -425,7 +482,7 @@ const BoxSelectToolInner: React.FC = () => { if (isDragging.current) { const point = raycastToGround(e) - if (point) currentPoint.current.copy(point) + if (point) setSnappedPoint(currentPoint.current, point.x, point.y, point.z) const bounds: Bounds = { minX: Math.min(startPoint.current.x, currentPoint.current.x), @@ -465,6 +522,7 @@ const BoxSelectToolInner: React.FC = () => { // Hide visuals if (rectFillRef.current) rectFillRef.current.visible = false if (outlineRef.current) outlineRef.current.visible = false + syncPreviewSelectedIds([]) // Reset pointerDown.current = false @@ -483,14 +541,16 @@ const BoxSelectToolInner: React.FC = () => { // grid:move for cursor tracking + rectangle update during drag useEffect(() => { const onMove = (event: GridEvent) => { + const [snappedX, snappedZ] = getSnappedGridPosition(event.position[0], event.position[2]) + // Always update cursor position if (cursorRef.current) { - cursorRef.current.position.set(event.position[0], event.position[1], event.position[2]) + cursorRef.current.position.set(snappedX, event.position[1], snappedZ) } if (!pointerDown.current) return - currentPoint.current.set(event.position[0], event.position[1], event.position[2]) + currentPoint.current.set(snappedX, event.position[1], snappedZ) // Check drag threshold (screen pixels) const nativeEvent = event.nativeEvent as unknown as PointerEvent @@ -509,18 +569,23 @@ const BoxSelectToolInner: React.FC = () => { gridY.current, ) - // Play snap sound when the set of captured nodes changes + const nextGridPosition: [number, number] = [snappedX, snappedZ] + if ( + previousGridPosition.current && + (nextGridPosition[0] !== previousGridPosition.current[0] || + nextGridPosition[1] !== previousGridPosition.current[1]) + ) { + sfxEmitter.emit('sfx:grid-snap') + } + previousGridPosition.current = nextGridPosition + const bounds: Bounds = { minX: Math.min(startPoint.current.x, currentPoint.current.x), maxX: Math.max(startPoint.current.x, currentPoint.current.x), minZ: Math.min(startPoint.current.z, currentPoint.current.z), maxZ: Math.max(startPoint.current.z, currentPoint.current.z), } - const hitCount = collectNodeIdsInBounds(bounds).length - if (hitCount !== prevHitCount.current) { - sfxEmitter.emit('sfx:grid-snap') - prevHitCount.current = hitCount - } + syncPreviewSelectedIds(collectNodeIdsInBounds(bounds)) } } @@ -545,10 +610,10 @@ const BoxSelectToolInner: React.FC = () => { > diff --git a/packages/editor/src/components/tools/stair/stair-defaults.ts b/packages/editor/src/components/tools/stair/stair-defaults.ts new file mode 100644 index 00000000..6ccd1f5e --- /dev/null +++ b/packages/editor/src/components/tools/stair/stair-defaults.ts @@ -0,0 +1,7 @@ +export const DEFAULT_STAIR_WIDTH = 1.0 +export const DEFAULT_STAIR_LENGTH = 3.0 +export const DEFAULT_STAIR_HEIGHT = 2.5 +export const DEFAULT_STAIR_STEP_COUNT = 10 +export const DEFAULT_STAIR_ATTACHMENT_SIDE = 'front' as const +export const DEFAULT_STAIR_FILL_TO_FLOOR = true +export const DEFAULT_STAIR_THICKNESS = 0.25 diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index 46b2365f..17786757 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -12,45 +12,48 @@ import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three' import { sfxEmitter } from '../../../lib/sfx-bus' import { CursorSphere } from '../shared/cursor-sphere' +import { + DEFAULT_STAIR_ATTACHMENT_SIDE, + DEFAULT_STAIR_FILL_TO_FLOOR, + DEFAULT_STAIR_HEIGHT, + DEFAULT_STAIR_LENGTH, + DEFAULT_STAIR_STEP_COUNT, + DEFAULT_STAIR_THICKNESS, + DEFAULT_STAIR_WIDTH, +} from './stair-defaults' const GRID_OFFSET = 0.02 -// Default stair segment dimensions -const DEFAULT_WIDTH = 1.0 -const DEFAULT_LENGTH = 3.0 -const DEFAULT_HEIGHT = 2.5 -const DEFAULT_STEP_COUNT = 10 - /** * Generates the step-profile geometry for the ghost preview. * Same algorithm as StairSystem's generateStairSegmentGeometry. */ function createStairPreviewGeometry(): THREE.BufferGeometry { - const riserHeight = DEFAULT_HEIGHT / DEFAULT_STEP_COUNT - const treadDepth = DEFAULT_LENGTH / DEFAULT_STEP_COUNT + const riserHeight = DEFAULT_STAIR_HEIGHT / DEFAULT_STAIR_STEP_COUNT + const treadDepth = DEFAULT_STAIR_LENGTH / DEFAULT_STAIR_STEP_COUNT const shape = new THREE.Shape() shape.moveTo(0, 0) - for (let i = 0; i < DEFAULT_STEP_COUNT; i++) { + for (let i = 0; i < DEFAULT_STAIR_STEP_COUNT; i++) { shape.lineTo(i * treadDepth, (i + 1) * riserHeight) shape.lineTo((i + 1) * treadDepth, (i + 1) * riserHeight) } // Fill to floor (absoluteHeight = 0) - shape.lineTo(DEFAULT_LENGTH, 0) + shape.lineTo(DEFAULT_STAIR_LENGTH, 0) shape.lineTo(0, 0) const geometry = new THREE.ExtrudeGeometry(shape, { steps: 1, - depth: DEFAULT_WIDTH, + depth: DEFAULT_STAIR_WIDTH, bevelEnabled: false, }) // Rotate so extrusion is along X (width), shape profile in XZ plane const matrix = new THREE.Matrix4() matrix.makeRotationY(-Math.PI / 2) - matrix.setPosition(DEFAULT_WIDTH / 2, 0, 0) + matrix.setPosition(DEFAULT_STAIR_WIDTH / 2, 0, 0) geometry.applyMatrix4(matrix) return geometry @@ -71,12 +74,13 @@ function commitStairPlacement( const segment = StairSegmentNode.parse({ segmentType: 'stair', - width: DEFAULT_WIDTH, - length: DEFAULT_LENGTH, - height: DEFAULT_HEIGHT, - stepCount: DEFAULT_STEP_COUNT, - attachmentSide: 'front', - fillToFloor: true, + width: DEFAULT_STAIR_WIDTH, + length: DEFAULT_STAIR_LENGTH, + height: DEFAULT_STAIR_HEIGHT, + stepCount: DEFAULT_STAIR_STEP_COUNT, + attachmentSide: DEFAULT_STAIR_ATTACHMENT_SIDE, + fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR, + thickness: DEFAULT_STAIR_THICKNESS, position: [0, 0, 0], }) diff --git a/packages/editor/src/components/tools/wall/wall-drafting.ts b/packages/editor/src/components/tools/wall/wall-drafting.ts old mode 100644 new mode 100755 index c8b28a00..a69256af --- a/packages/editor/src/components/tools/wall/wall-drafting.ts +++ b/packages/editor/src/components/tools/wall/wall-drafting.ts @@ -1,32 +1,40 @@ import { useScene, type WallNode, WallNode as WallSchema } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { sfxEmitter } from '../../../lib/sfx-bus' + export type WallPlanPoint = [number, number] + export const WALL_GRID_STEP = 0.5 export const WALL_JOIN_SNAP_RADIUS = 0.35 -export const WALL_MIN_LENGTH = 0.5 +export const WALL_MIN_LENGTH = 0.01 + function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number { const dx = a[0] - b[0] const dz = a[1] - b[1] return dx * dx + dz * dz } + function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number { return Math.round(value / step) * step } + export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): WallPlanPoint { return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)] } + export function snapPointTo45Degrees(start: WallPlanPoint, cursor: WallPlanPoint): WallPlanPoint { const dx = cursor[0] - start[0] const dz = cursor[1] - start[1] const angle = Math.atan2(dz, dx) const snappedAngle = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4) const distance = Math.sqrt(dx * dx + dz * dz) + return snapPointToGrid([ start[0] + Math.cos(snappedAngle) * distance, start[1] + Math.sin(snappedAngle) * distance, ]) } + function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null { const [x1, z1] = wall.start const [x2, z2] = wall.end @@ -36,12 +44,15 @@ function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoi if (lengthSquared < 1e-9) { return null } + const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared if (t <= 0 || t >= 1) { return null } + return [x1 + dx * t, z1 + dz * t] } + export function findWallSnapTarget( point: WallPlanPoint, walls: WallNode[], @@ -51,10 +62,12 @@ export function findWallSnapTarget( const radiusSquared = (options?.radius ?? WALL_JOIN_SNAP_RADIUS) ** 2 let bestTarget: WallPlanPoint | null = null let bestDistanceSquared = Number.POSITIVE_INFINITY + for (const wall of walls) { if (ignoreWallIds.has(wall.id)) { continue } + const candidates: Array = [ wall.start, wall.end, @@ -64,6 +77,7 @@ export function findWallSnapTarget( if (!candidate) { continue } + const candidateDistanceSquared = distanceSquared(point, candidate) if ( candidateDistanceSquared > radiusSquared || @@ -71,12 +85,15 @@ export function findWallSnapTarget( ) { continue } + bestTarget = candidate bestDistanceSquared = candidateDistanceSquared } } + return bestTarget } + export function snapWallDraftPoint(args: { point: WallPlanPoint walls: WallNode[] @@ -86,31 +103,38 @@ export function snapWallDraftPoint(args: { }): WallPlanPoint { const { point, walls, start, angleSnap = false, ignoreWallIds } = args const basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point) + return ( findWallSnapTarget(basePoint, walls, { ignoreWallIds, }) ?? basePoint ) } + export function isWallLongEnough(start: WallPlanPoint, end: WallPlanPoint): boolean { return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH } + export function createWallOnCurrentLevel( start: WallPlanPoint, end: WallPlanPoint, ): WallNode | null { const currentLevelId = useViewer.getState().selection.levelId const { createNode, nodes } = useScene.getState() + if (!(currentLevelId && isWallLongEnough(start, end))) { return null } + const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length const wall = WallSchema.parse({ name: `Wall ${wallCount + 1}`, start, end, }) + createNode(wall, currentLevelId) sfxEmitter.emit('sfx:structure-build') + return wall } diff --git a/packages/editor/src/components/tools/wall/wall-tool.tsx b/packages/editor/src/components/tools/wall/wall-tool.tsx old mode 100644 new mode 100755 index 1ecf6576..7dd467be --- a/packages/editor/src/components/tools/wall/wall-tool.tsx +++ b/packages/editor/src/components/tools/wall/wall-tool.tsx @@ -6,12 +6,7 @@ import { markToolCancelConsumed } from '../../../hooks/use-keyboard' import { EDITOR_LAYER } from '../../../lib/constants' import { sfxEmitter } from '../../../lib/sfx-bus' import { CursorSphere } from '../shared/cursor-sphere' -import { - createWallOnCurrentLevel, - snapWallDraftPoint, - WALL_MIN_LENGTH, - type WallPlanPoint, -} from './wall-drafting' +import { createWallOnCurrentLevel, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting' const WALL_HEIGHT = 2.5 @@ -23,7 +18,7 @@ const updateWallPreview = (mesh: Mesh, start: Vector3, end: Vector3) => { const direction = new Vector3(end.x - start.x, 0, end.z - start.z) const length = direction.length() - if (length < WALL_MIN_LENGTH) { + if (length < 0.01) { mesh.visible = false return } @@ -148,7 +143,7 @@ export const WallTool: React.FC = () => { endingPoint.current.set(snappedEnd[0], event.position[1], snappedEnd[1]) const dx = endingPoint.current.x - startingPoint.current.x const dz = endingPoint.current.z - startingPoint.current.z - if (dx * dx + dz * dz < WALL_MIN_LENGTH * WALL_MIN_LENGTH) return + if (dx * dx + dz * dz < 0.01 * 0.01) return createWallOnCurrentLevel( [startingPoint.current.x, startingPoint.current.z], [endingPoint.current.x, endingPoint.current.z], diff --git a/packages/editor/src/components/tools/zone/zone-tool.tsx b/packages/editor/src/components/tools/zone/zone-tool.tsx old mode 100644 new mode 100755 index 76c3b099..46ae0ab3 --- a/packages/editor/src/components/tools/zone/zone-tool.tsx +++ b/packages/editor/src/components/tools/zone/zone-tool.tsx @@ -2,7 +2,6 @@ import { emitter, type GridEvent, type LevelNode, useScene, ZoneNode } from '@pa import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three' -import { PALETTE_COLORS } from './../../../components/ui/primitives/color-dot' import { EDITOR_LAYER } from './../../../lib/constants' import useEditor from './../../../store/use-editor' import { CursorSphere } from '../shared/cursor-sphere' @@ -55,8 +54,8 @@ const commitZoneDrawing = (levelId: LevelNode['id'], points: Array<[number, numb const zoneCount = Object.values(nodes).filter((n) => n.type === 'zone').length const name = `Zone ${zoneCount + 1}` - // Cycle through colors - const color = PALETTE_COLORS[zoneCount % PALETTE_COLORS.length] + // Default to blue, cycle through palette for subsequent zones + const color = '#3b82f6' const zone = ZoneNode.parse({ name, diff --git a/packages/editor/src/components/ui/action-menu/camera-actions.tsx b/packages/editor/src/components/ui/action-menu/camera-actions.tsx old mode 100644 new mode 100755 index 71f2263f..f65363cc --- a/packages/editor/src/components/ui/action-menu/camera-actions.tsx +++ b/packages/editor/src/components/ui/action-menu/camera-actions.tsx @@ -1,97 +1,74 @@ -'use client' - -import { Icon } from '@iconify/react' -import { emitter } from '@pascal-app/core' -import Image from 'next/image' -import useEditor from '../../../store/use-editor' -import { ActionButton } from './action-button' - -export function CameraActions() { - const goToTopView = () => { - emitter.emit('camera-controls:top-view') - } - - const orbitCW = () => { - emitter.emit('camera-controls:orbit-cw') - } - - const orbitCCW = () => { - emitter.emit('camera-controls:orbit-ccw') - } - - const enterStreetView = () => { - useEditor.getState().setFirstPersonMode(true) - } - - return ( -
- {/* Orbit CCW */} - - Orbit Left - - - {/* Orbit CW */} - - Orbit Right - - - {/* Top View */} - - Top View - - - {/* Street View */} - - - -
- ) -} +'use client' + +import { emitter } from '@pascal-app/core' +import Image from 'next/image' +import { ActionButton } from './action-button' + +export function CameraActions() { + const goToTopView = () => { + emitter.emit('camera-controls:top-view') + } + + const orbitCW = () => { + emitter.emit('camera-controls:orbit-cw') + } + + const orbitCCW = () => { + emitter.emit('camera-controls:orbit-ccw') + } + + return ( +
+ {/* Orbit CCW */} + + Orbit Left + + + {/* Orbit CW */} + + Orbit Right + + + {/* Top View */} + + Top View + +
+ ) +} diff --git a/packages/editor/src/components/ui/action-menu/control-modes.tsx b/packages/editor/src/components/ui/action-menu/control-modes.tsx old mode 100644 new mode 100755 index 7c99e594..5d4c0c4b --- a/packages/editor/src/components/ui/action-menu/control-modes.tsx +++ b/packages/editor/src/components/ui/action-menu/control-modes.tsx @@ -9,7 +9,7 @@ import { cn } from './../../../lib/utils' import useEditor from './../../../store/use-editor' import { ActionButton } from './action-button' -type ControlId = 'select' | 'box-select' | 'site-edit' | 'build' | 'delete' +type ControlId = 'select' | 'box-select' | 'site-edit' | 'build' | 'furnish' | 'zone' | 'delete' type ControlConfig = { id: ControlId @@ -54,6 +54,22 @@ const controls: ControlConfig[] = [ color: 'hover:bg-green-500/20 hover:text-green-400', activeColor: 'bg-green-500/20 text-green-400', }, + { + id: 'furnish', + imageSrc: '/icons/couch.png', + label: 'Furnish', + shortcut: 'F', + color: 'hover:bg-green-500/20 hover:text-green-400', + activeColor: 'bg-green-500/20 text-green-400', + }, + { + id: 'zone', + imageSrc: '/icons/zone.png', + label: 'Zone', + shortcut: 'Z', + color: 'hover:bg-green-500/20 hover:text-green-400', + activeColor: 'bg-green-500/20 text-green-400', + }, { id: 'delete', icon: Trash2, @@ -82,11 +98,18 @@ export function ControlModes() { const isGroundFloor = levelNode?.type === 'level' && levelNode.level === 0 const canEnterSiteEdit = isGroundFloor || isSiteEditing + const structureLayer = useEditor((state) => state.structureLayer) + const getIsActive = (id: ControlId): boolean => { if (isSiteEditing) return id === 'site-edit' if (id === 'select') return mode === 'select' && selectionTool === 'click' if (id === 'box-select') return mode === 'select' && selectionTool === 'marquee' if (id === 'site-edit') return false + if (id === 'build') + return mode === 'build' && phase === 'structure' && structureLayer === 'elements' + if (id === 'furnish') return mode === 'build' && phase === 'furnish' + if (id === 'zone') + return mode === 'build' && phase === 'structure' && structureLayer === 'zones' return mode === id } @@ -118,6 +141,30 @@ export function ControlModes() { } else if (id === 'box-select') { setMode('select') setSelectionTool('marquee') + } else if (id === 'build') { + // Toggle: if already in structure build, go back to select + if (getIsActive('build')) { + setMode('select') + } else { + setPhase('structure') + setStructureLayer('elements') + setMode('build') + } + } else if (id === 'furnish') { + if (getIsActive('furnish')) { + setMode('select') + } else { + setPhase('furnish') + setMode('build') + } + } else if (id === 'zone') { + if (getIsActive('zone')) { + setMode('select') + } else { + setPhase('structure') + setStructureLayer('zones') + setMode('build') + } } else { setMode(id) } diff --git a/packages/editor/src/components/ui/action-menu/view-toggles.tsx b/packages/editor/src/components/ui/action-menu/view-toggles.tsx old mode 100644 new mode 100755 index fbb38397..83f4a12f --- a/packages/editor/src/components/ui/action-menu/view-toggles.tsx +++ b/packages/editor/src/components/ui/action-menu/view-toggles.tsx @@ -8,14 +8,18 @@ import { useScene, } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { ChevronDown } from 'lucide-react' -import { useCallback, useState } from 'react' +import { ChevronDown, Plus, Trash2 } from 'lucide-react' +import { useCallback, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { cn } from '../../../lib/utils' +import { useUploadStore } from '../../../store/use-upload' import { SliderControl } from '../controls/slider-control' import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover' import { ActionButton } from './action-button' +const MAX_FILE_SIZE = 200 * 1024 * 1024 // 200MB +const ACCEPTED_FILE_TYPES = '.glb,.gltf,image/jpeg,image/png,image/webp,image/gif' + // ── Helper: get guide images for the current level ────────────────────────── function useLevelGuides(): GuideNode[] { @@ -48,12 +52,67 @@ function useLevelScans(): ScanNode[] { ) } +// ── Shared upload button for dropdowns ────────────────────────────────────── + +function UploadButton() { + const fileInputRef = useRef(null) + const levelId = useViewer((s) => s.selection.levelId) + + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!(file && levelId)) return + e.target.value = '' + + const { uploadHandler } = useUploadStore.getState() + if (!uploadHandler) return + + if (file.size > MAX_FILE_SIZE) return + + const isScan = + file.name.toLowerCase().endsWith('.glb') || file.name.toLowerCase().endsWith('.gltf') + const isImage = file.type.startsWith('image/') + if (!(isScan || isImage)) return + + const type = isScan ? 'scan' : 'guide' + + const projectId = window.location.pathname.split('/editor/')[1]?.split('/')[0] + if (!projectId) return + + useUploadStore.getState().clearUpload(levelId) + uploadHandler(projectId, levelId, file, type) + }, + [levelId], + ) + + return ( + <> + + + + ) +} + // ── Guides toggle + dropdown ──────────────────────────────────────────────── function GuidesControl() { const showGuides = useViewer((state) => state.showGuides) const setShowGuides = useViewer((state) => state.setShowGuides) const updateNode = useScene((state) => state.updateNode) + const deleteNode = useScene((state) => state.deleteNode) const [isOpen, setIsOpen] = useState(false) const guides = useLevelGuides() @@ -74,7 +133,7 @@ function GuidesControl() { className={cn( 'rounded-r-none p-0', showGuides - ? 'bg-white/10' + ? 'bg-white/15' : 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0', )} label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`} @@ -82,11 +141,16 @@ function GuidesControl() { size="icon" variant="ghost" > - Guides +
+ Guides + + {guides.length} + +
{/* Dropdown chevron */} @@ -96,7 +160,13 @@ function GuidesControl() { aria-label="Guide image settings" className={cn( 'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors', - isOpen ? 'bg-white/10' : 'opacity-60 hover:bg-white/5 hover:opacity-100', + showGuides + ? isOpen + ? 'bg-white/10' + : 'bg-white/5 hover:bg-white/8' + : isOpen + ? 'bg-white/8' + : 'opacity-60 hover:bg-white/5 hover:opacity-100', )} type="button" > @@ -116,7 +186,7 @@ function GuidesControl() { -
+

Guide images

{hasGuides && (

@@ -124,13 +194,14 @@ function GuidesControl() {

)}
+
{hasGuides ? (
{guides.map((guide, index) => (
@@ -142,6 +213,14 @@ function GuidesControl() {

{guide.name || `Guide image ${index + 1}`}

+
state.showScans) const setShowScans = useViewer((state) => state.setShowScans) const updateNode = useScene((state) => state.updateNode) + const deleteNode = useScene((state) => state.deleteNode) const [isOpen, setIsOpen] = useState(false) const scans = useLevelScans() @@ -193,7 +273,7 @@ function ScansControl() { className={cn( 'rounded-r-none p-0', showScans - ? 'bg-white/10' + ? 'bg-white/15' : 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0', )} label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`} @@ -201,7 +281,12 @@ function ScansControl() { size="icon" variant="ghost" > - Scans +
+ Scans + + {scans.length} + +
{/* Dropdown chevron */} @@ -211,7 +296,13 @@ function ScansControl() { aria-label="Scan settings" className={cn( 'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors', - isOpen ? 'bg-white/10' : 'opacity-60 hover:bg-white/5 hover:opacity-100', + showScans + ? isOpen + ? 'bg-white/10' + : 'bg-white/5 hover:bg-white/8' + : isOpen + ? 'bg-white/8' + : 'opacity-60 hover:bg-white/5 hover:opacity-100', )} type="button" > @@ -231,7 +322,7 @@ function ScansControl() { -
+

Scans

{hasScans && (

@@ -239,13 +330,14 @@ function ScansControl() {

)}
+
{hasScans ? (
{scans.map((scan, index) => (
@@ -257,6 +349,14 @@ function ScansControl() {

{scan.name || `Scan ${index + 1}`}

+
= { 'camera-scope': '', } +// --------------------------------------------------------------------------- +// Empty state fallback (force-mounted, visible only when no results) +// --------------------------------------------------------------------------- +export interface CommandPaletteEmptyAction { + icon: ReactNode + label: (query: string) => string + onSelect: (query: string) => void +} + +function EmptyActionItem({ action }: { action: CommandPaletteEmptyAction }) { + const count = useCommandState((s) => s.filtered.count) + const search = useCommandState((s) => s.search) + if (count > 0) return null + // No Command.Group wrapper — groups hide themselves when not in filtered.groups (which is + // empty when nothing matches), swallowing the force-mounted item even with forceMount on + // the item itself. + return ( + action.onSelect(search)} + value="__empty_action__" + > + + {action.icon} + + {action.label(search)} + + ) +} + // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- -export function CommandPalette() { +export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEmptyAction }) { const { open, setOpen, @@ -353,9 +385,12 @@ export function CommandPalette() {
- - No commands found. - + {(!emptyAction || page) && ( + + No commands found. + + )} + {emptyAction && !page && } {/* ── Registered page view (e.g. 'ai') ─────────────────────── */} {page && diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx old mode 100644 new mode 100755 index d74ef247..90c1feed --- a/packages/editor/src/components/ui/controls/material-picker.tsx +++ b/packages/editor/src/components/ui/controls/material-picker.tsx @@ -35,7 +35,9 @@ type MaterialPickerProps = { } export function MaterialPicker({ value, onChange }: MaterialPickerProps) { - const [showCustom, setShowCustom] = useState(value?.preset === 'custom' || !!value?.properties) + const [showCustom, setShowCustom] = useState( + value?.preset === 'custom' || !!value?.properties, + ) const currentPreset = value?.preset || 'white' const currentProps = value?.properties || DEFAULT_MATERIALS[currentPreset] @@ -60,7 +62,10 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) { } } - const handlePropertyChange = (prop: keyof typeof currentProps, val: typeof currentProps[keyof typeof currentProps]) => { + const handlePropertyChange = ( + prop: keyof typeof currentProps, + val: (typeof currentProps)[keyof typeof currentProps], + ) => { onChange({ preset: showCustom ? 'custom' : currentPreset, properties: { @@ -84,7 +89,10 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) { onClick={() => handlePresetChange(preset)} style={{ backgroundColor: PRESET_COLORS[preset], - backgroundImage: preset === 'glass' ? 'linear-gradient(135deg, rgba(255,255,255,0.3) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.3) 50%, rgba(255,255,255,0.3) 75%, transparent 75%, transparent)' : undefined, + backgroundImage: + preset === 'glass' + ? 'linear-gradient(135deg, rgba(255,255,255,0.3) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.3) 50%, rgba(255,255,255,0.3) 75%, transparent 75%, transparent)' + : undefined, backgroundSize: preset === 'glass' ? '8px 8px' : undefined, }} title={PRESET_LABELS[preset]} @@ -96,15 +104,15 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) { {showCustom && (
- + handlePropertyChange('color', e.target.value)} type="color" value={currentProps.color} /> handlePropertyChange('color', e.target.value)} type="text" value={currentProps.color} @@ -112,41 +120,45 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
- + handlePropertyChange('roughness', parseFloat(e.target.value))} + onChange={(e) => handlePropertyChange('roughness', Number.parseFloat(e.target.value))} step={0.01} type="range" value={currentProps.roughness} /> - {currentProps.roughness.toFixed(2)} + + {currentProps.roughness.toFixed(2)} +
- + handlePropertyChange('metalness', parseFloat(e.target.value))} + onChange={(e) => handlePropertyChange('metalness', Number.parseFloat(e.target.value))} step={0.01} type="range" value={currentProps.metalness} /> - {currentProps.metalness.toFixed(2)} + + {currentProps.metalness.toFixed(2)} +
- + { - const opacity = parseFloat(e.target.value) + const opacity = Number.parseFloat(e.target.value) handlePropertyChange('opacity', opacity) if (opacity < 1 && !currentProps.transparent) { handlePropertyChange('transparent', true) @@ -156,14 +168,18 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) { type="range" value={currentProps.opacity} /> - {currentProps.opacity.toFixed(2)} + + {currentProps.opacity.toFixed(2)} +
- + setValue(e.target.value)} + onClick={(e) => e.stopPropagation()} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + handleSave() + } else if (e.key === 'Escape') { + e.preventDefault() + onStopEditing() + } + }} + placeholder={defaultName} + ref={inputRef} + type="text" + value={value} + /> + ) +} + +// ── Level row with three-dot menu ─────────────────────────────────────────── + +function LevelRow({ + level, + isSelected, + onSelect, + onRequestDelete, +}: { + level: LevelNode + isSelected: boolean + onSelect: () => void + onRequestDelete: () => void +}) { + const [isEditing, setIsEditing] = useState(false) + + return ( +
+ {isEditing ? ( + setIsEditing(false)} + /> + ) : ( +
+ + + {/* Vertical three-dot menu — inside the pill */} + + + + + + + + +
+ )} +
+ ) +} + +// ── Main component ────────────────────────────────────────────────────────── + export function FloatingLevelSelector() { const selectedBuildingId = useViewer((s) => s.selection.buildingId) const levelId = useViewer((s) => s.selection.levelId) const setSelection = useViewer((s) => s.setSelection) + const createNode = useScene((s) => s.createNode) + const updateNodes = useScene((s) => s.updateNodes) + + const [deletingLevel, setDeletingLevel] = useState(null) - // Resolve the effective building ID — selected or first in scene (scalar, stable reference) const resolvedBuildingId = useScene((state) => { if (selectedBuildingId) return selectedBuildingId const first = Object.values(state.nodes).find((n) => n?.type === 'building') as @@ -23,7 +181,6 @@ export function FloatingLevelSelector() { return first?.id ?? null }) - // Get levels for the resolved building (array, useShallow for stable reference) const levels = useScene( useShallow((state) => { if (!resolvedBuildingId) return [] as LevelNode[] @@ -36,41 +193,163 @@ export function FloatingLevelSelector() { }), ) - if (levels.length <= 1) return null + const handleAddAbove = useCallback(() => { + if (!resolvedBuildingId) return + const maxLevel = levels.length > 0 ? Math.max(...levels.map((l) => l.level)) : -1 + const newLevel = LevelNode.parse({ + level: maxLevel + 1, + children: [], + parentId: resolvedBuildingId, + }) + createNode(newLevel, resolvedBuildingId) + setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id }) + }, [resolvedBuildingId, levels, createNode, setSelection]) + + const handleAddBelow = useCallback(() => { + if (!resolvedBuildingId) return + const minLevel = levels.length > 0 ? Math.min(...levels.map((l) => l.level)) : 1 + const newLevel = LevelNode.parse({ + level: minLevel - 1, + children: [], + parentId: resolvedBuildingId, + }) + createNode(newLevel, resolvedBuildingId) + setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id }) + }, [resolvedBuildingId, levels, createNode, setSelection]) + + const handleInsertBetween = useCallback( + (lowerIndex: number) => { + if (!resolvedBuildingId) return + const lower = levels[lowerIndex] + if (!lower) return + + const newLevelNumber = lower.level + 1 + const toShift = levels.filter((l) => l.level >= newLevelNumber) + if (toShift.length > 0) { + updateNodes( + toShift.map((l) => ({ + id: l.id as AnyNodeId, + data: { level: l.level + 1 } as Partial, + })), + ) + } + + const newLevel = LevelNode.parse({ + level: newLevelNumber, + children: [], + parentId: resolvedBuildingId, + }) + createNode(newLevel, resolvedBuildingId) + setSelection({ buildingId: resolvedBuildingId, levelId: newLevel.id }) + }, + [resolvedBuildingId, levels, createNode, updateNodes, setSelection], + ) + + const handleConfirmDelete = useCallback(() => { + if (!deletingLevel) return + deleteLevelWithFallbackSelection(deletingLevel.id) + setDeletingLevel(null) + }, [deletingLevel]) + + if (levels.length === 0) return null - // Display highest level at top, ground at bottom const reversedLevels = [...levels].reverse() + const addButtonClass = + 'absolute left-1/2 z-10 flex h-4 w-4 -translate-x-1/2 items-center justify-center rounded-full border border-border/80 bg-neutral-800 text-muted-foreground/60 shadow-md transition-colors hover:bg-neutral-700 hover:text-foreground' + return ( -
- {/* Outer: rounded-xl (12px) with p-1 (4px) → inner: rounded-lg (8px) for concentric radii */} -
- {reversedLevels.map((level) => { - const isSelected = level.id === levelId - return ( + <> +
+
+ {/* Floating + at top edge */} + + + {/* Floating + at bottom edge */} + + + {/* Level list */} +
+ {reversedLevels.map((level, i) => { + const isSelected = level.id === levelId + const sortedIndex = levels.indexOf(level) + const showGapBelow = i < reversedLevels.length - 1 + + return ( +
+ setDeletingLevel(level)} + onSelect={() => + setSelection( + resolvedBuildingId + ? { buildingId: resolvedBuildingId, levelId: level.id } + : { levelId: level.id }, + ) + } + /> + + {showGapBelow && ( + + )} +
+ ) + })} +
+
+
+ + {/* Delete confirmation dialog */} + !open && setDeletingLevel(null)} open={!!deletingLevel}> + + + Delete level + + Are you sure you want to delete{' '} + {deletingLevel ? getLevelDisplayLabel(deletingLevel) : ''}? All + walls, floors, and objects on this level will be permanently removed. + + + - ) - })} -
-
+ + + + + ) } diff --git a/packages/editor/src/components/ui/item-catalog/catalog-items.tsx b/packages/editor/src/components/ui/item-catalog/catalog-items.tsx old mode 100644 new mode 100755 index 1f0b7906..d2ef0b1c --- a/packages/editor/src/components/ui/item-catalog/catalog-items.tsx +++ b/packages/editor/src/components/ui/item-catalog/catalog-items.tsx @@ -1,4 +1,4 @@ -import { type AssetInput, ItemNode } from '@pascal-app/core' +import type { AssetInput } from '@pascal-app/core' export const CATALOG_ITEMS: AssetInput[] = [ { id: 'tesla', diff --git a/packages/editor/src/components/ui/panels/ceiling-panel.tsx b/packages/editor/src/components/ui/panels/ceiling-panel.tsx old mode 100644 new mode 100755 index ee85f956..263609ab --- a/packages/editor/src/components/ui/panels/ceiling-panel.tsx +++ b/packages/editor/src/components/ui/panels/ceiling-panel.tsx @@ -32,6 +32,13 @@ export function CeilingPanel() { [selectedId, updateNode], ) + const handleMaterialChange = useCallback( + (material: MaterialSchema) => { + handleUpdate({ material }) + }, + [handleUpdate], + ) + const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) setEditingHole(null) @@ -95,10 +102,6 @@ export function CeilingPanel() { [selectedId, node?.holes, handleUpdate, editingHole, setEditingHole], ) - const handleMaterialChange = useCallback((material: MaterialSchema) => { - handleUpdate({ material }) - }, [handleUpdate]) - if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null const calculateArea = (polygon: Array<[number, number]>): number => { @@ -107,12 +110,8 @@ export function CeilingPanel() { const n = polygon.length for (let i = 0; i < n; i++) { const j = (i + 1) % n - const pi = polygon[i] - const pj = polygon[j] - if (pi && pj) { - area += pi[0] * pj[1] - area -= pj[0] * pi[1] - } + area += polygon[i]?.[0] * polygon[j]?.[1] + area -= polygon[j]?.[0] * polygon[i]?.[1] } return Math.abs(area) / 2 } @@ -224,10 +223,7 @@ export function CeilingPanel() { - + ) diff --git a/packages/editor/src/components/ui/panels/door-panel.tsx b/packages/editor/src/components/ui/panels/door-panel.tsx old mode 100644 new mode 100755 index 71df522a..c5ce52b3 --- a/packages/editor/src/components/ui/panels/door-panel.tsx +++ b/packages/editor/src/components/ui/panels/door-panel.tsx @@ -1,6 +1,13 @@ 'use client' -import { type AnyNode, type AnyNodeId, type MaterialSchema, DoorNode, emitter, useScene } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + DoorNode, + emitter, + type MaterialSchema, + useScene, +} from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback } from 'react' @@ -39,6 +46,13 @@ export function DoorPanel() { [selectedId, updateNode], ) + const handleMaterialChange = useCallback( + (material: MaterialSchema) => { + handleUpdate({ material }) + }, + [handleUpdate], + ) + const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -80,10 +94,9 @@ export function DoorPanel() { }, [node, setMovingNode, setSelection]) const setSegmentHeightRatio = (segIdx: number, newVal: number) => { - if (!node) return - const numSegs = node.segments.length - const totalH = node.segments.reduce((sum, s) => sum + s.heightRatio, 0) - const normH = node.segments.map((s) => s.heightRatio / totalH) + const numSegs = node?.segments.length + const totalH = node?.segments.reduce((sum, s) => sum + s.heightRatio, 0) + const normH = node?.segments.map((s) => s.heightRatio / totalH) const clamped = Math.max(0.05, Math.min(0.95, newVal)) const neighborIdx = segIdx < numSegs - 1 ? segIdx + 1 : segIdx - 1 const delta = clamped - normH[segIdx]! @@ -563,13 +576,6 @@ export function DoorPanel() {
- - handleUpdate({ material })} - value={node.material} - /> - - } label="Move" onClick={handleMove} /> @@ -586,6 +592,9 @@ export function DoorPanel() { /> + + + ) } diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx old mode 100644 new mode 100755 index 15c3f65d..4455842c --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -37,12 +37,12 @@ export function PanelManager() { return case 'roof-segment': return - case 'slab': - return case 'stair': return case 'stair-segment': return + case 'slab': + return case 'ceiling': return case 'wall': diff --git a/packages/editor/src/components/ui/panels/roof-panel.tsx b/packages/editor/src/components/ui/panels/roof-panel.tsx old mode 100644 new mode 100755 index 175a822b..b3ced382 --- a/packages/editor/src/components/ui/panels/roof-panel.tsx +++ b/packages/editor/src/components/ui/panels/roof-panel.tsx @@ -41,6 +41,13 @@ export function RoofPanel() { [selectedId, updateNode], ) + const handleMaterialChange = useCallback( + (material: MaterialSchema) => { + handleUpdate({ material }) + }, + [handleUpdate], + ) + const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -124,10 +131,6 @@ export function RoofPanel() { setSelection({ selectedIds: [] }) }, [selectedId, node, setSelection]) - const handleMaterialChange = useCallback((material: MaterialSchema) => { - handleUpdate({ material }) - }, [handleUpdate]) - if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null const segments = (node.children ?? []) @@ -235,13 +238,6 @@ export function RoofPanel() {
- - - - } label="Move" onClick={handleMove} /> @@ -258,6 +254,9 @@ export function RoofPanel() { /> + + + ) } diff --git a/packages/editor/src/components/ui/panels/roof-segment-panel.tsx b/packages/editor/src/components/ui/panels/roof-segment-panel.tsx old mode 100644 new mode 100755 index b509a281..8df1d687 --- a/packages/editor/src/components/ui/panels/roof-segment-panel.tsx +++ b/packages/editor/src/components/ui/panels/roof-segment-panel.tsx @@ -55,6 +55,13 @@ export function RoofSegmentPanel() { [selectedId, updateNode], ) + const handleMaterialChange = useCallback( + (material: MaterialSchema) => { + handleUpdate({ material }) + }, + [handleUpdate], + ) + const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -110,10 +117,6 @@ export function RoofSegmentPanel() { } }, [selectedId, node, setSelection]) - const handleMaterialChange = useCallback((material: MaterialSchema) => { - handleUpdate({ material }) - }, [handleUpdate]) - if (!node || node.type !== 'roof-segment' || selectedIds.length !== 1) return null return ( @@ -299,13 +302,6 @@ export function RoofSegmentPanel() {
- - - - } label="Move" onClick={handleMove} /> @@ -322,6 +318,9 @@ export function RoofSegmentPanel() { /> + + + ) } diff --git a/packages/editor/src/components/ui/panels/slab-panel.tsx b/packages/editor/src/components/ui/panels/slab-panel.tsx old mode 100644 new mode 100755 index 094c0227..9429aa78 --- a/packages/editor/src/components/ui/panels/slab-panel.tsx +++ b/packages/editor/src/components/ui/panels/slab-panel.tsx @@ -30,9 +30,12 @@ export function SlabPanel() { [selectedId, updateNode], ) - const handleMaterialChange = useCallback((material: MaterialSchema) => { - handleUpdate({ material }) - }, [handleUpdate]) + const handleMaterialChange = useCallback( + (material: MaterialSchema) => { + handleUpdate({ material }) + }, + [handleUpdate], + ) const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) @@ -105,12 +108,8 @@ export function SlabPanel() { const n = polygon.length for (let i = 0; i < n; i++) { const j = (i + 1) % n - const pi = polygon[i] - const pj = polygon[j] - if (pi && pj) { - area += pi[0] * pj[1] - area -= pj[0] * pi[1] - } + area += polygon[i]?.[0] * polygon[j]?.[1] + area -= polygon[j]?.[0] * polygon[i]?.[1] } return Math.abs(area) / 2 } @@ -221,12 +220,8 @@ export function SlabPanel() { />
- - + ) diff --git a/packages/editor/src/components/ui/panels/wall-panel.tsx b/packages/editor/src/components/ui/panels/wall-panel.tsx old mode 100644 new mode 100755 index 33fa1d48..3af5f402 --- a/packages/editor/src/components/ui/panels/wall-panel.tsx +++ b/packages/editor/src/components/ui/panels/wall-panel.tsx @@ -1,6 +1,12 @@ 'use client' -import { type AnyNode, type AnyNodeId, type MaterialSchema, useScene, type WallNode } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + type MaterialSchema, + useScene, + type WallNode, +} from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useCallback } from 'react' import { MaterialPicker } from '../controls/material-picker' @@ -26,29 +32,35 @@ export function WallPanel() { [selectedId, updateNode], ) - const handleUpdateLength = useCallback((newLength: number) => { - if (!node || newLength <= 0) return + const handleUpdateLength = useCallback( + (newLength: number) => { + if (!node || newLength <= 0) return - const dx = node.end[0] - node.start[0] - const dz = node.end[1] - node.start[1] - const currentLength = Math.sqrt(dx * dx + dz * dz) + const dx = node.end[0] - node.start[0] + const dz = node.end[1] - node.start[1] + const currentLength = Math.sqrt(dx * dx + dz * dz) - if (currentLength === 0) return + if (currentLength === 0) return - const dirX = dx / currentLength - const dirZ = dz / currentLength + const dirX = dx / currentLength + const dirZ = dz / currentLength - const newEnd: [number, number] = [ - node.start[0] + dirX * newLength, - node.start[1] + dirZ * newLength - ] + const newEnd: [number, number] = [ + node.start[0] + dirX * newLength, + node.start[1] + dirZ * newLength, + ] - handleUpdate({ end: newEnd }) - }, [node, handleUpdate]) + handleUpdate({ end: newEnd }) + }, + [node, handleUpdate], + ) - const handleMaterialChange = useCallback((material: MaterialSchema) => { - handleUpdate({ material }) - }, [handleUpdate]) + const handleMaterialChange = useCallback( + (material: MaterialSchema) => { + handleUpdate({ material }) + }, + [handleUpdate], + ) const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) @@ -104,10 +116,7 @@ export function WallPanel() { - + ) diff --git a/packages/editor/src/components/ui/panels/window-panel.tsx b/packages/editor/src/components/ui/panels/window-panel.tsx old mode 100644 new mode 100755 index 2fcb7278..fb56661b --- a/packages/editor/src/components/ui/panels/window-panel.tsx +++ b/packages/editor/src/components/ui/panels/window-panel.tsx @@ -1,6 +1,13 @@ 'use client' -import { type AnyNode, type AnyNodeId, emitter, type MaterialSchema, useScene, WindowNode } from '@pascal-app/core' +import { + type AnyNode, + type AnyNodeId, + emitter, + type MaterialSchema, + useScene, + WindowNode, +} from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { useCallback } from 'react' @@ -40,6 +47,13 @@ export function WindowPanel() { [selectedId, updateNode], ) + const handleMaterialChange = useCallback( + (material: MaterialSchema) => { + handleUpdate({ material }) + }, + [handleUpdate], + ) + const handleClose = useCallback(() => { setSelection({ selectedIds: [] }) }, [setSelection]) @@ -139,10 +153,6 @@ export function WindowPanel() { [handleUpdate], ) - const handleMaterialChange = useCallback((material: MaterialSchema) => { - handleUpdate({ material }) - }, [handleUpdate]) - if (!node || node.type !== 'window' || selectedIds.length !== 1) return null const numCols = node.columnRatios.length @@ -407,13 +417,6 @@ export function WindowPanel() { )} - - - - } label="Move" onClick={handleMove} /> @@ -430,6 +433,9 @@ export function WindowPanel() { /> + + + ) } diff --git a/packages/editor/src/components/ui/primitives/sidebar.tsx b/packages/editor/src/components/ui/primitives/sidebar.tsx old mode 100644 new mode 100755 index 09140e61..6ec2ea1b --- a/packages/editor/src/components/ui/primitives/sidebar.tsx +++ b/packages/editor/src/components/ui/primitives/sidebar.tsx @@ -32,7 +32,6 @@ const SIDEBAR_WIDTH = '18rem' const SIDEBAR_WIDTH_MOBILE = '18rem' const SIDEBAR_WIDTH_ICON = '3rem' const SIDEBAR_KEYBOARD_SHORTCUT = 'b' - const SIDEBAR_COLLAPSE_THRESHOLD = 220 const SIDEBAR_MAX_WIDTH = 800 diff --git a/packages/editor/src/components/ui/sidebar/app-sidebar.tsx b/packages/editor/src/components/ui/sidebar/app-sidebar.tsx old mode 100644 new mode 100755 index 08f9f0ff..383d2867 --- a/packages/editor/src/components/ui/sidebar/app-sidebar.tsx +++ b/packages/editor/src/components/ui/sidebar/app-sidebar.tsx @@ -1,16 +1,19 @@ 'use client' -import { type ReactNode, useEffect, useState } from 'react' -import { CommandPalette } from './../../../components/ui/command-palette' +import { type ReactNode, useEffect } from 'react' +import { + CommandPalette, + type CommandPaletteEmptyAction, +} from './../../../components/ui/command-palette' import { EditorCommands } from './../../../components/ui/command-palette/editor-commands' import { - Sidebar, SidebarContent, SidebarHeader, useSidebarStore, } from './../../../components/ui/primitives/sidebar' import { cn } from './../../../lib/utils' -import { IconRail, type PanelId } from './icon-rail' +import useEditor from './../../../store/use-editor' +import { type ExtraPanel, IconRail } from './icon-rail' import { SettingsPanel, type SettingsPanelProps } from './panels/settings-panel' import { SitePanel, type SitePanelProps } from './panels/site-panel' @@ -19,6 +22,8 @@ interface AppSidebarProps { sidebarTop?: ReactNode settingsPanelProps?: SettingsPanelProps sitePanelProps?: SitePanelProps + extraPanels?: ExtraPanel[] + commandPaletteEmptyAction?: CommandPaletteEmptyAction } export function AppSidebar({ @@ -26,8 +31,15 @@ export function AppSidebar({ sidebarTop, settingsPanelProps, sitePanelProps, + extraPanels, + commandPaletteEmptyAction, }: AppSidebarProps) { - const [activePanel, setActivePanel] = useState('site') + const activePanel = useEditor((s) => s.activeSidebarPanel) + const setActivePanel = useEditor((s) => s.setActiveSidebarPanel) + const hasActivePanel = + activePanel === 'site' || + activePanel === 'settings' || + Boolean(extraPanels?.some((panel) => panel.id === activePanel)) useEffect(() => { // Widen default sidebar (288px → 432px) for better project title visibility @@ -37,44 +49,55 @@ export function AppSidebar({ } }, []) + useEffect(() => { + if (!hasActivePanel) { + setActivePanel('site') + } + }, [hasActivePanel, setActivePanel]) + const renderPanelContent = () => { switch (activePanel) { case 'site': return case 'settings': return - default: - return null + default: { + const extra = extraPanels?.find((p) => p.id === activePanel) + if (extra) { + const Component = extra.component + return + } + return + } } } return ( <> - -
- {/* Icon Rail */} - +
+ {/* Icon Rail */} + - {/* Panel Content */} -
- {sidebarTop && ( - - {sidebarTop} - - )} + {/* Panel Content */} +
+ {sidebarTop && ( + + {sidebarTop} + + )} - - {renderPanelContent()} - -
+ + {renderPanelContent()} +
- +
- + ) } diff --git a/packages/editor/src/components/ui/sidebar/icon-rail.tsx b/packages/editor/src/components/ui/sidebar/icon-rail.tsx old mode 100644 new mode 100755 index 90008062..eb6d9eab --- a/packages/editor/src/components/ui/sidebar/icon-rail.tsx +++ b/packages/editor/src/components/ui/sidebar/icon-rail.tsx @@ -1,9 +1,6 @@ 'use client' -import { useViewer } from '@pascal-app/viewer' -import { Moon, Ruler, Sun } from 'lucide-react' -import { motion } from 'motion/react' -import { type ReactNode, useEffect, useState } from 'react' +import type { ComponentType, ReactNode } from 'react' import { Tooltip, TooltipContent, @@ -11,31 +8,39 @@ import { } from './../../../components/ui/primitives/tooltip' import { cn } from './../../../lib/utils' -export type PanelId = 'site' | 'settings' +export type PanelId = string + +export type ExtraPanel = { id: string; icon: ReactNode; label: string; component: ComponentType } interface IconRailProps { activePanel: PanelId onPanelChange: (panel: PanelId) => void appMenuButton?: ReactNode + extraPanels?: ExtraPanel[] className?: string } -const panels: { id: PanelId; iconSrc: string; label: string }[] = [ - { id: 'site', iconSrc: '/icons/level.png', label: 'Site' }, - { id: 'settings', iconSrc: '/icons/settings.png', label: 'Settings' }, -] +const sitePanel: { id: PanelId; iconSrc: string; label: string } = { + id: 'site', + iconSrc: '/icons/level.png', + label: 'Site', +} -export function IconRail({ activePanel, onPanelChange, appMenuButton, className }: IconRailProps) { - const theme = useViewer((state) => state.theme) - const setTheme = useViewer((state) => state.setTheme) - const unit = useViewer((state) => state.unit) - const setUnit = useViewer((state) => state.setUnit) - const [mounted, setMounted] = useState(false) +const settingsPanel: { id: PanelId; iconSrc: string; label: string } = { + id: 'settings', + iconSrc: '/icons/settings.png', + label: 'Settings', +} - useEffect(() => { - setMounted(true) - }, []) +const panels: { id: PanelId; iconSrc: string; label: string }[] = [sitePanel, settingsPanel] +export function IconRail({ + activePanel, + onPanelChange, + appMenuButton, + extraPanels, + className, +}: IconRailProps) { return (
- {panels.map((panel) => { + {/* Site panel */} + {[sitePanel].map((panel) => { const isActive = activePanel === panel.id return ( @@ -77,49 +83,63 @@ export function IconRail({ activePanel, onPanelChange, appMenuButton, className ) })} - {/* Spacer */} -
- - {/* Unit Toggle */} - {mounted && ( - - - - - Toggle units (metric/imperial) - - )} - - {/* Theme Toggle */} - {mounted && ( - - - - - Toggle theme - - )} + + {panel.icon} + + + + {panel.label} + + ) + })} + + {/* Settings panel */} + {[settingsPanel].map((panel) => { + const isActive = activePanel === panel.id + return ( + + + + + {panel.label} + + ) + })}
) } diff --git a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx old mode 100644 new mode 100755 index 003ba21a..be5bddc5 --- a/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/settings-panel/index.tsx @@ -18,7 +18,7 @@ import { DialogTrigger, } from './../../../../../components/ui/primitives/dialog' import { Switch } from './../../../../../components/ui/primitives/switch' -import useEditor from './../../../../../store/use-editor' +import useEditor, { selectDefaultBuildingAndLevel } from './../../../../../store/use-editor' import { AudioSettingsDialog } from './audio-settings-dialog' import { KeyboardShortcutsDialog } from './keyboard-shortcuts-dialog' @@ -202,12 +202,6 @@ export function SettingsPanel({ const isLocalProject = false // Props-based; only show cloud sections when projectId provided - const handleExport = async (format: 'glb' | 'stl' | 'obj' = 'glb') => { - if (exportScene) { - await exportScene(format) - } - } - const handleSaveBuild = () => { const sceneData = { nodes, rootNodeIds } const json = JSON.stringify(sceneData, null, 2) @@ -247,7 +241,8 @@ export function SettingsPanel({ const handleResetToDefault = () => { clearScene() resetSelection() - setPhase('site') + setPhase('structure') + selectDefaultBuildingAndLevel() } const handleGenerateThumbnail = () => { @@ -318,17 +313,29 @@ export function SettingsPanel({ {/* Export Section */}
- - -
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx old mode 100644 new mode 100755 index 04d29b6b..eea0ff1b --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/ceiling-tree-node.tsx @@ -118,12 +118,8 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number { for (let i = 0; i < n; i++) { const j = (i + 1) % n - const pi = polygon[i] - const pj = polygon[j] - if (pi && pj) { - area += pi[0] * pj[1] - area -= pj[0] * pi[1] - } + area += polygon[i]?.[0] * polygon[j]?.[1] + area -= polygon[j]?.[0] * polygon[i]?.[1] } return Math.abs(area) / 2 diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx old mode 100644 new mode 100755 index a48c036d..15dc30c3 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/index.tsx @@ -908,7 +908,7 @@ function LayerToggle() {
- S + B
diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx old mode 100644 new mode 100755 index 9811cd76..1bb75f5a --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/slab-tree-node.tsx @@ -88,12 +88,8 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number { for (let i = 0; i < n; i++) { const j = (i + 1) % n - const pi = polygon[i] - const pj = polygon[j] - if (pi && pj) { - area += pi[0] * pj[1] - area -= pj[0] * pi[1] - } + area += polygon[i]?.[0] * polygon[j]?.[1] + area -= polygon[j]?.[0] * polygon[i]?.[1] } return Math.abs(area) / 2 diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx old mode 100644 new mode 100755 index a28b4713..2145cf91 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/zone-tree-node.tsx @@ -79,12 +79,8 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number { for (let i = 0; i < n; i++) { const j = (i + 1) % n - const pi = polygon[i] - const pj = polygon[j] - if (pi && pj) { - area += pi[0] * pj[1] - area -= pj[0] * pi[1] - } + area += polygon[i]?.[0] * polygon[j]?.[1] + area -= polygon[j]?.[0] * polygon[i]?.[1] } return Math.abs(area) / 2 diff --git a/packages/editor/src/components/viewer-overlay.tsx b/packages/editor/src/components/viewer-overlay.tsx old mode 100644 new mode 100755 index 589a3415..06f19a13 --- a/packages/editor/src/components/viewer-overlay.tsx +++ b/packages/editor/src/components/viewer-overlay.tsx @@ -1,514 +1,499 @@ -'use client' - -import { Icon } from '@iconify/react' -import { - type AnyNode, - type AnyNodeId, - type BuildingNode, - emitter, - type LevelNode, - useScene, - type ZoneNode, -} from '@pascal-app/core' -import { useViewer } from '@pascal-app/viewer' -import { ArrowLeft, Camera, ChevronRight, Diamond, Layers, Moon, Footprints, Sun } from 'lucide-react' -import { motion } from 'motion/react' -import Link from 'next/link' -import { cn } from '../lib/utils' -import useEditor from '../store/use-editor' -import { ActionButton } from './ui/action-menu/action-button' -import { TooltipProvider } from './ui/primitives/tooltip' - -type ProjectOwner = { - id: string - name: string - username: string | null - image: string | null -} - -const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = { - stacked: 'Stacked', - exploded: 'Exploded', - solo: 'Solo', -} - -const levelModeBadgeLabels: Record<'manual' | 'stacked' | 'exploded' | 'solo', string> = { - manual: 'Stack', - stacked: 'Stack', - exploded: 'Exploded', - solo: 'Solo', -} - -const wallModeConfig = { - up: { - icon: (props: any) => ( - Full Height - ), - label: 'Full Height', - }, - cutaway: { - icon: (props: any) => ( - Cutaway - ), - label: 'Cutaway', - }, - down: { - icon: (props: any) => ( - Low - ), - label: 'Low', - }, -} - -const getNodeName = (node: AnyNode): string => { - if ('name' in node && node.name) return node.name - if (node.type === 'wall') return 'Wall' - if (node.type === 'item') return (node as { asset: { name: string } }).asset?.name || 'Item' - if (node.type === 'slab') return 'Slab' - if (node.type === 'ceiling') return 'Ceiling' - if (node.type === 'roof') return 'Roof' - if (node.type === 'roof-segment') return 'Roof Segment' - return node.type -} - -interface ViewerOverlayProps { - projectName?: string | null - owner?: ProjectOwner | null - canShowScans?: boolean - canShowGuides?: boolean - onBack?: () => void -} - -export const ViewerOverlay = ({ - projectName, - owner, - canShowScans = true, - canShowGuides = true, - onBack, -}: ViewerOverlayProps) => { - const selection = useViewer((s) => s.selection) - const nodes = useScene((s) => s.nodes) - const showScans = useViewer((s) => s.showScans) - const showGuides = useViewer((s) => s.showGuides) - const cameraMode = useViewer((s) => s.cameraMode) - const levelMode = useViewer((s) => s.levelMode) - const wallMode = useViewer((s) => s.wallMode) - const theme = useViewer((s) => s.theme) - - const building = selection.buildingId - ? (nodes[selection.buildingId] as BuildingNode | undefined) - : null - const level = selection.levelId ? (nodes[selection.levelId] as LevelNode | undefined) : null - const zone = selection.zoneId ? (nodes[selection.zoneId] as ZoneNode | undefined) : null - - // Get the first selected item (if any) - const selectedNode = - selection.selectedIds.length > 0 - ? (nodes[selection.selectedIds[0] as AnyNodeId] as AnyNode | undefined) - : null - - // Get all levels for the selected building - const levels = - building?.children - .map((id) => nodes[id as AnyNodeId] as LevelNode | undefined) - .filter((n): n is LevelNode => n?.type === 'level') - .sort((a, b) => a.level - b.level) ?? [] - - const handleLevelClick = (levelId: LevelNode['id']) => { - // When switching levels, deselect zone and items - useViewer.getState().setSelection({ levelId }) - } - - const handleBreadcrumbClick = (depth: 'root' | 'building' | 'level' | 'zone') => { - switch (depth) { - case 'root': - useViewer.getState().resetSelection() - break - case 'building': - useViewer.getState().setSelection({ levelId: null }) - break - case 'level': - useViewer.getState().setSelection({ zoneId: null }) - break - } - } - - return ( - <> - {/* Unified top-left card */} -
-
- {/* Project info + back */} -
- {onBack ? ( - - ) : ( - - - - )} -
-
- {projectName || 'Untitled'} -
- {owner?.username && ( - - @{owner.username} - - )} -
-
- - {/* Breadcrumb — only shown when navigated into a building */} - {building && ( -
-
- - - {building && ( - <> - - - - )} - - {level && ( - <> - - - - )} - - {zone && ( - <> - - - {zone.name} - - - )} - - {selectedNode && zone && ( - <> - - - {getNodeName(selectedNode)} - - - )} -
-
- )} -
- - {/* Level List (only when building is selected) */} - {building && levels.length > 0 && ( -
- - Levels - -
- {levels.map((lvl) => { - const isSelected = lvl.id === selection.levelId - return ( - - ) - })} -
-
- )} -
- - {/* Controls Panel - Bottom Center */} -
- -
- {/* Theme Toggle */} - - -
- - {/* Scans and Guides Visibility */} - {canShowScans && ( - useViewer.getState().setShowScans(!showScans)} - size="icon" - tooltipSide="top" - variant="ghost" - > - Scans - - )} - - {canShowGuides && ( - useViewer.getState().setShowGuides(!showGuides)} - size="icon" - tooltipSide="top" - variant="ghost" - > - Guides - - )} - - {(canShowScans || canShowGuides) &&
} - - {/* Camera Mode */} - - useViewer - .getState() - .setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective') - } - size="icon" - tooltipSide="top" - variant="ghost" - > - - - - {/* Level Mode */} - { - if (levelMode === 'manual') return useViewer.getState().setLevelMode('stacked') - const modes: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo'] - const nextIndex = (modes.indexOf(levelMode as any) + 1) % modes.length - useViewer.getState().setLevelMode(modes[nextIndex] ?? 'stacked') - }} - size="icon" - tooltipSide="top" - variant="ghost" - > - - {levelMode === 'solo' && } - {levelMode === 'exploded' && ( - - )} - {(levelMode === 'stacked' || levelMode === 'manual') && ( - - )} - - - - - {/* Wall Mode */} - { - const modes: ('cutaway' | 'up' | 'down')[] = ['cutaway', 'up', 'down'] - const nextIndex = (modes.indexOf(wallMode as any) + 1) % modes.length - useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway') - }} - size="icon" - tooltipSide="top" - variant="ghost" - > - {(() => { - const Icon = wallModeConfig[wallMode as keyof typeof wallModeConfig].icon - return - })()} - - -
- - {/* Camera Actions */} - emitter.emit('camera-controls:orbit-ccw')} - size="icon" - tooltipSide="top" - variant="ghost" - > - Orbit Left - - - emitter.emit('camera-controls:orbit-cw')} - size="icon" - tooltipSide="top" - variant="ghost" - > - Orbit Right - - - emitter.emit('camera-controls:top-view')} - size="icon" - tooltipSide="top" - variant="ghost" - > - Top View - - -
- - {/* Street View */} - useEditor.getState().setFirstPersonMode(true)} - size="icon" - tooltipSide="top" - variant="ghost" - > - - -
- -
- - ) -} +'use client' + +import { Icon } from '@iconify/react' +import { + type AnyNode, + type AnyNodeId, + type BuildingNode, + emitter, + type LevelNode, + useScene, + type ZoneNode, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { ArrowLeft, Camera, ChevronRight, Diamond, Layers, Moon, Sun } from 'lucide-react' +import { motion } from 'motion/react' +import Link from 'next/link' +import { cn } from '../lib/utils' +import { ActionButton } from './ui/action-menu/action-button' +import { TooltipProvider } from './ui/primitives/tooltip' + +type ProjectOwner = { + id: string + name: string + username: string | null + image: string | null +} + +const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = { + stacked: 'Stacked', + exploded: 'Exploded', + solo: 'Solo', +} + +const levelModeBadgeLabels: Record<'manual' | 'stacked' | 'exploded' | 'solo', string> = { + manual: 'Stack', + stacked: 'Stack', + exploded: 'Exploded', + solo: 'Solo', +} + +const wallModeConfig = { + up: { + icon: (props: any) => ( + Full Height + ), + label: 'Full Height', + }, + cutaway: { + icon: (props: any) => ( + Cutaway + ), + label: 'Cutaway', + }, + down: { + icon: (props: any) => ( + Low + ), + label: 'Low', + }, +} + +const getNodeName = (node: AnyNode): string => { + if ('name' in node && node.name) return node.name + if (node.type === 'wall') return 'Wall' + if (node.type === 'item') return (node as { asset: { name: string } }).asset?.name || 'Item' + if (node.type === 'slab') return 'Slab' + if (node.type === 'ceiling') return 'Ceiling' + if (node.type === 'roof') return 'Roof' + if (node.type === 'roof-segment') return 'Roof Segment' + return node.type +} + +interface ViewerOverlayProps { + projectName?: string | null + owner?: ProjectOwner | null + canShowScans?: boolean + canShowGuides?: boolean + onBack?: () => void +} + +export const ViewerOverlay = ({ + projectName, + owner, + canShowScans = true, + canShowGuides = true, + onBack, +}: ViewerOverlayProps) => { + const selection = useViewer((s) => s.selection) + const nodes = useScene((s) => s.nodes) + const showScans = useViewer((s) => s.showScans) + const showGuides = useViewer((s) => s.showGuides) + const cameraMode = useViewer((s) => s.cameraMode) + const levelMode = useViewer((s) => s.levelMode) + const wallMode = useViewer((s) => s.wallMode) + const theme = useViewer((s) => s.theme) + + const building = selection.buildingId + ? (nodes[selection.buildingId] as BuildingNode | undefined) + : null + const level = selection.levelId ? (nodes[selection.levelId] as LevelNode | undefined) : null + const zone = selection.zoneId ? (nodes[selection.zoneId] as ZoneNode | undefined) : null + + // Get the first selected item (if any) + const selectedNode = + selection.selectedIds.length > 0 + ? (nodes[selection.selectedIds[0] as AnyNodeId] as AnyNode | undefined) + : null + + // Get all levels for the selected building + const levels = + building?.children + .map((id) => nodes[id as AnyNodeId] as LevelNode | undefined) + .filter((n): n is LevelNode => n?.type === 'level') + .sort((a, b) => a.level - b.level) ?? [] + + const handleLevelClick = (levelId: LevelNode['id']) => { + // When switching levels, deselect zone and items + useViewer.getState().setSelection({ levelId }) + } + + const handleBreadcrumbClick = (depth: 'root' | 'building' | 'level' | 'zone') => { + switch (depth) { + case 'root': + useViewer.getState().resetSelection() + break + case 'building': + useViewer.getState().setSelection({ levelId: null }) + break + case 'level': + useViewer.getState().setSelection({ zoneId: null }) + break + } + } + + return ( + <> + {/* Unified top-left card */} +
+
+ {/* Project info + back */} +
+ {onBack ? ( + + ) : ( + + + + )} +
+
+ {projectName || 'Untitled'} +
+ {owner?.username && ( + + @{owner.username} + + )} +
+
+ + {/* Breadcrumb — only shown when navigated into a building */} + {building && ( +
+
+ + + {building && ( + <> + + + + )} + + {level && ( + <> + + + + )} + + {zone && ( + <> + + + {zone.name} + + + )} + + {selectedNode && zone && ( + <> + + + {getNodeName(selectedNode)} + + + )} +
+
+ )} +
+ + {/* Level List (only when building is selected) */} + {building && levels.length > 0 && ( +
+ + Levels + +
+ {levels.map((lvl) => { + const isSelected = lvl.id === selection.levelId + return ( + + ) + })} +
+
+ )} +
+ + {/* Controls Panel - Bottom Center */} +
+ +
+ {/* Theme Toggle */} + + +
+ + {/* Scans and Guides Visibility */} + {canShowScans && ( + useViewer.getState().setShowScans(!showScans)} + size="icon" + tooltipSide="top" + variant="ghost" + > + Scans + + )} + + {canShowGuides && ( + useViewer.getState().setShowGuides(!showGuides)} + size="icon" + tooltipSide="top" + variant="ghost" + > + Guides + + )} + + {(canShowScans || canShowGuides) &&
} + + {/* Camera Mode */} + + useViewer + .getState() + .setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective') + } + size="icon" + tooltipSide="top" + variant="ghost" + > + + + + {/* Level Mode */} + { + if (levelMode === 'manual') return useViewer.getState().setLevelMode('stacked') + const modes: ('stacked' | 'exploded' | 'solo')[] = ['stacked', 'exploded', 'solo'] + const nextIndex = (modes.indexOf(levelMode as any) + 1) % modes.length + useViewer.getState().setLevelMode(modes[nextIndex] ?? 'stacked') + }} + size="icon" + tooltipSide="top" + variant="ghost" + > + + {levelMode === 'solo' && } + {levelMode === 'exploded' && ( + + )} + {(levelMode === 'stacked' || levelMode === 'manual') && ( + + )} + + + + + {/* Wall Mode */} + { + const modes: ('cutaway' | 'up' | 'down')[] = ['cutaway', 'up', 'down'] + const nextIndex = (modes.indexOf(wallMode as any) + 1) % modes.length + useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway') + }} + size="icon" + tooltipSide="top" + variant="ghost" + > + {(() => { + const Icon = wallModeConfig[wallMode as keyof typeof wallModeConfig].icon + return + })()} + + +
+ + {/* Camera Actions */} + emitter.emit('camera-controls:orbit-ccw')} + size="icon" + tooltipSide="top" + variant="ghost" + > + Orbit Left + + + emitter.emit('camera-controls:orbit-cw')} + size="icon" + tooltipSide="top" + variant="ghost" + > + Orbit Right + + + emitter.emit('camera-controls:top-view')} + size="icon" + tooltipSide="top" + variant="ghost" + > + Top View + +
+ +
+ + ) +} diff --git a/packages/editor/src/components/viewer-zone-system.tsx b/packages/editor/src/components/viewer-zone-system.tsx old mode 100644 new mode 100755 index 7dd03ded..bbc26132 --- a/packages/editor/src/components/viewer-zone-system.tsx +++ b/packages/editor/src/components/viewer-zone-system.tsx @@ -3,10 +3,13 @@ import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useFrame } from '@react-three/fiber' +import type { Mesh } from 'three' +import useEditor from '../store/use-editor' export const ViewerZoneSystem = () => { useFrame(() => { const { levelId, zoneId } = useViewer.getState().selection + const structureLayer = useEditor.getState().structureLayer const nodes = useScene.getState().nodes sceneRegistry.byType.zone.forEach((id) => { @@ -16,16 +19,24 @@ export const ViewerZoneSystem = () => { const zone = nodes[id as ZoneNode['id']] as ZoneNode | undefined if (!zone) return - // Hide zones if: - // 1. No level is selected - // 2. Zone is not on the selected level - // 3. A zone is already selected (hide all zones to show zone contents) const isOnSelectedLevel = zone.parentId === levelId - const shouldShow = !!levelId && isOnSelectedLevel && !zoneId - obj.visible = shouldShow + // Keep group visible (so labels stay active), hide/show meshes only. + // Zone geometry: visible in zone mode on the right level, OR when this zone is selected. + // The editor ZoneSystem handles the selected zone's opacity animation. + const isSelected = id === zoneId + const shouldShowGeometry = + (structureLayer === 'zones' && !!levelId && isOnSelectedLevel) || isSelected + if (!obj.visible) obj.visible = true + obj.traverse((child) => { + if ((child as Mesh).isMesh) { + child.visible = shouldShowGeometry + } + }) - const targetOpacity = shouldShow ? '1' : '0' + // Labels: always visible on the current level (regardless of mode or zone selection) + const showLabel = !!levelId && isOnSelectedLevel + const targetOpacity = showLabel ? '1' : '0' const labelEl = document.getElementById(`${id}-label`) if (labelEl && labelEl.style.opacity !== targetOpacity) { labelEl.style.opacity = targetOpacity diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts old mode 100644 new mode 100755 index 9ea21661..5cfae479 --- a/packages/editor/src/hooks/use-keyboard.ts +++ b/packages/editor/src/hooks/use-keyboard.ts @@ -11,7 +11,7 @@ export const markToolCancelConsumed = () => { _toolCancelConsumed = true } -export const useKeyboard = () => { +export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => { useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { // Don't handle shortcuts if user is typing in an input @@ -30,9 +30,20 @@ export const useKeyboard = () => { // Only switch to select mode if no tool had an active mid-action to cancel. // (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool) if (!_toolCancelConsumed) { - // Return to the default select tool while keeping the active building/level context. + const currentPhase = useEditor.getState().phase + const currentStructureLayer = useEditor.getState().structureLayer + useEditor.getState().setEditingHole(null) - useEditor.getState().setMode('select') + + // From zone mode, return to structure select + if (currentPhase === 'structure' && currentStructureLayer === 'zones') { + useEditor.getState().setStructureLayer('elements') + useEditor.getState().setMode('select') + } else { + // Return to the default select tool while keeping the active building/level context. + useEditor.getState().setMode('select') + } + useEditor.getState().setFloorplanSelectionTool('click') // Clear selections to close UI panels, but KEEP the active building and level context. @@ -51,29 +62,34 @@ export const useKeyboard = () => { e.preventDefault() useEditor.getState().setPhase('furnish') useEditor.getState().setMode('select') - } else if (e.key === 's' && !e.metaKey && !e.ctrlKey) { - e.preventDefault() - useEditor.getState().setPhase('structure') - useEditor.getState().setStructureLayer('elements') } else if (e.key === 'f' && !e.metaKey && !e.ctrlKey) { + if (isVersionPreviewMode) return e.preventDefault() useEditor.getState().setPhase('furnish') + useEditor.getState().setMode('build') } else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) { + if (isVersionPreviewMode) return e.preventDefault() useEditor.getState().setPhase('structure') useEditor.getState().setStructureLayer('zones') + useEditor.getState().setMode('build') } if (e.key === 'v' && !e.metaKey && !e.ctrlKey) { e.preventDefault() useEditor.getState().setMode('select') useEditor.getState().setFloorplanSelectionTool('click') } else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) { + if (isVersionPreviewMode) return e.preventDefault() + useEditor.getState().setPhase('structure') + useEditor.getState().setStructureLayer('elements') useEditor.getState().setMode('build') } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { + if (isVersionPreviewMode) return e.preventDefault() useScene.temporal.getState().undo() } else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) { + if (isVersionPreviewMode) return e.preventDefault() useScene.temporal.getState().redo() } else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) { @@ -108,7 +124,7 @@ export const useKeyboard = () => { } } } - } else if (e.key === 'r' || e.key === 'R') { + } else if ((e.key === 'r' || e.key === 'R') && !isVersionPreviewMode) { // Rotate selected node clockwise if it supports rotation (items, roofs, etc.) const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedNodeIds.length === 1) { @@ -128,7 +144,7 @@ export const useKeyboard = () => { sfxEmitter.emit('sfx:item-rotate') } } - } else if (e.key === 't' || e.key === 'T') { + } else if ((e.key === 't' || e.key === 'T') && !isVersionPreviewMode) { // Rotate selected node counter-clockwise const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedNodeIds.length === 1) { @@ -147,9 +163,30 @@ export const useKeyboard = () => { sfxEmitter.emit('sfx:item-rotate') } } - } else if (e.key === 'Delete' || e.key === 'Backspace') { + } else if ((e.key === 'Delete' || e.key === 'Backspace') && !isVersionPreviewMode) { e.preventDefault() + // Check for a selected reference (guide/scan) first + const selectedRefId = useEditor.getState().selectedReferenceId + if (selectedRefId) { + const refNode = useScene.getState().nodes[selectedRefId as AnyNodeId] + if (refNode && (refNode.type === 'guide' || refNode.type === 'scan')) { + sfxEmitter.emit('sfx:structure-delete') + useScene.getState().deleteNode(selectedRefId as AnyNodeId) + useEditor.getState().setSelectedReferenceId(null) + return + } + } + + // Delete selected zone + const selectedZoneId = useViewer.getState().selection.zoneId + if (selectedZoneId) { + sfxEmitter.emit('sfx:structure-delete') + useScene.getState().deleteNode(selectedZoneId as AnyNodeId) + useViewer.getState().setSelection({ zoneId: null }) + return + } + const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] if (selectedNodeIds.length > 0) { @@ -171,7 +208,7 @@ export const useKeyboard = () => { } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, []) + }, [isVersionPreviewMode]) return null } diff --git a/packages/editor/src/index.tsx b/packages/editor/src/index.tsx index 1e86bb44..a6e21be9 100644 --- a/packages/editor/src/index.tsx +++ b/packages/editor/src/index.tsx @@ -15,7 +15,6 @@ export { } from './components/ui/sidebar/panels/settings-panel' export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel' export type { SidebarTab } from './components/ui/sidebar/tab-bar' -export { ViewerToolbarLeft, ViewerToolbarRight } from './components/ui/viewer-toolbar' export type { PresetsAdapter, PresetsTab } from './contexts/presets-context' export { PresetsProvider } from './contexts/presets-context' export type { SaveStatus } from './hooks/use-auto-save' @@ -31,3 +30,4 @@ export { usePaletteViewRegistry, } from './store/use-palette-view-registry' export { useUploadStore } from './store/use-upload' +export { ViewerToolbarLeft, ViewerToolbarRight } from './components/ui/viewer-toolbar' diff --git a/packages/editor/src/lib/scene.ts b/packages/editor/src/lib/scene.ts old mode 100644 new mode 100755 index 09fa0e4e..6eea48f3 --- a/packages/editor/src/lib/scene.ts +++ b/packages/editor/src/lib/scene.ts @@ -20,14 +20,6 @@ type PersistedSelectionPath = { selectedIds: string[] } -/** - * IDs are stored as plain strings in localStorage. Cast them back to their - * branded template-literal types before passing to the viewer store. - */ -function toViewerSelection(s: PersistedSelectionPath) { - return s as unknown as Parameters['setSelection']>[0] -} - const EMPTY_PERSISTED_SELECTION: PersistedSelectionPath = { buildingId: null, levelId: null, @@ -271,9 +263,27 @@ export function syncEditorSelectionFromCurrentScene() { : null if (firstBuilding && firstLevel) { + const isEmptyLevel = !firstLevel.children || firstLevel.children.length === 0 + + // For empty projects (new/blank), always start in structure/build/wall + // regardless of persisted state from a previous project + if (isEmptyLevel) { + useViewer.getState().setSelection({ + buildingId: firstBuilding.id, + levelId: firstLevel.id, + selectedIds: [], + zoneId: null, + }) + useEditor.getState().setPhase('structure') + useEditor.getState().setStructureLayer('elements') + useEditor.getState().setMode('build') + useEditor.getState().setTool('wall') + return + } + if (shouldRestoreEditorUiState) { if (restoredSelection) { - useViewer.getState().setSelection(toViewerSelection(restoredSelection)) + useViewer.getState().setSelection(restoredSelection) useEditor.setState( restoredEditorUiState.phase === 'site' ? (selectionDrivenEditorUiState ?? restoredEditorUiState) @@ -295,7 +305,7 @@ export function syncEditorSelectionFromCurrentScene() { } if (restoredSelection) { - useViewer.getState().setSelection(toViewerSelection(restoredSelection)) + useViewer.getState().setSelection(restoredSelection) if (selectionDrivenEditorUiState) { useEditor.setState(selectionDrivenEditorUiState) } @@ -310,11 +320,6 @@ export function syncEditorSelectionFromCurrentScene() { }) useEditor.getState().setPhase('structure') useEditor.getState().setStructureLayer('elements') - - if (!firstLevel.children || firstLevel.children.length === 0) { - useEditor.getState().setMode('build') - useEditor.getState().setTool('wall') - } } else { useEditor.getState().setPhase('site') useViewer.getState().setSelection({ diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 8a1d6b5c..ae2950dd 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -81,9 +81,25 @@ type EditorState = { setCatalogCategory: (category: CatalogCategory | null) => void selectedItem: AssetInput | null setSelectedItem: (item: AssetInput) => void - movingNode: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | StairNode | StairSegmentNode | null + movingNode: + | ItemNode + | WindowNode + | DoorNode + | RoofNode + | RoofSegmentNode + | StairNode + | StairSegmentNode + | null setMovingNode: ( - node: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null, + node: + | ItemNode + | WindowNode + | DoorNode + | RoofNode + | RoofSegmentNode + | StairNode + | StairSegmentNode + | null, ) => void selectedReferenceId: string | null setSelectedReferenceId: (id: string | null) => void @@ -109,12 +125,13 @@ type EditorState = { setFloorplanHovered: (hovered: boolean) => void floorplanSelectionTool: FloorplanSelectionTool setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void + // First-person walkthrough mode (street view) + isFirstPersonMode: boolean + _viewModeBeforeFirstPerson: ViewMode | null + setFirstPersonMode: (enabled: boolean) => void // Development-only camera debug flag for inspecting underside geometry allowUndergroundCamera: boolean setAllowUndergroundCamera: (enabled: boolean) => void - // First-person walkthrough mode (street view) - isFirstPersonMode: boolean - setFirstPersonMode: (enabled: boolean) => void activeSidebarPanel: string setActiveSidebarPanel: (id: string) => void floorplanPaneRatio: number @@ -403,7 +420,15 @@ const useEditor = create()( setCatalogCategory: (category) => set({ catalogCategory: category }), selectedItem: null, setSelectedItem: (item) => set({ selectedItem: item }), - movingNode: null as ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null, + movingNode: null as + | ItemNode + | WindowNode + | DoorNode + | RoofNode + | RoofSegmentNode + | StairNode + | StairSegmentNode + | null, setMovingNode: (node) => set({ movingNode: node }), selectedReferenceId: null, setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), @@ -442,9 +467,7 @@ const useEditor = create()( _viewModeBeforeFirstPerson: null as ViewMode | null, setFirstPersonMode: (enabled) => { if (enabled) { - // Save current view mode and force 3D for immersive walkthrough const currentViewMode = get().viewMode - // Force perspective camera and full-height walls for immersive walkthrough useViewer.getState().setCameraMode('perspective') useViewer.getState().setWallMode('up') set({ @@ -458,7 +481,6 @@ const useEditor = create()( }) useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) } else { - // Restore previous view mode const prevMode = get()._viewModeBeforeFirstPerson set({ isFirstPersonMode: false, diff --git a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx index 11a3a4bb..7c2f37ef 100644 --- a/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx +++ b/packages/viewer/src/components/renderers/ceiling/ceiling-renderer.tsx @@ -14,7 +14,7 @@ const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1. const gridPattern = lineX.max(lineY) const gridOpacity = mix(float(0.2), float(0.6), gridPattern) -function createCeilingMaterials(color: string = '#999999') { +function createCeilingMaterials(color = '#999999') { const topMaterial = new MeshBasicNodeMaterial({ color, transparent: true, diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx index 6b3ce98f..5366d3d5 100644 --- a/packages/viewer/src/components/renderers/item/item-renderer.tsx +++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx @@ -1,6 +1,8 @@ import { type AnimationEffect, type AnyNodeId, + baseMaterial, + glassMaterial, type Interactive, type ItemNode, type LightEffect, @@ -16,36 +18,18 @@ import { Suspense, useEffect, useMemo, useRef } from 'react' import type { AnimationAction, Group, Material, Mesh } from 'three' import { MathUtils } from 'three' import { positionLocal, smoothstep, time } from 'three/tsl' -import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu' +import { MeshStandardNodeMaterial } from 'three/webgpu' import { useNodeEvents } from '../../../hooks/use-node-events' import { resolveCdnUrl } from '../../../lib/asset-url' import { useItemLightPool } from '../../../store/use-item-light-pool' import { ErrorBoundary } from '../../error-boundary' import { NodeRenderer } from '../node-renderer' -// Shared materials to avoid creating new instances for every mesh -const defaultMaterial = new MeshStandardNodeMaterial({ - color: 0xff_ff_ff, - roughness: 1, - metalness: 0, -}) - -const glassMaterial = new MeshStandardNodeMaterial({ - name: 'glass', - color: 'lightgray', - roughness: 0.8, - metalness: 0, - transparent: true, - opacity: 0.35, - side: DoubleSide, - depthWrite: false, -}) - const getMaterialForOriginal = (original: Material): MeshStandardNodeMaterial => { if (original.name.toLowerCase() === 'glass') { return glassMaterial } - return defaultMaterial + return baseMaterial } const BrokenItemFallback = ({ node }: { node: ItemNode }) => { @@ -145,6 +129,18 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => { if (Array.isArray(mesh.material)) { mesh.material = mesh.material.map((mat) => getMaterialForOriginal(mat)) hasGlass = mesh.material.some((mat) => mat.name === 'glass') + + // Fix geometry groups that reference materialIndex beyond the material + // array length — this causes three-mesh-bvh to crash with + // "Cannot read properties of undefined (reading 'side')" + const matCount = mesh.material.length + if (mesh.geometry.groups.length > 0) { + for (const group of mesh.geometry.groups) { + if (group.materialIndex !== undefined && group.materialIndex >= matCount) { + group.materialIndex = 0 + } + } + } } else { mesh.material = getMaterialForOriginal(mesh.material) hasGlass = mesh.material.name === 'glass' diff --git a/packages/viewer/src/components/renderers/site/site-renderer.tsx b/packages/viewer/src/components/renderers/site/site-renderer.tsx index 2661efe5..e52b6c4c 100644 --- a/packages/viewer/src/components/renderers/site/site-renderer.tsx +++ b/packages/viewer/src/components/renderers/site/site-renderer.tsx @@ -1,7 +1,9 @@ -import { type SiteNode, useRegistry } from '@pascal-app/core' +import { type SiteNode, type SlabNode, useRegistry, useScene } from '@pascal-app/core' +import polygonClipping from 'polygon-clipping' import { useMemo, useRef } from 'react' -import { BufferGeometry, Float32BufferAttribute, type Group, Shape } from 'three' +import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape } from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' +import useViewer from '../../../store/use-viewer' import { NodeRenderer } from '../node-renderer' const Y_OFFSET = 0.01 @@ -29,29 +31,76 @@ const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeom return geometry } +type S = ReturnType + export const SiteRenderer = ({ node }: { node: SiteNode }) => { const ref = useRef(null!) useRegistry(node.id, 'site', ref) - // Create floor shape from polygon points - const floorShape = useMemo(() => { + const theme = useViewer((state) => state.theme) + const bgColor = theme === 'dark' ? '#1f2433' : '#fafafa' + + // Cache slab polygon references to keep the selector stable across unrelated store updates + const slabPolygonsCache = useRef<[number, number][][]>([]) + const slabPolygons = useScene((state: S) => { + const nodeList = Object.values(state.nodes) + + const levelIndexById = new Map() + let lowestLevelIndex = Number.POSITIVE_INFINITY + nodeList.forEach((n) => { + if (n.type !== 'level') return + levelIndexById.set(n.id, n.level) + lowestLevelIndex = Math.min(lowestLevelIndex, n.level) + }) + + const next = nodeList + .filter((n): n is SlabNode => n.type === 'slab' && n.visible && n.polygon.length >= 3) + .filter((n) => { + if (!Number.isFinite(lowestLevelIndex)) return true + const parentLevel = n.parentId ? levelIndexById.get(n.parentId as string) : undefined + return parentLevel === lowestLevelIndex + }) + .map((n) => n.polygon as [number, number][]) + + const prev = slabPolygonsCache.current + if (next.length === prev.length && next.every((p, i) => p === prev[i])) return prev + slabPolygonsCache.current = next + return next + }) + + // Ground shape: site polygon with slab footprints punched as holes + const groundShape = useMemo(() => { if (!node?.polygon?.points || node.polygon.points.length < 3) return null + + const pts = node.polygon.points const shape = new Shape() - const firstPt = node.polygon.points[0]! - - // Shape is in X-Y plane, we rotate it to X-Z plane - // Negate Y (which becomes Z) to get correct orientation - shape.moveTo(firstPt[0]!, -firstPt[1]!) - - for (let i = 1; i < node.polygon.points.length; i++) { - const pt = node.polygon.points[i]! - shape.lineTo(pt[0]!, -pt[1]!) - } + shape.moveTo(pts[0]![0], -pts[0]![1]) + for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i]![0], -pts[i]![1]) shape.closePath() + if (slabPolygons.length > 0) { + const multiPolygons = slabPolygons.map((p) => [ + p.map((pt) => [pt[0], -pt[1]] as [number, number]), + ]) + const unioned = polygonClipping.union( + multiPolygons[0] as polygonClipping.Polygon, + ...(multiPolygons.slice(1) as polygonClipping.Polygon[]), + ) + for (const geom of unioned) { + const ring = geom[0] + if (ring && ring.length > 0) { + const hole = new Path() + hole.moveTo(ring[0]![0], ring[0]![1]) + for (let i = 1; i < ring.length; i++) hole.lineTo(ring[i]![0], ring[i]![1]) + hole.closePath() + shape.holes.push(hole) + } + } + } + return shape - }, [node?.polygon?.points]) + }, [node?.polygon?.points, slabPolygons]) // Create boundary line geometry const lineGeometry = useMemo(() => { @@ -61,7 +110,7 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { const handlers = useNodeEvents(node, 'site') - if (!(node && floorShape && lineGeometry)) { + if (!(node && lineGeometry)) { return null } @@ -75,11 +124,19 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => { /> ))} - {/* Transparent floor fill */} - - - - + {/* Ground fill: site polygon with slab holes, occludes below-grade geometry */} + {groundShape && ( + + + + + )} {/* Simple boundary line */} {/* @ts-ignore */} diff --git a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx index 0abfbdc4..a73d9622 100644 --- a/packages/viewer/src/components/renderers/slab/slab-renderer.tsx +++ b/packages/viewer/src/components/renderers/slab/slab-renderer.tsx @@ -23,8 +23,8 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => { receiveShadow ref={ref} {...handlers} - visible={node.visible} material={material} + visible={node.visible} > diff --git a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx index 1f54e9ec..74691c71 100644 --- a/packages/viewer/src/components/renderers/wall/wall-renderer.tsx +++ b/packages/viewer/src/components/renderers/wall/wall-renderer.tsx @@ -23,7 +23,7 @@ export const WallRenderer = ({ node }: { node: WallNode }) => { }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) return ( - + diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index eca2c561..87c6e952 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -1,9 +1,5 @@ 'use client' -// Must run before @react-three/fiber's Canvas instantiates new THREE.Clock(). -// See lib/suppress-three-clock-warning.ts for rationale and removal condition. -import '../../lib/suppress-three-clock-warning' - import { CeilingSystem, DoorSystem, @@ -19,7 +15,6 @@ import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@re import { useEffect, useMemo, useRef } from 'react' import * as THREE from 'three/webgpu' import useViewer from '../../store/use-viewer' -import { ExportSystem } from '../../systems/export/export-system' import { GuideSystem } from '../../systems/guide/guide-system' import { ItemLightSystem } from '../../systems/item-light/item-light-system' import { LevelSystem } from '../../systems/level/level-system' @@ -151,7 +146,6 @@ const Viewer: React.FC = ({ - {/* */} diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx index 27a82b94..9d3299d6 100644 --- a/packages/viewer/src/components/viewer/post-processing.tsx +++ b/packages/viewer/src/components/viewer/post-processing.tsx @@ -1,7 +1,6 @@ import { useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Color, Layers, UnsignedByteType } from 'three' -import { outline } from 'three/addons/tsl/display/OutlineNode.js' import { ssgi } from 'three/addons/tsl/display/SSGINode.js' import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js' import { @@ -23,6 +22,7 @@ import { } from 'three/tsl' import { RenderPipeline, type WebGPURenderer } from 'three/webgpu' import { SCENE_LAYER, ZONE_LAYER } from '../../lib/layers' +import { mergedOutline } from '../../lib/merged-outline-node' import useViewer from '../../store/use-viewer' // SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion @@ -67,6 +67,7 @@ const PostProcessingPasses = () => { l.disable(SCENE_LAYER) return l }, []) + const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode) // Subscribe to projectId so the pipeline rebuilds on project switch const projectId = useViewer((s) => s.projectId) @@ -197,60 +198,45 @@ const PostProcessingPasses = () => { ) } - function generateSelectedOutlinePass() { - const edgeStrength = uniform(3) - const edgeGlow = uniform(0) - const edgeThickness = uniform(1) - const visibleEdgeColor = uniform(new Color(0xff_ff_ff)) - const hiddenEdgeColor = uniform(new Color(0xf3_ff_47)) + // Single merged outline node: one shared depth pass for both selected + hovered groups. + const outliner = useViewer.getState().outliner + const outlineNode = mergedOutline(scene, camera, { + primaryObjects: outliner.selectedObjects, + secondaryObjects: outliner.hoveredObjects, + primaryEdgeThickness: uniform(1), + secondaryEdgeThickness: uniform(1.5), + }) - const outlinePass = outline(scene, camera, { - selectedObjects: useViewer.getState().outliner.selectedObjects, - edgeGlow, - edgeThickness, - }) - const { visibleEdge, hiddenEdge } = outlinePass + // Selected: white visible, yellow hidden + const selectedVisibleColor = uniform(new Color(0xff_ff_ff)) + const selectedHiddenColor = uniform(new Color(0xf3_ff_47)) + const selectedStrength = uniform(3) + const selectedOutline = outlineNode.primaryVisibleEdge + .mul(selectedVisibleColor) + .add(outlineNode.primaryHiddenEdge.mul(selectedHiddenColor)) + .mul(selectedStrength) - const outlineColor = visibleEdge - .mul(visibleEdgeColor) - .add(hiddenEdge.mul(hiddenEdgeColor)) - .mul(edgeStrength) - - return outlineColor - } - - function generateHoverOutlinePass() { - const edgeStrength = uniform(5) - const edgeGlow = uniform(0.5) - const edgeThickness = uniform(1.5) - const pulsePeriod = uniform(3) - const visibleEdgeColor = uniform(new Color(0x00_aa_ff)) - const hiddenEdgeColor = uniform(new Color(0xf3_ff_47)) - - const outlinePass = outline(scene, camera, { - selectedObjects: useViewer.getState().outliner.hoveredObjects, - edgeGlow, - edgeThickness, - }) - const { visibleEdge, hiddenEdge } = outlinePass - - const period = time.div(pulsePeriod).mul(2) - const osc = oscSine(period).mul(0.5).add(0.5) // osc [ 0.5, 1.0 ] - - const outlineColor = visibleEdge - .mul(visibleEdgeColor) - .add(hiddenEdge.mul(hiddenEdgeColor)) - .mul(edgeStrength) - const outlinePulse = pulsePeriod.greaterThan(0).select(outlineColor.mul(osc), outlineColor) - - return outlinePulse - } - - const selectedOutlinePass = generateSelectedOutlinePass() - const hoverOutlinePass = generateHoverOutlinePass() + // Hovered: blue visible, yellow hidden, pulsing + const hoverVisibleColor = uniform( + new Color(hoverHighlightMode === 'delete' ? 0xef_44_44 : 0x00_aa_ff), + ) + const hoverHiddenColor = uniform( + new Color(hoverHighlightMode === 'delete' ? 0x99_1b_1b : 0xf3_ff_47), + ) + const hoverStrength = uniform(hoverHighlightMode === 'delete' ? 6 : 5) + const pulsePeriod = uniform(3) + const osc = + hoverHighlightMode === 'delete' + ? float(1) + : oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5) // [ 0.5, 1.0 ] + const hoverOutline = outlineNode.secondaryVisibleEdge + .mul(hoverVisibleColor) + .add(outlineNode.secondaryHiddenEdge.mul(hoverHiddenColor)) + .mul(hoverStrength) + .mul(osc) const compositeWithOutlines = vec4( - add(sceneColor.rgb, selectedOutlinePass.add(hoverOutlinePass)), + add(sceneColor.rgb, selectedOutline.add(hoverOutline)), sceneColor.a, ) @@ -280,7 +266,7 @@ const PostProcessingPasses = () => { } renderPipelineRef.current = null } - }, [renderer, scene, camera, isInitialized, zoneLayers]) + }, [renderer, scene, camera, hoverHighlightMode, isInitialized, zoneLayers]) useFrame((_, delta) => { // Animate background colour toward the current theme target (same lerp as AnimatedBackground) diff --git a/packages/viewer/src/components/viewer/walkthrough-controls.tsx b/packages/viewer/src/components/viewer/walkthrough-controls.tsx new file mode 100644 index 00000000..b3d55f28 --- /dev/null +++ b/packages/viewer/src/components/viewer/walkthrough-controls.tsx @@ -0,0 +1,136 @@ +'use client' + +import { PointerLockControls } from '@react-three/drei' +import { useFrame, useThree } from '@react-three/fiber' +import { useCallback, useEffect, useRef } from 'react' +import { Vector3 } from 'three' +import useViewer from '../../store/use-viewer' + +const MOVE_SPEED = 5 +const EYE_HEIGHT = 1.6 + +const _direction = new Vector3() +const _forward = new Vector3() +const _right = new Vector3() + +export const WalkthroughControls = () => { + const controlsRef = useRef(null!) + const walkthroughMode = useViewer((s: any) => s.walkthroughMode) + const keys = useRef({ w: false, a: false, s: false, d: false }) + const camera = useThree((s) => s.camera) + + // Set initial eye height + useEffect(() => { + if (walkthroughMode) { + camera.position.y = EYE_HEIGHT + } + }, [walkthroughMode, camera]) + + // Keyboard handlers + useEffect(() => { + if (!walkthroughMode) return + + const onKeyDown = (e: KeyboardEvent) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return + const key = e.key.toLowerCase() + + // ESC exits walkthrough mode completely + if (e.key === 'Escape') { + e.preventDefault() + e.stopPropagation() + useViewer.getState().setWalkthroughMode(false) + return + } + + if (key === 'w' || key === 'arrowup') keys.current.w = true + if (key === 'a' || key === 'arrowleft') keys.current.a = true + if (key === 's' || key === 'arrowdown') keys.current.s = true + if (key === 'd' || key === 'arrowright') keys.current.d = true + } + + const onKeyUp = (e: KeyboardEvent) => { + const key = e.key.toLowerCase() + if (key === 'w' || key === 'arrowup') keys.current.w = false + if (key === 'a' || key === 'arrowleft') keys.current.a = false + if (key === 's' || key === 'arrowdown') keys.current.s = false + if (key === 'd' || key === 'arrowright') keys.current.d = false + } + + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + + return () => { + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + // Reset keys on cleanup + keys.current = { w: false, a: false, s: false, d: false } + } + }, [walkthroughMode]) + + // Release pointer lock when walkthrough mode is turned off + useEffect(() => { + if (!walkthroughMode && document.pointerLockElement) { + document.exitPointerLock() + } + }, [walkthroughMode]) + + // Movement loop + useFrame((_, delta) => { + if (!(walkthroughMode && controlsRef.current)) return + + _direction.set(0, 0, 0) + + // Get camera forward and right vectors (XZ plane only) + camera.getWorldDirection(_forward) + _forward.y = 0 + _forward.normalize() + + _right.crossVectors(_forward, camera.up).normalize() + + if (keys.current.w) _direction.add(_forward) + if (keys.current.s) _direction.sub(_forward) + if (keys.current.d) _direction.add(_right) + if (keys.current.a) _direction.sub(_right) + + if (_direction.lengthSq() > 0) { + _direction.normalize().multiplyScalar(MOVE_SPEED * delta) + camera.position.add(_direction) + // Keep eye height constant + camera.position.y = EYE_HEIGHT + } + }) + + const handleClick = useCallback(() => { + if (walkthroughMode && controlsRef.current) { + // Feature detection: some browsers (Facebook/Instagram in-app, older Safari) + // don't support pointer lock on the canvas element + if (typeof controlsRef.current.lock === 'function') { + try { + controlsRef.current.lock() + } catch { + // Silently ignore — pointer lock unavailable in this browser context + } + } + } + }, [walkthroughMode]) + + // Click to lock + useEffect(() => { + if (!walkthroughMode) return + const canvas = document.querySelector('canvas') + if (!canvas) return + + canvas.addEventListener('click', handleClick) + return () => canvas.removeEventListener('click', handleClick) + }, [walkthroughMode, handleClick]) + + if (!walkthroughMode) return null + + // Skip PointerLockControls on browsers that don't support pointer lock + // (Facebook/Instagram in-app browsers, some iOS WebViews) + if (typeof document !== 'undefined' && !('requestPointerLock' in HTMLElement.prototype)) { + return null + } + + return +} diff --git a/packages/viewer/src/hooks/use-gltf-ktx2.tsx b/packages/viewer/src/hooks/use-gltf-ktx2.tsx index 040beaf7..28578c76 100644 --- a/packages/viewer/src/hooks/use-gltf-ktx2.tsx +++ b/packages/viewer/src/hooks/use-gltf-ktx2.tsx @@ -36,5 +36,4 @@ const useGLTFKTX2 = (path: string): ReturnType => { loader.setMeshoptDecoder(MeshoptDecoder) }) } - export { useGLTFKTX2 } diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index bfc04ae0..6fb21f4a 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -1,4 +1,5 @@ export { default as Viewer } from './components/viewer' +export { WalkthroughControls } from './components/viewer/walkthrough-controls' export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url' export { SCENE_LAYER, ZONE_LAYER } from './lib/layers' export { @@ -13,7 +14,7 @@ export { DEFAULT_WINDOW_MATERIAL, disposeMaterial, } from './lib/materials' +export { mergedOutline } from './lib/merged-outline-node' export { default as useViewer } from './store/use-viewer' -export { ExportSystem } from './systems/export/export-system' export { InteractiveSystem } from './systems/interactive/interactive-system' export { snapLevelsToTruePositions } from './systems/level/level-utils' diff --git a/packages/viewer/src/lib/merged-outline-node.ts b/packages/viewer/src/lib/merged-outline-node.ts new file mode 100644 index 00000000..440535d1 --- /dev/null +++ b/packages/viewer/src/lib/merged-outline-node.ts @@ -0,0 +1,614 @@ +// @ts-nocheck — Three.js TSL/WebGPU internal APIs have incomplete type definitions; +// this file is a fork of OutlineNode and is intentionally exempt from strict TS checking. + +/** + * MergedOutlineNode — a fork of Three.js OutlineNode that processes two object + * groups (primary = selected, secondary = hovered) in a single pass, sharing the + * expensive non-selected depth pre-render between both groups. + * + * Cost comparison vs two separate OutlineNode instances: + * Before: depth_A + mask_A + edge_A×6 + depth_B + mask_B + edge_B×6 = 2 depth passes + * After: depth_AB (shared) + mask_A + edge_A×6 + mask_B + edge_B×6 = 1 depth pass + * + * Additional early-outs: + * - Both empty → skip everything (0 passes) + * - Only primary → skip secondary mask/edge/blur + * - Only secondary → skip primary mask/edge/blur + */ + +import { DepthTexture, FloatType, type Object3D, RenderTarget, Vector2 } from 'three' +import { + color, + exp, + Fn, + float, + int, + Loop, + min, + mul, + nodeObject, + orthographicDepthToViewZ, + passTexture, + perspectiveDepthToViewZ, + positionView, + reference, + screenUV, + texture, + textureSize, + uniform, + uv, + vec2, + vec3, + vec4, +} from 'three/tsl' +import { + NodeMaterial, + NodeUpdateType, + QuadMesh, + RendererUtils, + SpriteNodeMaterial, + TempNode, +} from 'three/webgpu' + +const _quadMesh = new QuadMesh() +const _size = new Vector2() +const _BLUR_X = new Vector2(1.0, 0.0) +const _BLUR_Y = new Vector2(0.0, 1.0) +let _rendererState: any // eslint-disable-line @typescript-eslint/no-explicit-any + +// --------------------------------------------------------------------------- +// Helper: render targets for one outline group +// --------------------------------------------------------------------------- +function makeGroupTargets(downSampleRatio: number) { + const maskBuffer = new RenderTarget() + const maskDownSample = new RenderTarget(1, 1, { depthBuffer: false }) + const edgeBuffer1 = new RenderTarget(1, 1, { depthBuffer: false }) + const edgeBuffer2 = new RenderTarget(1, 1, { depthBuffer: false }) + const blurBuffer1 = new RenderTarget(1, 1, { depthBuffer: false }) + const blurBuffer2 = new RenderTarget(1, 1, { depthBuffer: false }) + const composite = new RenderTarget(1, 1, { depthBuffer: false }) + + function setSize(w: number, h: number) { + maskBuffer.setSize(w, h) + composite.setSize(w, h) + let rx = Math.round(w / downSampleRatio) + let ry = Math.round(h / downSampleRatio) + maskDownSample.setSize(rx, ry) + edgeBuffer1.setSize(rx, ry) + blurBuffer1.setSize(rx, ry) + rx = Math.round(rx / 2) + ry = Math.round(ry / 2) + edgeBuffer2.setSize(rx, ry) + blurBuffer2.setSize(rx, ry) + } + + function dispose() { + maskBuffer.dispose() + maskDownSample.dispose() + edgeBuffer1.dispose() + edgeBuffer2.dispose() + blurBuffer1.dispose() + blurBuffer2.dispose() + composite.dispose() + } + + return { + maskBuffer, + maskDownSample, + edgeBuffer1, + edgeBuffer2, + blurBuffer1, + blurBuffer2, + composite, + setSize, + dispose, + } +} + +type GroupTargets = ReturnType + +// --------------------------------------------------------------------------- +// MergedOutlineNode +// --------------------------------------------------------------------------- +export class MergedOutlineNode extends TempNode { + static get type() { + return 'MergedOutlineNode' + } + + scene: any + camera: any + primaryObjects: Object3D[] + secondaryObjects: Object3D[] + primaryEdgeThicknessNode: any + secondaryEdgeThicknessNode: any + primaryEdgeGlowNode: any + secondaryEdgeGlowNode: any + downSampleRatio: number + updateBeforeType: string + + private readonly _depthRT: RenderTarget + private readonly _depthTexUniform: any + + private readonly _groupA: GroupTargets + private readonly _groupB: GroupTargets + private readonly _maskTexA: any + private readonly _maskDownTexA: any + private readonly _edge1TexA: any + private readonly _edge2TexA: any + private readonly _blurColorTexA: any + private readonly _maskTexB: any + private readonly _maskDownTexB: any + private readonly _edge1TexB: any + private readonly _edge2TexB: any + private readonly _blurColorTexB: any + private readonly _blurDirectionA: any + private readonly _blurDirectionB: any + private readonly _cameraNear: any + private readonly _cameraFar: any + + private readonly _depthMaterial: NodeMaterial + private readonly _depthSpriteMaterial: SpriteNodeMaterial + private readonly _prepareMaskMatA: NodeMaterial + private readonly _prepareMaskSpriteMatA: SpriteNodeMaterial + private readonly _copyMatA: NodeMaterial + private readonly _edgeDetectMatA: NodeMaterial + private readonly _blurMat1A: NodeMaterial + private readonly _blurMat2A: NodeMaterial + private readonly _compositeMatA: NodeMaterial + private readonly _prepareMaskMatB: NodeMaterial + private readonly _prepareMaskSpriteMatB: SpriteNodeMaterial + private readonly _copyMatB: NodeMaterial + private readonly _edgeDetectMatB: NodeMaterial + private readonly _blurMat1B: NodeMaterial + private readonly _blurMat2B: NodeMaterial + private readonly _compositeMatB: NodeMaterial + + private readonly _cacheA = new Set() + private readonly _cacheB = new Set() + + private readonly _textureNodeA: any + private readonly _textureNodeB: any + + constructor( + scene: any, + camera: any, + params: { + primaryObjects?: Object3D[] + secondaryObjects?: Object3D[] + primaryEdgeThickness?: any + secondaryEdgeThickness?: any + primaryEdgeGlow?: any + secondaryEdgeGlow?: any + downSampleRatio?: number + } = {}, + ) { + super('vec4') + + const { + primaryObjects = [], + secondaryObjects = [], + primaryEdgeThickness = float(1), + secondaryEdgeThickness = float(1), + primaryEdgeGlow = float(0), + secondaryEdgeGlow = float(0), + downSampleRatio = 2, + } = params + + this.scene = scene + this.camera = camera + this.primaryObjects = primaryObjects + this.secondaryObjects = secondaryObjects + this.primaryEdgeThicknessNode = nodeObject(primaryEdgeThickness) + this.secondaryEdgeThicknessNode = nodeObject(secondaryEdgeThickness) + this.primaryEdgeGlowNode = nodeObject(primaryEdgeGlow) + this.secondaryEdgeGlowNode = nodeObject(secondaryEdgeGlow) + this.downSampleRatio = downSampleRatio + this.updateBeforeType = NodeUpdateType.FRAME + + this._depthRT = new RenderTarget() + this._depthRT.depthTexture = new DepthTexture() + this._depthRT.depthTexture.type = FloatType + + this._groupA = makeGroupTargets(downSampleRatio) + this._groupB = makeGroupTargets(downSampleRatio) + + this._cameraNear = reference('near', 'float', camera) + this._cameraFar = reference('far', 'float', camera) + this._blurDirectionA = uniform(new Vector2()) + this._blurDirectionB = uniform(new Vector2()) + this._depthTexUniform = texture(this._depthRT.depthTexture) + + this._maskTexA = texture(this._groupA.maskBuffer.texture) + this._maskDownTexA = texture(this._groupA.maskDownSample.texture) + this._edge1TexA = texture(this._groupA.edgeBuffer1.texture) + this._edge2TexA = texture(this._groupA.edgeBuffer2.texture) + this._blurColorTexA = texture(this._groupA.edgeBuffer1.texture) + + this._maskTexB = texture(this._groupB.maskBuffer.texture) + this._maskDownTexB = texture(this._groupB.maskDownSample.texture) + this._edge1TexB = texture(this._groupB.edgeBuffer1.texture) + this._edge2TexB = texture(this._groupB.edgeBuffer2.texture) + this._blurColorTexB = texture(this._groupB.edgeBuffer1.texture) + + this._depthMaterial = new NodeMaterial() + this._depthMaterial.colorNode = color(0, 0, 0) + this._depthMaterial.name = 'MergedOutline.depth' + this._depthSpriteMaterial = new SpriteNodeMaterial() + this._depthSpriteMaterial.colorNode = color(0, 0, 0) + this._depthSpriteMaterial.name = 'MergedOutline.depthSprite' + + this._prepareMaskMatA = new NodeMaterial() + this._prepareMaskMatA.name = 'MergedOutline.maskA' + this._prepareMaskSpriteMatA = new SpriteNodeMaterial() + this._prepareMaskSpriteMatA.name = 'MergedOutline.maskSpriteA' + this._copyMatA = new NodeMaterial() + this._copyMatA.name = 'MergedOutline.copyA' + this._edgeDetectMatA = new NodeMaterial() + this._edgeDetectMatA.name = 'MergedOutline.edgeA' + this._blurMat1A = new NodeMaterial() + this._blurMat1A.name = 'MergedOutline.blur1A' + this._blurMat2A = new NodeMaterial() + this._blurMat2A.name = 'MergedOutline.blur2A' + this._compositeMatA = new NodeMaterial() + this._compositeMatA.name = 'MergedOutline.compositeA' + + this._prepareMaskMatB = new NodeMaterial() + this._prepareMaskMatB.name = 'MergedOutline.maskB' + this._prepareMaskSpriteMatB = new SpriteNodeMaterial() + this._prepareMaskSpriteMatB.name = 'MergedOutline.maskSpriteB' + this._copyMatB = new NodeMaterial() + this._copyMatB.name = 'MergedOutline.copyB' + this._edgeDetectMatB = new NodeMaterial() + this._edgeDetectMatB.name = 'MergedOutline.edgeB' + this._blurMat1B = new NodeMaterial() + this._blurMat1B.name = 'MergedOutline.blur1B' + this._blurMat2B = new NodeMaterial() + this._blurMat2B.name = 'MergedOutline.blur2B' + this._compositeMatB = new NodeMaterial() + this._compositeMatB.name = 'MergedOutline.compositeB' + + // Output: R = visibleEdge, G = hiddenEdge + this._textureNodeA = passTexture(this, this._groupA.composite.texture) + this._textureNodeB = passTexture(this, this._groupB.composite.texture) + } + + get primaryVisibleEdge() { + return this._textureNodeA.r + } + get primaryHiddenEdge() { + return this._textureNodeA.g + } + get secondaryVisibleEdge() { + return this._textureNodeB.r + } + get secondaryHiddenEdge() { + return this._textureNodeB.g + } + + setSize(width: number, height: number) { + this._depthRT.setSize(width, height) + this._groupA.setSize(width, height) + this._groupB.setSize(width, height) + } + + updateBefore(frame: any) { + const hasPrimary = this.primaryObjects.length > 0 + const hasSecondary = this.secondaryObjects.length > 0 + + const { renderer } = frame + const { camera, scene } = this + + _rendererState = RendererUtils.resetRendererAndSceneState(renderer, scene, _rendererState) + + const size = renderer.getDrawingBufferSize(_size) + this.setSize(size.width, size.height) + + // Clear composites for inactive groups so stale outlines don't persist on GPU. + // Must happen inside resetRendererAndSceneState to avoid MSAA state corruption. + if (!hasPrimary) { + renderer.setRenderTarget(this._groupA.composite) + renderer.clearColor() + } + if (!hasSecondary) { + renderer.setRenderTarget(this._groupB.composite) + renderer.clearColor() + } + + const hasAny = hasPrimary || hasSecondary + if (!hasAny) { + RendererUtils.restoreRendererAndSceneState(renderer, scene, _rendererState) + return + } + + renderer.setClearColor(0xff_ff_ff, 1) + + if (hasPrimary) this._buildCache(this.primaryObjects, this._cacheA) + if (hasSecondary) this._buildCache(this.secondaryObjects, this._cacheB) + + const savedName = scene.name + + // ── 1. Shared depth pass: all objects NOT in either group ───────────────── + renderer.setRenderTarget(this._depthRT) + renderer.setRenderObjectFunction( + (obj: any, sc: any, cam: any, geo: any, _mat: any, grp: any, lights: any, clip: any) => { + const inCache = this._cacheA.has(obj) || this._cacheB.has(obj) + if (!inCache) { + const m = obj.isSprite ? this._depthSpriteMaterial : this._depthMaterial + renderer.renderObject(obj, sc, cam, geo, m, grp, lights, clip) + } + }, + ) + scene.name = 'MergedOutline [ Depth ]' + renderer.render(scene, camera) + + // ── 2a. Primary mask pass ───────────────────────────────────────────────── + if (hasPrimary) { + renderer.setRenderTarget(this._groupA.maskBuffer) + renderer.setRenderObjectFunction( + (obj: any, sc: any, cam: any, geo: any, _mat: any, grp: any, lights: any, clip: any) => { + if (this._cacheA.has(obj)) { + const m = obj.isSprite ? this._prepareMaskSpriteMatA : this._prepareMaskMatA + renderer.renderObject(obj, sc, cam, geo, m, grp, lights, clip) + } + }, + ) + scene.name = 'MergedOutline [ Mask A ]' + renderer.render(scene, camera) + } + + // ── 2b. Secondary mask pass ─────────────────────────────────────────────── + if (hasSecondary) { + renderer.setRenderTarget(this._groupB.maskBuffer) + renderer.setRenderObjectFunction( + (obj: any, sc: any, cam: any, geo: any, _mat: any, grp: any, lights: any, clip: any) => { + if (this._cacheB.has(obj)) { + const m = obj.isSprite ? this._prepareMaskSpriteMatB : this._prepareMaskMatB + renderer.renderObject(obj, sc, cam, geo, m, grp, lights, clip) + } + }, + ) + scene.name = 'MergedOutline [ Mask B ]' + renderer.render(scene, camera) + } + + renderer.setRenderObjectFunction(_rendererState.renderObjectFunction) + this._cacheA.clear() + this._cacheB.clear() + scene.name = savedName + + // ── 3–7. Edge detect + blur + composite per active group ────────────────── + if (hasPrimary) this._runEdgePipeline(renderer, 'A') + if (hasSecondary) this._runEdgePipeline(renderer, 'B') + + RendererUtils.restoreRendererAndSceneState(renderer, scene, _rendererState) + } + + private _runEdgePipeline(renderer: any, group: 'A' | 'B') { + const isA = group === 'A' + const g = isA ? this._groupA : this._groupB + const copyMat = isA ? this._copyMatA : this._copyMatB + const edgeMat = isA ? this._edgeDetectMatA : this._edgeDetectMatB + const blur1 = isA ? this._blurMat1A : this._blurMat1B + const blur2 = isA ? this._blurMat2A : this._blurMat2B + const blurDir = isA ? this._blurDirectionA : this._blurDirectionB + const blurColorTex = isA ? this._blurColorTexA : this._blurColorTexB + const compositeMat = isA ? this._compositeMatA : this._compositeMatB + + _quadMesh.material = copyMat + renderer.setRenderTarget(g.maskDownSample) + _quadMesh.render(renderer) + + _quadMesh.material = edgeMat + renderer.setRenderTarget(g.edgeBuffer1) + _quadMesh.render(renderer) + + blurColorTex.value = g.edgeBuffer1.texture + blurDir.value.copy(_BLUR_X) + _quadMesh.material = blur1 + renderer.setRenderTarget(g.blurBuffer1) + _quadMesh.render(renderer) + + blurColorTex.value = g.blurBuffer1.texture + blurDir.value.copy(_BLUR_Y) + renderer.setRenderTarget(g.edgeBuffer1) + _quadMesh.render(renderer) + + blurColorTex.value = g.edgeBuffer1.texture + blurDir.value.copy(_BLUR_X) + _quadMesh.material = blur2 + renderer.setRenderTarget(g.blurBuffer2) + _quadMesh.render(renderer) + + blurColorTex.value = g.blurBuffer2.texture + blurDir.value.copy(_BLUR_Y) + renderer.setRenderTarget(g.edgeBuffer2) + _quadMesh.render(renderer) + + _quadMesh.material = compositeMat + renderer.setRenderTarget(g.composite) + _quadMesh.render(renderer) + } + + setup(_builder: any) { + // ── prepareMask ─────────────────────────────────────────────────────────── + const buildPrepareMask = () => { + const depth = this._depthTexUniform.sample(screenUV) + const viewZ = this.camera.isPerspectiveCamera + ? perspectiveDepthToViewZ(depth, this._cameraNear, this._cameraFar) + : orthographicDepthToViewZ(depth, this._cameraNear, this._cameraFar) + const depthTest = positionView.z.lessThanEqual(viewZ).select(1, 0) + return vec3(0.0, depthTest, 1.0) + } + + const maskColorA = buildPrepareMask() + this._prepareMaskMatA.colorNode = maskColorA + this._prepareMaskMatA.needsUpdate = true + this._prepareMaskSpriteMatA.colorNode = maskColorA + this._prepareMaskSpriteMatA.needsUpdate = true + + const maskColorB = buildPrepareMask() + this._prepareMaskMatB.colorNode = maskColorB + this._prepareMaskMatB.needsUpdate = true + this._prepareMaskSpriteMatB.colorNode = maskColorB + this._prepareMaskSpriteMatB.needsUpdate = true + + // ── Copy ────────────────────────────────────────────────────────────────── + this._copyMatA.fragmentNode = this._maskTexA + this._copyMatA.needsUpdate = true + this._copyMatB.fragmentNode = this._maskTexB + this._copyMatB.needsUpdate = true + + // ── Edge detection ──────────────────────────────────────────────────────── + const buildEdgeDetect = (maskDownTex: any) => + Fn(() => { + const resolution = textureSize(maskDownTex) + const invSize = vec2(1).div(resolution).toVar() + const uvOffset = vec4(1.0, 0.0, 0.0, 1.0).mul(vec4(invSize, invSize)) + const uvNode = uv() + const c1 = maskDownTex.sample(uvNode.add(uvOffset.xy)).toVar() + const c2 = maskDownTex.sample(uvNode.sub(uvOffset.xy)).toVar() + const c3 = maskDownTex.sample(uvNode.add(uvOffset.yw)).toVar() + const c4 = maskDownTex.sample(uvNode.sub(uvOffset.yw)).toVar() + const diff1 = mul(c1.r.sub(c2.r), 0.5) + const diff2 = mul(c3.r.sub(c4.r), 0.5) + const d = vec2(diff1, diff2).length() + const a1 = min(c1.g, c2.g) + const a2 = min(c3.g, c4.g) + const visibilityFactor = min(a1, a2) + // R = visible edge, G = hidden edge (matches OutlineNode convention) + const edgeColor = visibilityFactor + .oneMinus() + .greaterThan(0.001) + .select(vec3(1, 0, 0), vec3(0, 1, 0)) + return vec4(edgeColor, 1).mul(d) + })() + + this._edgeDetectMatA.fragmentNode = buildEdgeDetect(this._maskDownTexA) + this._edgeDetectMatA.needsUpdate = true + this._edgeDetectMatB.fragmentNode = buildEdgeDetect(this._maskDownTexB) + this._edgeDetectMatB.needsUpdate = true + + // ── Separable blur ──────────────────────────────────────────────────────── + const MAX_RADIUS = 4 + + const gaussianPdf = Fn(([x, sigma]: any[]) => + float(0.398_94).mul(exp(float(-0.5).mul(x).mul(x).div(sigma.mul(sigma))).div(sigma)), + ) + + const buildBlur = (maskDownTex: any, blurColorTex: any, blurDir: any, kernelRadius: any) => + Fn(() => { + const resolution = textureSize(maskDownTex) + const invSize = vec2(1).div(resolution).toVar() + const uvNode = uv() + const sigma = kernelRadius.div(2).toVar() + const weightSum = gaussianPdf(0, sigma).toVar() + const diffuseSum = blurColorTex.sample(uvNode).mul(weightSum).toVar() + const delta = blurDir.mul(invSize).mul(kernelRadius).div(MAX_RADIUS).toVar() + const uvOffset = delta.toVar() + Loop( + { start: int(1), end: int(MAX_RADIUS), type: 'int', condition: '<=' }, + ({ i }: any) => { + const x = kernelRadius.mul(float(i)).div(MAX_RADIUS) + const w = gaussianPdf(x, sigma) + diffuseSum.addAssign( + blurColorTex + .sample(uvNode.add(uvOffset)) + .add(blurColorTex.sample(uvNode.sub(uvOffset))) + .mul(w), + ) + weightSum.addAssign(w.mul(2)) + uvOffset.addAssign(delta) + }, + ) + return diffuseSum.div(weightSum) + })() + + this._blurMat1A.fragmentNode = buildBlur( + this._maskDownTexA, + this._blurColorTexA, + this._blurDirectionA, + this.primaryEdgeThicknessNode, + ) + this._blurMat1A.needsUpdate = true + this._blurMat2A.fragmentNode = buildBlur( + this._maskDownTexA, + this._blurColorTexA, + this._blurDirectionA, + float(MAX_RADIUS), + ) + this._blurMat2A.needsUpdate = true + this._blurMat1B.fragmentNode = buildBlur( + this._maskDownTexB, + this._blurColorTexB, + this._blurDirectionB, + this.secondaryEdgeThicknessNode, + ) + this._blurMat1B.needsUpdate = true + this._blurMat2B.fragmentNode = buildBlur( + this._maskDownTexB, + this._blurColorTexB, + this._blurDirectionB, + float(MAX_RADIUS), + ) + this._blurMat2B.needsUpdate = true + + // ── Composite ───────────────────────────────────────────────────────────── + const buildComposite = (maskTex: any, edge1Tex: any, edge2Tex: any, edgeGlowNode: any) => + Fn(() => maskTex.r.mul(edge1Tex.add(edge2Tex.mul(edgeGlowNode))))() + + this._compositeMatA.fragmentNode = buildComposite( + this._maskTexA, + this._edge1TexA, + this._edge2TexA, + this.primaryEdgeGlowNode, + ) + this._compositeMatA.needsUpdate = true + this._compositeMatB.fragmentNode = buildComposite( + this._maskTexB, + this._edge1TexB, + this._edge2TexB, + this.secondaryEdgeGlowNode, + ) + this._compositeMatB.needsUpdate = true + + return this._textureNodeA + } + + dispose() { + this.primaryObjects.length = 0 + this.secondaryObjects.length = 0 + this._depthRT.dispose() + this._groupA.dispose() + this._groupB.dispose() + this._depthMaterial.dispose() + this._depthSpriteMaterial.dispose() + this._prepareMaskMatA.dispose() + this._prepareMaskSpriteMatA.dispose() + this._copyMatA.dispose() + this._edgeDetectMatA.dispose() + this._blurMat1A.dispose() + this._blurMat2A.dispose() + this._compositeMatA.dispose() + this._prepareMaskMatB.dispose() + this._prepareMaskSpriteMatB.dispose() + this._copyMatB.dispose() + this._edgeDetectMatB.dispose() + this._blurMat1B.dispose() + this._blurMat2B.dispose() + this._compositeMatB.dispose() + } + + private _buildCache(objects: Object3D[], cache: Set) { + for (const obj of objects) { + obj.traverse((child: any) => { + if (child.isMesh || child.isSprite) cache.add(child) + }) + } + } +} + +export const mergedOutline = ( + scene: any, + camera: any, + params?: ConstructorParameters[2], +) => new MergedOutlineNode(scene, camera, params) diff --git a/packages/viewer/src/r3f.d.ts b/packages/viewer/src/r3f.d.ts index 172e7de5..8e0796cf 100644 --- a/packages/viewer/src/r3f.d.ts +++ b/packages/viewer/src/r3f.d.ts @@ -78,18 +78,21 @@ interface ThreeJSXElements { } declare module 'react' { + // biome-ignore lint/style/noNamespace: Required for JSX module augmentation namespace JSX { interface IntrinsicElements extends ThreeJSXElements {} } } declare module 'react/jsx-runtime' { + // biome-ignore lint/style/noNamespace: Required for JSX module augmentation namespace JSX { interface IntrinsicElements extends ThreeJSXElements {} } } declare module 'react/jsx-dev-runtime' { + // biome-ignore lint/style/noNamespace: Required for JSX module augmentation namespace JSX { interface IntrinsicElements extends ThreeJSXElements {} } diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index fd8c4889..070ec71d 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -71,6 +71,9 @@ type ViewerState = { debugColors: boolean setDebugColors: (enabled: boolean) => void + walkthroughMode: boolean + setWalkthroughMode: (mode: boolean) => void + cameraDragging: boolean setCameraDragging: (dragging: boolean) => void } @@ -194,6 +197,9 @@ const useViewer = create()( debugColors: false, setDebugColors: (enabled) => set({ debugColors: enabled }), + walkthroughMode: false, + setWalkthroughMode: (mode) => set({ walkthroughMode: mode }), + cameraDragging: false, setCameraDragging: (dragging) => set({ cameraDragging: dragging }), }), diff --git a/packages/viewer/src/systems/wall/wall-cutout.tsx b/packages/viewer/src/systems/wall/wall-cutout.tsx index 0b50da19..af416556 100644 --- a/packages/viewer/src/systems/wall/wall-cutout.tsx +++ b/packages/viewer/src/systems/wall/wall-cutout.tsx @@ -1,14 +1,35 @@ -import { sceneRegistry, useScene, type WallNode } from '@pascal-app/core' +import { + type AnyNodeId, + baseMaterial, + sceneRegistry, + useScene, + type WallNode, +} from '@pascal-app/core' import { useFrame } from '@react-three/fiber' import { useRef } from 'react' +import { Color } from 'three' import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl' - import { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu' import useViewer from '../../store/use-viewer' const tmpVec = new Vector3() const u = new Vector3() const v = new Vector3() +const DEFAULT_WALL_COLOR = '#f2f0ed' +const WALL_HIGHLIGHT_PROFILES = { + delete: { + color: new Color('#dc2626'), + blend: 0.78, + emissiveIntensity: 0.46, + }, + selection: { + color: new Color('#818cf8'), + blend: 0.32, + emissiveIntensity: 0.42, + }, +} as const + +type WallHighlightKind = keyof typeof WALL_HIGHLIGHT_PROFILES const dotPattern = Fn(() => { const scale = float(0.1) @@ -30,23 +51,15 @@ const dotPattern = Fn(() => { interface WallMaterials { visible: MeshStandardNodeMaterial invisible: MeshStandardNodeMaterial + deleteVisible: MeshStandardNodeMaterial + deleteInvisible: MeshStandardNodeMaterial + highlightedVisible: MeshStandardNodeMaterial + highlightedInvisible: MeshStandardNodeMaterial materialHash: string } const wallMaterialCache = new Map() -function getMaterialHash(wallNode: WallNode): string { - if (!wallNode.material) return 'none' - const mat = wallNode.material - if (mat.preset && mat.preset !== 'custom') { - return `preset-${mat.preset}` - } - if (mat.properties) { - return `props-${mat.properties.color}-${mat.properties.roughness}-${mat.properties.metalness}` - } - return 'default' -} - const presetColors = { white: '#ffffff', brick: '#8b4513', @@ -59,10 +72,43 @@ const presetColors = { marble: '#f5f5f5', } as const +function getMaterialHash(wallNode: WallNode): string { + if (!wallNode.material) return 'none' + const mat = wallNode.material + if (mat.preset && mat.preset !== 'custom') { + return `preset-${mat.preset}` + } + if (mat.properties) { + return `props-${mat.properties.color}-${mat.properties.roughness}-${mat.properties.metalness}` + } + return 'default' +} + function getPresetColor(preset: string): string { return presetColors[preset as keyof typeof presetColors] ?? '#ffffff' } +function getHighlightedColor(color: string, kind: WallHighlightKind): Color { + const profile = WALL_HIGHLIGHT_PROFILES[kind] + return new Color(color).lerp(profile.color, profile.blend) +} + +function createHighlightedWallMaterial( + material: MeshStandardNodeMaterial, + baseColor: string, + kind: WallHighlightKind, +): MeshStandardNodeMaterial { + const highlightedMaterial = material.clone() + const highlightedColor = getHighlightedColor(baseColor, kind) + const profile = WALL_HIGHLIGHT_PROFILES[kind] + + highlightedMaterial.color = highlightedColor + highlightedMaterial.emissive = highlightedColor.clone() + highlightedMaterial.emissiveIntensity = profile.emissiveIntensity + + return highlightedMaterial +} + function getMaterialsForWall(wallNode: WallNode): WallMaterials { const cacheKey = wallNode.id const materialHash = getMaterialHash(wallNode) @@ -75,20 +121,26 @@ function getMaterialsForWall(wallNode: WallNode): WallMaterials { if (existing) { existing.visible.dispose() existing.invisible.dispose() + existing.deleteVisible.dispose() + existing.deleteInvisible.dispose() + existing.highlightedVisible.dispose() + existing.highlightedInvisible.dispose() } - let userColor = '#ffffff' + let userColor = DEFAULT_WALL_COLOR if (wallNode.material?.properties?.color) { userColor = wallNode.material.properties.color } else if (wallNode.material?.preset && wallNode.material.preset !== 'custom') { userColor = getPresetColor(wallNode.material.preset) } - const visibleMat = new MeshStandardNodeMaterial({ - color: userColor, - roughness: 1, - metalness: 0, - }) + const visibleMat = wallNode.material + ? new MeshStandardNodeMaterial({ + color: userColor, + roughness: 1, + metalness: 0, + }) + : (baseMaterial.clone() as MeshStandardNodeMaterial) const invisibleMat = new MeshStandardNodeMaterial({ transparent: true, @@ -98,7 +150,20 @@ function getMaterialsForWall(wallNode: WallNode): WallMaterials { emissive: userColor, }) - const result: WallMaterials = { visible: visibleMat, invisible: invisibleMat, materialHash } + const highlightedVisible = createHighlightedWallMaterial(visibleMat, userColor, 'selection') + const highlightedInvisible = createHighlightedWallMaterial(invisibleMat, userColor, 'selection') + const deleteVisible = createHighlightedWallMaterial(visibleMat, userColor, 'delete') + const deleteInvisible = createHighlightedWallMaterial(invisibleMat, userColor, 'delete') + + const result: WallMaterials = { + visible: visibleMat, + invisible: invisibleMat, + deleteVisible, + deleteInvisible, + highlightedVisible, + highlightedInvisible, + materialHash, + } wallMaterialCache.set(cacheKey, result) return result } @@ -135,78 +200,77 @@ export const WallCutout = () => { const lastUpdateTime = useRef(0) const lastWallMode = useRef(useViewer.getState().wallMode) const lastNumberOfWalls = useRef(0) - const lastWallMaterials = useRef>(new Map()) + const lastHighlightKey = useRef('') useFrame(({ camera, clock }) => { const wallMode = useViewer.getState().wallMode + const selectedIds = useViewer.getState().selection.selectedIds + const previewSelectedIds = useViewer.getState().previewSelectedIds + const hoveredId = useViewer.getState().hoveredId + const hoverHighlightMode = useViewer.getState().hoverHighlightMode const currentTime = clock.elapsedTime const currentCameraPosition = camera.position camera.getWorldDirection(tmpVec) tmpVec.add(currentCameraPosition) + const highlightedWallIds = new Set( + [...selectedIds, ...previewSelectedIds].filter( + (id) => useScene.getState().nodes[id as AnyNodeId]?.type === 'wall', + ), + ) + const deleteHoveredWallId = + hoverHighlightMode === 'delete' && + hoveredId && + useScene.getState().nodes[hoveredId as AnyNodeId]?.type === 'wall' + ? hoveredId + : null + const highlightKey = `${Array.from(highlightedWallIds).sort().join('|')}::${deleteHoveredWallId ?? ''}` const distanceMoved = currentCameraPosition.distanceTo(lastCameraPosition.current) const directionChanged = tmpVec.distanceTo(lastCameraTarget.current) const timeSinceUpdate = currentTime - lastUpdateTime.current - const shouldUpdate = + if ( ((distanceMoved > 0.5 || directionChanged > 0.3) && timeSinceUpdate > 0.1) || lastWallMode.current !== wallMode || - sceneRegistry.byType.wall.size !== lastNumberOfWalls.current - - const walls = sceneRegistry.byType.wall - const currentWallIds = new Set() - - walls.forEach((wallId) => { - const wallMesh = sceneRegistry.nodes.get(wallId) - if (!wallMesh) return - const wallNode = useScene.getState().nodes[wallId as WallNode['id']] - if (!wallNode || wallNode.type !== 'wall') return - - currentWallIds.add(wallId) - - const hideWall = getWallHideState(wallNode, wallMesh as Mesh, wallMode, u) - - if (shouldUpdate) { - const materials = getMaterialsForWall(wallNode) - ;(wallMesh as Mesh).material = hideWall ? materials.invisible : materials.visible - } else { - const currentMaterial = (wallMesh as Mesh).material - const materials = wallMaterialCache.get(wallId) - if ( - !materials || - currentMaterial !== (hideWall ? materials.invisible : materials.visible) - ) { - const newMaterials = getMaterialsForWall(wallNode) - ;(wallMesh as Mesh).material = hideWall ? newMaterials.invisible : newMaterials.visible - } - } - }) - - if (shouldUpdate) { + sceneRegistry.byType.wall.size !== lastNumberOfWalls.current || + lastHighlightKey.current !== highlightKey + ) { lastCameraPosition.current.copy(currentCameraPosition) lastCameraTarget.current.copy(tmpVec) lastUpdateTime.current = currentTime camera.getWorldDirection(u) - if (lastWallMode.current !== wallMode) { - wallMaterialCache.clear() - } + const walls = sceneRegistry.byType.wall + walls.forEach((wallId) => { + const wallMesh = sceneRegistry.nodes.get(wallId) + if (!wallMesh) return + const wallNode = useScene.getState().nodes[wallId as WallNode['id']] + if (!wallNode || wallNode.type !== 'wall') return - for (const [wallId, mats] of lastWallMaterials.current) { - if (!currentWallIds.has(wallId)) { - mats.visible.dispose() - mats.invisible.dispose() - wallMaterialCache.delete(wallId) + const hideWall = getWallHideState(wallNode, wallMesh as Mesh, wallMode, u) + const isDeleteHighlighted = deleteHoveredWallId === wallId + const isSelectionHighlighted = !isDeleteHighlighted && highlightedWallIds.has(wallId) + const materials = getMaterialsForWall(wallNode) + + if (hideWall) { + ;(wallMesh as Mesh).material = isDeleteHighlighted + ? materials.deleteInvisible + : isSelectionHighlighted + ? materials.highlightedInvisible + : materials.invisible + } else { + ;(wallMesh as Mesh).material = isDeleteHighlighted + ? materials.deleteVisible + : isSelectionHighlighted + ? materials.highlightedVisible + : wallNode.material + ? materials.visible + : baseMaterial } - } - - lastWallMaterials.current.clear() - for (const [wallId, mats] of wallMaterialCache) { - lastWallMaterials.current.set(wallId, mats) - } - + }) lastWallMode.current = wallMode lastNumberOfWalls.current = sceneRegistry.byType.wall.size + lastHighlightKey.current = highlightKey } }) return null