sync: comprehensive monorepo → editor parity (2D/3D decoupling, UX polish, crash fixes)

Squash merge of 3 commits:
1. useLiveTransforms store for 2D/3D decoupling + floorplan overhaul + Sentry crash fixes
2. Comprehensive 59-file sync bringing editor to full monorepo parity (selection highlights, delete tool, furnish/zone modes, keyboard shortcuts, all panels)
3. Missing files fix (materials.ts, merged-outline-node.ts, type fix)

75 files changed, ~6K additions.
This commit is contained in:
Pascal
2026-04-07 19:21:10 -04:00
committed by GitHub
parent e8ad92592d
commit 0a46a9deb4
77 changed files with 6890 additions and 2062 deletions
+5
View File
@@ -10,6 +10,11 @@
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js", "import": "./dist/index.js",
"default": "./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": [ "files": [
@@ -1,3 +1,5 @@
'use client'
import { useLayoutEffect } from 'react' import { useLayoutEffect } from 'react'
import type * as THREE from 'three' import type * as THREE from 'three'
+3 -8
View File
@@ -1,5 +1,3 @@
// Store
export type { export type {
BuildingEvent, BuildingEvent,
CameraControlEvent, CameraControlEvent,
@@ -20,9 +18,7 @@ export type {
WindowEvent, WindowEvent,
ZoneEvent, ZoneEvent,
} from './events/bus' } from './events/bus'
// Events
export { emitter, eventSuffixes } from './events/bus' export { emitter, eventSuffixes } from './events/bus'
// Hooks
export { export {
sceneRegistry, sceneRegistry,
useRegistry, useRegistry,
@@ -33,24 +29,22 @@ export {
resolveLevelId, resolveLevelId,
} from './hooks/spatial-grid/spatial-grid-sync' } from './hooks/spatial-grid/spatial-grid-sync'
export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query' export { useSpatialQuery } from './hooks/spatial-grid/use-spatial-query'
// Asset storage
export { loadAssetUrl, saveAsset } from './lib/asset-storage' export { loadAssetUrl, saveAsset } from './lib/asset-storage'
// Space detection
export { export {
detectSpacesForLevel, detectSpacesForLevel,
initSpaceDetectionSync, initSpaceDetectionSync,
type Space, type Space,
wallTouchesOthers, wallTouchesOthers,
} from './lib/space-detection' } from './lib/space-detection'
// Schema export { baseMaterial, glassMaterial } from './materials'
export * from './schema' export * from './schema'
export { export {
type ControlValue, type ControlValue,
type ItemInteractiveState, type ItemInteractiveState,
useInteractive, useInteractive,
} from './store/use-interactive' } from './store/use-interactive'
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
export { clearSceneHistory, default as useScene } from './store/use-scene' export { clearSceneHistory, default as useScene } from './store/use-scene'
// Systems
export { CeilingSystem } from './systems/ceiling/ceiling-system' export { CeilingSystem } from './systems/ceiling/ceiling-system'
export { DoorSystem } from './systems/door/door-system' export { DoorSystem } from './systems/door/door-system'
export { ItemSystem } from './systems/item/item-system' export { ItemSystem } from './systems/item/item-system'
@@ -71,5 +65,6 @@ export {
} from './systems/wall/wall-mitering' } from './systems/wall/wall-mitering'
export { WallSystem } from './systems/wall/wall-system' export { WallSystem } from './systems/wall/wall-system'
export { WindowSystem } from './systems/window/window-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 { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph'
export { isObject } from './utils/types' export { isObject } from './utils/types'
+24
View File
@@ -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,
})
+1 -1
View File
@@ -1,5 +1,5 @@
// Base // Base
export { BaseNode, generateId, nodeType, objectId } from './base' export { BaseNode, generateId, Material, nodeType, objectId } from './base'
// Camera // Camera
export { CameraSchema } from './camera' export { CameraSchema } from './camera'
// Collections // Collections
+16 -30
View File
@@ -64,7 +64,6 @@ export const updateNodesAction = (
) => { ) => {
if (get().readOnly) return if (get().readOnly) return
const parentsToUpdate = new Set<AnyNodeId>() const parentsToUpdate = new Set<AnyNodeId>()
const idsToMarkDirty = new Set<AnyNodeId>()
set((state) => { set((state) => {
const nextNodes = { ...state.nodes } const nextNodes = { ...state.nodes }
@@ -105,26 +104,19 @@ export const updateNodesAction = (
return { nodes: nextNodes } 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) { for (const u of updates) {
idsToMarkDirty.add(u.id) pendingUpdates.add(u.id)
} }
for (const pId of parentsToUpdate) { 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) { if (pendingRafId !== null) {
cancelAnimationFrame(pendingRafId) cancelAnimationFrame(pendingRafId)
} }
pendingRafId = requestAnimationFrame(() => { pendingRafId = requestAnimationFrame(() => {
// Mark all pending updates as dirty
pendingUpdates.forEach((id) => { pendingUpdates.forEach((id) => {
get().markDirty(id) get().markDirty(id)
}) })
@@ -146,32 +138,26 @@ export const deleteNodesAction = (
const nextCollections = { ...state.collections } const nextCollections = { ...state.collections }
let nextRootIds = [...state.rootNodeIds] let nextRootIds = [...state.rootNodeIds]
// Collect all IDs to delete (including descendants) in a first pass // Collect all ids to delete (the requested ids + all their descendants) before
// This avoids issues with recursive calls during state mutation // mutating anything, so the recursive walk reads consistent state.
const allIdsToDelete = new Set<AnyNodeId>() const allIds = new Set<AnyNodeId>()
const collectDescendants = (id: AnyNodeId) => { const collect = (id: AnyNodeId) => {
if (allIds.has(id)) return
allIds.add(id)
const node = nextNodes[id] const node = nextNodes[id]
if (!node) return if (node && 'children' in node) {
allIdsToDelete.add(id) for (const cid of node.children as AnyNodeId[]) collect(cid)
if ('children' in node && node.children) {
for (const childId of node.children as AnyNodeId[]) {
collectDescendants(childId)
}
} }
} }
for (const id of ids) collect(id)
for (const id of ids) { for (const id of allIds) {
collectDescendants(id)
}
// Now process all nodes for deletion
for (const id of allIdsToDelete) {
const node = nextNodes[id] const node = nextNodes[id]
if (!node) continue 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 const parentId = node.parentId as AnyNodeId | null
if (parentId && nextNodes[parentId]) { if (parentId && nextNodes[parentId] && !allIds.has(parentId)) {
const parent = nextNodes[parentId] as AnyContainerNode const parent = nextNodes[parentId] as AnyContainerNode
if (parent.children) { if (parent.children) {
nextNodes[parent.id] = { 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) nextRootIds = nextRootIds.filter((rid) => rid !== id)
// 3. Remove from any collections it belongs to // 3. Remove from any collections it belongs to
@@ -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<string, LiveTransform>
set(nodeId: string, transform: LiveTransform): void
get(nodeId: string): LiveTransform | undefined
clear(nodeId: string): void
clearAll(): void
}
const useLiveTransforms = create<LiveTransformState>((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
+20 -18
View File
@@ -123,11 +123,6 @@ const useScene: UseSceneStore = create<SceneState>()(
setReadOnly: (readOnly: boolean) => set({ readOnly }), setReadOnly: (readOnly: boolean) => set({ readOnly }),
unloadScene: () => { unloadScene: () => {
// Clear temporal tracking to prevent memory leaks from stale node references
prevPastLength = 0
prevFutureLength = 0
prevNodesSnapshot = null
set({ set({
nodes: {}, nodes: {},
rootNodeIds: [], rootNodeIds: [],
@@ -145,14 +140,29 @@ const useScene: UseSceneStore = create<SceneState>()(
// Apply backward compatibility migrations // Apply backward compatibility migrations
const patchedNodes = migrateNodes(nodes) 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({ set({
nodes: patchedNodes, nodes: cleanedNodes,
rootNodeIds, rootNodeIds,
dirtyNodes: new Set<AnyNodeId>(), dirtyNodes: new Set<AnyNodeId>(),
collections: {}, collections: {},
}) })
// Mark all nodes as dirty to trigger re-validation // Mark all nodes as dirty to trigger re-validation
Object.values(patchedNodes).forEach((node) => { Object.values(cleanedNodes).forEach((node) => {
get().markDirty(node.id) get().markDirty(node.id)
}) })
}, },
@@ -292,7 +302,7 @@ const useScene: UseSceneStore = create<SceneState>()(
if (!col) return state if (!col) return state
const nextCollections = { const nextCollections = {
...state.collections, ...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] const node = state.nodes[nodeId]
if (!(node && 'collectionIds' in node)) return { collections: nextCollections } if (!(node && 'collectionIds' in node)) return { collections: nextCollections }
@@ -324,21 +334,13 @@ let prevPastLength = 0
let prevFutureLength = 0 let prevFutureLength = 0
let prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null let prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null
/** export function clearSceneHistory() {
* Clears temporal history tracking variables to prevent memory leaks. useScene.temporal.getState().clear()
* Should be called when unloading a scene to release node references.
*/
export function clearTemporalTracking() {
prevPastLength = 0 prevPastLength = 0
prevFutureLength = 0 prevFutureLength = 0
prevNodesSnapshot = null prevNodesSnapshot = null
} }
export function clearSceneHistory() {
useScene.temporal.getState().clear()
clearTemporalTracking()
}
// Subscribe to the temporal store (Undo/Redo events) // Subscribe to the temporal store (Undo/Redo events)
useScene.temporal.subscribe((state) => { useScene.temporal.subscribe((state) => {
const currentPastLength = state.pastStates.length const currentPastLength = state.pastStates.length
+1 -19
View File
@@ -1,28 +1,10 @@
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import * as THREE from 'three' import * as THREE from 'three'
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import { baseMaterial, glassMaterial } from '../../materials'
import type { AnyNodeId, DoorNode } from '../../schema' import type { AnyNodeId, DoorNode } from '../../schema'
import useScene from '../../store/use-scene' 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 // Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
@@ -10,8 +10,15 @@ import useScene from '../../store/use-scene'
const csgEvaluator = new Evaluator() const csgEvaluator = new Evaluator()
csgEvaluator.useGroups = true 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'] 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 // Pooled objects to avoid per-frame allocation in updateMergedRoofGeometry
const _matrix = new THREE.Matrix4() const _matrix = new THREE.Matrix4()
const _position = new THREE.Vector3() const _position = new THREE.Vector3()
@@ -78,6 +85,8 @@ export const RoofSystem = () => {
mesh.rotation.y = node.rotation mesh.rotation.y = node.rotation
} }
clearDirty(id as AnyNodeId) clearDirty(id as AnyNodeId)
} else {
clearDirty(id as AnyNodeId)
} }
// Queue the parent roof for a merged geometry update // Queue the parent roof for a merged geometry update
if (node.parentId) { if (node.parentId) {
@@ -179,6 +188,7 @@ function updateMergedRoofGeometry(
const next: Brush = csgEvaluator.evaluate(totalShinSlab, brushes.shinSlab, ADDITION) as Brush const next: Brush = csgEvaluator.evaluate(totalShinSlab, brushes.shinSlab, ADDITION) as Brush
totalShinSlab.geometry.dispose() totalShinSlab.geometry.dispose()
brushes.shinSlab.geometry.dispose() brushes.shinSlab.geometry.dispose()
prepareBrushForCSG(next)
totalShinSlab = next totalShinSlab = next
} else { } else {
totalShinSlab = brushes.shinSlab totalShinSlab = brushes.shinSlab
@@ -188,6 +198,7 @@ function updateMergedRoofGeometry(
const next: Brush = csgEvaluator.evaluate(totalDeckSlab, brushes.deckSlab, ADDITION) as Brush const next: Brush = csgEvaluator.evaluate(totalDeckSlab, brushes.deckSlab, ADDITION) as Brush
totalDeckSlab.geometry.dispose() totalDeckSlab.geometry.dispose()
brushes.deckSlab.geometry.dispose() brushes.deckSlab.geometry.dispose()
prepareBrushForCSG(next)
totalDeckSlab = next totalDeckSlab = next
} else { } else {
totalDeckSlab = brushes.deckSlab totalDeckSlab = brushes.deckSlab
@@ -197,6 +208,7 @@ function updateMergedRoofGeometry(
const next: Brush = csgEvaluator.evaluate(totalWall, brushes.wallBrush, ADDITION) as Brush const next: Brush = csgEvaluator.evaluate(totalWall, brushes.wallBrush, ADDITION) as Brush
totalWall.geometry.dispose() totalWall.geometry.dispose()
brushes.wallBrush.geometry.dispose() brushes.wallBrush.geometry.dispose()
prepareBrushForCSG(next)
totalWall = next totalWall = next
} else { } else {
totalWall = brushes.wallBrush totalWall = brushes.wallBrush
@@ -206,6 +218,7 @@ function updateMergedRoofGeometry(
const next: Brush = csgEvaluator.evaluate(totalInner, brushes.innerBrush, ADDITION) as Brush const next: Brush = csgEvaluator.evaluate(totalInner, brushes.innerBrush, ADDITION) as Brush
totalInner.geometry.dispose() totalInner.geometry.dispose()
brushes.innerBrush.geometry.dispose() brushes.innerBrush.geometry.dispose()
prepareBrushForCSG(next)
totalInner = next totalInner = next
} else { } else {
totalInner = brushes.innerBrush totalInner = brushes.innerBrush
@@ -505,6 +518,10 @@ export function getRoofSegmentBrushes(
const toBrush = (geo: THREE.BufferGeometry): Brush | null => { const toBrush = (geo: THREE.BufferGeometry): Brush | null => {
if (!geo?.attributes.position || geo.attributes.position.count === 0) return null if (!geo?.attributes.position || geo.attributes.position.count === 0) return null
if (!geo.index) 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 = computeBoundsTree
geo.computeBoundsTree({ maxLeafSize: 10 }) geo.computeBoundsTree({ maxLeafSize: 10 })
const brush = new Brush(geo, dummyMats) const brush = new Brush(geo, dummyMats)
+79 -34
View File
@@ -42,6 +42,11 @@ function updateSlabGeometry(node: SlabNode, mesh: THREE.Mesh) {
mesh.geometry.dispose() mesh.geometry.dispose()
mesh.geometry = newGeo 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 */ /** 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 * Generates extruded slab geometry from polygon
*/ */
export function generateSlabGeometry(slabNode: SlabNode): THREE.BufferGeometry { 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 polygon = outsetPolygon(slabNode.polygon, SLAB_OUTSET)
const elevation = slabNode.elevation ?? 0.05 const elevation = slabNode.elevation ?? 0.05
if (polygon.length < 3) { if (polygon.length < 3) return new THREE.BufferGeometry()
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 shape = new THREE.Shape()
const firstPt = polygon[0]! shape.moveTo(polygon[0]![0], -polygon[0]![1])
for (let i = 1; i < polygon.length; i++) shape.lineTo(polygon[i]![0], -polygon[i]![1])
// 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.closePath() shape.closePath()
// Add holes to the shape for (const holePolygon of slabNode.holes ?? []) {
const holes = slabNode.holes || []
for (const holePolygon of holes) {
if (holePolygon.length < 3) continue if (holePolygon.length < 3) continue
const holePath = new THREE.Path() const holePath = new THREE.Path()
const holeFirstPt = holePolygon[0]! holePath.moveTo(holePolygon[0]![0], -holePolygon[0]![1])
holePath.moveTo(holeFirstPt[0], -holeFirstPt[1]) for (let i = 1; i < holePolygon.length; i++)
holePath.lineTo(holePolygon[i]![0], -holePolygon[i]![1])
for (let i = 1; i < holePolygon.length; i++) {
const pt = holePolygon[i]!
holePath.lineTo(pt[0], -pt[1])
}
holePath.closePath() holePath.closePath()
shape.holes.push(holePath) shape.holes.push(holePath)
} }
// Extrude the shape by elevation const geometry = new THREE.ExtrudeGeometry(shape, { depth: elevation, bevelEnabled: false })
const geometry = new THREE.ExtrudeGeometry(shape, {
depth: elevation,
bevelEnabled: false,
})
// Rotate so extrusion direction (Z) becomes height direction (Y)
geometry.rotateX(-Math.PI / 2) geometry.rotateX(-Math.PI / 2)
geometry.computeVertexNormals() geometry.computeVertexNormals()
return geometry 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
}
@@ -22,6 +22,7 @@ const csgEvaluator = new Evaluator()
// WALL SYSTEM // WALL SYSTEM
// ============================================================================ // ============================================================================
let useFrameNb = 0
export const WallSystem = () => { export const WallSystem = () => {
const dirtyNodes = useScene((state) => state.dirtyNodes) const dirtyNodes = useScene((state) => state.dirtyNodes)
const clearDirty = useScene((state) => state.clearDirty) const clearDirty = useScene((state) => state.clearDirty)
@@ -34,6 +35,7 @@ export const WallSystem = () => {
// Collect dirty walls and their levels // Collect dirty walls and their levels
const dirtyWallsByLevel = new Map<string, Set<string>>() const dirtyWallsByLevel = new Map<string, Set<string>>()
useFrameNb += 1
dirtyNodes.forEach((id) => { dirtyNodes.forEach((id) => {
const node = nodes[id] const node = nodes[id]
if (!node || node.type !== 'wall') return if (!node || node.type !== 'wall') return
@@ -1,28 +1,10 @@
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import * as THREE from 'three' import * as THREE from 'three'
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
import { baseMaterial, glassMaterial } from '../../materials'
import type { AnyNodeId, WindowNode } from '../../schema' import type { AnyNodeId, WindowNode } from '../../schema'
import useScene from '../../store/use-scene' 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 // Invisible material for root mesh — used as selection hitbox only
const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false }) const hitboxMaterial = new THREE.MeshBasicMaterial({ visible: false })
@@ -108,7 +90,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
// Top / bottom — full width // Top / bottom — full width
addBox( addBox(
mesh, mesh,
frameMaterial, baseMaterial,
width, width,
frameThickness, frameThickness,
frameDepth, frameDepth,
@@ -118,7 +100,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
) )
addBox( addBox(
mesh, mesh,
frameMaterial, baseMaterial,
width, width,
frameThickness, frameThickness,
frameDepth, frameDepth,
@@ -129,7 +111,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
// Left / right — inner height to avoid corner overlap // Left / right — inner height to avoid corner overlap
addBox( addBox(
mesh, mesh,
frameMaterial, baseMaterial,
frameThickness, frameThickness,
innerH, innerH,
frameDepth, frameDepth,
@@ -139,7 +121,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
) )
addBox( addBox(
mesh, mesh,
frameMaterial, baseMaterial,
frameThickness, frameThickness,
innerH, innerH,
frameDepth, frameDepth,
@@ -184,7 +166,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
cx += colWidths[c]! cx += colWidths[c]!
addBox( addBox(
mesh, mesh,
frameMaterial, baseMaterial,
columnDividerThickness, columnDividerThickness,
innerH, innerH,
frameDepth, frameDepth,
@@ -203,7 +185,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
for (let c = 0; c < numCols; c++) { for (let c = 0; c < numCols; c++) {
addBox( addBox(
mesh, mesh,
frameMaterial, baseMaterial,
colWidths[c]!, colWidths[c]!,
rowDividerThickness, rowDividerThickness,
frameDepth, frameDepth,
@@ -239,7 +221,7 @@ function updateWindowMesh(node: WindowNode, mesh: THREE.Mesh) {
const sillZ = frameDepth / 2 + sillDepth / 2 const sillZ = frameDepth / 2 + sillDepth / 2
addBox( addBox(
mesh, mesh,
frameMaterial, baseMaterial,
sillW, sillW,
sillThickness, sillThickness,
sillDepth, sillDepth,
+27 -6
View File
@@ -21,8 +21,8 @@ function extractIdPrefix(id: string): string {
* parent-child relationships and other internal references. * parent-child relationships and other internal references.
* *
* This is useful for: * This is useful for:
* - Duplicating a project (host app creates a new project record, then loads the cloned scene)
* - Copying nodes between different projects * - Copying nodes between different projects
* - Duplicating a subset of a scene within the same project
* - Multi-scene in-memory scenarios * - Multi-scene in-memory scenarios
*/ */
export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
@@ -42,7 +42,6 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
for (const [oldId, node] of Object.entries(nodes)) { for (const [oldId, node] of Object.entries(nodes)) {
const newId = idMap.get(oldId)! as AnyNodeId const newId = idMap.get(oldId)! as AnyNodeId
// structuredClone to avoid shared references between original and clone
const clonedNode = structuredClone({ ...node, id: newId }) as AnyNode const clonedNode = structuredClone({ ...node, id: newId }) as AnyNode
// Remap parentId // Remap parentId
@@ -50,10 +49,23 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
clonedNode.parentId = (idMap.get(clonedNode.parentId) ?? null) as AnyNodeId | null 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)) { if ('children' in clonedNode && Array.isArray(clonedNode.children)) {
;(clonedNode as Record<string, unknown>).children = (clonedNode.children as string[]) ;(clonedNode as Record<string, unknown>).children = (clonedNode.children as unknown[])
.map((childId) => idMap.get(childId)) .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) .filter((id): id is string => id !== undefined)
} }
@@ -78,7 +90,6 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
clonedCollections = {} as Record<CollectionId, Collection> clonedCollections = {} as Record<CollectionId, Collection>
const collectionIdMap = new Map<string, CollectionId>() const collectionIdMap = new Map<string, CollectionId>()
// Generate new collection IDs
for (const collectionId of Object.keys(collections)) { for (const collectionId of Object.keys(collections)) {
collectionIdMap.set(collectionId, generateId('collection')) collectionIdMap.set(collectionId, generateId('collection'))
} }
@@ -166,6 +177,9 @@ export function cloneLevelSubtree(
const newLevelId = idMap.get(levelId)! as AnyNodeId const newLevelId = idMap.get(levelId)! as AnyNodeId
// Clone each node with remapped references. // 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[] = [] const clonedNodes: AnyNode[] = []
for (const oldId of subtreeIds) { for (const oldId of subtreeIds) {
const node = nodes[oldId] const node = nodes[oldId]
@@ -178,6 +192,7 @@ export function cloneLevelSubtree(
;(cloned as Record<string, unknown>).id = newId ;(cloned as Record<string, unknown>).id = newId
// Remap parentId — but only for descendants, not the level node itself // 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') { if (oldId !== levelId && cloned.parentId && typeof cloned.parentId === 'string') {
cloned.parentId = (idMap.get(cloned.parentId) ?? cloned.parentId) as AnyNodeId | null cloned.parentId = (idMap.get(cloned.parentId) ?? cloned.parentId) as AnyNodeId | null
} }
@@ -219,6 +234,7 @@ export function cloneLevelSubtree(
export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph { export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph {
const { nodes, rootNodeIds, collections } = sceneGraph const { nodes, rootNodeIds, collections } = sceneGraph
// First, identify scan and guide node IDs to exclude (user-uploaded imagery)
const excludedNodeIds = new Set<string>() const excludedNodeIds = new Set<string>()
for (const [nodeId, node] of Object.entries(nodes)) { for (const [nodeId, node] of Object.entries(nodes)) {
if (node.type === 'scan' || node.type === 'guide') { 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<AnyNodeId, AnyNode> const filteredNodes = {} as Record<AnyNodeId, AnyNode>
for (const [nodeId, node] of Object.entries(nodes)) { for (const [nodeId, node] of Object.entries(nodes)) {
if (excludedNodeIds.has(nodeId)) continue if (excludedNodeIds.has(nodeId)) continue
const clonedNode = structuredClone(node) as AnyNode 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)) { if ('children' in clonedNode && Array.isArray(clonedNode.children)) {
;(clonedNode as Record<string, unknown>).children = (clonedNode.children as unknown[]).filter( ;(clonedNode as Record<string, unknown>).children = (clonedNode.children as unknown[]).filter(
(child) => { (child) => {
@@ -251,6 +270,7 @@ export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph {
const filteredRootNodeIds = rootNodeIds.filter((id) => !excludedNodeIds.has(id)) const filteredRootNodeIds = rootNodeIds.filter((id) => !excludedNodeIds.has(id))
// Filter collections to remove references to scan nodes
let filteredCollections: Record<CollectionId, Collection> | undefined let filteredCollections: Record<CollectionId, Collection> | undefined
if (collections) { if (collections) {
filteredCollections = {} as Record<CollectionId, Collection> filteredCollections = {} as Record<CollectionId, Collection>
@@ -269,6 +289,7 @@ export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph {
} }
} }
// Now clone the filtered graph with new IDs
return cloneSceneGraph({ return cloneSceneGraph({
nodes: filteredNodes, nodes: filteredNodes,
rootNodeIds: filteredRootNodeIds, rootNodeIds: filteredRootNodeIds,
+2 -1
View File
@@ -4,7 +4,8 @@
"description": "Pascal building editor component", "description": "Pascal building editor component",
"type": "module", "type": "module",
"exports": { "exports": {
".": "./src/index.tsx" ".": "./src/index.tsx",
"./catalog": "./src/components/ui/item-catalog/catalog-items.tsx"
}, },
"scripts": { "scripts": {
"check-types": "tsc --noEmit" "check-types": "tsc --noEmit"
@@ -1,7 +1,7 @@
'use client' 'use client'
import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core' import { type CameraControlEvent, emitter, sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer, ZONE_LAYER } from '@pascal-app/viewer' import { useViewer, WalkthroughControls, ZONE_LAYER } from '@pascal-app/viewer'
import { CameraControls, CameraControlsImpl } from '@react-three/drei' import { CameraControls, CameraControlsImpl } from '@react-three/drei'
import { useThree } from '@react-three/fiber' import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef } from 'react' import { useCallback, useEffect, useMemo, useRef } from 'react'
@@ -22,7 +22,7 @@ const DEBUG_MAX_POLAR_ANGLE = Math.PI - 0.05
export const CustomCameraControls = () => { export const CustomCameraControls = () => {
const controls = useRef<CameraControlsImpl>(null!) const controls = useRef<CameraControlsImpl>(null!)
const isPreviewMode = useEditor((s) => s.isPreviewMode) const isPreviewMode = useEditor((s) => s.isPreviewMode)
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) const walkthroughMode = useViewer((s) => s.walkthroughMode)
const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera) const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera)
const selection = useViewer((s) => s.selection) const selection = useViewer((s) => s.selection)
const currentLevelId = selection.levelId const currentLevelId = selection.levelId
@@ -39,7 +39,7 @@ export const CustomCameraControls = () => {
}, [camera, raycaster]) }, [camera, raycaster])
useEffect(() => { useEffect(() => {
if (isPreviewMode || isFirstPersonMode) return if (isPreviewMode) return // Preview mode uses auto-navigate instead
let targetY = 0 let targetY = 0
if (currentLevelId) { if (currentLevelId) {
const levelMesh = sceneRegistry.nodes.get(currentLevelId) const levelMesh = sceneRegistry.nodes.get(currentLevelId)
@@ -47,21 +47,17 @@ export const CustomCameraControls = () => {
targetY = levelMesh.position.y targetY = levelMesh.position.y
} }
} }
if (!controls.current) return
if (firstLoad.current) { if (firstLoad.current) {
firstLoad.current = false firstLoad.current = false
;(controls.current as CameraControlsImpl).setLookAt(20, 20, 20, 0, 0, 0, true) controls.current.setLookAt(20, 20, 20, 0, 0, 0, true)
} }
;(controls.current as CameraControlsImpl).getTarget(currentTarget) controls.current.getTarget(currentTarget)
;(controls.current as CameraControlsImpl).moveTo( controls.current.moveTo(currentTarget.x, targetY, currentTarget.z, true)
currentTarget.x, }, [currentLevelId, isPreviewMode])
targetY,
currentTarget.z,
true,
)
}, [currentLevelId, isPreviewMode, isFirstPersonMode])
useEffect(() => { useEffect(() => {
if (!controls.current || isFirstPersonMode) return if (!controls.current) return
controls.current.maxPolarAngle = maxPolarAngle controls.current.maxPolarAngle = maxPolarAngle
controls.current.minPolarAngle = 0 controls.current.minPolarAngle = 0
@@ -69,7 +65,7 @@ export const CustomCameraControls = () => {
if (controls.current.polarAngle > maxPolarAngle) { if (controls.current.polarAngle > maxPolarAngle) {
controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true) controls.current.rotateTo(controls.current.azimuthAngle, maxPolarAngle, true)
} }
}, [maxPolarAngle, isFirstPersonMode]) }, [maxPolarAngle])
const focusNode = useCallback( const focusNode = useCallback(
(nodeId: string) => { (nodeId: string) => {
@@ -117,8 +113,6 @@ export const CustomCameraControls = () => {
}, [cameraMode, isPreviewMode]) }, [cameraMode, isPreviewMode])
useEffect(() => { useEffect(() => {
if (isFirstPersonMode) return
const keyState = { const keyState = {
shiftRight: false, shiftRight: false,
shiftLeft: false, shiftLeft: false,
@@ -199,7 +193,7 @@ export const CustomCameraControls = () => {
document.removeEventListener('keydown', onKeyDown) document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('keyup', onKeyUp) document.removeEventListener('keyup', onKeyUp)
} }
}, [cameraMode, isPreviewMode, isFirstPersonMode]) }, [cameraMode, isPreviewMode])
// Preview mode: auto-navigate camera to selected node (viewer behavior) // Preview mode: auto-navigate camera to selected node (viewer behavior)
const previewTargetNodeId = isPreviewMode const previewTargetNodeId = isPreviewMode
@@ -221,6 +215,14 @@ export const CustomCameraControls = () => {
// Check if node has a saved camera // Check if node has a saved camera
if (node.camera) { if (node.camera) {
const { position, target } = 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(() => { requestAnimationFrame(() => {
if (!controls.current) return if (!controls.current) return
controls.current.setLookAt( controls.current.setLookAt(
@@ -233,6 +235,7 @@ export const CustomCameraControls = () => {
true, true,
) )
}) })
}
return return
} }
@@ -261,8 +264,6 @@ export const CustomCameraControls = () => {
}, [isPreviewMode, previewTargetNodeId]) }, [isPreviewMode, previewTargetNodeId])
useEffect(() => { useEffect(() => {
if (isFirstPersonMode) return
const handleNodeCapture = ({ nodeId }: CameraControlEvent) => { const handleNodeCapture = ({ nodeId }: CameraControlEvent) => {
if (!controls.current) return if (!controls.current) return
@@ -354,7 +355,7 @@ export const CustomCameraControls = () => {
emitter.off('camera-controls:orbit-cw', handleOrbitCW) emitter.off('camera-controls:orbit-cw', handleOrbitCW)
emitter.off('camera-controls:orbit-ccw', handleOrbitCCW) emitter.off('camera-controls:orbit-ccw', handleOrbitCCW)
} }
}, [focusNode, isFirstPersonMode]) }, [focusNode])
const onTransitionStart = useCallback(() => { const onTransitionStart = useCallback(() => {
useViewer.getState().setCameraDragging(true) useViewer.getState().setCameraDragging(true)
@@ -364,9 +365,8 @@ export const CustomCameraControls = () => {
useViewer.getState().setCameraDragging(false) useViewer.getState().setCameraDragging(false)
}, []) }, [])
// In first-person mode, don't render orbit controls — FirstPersonControls takes over if (walkthroughMode) {
if (isFirstPersonMode) { return <WalkthroughControls />
return null
} }
return ( return (
+13 -2
View File
@@ -14,9 +14,11 @@ const SIDEBAR_COLLAPSE_THRESHOLD = 220
function LeftColumn({ function LeftColumn({
tabs, tabs,
renderTabContent, renderTabContent,
sidebarOverlay,
}: { }: {
tabs: SidebarTab[] tabs: SidebarTab[]
renderTabContent: (tabId: string) => ReactNode renderTabContent: (tabId: string) => ReactNode
sidebarOverlay?: ReactNode
}) { }) {
const width = useSidebarStore((s) => s.width) const width = useSidebarStore((s) => s.width)
const isCollapsed = useSidebarStore((s) => s.isCollapsed) const isCollapsed = useSidebarStore((s) => s.isCollapsed)
@@ -108,7 +110,10 @@ function LeftColumn({
}} }}
> >
<TabBar activeTab={activePanel} onTabChange={setActivePanel} tabs={tabs} /> <TabBar activeTab={activePanel} onTabChange={setActivePanel} tabs={tabs} />
<div className="flex flex-1 flex-col overflow-hidden">{renderTabContent(activePanel)}</div> <div className="relative flex flex-1 flex-col overflow-hidden">
{renderTabContent(activePanel)}
{sidebarOverlay && <div className="absolute inset-0 z-50">{sidebarOverlay}</div>}
</div>
{/* Resize handle + hit area */} {/* Resize handle + hit area */}
<div <div
@@ -171,6 +176,7 @@ export interface EditorLayoutV2Props {
navbarSlot?: ReactNode navbarSlot?: ReactNode
sidebarTabs?: SidebarTab[] sidebarTabs?: SidebarTab[]
renderTabContent: (tabId: string) => ReactNode renderTabContent: (tabId: string) => ReactNode
sidebarOverlay?: ReactNode
viewerToolbarLeft?: ReactNode viewerToolbarLeft?: ReactNode
viewerToolbarRight?: ReactNode viewerToolbarRight?: ReactNode
viewerContent: ReactNode viewerContent: ReactNode
@@ -181,6 +187,7 @@ export function EditorLayoutV2({
navbarSlot, navbarSlot,
sidebarTabs = [], sidebarTabs = [],
renderTabContent, renderTabContent,
sidebarOverlay,
viewerToolbarLeft, viewerToolbarLeft,
viewerToolbarRight, viewerToolbarRight,
viewerContent, viewerContent,
@@ -194,7 +201,11 @@ export function EditorLayoutV2({
{/* Main content: left column + right column */} {/* Main content: left column + right column */}
<div className="flex min-h-0 flex-1"> <div className="flex min-h-0 flex-1">
{sidebarTabs.length > 0 && ( {sidebarTabs.length > 0 && (
<LeftColumn renderTabContent={renderTabContent} tabs={sidebarTabs} /> <LeftColumn
renderTabContent={renderTabContent}
sidebarOverlay={sidebarOverlay}
tabs={sidebarTabs}
/>
)} )}
<RightColumn <RightColumn
overlays={overlays} overlays={overlays}
+48 -4
View File
@@ -7,6 +7,8 @@ import {
ItemNode, ItemNode,
RoofNode, RoofNode,
RoofSegmentNode, RoofSegmentNode,
StairNode,
StairSegmentNode,
sceneRegistry, sceneRegistry,
useScene, useScene,
WindowNode, WindowNode,
@@ -20,7 +22,17 @@ import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor' import useEditor from '../../store/use-editor'
import { NodeActionMenu } from './node-action-menu' import { NodeActionMenu } from './node-action-menu'
const ALLOWED_TYPES = ['item', 'door', 'window', 'roof', 'roof-segment', 'wall', 'slab'] const ALLOWED_TYPES = [
'item',
'door',
'window',
'roof',
'roof-segment',
'stair',
'stair-segment',
'wall',
'slab',
]
const DELETE_ONLY_TYPES = ['wall', 'slab'] const DELETE_ONLY_TYPES = ['wall', 'slab']
export function FloatingActionMenu() { export function FloatingActionMenu() {
@@ -66,7 +78,9 @@ export function FloatingActionMenu() {
node.type === 'window' || node.type === 'window' ||
node.type === 'door' || node.type === 'door' ||
node.type === 'roof' || node.type === 'roof' ||
node.type === 'roof-segment' node.type === 'roof-segment' ||
node.type === 'stair' ||
node.type === 'stair-segment'
) { ) {
setMovingNode(node as any) setMovingNode(node as any)
} }
@@ -98,6 +112,10 @@ export function FloatingActionMenu() {
duplicate = RoofNode.parse(duplicateInfo) duplicate = RoofNode.parse(duplicateInfo)
} else if (node.type === 'roof-segment') { } else if (node.type === 'roof-segment') {
duplicate = RoofSegmentNode.parse(duplicateInfo) duplicate = RoofSegmentNode.parse(duplicateInfo)
} else if (node.type === 'stair') {
duplicate = StairNode.parse(duplicateInfo)
} else if (node.type === 'stair-segment') {
duplicate = StairSegmentNode.parse(duplicateInfo)
} }
} catch (error) { } catch (error) {
console.error('Failed to parse duplicate', error) console.error('Failed to parse duplicate', error)
@@ -107,7 +125,12 @@ export function FloatingActionMenu() {
if (duplicate) { if (duplicate) {
if (duplicate.type === 'door' || duplicate.type === 'window') { if (duplicate.type === 'door' || duplicate.type === 'window') {
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
} else if (duplicate.type === 'roof' || duplicate.type === 'roof-segment') { } else if (
duplicate.type === 'roof' ||
duplicate.type === 'roof-segment' ||
duplicate.type === 'stair' ||
duplicate.type === 'stair-segment'
) {
// Add small offset to make it visible // Add small offset to make it visible
if ('position' in duplicate) { if ('position' in duplicate) {
duplicate.position = [ duplicate.position = [
@@ -136,13 +159,34 @@ export function FloatingActionMenu() {
} }
} }
} }
// Duplicate children for stair nodes
if (node.type === 'stair' && node.children) {
const nodesState = useScene.getState().nodes
for (const childId of node.children) {
const childNode = nodesState[childId]
if (childNode && childNode.type === 'stair-segment') {
let childDuplicateInfo = structuredClone(childNode) as any
delete childDuplicateInfo.id
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
try {
const childDuplicate = StairSegmentNode.parse(childDuplicateInfo)
useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
} catch (e) {
console.error('Failed to duplicate stair segment', e)
}
}
}
}
} }
if ( if (
duplicate.type === 'item' || duplicate.type === 'item' ||
duplicate.type === 'window' || duplicate.type === 'window' ||
duplicate.type === 'door' || duplicate.type === 'door' ||
duplicate.type === 'roof' || duplicate.type === 'roof' ||
duplicate.type === 'roof-segment' duplicate.type === 'roof-segment' ||
duplicate.type === 'stair' ||
duplicate.type === 'stair-segment'
) { ) {
setMovingNode(duplicate as any) setMovingNode(duplicate as any)
} }
File diff suppressed because it is too large Load Diff
+130 -40
View File
@@ -8,7 +8,14 @@ import {
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer' import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react' import {
type ReactNode,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useRef,
useState,
} from 'react'
import { ViewerOverlay } from '../../components/viewer-overlay' import { ViewerOverlay } from '../../components/viewer-overlay'
import { ViewerZoneSystem } from '../../components/viewer-zone-system' import { ViewerZoneSystem } from '../../components/viewer-zone-system'
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context' import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
@@ -47,7 +54,6 @@ import type { SidebarTab } from '../ui/sidebar/tab-bar'
import { CustomCameraControls } from './custom-camera-controls' import { CustomCameraControls } from './custom-camera-controls'
import { EditorLayoutV2 } from './editor-layout-v2' import { EditorLayoutV2 } from './editor-layout-v2'
import { ExportManager } from './export-manager' import { ExportManager } from './export-manager'
import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
import { FloatingActionMenu } from './floating-action-menu' import { FloatingActionMenu } from './floating-action-menu'
import { FloorplanPanel } from './floorplan-panel' import { FloorplanPanel } from './floorplan-panel'
import { Grid } from './grid' import { Grid } from './grid'
@@ -56,17 +62,19 @@ import { SelectionManager } from './selection-manager'
import { SiteEdgeLabels } from './site-edge-labels' import { SiteEdgeLabels } from './site-edge-labels'
import { ThumbnailGenerator } from './thumbnail-generator' import { ThumbnailGenerator } from './thumbnail-generator'
import { WallMeasurementLabel } from './wall-measurement-label' import { WallMeasurementLabel } from './wall-measurement-label'
import { FirstPersonControls, FirstPersonOverlay } from './first-person-controls'
const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1' const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-dismissed:v1'
const DELETE_CURSOR_BADGE_COLOR = '#ef4444'
const DELETE_CURSOR_BADGE_OFFSET_X = 14
const DELETE_CURSOR_BADGE_OFFSET_Y = 14
/** /**
* Wire up module-level singletons (spatial grid, space detection, SFX) for * Wire up module-level singletons (spatial grid, space detection, SFX) for
* an Editor mount. Returns a teardown function that detaches the scene-store * an Editor mount. Returns a teardown function that detaches the scene-store
* subscriptions and resets the shared singletons so a subsequent remount — * subscriptions and resets the shared singletons so a subsequent remount —
* including hot navigation back to the editor in the same tab — starts from * including hot navigation back to the editor in the same tab — starts from
* a clean slate. Without this, the spatial-grid manager and viewer outliner * a clean slate.
* accumulate stale references from the previous Editor instance and can
* freeze the app on re-entry.
*/ */
function initializeEditorRuntime(): () => void { function initializeEditorRuntime(): () => void {
const unsubscribeSpatialGrid = initSpatialGridSync() const unsubscribeSpatialGrid = initSpatialGridSync()
@@ -77,15 +85,8 @@ function initializeEditorRuntime(): () => void {
unsubscribeSpatialGrid() unsubscribeSpatialGrid()
unsubscribeSpaceDetection?.() unsubscribeSpaceDetection?.()
// Drop all entries the spatial-grid singleton accumulated for the
// previous scene so the next mount re-syncs from current state instead
// of layering on top of stale data.
spatialGridManager.clear() spatialGridManager.clear()
// The viewer outliner holds direct Object3D references used by the
// post-processing selection pass. Clearing the underlying arrays (we
// intentionally mutate in place — there is no setter by design) releases
// those refs so the disposed Three.js scene graph can be GC'd.
const outliner = useViewer.getState().outliner const outliner = useViewer.getState().outliner
outliner.selectedObjects.length = 0 outliner.selectedObjects.length = 0
outliner.hoveredObjects.length = 0 outliner.hoveredObjects.length = 0
@@ -123,6 +124,10 @@ export interface EditorProps {
// Thumbnail // Thumbnail
onThumbnailCapture?: (blob: Blob) => void onThumbnailCapture?: (blob: Blob) => void
// Version preview overlays (rendered by host app)
sidebarOverlay?: ReactNode
viewerBanner?: ReactNode
// Panel config (passed through to sidebar panels — v1 only) // Panel config (passed through to sidebar panels — v1 only)
settingsPanelProps?: SettingsPanelProps settingsPanelProps?: SettingsPanelProps
sitePanelProps?: SitePanelProps sitePanelProps?: SitePanelProps
@@ -472,6 +477,35 @@ function ViewerCanvasControlsHint({
) )
} }
function DeleteCursorBadge({ position }: { position: { x: number; y: number } }) {
return (
<div
aria-hidden="true"
className="pointer-events-none absolute z-40"
style={{
left: position.x + DELETE_CURSOR_BADGE_OFFSET_X,
top: position.y + DELETE_CURSOR_BADGE_OFFSET_Y,
}}
>
<div
className="flex h-8 w-8 items-center justify-center rounded-xl border border-white/5 bg-zinc-900/95 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]"
style={{
boxShadow: `0 8px 16px -4px rgba(0,0,0,0.3), 0 4px 8px -4px rgba(0,0,0,0.2), 0 0 18px ${DELETE_CURSOR_BADGE_COLOR}22`,
}}
>
<Icon
aria-hidden="true"
className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
color={DELETE_CURSOR_BADGE_COLOR}
height={18}
icon="mdi:trash-can-outline"
width={18}
/>
</div>
</div>
)
}
export default function Editor({ export default function Editor({
layoutVersion = 'v1', layoutVersion = 'v1',
appMenuButton, appMenuButton,
@@ -489,13 +523,15 @@ export default function Editor({
isVersionPreviewMode = false, isVersionPreviewMode = false,
isLoading = false, isLoading = false,
onThumbnailCapture, onThumbnailCapture,
sidebarOverlay,
viewerBanner,
settingsPanelProps, settingsPanelProps,
sitePanelProps, sitePanelProps,
extraSidebarPanels, extraSidebarPanels,
presetsAdapter, presetsAdapter,
commandPaletteEmptyAction, commandPaletteEmptyAction,
}: EditorProps) { }: EditorProps) {
useKeyboard() useKeyboard({ isVersionPreviewMode })
const { isLoadingSceneRef } = useAutoSave({ const { isLoadingSceneRef } = useAutoSave({
onSave, onSave,
@@ -510,10 +546,14 @@ export default function Editor({
null, null,
) )
const isPreviewMode = useEditor((s) => s.isPreviewMode) const isPreviewMode = useEditor((s) => s.isPreviewMode)
const mode = useEditor((s) => s.mode)
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen) const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen)
const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio) const floorplanPaneRatio = useEditor((s) => s.floorplanPaneRatio)
const setFloorplanPaneRatio = useEditor((s) => s.setFloorplanPaneRatio) const setFloorplanPaneRatio = useEditor((s) => s.setFloorplanPaneRatio)
const [viewerCursorPosition, setViewerCursorPosition] = useState<{ x: number; y: number } | null>(
null,
)
const sidebarWidth = useSidebarStore((s) => s.width) const sidebarWidth = useSidebarStore((s) => s.width)
const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed) const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
@@ -602,6 +642,17 @@ export default function Editor({
} }
}, [isVersionPreviewMode, previewScene]) }, [isVersionPreviewMode, previewScene])
// Lock scene graph and reset to select mode when entering version preview
useEffect(() => {
useScene.getState().setReadOnly(isVersionPreviewMode)
if (isVersionPreviewMode) {
useEditor.getState().setMode('select')
}
return () => {
useScene.getState().setReadOnly(false)
}
}, [isVersionPreviewMode])
useEffect(() => { useEffect(() => {
document.body.classList.add('dark') document.body.classList.add('dark')
return () => { return () => {
@@ -623,8 +674,8 @@ export default function Editor({
const viewerSceneContent = ( const viewerSceneContent = (
<> <>
{!isFirstPersonMode && <SelectionManager />} {!isFirstPersonMode && <SelectionManager />}
{!isFirstPersonMode && <BoxSelectTool />} {!isVersionPreviewMode && !isFirstPersonMode && <BoxSelectTool />}
{!isFirstPersonMode && <FloatingActionMenu />} {!isVersionPreviewMode && !isFirstPersonMode && <FloatingActionMenu />}
{!isFirstPersonMode && <WallMeasurementLabel />} {!isFirstPersonMode && <WallMeasurementLabel />}
<ExportManager /> <ExportManager />
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />} {isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
@@ -632,9 +683,9 @@ export default function Editor({
<RoofEditSystem /> <RoofEditSystem />
<StairEditSystem /> <StairEditSystem />
{!isLoading && !isFirstPersonMode && <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />} {!isLoading && !isFirstPersonMode && <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />}
{!isLoading && !isFirstPersonMode && <ToolManager />} {!(isLoading || isVersionPreviewMode) && !isFirstPersonMode && <ToolManager />}
<CustomCameraControls />
{isFirstPersonMode && <FirstPersonControls />} {isFirstPersonMode && <FirstPersonControls />}
<CustomCameraControls />
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} /> <ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
<PresetThumbnailGenerator /> <PresetThumbnailGenerator />
{!isFirstPersonMode && <SiteEdgeLabels />} {!isFirstPersonMode && <SiteEdgeLabels />}
@@ -661,6 +712,33 @@ export default function Editor({
const show2d = viewMode === '2d' || viewMode === 'split' const show2d = viewMode === '2d' || viewMode === 'split'
const show3d = viewMode === '3d' || viewMode === 'split' const show3d = viewMode === '3d' || viewMode === 'split'
const showDeleteCursorBadge = mode === 'delete' && !isVersionPreviewMode
useEffect(() => {
if (!(showDeleteCursorBadge && show3d)) {
setViewerCursorPosition(null)
}
}, [show3d, showDeleteCursorBadge])
const handleViewerPointerMove = useCallback(
(event: ReactPointerEvent<HTMLDivElement>) => {
if (!showDeleteCursorBadge) {
setViewerCursorPosition(null)
return
}
const rect = event.currentTarget.getBoundingClientRect()
setViewerCursorPosition({
x: event.clientX - rect.left,
y: event.clientY - rect.top,
})
},
[showDeleteCursorBadge],
)
const handleViewerPointerLeave = useCallback(() => {
setViewerCursorPosition(null)
}, [])
const viewerCanvas = ( const viewerCanvas = (
<ErrorBoundary fallback={<EditorSceneCrashFallback />}> <ErrorBoundary fallback={<EditorSceneCrashFallback />}>
@@ -689,8 +767,14 @@ export default function Editor({
{/* 3D viewer — always mounted, hidden via CSS to avoid destroying the WebGL context */} {/* 3D viewer — always mounted, hidden via CSS to avoid destroying the WebGL context */}
<div <div
className="relative min-w-0 flex-1 overflow-hidden" className="relative min-w-0 flex-1 overflow-hidden"
onPointerEnter={handleViewerPointerMove}
onPointerLeave={handleViewerPointerLeave}
onPointerMove={handleViewerPointerMove}
style={{ display: show3d ? undefined : 'none' }} style={{ display: show3d ? undefined : 'none' }}
> >
{showDeleteCursorBadge && viewerCursorPosition ? (
<DeleteCursorBadge position={viewerCursorPosition} />
) : null}
{!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? ( {!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
<ViewerCanvasControlsHint <ViewerCanvasControlsHint
isPreviewMode={isPreviewMode} isPreviewMode={isPreviewMode}
@@ -701,7 +785,7 @@ export default function Editor({
<Viewer selectionManager={isFirstPersonMode ? 'default' : 'custom'}>{viewerSceneContent}</Viewer> <Viewer selectionManager={isFirstPersonMode ? 'default' : 'custom'}>{viewerSceneContent}</Viewer>
</div> </div>
</div> </div>
{!isLoading && <ZoneLabelEditorSystem />} {!(isLoading || isVersionPreviewMode) && <ZoneLabelEditorSystem />}
</ErrorBoundary> </ErrorBoundary>
) )
@@ -741,6 +825,34 @@ export default function Editor({
</div> </div>
) : ( ) : (
<> <>
<EditorLayoutV2
navbarSlot={navbarSlot}
overlays={
<>
<FloatingLevelSelector />
{!isVersionPreviewMode && (
<div className="pointer-events-auto">
<ActionMenu />
</div>
)}
{!isVersionPreviewMode && (
<div className="pointer-events-auto">
<PanelManager />
</div>
)}
<div className="pointer-events-auto">
<HelperManager />
</div>
{viewerBanner}
</>
}
renderTabContent={renderTabContent}
sidebarOverlay={sidebarOverlay}
sidebarTabs={tabBarTabs}
viewerContent={viewerCanvas}
viewerToolbarLeft={viewerToolbarLeft}
viewerToolbarRight={viewerToolbarRight}
/>
{/* First-person overlay — rendered on top of normal layout */} {/* First-person overlay — rendered on top of normal layout */}
{isFirstPersonMode && ( {isFirstPersonMode && (
<div className="fixed inset-0 z-50 pointer-events-none"> <div className="fixed inset-0 z-50 pointer-events-none">
@@ -749,28 +861,6 @@ export default function Editor({
/> />
</div> </div>
)} )}
<EditorLayoutV2
navbarSlot={navbarSlot}
overlays={
<>
<FloatingLevelSelector />
<div className="pointer-events-auto">
<ActionMenu />
</div>
<div className="pointer-events-auto">
<PanelManager />
</div>
<div className="pointer-events-auto">
<HelperManager />
</div>
</>
}
renderTabContent={renderTabContent}
sidebarTabs={tabBarTabs}
viewerContent={viewerCanvas}
viewerToolbarLeft={viewerToolbarLeft}
viewerToolbarRight={viewerToolbarRight}
/>
<EditorCommands /> <EditorCommands />
<CommandPalette emptyAction={commandPaletteEmptyAction} /> <CommandPalette emptyAction={commandPaletteEmptyAction} />
</> </>
+265 -10
View File
@@ -11,7 +11,8 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' 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 { sfxEmitter } from '../../lib/sfx-bus'
import useEditor, { type Phase, type StructureLayer } from './../../store/use-editor' import useEditor, { type Phase, type StructureLayer } from './../../store/use-editor'
import { boxSelectHandled } from '../tools/select/box-select-tool' import { boxSelectHandled } from '../tools/select/box-select-tool'
@@ -66,6 +67,88 @@ export const resolveBuildingId = (
return null 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 = ( const computeNextIds = (
node: AnyNode, node: AnyNode,
selectedIds: string[], selectedIds: string[],
@@ -99,7 +182,19 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
}, },
structure: { 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) => { handleSelect: (node, nativeEvent, modifierKeys) => {
const { selection, setSelection } = useViewer.getState() const { selection, setSelection } = useViewer.getState()
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
@@ -144,7 +239,9 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
node.type === 'slab' || node.type === 'slab' ||
node.type === 'ceiling' || node.type === 'ceiling' ||
node.type === 'roof' || node.type === 'roof' ||
node.type === 'roof-segment' node.type === 'roof-segment' ||
node.type === 'stair' ||
node.type === 'stair-segment'
) )
return true return true
if (node.type === 'item') { if (node.type === 'item') {
@@ -204,6 +301,8 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
node.type === 'ceiling' || node.type === 'ceiling' ||
node.type === 'roof' || node.type === 'roof' ||
node.type === 'roof-segment' || node.type === 'roof-segment' ||
node.type === 'stair' ||
node.type === 'stair-segment' ||
node.type === 'window' || node.type === 'window' ||
node.type === 'door' node.type === 'door'
) { ) {
@@ -233,6 +332,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
export const SelectionManager = () => { export const SelectionManager = () => {
const phase = useEditor((s) => s.phase) const phase = useEditor((s) => s.phase)
const mode = useEditor((s) => s.mode) const mode = useEditor((s) => s.mode)
const setHoverHighlightMode = useViewer((s) => s.setHoverHighlightMode)
const modifierKeysRef = useRef<ModifierKeys>({ const modifierKeysRef = useRef<ModifierKeys>({
meta: false, meta: false,
ctrl: false, ctrl: false,
@@ -241,6 +341,14 @@ export const SelectionManager = () => {
const movingNode = useEditor((s) => s.movingNode) const movingNode = useEditor((s) => s.movingNode)
useEffect(() => {
setHoverHighlightMode(mode === 'delete' ? 'delete' : 'default')
return () => {
setHoverHighlightMode('default')
}
}, [mode, setHoverHighlightMode])
useEffect(() => { useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Meta') modifierKeysRef.current.meta = true if (event.key === 'Meta') modifierKeysRef.current.meta = true
@@ -314,6 +422,12 @@ export const SelectionManager = () => {
nodeToSelect = parentNode 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) activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
@@ -333,6 +447,8 @@ export const SelectionManager = () => {
'ceiling', 'ceiling',
'roof', 'roof',
'roof-segment', 'roof-segment',
'stair',
'stair-segment',
'window', 'window',
'door', 'door',
] ]
@@ -343,8 +459,15 @@ export const SelectionManager = () => {
const onGridClick = () => { const onGridClick = () => {
if (clickHandledRef.current) return if (clickHandledRef.current) return
if (boxSelectHandled) 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() 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) emitter.on('grid:click', onGridClick)
@@ -415,6 +538,8 @@ export const SelectionManager = () => {
node.type === 'ceiling' || node.type === 'ceiling' ||
node.type === 'roof' || node.type === 'roof' ||
node.type === 'roof-segment' || node.type === 'roof-segment' ||
node.type === 'stair' ||
node.type === 'stair-segment' ||
node.type === 'window' || node.type === 'window' ||
node.type === 'door' node.type === 'door'
) { ) {
@@ -422,6 +547,9 @@ export const SelectionManager = () => {
if (node.type === 'roof-segment' && currentPhase === 'structure') { if (node.type === 'roof-segment' && currentPhase === 'structure') {
forceSelect = true // allow double click to dive into roof-segment even if already in structure phase 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') { } else if (node.type === 'item') {
const item = node as ItemNode const item = node as ItemNode
if (item.asset.category === 'door' || item.asset.category === 'window') { if (item.asset.category === 'door' || item.asset.category === 'window') {
@@ -461,6 +589,8 @@ export const SelectionManager = () => {
'ceiling', 'ceiling',
'roof', 'roof',
'roof-segment', 'roof-segment',
'stair',
'stair-segment',
'window', 'window',
'door', 'door',
'zone', 'zone',
@@ -529,6 +659,8 @@ export const SelectionManager = () => {
'ceiling', 'ceiling',
'roof', 'roof',
'roof-segment', 'roof-segment',
'stair',
'stair-segment',
'window', 'window',
'door', 'door',
'zone', 'zone',
@@ -553,6 +685,7 @@ export const SelectionManager = () => {
return ( return (
<> <>
<SelectionStateSync /> <SelectionStateSync />
<SelectionMaterialSync />
<EditorOutlinerSync /> <EditorOutlinerSync />
</> </>
) )
@@ -590,9 +723,127 @@ const SelectionStateSync = () => {
return null 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<string, HighlightKind>())
const highlightedMaterialsRef = useRef(
new Map<
Mesh,
{
originalMaterial: Material | Material[]
highlightedMaterial: Material | Material[]
kind: HighlightKind
}
>(),
)
const syncSelectionMaterials = useCallback(() => {
const activeMeshes = new Set<Mesh>()
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<string, HighlightKind>()
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 EditorOutlinerSync = () => {
const phase = useEditor((s) => s.phase) const phase = useEditor((s) => s.phase)
const selection = useViewer((s) => s.selection) const selection = useViewer((s) => s.selection)
const previewSelectedIds = useViewer((s) => s.previewSelectedIds)
const hoveredId = useViewer((s) => s.hoveredId) const hoveredId = useViewer((s) => s.hoveredId)
const outliner = useViewer((s) => s.outliner) const outliner = useViewer((s) => s.outliner)
@@ -609,19 +860,23 @@ const EditorOutlinerSync = () => {
case 'structure': case 'structure':
// Highlight selected items (walls/slabs) // Highlight selected items (walls/slabs)
// We IGNORE buildingId even if it's set in the store // We IGNORE buildingId even if it's set in the store
idsToHighlight = selection.selectedIds idsToHighlight = Array.from(new Set([...selection.selectedIds, ...previewSelectedIds]))
break break
case 'furnish': case 'furnish':
// Highlight selected furniture/items // Highlight selected furniture/items
idsToHighlight = selection.selectedIds idsToHighlight = Array.from(new Set([...selection.selectedIds, ...previewSelectedIds]))
break break
default: default:
// Pure Viewer mode: Highlight based on the "deepest" selection // Pure Viewer mode: Highlight based on the "deepest" selection
if (selection.selectedIds.length > 0) idsToHighlight = selection.selectedIds if (selection.selectedIds.length > 0 || previewSelectedIds.length > 0) {
else if (selection.levelId) idsToHighlight = [selection.levelId] idsToHighlight = Array.from(new Set([...selection.selectedIds, ...previewSelectedIds]))
else if (selection.buildingId) idsToHighlight = [selection.buildingId] } 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) // 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) const obj = sceneRegistry.nodes.get(hoveredId)
if (obj) outliner.hoveredObjects.push(obj) if (obj) outliner.hoveredObjects.push(obj)
} }
}, [phase, selection, hoveredId, outliner]) }, [phase, previewSelectedIds, selection, hoveredId, outliner])
return null return null
} }
-1
View File
@@ -58,7 +58,6 @@ export function WallMeasurementLabel() {
const [wallObject, setWallObject] = useState<THREE.Object3D | null>(null) const [wallObject, setWallObject] = useState<THREE.Object3D | null>(null)
// biome-ignore lint/correctness/useExhaustiveDependencies: reset cached object when selection changes
useEffect(() => { useEffect(() => {
setWallObject(null) setWallObject(null)
}, [selectedId]) }, [selectedId])
+142 -16
View File
@@ -1,11 +1,12 @@
'use client' '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 { useViewer } from '@pascal-app/viewer'
import { Check, Pencil } from 'lucide-react' import { Check, Pencil } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
// ─── Per-zone label editor ──────────────────────────────────────────────────── // ─── Per-zone label editor ────────────────────────────────────────────────────
@@ -13,7 +14,13 @@ import useEditor from '../../../store/use-editor'
function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) { function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
const zone = useScene((s) => s.nodes[zoneId] as ZoneNode | undefined) const zone = useScene((s) => s.nodes[zoneId] as ZoneNode | undefined)
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const deleteNode = useScene((s) => s.deleteNode)
const setSelection = useViewer((s) => s.setSelection) 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 [editing, setEditing] = useState(false)
const [value, setValue] = useState('') const [value, setValue] = useState('')
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
@@ -27,15 +34,26 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
// Setup: find the label element, enable pointer events, and hide the // Setup: find the label element, enable pointer events, and hide the
// zone-renderer's own text node (children[0]) — we replace it via portal. // zone-renderer's own text node (children[0]) — we replace it via portal.
// Retries via rAF because the <Html> element from drei may not exist yet at mount time.
useEffect(() => { useEffect(() => {
const el = document.getElementById(`${zoneId}-label`) let cancelled = false
if (!el) return let textEl: HTMLElement | undefined
setLabelEl(el)
const textEl = el.children[0] as HTMLElement | undefined 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' if (textEl) textEl.style.display = 'none'
}
tryFind()
return () => { return () => {
cancelled = true
if (textEl) textEl.style.display = '' if (textEl) textEl.style.display = ''
} }
}, [zoneId]) }, [zoneId])
@@ -48,6 +66,29 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
} }
}, [editing]) }, [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 save = useCallback(() => {
const trimmed = value.trim() const trimmed = value.trim()
if (trimmed !== (zone?.name ?? '')) { if (trimmed !== (zone?.name ?? '')) {
@@ -61,9 +102,38 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
setEditing(false) setEditing(false)
}, [zone?.name]) }, [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 if (!labelEl) return null
const shadowColor = zone?.color ?? '#6366f1' const shadowColor = isDeleteHovered ? '#dc2626' : (zone?.color ?? '#6366f1')
const textShadow = [ const textShadow = [
`-1px -1px 0 ${shadowColor}`, `-1px -1px 0 ${shadowColor}`,
` 1px -1px 0 ${shadowColor}`, ` 1px -1px 0 ${shadowColor}`,
@@ -151,18 +221,78 @@ function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setSelection({ zoneId }) if (mode === 'delete') {
setValue(zoneNameRef.current) sfxEmitter.emit('sfx:structure-delete')
setEditing(true) deleteNode(zoneId as AnyNodeId)
setSelection({ zoneId: null })
return
}
if (isSelected) {
// Already selected → enter text editing
enterTextEditing()
} else {
// Not selected → select zone + switch to zone mode
selectZone()
}
}} }}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
style={{ ...sharedStyle, background: 'none', border: 'none', cursor: 'text', padding: 0 }} onPointerEnter={(e) => {
if (mode === 'delete') {
useViewer.setState({ hoveredId: zoneId })
}
}}
onPointerLeave={() => {
if (mode === 'delete' && useViewer.getState().hoveredId === zoneId) {
useViewer.setState({ hoveredId: null })
}
}}
onPointerMove={
mode === 'delete'
? (e) => {
// Re-dispatch pointermove to the viewer container so DeleteCursorBadge tracks the cursor.
const viewerDiv = (e.currentTarget as HTMLElement).closest(
'.relative.overflow-hidden',
)
if (viewerDiv) {
viewerDiv.dispatchEvent(
new PointerEvent('pointermove', {
clientX: e.clientX,
clientY: e.clientY,
bubbles: true,
}),
)
}
}
: undefined
}
style={{
...sharedStyle,
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
}}
type="button" type="button"
> >
<span>{zone?.name}</span> <span>{zone?.name}</span>
<span style={{ display: 'inline-flex', alignItems: 'center', opacity: 0.55 }}> {isSelected && (
<Pencil size={10} /> <span
onClick={(e) => {
e.stopPropagation()
enterTextEditing()
}}
role="button"
style={{
display: 'inline-flex',
alignItems: 'center',
cursor: 'text',
filter: `drop-shadow(0 0 2px ${shadowColor})`,
}}
tabIndex={0}
>
<Pencil size={12} />
</span> </span>
)}
</button> </button>
), ),
labelEl, labelEl,
@@ -179,10 +309,6 @@ export function ZoneLabelEditorSystem() {
.map((n) => n.id as ZoneNode['id']), .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 ( return (
<> <>
+61 -15
View File
@@ -1,17 +1,26 @@
import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core' import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import { type Group, MathUtils, type Mesh } from 'three'
import type { MeshBasicNodeMaterial } from 'three/webgpu'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
export const ZoneSystem = () => { // Disable raycasting on zone geometry so clicks pass through to items underneath.
useFrame(() => { // Zone selection in the editor is handled exclusively via the HTML label overlay.
const structureLayer = useEditor.getState().structureLayer const noopRaycast = () => {}
const levelMode = useViewer.getState().levelMode
const selectedLevelId = useViewer.getState().selection.levelId
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 zones = sceneRegistry.byType.zone || new Set()
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
const lerpSpeed = 10 * delta
zones.forEach((zoneId) => { zones.forEach((zoneId) => {
const obj = sceneRegistry.nodes.get(zoneId) const obj = sceneRegistry.nodes.get(zoneId)
@@ -19,20 +28,57 @@ export const ZoneSystem = () => {
const zone = nodes[zoneId as ZoneNode['id']] as ZoneNode | undefined 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 isOnSelectedLevel = zone?.parentId === selectedLevelId
const hideInSoloMode = levelMode === 'solo' && selectedLevelId && !isOnSelectedLevel const isSelected = zoneId === selectedZoneId
const isDeleteHovered = editorMode === 'delete' && hoveredId === zoneId
if (obj.visible !== visible) { // Keep group visible (so <Html> labels stay active), hide/show meshes only.
obj.visible = visible // 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 floor = (obj as Group).getObjectByName('floor') as Mesh | undefined
const showLabel = visible && !hideInSoloMode if (floor) {
const targetOpacity = showLabel ? '1' : '0' 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`) const labelEl = document.getElementById(`${zoneId}-label`)
if (labelEl && labelEl.style.opacity !== targetOpacity) { if (labelEl && labelEl.style.opacity !== labelOpacity) {
labelEl.style.opacity = targetOpacity labelEl.style.opacity = labelOpacity
} }
}) })
}) })
@@ -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 { Vector3 } from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
@@ -76,5 +84,7 @@ export const MoveTool: React.FC = () => {
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} /> if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} /> return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
return <MoveItemContent movingNode={movingNode as ItemNode} /> return <MoveItemContent movingNode={movingNode as ItemNode} />
} }
@@ -9,6 +9,7 @@ import {
resolveLevelId, resolveLevelId,
sceneRegistry, sceneRegistry,
spatialGridManager, spatialGridManager,
useLiveTransforms,
useScene, useScene,
useSpatialQuery, useSpatialQuery,
type WallEvent, type WallEvent,
@@ -219,6 +220,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const draft = draftNode.current const draft = draftNode.current
if (draft) draft.position = result.gridPosition 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() revalidate()
} }
@@ -229,6 +238,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
// Preserve cursor rotation for the next draft // Preserve cursor rotation for the next draft
const currentRotation: [number, number, number] = [0, cursorGroupRef.current.rotation.y, 0] 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) draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) { if (configRef.current.onCommitted()) {
draftNode.create(gridPosition.current, asset, currentRotation) draftNode.create(gridPosition.current, asset, currentRotation)
@@ -353,6 +367,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (result.dirtyNodeId && posChanged) { if (result.dirtyNodeId && posChanged) {
useScene.getState().dirtyNodes.add(result.dirtyNodeId) 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 if (!result) return
event.stopPropagation() event.stopPropagation()
// Clear live transform before commit
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
}
draftNode.commit(result.nodeUpdate) draftNode.commit(result.nodeUpdate)
if (result.dirtyNodeId) { if (result.dirtyNodeId) {
useScene.getState().dirtyNodes.add(result.dirtyNodeId) useScene.getState().dirtyNodes.add(result.dirtyNodeId)
@@ -470,6 +494,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
draft.position = result.gridPosition draft.position = result.gridPosition
const mesh = sceneRegistry.nodes.get(draft.id) const mesh = sceneRegistry.nodes.get(draft.id)
if (mesh) mesh.position.set(...result.gridPosition) 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() revalidate()
@@ -508,6 +538,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (!result) return if (!result) return
event.stopPropagation() event.stopPropagation()
// Clear live transform before commit
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
}
draftNode.commit(result.nodeUpdate) draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) { if (configRef.current.onCommitted()) {
@@ -578,6 +612,12 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
draft.position = result.gridPosition draft.position = result.gridPosition
const mesh = sceneRegistry.nodes.get(draft.id) const mesh = sceneRegistry.nodes.get(draft.id)
if (mesh) mesh.position.copy(gridPosition.current) 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 if (!result) return
event.stopPropagation() event.stopPropagation()
// Clear live transform before commit
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
}
draftNode.commit(result.nodeUpdate) draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) { if (configRef.current.onCommitted()) {
@@ -657,6 +701,16 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
cursorGroupRef.current.rotation.y = newRotationY cursorGroupRef.current.rotation.y = newRotationY
const mesh = sceneRegistry.nodes.get(draft.id) const mesh = sceneRegistry.nodes.get(draft.id)
if (mesh) mesh.rotation.y = newRotationY 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() revalidate()
} }
} }
@@ -693,7 +747,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const draft = draftNode.current const draft = draftNode.current
const dims = draft ? getScaledDimensions(draft) : (asset.dimensions ?? DEFAULT_DIMENSIONS) const dims = draft ? getScaledDimensions(draft) : (asset.dimensions ?? DEFAULT_DIMENSIONS)
const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2]) 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) const edgesGeometry = new EdgesGeometry(boxGeometry)
edgesRef.current.geometry = edgesGeometry edgesRef.current.geometry = edgesGeometry
@@ -715,6 +770,10 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
emitter.on('ceiling:leave', onCeilingLeave) emitter.on('ceiling:leave', onCeilingLeave)
return () => { return () => {
// Clear live transform for any remaining draft
if (draftNode.current) {
useLiveTransforms.getState().clear(draftNode.current.id)
}
draftNode.destroy() draftNode.destroy()
useScene.temporal.getState().resume() useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
@@ -793,16 +852,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
? getScaledDimensions(initialDraft) ? getScaledDimensions(initialDraft)
: (config.asset.dimensions ?? DEFAULT_DIMENSIONS) : (config.asset.dimensions ?? DEFAULT_DIMENSIONS)
const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2]) 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) // Base plane geometry (colored rectangle on the ground)
const basePlaneGeometry = new PlaneGeometry(dims[0], dims[2]) const basePlaneGeometry = new PlaneGeometry(dims[0], dims[2])
basePlaneGeometry.rotateX(-Math.PI / 2) // Make it horizontal 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 ( return (
<group ref={cursorGroupRef}> <group ref={cursorGroupRef}>
<lineSegments layers={EDITOR_LAYER} material={edgeMaterial} ref={edgesRef}> <lineSegments layers={EDITOR_LAYER} material={edgeMaterial} ref={edgesRef} renderOrder={999}>
<edgesGeometry args={[initialBoxGeometry]} /> <edgesGeometry args={[initialBoxGeometry]} />
</lineSegments> </lineSegments>
<mesh <mesh
@@ -810,6 +870,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
layers={EDITOR_LAYER} layers={EDITOR_LAYER}
material={basePlaneMaterial} material={basePlaneMaterial}
ref={basePlaneRef} ref={basePlaneRef}
renderOrder={999}
/> />
</group> </group>
) )
@@ -4,7 +4,10 @@ import {
type GridEvent, type GridEvent,
type RoofNode, type RoofNode,
type RoofSegmentNode, type RoofSegmentNode,
type StairNode,
type StairSegmentNode,
sceneRegistry, sceneRegistry,
useLiveTransforms,
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
@@ -14,9 +17,9 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({ export const MoveRoofTool: React.FC<{
node: movingNode, node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode
}) => { }> = ({ node: movingNode }) => {
const exitMoveMode = useCallback(() => { const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingNode(null) useEditor.getState().setMovingNode(null)
}, []) }, [])
@@ -31,7 +34,10 @@ export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({
return [pos.x, pos.y, pos.z] return [pos.x, pos.y, pos.z]
} }
// Fallback if not registered (e.g. newly created duplicate without mesh yet) // 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] const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
if (parentNode && 'position' in parentNode && 'rotation' in parentNode) { if (parentNode && 'position' in parentNode && 'rotation' in parentNode) {
const parentAngle = parentNode.rotation as number 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. // user sees the individual segment tracking the cursor.
let segmentWrapperGroup: THREE.Object3D | null = null let segmentWrapperGroup: THREE.Object3D | null = null
let mergedRoofMesh: 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) const segmentMesh = sceneRegistry.nodes.get(movingNode.id)
if (segmentMesh?.parent) { if (segmentMesh?.parent) {
// segmentMesh.parent = <group visible={isSelected}> wrapper in RoofRenderer // segmentMesh.parent = <group visible={isSelected}> wrapper in Roof/StairRenderer
// segmentMesh.parent.parent = the registered roof group // segmentMesh.parent.parent = the registered roof/stair group
segmentWrapperGroup = segmentMesh.parent 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 segmentWrapperGroup.visible = true
if (mergedRoofMesh) mergedRoofMesh.visible = false if (mergedRoofMesh) mergedRoofMesh.visible = false
} }
@@ -111,7 +118,10 @@ export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({
let localX = gridX let localX = gridX
let localZ = gridZ 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] const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
if (parentNode && 'position' in parentNode && 'rotation' in parentNode) { if (parentNode && 'position' in parentNode && 'rotation' in parentNode) {
const parentObj = sceneRegistry.nodes.get(movingNode.parentId) 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.x = localX
mesh.position.z = localZ 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) => { const onGridClick = (event: GridEvent) => {
@@ -181,11 +197,13 @@ export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({
sfxEmitter.emit('sfx:item-place') sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [movingNode.id] }) useViewer.getState().setSelection({ selectedIds: [movingNode.id] })
useLiveTransforms.getState().clear(movingNode.id)
exitMoveMode() exitMoveMode()
event.nativeEvent?.stopPropagation?.() event.nativeEvent?.stopPropagation?.()
} }
const onCancel = () => { const onCancel = () => {
useLiveTransforms.getState().clear(movingNode.id)
if (isNew) { if (isNew) {
useScene.getState().deleteNode(movingNode.id) useScene.getState().deleteNode(movingNode.id)
} else { } else {
@@ -218,6 +236,15 @@ export const MoveRoofTool: React.FC<{ node: RoofNode | RoofSegmentNode }> = ({
// Directly update the Three.js mesh — no store update during drag // Directly update the Three.js mesh — no store update during drag
const mesh = sceneRegistry.nodes.get(movingNode.id) const mesh = sceneRegistry.nodes.get(movingNode.id)
if (mesh) mesh.rotation.y = pendingRotation 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 (segmentWrapperGroup) segmentWrapperGroup.visible = false
if (mergedRoofMesh) mergedRoofMesh.visible = true if (mergedRoofMesh) mergedRoofMesh.visible = true
// Clear ephemeral live transform
useLiveTransforms.getState().clear(movingNode.id)
if (!wasCommitted) { if (!wasCommitted) {
if (isNew) { if (isNew) {
useScene.getState().deleteNode(movingNode.id) useScene.getState().deleteNode(movingNode.id)
@@ -16,6 +16,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber' import { useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { import {
Box3,
BufferAttribute, BufferAttribute,
BufferGeometry, BufferGeometry,
DoubleSide, DoubleSide,
@@ -151,6 +152,7 @@ function pointInPolygon(x: number, z: number, polygon: [number, number][]): bool
// ── Node-in-bounds checks ─────────────────────────────────────────────────── // ── Node-in-bounds checks ───────────────────────────────────────────────────
const _tempVec = new Vector3() const _tempVec = new Vector3()
const _tempBox = new Box3()
function getNodeWorldXZ(nodeId: string): [number, number] | null { function getNodeWorldXZ(nodeId: string): [number, number] | null {
const obj = sceneRegistry.nodes.get(nodeId) const obj = sceneRegistry.nodes.get(nodeId)
@@ -159,6 +161,26 @@ function getNodeWorldXZ(nodeId: string): [number, number] | null {
return [_tempVec.x, _tempVec.z] 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[] { function collectNodeIdsInBounds(bounds: Bounds): string[] {
const { levelId } = useViewer.getState().selection const { levelId } = useViewer.getState().selection
const { nodes } = useScene.getState() const { nodes } = useScene.getState()
@@ -214,6 +236,10 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
if (xz && pointInBounds(xz[0], xz[1], bounds)) { if (xz && pointInBounds(xz[0], xz[1], bounds)) {
result.push(node.id) result.push(node.id)
} }
} else if (node.type === 'stair') {
if (objectBoundsIntersectsBounds(node.id, bounds)) {
result.push(node.id)
}
} }
} }
} else if (phase === 'structure' && structureLayer === 'zones') { } else if (phase === 'structure' && structureLayer === 'zones') {
@@ -243,6 +269,13 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
return result return result
} }
function haveSameIds(currentIds: string[], nextIds: string[]): boolean {
return (
currentIds.length === nextIds.length &&
currentIds.every((currentId, index) => currentId === nextIds[index])
)
}
// ── Visual helpers ────────────────────────────────────────────────────────── // ── Visual helpers ──────────────────────────────────────────────────────────
function updateRectVisuals( function updateRectVisuals(
@@ -300,11 +333,11 @@ function createOutlineSegments(): LineSegments {
geo.setAttribute('position', new BufferAttribute(positions, 3)) geo.setAttribute('position', new BufferAttribute(positions, 3))
const mat = new LineBasicMaterial({ const mat = new LineBasicMaterial({
color: '#818cf8', color: BOX_SELECT_ACCENT_COLOR,
depthTest: false, depthTest: false,
depthWrite: false, depthWrite: false,
transparent: true, transparent: true,
opacity: 0.6, opacity: 0.85,
}) })
const segments = new LineSegments(geo, mat) const segments = new LineSegments(geo, mat)
@@ -318,8 +351,18 @@ function createOutlineSegments(): LineSegments {
// ── Drag threshold (pixels) ───────────────────────────────────────────────── // ── Drag threshold (pixels) ─────────────────────────────────────────────────
const BOX_SELECT_ACCENT_COLOR = '#818cf8'
const DRAG_THRESHOLD_PX = 4 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 ─────────────────────────────────────────────────────────────── // ── Component ───────────────────────────────────────────────────────────────
export const BoxSelectTool: React.FC = () => { export const BoxSelectTool: React.FC = () => {
@@ -344,6 +387,7 @@ const BOX_SELECT_TOOLTIP = (
const BoxSelectToolInner: React.FC = () => { const BoxSelectToolInner: React.FC = () => {
const { camera, gl } = useThree() const { camera, gl } = useThree()
const setPreviewSelectedIds = useViewer((state) => state.setPreviewSelectedIds)
const cursorRef = useRef<Group>(null) const cursorRef = useRef<Group>(null)
const rectFillRef = useRef<Mesh>(null!) const rectFillRef = useRef<Mesh>(null!)
const outlineRef = useRef(createOutlineSegments()) const outlineRef = useRef(createOutlineSegments())
@@ -354,7 +398,8 @@ const BoxSelectToolInner: React.FC = () => {
const startClientX = useRef(0) const startClientX = useRef(0)
const startClientY = useRef(0) const startClientY = useRef(0)
const gridY = useRef(0) const gridY = useRef(0)
const prevHitCount = useRef(0) const previousGridPosition = useRef<[number, number] | null>(null)
const previewSelectedIdsRef = useRef<string[]>([])
// Raycasting helpers (same technique as useGridEvents) // Raycasting helpers (same technique as useGridEvents)
const raycasterRef = useRef(new Raycaster()) const raycasterRef = useRef(new Raycaster())
@@ -366,10 +411,21 @@ const BoxSelectToolInner: React.FC = () => {
useEffect(() => { useEffect(() => {
const outline = outlineRef.current const outline = outlineRef.current
return () => { return () => {
previewSelectedIdsRef.current = []
setPreviewSelectedIds([])
outline.geometry.dispose() outline.geometry.dispose()
;(outline.material as LineBasicMaterial).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 // Sync ground plane Y with the current level
useEffect(() => { useEffect(() => {
@@ -409,14 +465,15 @@ const BoxSelectToolInner: React.FC = () => {
const point = raycastToGround(e) const point = raycastToGround(e)
if (!point) return if (!point) return
startPoint.current.copy(point) setSnappedPoint(startPoint.current, point.x, point.y, point.z)
currentPoint.current.copy(point) setSnappedPoint(currentPoint.current, point.x, point.y, point.z)
gridY.current = point.y gridY.current = point.y
pointerDown.current = true pointerDown.current = true
isDragging.current = false isDragging.current = false
prevHitCount.current = 0 previousGridPosition.current = getSnappedGridPosition(point.x, point.z)
startClientX.current = e.clientX startClientX.current = e.clientX
startClientY.current = e.clientY startClientY.current = e.clientY
syncPreviewSelectedIds([])
} }
const onCanvasPointerUp = (e: PointerEvent) => { const onCanvasPointerUp = (e: PointerEvent) => {
@@ -425,7 +482,7 @@ const BoxSelectToolInner: React.FC = () => {
if (isDragging.current) { if (isDragging.current) {
const point = raycastToGround(e) const point = raycastToGround(e)
if (point) currentPoint.current.copy(point) if (point) setSnappedPoint(currentPoint.current, point.x, point.y, point.z)
const bounds: Bounds = { const bounds: Bounds = {
minX: Math.min(startPoint.current.x, currentPoint.current.x), minX: Math.min(startPoint.current.x, currentPoint.current.x),
@@ -465,6 +522,7 @@ const BoxSelectToolInner: React.FC = () => {
// Hide visuals // Hide visuals
if (rectFillRef.current) rectFillRef.current.visible = false if (rectFillRef.current) rectFillRef.current.visible = false
if (outlineRef.current) outlineRef.current.visible = false if (outlineRef.current) outlineRef.current.visible = false
syncPreviewSelectedIds([])
// Reset // Reset
pointerDown.current = false pointerDown.current = false
@@ -483,14 +541,16 @@ const BoxSelectToolInner: React.FC = () => {
// grid:move for cursor tracking + rectangle update during drag // grid:move for cursor tracking + rectangle update during drag
useEffect(() => { useEffect(() => {
const onMove = (event: GridEvent) => { const onMove = (event: GridEvent) => {
const [snappedX, snappedZ] = getSnappedGridPosition(event.position[0], event.position[2])
// Always update cursor position // Always update cursor position
if (cursorRef.current) { 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 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) // Check drag threshold (screen pixels)
const nativeEvent = event.nativeEvent as unknown as PointerEvent const nativeEvent = event.nativeEvent as unknown as PointerEvent
@@ -509,18 +569,23 @@ const BoxSelectToolInner: React.FC = () => {
gridY.current, 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 = { const bounds: Bounds = {
minX: Math.min(startPoint.current.x, currentPoint.current.x), minX: Math.min(startPoint.current.x, currentPoint.current.x),
maxX: Math.max(startPoint.current.x, currentPoint.current.x), maxX: Math.max(startPoint.current.x, currentPoint.current.x),
minZ: Math.min(startPoint.current.z, currentPoint.current.z), minZ: Math.min(startPoint.current.z, currentPoint.current.z),
maxZ: Math.max(startPoint.current.z, currentPoint.current.z), maxZ: Math.max(startPoint.current.z, currentPoint.current.z),
} }
const hitCount = collectNodeIdsInBounds(bounds).length syncPreviewSelectedIds(collectNodeIdsInBounds(bounds))
if (hitCount !== prevHitCount.current) {
sfxEmitter.emit('sfx:grid-snap')
prevHitCount.current = hitCount
}
} }
} }
@@ -545,10 +610,10 @@ const BoxSelectToolInner: React.FC = () => {
> >
<planeGeometry args={[1, 1]} /> <planeGeometry args={[1, 1]} />
<meshBasicMaterial <meshBasicMaterial
color="#818cf8" color={BOX_SELECT_ACCENT_COLOR}
depthTest={false} depthTest={false}
depthWrite={false} depthWrite={false}
opacity={0.12} opacity={0.14}
side={DoubleSide} side={DoubleSide}
transparent transparent
/> />
@@ -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
@@ -12,45 +12,48 @@ import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere' 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 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. * Generates the step-profile geometry for the ghost preview.
* Same algorithm as StairSystem's generateStairSegmentGeometry. * Same algorithm as StairSystem's generateStairSegmentGeometry.
*/ */
function createStairPreviewGeometry(): THREE.BufferGeometry { function createStairPreviewGeometry(): THREE.BufferGeometry {
const riserHeight = DEFAULT_HEIGHT / DEFAULT_STEP_COUNT const riserHeight = DEFAULT_STAIR_HEIGHT / DEFAULT_STAIR_STEP_COUNT
const treadDepth = DEFAULT_LENGTH / DEFAULT_STEP_COUNT const treadDepth = DEFAULT_STAIR_LENGTH / DEFAULT_STAIR_STEP_COUNT
const shape = new THREE.Shape() const shape = new THREE.Shape()
shape.moveTo(0, 0) 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 * treadDepth, (i + 1) * riserHeight)
shape.lineTo((i + 1) * treadDepth, (i + 1) * riserHeight) shape.lineTo((i + 1) * treadDepth, (i + 1) * riserHeight)
} }
// Fill to floor (absoluteHeight = 0) // Fill to floor (absoluteHeight = 0)
shape.lineTo(DEFAULT_LENGTH, 0) shape.lineTo(DEFAULT_STAIR_LENGTH, 0)
shape.lineTo(0, 0) shape.lineTo(0, 0)
const geometry = new THREE.ExtrudeGeometry(shape, { const geometry = new THREE.ExtrudeGeometry(shape, {
steps: 1, steps: 1,
depth: DEFAULT_WIDTH, depth: DEFAULT_STAIR_WIDTH,
bevelEnabled: false, bevelEnabled: false,
}) })
// Rotate so extrusion is along X (width), shape profile in XZ plane // Rotate so extrusion is along X (width), shape profile in XZ plane
const matrix = new THREE.Matrix4() const matrix = new THREE.Matrix4()
matrix.makeRotationY(-Math.PI / 2) matrix.makeRotationY(-Math.PI / 2)
matrix.setPosition(DEFAULT_WIDTH / 2, 0, 0) matrix.setPosition(DEFAULT_STAIR_WIDTH / 2, 0, 0)
geometry.applyMatrix4(matrix) geometry.applyMatrix4(matrix)
return geometry return geometry
@@ -71,12 +74,13 @@ function commitStairPlacement(
const segment = StairSegmentNode.parse({ const segment = StairSegmentNode.parse({
segmentType: 'stair', segmentType: 'stair',
width: DEFAULT_WIDTH, width: DEFAULT_STAIR_WIDTH,
length: DEFAULT_LENGTH, length: DEFAULT_STAIR_LENGTH,
height: DEFAULT_HEIGHT, height: DEFAULT_STAIR_HEIGHT,
stepCount: DEFAULT_STEP_COUNT, stepCount: DEFAULT_STAIR_STEP_COUNT,
attachmentSide: 'front', attachmentSide: DEFAULT_STAIR_ATTACHMENT_SIDE,
fillToFloor: true, fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR,
thickness: DEFAULT_STAIR_THICKNESS,
position: [0, 0, 0], position: [0, 0, 0],
}) })
+25 -1
View File
@@ -1,32 +1,40 @@
import { useScene, type WallNode, WallNode as WallSchema } from '@pascal-app/core' import { useScene, type WallNode, WallNode as WallSchema } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
export type WallPlanPoint = [number, number] export type WallPlanPoint = [number, number]
export const WALL_GRID_STEP = 0.5 export const WALL_GRID_STEP = 0.5
export const WALL_JOIN_SNAP_RADIUS = 0.35 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 { function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
const dx = a[0] - b[0] const dx = a[0] - b[0]
const dz = a[1] - b[1] const dz = a[1] - b[1]
return dx * dx + dz * dz return dx * dx + dz * dz
} }
function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number { function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
return Math.round(value / step) * step return Math.round(value / step) * step
} }
export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): WallPlanPoint { export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): WallPlanPoint {
return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)] return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)]
} }
export function snapPointTo45Degrees(start: WallPlanPoint, cursor: WallPlanPoint): WallPlanPoint { export function snapPointTo45Degrees(start: WallPlanPoint, cursor: WallPlanPoint): WallPlanPoint {
const dx = cursor[0] - start[0] const dx = cursor[0] - start[0]
const dz = cursor[1] - start[1] const dz = cursor[1] - start[1]
const angle = Math.atan2(dz, dx) const angle = Math.atan2(dz, dx)
const snappedAngle = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4) const snappedAngle = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4)
const distance = Math.sqrt(dx * dx + dz * dz) const distance = Math.sqrt(dx * dx + dz * dz)
return snapPointToGrid([ return snapPointToGrid([
start[0] + Math.cos(snappedAngle) * distance, start[0] + Math.cos(snappedAngle) * distance,
start[1] + Math.sin(snappedAngle) * distance, start[1] + Math.sin(snappedAngle) * distance,
]) ])
} }
function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null { function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null {
const [x1, z1] = wall.start const [x1, z1] = wall.start
const [x2, z2] = wall.end const [x2, z2] = wall.end
@@ -36,12 +44,15 @@ function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoi
if (lengthSquared < 1e-9) { if (lengthSquared < 1e-9) {
return null return null
} }
const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared
if (t <= 0 || t >= 1) { if (t <= 0 || t >= 1) {
return null return null
} }
return [x1 + dx * t, z1 + dz * t] return [x1 + dx * t, z1 + dz * t]
} }
export function findWallSnapTarget( export function findWallSnapTarget(
point: WallPlanPoint, point: WallPlanPoint,
walls: WallNode[], walls: WallNode[],
@@ -51,10 +62,12 @@ export function findWallSnapTarget(
const radiusSquared = (options?.radius ?? WALL_JOIN_SNAP_RADIUS) ** 2 const radiusSquared = (options?.radius ?? WALL_JOIN_SNAP_RADIUS) ** 2
let bestTarget: WallPlanPoint | null = null let bestTarget: WallPlanPoint | null = null
let bestDistanceSquared = Number.POSITIVE_INFINITY let bestDistanceSquared = Number.POSITIVE_INFINITY
for (const wall of walls) { for (const wall of walls) {
if (ignoreWallIds.has(wall.id)) { if (ignoreWallIds.has(wall.id)) {
continue continue
} }
const candidates: Array<WallPlanPoint | null> = [ const candidates: Array<WallPlanPoint | null> = [
wall.start, wall.start,
wall.end, wall.end,
@@ -64,6 +77,7 @@ export function findWallSnapTarget(
if (!candidate) { if (!candidate) {
continue continue
} }
const candidateDistanceSquared = distanceSquared(point, candidate) const candidateDistanceSquared = distanceSquared(point, candidate)
if ( if (
candidateDistanceSquared > radiusSquared || candidateDistanceSquared > radiusSquared ||
@@ -71,12 +85,15 @@ export function findWallSnapTarget(
) { ) {
continue continue
} }
bestTarget = candidate bestTarget = candidate
bestDistanceSquared = candidateDistanceSquared bestDistanceSquared = candidateDistanceSquared
} }
} }
return bestTarget return bestTarget
} }
export function snapWallDraftPoint(args: { export function snapWallDraftPoint(args: {
point: WallPlanPoint point: WallPlanPoint
walls: WallNode[] walls: WallNode[]
@@ -86,31 +103,38 @@ export function snapWallDraftPoint(args: {
}): WallPlanPoint { }): WallPlanPoint {
const { point, walls, start, angleSnap = false, ignoreWallIds } = args const { point, walls, start, angleSnap = false, ignoreWallIds } = args
const basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point) const basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point)
return ( return (
findWallSnapTarget(basePoint, walls, { findWallSnapTarget(basePoint, walls, {
ignoreWallIds, ignoreWallIds,
}) ?? basePoint }) ?? basePoint
) )
} }
export function isWallLongEnough(start: WallPlanPoint, end: WallPlanPoint): boolean { export function isWallLongEnough(start: WallPlanPoint, end: WallPlanPoint): boolean {
return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH
} }
export function createWallOnCurrentLevel( export function createWallOnCurrentLevel(
start: WallPlanPoint, start: WallPlanPoint,
end: WallPlanPoint, end: WallPlanPoint,
): WallNode | null { ): WallNode | null {
const currentLevelId = useViewer.getState().selection.levelId const currentLevelId = useViewer.getState().selection.levelId
const { createNode, nodes } = useScene.getState() const { createNode, nodes } = useScene.getState()
if (!(currentLevelId && isWallLongEnough(start, end))) { if (!(currentLevelId && isWallLongEnough(start, end))) {
return null return null
} }
const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length
const wall = WallSchema.parse({ const wall = WallSchema.parse({
name: `Wall ${wallCount + 1}`, name: `Wall ${wallCount + 1}`,
start, start,
end, end,
}) })
createNode(wall, currentLevelId) createNode(wall, currentLevelId)
sfxEmitter.emit('sfx:structure-build') sfxEmitter.emit('sfx:structure-build')
return wall return wall
} }
+3 -8
View File
@@ -6,12 +6,7 @@ import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { EDITOR_LAYER } from '../../../lib/constants' import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { import { createWallOnCurrentLevel, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
createWallOnCurrentLevel,
snapWallDraftPoint,
WALL_MIN_LENGTH,
type WallPlanPoint,
} from './wall-drafting'
const WALL_HEIGHT = 2.5 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 direction = new Vector3(end.x - start.x, 0, end.z - start.z)
const length = direction.length() const length = direction.length()
if (length < WALL_MIN_LENGTH) { if (length < 0.01) {
mesh.visible = false mesh.visible = false
return return
} }
@@ -148,7 +143,7 @@ export const WallTool: React.FC = () => {
endingPoint.current.set(snappedEnd[0], event.position[1], snappedEnd[1]) endingPoint.current.set(snappedEnd[0], event.position[1], snappedEnd[1])
const dx = endingPoint.current.x - startingPoint.current.x const dx = endingPoint.current.x - startingPoint.current.x
const dz = endingPoint.current.z - startingPoint.current.z 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( createWallOnCurrentLevel(
[startingPoint.current.x, startingPoint.current.z], [startingPoint.current.x, startingPoint.current.z],
[endingPoint.current.x, endingPoint.current.z], [endingPoint.current.x, endingPoint.current.z],
+2 -3
View File
@@ -2,7 +2,6 @@ import { emitter, type GridEvent, type LevelNode, useScene, ZoneNode } from '@pa
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three' import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
import { PALETTE_COLORS } from './../../../components/ui/primitives/color-dot'
import { EDITOR_LAYER } from './../../../lib/constants' import { EDITOR_LAYER } from './../../../lib/constants'
import useEditor from './../../../store/use-editor' import useEditor from './../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere' 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 zoneCount = Object.values(nodes).filter((n) => n.type === 'zone').length
const name = `Zone ${zoneCount + 1}` const name = `Zone ${zoneCount + 1}`
// Cycle through colors // Default to blue, cycle through palette for subsequent zones
const color = PALETTE_COLORS[zoneCount % PALETTE_COLORS.length] const color = '#3b82f6'
const zone = ZoneNode.parse({ const zone = ZoneNode.parse({
name, name,
-23
View File
@@ -1,9 +1,7 @@
'use client' 'use client'
import { Icon } from '@iconify/react'
import { emitter } from '@pascal-app/core' import { emitter } from '@pascal-app/core'
import Image from 'next/image' import Image from 'next/image'
import useEditor from '../../../store/use-editor'
import { ActionButton } from './action-button' import { ActionButton } from './action-button'
export function CameraActions() { export function CameraActions() {
@@ -19,10 +17,6 @@ export function CameraActions() {
emitter.emit('camera-controls:orbit-ccw') emitter.emit('camera-controls:orbit-ccw')
} }
const enterStreetView = () => {
useEditor.getState().setFirstPersonMode(true)
}
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{/* Orbit CCW */} {/* Orbit CCW */}
@@ -75,23 +69,6 @@ export function CameraActions() {
width={28} width={28}
/> />
</ActionButton> </ActionButton>
{/* Street View */}
<ActionButton
className="group hover:bg-white/5"
label="Street View"
onClick={enterStreetView}
size="icon"
variant="ghost"
>
<Icon
className="opacity-70 transition-opacity group-hover:opacity-100"
color="currentColor"
height={22}
icon="mdi:walk"
width={22}
/>
</ActionButton>
</div> </div>
) )
} }
+48 -1
View File
@@ -9,7 +9,7 @@ import { cn } from './../../../lib/utils'
import useEditor from './../../../store/use-editor' import useEditor from './../../../store/use-editor'
import { ActionButton } from './action-button' 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 = { type ControlConfig = {
id: ControlId id: ControlId
@@ -54,6 +54,22 @@ const controls: ControlConfig[] = [
color: 'hover:bg-green-500/20 hover:text-green-400', color: 'hover:bg-green-500/20 hover:text-green-400',
activeColor: 'bg-green-500/20 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', id: 'delete',
icon: Trash2, icon: Trash2,
@@ -82,11 +98,18 @@ export function ControlModes() {
const isGroundFloor = levelNode?.type === 'level' && levelNode.level === 0 const isGroundFloor = levelNode?.type === 'level' && levelNode.level === 0
const canEnterSiteEdit = isGroundFloor || isSiteEditing const canEnterSiteEdit = isGroundFloor || isSiteEditing
const structureLayer = useEditor((state) => state.structureLayer)
const getIsActive = (id: ControlId): boolean => { const getIsActive = (id: ControlId): boolean => {
if (isSiteEditing) return id === 'site-edit' if (isSiteEditing) return id === 'site-edit'
if (id === 'select') return mode === 'select' && selectionTool === 'click' if (id === 'select') return mode === 'select' && selectionTool === 'click'
if (id === 'box-select') return mode === 'select' && selectionTool === 'marquee' if (id === 'box-select') return mode === 'select' && selectionTool === 'marquee'
if (id === 'site-edit') return false 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 return mode === id
} }
@@ -118,6 +141,30 @@ export function ControlModes() {
} else if (id === 'box-select') { } else if (id === 'box-select') {
setMode('select') setMode('select')
setSelectionTool('marquee') 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 { } else {
setMode(id) setMode(id)
} }
+110 -10
View File
@@ -8,14 +8,18 @@ import {
useScene, useScene,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { ChevronDown } from 'lucide-react' import { ChevronDown, Plus, Trash2 } from 'lucide-react'
import { useCallback, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { cn } from '../../../lib/utils' import { cn } from '../../../lib/utils'
import { useUploadStore } from '../../../store/use-upload'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover' import { Popover, PopoverContent, PopoverTrigger } from '../primitives/popover'
import { ActionButton } from './action-button' 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 ────────────────────────── // ── Helper: get guide images for the current level ──────────────────────────
function useLevelGuides(): GuideNode[] { function useLevelGuides(): GuideNode[] {
@@ -48,12 +52,67 @@ function useLevelScans(): ScanNode[] {
) )
} }
// ── Shared upload button for dropdowns ──────────────────────────────────────
function UploadButton() {
const fileInputRef = useRef<HTMLInputElement>(null)
const levelId = useViewer((s) => s.selection.levelId)
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<>
<button
aria-label="Upload scan or guide image"
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border/40 text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground"
onClick={() => fileInputRef.current?.click()}
type="button"
>
<Plus className="h-3 w-3" />
</button>
<input
accept={ACCEPTED_FILE_TYPES}
className="hidden"
onChange={handleFileChange}
ref={fileInputRef}
type="file"
/>
</>
)
}
// ── Guides toggle + dropdown ──────────────────────────────────────────────── // ── Guides toggle + dropdown ────────────────────────────────────────────────
function GuidesControl() { function GuidesControl() {
const showGuides = useViewer((state) => state.showGuides) const showGuides = useViewer((state) => state.showGuides)
const setShowGuides = useViewer((state) => state.setShowGuides) const setShowGuides = useViewer((state) => state.setShowGuides)
const updateNode = useScene((state) => state.updateNode) const updateNode = useScene((state) => state.updateNode)
const deleteNode = useScene((state) => state.deleteNode)
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const guides = useLevelGuides() const guides = useLevelGuides()
@@ -74,7 +133,7 @@ function GuidesControl() {
className={cn( className={cn(
'rounded-r-none p-0', 'rounded-r-none p-0',
showGuides showGuides
? 'bg-white/10' ? 'bg-white/15'
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0', : 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
)} )}
label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`} label={`Guides: ${showGuides ? 'Visible' : 'Hidden'}`}
@@ -82,11 +141,16 @@ function GuidesControl() {
size="icon" size="icon"
variant="ghost" variant="ghost"
> >
<div className="relative">
<img <img
alt="Guides" alt="Guides"
className="h-[28px] w-[28px] object-contain" className="h-[28px] w-[28px] object-contain"
src="/icons/floorplan.png" src="/icons/floorplan.png"
/> />
<span className="absolute -right-1.5 -bottom-1 min-w-[14px] rounded-full bg-white/20 px-[3px] text-center font-medium text-[9px] text-white/70 leading-[14px]">
{guides.length}
</span>
</div>
</ActionButton> </ActionButton>
{/* Dropdown chevron */} {/* Dropdown chevron */}
@@ -96,7 +160,13 @@ function GuidesControl() {
aria-label="Guide image settings" aria-label="Guide image settings"
className={cn( className={cn(
'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors', '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" type="button"
> >
@@ -116,7 +186,7 @@ function GuidesControl() {
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80"> <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
<img alt="" className="h-4 w-4 object-contain" src="/icons/floorplan.png" /> <img alt="" className="h-4 w-4 object-contain" src="/icons/floorplan.png" />
</span> </span>
<div className="min-w-0"> <div className="min-w-0 flex-1">
<p className="font-medium text-foreground text-sm">Guide images</p> <p className="font-medium text-foreground text-sm">Guide images</p>
{hasGuides && ( {hasGuides && (
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
@@ -124,13 +194,14 @@ function GuidesControl() {
</p> </p>
)} )}
</div> </div>
<UploadButton />
</div> </div>
{hasGuides ? ( {hasGuides ? (
<div className="max-h-56 space-y-2 overflow-y-auto pr-1"> <div className="max-h-56 space-y-2 overflow-y-auto pr-1">
{guides.map((guide, index) => ( {guides.map((guide, index) => (
<div <div
className="space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5" className="group/item space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5"
key={guide.id} key={guide.id}
> >
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
@@ -142,6 +213,14 @@ function GuidesControl() {
<p className="truncate font-medium text-foreground text-sm"> <p className="truncate font-medium text-foreground text-sm">
{guide.name || `Guide image ${index + 1}`} {guide.name || `Guide image ${index + 1}`}
</p> </p>
<button
aria-label="Delete guide image"
className="ml-auto flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover/item:opacity-100"
onClick={() => deleteNode(guide.id)}
type="button"
>
<Trash2 className="h-3 w-3" />
</button>
</div> </div>
<SliderControl <SliderControl
label="Opacity" label="Opacity"
@@ -173,6 +252,7 @@ function ScansControl() {
const showScans = useViewer((state) => state.showScans) const showScans = useViewer((state) => state.showScans)
const setShowScans = useViewer((state) => state.setShowScans) const setShowScans = useViewer((state) => state.setShowScans)
const updateNode = useScene((state) => state.updateNode) const updateNode = useScene((state) => state.updateNode)
const deleteNode = useScene((state) => state.deleteNode)
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
const scans = useLevelScans() const scans = useLevelScans()
@@ -193,7 +273,7 @@ function ScansControl() {
className={cn( className={cn(
'rounded-r-none p-0', 'rounded-r-none p-0',
showScans showScans
? 'bg-white/10' ? 'bg-white/15'
: 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0', : 'opacity-60 grayscale hover:bg-white/5 hover:opacity-100 hover:grayscale-0',
)} )}
label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`} label={`Scans: ${showScans ? 'Visible' : 'Hidden'}`}
@@ -201,7 +281,12 @@ function ScansControl() {
size="icon" size="icon"
variant="ghost" variant="ghost"
> >
<div className="relative">
<img alt="Scans" className="h-[28px] w-[28px] object-contain" src="/icons/mesh.png" /> <img alt="Scans" className="h-[28px] w-[28px] object-contain" src="/icons/mesh.png" />
<span className="absolute -right-1.5 -bottom-1 min-w-[14px] rounded-full bg-white/20 px-[3px] text-center font-medium text-[9px] text-white/70 leading-[14px]">
{scans.length}
</span>
</div>
</ActionButton> </ActionButton>
{/* Dropdown chevron */} {/* Dropdown chevron */}
@@ -211,7 +296,13 @@ function ScansControl() {
aria-label="Scan settings" aria-label="Scan settings"
className={cn( className={cn(
'flex h-11 w-6 items-center justify-center rounded-r-lg transition-colors', '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" type="button"
> >
@@ -231,7 +322,7 @@ function ScansControl() {
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80"> <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-background/80">
<img alt="" className="h-4 w-4 object-contain" src="/icons/mesh.png" /> <img alt="" className="h-4 w-4 object-contain" src="/icons/mesh.png" />
</span> </span>
<div className="min-w-0"> <div className="min-w-0 flex-1">
<p className="font-medium text-foreground text-sm">Scans</p> <p className="font-medium text-foreground text-sm">Scans</p>
{hasScans && ( {hasScans && (
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
@@ -239,13 +330,14 @@ function ScansControl() {
</p> </p>
)} )}
</div> </div>
<UploadButton />
</div> </div>
{hasScans ? ( {hasScans ? (
<div className="max-h-56 space-y-2 overflow-y-auto pr-1"> <div className="max-h-56 space-y-2 overflow-y-auto pr-1">
{scans.map((scan, index) => ( {scans.map((scan, index) => (
<div <div
className="space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5" className="group/item space-y-2 rounded-xl border border-border/45 bg-background/75 p-2.5"
key={scan.id} key={scan.id}
> >
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
@@ -257,6 +349,14 @@ function ScansControl() {
<p className="truncate font-medium text-foreground text-sm"> <p className="truncate font-medium text-foreground text-sm">
{scan.name || `Scan ${index + 1}`} {scan.name || `Scan ${index + 1}`}
</p> </p>
<button
aria-label="Delete scan"
className="ml-auto flex h-5 w-5 shrink-0 items-center justify-center rounded-md text-muted-foreground/50 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover/item:opacity-100"
onClick={() => deleteNode(scan.id)}
type="button"
>
<Trash2 className="h-3 w-3" />
</button>
</div> </div>
<SliderControl <SliderControl
label="Opacity" label="Opacity"
+37 -2
View File
@@ -3,8 +3,9 @@
import type { AnyNodeId, LevelNode } from '@pascal-app/core' import type { AnyNodeId, LevelNode } from '@pascal-app/core'
import { useScene } from '@pascal-app/core' import { useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { Command } from 'cmdk' import { Command, useCommandState } from 'cmdk'
import { ChevronRight, Search } from 'lucide-react' import { ChevronRight, Search } from 'lucide-react'
import type { ReactNode } from 'react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { create } from 'zustand' import { create } from 'zustand'
import { useShallow } from 'zustand/shallow' import { useShallow } from 'zustand/shallow'
@@ -160,10 +161,41 @@ const PAGE_LABEL: Record<string, string> = {
'camera-scope': '', '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 (
<Command.Item
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-foreground text-sm transition-colors data-[selected=true]:bg-accent"
forceMount
onSelect={() => action.onSelect(search)}
value="__empty_action__"
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
{action.icon}
</span>
<span className="flex-1 truncate">{action.label(search)}</span>
</Command.Item>
)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Main component // Main component
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function CommandPalette() { export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEmptyAction }) {
const { const {
open, open,
setOpen, setOpen,
@@ -353,9 +385,12 @@ export function CommandPalette() {
</div> </div>
<Command.List className="max-h-100 overflow-y-auto p-1.5"> <Command.List className="max-h-100 overflow-y-auto p-1.5">
{(!emptyAction || page) && (
<Command.Empty className="py-8 text-center text-muted-foreground text-sm"> <Command.Empty className="py-8 text-center text-muted-foreground text-sm">
No commands found. No commands found.
</Command.Empty> </Command.Empty>
)}
{emptyAction && !page && <EmptyActionItem action={emptyAction} />}
{/* ── Registered page view (e.g. 'ai') ─────────────────────── */} {/* ── Registered page view (e.g. 'ai') ─────────────────────── */}
{page && {page &&
+37 -21
View File
@@ -35,7 +35,9 @@ type MaterialPickerProps = {
} }
export function MaterialPicker({ value, onChange }: MaterialPickerProps) { export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
const [showCustom, setShowCustom] = useState<boolean>(value?.preset === 'custom' || !!value?.properties) const [showCustom, setShowCustom] = useState<boolean>(
value?.preset === 'custom' || !!value?.properties,
)
const currentPreset = value?.preset || 'white' const currentPreset = value?.preset || 'white'
const currentProps = value?.properties || DEFAULT_MATERIALS[currentPreset] 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({ onChange({
preset: showCustom ? 'custom' : currentPreset, preset: showCustom ? 'custom' : currentPreset,
properties: { properties: {
@@ -84,7 +89,10 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
onClick={() => handlePresetChange(preset)} onClick={() => handlePresetChange(preset)}
style={{ style={{
backgroundColor: PRESET_COLORS[preset], 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, backgroundSize: preset === 'glass' ? '8px 8px' : undefined,
}} }}
title={PRESET_LABELS[preset]} title={PRESET_LABELS[preset]}
@@ -96,15 +104,15 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
{showCustom && ( {showCustom && (
<div className="space-y-2 pt-2"> <div className="space-y-2 pt-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs text-gray-500 w-16">Color</label> <label className="w-16 text-gray-500 text-xs">Color</label>
<input <input
className="h-7 w-12 rounded border border-gray-300 cursor-pointer" className="h-7 w-12 cursor-pointer rounded border border-gray-300"
onChange={(e) => handlePropertyChange('color', e.target.value)} onChange={(e) => handlePropertyChange('color', e.target.value)}
type="color" type="color"
value={currentProps.color} value={currentProps.color}
/> />
<input <input
className="flex-1 h-7 px-2 text-xs border border-gray-300 rounded" className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
onChange={(e) => handlePropertyChange('color', e.target.value)} onChange={(e) => handlePropertyChange('color', e.target.value)}
type="text" type="text"
value={currentProps.color} value={currentProps.color}
@@ -112,41 +120,45 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs text-gray-500 w-16">Roughness</label> <label className="w-16 text-gray-500 text-xs">Roughness</label>
<input <input
className="flex-1 h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer" className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
max={1} max={1}
min={0} min={0}
onChange={(e) => handlePropertyChange('roughness', parseFloat(e.target.value))} onChange={(e) => handlePropertyChange('roughness', Number.parseFloat(e.target.value))}
step={0.01} step={0.01}
type="range" type="range"
value={currentProps.roughness} value={currentProps.roughness}
/> />
<span className="text-xs text-gray-400 w-8 text-right">{currentProps.roughness.toFixed(2)}</span> <span className="w-8 text-right text-gray-400 text-xs">
{currentProps.roughness.toFixed(2)}
</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs text-gray-500 w-16">Metalness</label> <label className="w-16 text-gray-500 text-xs">Metalness</label>
<input <input
className="flex-1 h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer" className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
max={1} max={1}
min={0} min={0}
onChange={(e) => handlePropertyChange('metalness', parseFloat(e.target.value))} onChange={(e) => handlePropertyChange('metalness', Number.parseFloat(e.target.value))}
step={0.01} step={0.01}
type="range" type="range"
value={currentProps.metalness} value={currentProps.metalness}
/> />
<span className="text-xs text-gray-400 w-8 text-right">{currentProps.metalness.toFixed(2)}</span> <span className="w-8 text-right text-gray-400 text-xs">
{currentProps.metalness.toFixed(2)}
</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs text-gray-500 w-16">Opacity</label> <label className="w-16 text-gray-500 text-xs">Opacity</label>
<input <input
className="flex-1 h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer" className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
max={1} max={1}
min={0} min={0}
onChange={(e) => { onChange={(e) => {
const opacity = parseFloat(e.target.value) const opacity = Number.parseFloat(e.target.value)
handlePropertyChange('opacity', opacity) handlePropertyChange('opacity', opacity)
if (opacity < 1 && !currentProps.transparent) { if (opacity < 1 && !currentProps.transparent) {
handlePropertyChange('transparent', true) handlePropertyChange('transparent', true)
@@ -156,14 +168,18 @@ export function MaterialPicker({ value, onChange }: MaterialPickerProps) {
type="range" type="range"
value={currentProps.opacity} value={currentProps.opacity}
/> />
<span className="text-xs text-gray-400 w-8 text-right">{currentProps.opacity.toFixed(2)}</span> <span className="w-8 text-right text-gray-400 text-xs">
{currentProps.opacity.toFixed(2)}
</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs text-gray-500 w-16">Side</label> <label className="w-16 text-gray-500 text-xs">Side</label>
<select <select
className="flex-1 h-7 px-2 text-xs border border-gray-300 rounded" className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
onChange={(e) => handlePropertyChange('side', e.target.value as 'front' | 'back' | 'double')} onChange={(e) =>
handlePropertyChange('side', e.target.value as 'front' | 'back' | 'double')
}
value={currentProps.side} value={currentProps.side}
> >
<option value="front">Front</option> <option value="front">Front</option>
@@ -16,6 +16,11 @@ interface SliderControlProps {
unit?: string unit?: string
} }
function stepPrecision(s: number): number {
if (s <= 0) return 0
return Math.max(0, Math.ceil(-Math.log10(s)))
}
export function SliderControl({ export function SliderControl({
label, label,
value, value,
@@ -32,7 +37,7 @@ export function SliderControl({
const [isHovered, setIsHovered] = useState(false) const [isHovered, setIsHovered] = useState(false)
const [inputValue, setInputValue] = useState(value.toFixed(precision)) const [inputValue, setInputValue] = useState(value.toFixed(precision))
const dragRef = useRef<{ accumulatedDx: number; startValue: number } | null>(null) const dragRef = useRef<{ startX: number; startValue: number } | null>(null)
const labelRef = useRef<HTMLDivElement>(null) const labelRef = useRef<HTMLDivElement>(null)
const valueRef = useRef(value) const valueRef = useRef(value)
valueRef.current = value valueRef.current = value
@@ -57,7 +62,7 @@ export function SliderControl({
if (e.shiftKey) s = step * 10 if (e.shiftKey) s = step * 10
else if (e.altKey) s = step * 0.1 else if (e.altKey) s = step * 0.1
const newValue = clamp(valueRef.current + direction * s) const newValue = clamp(valueRef.current + direction * s)
const final = Number.parseFloat(newValue.toFixed(precision)) const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
if (final !== valueRef.current) onChange(final) if (final !== valueRef.current) onChange(final)
} }
el.addEventListener('wheel', handleWheel, { passive: false }) el.addEventListener('wheel', handleWheel, { passive: false })
@@ -77,7 +82,7 @@ export function SliderControl({
if (e.shiftKey) s = step * 10 if (e.shiftKey) s = step * 10
else if (e.metaKey || e.ctrlKey) s = step * 0.1 else if (e.metaKey || e.ctrlKey) s = step * 0.1
const newValue = clamp(valueRef.current + direction * s) const newValue = clamp(valueRef.current + direction * s)
const final = Number.parseFloat(newValue.toFixed(precision)) const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
if (final !== valueRef.current) onChange(final) if (final !== valueRef.current) onChange(final)
} }
} }
@@ -89,15 +94,8 @@ export function SliderControl({
(e: React.PointerEvent<HTMLDivElement>) => { (e: React.PointerEvent<HTMLDivElement>) => {
if (isEditing) return if (isEditing) return
e.preventDefault() e.preventDefault()
// Use PointerLock for infinite dragging (Unity3D-style). e.currentTarget.setPointerCapture(e.pointerId)
// Falls back to pointer capture if lock is denied. dragRef.current = { startX: e.clientX, startValue: valueRef.current }
const el = e.currentTarget
if (el.requestPointerLock) {
el.requestPointerLock()
} else {
el.setPointerCapture(e.pointerId)
}
dragRef.current = { accumulatedDx: 0, startValue: valueRef.current }
setIsDragging(true) setIsDragging(true)
useScene.temporal.getState().pause() useScene.temporal.getState().pause()
}, },
@@ -107,15 +105,15 @@ export function SliderControl({
const handleLabelPointerMove = useCallback( const handleLabelPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => { (e: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return if (!dragRef.current) return
// Accumulate movementX for infinite dragging. movementX gives the const { startX, startValue } = dragRef.current
// delta since the last event, independent of screen bounds. const dx = e.clientX - startX
dragRef.current.accumulatedDx += e.movementX
const { accumulatedDx, startValue } = dragRef.current
let s = step let s = step
if (e.shiftKey) s = step * 10 if (e.shiftKey) s = step * 10
else if (e.metaKey || e.ctrlKey) s = step * 0.1 else if (e.metaKey || e.ctrlKey) s = step * 0.1
// 4 px per step at default sensitivity // 4 px per step at default sensitivity
const newValue = clamp(Number.parseFloat((startValue + (accumulatedDx / 4) * s).toFixed(precision))) const newValue = clamp(
Number.parseFloat((startValue + (dx / 4) * s).toFixed(stepPrecision(s))),
)
onChange(newValue) onChange(newValue)
}, },
[step, precision, clamp, onChange], [step, precision, clamp, onChange],
@@ -128,12 +126,7 @@ export function SliderControl({
const finalVal = valueRef.current const finalVal = valueRef.current
dragRef.current = null dragRef.current = null
setIsDragging(false) setIsDragging(false)
if (document.pointerLockElement) {
document.exitPointerLock()
} else {
e.currentTarget.releasePointerCapture(e.pointerId) e.currentTarget.releasePointerCapture(e.pointerId)
}
if (startValue !== finalVal) { if (startValue !== finalVal) {
onChange(startValue) onChange(startValue)
@@ -146,28 +139,6 @@ export function SliderControl({
[onChange], [onChange],
) )
// Clean up drag state if pointer lock is lost unexpectedly (e.g. Escape key)
useEffect(() => {
const handlePointerLockChange = () => {
if (!document.pointerLockElement && dragRef.current) {
const { startValue } = dragRef.current
const finalVal = valueRef.current
dragRef.current = null
setIsDragging(false)
if (startValue !== finalVal) {
onChange(startValue)
useScene.temporal.getState().resume()
onChange(finalVal)
} else {
useScene.temporal.getState().resume()
}
}
}
document.addEventListener('pointerlockchange', handlePointerLockChange)
return () => document.removeEventListener('pointerlockchange', handlePointerLockChange)
}, [onChange])
const handleValueClick = useCallback(() => { const handleValueClick = useCallback(() => {
setIsEditing(true) setIsEditing(true)
setInputValue(value.toFixed(precision)) setInputValue(value.toFixed(precision))
@@ -262,7 +233,7 @@ export function SliderControl({
className="flex cursor-text items-center text-foreground/60 transition-colors hover:text-foreground" className="flex cursor-text items-center text-foreground/60 transition-colors hover:text-foreground"
onClick={handleValueClick} onClick={handleValueClick}
> >
<span className="font-mono tabular-nums tracking-tight"> <span className="font-mono tabular-nums tracking-tight" suppressHydrationWarning>
{Number(value.toFixed(precision)).toFixed(precision)} {Number(value.toFixed(precision)).toFixed(precision)}
</span> </span>
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>} {unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
+299 -20
View File
@@ -1,20 +1,178 @@
'use client' 'use client'
import { type BuildingNode, type LevelNode, useScene } from '@pascal-app/core' import {
type AnyNode,
type AnyNodeId,
type BuildingNode,
LevelNode,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { MoreVertical, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useShallow } from 'zustand/react/shallow' import { useShallow } from 'zustand/react/shallow'
import { deleteLevelWithFallbackSelection } from '../../lib/level-selection'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './primitives/dialog'
import { Popover, PopoverContent, PopoverTrigger } from './primitives/popover'
function getLevelDisplayLabel(level: LevelNode) { function getLevelDisplayLabel(level: LevelNode) {
return level.name || `Level ${level.level}` return level.name || `Level ${level.level}`
} }
// ── Inline rename input for a level row ─────────────────────────────────────
function LevelInlineRename({
level,
isEditing,
onStopEditing,
}: {
level: LevelNode
isEditing: boolean
onStopEditing: () => void
}) {
const updateNode = useScene((s) => s.updateNode)
const defaultName = `Level ${level.level}`
const [value, setValue] = useState(level.name || '')
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (isEditing) {
setValue(level.name || '')
setTimeout(() => {
inputRef.current?.focus()
inputRef.current?.select()
}, 0)
}
}, [isEditing, level.name])
const handleSave = useCallback(() => {
const trimmed = value.trim()
if (trimmed !== level.name) {
updateNode(level.id, { name: trimmed || undefined })
}
onStopEditing()
}, [value, level.id, level.name, updateNode, onStopEditing])
if (!isEditing) return null
return (
<input
className="m-0 h-full w-full min-w-0 rounded-lg bg-transparent px-2.5 py-1.5 font-medium text-foreground text-xs outline-none ring-1 ring-primary/50"
onBlur={handleSave}
onChange={(e) => 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 (
<div className="group/level">
{isEditing ? (
<LevelInlineRename
isEditing={isEditing}
level={level}
onStopEditing={() => setIsEditing(false)}
/>
) : (
<div
className={cn(
'flex items-center rounded-lg transition-colors',
isSelected
? 'bg-white/10 text-foreground'
: 'text-muted-foreground/70 hover:bg-white/5 hover:text-muted-foreground',
)}
>
<button
className="flex min-w-0 flex-1 items-center justify-start px-2.5 py-1.5 font-medium text-xs"
onClick={onSelect}
onDoubleClick={(e) => {
e.stopPropagation()
setIsEditing(true)
}}
title={getLevelDisplayLabel(level)}
type="button"
>
<span className="truncate">{getLevelDisplayLabel(level)}</span>
</button>
{/* Vertical three-dot menu — inside the pill */}
<Popover>
<PopoverTrigger asChild>
<button
className="flex h-5 w-4 shrink-0 items-center justify-center text-muted-foreground/40 opacity-0 transition-all hover:text-foreground group-hover/level:opacity-100"
onClick={(e) => e.stopPropagation()}
type="button"
>
<MoreVertical className="h-3 w-3" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-36 p-1" side="right" sideOffset={8}>
<button
className="flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-muted-foreground text-xs transition-colors hover:bg-white/10 hover:text-red-400"
onClick={(e) => {
e.stopPropagation()
onRequestDelete()
}}
type="button"
>
<Trash2 className="h-3 w-3" />
Delete level
</button>
</PopoverContent>
</Popover>
</div>
)}
</div>
)
}
// ── Main component ──────────────────────────────────────────────────────────
export function FloatingLevelSelector() { export function FloatingLevelSelector() {
const selectedBuildingId = useViewer((s) => s.selection.buildingId) const selectedBuildingId = useViewer((s) => s.selection.buildingId)
const levelId = useViewer((s) => s.selection.levelId) const levelId = useViewer((s) => s.selection.levelId)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const createNode = useScene((s) => s.createNode)
const updateNodes = useScene((s) => s.updateNodes)
const [deletingLevel, setDeletingLevel] = useState<LevelNode | null>(null)
// Resolve the effective building ID — selected or first in scene (scalar, stable reference)
const resolvedBuildingId = useScene((state) => { const resolvedBuildingId = useScene((state) => {
if (selectedBuildingId) return selectedBuildingId if (selectedBuildingId) return selectedBuildingId
const first = Object.values(state.nodes).find((n) => n?.type === 'building') as const first = Object.values(state.nodes).find((n) => n?.type === 'building') as
@@ -23,7 +181,6 @@ export function FloatingLevelSelector() {
return first?.id ?? null return first?.id ?? null
}) })
// Get levels for the resolved building (array, useShallow for stable reference)
const levels = useScene( const levels = useScene(
useShallow((state) => { useShallow((state) => {
if (!resolvedBuildingId) return [] as LevelNode[] 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<AnyNode>,
})),
)
}
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 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 ( return (
<>
<div className="pointer-events-auto absolute top-14 left-3 z-20"> <div className="pointer-events-auto absolute top-14 left-3 z-20">
{/* Outer: rounded-xl (12px) with p-1 (4px) → inner: rounded-lg (8px) for concentric radii */} <div className="relative">
<div className="flex flex-col gap-0.5 rounded-xl border border-border bg-background/90 p-1 shadow-2xl backdrop-blur-md"> {/* Floating + at top edge */}
{reversedLevels.map((level) => {
const isSelected = level.id === levelId
return (
<button <button
className={cn( className={cn(addButtonClass, 'top-0 -translate-y-1/2')}
'flex min-w-[80px] items-center justify-start rounded-lg px-2.5 py-1.5 font-medium text-xs transition-colors', onClick={handleAddAbove}
isSelected title="Add level above"
? 'bg-white/10 text-foreground' type="button"
: 'text-muted-foreground/70 hover:bg-white/5 hover:text-muted-foreground', >
)} <Plus className="h-2.5 w-2.5" />
key={level.id} </button>
onClick={() =>
{/* Floating + at bottom edge */}
<button
className={cn(addButtonClass, 'bottom-0 translate-y-1/2')}
onClick={handleAddBelow}
title="Add level below"
type="button"
>
<Plus className="h-2.5 w-2.5" />
</button>
{/* Level list */}
<div className="flex flex-col gap-0.5 rounded-xl border border-border bg-background/90 p-1 shadow-2xl backdrop-blur-md">
{reversedLevels.map((level, i) => {
const isSelected = level.id === levelId
const sortedIndex = levels.indexOf(level)
const showGapBelow = i < reversedLevels.length - 1
return (
<div className="relative" key={level.id}>
<LevelRow
isSelected={isSelected}
level={level}
onRequestDelete={() => setDeletingLevel(level)}
onSelect={() =>
setSelection( setSelection(
resolvedBuildingId resolvedBuildingId
? { buildingId: resolvedBuildingId, levelId: level.id } ? { buildingId: resolvedBuildingId, levelId: level.id }
: { levelId: level.id }, : { levelId: level.id },
) )
} }
title={getLevelDisplayLabel(level)} />
{showGapBelow && (
<button
className={cn(addButtonClass, 'bottom-0 translate-y-1/2')}
onClick={() => handleInsertBetween(sortedIndex - 1)}
title="Insert level here"
type="button" type="button"
> >
<span className="truncate">{getLevelDisplayLabel(level)}</span> <Plus className="h-2.5 w-2.5" />
</button> </button>
)}
</div>
) )
})} })}
</div> </div>
</div> </div>
</div>
{/* Delete confirmation dialog */}
<Dialog onOpenChange={(open) => !open && setDeletingLevel(null)} open={!!deletingLevel}>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>Delete level</DialogTitle>
<DialogDescription>
Are you sure you want to delete{' '}
<strong>{deletingLevel ? getLevelDisplayLabel(deletingLevel) : ''}</strong>? All
walls, floors, and objects on this level will be permanently removed.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<button
className="rounded-full border border-border px-4 py-2 text-sm transition-colors hover:bg-accent"
onClick={() => setDeletingLevel(null)}
type="button"
>
Cancel
</button>
<button
className="rounded-full bg-red-600 px-4 py-2 text-sm text-white transition-colors hover:bg-red-700"
onClick={handleConfirmDelete}
type="button"
>
Delete
</button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
) )
} }
+1 -1
View File
@@ -1,4 +1,4 @@
import { type AssetInput, ItemNode } from '@pascal-app/core' import type { AssetInput } from '@pascal-app/core'
export const CATALOG_ITEMS: AssetInput[] = [ export const CATALOG_ITEMS: AssetInput[] = [
{ {
id: 'tesla', id: 'tesla',
+10 -14
View File
@@ -32,6 +32,13 @@ export function CeilingPanel() {
[selectedId, updateNode], [selectedId, updateNode],
) )
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
},
[handleUpdate],
)
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
setEditingHole(null) setEditingHole(null)
@@ -95,10 +102,6 @@ export function CeilingPanel() {
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole], [selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
) )
const handleMaterialChange = useCallback((material: MaterialSchema) => {
handleUpdate({ material })
}, [handleUpdate])
if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null if (!node || node.type !== 'ceiling' || selectedIds.length !== 1) return null
const calculateArea = (polygon: Array<[number, number]>): number => { const calculateArea = (polygon: Array<[number, number]>): number => {
@@ -107,12 +110,8 @@ export function CeilingPanel() {
const n = polygon.length const n = polygon.length
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const j = (i + 1) % n const j = (i + 1) % n
const pi = polygon[i] area += polygon[i]?.[0] * polygon[j]?.[1]
const pj = polygon[j] area -= polygon[j]?.[0] * polygon[i]?.[1]
if (pi && pj) {
area += pi[0] * pj[1]
area -= pj[0] * pi[1]
}
} }
return Math.abs(area) / 2 return Math.abs(area) / 2
} }
@@ -224,10 +223,7 @@ export function CeilingPanel() {
</PanelSection> </PanelSection>
<PanelSection title="Material"> <PanelSection title="Material">
<MaterialPicker <MaterialPicker onChange={handleMaterialChange} value={node.material} />
onChange={handleMaterialChange}
value={node.material}
/>
</PanelSection> </PanelSection>
</PanelWrapper> </PanelWrapper>
) )
+21 -12
View File
@@ -1,6 +1,13 @@
'use client' '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 { useViewer } from '@pascal-app/viewer'
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react' import { useCallback } from 'react'
@@ -39,6 +46,13 @@ export function DoorPanel() {
[selectedId, updateNode], [selectedId, updateNode],
) )
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
},
[handleUpdate],
)
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [setSelection])
@@ -80,10 +94,9 @@ export function DoorPanel() {
}, [node, setMovingNode, setSelection]) }, [node, setMovingNode, setSelection])
const setSegmentHeightRatio = (segIdx: number, newVal: number) => { const setSegmentHeightRatio = (segIdx: number, newVal: number) => {
if (!node) return const numSegs = node?.segments.length
const numSegs = node.segments.length const totalH = node?.segments.reduce((sum, s) => sum + s.heightRatio, 0)
const totalH = node.segments.reduce((sum, s) => sum + s.heightRatio, 0) const normH = node?.segments.map((s) => s.heightRatio / totalH)
const normH = node.segments.map((s) => s.heightRatio / totalH)
const clamped = Math.max(0.05, Math.min(0.95, newVal)) const clamped = Math.max(0.05, Math.min(0.95, newVal))
const neighborIdx = segIdx < numSegs - 1 ? segIdx + 1 : segIdx - 1 const neighborIdx = segIdx < numSegs - 1 ? segIdx + 1 : segIdx - 1
const delta = clamped - normH[segIdx]! const delta = clamped - normH[segIdx]!
@@ -563,13 +576,6 @@ export function DoorPanel() {
</div> </div>
</PanelSection> </PanelSection>
<PanelSection title="Material">
<MaterialPicker
onChange={(material) => handleUpdate({ material })}
value={node.material}
/>
</PanelSection>
<PanelSection title="Actions"> <PanelSection title="Actions">
<ActionGroup> <ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} /> <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
@@ -586,6 +592,9 @@ export function DoorPanel() {
/> />
</ActionGroup> </ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
</PanelSection>
</PanelWrapper> </PanelWrapper>
) )
} }
+2 -2
View File
@@ -37,12 +37,12 @@ export function PanelManager() {
return <RoofPanel /> return <RoofPanel />
case 'roof-segment': case 'roof-segment':
return <RoofSegmentPanel /> return <RoofSegmentPanel />
case 'slab':
return <SlabPanel />
case 'stair': case 'stair':
return <StairPanel /> return <StairPanel />
case 'stair-segment': case 'stair-segment':
return <StairSegmentPanel /> return <StairSegmentPanel />
case 'slab':
return <SlabPanel />
case 'ceiling': case 'ceiling':
return <CeilingPanel /> return <CeilingPanel />
case 'wall': case 'wall':
+10 -11
View File
@@ -41,6 +41,13 @@ export function RoofPanel() {
[selectedId, updateNode], [selectedId, updateNode],
) )
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
},
[handleUpdate],
)
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [setSelection])
@@ -124,10 +131,6 @@ export function RoofPanel() {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [selectedId, node, setSelection]) }, [selectedId, node, setSelection])
const handleMaterialChange = useCallback((material: MaterialSchema) => {
handleUpdate({ material })
}, [handleUpdate])
if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null if (!node || node.type !== 'roof' || selectedIds.length !== 1) return null
const segments = (node.children ?? []) const segments = (node.children ?? [])
@@ -235,13 +238,6 @@ export function RoofPanel() {
</div> </div>
</PanelSection> </PanelSection>
<PanelSection title="Material">
<MaterialPicker
onChange={handleMaterialChange}
value={node.material}
/>
</PanelSection>
<PanelSection title="Actions"> <PanelSection title="Actions">
<ActionGroup> <ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} /> <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
@@ -258,6 +254,9 @@ export function RoofPanel() {
/> />
</ActionGroup> </ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
</PanelSection>
</PanelWrapper> </PanelWrapper>
) )
} }
+10 -11
View File
@@ -55,6 +55,13 @@ export function RoofSegmentPanel() {
[selectedId, updateNode], [selectedId, updateNode],
) )
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
},
[handleUpdate],
)
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [setSelection])
@@ -110,10 +117,6 @@ export function RoofSegmentPanel() {
} }
}, [selectedId, node, setSelection]) }, [selectedId, node, setSelection])
const handleMaterialChange = useCallback((material: MaterialSchema) => {
handleUpdate({ material })
}, [handleUpdate])
if (!node || node.type !== 'roof-segment' || selectedIds.length !== 1) return null if (!node || node.type !== 'roof-segment' || selectedIds.length !== 1) return null
return ( return (
@@ -299,13 +302,6 @@ export function RoofSegmentPanel() {
</div> </div>
</PanelSection> </PanelSection>
<PanelSection title="Material">
<MaterialPicker
onChange={handleMaterialChange}
value={node.material}
/>
</PanelSection>
<PanelSection title="Actions"> <PanelSection title="Actions">
<ActionGroup> <ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} /> <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
@@ -322,6 +318,9 @@ export function RoofSegmentPanel() {
/> />
</ActionGroup> </ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
</PanelSection>
</PanelWrapper> </PanelWrapper>
) )
} }
+8 -13
View File
@@ -30,9 +30,12 @@ export function SlabPanel() {
[selectedId, updateNode], [selectedId, updateNode],
) )
const handleMaterialChange = useCallback((material: MaterialSchema) => { const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material }) handleUpdate({ material })
}, [handleUpdate]) },
[handleUpdate],
)
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
@@ -105,12 +108,8 @@ export function SlabPanel() {
const n = polygon.length const n = polygon.length
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const j = (i + 1) % n const j = (i + 1) % n
const pi = polygon[i] area += polygon[i]?.[0] * polygon[j]?.[1]
const pj = polygon[j] area -= polygon[j]?.[0] * polygon[i]?.[1]
if (pi && pj) {
area += pi[0] * pj[1]
area -= pj[0] * pi[1]
}
} }
return Math.abs(area) / 2 return Math.abs(area) / 2
} }
@@ -221,12 +220,8 @@ export function SlabPanel() {
/> />
</div> </div>
</PanelSection> </PanelSection>
<PanelSection title="Material"> <PanelSection title="Material">
<MaterialPicker <MaterialPicker onChange={handleMaterialChange} value={node.material} />
onChange={handleMaterialChange}
value={node.material}
/>
</PanelSection> </PanelSection>
</PanelWrapper> </PanelWrapper>
) )
+19 -10
View File
@@ -1,6 +1,12 @@
'use client' '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 { useViewer } from '@pascal-app/viewer'
import { useCallback } from 'react' import { useCallback } from 'react'
import { MaterialPicker } from '../controls/material-picker' import { MaterialPicker } from '../controls/material-picker'
@@ -26,7 +32,8 @@ export function WallPanel() {
[selectedId, updateNode], [selectedId, updateNode],
) )
const handleUpdateLength = useCallback((newLength: number) => { const handleUpdateLength = useCallback(
(newLength: number) => {
if (!node || newLength <= 0) return if (!node || newLength <= 0) return
const dx = node.end[0] - node.start[0] const dx = node.end[0] - node.start[0]
@@ -40,15 +47,20 @@ export function WallPanel() {
const newEnd: [number, number] = [ const newEnd: [number, number] = [
node.start[0] + dirX * newLength, node.start[0] + dirX * newLength,
node.start[1] + dirZ * newLength node.start[1] + dirZ * newLength,
] ]
handleUpdate({ end: newEnd }) handleUpdate({ end: newEnd })
}, [node, handleUpdate]) },
[node, handleUpdate],
)
const handleMaterialChange = useCallback((material: MaterialSchema) => { const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material }) handleUpdate({ material })
}, [handleUpdate]) },
[handleUpdate],
)
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
@@ -104,10 +116,7 @@ export function WallPanel() {
</PanelSection> </PanelSection>
<PanelSection title="Material"> <PanelSection title="Material">
<MaterialPicker <MaterialPicker onChange={handleMaterialChange} value={node.material} />
onChange={handleMaterialChange}
value={node.material}
/>
</PanelSection> </PanelSection>
</PanelWrapper> </PanelWrapper>
) )
+18 -12
View File
@@ -1,6 +1,13 @@
'use client' '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 { useViewer } from '@pascal-app/viewer'
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react' import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback } from 'react' import { useCallback } from 'react'
@@ -40,6 +47,13 @@ export function WindowPanel() {
[selectedId, updateNode], [selectedId, updateNode],
) )
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
},
[handleUpdate],
)
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [setSelection]) }, [setSelection])
@@ -139,10 +153,6 @@ export function WindowPanel() {
[handleUpdate], [handleUpdate],
) )
const handleMaterialChange = useCallback((material: MaterialSchema) => {
handleUpdate({ material })
}, [handleUpdate])
if (!node || node.type !== 'window' || selectedIds.length !== 1) return null if (!node || node.type !== 'window' || selectedIds.length !== 1) return null
const numCols = node.columnRatios.length const numCols = node.columnRatios.length
@@ -407,13 +417,6 @@ export function WindowPanel() {
)} )}
</PanelSection> </PanelSection>
<PanelSection title="Material">
<MaterialPicker
onChange={handleMaterialChange}
value={node.material}
/>
</PanelSection>
<PanelSection title="Actions"> <PanelSection title="Actions">
<ActionGroup> <ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} /> <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
@@ -430,6 +433,9 @@ export function WindowPanel() {
/> />
</ActionGroup> </ActionGroup>
</PanelSection> </PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
</PanelSection>
</PanelWrapper> </PanelWrapper>
) )
} }
-1
View File
@@ -32,7 +32,6 @@ const SIDEBAR_WIDTH = '18rem'
const SIDEBAR_WIDTH_MOBILE = '18rem' const SIDEBAR_WIDTH_MOBILE = '18rem'
const SIDEBAR_WIDTH_ICON = '3rem' const SIDEBAR_WIDTH_ICON = '3rem'
const SIDEBAR_KEYBOARD_SHORTCUT = 'b' const SIDEBAR_KEYBOARD_SHORTCUT = 'b'
const SIDEBAR_COLLAPSE_THRESHOLD = 220 const SIDEBAR_COLLAPSE_THRESHOLD = 220
const SIDEBAR_MAX_WIDTH = 800 const SIDEBAR_MAX_WIDTH = 800
+34 -11
View File
@@ -1,16 +1,19 @@
'use client' 'use client'
import { type ReactNode, useEffect, useState } from 'react' import { type ReactNode, useEffect } from 'react'
import { CommandPalette } from './../../../components/ui/command-palette' import {
CommandPalette,
type CommandPaletteEmptyAction,
} from './../../../components/ui/command-palette'
import { EditorCommands } from './../../../components/ui/command-palette/editor-commands' import { EditorCommands } from './../../../components/ui/command-palette/editor-commands'
import { import {
Sidebar,
SidebarContent, SidebarContent,
SidebarHeader, SidebarHeader,
useSidebarStore, useSidebarStore,
} from './../../../components/ui/primitives/sidebar' } from './../../../components/ui/primitives/sidebar'
import { cn } from './../../../lib/utils' 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 { SettingsPanel, type SettingsPanelProps } from './panels/settings-panel'
import { SitePanel, type SitePanelProps } from './panels/site-panel' import { SitePanel, type SitePanelProps } from './panels/site-panel'
@@ -19,6 +22,8 @@ interface AppSidebarProps {
sidebarTop?: ReactNode sidebarTop?: ReactNode
settingsPanelProps?: SettingsPanelProps settingsPanelProps?: SettingsPanelProps
sitePanelProps?: SitePanelProps sitePanelProps?: SitePanelProps
extraPanels?: ExtraPanel[]
commandPaletteEmptyAction?: CommandPaletteEmptyAction
} }
export function AppSidebar({ export function AppSidebar({
@@ -26,8 +31,15 @@ export function AppSidebar({
sidebarTop, sidebarTop,
settingsPanelProps, settingsPanelProps,
sitePanelProps, sitePanelProps,
extraPanels,
commandPaletteEmptyAction,
}: AppSidebarProps) { }: AppSidebarProps) {
const [activePanel, setActivePanel] = useState<PanelId>('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(() => { useEffect(() => {
// Widen default sidebar (288px → 432px) for better project title visibility // Widen default sidebar (288px → 432px) for better project title visibility
@@ -37,25 +49,37 @@ export function AppSidebar({
} }
}, []) }, [])
useEffect(() => {
if (!hasActivePanel) {
setActivePanel('site')
}
}, [hasActivePanel, setActivePanel])
const renderPanelContent = () => { const renderPanelContent = () => {
switch (activePanel) { switch (activePanel) {
case 'site': case 'site':
return <SitePanel {...sitePanelProps} /> return <SitePanel {...sitePanelProps} />
case 'settings': case 'settings':
return <SettingsPanel {...settingsPanelProps} /> return <SettingsPanel {...settingsPanelProps} />
default: default: {
return null const extra = extraPanels?.find((p) => p.id === activePanel)
if (extra) {
const Component = extra.component
return <Component />
}
return <SitePanel {...sitePanelProps} />
}
} }
} }
return ( return (
<> <>
<Sidebar className={cn('dark text-white')} variant="floating"> <div className={cn('dark flex h-full w-full bg-sidebar text-sidebar-foreground')}>
<div className="flex h-full">
{/* Icon Rail */} {/* Icon Rail */}
<IconRail <IconRail
activePanel={activePanel} activePanel={activePanel}
appMenuButton={appMenuButton} appMenuButton={appMenuButton}
extraPanels={extraPanels}
onPanelChange={setActivePanel} onPanelChange={setActivePanel}
/> />
@@ -72,9 +96,8 @@ export function AppSidebar({
</SidebarContent> </SidebarContent>
</div> </div>
</div> </div>
</Sidebar>
<EditorCommands /> <EditorCommands />
<CommandPalette /> <CommandPalette emptyAction={commandPaletteEmptyAction} />
</> </>
) )
} }
+67 -47
View File
@@ -1,9 +1,6 @@
'use client' 'use client'
import { useViewer } from '@pascal-app/viewer' import type { ComponentType, ReactNode } from 'react'
import { Moon, Ruler, Sun } from 'lucide-react'
import { motion } from 'motion/react'
import { type ReactNode, useEffect, useState } from 'react'
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -11,31 +8,39 @@ import {
} from './../../../components/ui/primitives/tooltip' } from './../../../components/ui/primitives/tooltip'
import { cn } from './../../../lib/utils' 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 { interface IconRailProps {
activePanel: PanelId activePanel: PanelId
onPanelChange: (panel: PanelId) => void onPanelChange: (panel: PanelId) => void
appMenuButton?: ReactNode appMenuButton?: ReactNode
extraPanels?: ExtraPanel[]
className?: string className?: string
} }
const panels: { id: PanelId; iconSrc: string; label: string }[] = [ const sitePanel: { id: PanelId; iconSrc: string; label: string } = {
{ id: 'site', iconSrc: '/icons/level.png', label: 'Site' }, id: 'site',
{ id: 'settings', iconSrc: '/icons/settings.png', label: 'Settings' }, iconSrc: '/icons/level.png',
] label: 'Site',
}
export function IconRail({ activePanel, onPanelChange, appMenuButton, className }: IconRailProps) { const settingsPanel: { id: PanelId; iconSrc: string; label: string } = {
const theme = useViewer((state) => state.theme) id: 'settings',
const setTheme = useViewer((state) => state.setTheme) iconSrc: '/icons/settings.png',
const unit = useViewer((state) => state.unit) label: 'Settings',
const setUnit = useViewer((state) => state.setUnit) }
const [mounted, setMounted] = useState(false)
useEffect(() => { const panels: { id: PanelId; iconSrc: string; label: string }[] = [sitePanel, settingsPanel]
setMounted(true)
}, [])
export function IconRail({
activePanel,
onPanelChange,
appMenuButton,
extraPanels,
className,
}: IconRailProps) {
return ( return (
<div <div
className={cn( className={cn(
@@ -49,7 +54,8 @@ export function IconRail({ activePanel, onPanelChange, appMenuButton, className
{/* Divider */} {/* Divider */}
<div className="mb-1 h-px w-8 bg-border/50" /> <div className="mb-1 h-px w-8 bg-border/50" />
{panels.map((panel) => { {/* Site panel */}
{[sitePanel].map((panel) => {
const isActive = activePanel === panel.id const isActive = activePanel === panel.id
return ( return (
<Tooltip key={panel.id}> <Tooltip key={panel.id}>
@@ -77,49 +83,63 @@ export function IconRail({ activePanel, onPanelChange, appMenuButton, className
) )
})} })}
{/* Spacer */} {/* Extra panels (injected between site and settings) */}
<div className="flex-1" /> {extraPanels?.map((panel) => {
const isActive = activePanel === panel.id
{/* Unit Toggle */} return (
{mounted && ( <Tooltip key={panel.id}>
<Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
className="mb-1 flex h-9 w-9 items-center justify-center rounded-lg border border-border/50 bg-accent/40 text-foreground transition-all hover:bg-accent" className={cn(
onClick={() => setUnit(unit === 'metric' ? 'imperial' : 'metric')} 'flex h-9 w-9 items-center justify-center rounded-lg transition-all',
isActive ? 'bg-accent' : 'hover:bg-accent',
)}
onClick={() => onPanelChange(panel.id)}
type="button" type="button"
> >
<div className="flex h-full w-full flex-col items-center justify-center gap-0.5 font-medium text-[10px] leading-none"> <span
{unit === 'metric' ? 'm' : 'ft'} className={cn(
</div> 'flex h-6 w-6 items-center justify-center transition-all',
!isActive && 'opacity-50',
)}
>
{panel.icon}
</span>
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right">Toggle units (metric/imperial)</TooltipContent> <TooltipContent side="right">{panel.label}</TooltipContent>
</Tooltip> </Tooltip>
)} )
})}
{/* Theme Toggle */} {/* Settings panel */}
{mounted && ( {[settingsPanel].map((panel) => {
<Tooltip> const isActive = activePanel === panel.id
return (
<Tooltip key={panel.id}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
className="mb-2 flex h-9 w-9 items-center justify-center rounded-lg border border-border/50 bg-accent/40 text-foreground transition-all hover:bg-accent" className={cn(
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} 'flex h-9 w-9 items-center justify-center rounded-lg transition-all',
isActive ? 'bg-accent' : 'hover:bg-accent',
)}
onClick={() => onPanelChange(panel.id)}
type="button" type="button"
> >
<motion.div <img
animate={{ rotate: 0, opacity: 1 }} alt={panel.label}
initial={{ rotate: -90, opacity: 0 }} className={cn(
key={theme} 'h-6 w-6 object-contain transition-all',
transition={{ duration: 0.25, ease: 'easeOut' }} !isActive && 'opacity-50 saturate-0',
> )}
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />} src={panel.iconSrc}
</motion.div> />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right">Toggle theme</TooltipContent> <TooltipContent side="right">{panel.label}</TooltipContent>
</Tooltip> </Tooltip>
)} )
})}
</div> </div>
) )
} }
+21 -14
View File
@@ -18,7 +18,7 @@ import {
DialogTrigger, DialogTrigger,
} from './../../../../../components/ui/primitives/dialog' } from './../../../../../components/ui/primitives/dialog'
import { Switch } from './../../../../../components/ui/primitives/switch' 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 { AudioSettingsDialog } from './audio-settings-dialog'
import { KeyboardShortcutsDialog } from './keyboard-shortcuts-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 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 handleSaveBuild = () => {
const sceneData = { nodes, rootNodeIds } const sceneData = { nodes, rootNodeIds }
const json = JSON.stringify(sceneData, null, 2) const json = JSON.stringify(sceneData, null, 2)
@@ -247,7 +241,8 @@ export function SettingsPanel({
const handleResetToDefault = () => { const handleResetToDefault = () => {
clearScene() clearScene()
resetSelection() resetSelection()
setPhase('site') setPhase('structure')
selectDefaultBuildingAndLevel()
} }
const handleGenerateThumbnail = () => { const handleGenerateThumbnail = () => {
@@ -318,17 +313,29 @@ export function SettingsPanel({
{/* Export Section */} {/* Export Section */}
<div className="space-y-2"> <div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase">Export</label> <label className="font-medium text-muted-foreground text-xs uppercase">Export</label>
<Button className="w-full justify-start gap-2" onClick={() => handleExport('glb')} variant="outline"> <Button
className="w-full justify-start gap-2"
onClick={() => exportScene?.('glb')}
variant="outline"
>
<Download className="size-4" /> <Download className="size-4" />
Export as GLB Export GLB
</Button> </Button>
<Button className="w-full justify-start gap-2" onClick={() => handleExport('stl')} variant="outline"> <Button
className="w-full justify-start gap-2"
onClick={() => exportScene?.('stl')}
variant="outline"
>
<Download className="size-4" /> <Download className="size-4" />
Export as STL Export STL
</Button> </Button>
<Button className="w-full justify-start gap-2" onClick={() => handleExport('obj')} variant="outline"> <Button
className="w-full justify-start gap-2"
onClick={() => exportScene?.('obj')}
variant="outline"
>
<Download className="size-4" /> <Download className="size-4" />
Export as OBJ Export OBJ
</Button> </Button>
</div> </div>
@@ -118,12 +118,8 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const j = (i + 1) % n const j = (i + 1) % n
const pi = polygon[i] area += polygon[i]?.[0] * polygon[j]?.[1]
const pj = polygon[j] area -= polygon[j]?.[0] * polygon[i]?.[1]
if (pi && pj) {
area += pi[0] * pj[1]
area -= pj[0] * pi[1]
}
} }
return Math.abs(area) / 2 return Math.abs(area) / 2
+1 -1
View File
@@ -908,7 +908,7 @@ function LayerToggle() {
</div> </div>
<div className="absolute right-1.5 bottom-1 z-10 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md"> <div className="absolute right-1.5 bottom-1 z-10 rounded border border-border/40 bg-background/40 px-1 py-[2px] backdrop-blur-md">
<span className="block font-medium font-mono text-[9px] text-muted-foreground/70 leading-none"> <span className="block font-medium font-mono text-[9px] text-muted-foreground/70 leading-none">
S B
</span> </span>
</div> </div>
</button> </button>
+2 -6
View File
@@ -88,12 +88,8 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const j = (i + 1) % n const j = (i + 1) % n
const pi = polygon[i] area += polygon[i]?.[0] * polygon[j]?.[1]
const pj = polygon[j] area -= polygon[j]?.[0] * polygon[i]?.[1]
if (pi && pj) {
area += pi[0] * pj[1]
area -= pj[0] * pi[1]
}
} }
return Math.abs(area) / 2 return Math.abs(area) / 2
+2 -6
View File
@@ -79,12 +79,8 @@ function calculatePolygonArea(polygon: Array<[number, number]>): number {
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const j = (i + 1) % n const j = (i + 1) % n
const pi = polygon[i] area += polygon[i]?.[0] * polygon[j]?.[1]
const pj = polygon[j] area -= polygon[j]?.[0] * polygon[i]?.[1]
if (pi && pj) {
area += pi[0] * pj[1]
area -= pj[0] * pi[1]
}
} }
return Math.abs(area) / 2 return Math.abs(area) / 2
+1 -16
View File
@@ -11,11 +11,10 @@ import {
type ZoneNode, type ZoneNode,
} from '@pascal-app/core' } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { ArrowLeft, Camera, ChevronRight, Diamond, Layers, Moon, Footprints, Sun } from 'lucide-react' import { ArrowLeft, Camera, ChevronRight, Diamond, Layers, Moon, Sun } from 'lucide-react'
import { motion } from 'motion/react' import { motion } from 'motion/react'
import Link from 'next/link' import Link from 'next/link'
import { cn } from '../lib/utils' import { cn } from '../lib/utils'
import useEditor from '../store/use-editor'
import { ActionButton } from './ui/action-menu/action-button' import { ActionButton } from './ui/action-menu/action-button'
import { TooltipProvider } from './ui/primitives/tooltip' import { TooltipProvider } from './ui/primitives/tooltip'
@@ -492,20 +491,6 @@ export const ViewerOverlay = ({
src="/icons/topview.png" src="/icons/topview.png"
/> />
</ActionButton> </ActionButton>
<div className="mx-1 h-5 w-px bg-border/40" />
{/* Street View */}
<ActionButton
className="group hover:bg-white/5"
label="Street View"
onClick={() => useEditor.getState().setFirstPersonMode(true)}
size="icon"
tooltipSide="top"
variant="ghost"
>
<Footprints className="h-5 w-5 opacity-70 transition-opacity group-hover:opacity-100" />
</ActionButton>
</div> </div>
</TooltipProvider> </TooltipProvider>
</div> </div>
+18 -7
View File
@@ -3,10 +3,13 @@
import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core' import { sceneRegistry, useScene, type ZoneNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber' import { useFrame } from '@react-three/fiber'
import type { Mesh } from 'three'
import useEditor from '../store/use-editor'
export const ViewerZoneSystem = () => { export const ViewerZoneSystem = () => {
useFrame(() => { useFrame(() => {
const { levelId, zoneId } = useViewer.getState().selection const { levelId, zoneId } = useViewer.getState().selection
const structureLayer = useEditor.getState().structureLayer
const nodes = useScene.getState().nodes const nodes = useScene.getState().nodes
sceneRegistry.byType.zone.forEach((id) => { sceneRegistry.byType.zone.forEach((id) => {
@@ -16,16 +19,24 @@ export const ViewerZoneSystem = () => {
const zone = nodes[id as ZoneNode['id']] as ZoneNode | undefined const zone = nodes[id as ZoneNode['id']] as ZoneNode | undefined
if (!zone) return 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 isOnSelectedLevel = zone.parentId === levelId
const shouldShow = !!levelId && isOnSelectedLevel && !zoneId
obj.visible = shouldShow // Keep group visible (so <Html> 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`) const labelEl = document.getElementById(`${id}-label`)
if (labelEl && labelEl.style.opacity !== targetOpacity) { if (labelEl && labelEl.style.opacity !== targetOpacity) {
labelEl.style.opacity = targetOpacity labelEl.style.opacity = targetOpacity
+47 -10
View File
@@ -11,7 +11,7 @@ export const markToolCancelConsumed = () => {
_toolCancelConsumed = true _toolCancelConsumed = true
} }
export const useKeyboard = () => { export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
// Don't handle shortcuts if user is typing in an input // 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. // 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) // (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool)
if (!_toolCancelConsumed) { 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().setEditingHole(null)
// From zone mode, return to structure select
if (currentPhase === 'structure' && currentStructureLayer === 'zones') {
useEditor.getState().setStructureLayer('elements')
useEditor.getState().setMode('select') 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') useEditor.getState().setFloorplanSelectionTool('click')
// Clear selections to close UI panels, but KEEP the active building and level context. // Clear selections to close UI panels, but KEEP the active building and level context.
@@ -51,29 +62,34 @@ export const useKeyboard = () => {
e.preventDefault() e.preventDefault()
useEditor.getState().setPhase('furnish') useEditor.getState().setPhase('furnish')
useEditor.getState().setMode('select') 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) { } else if (e.key === 'f' && !e.metaKey && !e.ctrlKey) {
if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
useEditor.getState().setPhase('furnish') useEditor.getState().setPhase('furnish')
useEditor.getState().setMode('build')
} else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) { } else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
useEditor.getState().setPhase('structure') useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('zones') useEditor.getState().setStructureLayer('zones')
useEditor.getState().setMode('build')
} }
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) { if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
e.preventDefault() e.preventDefault()
useEditor.getState().setMode('select') useEditor.getState().setMode('select')
useEditor.getState().setFloorplanSelectionTool('click') useEditor.getState().setFloorplanSelectionTool('click')
} else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) { } else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) {
if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('elements')
useEditor.getState().setMode('build') useEditor.getState().setMode('build')
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
useScene.temporal.getState().undo() useScene.temporal.getState().undo()
} else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) { } else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return
e.preventDefault() e.preventDefault()
useScene.temporal.getState().redo() useScene.temporal.getState().redo()
} else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) { } 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.) // Rotate selected node clockwise if it supports rotation (items, roofs, etc.)
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length === 1) { if (selectedNodeIds.length === 1) {
@@ -128,7 +144,7 @@ export const useKeyboard = () => {
sfxEmitter.emit('sfx:item-rotate') 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 // Rotate selected node counter-clockwise
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length === 1) { if (selectedNodeIds.length === 1) {
@@ -147,9 +163,30 @@ export const useKeyboard = () => {
sfxEmitter.emit('sfx:item-rotate') sfxEmitter.emit('sfx:item-rotate')
} }
} }
} else if (e.key === 'Delete' || e.key === 'Backspace') { } else if ((e.key === 'Delete' || e.key === 'Backspace') && !isVersionPreviewMode) {
e.preventDefault() 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[] const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length > 0) { if (selectedNodeIds.length > 0) {
@@ -171,7 +208,7 @@ export const useKeyboard = () => {
} }
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown)
}, []) }, [isVersionPreviewMode])
return null return null
} }
+1 -1
View File
@@ -15,7 +15,6 @@ export {
} from './components/ui/sidebar/panels/settings-panel' } from './components/ui/sidebar/panels/settings-panel'
export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel' export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'
export type { SidebarTab } from './components/ui/sidebar/tab-bar' 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 type { PresetsAdapter, PresetsTab } from './contexts/presets-context'
export { PresetsProvider } from './contexts/presets-context' export { PresetsProvider } from './contexts/presets-context'
export type { SaveStatus } from './hooks/use-auto-save' export type { SaveStatus } from './hooks/use-auto-save'
@@ -31,3 +30,4 @@ export {
usePaletteViewRegistry, usePaletteViewRegistry,
} from './store/use-palette-view-registry' } from './store/use-palette-view-registry'
export { useUploadStore } from './store/use-upload' export { useUploadStore } from './store/use-upload'
export { ViewerToolbarLeft, ViewerToolbarRight } from './components/ui/viewer-toolbar'
Regular → Executable
+20 -15
View File
@@ -20,14 +20,6 @@ type PersistedSelectionPath = {
selectedIds: string[] 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<ReturnType<typeof useViewer.getState>['setSelection']>[0]
}
const EMPTY_PERSISTED_SELECTION: PersistedSelectionPath = { const EMPTY_PERSISTED_SELECTION: PersistedSelectionPath = {
buildingId: null, buildingId: null,
levelId: null, levelId: null,
@@ -271,9 +263,27 @@ export function syncEditorSelectionFromCurrentScene() {
: null : null
if (firstBuilding && firstLevel) { 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 (shouldRestoreEditorUiState) {
if (restoredSelection) { if (restoredSelection) {
useViewer.getState().setSelection(toViewerSelection(restoredSelection)) useViewer.getState().setSelection(restoredSelection)
useEditor.setState( useEditor.setState(
restoredEditorUiState.phase === 'site' restoredEditorUiState.phase === 'site'
? (selectionDrivenEditorUiState ?? restoredEditorUiState) ? (selectionDrivenEditorUiState ?? restoredEditorUiState)
@@ -295,7 +305,7 @@ export function syncEditorSelectionFromCurrentScene() {
} }
if (restoredSelection) { if (restoredSelection) {
useViewer.getState().setSelection(toViewerSelection(restoredSelection)) useViewer.getState().setSelection(restoredSelection)
if (selectionDrivenEditorUiState) { if (selectionDrivenEditorUiState) {
useEditor.setState(selectionDrivenEditorUiState) useEditor.setState(selectionDrivenEditorUiState)
} }
@@ -310,11 +320,6 @@ export function syncEditorSelectionFromCurrentScene() {
}) })
useEditor.getState().setPhase('structure') useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('elements') useEditor.getState().setStructureLayer('elements')
if (!firstLevel.children || firstLevel.children.length === 0) {
useEditor.getState().setMode('build')
useEditor.getState().setTool('wall')
}
} else { } else {
useEditor.getState().setPhase('site') useEditor.getState().setPhase('site')
useViewer.getState().setSelection({ useViewer.getState().setSelection({
+31 -9
View File
@@ -81,9 +81,25 @@ type EditorState = {
setCatalogCategory: (category: CatalogCategory | null) => void setCatalogCategory: (category: CatalogCategory | null) => void
selectedItem: AssetInput | null selectedItem: AssetInput | null
setSelectedItem: (item: AssetInput) => void setSelectedItem: (item: AssetInput) => void
movingNode: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | StairNode | StairSegmentNode | null movingNode:
| ItemNode
| WindowNode
| DoorNode
| RoofNode
| RoofSegmentNode
| StairNode
| StairSegmentNode
| null
setMovingNode: ( setMovingNode: (
node: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null, node:
| ItemNode
| WindowNode
| DoorNode
| RoofNode
| RoofSegmentNode
| StairNode
| StairSegmentNode
| null,
) => void ) => void
selectedReferenceId: string | null selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void setSelectedReferenceId: (id: string | null) => void
@@ -109,12 +125,13 @@ type EditorState = {
setFloorplanHovered: (hovered: boolean) => void setFloorplanHovered: (hovered: boolean) => void
floorplanSelectionTool: FloorplanSelectionTool floorplanSelectionTool: FloorplanSelectionTool
setFloorplanSelectionTool: (tool: FloorplanSelectionTool) => void 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 // Development-only camera debug flag for inspecting underside geometry
allowUndergroundCamera: boolean allowUndergroundCamera: boolean
setAllowUndergroundCamera: (enabled: boolean) => void setAllowUndergroundCamera: (enabled: boolean) => void
// First-person walkthrough mode (street view)
isFirstPersonMode: boolean
setFirstPersonMode: (enabled: boolean) => void
activeSidebarPanel: string activeSidebarPanel: string
setActiveSidebarPanel: (id: string) => void setActiveSidebarPanel: (id: string) => void
floorplanPaneRatio: number floorplanPaneRatio: number
@@ -403,7 +420,15 @@ const useEditor = create<EditorState>()(
setCatalogCategory: (category) => set({ catalogCategory: category }), setCatalogCategory: (category) => set({ catalogCategory: category }),
selectedItem: null, selectedItem: null,
setSelectedItem: (item) => set({ selectedItem: item }), 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 }), setMovingNode: (node) => set({ movingNode: node }),
selectedReferenceId: null, selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }), setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
@@ -442,9 +467,7 @@ const useEditor = create<EditorState>()(
_viewModeBeforeFirstPerson: null as ViewMode | null, _viewModeBeforeFirstPerson: null as ViewMode | null,
setFirstPersonMode: (enabled) => { setFirstPersonMode: (enabled) => {
if (enabled) { if (enabled) {
// Save current view mode and force 3D for immersive walkthrough
const currentViewMode = get().viewMode const currentViewMode = get().viewMode
// Force perspective camera and full-height walls for immersive walkthrough
useViewer.getState().setCameraMode('perspective') useViewer.getState().setCameraMode('perspective')
useViewer.getState().setWallMode('up') useViewer.getState().setWallMode('up')
set({ set({
@@ -458,7 +481,6 @@ const useEditor = create<EditorState>()(
}) })
useViewer.getState().setSelection({ selectedIds: [], zoneId: null }) useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
} else { } else {
// Restore previous view mode
const prevMode = get()._viewModeBeforeFirstPerson const prevMode = get()._viewModeBeforeFirstPerson
set({ set({
isFirstPersonMode: false, isFirstPersonMode: false,
@@ -14,7 +14,7 @@ const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.
const gridPattern = lineX.max(lineY) const gridPattern = lineX.max(lineY)
const gridOpacity = mix(float(0.2), float(0.6), gridPattern) const gridOpacity = mix(float(0.2), float(0.6), gridPattern)
function createCeilingMaterials(color: string = '#999999') { function createCeilingMaterials(color = '#999999') {
const topMaterial = new MeshBasicNodeMaterial({ const topMaterial = new MeshBasicNodeMaterial({
color, color,
transparent: true, transparent: true,
@@ -1,6 +1,8 @@
import { import {
type AnimationEffect, type AnimationEffect,
type AnyNodeId, type AnyNodeId,
baseMaterial,
glassMaterial,
type Interactive, type Interactive,
type ItemNode, type ItemNode,
type LightEffect, type LightEffect,
@@ -16,36 +18,18 @@ import { Suspense, useEffect, useMemo, useRef } from 'react'
import type { AnimationAction, Group, Material, Mesh } from 'three' import type { AnimationAction, Group, Material, Mesh } from 'three'
import { MathUtils } from 'three' import { MathUtils } from 'three'
import { positionLocal, smoothstep, time } from 'three/tsl' 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 { useNodeEvents } from '../../../hooks/use-node-events'
import { resolveCdnUrl } from '../../../lib/asset-url' import { resolveCdnUrl } from '../../../lib/asset-url'
import { useItemLightPool } from '../../../store/use-item-light-pool' import { useItemLightPool } from '../../../store/use-item-light-pool'
import { ErrorBoundary } from '../../error-boundary' import { ErrorBoundary } from '../../error-boundary'
import { NodeRenderer } from '../node-renderer' 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 => { const getMaterialForOriginal = (original: Material): MeshStandardNodeMaterial => {
if (original.name.toLowerCase() === 'glass') { if (original.name.toLowerCase() === 'glass') {
return glassMaterial return glassMaterial
} }
return defaultMaterial return baseMaterial
} }
const BrokenItemFallback = ({ node }: { node: ItemNode }) => { const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
@@ -145,6 +129,18 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
if (Array.isArray(mesh.material)) { if (Array.isArray(mesh.material)) {
mesh.material = mesh.material.map((mat) => getMaterialForOriginal(mat)) mesh.material = mesh.material.map((mat) => getMaterialForOriginal(mat))
hasGlass = mesh.material.some((mat) => mat.name === 'glass') 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 { } else {
mesh.material = getMaterialForOriginal(mesh.material) mesh.material = getMaterialForOriginal(mesh.material)
hasGlass = mesh.material.name === 'glass' hasGlass = mesh.material.name === 'glass'
@@ -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 { 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 { useNodeEvents } from '../../../hooks/use-node-events'
import useViewer from '../../../store/use-viewer'
import { NodeRenderer } from '../node-renderer' import { NodeRenderer } from '../node-renderer'
const Y_OFFSET = 0.01 const Y_OFFSET = 0.01
@@ -29,29 +31,76 @@ const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeom
return geometry return geometry
} }
type S = ReturnType<typeof useScene.getState>
export const SiteRenderer = ({ node }: { node: SiteNode }) => { export const SiteRenderer = ({ node }: { node: SiteNode }) => {
const ref = useRef<Group>(null!) const ref = useRef<Group>(null!)
useRegistry(node.id, 'site', ref) useRegistry(node.id, 'site', ref)
// Create floor shape from polygon points const theme = useViewer((state) => state.theme)
const floorShape = useMemo(() => { 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<string, number>()
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 if (!node?.polygon?.points || node.polygon.points.length < 3) return null
const pts = node.polygon.points
const shape = new Shape() const shape = new Shape()
const firstPt = node.polygon.points[0]! 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 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.closePath() 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 return shape
}, [node?.polygon?.points]) }, [node?.polygon?.points, slabPolygons])
// Create boundary line geometry // Create boundary line geometry
const lineGeometry = useMemo(() => { const lineGeometry = useMemo(() => {
@@ -61,7 +110,7 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
const handlers = useNodeEvents(node, 'site') const handlers = useNodeEvents(node, 'site')
if (!(node && floorShape && lineGeometry)) { if (!(node && lineGeometry)) {
return null 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 */}
<mesh position={[0, Y_OFFSET - 0.005, 0]} receiveShadow rotation={[-Math.PI / 2, 0, 0]}> {groundShape && (
<shapeGeometry args={[floorShape]} /> <mesh position={[0, -0.05, 0]} receiveShadow rotation={[-Math.PI / 2, 0, 0]}>
<shadowMaterial opacity={0.75} transparent /> <shapeGeometry args={[groundShape]} />
<meshStandardMaterial
color={bgColor}
depthWrite={true}
polygonOffset={true}
polygonOffsetFactor={1}
polygonOffsetUnits={1}
/>
</mesh> </mesh>
)}
{/* Simple boundary line */} {/* Simple boundary line */}
{/* @ts-ignore */} {/* @ts-ignore */}
@@ -23,8 +23,8 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
receiveShadow receiveShadow
ref={ref} ref={ref}
{...handlers} {...handlers}
visible={node.visible}
material={material} material={material}
visible={node.visible}
> >
<boxGeometry args={[0, 0, 0]} /> <boxGeometry args={[0, 0, 0]} />
</mesh> </mesh>
@@ -23,7 +23,7 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture]) }, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
return ( return (
<mesh castShadow receiveShadow ref={ref} visible={node.visible} material={material}> <mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}>
<boxGeometry args={[0, 0, 0]} /> <boxGeometry args={[0, 0, 0]} />
<mesh name="collision-mesh" visible={false} {...handlers}> <mesh name="collision-mesh" visible={false} {...handlers}>
<boxGeometry args={[0, 0, 0]} /> <boxGeometry args={[0, 0, 0]} />
@@ -1,9 +1,5 @@
'use client' '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 { import {
CeilingSystem, CeilingSystem,
DoorSystem, DoorSystem,
@@ -19,7 +15,6 @@ import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@re
import { useEffect, useMemo, useRef } from 'react' import { useEffect, useMemo, useRef } from 'react'
import * as THREE from 'three/webgpu' import * as THREE from 'three/webgpu'
import useViewer from '../../store/use-viewer' import useViewer from '../../store/use-viewer'
import { ExportSystem } from '../../systems/export/export-system'
import { GuideSystem } from '../../systems/guide/guide-system' import { GuideSystem } from '../../systems/guide/guide-system'
import { ItemLightSystem } from '../../systems/item-light/item-light-system' import { ItemLightSystem } from '../../systems/item-light/item-light-system'
import { LevelSystem } from '../../systems/level/level-system' import { LevelSystem } from '../../systems/level/level-system'
@@ -151,7 +146,6 @@ const Viewer: React.FC<ViewerProps> = ({
<WallSystem /> <WallSystem />
<WindowSystem /> <WindowSystem />
<ZoneSystem /> <ZoneSystem />
<ExportSystem />
<PostProcessing /> <PostProcessing />
{/* <DebugRenderer /> */} {/* <DebugRenderer /> */}
<GPUDeviceWatcher /> <GPUDeviceWatcher />
@@ -1,7 +1,6 @@
import { useFrame, useThree } from '@react-three/fiber' import { useFrame, useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Color, Layers, UnsignedByteType } from 'three' 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 { ssgi } from 'three/addons/tsl/display/SSGINode.js'
import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js' import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'
import { import {
@@ -23,6 +22,7 @@ import {
} from 'three/tsl' } from 'three/tsl'
import { RenderPipeline, type WebGPURenderer } from 'three/webgpu' import { RenderPipeline, type WebGPURenderer } from 'three/webgpu'
import { SCENE_LAYER, ZONE_LAYER } from '../../lib/layers' import { SCENE_LAYER, ZONE_LAYER } from '../../lib/layers'
import { mergedOutline } from '../../lib/merged-outline-node'
import useViewer from '../../store/use-viewer' import useViewer from '../../store/use-viewer'
// SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion // SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion
@@ -67,6 +67,7 @@ const PostProcessingPasses = () => {
l.disable(SCENE_LAYER) l.disable(SCENE_LAYER)
return l return l
}, []) }, [])
const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode)
// Subscribe to projectId so the pipeline rebuilds on project switch // Subscribe to projectId so the pipeline rebuilds on project switch
const projectId = useViewer((s) => s.projectId) const projectId = useViewer((s) => s.projectId)
@@ -197,60 +198,45 @@ const PostProcessingPasses = () => {
) )
} }
function generateSelectedOutlinePass() { // Single merged outline node: one shared depth pass for both selected + hovered groups.
const edgeStrength = uniform(3) const outliner = useViewer.getState().outliner
const edgeGlow = uniform(0) const outlineNode = mergedOutline(scene, camera, {
const edgeThickness = uniform(1) primaryObjects: outliner.selectedObjects,
const visibleEdgeColor = uniform(new Color(0xff_ff_ff)) secondaryObjects: outliner.hoveredObjects,
const hiddenEdgeColor = uniform(new Color(0xf3_ff_47)) primaryEdgeThickness: uniform(1),
secondaryEdgeThickness: uniform(1.5),
const outlinePass = outline(scene, camera, {
selectedObjects: useViewer.getState().outliner.selectedObjects,
edgeGlow,
edgeThickness,
}) })
const { visibleEdge, hiddenEdge } = outlinePass
const outlineColor = visibleEdge // Selected: white visible, yellow hidden
.mul(visibleEdgeColor) const selectedVisibleColor = uniform(new Color(0xff_ff_ff))
.add(hiddenEdge.mul(hiddenEdgeColor)) const selectedHiddenColor = uniform(new Color(0xf3_ff_47))
.mul(edgeStrength) const selectedStrength = uniform(3)
const selectedOutline = outlineNode.primaryVisibleEdge
.mul(selectedVisibleColor)
.add(outlineNode.primaryHiddenEdge.mul(selectedHiddenColor))
.mul(selectedStrength)
return outlineColor // Hovered: blue visible, yellow hidden, pulsing
} const hoverVisibleColor = uniform(
new Color(hoverHighlightMode === 'delete' ? 0xef_44_44 : 0x00_aa_ff),
function generateHoverOutlinePass() { )
const edgeStrength = uniform(5) const hoverHiddenColor = uniform(
const edgeGlow = uniform(0.5) new Color(hoverHighlightMode === 'delete' ? 0x99_1b_1b : 0xf3_ff_47),
const edgeThickness = uniform(1.5) )
const hoverStrength = uniform(hoverHighlightMode === 'delete' ? 6 : 5)
const pulsePeriod = uniform(3) const pulsePeriod = uniform(3)
const visibleEdgeColor = uniform(new Color(0x00_aa_ff)) const osc =
const hiddenEdgeColor = uniform(new Color(0xf3_ff_47)) hoverHighlightMode === 'delete'
? float(1)
const outlinePass = outline(scene, camera, { : oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5) // [ 0.5, 1.0 ]
selectedObjects: useViewer.getState().outliner.hoveredObjects, const hoverOutline = outlineNode.secondaryVisibleEdge
edgeGlow, .mul(hoverVisibleColor)
edgeThickness, .add(outlineNode.secondaryHiddenEdge.mul(hoverHiddenColor))
}) .mul(hoverStrength)
const { visibleEdge, hiddenEdge } = outlinePass .mul(osc)
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()
const compositeWithOutlines = vec4( const compositeWithOutlines = vec4(
add(sceneColor.rgb, selectedOutlinePass.add(hoverOutlinePass)), add(sceneColor.rgb, selectedOutline.add(hoverOutline)),
sceneColor.a, sceneColor.a,
) )
@@ -280,7 +266,7 @@ const PostProcessingPasses = () => {
} }
renderPipelineRef.current = null renderPipelineRef.current = null
} }
}, [renderer, scene, camera, isInitialized, zoneLayers]) }, [renderer, scene, camera, hoverHighlightMode, isInitialized, zoneLayers])
useFrame((_, delta) => { useFrame((_, delta) => {
// Animate background colour toward the current theme target (same lerp as AnimatedBackground) // Animate background colour toward the current theme target (same lerp as AnimatedBackground)
@@ -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<any>(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 <PointerLockControls ref={controlsRef} />
}
@@ -36,5 +36,4 @@ const useGLTFKTX2 = (path: string): ReturnType<typeof useGLTF> => {
loader.setMeshoptDecoder(MeshoptDecoder) loader.setMeshoptDecoder(MeshoptDecoder)
}) })
} }
export { useGLTFKTX2 } export { useGLTFKTX2 }
+2 -1
View File
@@ -1,4 +1,5 @@
export { default as Viewer } from './components/viewer' 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 { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url'
export { SCENE_LAYER, ZONE_LAYER } from './lib/layers' export { SCENE_LAYER, ZONE_LAYER } from './lib/layers'
export { export {
@@ -13,7 +14,7 @@ export {
DEFAULT_WINDOW_MATERIAL, DEFAULT_WINDOW_MATERIAL,
disposeMaterial, disposeMaterial,
} from './lib/materials' } from './lib/materials'
export { mergedOutline } from './lib/merged-outline-node'
export { default as useViewer } from './store/use-viewer' export { default as useViewer } from './store/use-viewer'
export { ExportSystem } from './systems/export/export-system'
export { InteractiveSystem } from './systems/interactive/interactive-system' export { InteractiveSystem } from './systems/interactive/interactive-system'
export { snapLevelsToTruePositions } from './systems/level/level-utils' export { snapLevelsToTruePositions } from './systems/level/level-utils'
@@ -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<typeof makeGroupTargets>
// ---------------------------------------------------------------------------
// 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<Object3D>()
private readonly _cacheB = new Set<Object3D>()
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
// ── 37. 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<Object3D>) {
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<typeof MergedOutlineNode>[2],
) => new MergedOutlineNode(scene, camera, params)
+3
View File
@@ -78,18 +78,21 @@ interface ThreeJSXElements {
} }
declare module 'react' { declare module 'react' {
// biome-ignore lint/style/noNamespace: Required for JSX module augmentation
namespace JSX { namespace JSX {
interface IntrinsicElements extends ThreeJSXElements {} interface IntrinsicElements extends ThreeJSXElements {}
} }
} }
declare module 'react/jsx-runtime' { declare module 'react/jsx-runtime' {
// biome-ignore lint/style/noNamespace: Required for JSX module augmentation
namespace JSX { namespace JSX {
interface IntrinsicElements extends ThreeJSXElements {} interface IntrinsicElements extends ThreeJSXElements {}
} }
} }
declare module 'react/jsx-dev-runtime' { declare module 'react/jsx-dev-runtime' {
// biome-ignore lint/style/noNamespace: Required for JSX module augmentation
namespace JSX { namespace JSX {
interface IntrinsicElements extends ThreeJSXElements {} interface IntrinsicElements extends ThreeJSXElements {}
} }
+6
View File
@@ -71,6 +71,9 @@ type ViewerState = {
debugColors: boolean debugColors: boolean
setDebugColors: (enabled: boolean) => void setDebugColors: (enabled: boolean) => void
walkthroughMode: boolean
setWalkthroughMode: (mode: boolean) => void
cameraDragging: boolean cameraDragging: boolean
setCameraDragging: (dragging: boolean) => void setCameraDragging: (dragging: boolean) => void
} }
@@ -194,6 +197,9 @@ const useViewer = create<ViewerState>()(
debugColors: false, debugColors: false,
setDebugColors: (enabled) => set({ debugColors: enabled }), setDebugColors: (enabled) => set({ debugColors: enabled }),
walkthroughMode: false,
setWalkthroughMode: (mode) => set({ walkthroughMode: mode }),
cameraDragging: false, cameraDragging: false,
setCameraDragging: (dragging) => set({ cameraDragging: dragging }), setCameraDragging: (dragging) => set({ cameraDragging: dragging }),
}), }),
+124 -60
View File
@@ -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 { useFrame } from '@react-three/fiber'
import { useRef } from 'react' import { useRef } from 'react'
import { Color } from 'three'
import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl' import { Fn, float, fract, length, mix, positionLocal, smoothstep, step, vec2 } from 'three/tsl'
import { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu' import { type Mesh, MeshStandardNodeMaterial, Vector3 } from 'three/webgpu'
import useViewer from '../../store/use-viewer' import useViewer from '../../store/use-viewer'
const tmpVec = new Vector3() const tmpVec = new Vector3()
const u = new Vector3() const u = new Vector3()
const v = 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 dotPattern = Fn(() => {
const scale = float(0.1) const scale = float(0.1)
@@ -30,23 +51,15 @@ const dotPattern = Fn(() => {
interface WallMaterials { interface WallMaterials {
visible: MeshStandardNodeMaterial visible: MeshStandardNodeMaterial
invisible: MeshStandardNodeMaterial invisible: MeshStandardNodeMaterial
deleteVisible: MeshStandardNodeMaterial
deleteInvisible: MeshStandardNodeMaterial
highlightedVisible: MeshStandardNodeMaterial
highlightedInvisible: MeshStandardNodeMaterial
materialHash: string materialHash: string
} }
const wallMaterialCache = new Map<string, WallMaterials>() const wallMaterialCache = new Map<string, WallMaterials>()
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 = { const presetColors = {
white: '#ffffff', white: '#ffffff',
brick: '#8b4513', brick: '#8b4513',
@@ -59,10 +72,43 @@ const presetColors = {
marble: '#f5f5f5', marble: '#f5f5f5',
} as const } 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 { function getPresetColor(preset: string): string {
return presetColors[preset as keyof typeof presetColors] ?? '#ffffff' 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 { function getMaterialsForWall(wallNode: WallNode): WallMaterials {
const cacheKey = wallNode.id const cacheKey = wallNode.id
const materialHash = getMaterialHash(wallNode) const materialHash = getMaterialHash(wallNode)
@@ -75,20 +121,26 @@ function getMaterialsForWall(wallNode: WallNode): WallMaterials {
if (existing) { if (existing) {
existing.visible.dispose() existing.visible.dispose()
existing.invisible.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) { if (wallNode.material?.properties?.color) {
userColor = wallNode.material.properties.color userColor = wallNode.material.properties.color
} else if (wallNode.material?.preset && wallNode.material.preset !== 'custom') { } else if (wallNode.material?.preset && wallNode.material.preset !== 'custom') {
userColor = getPresetColor(wallNode.material.preset) userColor = getPresetColor(wallNode.material.preset)
} }
const visibleMat = new MeshStandardNodeMaterial({ const visibleMat = wallNode.material
? new MeshStandardNodeMaterial({
color: userColor, color: userColor,
roughness: 1, roughness: 1,
metalness: 0, metalness: 0,
}) })
: (baseMaterial.clone() as MeshStandardNodeMaterial)
const invisibleMat = new MeshStandardNodeMaterial({ const invisibleMat = new MeshStandardNodeMaterial({
transparent: true, transparent: true,
@@ -98,7 +150,20 @@ function getMaterialsForWall(wallNode: WallNode): WallMaterials {
emissive: userColor, 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) wallMaterialCache.set(cacheKey, result)
return result return result
} }
@@ -135,78 +200,77 @@ export const WallCutout = () => {
const lastUpdateTime = useRef(0) const lastUpdateTime = useRef(0)
const lastWallMode = useRef<string>(useViewer.getState().wallMode) const lastWallMode = useRef<string>(useViewer.getState().wallMode)
const lastNumberOfWalls = useRef(0) const lastNumberOfWalls = useRef(0)
const lastWallMaterials = useRef<Map<string, WallMaterials>>(new Map()) const lastHighlightKey = useRef('')
useFrame(({ camera, clock }) => { useFrame(({ camera, clock }) => {
const wallMode = useViewer.getState().wallMode 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 currentTime = clock.elapsedTime
const currentCameraPosition = camera.position const currentCameraPosition = camera.position
camera.getWorldDirection(tmpVec) camera.getWorldDirection(tmpVec)
tmpVec.add(currentCameraPosition) 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 distanceMoved = currentCameraPosition.distanceTo(lastCameraPosition.current)
const directionChanged = tmpVec.distanceTo(lastCameraTarget.current) const directionChanged = tmpVec.distanceTo(lastCameraTarget.current)
const timeSinceUpdate = currentTime - lastUpdateTime.current const timeSinceUpdate = currentTime - lastUpdateTime.current
const shouldUpdate = if (
((distanceMoved > 0.5 || directionChanged > 0.3) && timeSinceUpdate > 0.1) || ((distanceMoved > 0.5 || directionChanged > 0.3) && timeSinceUpdate > 0.1) ||
lastWallMode.current !== wallMode || lastWallMode.current !== wallMode ||
sceneRegistry.byType.wall.size !== lastNumberOfWalls.current sceneRegistry.byType.wall.size !== lastNumberOfWalls.current ||
lastHighlightKey.current !== highlightKey
) {
lastCameraPosition.current.copy(currentCameraPosition)
lastCameraTarget.current.copy(tmpVec)
lastUpdateTime.current = currentTime
camera.getWorldDirection(u)
const walls = sceneRegistry.byType.wall const walls = sceneRegistry.byType.wall
const currentWallIds = new Set<string>()
walls.forEach((wallId) => { walls.forEach((wallId) => {
const wallMesh = sceneRegistry.nodes.get(wallId) const wallMesh = sceneRegistry.nodes.get(wallId)
if (!wallMesh) return if (!wallMesh) return
const wallNode = useScene.getState().nodes[wallId as WallNode['id']] const wallNode = useScene.getState().nodes[wallId as WallNode['id']]
if (!wallNode || wallNode.type !== 'wall') return if (!wallNode || wallNode.type !== 'wall') return
currentWallIds.add(wallId)
const hideWall = getWallHideState(wallNode, wallMesh as Mesh, wallMode, u) const hideWall = getWallHideState(wallNode, wallMesh as Mesh, wallMode, u)
const isDeleteHighlighted = deleteHoveredWallId === wallId
if (shouldUpdate) { const isSelectionHighlighted = !isDeleteHighlighted && highlightedWallIds.has(wallId)
const materials = getMaterialsForWall(wallNode) const materials = getMaterialsForWall(wallNode)
;(wallMesh as Mesh).material = hideWall ? materials.invisible : materials.visible
if (hideWall) {
;(wallMesh as Mesh).material = isDeleteHighlighted
? materials.deleteInvisible
: isSelectionHighlighted
? materials.highlightedInvisible
: materials.invisible
} else { } else {
const currentMaterial = (wallMesh as Mesh).material ;(wallMesh as Mesh).material = isDeleteHighlighted
const materials = wallMaterialCache.get(wallId) ? materials.deleteVisible
if ( : isSelectionHighlighted
!materials || ? materials.highlightedVisible
currentMaterial !== (hideWall ? materials.invisible : materials.visible) : wallNode.material
) { ? materials.visible
const newMaterials = getMaterialsForWall(wallNode) : baseMaterial
;(wallMesh as Mesh).material = hideWall ? newMaterials.invisible : newMaterials.visible
}
} }
}) })
if (shouldUpdate) {
lastCameraPosition.current.copy(currentCameraPosition)
lastCameraTarget.current.copy(tmpVec)
lastUpdateTime.current = currentTime
camera.getWorldDirection(u)
if (lastWallMode.current !== wallMode) {
wallMaterialCache.clear()
}
for (const [wallId, mats] of lastWallMaterials.current) {
if (!currentWallIds.has(wallId)) {
mats.visible.dispose()
mats.invisible.dispose()
wallMaterialCache.delete(wallId)
}
}
lastWallMaterials.current.clear()
for (const [wallId, mats] of wallMaterialCache) {
lastWallMaterials.current.set(wallId, mats)
}
lastWallMode.current = wallMode lastWallMode.current = wallMode
lastNumberOfWalls.current = sceneRegistry.byType.wall.size lastNumberOfWalls.current = sceneRegistry.byType.wall.size
lastHighlightKey.current = highlightKey
} }
}) })
return null return null