feat: stair system, scene graph utilities, read-only mode, viewer state improvements (#210)
Stair system (full stack): - New StairNode + StairSegmentNode schemas with flights, landings, L/U-shapes - StairSystem: geometry generation with throttled per-frame updates - Stair tool, edit system, panels, tree node, and renderers - Event bus types, scene registry, and command palette entries Scene graph utilities: - cloneLevelSubtree: deep-clone a level with remapped IDs - forkSceneGraph: clone + strip scan/guide nodes for project forking Core improvements: - Read-only mode on scene store (blocks create/update/delete when locked) - readOnly guards on node-actions and collection actions - Upload store for scan/guide file upload handling Viewer state: - previewSelectedIds for box-select live preview - hoverHighlightMode (default/delete) for delete-mode hover outline
This commit is contained in:
@@ -10,6 +10,8 @@ import type {
|
||||
RoofSegmentNode,
|
||||
SiteNode,
|
||||
SlabNode,
|
||||
StairNode,
|
||||
StairSegmentNode,
|
||||
WallNode,
|
||||
WindowNode,
|
||||
ZoneNode,
|
||||
@@ -41,6 +43,8 @@ export type SlabEvent = NodeEvent<SlabNode>
|
||||
export type CeilingEvent = NodeEvent<CeilingNode>
|
||||
export type RoofEvent = NodeEvent<RoofNode>
|
||||
export type RoofSegmentEvent = NodeEvent<RoofSegmentNode>
|
||||
export type StairEvent = NodeEvent<StairNode>
|
||||
export type StairSegmentEvent = NodeEvent<StairSegmentNode>
|
||||
export type WindowEvent = NodeEvent<WindowNode>
|
||||
export type DoorEvent = NodeEvent<DoorNode>
|
||||
|
||||
@@ -104,6 +108,8 @@ type EditorEvents = GridEvents &
|
||||
NodeEvents<'ceiling', CeilingEvent> &
|
||||
NodeEvents<'roof', RoofEvent> &
|
||||
NodeEvents<'roof-segment', RoofSegmentEvent> &
|
||||
NodeEvents<'stair', StairEvent> &
|
||||
NodeEvents<'stair-segment', StairSegmentEvent> &
|
||||
NodeEvents<'window', WindowEvent> &
|
||||
NodeEvents<'door', DoorEvent> &
|
||||
CameraControlEvents &
|
||||
|
||||
@@ -18,6 +18,8 @@ export const sceneRegistry = {
|
||||
zone: new Set<string>(),
|
||||
roof: new Set<string>(),
|
||||
'roof-segment': new Set<string>(),
|
||||
stair: new Set<string>(),
|
||||
'stair-segment': new Set<string>(),
|
||||
scan: new Set<string>(),
|
||||
guide: new Set<string>(),
|
||||
window: new Set<string>(),
|
||||
|
||||
@@ -14,6 +14,8 @@ export type {
|
||||
RoofSegmentEvent,
|
||||
SiteEvent,
|
||||
SlabEvent,
|
||||
StairEvent,
|
||||
StairSegmentEvent,
|
||||
WallEvent,
|
||||
WindowEvent,
|
||||
ZoneEvent,
|
||||
@@ -54,6 +56,7 @@ export { DoorSystem } from './systems/door/door-system'
|
||||
export { ItemSystem } from './systems/item/item-system'
|
||||
export { RoofSystem } from './systems/roof/roof-system'
|
||||
export { SlabSystem } from './systems/slab/slab-system'
|
||||
export { StairSystem } from './systems/stair/stair-system'
|
||||
export {
|
||||
DEFAULT_WALL_HEIGHT,
|
||||
DEFAULT_WALL_THICKNESS,
|
||||
@@ -68,5 +71,5 @@ export {
|
||||
} from './systems/wall/wall-mitering'
|
||||
export { WallSystem } from './systems/wall/wall-system'
|
||||
export { WindowSystem } from './systems/window/window-system'
|
||||
export { cloneSceneGraph } from './utils/clone-scene-graph'
|
||||
export { cloneLevelSubtree, cloneSceneGraph, forkSceneGraph } from './utils/clone-scene-graph'
|
||||
export { isObject } from './utils/types'
|
||||
|
||||
@@ -36,6 +36,8 @@ export { ScanNode } from './nodes/scan'
|
||||
// Nodes
|
||||
export { SiteNode } from './nodes/site'
|
||||
export { SlabNode } from './nodes/slab'
|
||||
export { StairNode } from './nodes/stair'
|
||||
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
|
||||
export { WallNode } from './nodes/wall'
|
||||
export { WindowNode } from './nodes/window'
|
||||
export { ZoneNode } from './nodes/zone'
|
||||
|
||||
@@ -6,6 +6,7 @@ import { GuideNode } from './guide'
|
||||
import { RoofNode } from './roof'
|
||||
import { ScanNode } from './scan'
|
||||
import { SlabNode } from './slab'
|
||||
import { StairNode } from './stair'
|
||||
import { WallNode } from './wall'
|
||||
import { ZoneNode } from './zone'
|
||||
|
||||
@@ -20,6 +21,7 @@ export const LevelNode = BaseNode.extend({
|
||||
SlabNode.shape.id,
|
||||
CeilingNode.shape.id,
|
||||
RoofNode.shape.id,
|
||||
StairNode.shape.id,
|
||||
ScanNode.shape.id,
|
||||
GuideNode.shape.id,
|
||||
]),
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { MaterialSchema } from '../material'
|
||||
|
||||
export const StairSegmentType = z.enum(['stair', 'landing'])
|
||||
|
||||
export type StairSegmentType = z.infer<typeof StairSegmentType>
|
||||
|
||||
export const AttachmentSide = z.enum(['front', 'left', 'right'])
|
||||
|
||||
export type AttachmentSide = z.infer<typeof AttachmentSide>
|
||||
|
||||
export const StairSegmentNode = BaseNode.extend({
|
||||
id: objectId('sseg'),
|
||||
type: nodeType('stair-segment'),
|
||||
material: MaterialSchema.optional(),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
// Rotation around Y axis in radians
|
||||
rotation: z.number().default(0),
|
||||
// Stair or landing
|
||||
segmentType: StairSegmentType.default('stair'),
|
||||
// Width of the stair flight / landing
|
||||
width: z.number().default(1.0),
|
||||
// Horizontal run (depth along travel direction)
|
||||
length: z.number().default(3.0),
|
||||
// Vertical rise (0 for landings)
|
||||
height: z.number().default(2.5),
|
||||
// Number of steps (only used for stair type)
|
||||
stepCount: z.number().default(10),
|
||||
// Which side of the previous segment to attach to
|
||||
attachmentSide: AttachmentSide.default('front'),
|
||||
// Whether to fill the underside down to floor level
|
||||
fillToFloor: z.boolean().default(true),
|
||||
// Thickness of the stair slab when not filled to floor
|
||||
thickness: z.number().default(0.25),
|
||||
}).describe(
|
||||
dedent`
|
||||
Stair segment node - an individual flight or landing within a stair group.
|
||||
Each segment generates a complete stair/landing geometry.
|
||||
Multiple segments chain together to form complex staircase shapes (L-shape, U-shape, etc.).
|
||||
- segmentType: stair (with steps) or landing (flat platform)
|
||||
- width: width of the flight/landing
|
||||
- length: horizontal run distance
|
||||
- height: vertical rise (0 for landings)
|
||||
- stepCount: number of steps (stair type only)
|
||||
- attachmentSide: front, left, or right - which side of the previous segment to attach to
|
||||
- fillToFloor: whether to fill the underside down to the absolute floor level
|
||||
- thickness: slab thickness when not filled to floor
|
||||
`,
|
||||
)
|
||||
|
||||
export type StairSegmentNode = z.infer<typeof StairSegmentNode>
|
||||
@@ -0,0 +1,27 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { MaterialSchema } from '../material'
|
||||
import { StairSegmentNode } from './stair-segment'
|
||||
|
||||
export const StairNode = BaseNode.extend({
|
||||
id: objectId('stair'),
|
||||
type: nodeType('stair'),
|
||||
material: MaterialSchema.optional(),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
// Rotation around Y axis in radians
|
||||
rotation: z.number().default(0),
|
||||
// Child stair segment IDs
|
||||
children: z.array(StairSegmentNode.shape.id).default([]),
|
||||
}).describe(
|
||||
dedent`
|
||||
Stair node - a container for stair segments.
|
||||
Acts as a group that holds one or more StairSegmentNodes (flights and landings).
|
||||
Segments chain together based on their attachmentSide to form complex staircase shapes.
|
||||
- position: center position of the stair group
|
||||
- rotation: rotation around Y axis
|
||||
- children: array of StairSegmentNode IDs
|
||||
`,
|
||||
)
|
||||
|
||||
export type StairNode = z.infer<typeof StairNode>
|
||||
@@ -10,6 +10,8 @@ import { RoofSegmentNode } from './nodes/roof-segment'
|
||||
import { ScanNode } from './nodes/scan'
|
||||
import { SiteNode } from './nodes/site'
|
||||
import { SlabNode } from './nodes/slab'
|
||||
import { StairNode } from './nodes/stair'
|
||||
import { StairSegmentNode } from './nodes/stair-segment'
|
||||
import { WallNode } from './nodes/wall'
|
||||
import { WindowNode } from './nodes/window'
|
||||
import { ZoneNode } from './nodes/zone'
|
||||
@@ -25,6 +27,8 @@ export const AnyNode = z.discriminatedUnion('type', [
|
||||
CeilingNode,
|
||||
RoofNode,
|
||||
RoofSegmentNode,
|
||||
StairNode,
|
||||
StairSegmentNode,
|
||||
ScanNode,
|
||||
GuideNode,
|
||||
WindowNode,
|
||||
|
||||
@@ -13,6 +13,7 @@ export const createNodesAction = (
|
||||
get: () => SceneState,
|
||||
ops: { node: AnyNode; parentId?: AnyNodeId }[],
|
||||
) => {
|
||||
if (get().readOnly) return
|
||||
set((state) => {
|
||||
const nextNodes = { ...state.nodes }
|
||||
const nextRootIds = [...state.rootNodeIds]
|
||||
@@ -61,6 +62,7 @@ export const updateNodesAction = (
|
||||
get: () => SceneState,
|
||||
updates: { id: AnyNodeId; data: Partial<AnyNode> }[],
|
||||
) => {
|
||||
if (get().readOnly) return
|
||||
const parentsToUpdate = new Set<AnyNodeId>()
|
||||
const idsToMarkDirty = new Set<AnyNodeId>()
|
||||
|
||||
@@ -136,6 +138,7 @@ export const deleteNodesAction = (
|
||||
get: () => SceneState,
|
||||
ids: AnyNodeId[],
|
||||
) => {
|
||||
if (get().readOnly) return
|
||||
const parentsToMarkDirty = new Set<AnyNodeId>()
|
||||
|
||||
set((state) => {
|
||||
|
||||
@@ -67,6 +67,10 @@ export type SceneState = {
|
||||
// 4. Relational metadata — not nodes
|
||||
collections: Record<CollectionId, Collection>
|
||||
|
||||
// 5. Read-only lock — when true all create/update/delete operations are no-ops
|
||||
readOnly: boolean
|
||||
setReadOnly: (readOnly: boolean) => void
|
||||
|
||||
// Actions
|
||||
loadScene: () => void
|
||||
clearScene: () => void
|
||||
@@ -114,6 +118,10 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
// 4. Collections
|
||||
collections: {} as Record<CollectionId, Collection>,
|
||||
|
||||
// 5. Read-only lock
|
||||
readOnly: false,
|
||||
setReadOnly: (readOnly: boolean) => set({ readOnly }),
|
||||
|
||||
unloadScene: () => {
|
||||
// Clear temporal tracking to prevent memory leaks from stale node references
|
||||
prevPastLength = 0
|
||||
@@ -208,6 +216,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
// --- COLLECTIONS ---
|
||||
|
||||
createCollection: (name, nodeIds = []) => {
|
||||
if (get().readOnly) return '' as CollectionId
|
||||
const id = generateCollectionId()
|
||||
const collection: Collection = { id, name, nodeIds }
|
||||
set((state) => {
|
||||
@@ -227,6 +236,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
},
|
||||
|
||||
deleteCollection: (id) => {
|
||||
if (get().readOnly) return
|
||||
set((state) => {
|
||||
const col = state.collections[id]
|
||||
const nextCollections = { ...state.collections }
|
||||
@@ -246,6 +256,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
},
|
||||
|
||||
updateCollection: (id, data) => {
|
||||
if (get().readOnly) return
|
||||
set((state) => {
|
||||
const col = state.collections[id]
|
||||
if (!col) return state
|
||||
@@ -254,6 +265,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
},
|
||||
|
||||
addToCollection: (id, nodeId) => {
|
||||
if (get().readOnly) return
|
||||
set((state) => {
|
||||
const col = state.collections[id]
|
||||
if (!col || col.nodeIds.includes(nodeId)) return state
|
||||
@@ -274,12 +286,13 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
},
|
||||
|
||||
removeFromCollection: (id, nodeId) => {
|
||||
if (get().readOnly) return
|
||||
set((state) => {
|
||||
const col = state.collections[id]
|
||||
if (!col) return state
|
||||
const nextCollections = {
|
||||
...state.collections,
|
||||
[id]: { ...col, nodeIds: col.nodeIds.filter((n) => n !== nodeId) },
|
||||
[id]: { ...col, nodeIds: col.nodeIds.filter((n: AnyNodeId) => n !== nodeId) },
|
||||
}
|
||||
const node = state.nodes[nodeId]
|
||||
if (!(node && 'collectionIds' in node)) return { collections: nextCollections }
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import * as THREE from 'three'
|
||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||
import type { AnyNode, AnyNodeId, StairNode, StairSegmentNode } from '../../schema'
|
||||
import useScene from '../../store/use-scene'
|
||||
|
||||
const pendingStairUpdates = new Set<AnyNodeId>()
|
||||
const MAX_STAIRS_PER_FRAME = 2
|
||||
const MAX_SEGMENTS_PER_FRAME = 4
|
||||
|
||||
// ============================================================================
|
||||
// STAIR SYSTEM
|
||||
// ============================================================================
|
||||
|
||||
export const StairSystem = () => {
|
||||
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
||||
const clearDirty = useScene((state) => state.clearDirty)
|
||||
const rootNodeIds = useScene((state) => state.rootNodeIds)
|
||||
|
||||
useFrame(() => {
|
||||
if (rootNodeIds.length === 0) {
|
||||
pendingStairUpdates.clear()
|
||||
return
|
||||
}
|
||||
|
||||
if (dirtyNodes.size === 0 && pendingStairUpdates.size === 0) return
|
||||
|
||||
const nodes = useScene.getState().nodes
|
||||
|
||||
// --- Pass 1: Process dirty stair-segments (throttled) ---
|
||||
// Collect parent stair IDs that need segment transform recomputation
|
||||
const parentsNeedingSegmentSync = new Set<AnyNodeId>()
|
||||
|
||||
let segmentsProcessed = 0
|
||||
dirtyNodes.forEach((id) => {
|
||||
const node = nodes[id]
|
||||
if (!node) return
|
||||
|
||||
if (node.type === 'stair-segment') {
|
||||
const mesh = sceneRegistry.nodes.get(id) as THREE.Mesh
|
||||
if (mesh) {
|
||||
const isVisible = mesh.parent?.visible !== false
|
||||
if (isVisible && segmentsProcessed < MAX_SEGMENTS_PER_FRAME) {
|
||||
// Geometry will be updated; chained position is applied in the parent sync pass below
|
||||
updateStairSegmentGeometry(node as StairSegmentNode, mesh)
|
||||
if (node.parentId) parentsNeedingSegmentSync.add(node.parentId as AnyNodeId)
|
||||
segmentsProcessed++
|
||||
} else if (isVisible) {
|
||||
return // Over budget — keep dirty, process next frame
|
||||
} else if (mesh.geometry.type === 'BoxGeometry') {
|
||||
// Replace BoxGeometry placeholder with empty geometry
|
||||
mesh.geometry.dispose()
|
||||
const placeholder = new THREE.BufferGeometry()
|
||||
placeholder.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
||||
mesh.geometry = placeholder
|
||||
}
|
||||
clearDirty(id as AnyNodeId)
|
||||
} else {
|
||||
clearDirty(id as AnyNodeId)
|
||||
}
|
||||
// Queue the parent stair for a merged geometry update
|
||||
if (node.parentId) {
|
||||
pendingStairUpdates.add(node.parentId as AnyNodeId)
|
||||
}
|
||||
} else if (node.type === 'stair') {
|
||||
pendingStairUpdates.add(id as AnyNodeId)
|
||||
// Also sync individual segment positions when in edit mode
|
||||
parentsNeedingSegmentSync.add(id as AnyNodeId)
|
||||
clearDirty(id as AnyNodeId)
|
||||
}
|
||||
})
|
||||
|
||||
// --- Pass 1b: Sync chained transforms to individual segment meshes (edit mode) ---
|
||||
for (const stairId of parentsNeedingSegmentSync) {
|
||||
const stairNode = nodes[stairId]
|
||||
if (!stairNode || stairNode.type !== 'stair') continue
|
||||
syncSegmentMeshTransforms(stairNode as StairNode, nodes)
|
||||
}
|
||||
|
||||
// --- Pass 2: Process pending merged-stair updates (throttled) ---
|
||||
let stairsProcessed = 0
|
||||
for (const id of pendingStairUpdates) {
|
||||
if (stairsProcessed >= MAX_STAIRS_PER_FRAME) break
|
||||
|
||||
const node = nodes[id]
|
||||
if (!node || node.type !== 'stair') {
|
||||
pendingStairUpdates.delete(id)
|
||||
continue
|
||||
}
|
||||
const group = sceneRegistry.nodes.get(id) as THREE.Group
|
||||
if (group) {
|
||||
const mergedMesh = group.getObjectByName('merged-stair') as THREE.Mesh | undefined
|
||||
if (mergedMesh?.visible !== false) {
|
||||
updateMergedStairGeometry(node as StairNode, group, nodes)
|
||||
stairsProcessed++
|
||||
}
|
||||
}
|
||||
pendingStairUpdates.delete(id)
|
||||
}
|
||||
}, 5)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SEGMENT GEOMETRY
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Generates the step/landing profile as a THREE.Shape (in the XY plane),
|
||||
* then extrudes along Z for the segment width.
|
||||
*/
|
||||
function generateStairSegmentGeometry(
|
||||
segment: StairSegmentNode,
|
||||
absoluteHeight: number,
|
||||
): THREE.BufferGeometry {
|
||||
const { width, length, height, stepCount, segmentType, fillToFloor, thickness } = segment
|
||||
|
||||
const shape = new THREE.Shape()
|
||||
|
||||
if (segmentType === 'landing') {
|
||||
shape.moveTo(0, 0)
|
||||
shape.lineTo(length, 0)
|
||||
|
||||
if (fillToFloor) {
|
||||
shape.lineTo(length, -absoluteHeight)
|
||||
shape.lineTo(0, -absoluteHeight)
|
||||
} else {
|
||||
shape.lineTo(length, -thickness)
|
||||
shape.lineTo(0, -thickness)
|
||||
}
|
||||
} else {
|
||||
const riserHeight = height / stepCount
|
||||
const treadDepth = length / stepCount
|
||||
|
||||
shape.moveTo(0, 0)
|
||||
|
||||
// Draw step profile
|
||||
for (let i = 0; i < stepCount; i++) {
|
||||
shape.lineTo(i * treadDepth, (i + 1) * riserHeight)
|
||||
shape.lineTo((i + 1) * treadDepth, (i + 1) * riserHeight)
|
||||
}
|
||||
|
||||
if (fillToFloor) {
|
||||
shape.lineTo(length, -absoluteHeight)
|
||||
shape.lineTo(0, -absoluteHeight)
|
||||
} else {
|
||||
// Sloped bottom with consistent thickness
|
||||
const angle = Math.atan(riserHeight / treadDepth)
|
||||
const vOff = thickness / Math.cos(angle)
|
||||
|
||||
// Bottom-back corner
|
||||
shape.lineTo(length, height - vOff)
|
||||
|
||||
if (absoluteHeight === 0) {
|
||||
// Ground floor: slope hits the ground (y=0)
|
||||
const m = riserHeight / treadDepth
|
||||
const xGround = length - (height - vOff) / m
|
||||
|
||||
if (xGround > 0) {
|
||||
shape.lineTo(xGround, 0)
|
||||
}
|
||||
} else {
|
||||
// Floating: parallel slope
|
||||
shape.lineTo(0, -vOff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
shape.lineTo(0, 0)
|
||||
|
||||
const geometry = new THREE.ExtrudeGeometry(shape, {
|
||||
steps: 1,
|
||||
depth: width,
|
||||
bevelEnabled: false,
|
||||
})
|
||||
|
||||
// Rotate so extrusion is along X (width), and the shape is in the XZ plane
|
||||
// Shape is drawn in XY, extruded along Z → rotate -90° around Y then offset
|
||||
const matrix = new THREE.Matrix4()
|
||||
matrix.makeRotationY(-Math.PI / 2)
|
||||
matrix.setPosition(width / 2, 0, 0)
|
||||
geometry.applyMatrix4(matrix)
|
||||
|
||||
return geometry
|
||||
}
|
||||
|
||||
function updateStairSegmentGeometry(node: StairSegmentNode, mesh: THREE.Mesh) {
|
||||
// Compute absolute height from parent chain
|
||||
const absoluteHeight = computeAbsoluteHeight(node)
|
||||
|
||||
const newGeometry = generateStairSegmentGeometry(node, absoluteHeight)
|
||||
|
||||
mesh.geometry.dispose()
|
||||
mesh.geometry = newGeometry
|
||||
|
||||
// NOTE: position/rotation are NOT set here — they're set by syncSegmentMeshTransforms
|
||||
// which computes the chained position based on segment order and attachmentSide.
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies chained transforms to individual segment meshes (edit mode).
|
||||
* Each segment's world position is determined by the chain of previous segments,
|
||||
* not by the node's stored position field.
|
||||
*/
|
||||
function syncSegmentMeshTransforms(stairNode: StairNode, nodes: Record<string, AnyNode>) {
|
||||
const segments = (stairNode.children ?? [])
|
||||
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
|
||||
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
|
||||
|
||||
if (segments.length === 0) return
|
||||
|
||||
const transforms = computeSegmentTransforms(segments)
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i]!
|
||||
const transform = transforms[i]!
|
||||
const mesh = sceneRegistry.nodes.get(segment.id) as THREE.Mesh | undefined
|
||||
if (mesh) {
|
||||
mesh.position.set(transform.position[0], transform.position[1], transform.position[2])
|
||||
mesh.rotation.y = transform.rotation
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MERGED STAIR GEOMETRY
|
||||
// ============================================================================
|
||||
|
||||
const _matrix = new THREE.Matrix4()
|
||||
const _position = new THREE.Vector3()
|
||||
const _quaternion = new THREE.Quaternion()
|
||||
const _scale = new THREE.Vector3(1, 1, 1)
|
||||
const _yAxis = new THREE.Vector3(0, 1, 0)
|
||||
|
||||
function updateMergedStairGeometry(
|
||||
stairNode: StairNode,
|
||||
group: THREE.Group,
|
||||
nodes: Record<string, AnyNode>,
|
||||
) {
|
||||
const mergedMesh = group.getObjectByName('merged-stair') as THREE.Mesh | undefined
|
||||
if (!mergedMesh) return
|
||||
|
||||
const children = stairNode.children ?? []
|
||||
const segments = children
|
||||
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
|
||||
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
|
||||
|
||||
if (segments.length === 0) {
|
||||
mergedMesh.geometry.dispose()
|
||||
mergedMesh.geometry = new THREE.BufferGeometry()
|
||||
mergedMesh.geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
||||
return
|
||||
}
|
||||
|
||||
// Compute chained transforms for segments
|
||||
const transforms = computeSegmentTransforms(segments)
|
||||
|
||||
const geometries: THREE.BufferGeometry[] = []
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i]!
|
||||
const transform = transforms[i]!
|
||||
|
||||
const absoluteHeight = transform.position[1]
|
||||
const geo = generateStairSegmentGeometry(segment, absoluteHeight)
|
||||
|
||||
// Apply segment transform (position + rotation) relative to parent stair
|
||||
_position.set(transform.position[0], transform.position[1], transform.position[2])
|
||||
_quaternion.setFromAxisAngle(_yAxis, transform.rotation)
|
||||
_matrix.compose(_position, _quaternion, _scale)
|
||||
geo.applyMatrix4(_matrix)
|
||||
|
||||
geometries.push(geo)
|
||||
}
|
||||
|
||||
const merged = mergeGeometries(geometries, false)
|
||||
if (merged) {
|
||||
mergedMesh.geometry.dispose()
|
||||
mergedMesh.geometry = merged
|
||||
}
|
||||
|
||||
// Dispose individual geometries
|
||||
for (const geo of geometries) {
|
||||
geo.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SEGMENT CHAINING
|
||||
// ============================================================================
|
||||
|
||||
interface SegmentTransform {
|
||||
position: [number, number, number]
|
||||
rotation: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes world-relative transforms for each segment by chaining
|
||||
* based on attachmentSide. This mirrors the prototype's StairSystem logic.
|
||||
*/
|
||||
function computeSegmentTransforms(segments: StairSegmentNode[]): SegmentTransform[] {
|
||||
const transforms: SegmentTransform[] = []
|
||||
let currentPos = new THREE.Vector3(0, 0, 0)
|
||||
let currentRot = 0
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const segment = segments[i]!
|
||||
|
||||
if (i === 0) {
|
||||
transforms.push({
|
||||
position: [currentPos.x, currentPos.y, currentPos.z],
|
||||
rotation: currentRot,
|
||||
})
|
||||
} else {
|
||||
const prev = segments[i - 1]!
|
||||
const localAttachPos = new THREE.Vector3()
|
||||
let rotChange = 0
|
||||
|
||||
switch (segment.attachmentSide) {
|
||||
case 'front':
|
||||
localAttachPos.set(0, prev.height, prev.length)
|
||||
rotChange = 0
|
||||
break
|
||||
case 'left':
|
||||
localAttachPos.set(prev.width / 2, prev.height, prev.length / 2)
|
||||
rotChange = Math.PI / 2
|
||||
break
|
||||
case 'right':
|
||||
localAttachPos.set(-prev.width / 2, prev.height, prev.length / 2)
|
||||
rotChange = -Math.PI / 2
|
||||
break
|
||||
}
|
||||
|
||||
// Rotate local attachment point by previous global rotation
|
||||
localAttachPos.applyAxisAngle(new THREE.Vector3(0, 1, 0), currentRot)
|
||||
currentPos = currentPos.clone().add(localAttachPos)
|
||||
currentRot += rotChange
|
||||
|
||||
transforms.push({
|
||||
position: [currentPos.x, currentPos.y, currentPos.z],
|
||||
rotation: currentRot,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return transforms
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the absolute Y height of a segment by traversing the stair's segment chain.
|
||||
*/
|
||||
function computeAbsoluteHeight(node: StairSegmentNode): number {
|
||||
const nodes = useScene.getState().nodes
|
||||
if (!node.parentId) return 0
|
||||
|
||||
const parent = nodes[node.parentId as AnyNodeId]
|
||||
if (!parent || parent.type !== 'stair') return 0
|
||||
|
||||
const stair = parent as StairNode
|
||||
const segments = (stair.children ?? [])
|
||||
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
|
||||
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
|
||||
|
||||
const transforms = computeSegmentTransforms(segments)
|
||||
const index = segments.findIndex((s) => s.id === node.id)
|
||||
if (index < 0) return 0
|
||||
|
||||
return transforms[index]?.position[1] ?? 0
|
||||
}
|
||||
@@ -118,3 +118,160 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
||||
...(clonedCollections && { collections: clonedCollections }),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep clones a level node and all its descendants with fresh IDs.
|
||||
* All internal references (parentId, children, wallId) are remapped to the new IDs.
|
||||
* The cloned level node's parentId is preserved (building ID) — not remapped.
|
||||
*
|
||||
* Unlike `cloneSceneGraph` (which operates on serialized data), this function works
|
||||
* on live runtime nodes that may have non-serializable properties (Three.js objects,
|
||||
* etc.). It uses JSON roundtrip to safely strip them.
|
||||
*
|
||||
* @returns clonedNodes - flat array of all cloned nodes (level + descendants)
|
||||
* @returns newLevelId - the ID of the cloned level node
|
||||
* @returns idMap - old ID → new ID mapping
|
||||
*/
|
||||
export function cloneLevelSubtree(
|
||||
nodes: Record<AnyNodeId, AnyNode>,
|
||||
levelId: AnyNodeId,
|
||||
): { clonedNodes: AnyNode[]; newLevelId: AnyNodeId; idMap: Map<string, string> } {
|
||||
const levelNode = nodes[levelId]
|
||||
if (!levelNode || levelNode.type !== 'level') {
|
||||
throw new Error(`Node "${levelId}" is not a level`)
|
||||
}
|
||||
|
||||
// Recursively collect the level node + all descendants via children arrays
|
||||
const subtreeIds = new Set<AnyNodeId>()
|
||||
const collect = (id: AnyNodeId) => {
|
||||
if (subtreeIds.has(id)) return
|
||||
const node = nodes[id]
|
||||
if (!node) return
|
||||
subtreeIds.add(id)
|
||||
if ('children' in node && Array.isArray(node.children)) {
|
||||
for (const childId of node.children as AnyNodeId[]) {
|
||||
collect(childId)
|
||||
}
|
||||
}
|
||||
}
|
||||
collect(levelId)
|
||||
|
||||
// Build ID mapping: old → new
|
||||
const idMap = new Map<string, string>()
|
||||
for (const oldId of subtreeIds) {
|
||||
const prefix = extractIdPrefix(oldId)
|
||||
idMap.set(oldId, generateId(prefix))
|
||||
}
|
||||
|
||||
const newLevelId = idMap.get(levelId)! as AnyNodeId
|
||||
|
||||
// Clone each node with remapped references.
|
||||
const clonedNodes: AnyNode[] = []
|
||||
for (const oldId of subtreeIds) {
|
||||
const node = nodes[oldId]
|
||||
if (!node) continue
|
||||
|
||||
const newId = idMap.get(oldId)! as AnyNodeId
|
||||
|
||||
// JSON roundtrip: safely strips functions, Object3D, circular refs, etc.
|
||||
const cloned = JSON.parse(JSON.stringify(node)) as AnyNode
|
||||
;(cloned as Record<string, unknown>).id = newId
|
||||
|
||||
// Remap parentId — but only for descendants, not the level node itself
|
||||
if (oldId !== levelId && cloned.parentId && typeof cloned.parentId === 'string') {
|
||||
cloned.parentId = (idMap.get(cloned.parentId) ?? cloned.parentId) as AnyNodeId | null
|
||||
}
|
||||
|
||||
// Remap children array
|
||||
if ('children' in cloned && Array.isArray(cloned.children)) {
|
||||
;(cloned as Record<string, unknown>).children = (cloned.children as unknown[])
|
||||
.map((child) => {
|
||||
if (typeof child === 'string') return idMap.get(child) ?? child
|
||||
if (
|
||||
child &&
|
||||
typeof child === 'object' &&
|
||||
'id' in child &&
|
||||
typeof (child as any).id === 'string'
|
||||
) {
|
||||
return idMap.get((child as any).id) ?? (child as any).id
|
||||
}
|
||||
return child
|
||||
})
|
||||
.filter((id): id is string => typeof id === 'string')
|
||||
}
|
||||
|
||||
// Remap wallId (doors/windows attached to walls)
|
||||
if ('wallId' in cloned && typeof cloned.wallId === 'string') {
|
||||
;(cloned as Record<string, unknown>).wallId = idMap.get(cloned.wallId) ?? cloned.wallId
|
||||
}
|
||||
|
||||
clonedNodes.push(cloned)
|
||||
}
|
||||
|
||||
return { clonedNodes, newLevelId, idMap }
|
||||
}
|
||||
|
||||
/**
|
||||
* Forks a scene graph for use as a new project: clones with new IDs and strips
|
||||
* scan and guide nodes (and their references) since those contain user-uploaded
|
||||
* imagery that shouldn't carry over to a forked project.
|
||||
*/
|
||||
export function forkSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
||||
const { nodes, rootNodeIds, collections } = sceneGraph
|
||||
|
||||
const excludedNodeIds = new Set<string>()
|
||||
for (const [nodeId, node] of Object.entries(nodes)) {
|
||||
if (node.type === 'scan' || node.type === 'guide') {
|
||||
excludedNodeIds.add(nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredNodes = {} as Record<AnyNodeId, AnyNode>
|
||||
for (const [nodeId, node] of Object.entries(nodes)) {
|
||||
if (excludedNodeIds.has(nodeId)) continue
|
||||
|
||||
const clonedNode = structuredClone(node) as AnyNode
|
||||
|
||||
if ('children' in clonedNode && Array.isArray(clonedNode.children)) {
|
||||
;(clonedNode as Record<string, unknown>).children = (clonedNode.children as unknown[]).filter(
|
||||
(child) => {
|
||||
const childId =
|
||||
typeof child === 'string'
|
||||
? child
|
||||
: child && typeof child === 'object' && 'id' in child
|
||||
? (child as any).id
|
||||
: null
|
||||
return childId ? !excludedNodeIds.has(childId) : true
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
filteredNodes[nodeId as AnyNodeId] = clonedNode
|
||||
}
|
||||
|
||||
const filteredRootNodeIds = rootNodeIds.filter((id) => !excludedNodeIds.has(id))
|
||||
|
||||
let filteredCollections: Record<CollectionId, Collection> | undefined
|
||||
if (collections) {
|
||||
filteredCollections = {} as Record<CollectionId, Collection>
|
||||
for (const [collectionId, collection] of Object.entries(collections)) {
|
||||
const filteredNodeIds = collection.nodeIds.filter((id) => !excludedNodeIds.has(id))
|
||||
if (filteredNodeIds.length > 0) {
|
||||
filteredCollections[collectionId as CollectionId] = {
|
||||
...collection,
|
||||
nodeIds: filteredNodeIds as AnyNodeId[],
|
||||
controlNodeId:
|
||||
collection.controlNodeId && excludedNodeIds.has(collection.controlNodeId)
|
||||
? undefined
|
||||
: collection.controlNodeId,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cloneSceneGraph({
|
||||
nodes: filteredNodes,
|
||||
rootNodeIds: filteredRootNodeIds,
|
||||
...(filteredCollections && { collections: filteredCollections }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { initSFXBus } from '../../lib/sfx-bus'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
|
||||
import { RoofEditSystem } from '../systems/roof/roof-edit-system'
|
||||
import { StairEditSystem } from '../systems/stair/stair-edit-system'
|
||||
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
|
||||
import { ZoneSystem } from '../systems/zone/zone-system'
|
||||
import { BoxSelectTool } from '../tools/select/box-select-tool'
|
||||
@@ -600,6 +601,7 @@ export default function Editor({
|
||||
{isFirstPersonMode ? <ViewerZoneSystem /> : <ZoneSystem />}
|
||||
<CeilingSystem />
|
||||
<RoofEditSystem />
|
||||
<StairEditSystem />
|
||||
{!isLoading && !isFirstPersonMode && <Grid cellColor="#aaa" fadeDistance={500} sectionColor="#ccc" />}
|
||||
{!isLoading && !isFirstPersonMode && <ToolManager />}
|
||||
<CustomCameraControls />
|
||||
@@ -617,6 +619,7 @@ export default function Editor({
|
||||
<ViewerZoneSystem />
|
||||
<CeilingSystem />
|
||||
<RoofEditSystem />
|
||||
<StairEditSystem />
|
||||
<CustomCameraControls />
|
||||
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
||||
<PresetThumbnailGenerator />
|
||||
|
||||
@@ -32,6 +32,8 @@ type SelectableNodeType =
|
||||
| 'ceiling'
|
||||
| 'roof'
|
||||
| 'roof-segment'
|
||||
| 'stair'
|
||||
| 'stair-segment'
|
||||
| 'window'
|
||||
| 'door'
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { type AnyNodeId, type StairNode, sceneRegistry, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Imperatively toggles the Three.js visibility of stair objects based on the
|
||||
* editor selection — without causing React re-renders in StairRenderer.
|
||||
*
|
||||
* When a stair (or one of its segments) is selected:
|
||||
* - merged-stair mesh is hidden
|
||||
* - segments-wrapper group is shown (individual segments visible for editing)
|
||||
* - all children are marked dirty so StairSystem rebuilds their geometry
|
||||
*
|
||||
* When deselected:
|
||||
* - merged-stair mesh is shown
|
||||
* - segments-wrapper group is hidden
|
||||
*/
|
||||
export const StairEditSystem = () => {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const prevActiveStairIds = useRef(new Set<string>())
|
||||
|
||||
useEffect(() => {
|
||||
const nodes = useScene.getState().nodes
|
||||
|
||||
// Collect which stair nodes should be in "edit mode"
|
||||
const activeStairIds = new Set<string>()
|
||||
for (const id of selectedIds) {
|
||||
const node = nodes[id as AnyNodeId]
|
||||
if (!node) continue
|
||||
if (node.type === 'stair') {
|
||||
activeStairIds.add(id)
|
||||
} else if (node.type === 'stair-segment' && node.parentId) {
|
||||
activeStairIds.add(node.parentId)
|
||||
}
|
||||
}
|
||||
|
||||
// Update all stairs that are currently active OR were previously active
|
||||
const stairIdsToUpdate = new Set([...activeStairIds, ...prevActiveStairIds.current])
|
||||
|
||||
for (const stairId of stairIdsToUpdate) {
|
||||
const group = sceneRegistry.nodes.get(stairId)
|
||||
if (!group) continue
|
||||
|
||||
const mergedMesh = group.getObjectByName('merged-stair')
|
||||
const segmentsWrapper = group.getObjectByName('segments-wrapper')
|
||||
const isActive = activeStairIds.has(stairId)
|
||||
|
||||
if (mergedMesh) mergedMesh.visible = !isActive
|
||||
if (segmentsWrapper) segmentsWrapper.visible = isActive
|
||||
|
||||
const stairNode = nodes[stairId as AnyNodeId] as StairNode | undefined
|
||||
if (stairNode?.children?.length) {
|
||||
const wasActive = prevActiveStairIds.current.has(stairId)
|
||||
if (isActive !== wasActive) {
|
||||
// Entering edit mode: rebuild individual segment geometries
|
||||
// Exiting edit mode: sync transforms + rebuild merged mesh
|
||||
const { markDirty } = useScene.getState()
|
||||
for (const childId of stairNode.children) {
|
||||
markDirty(childId as AnyNodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prevActiveStairIds.current = activeStairIds
|
||||
}, [selectedIds])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
emitter,
|
||||
type GridEvent,
|
||||
type LevelNode,
|
||||
StairNode,
|
||||
StairSegmentNode,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
|
||||
const GRID_OFFSET = 0.02
|
||||
|
||||
// Default stair segment dimensions
|
||||
const DEFAULT_WIDTH = 1.0
|
||||
const DEFAULT_LENGTH = 3.0
|
||||
const DEFAULT_HEIGHT = 2.5
|
||||
const DEFAULT_STEP_COUNT = 10
|
||||
|
||||
/**
|
||||
* Generates the step-profile geometry for the ghost preview.
|
||||
* Same algorithm as StairSystem's generateStairSegmentGeometry.
|
||||
*/
|
||||
function createStairPreviewGeometry(): THREE.BufferGeometry {
|
||||
const riserHeight = DEFAULT_HEIGHT / DEFAULT_STEP_COUNT
|
||||
const treadDepth = DEFAULT_LENGTH / DEFAULT_STEP_COUNT
|
||||
|
||||
const shape = new THREE.Shape()
|
||||
shape.moveTo(0, 0)
|
||||
|
||||
for (let i = 0; i < DEFAULT_STEP_COUNT; i++) {
|
||||
shape.lineTo(i * treadDepth, (i + 1) * riserHeight)
|
||||
shape.lineTo((i + 1) * treadDepth, (i + 1) * riserHeight)
|
||||
}
|
||||
|
||||
// Fill to floor (absoluteHeight = 0)
|
||||
shape.lineTo(DEFAULT_LENGTH, 0)
|
||||
shape.lineTo(0, 0)
|
||||
|
||||
const geometry = new THREE.ExtrudeGeometry(shape, {
|
||||
steps: 1,
|
||||
depth: DEFAULT_WIDTH,
|
||||
bevelEnabled: false,
|
||||
})
|
||||
|
||||
// Rotate so extrusion is along X (width), shape profile in XZ plane
|
||||
const matrix = new THREE.Matrix4()
|
||||
matrix.makeRotationY(-Math.PI / 2)
|
||||
matrix.setPosition(DEFAULT_WIDTH / 2, 0, 0)
|
||||
geometry.applyMatrix4(matrix)
|
||||
|
||||
return geometry
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a stair group with one default stair segment at the given position/rotation.
|
||||
*/
|
||||
function commitStairPlacement(
|
||||
levelId: LevelNode['id'],
|
||||
position: [number, number, number],
|
||||
rotation: number,
|
||||
): void {
|
||||
const { createNodes, nodes } = useScene.getState()
|
||||
|
||||
const stairCount = Object.values(nodes).filter((n) => n.type === 'stair').length
|
||||
const name = `Staircase ${stairCount + 1}`
|
||||
|
||||
const segment = StairSegmentNode.parse({
|
||||
segmentType: 'stair',
|
||||
width: DEFAULT_WIDTH,
|
||||
length: DEFAULT_LENGTH,
|
||||
height: DEFAULT_HEIGHT,
|
||||
stepCount: DEFAULT_STEP_COUNT,
|
||||
attachmentSide: 'front',
|
||||
fillToFloor: true,
|
||||
position: [0, 0, 0],
|
||||
})
|
||||
|
||||
const stair = StairNode.parse({
|
||||
name,
|
||||
position,
|
||||
rotation,
|
||||
children: [segment.id],
|
||||
})
|
||||
|
||||
createNodes([
|
||||
{ node: stair, parentId: levelId },
|
||||
{ node: segment, parentId: stair.id },
|
||||
])
|
||||
|
||||
sfxEmitter.emit('sfx:structure-build')
|
||||
}
|
||||
|
||||
export const StairTool: React.FC = () => {
|
||||
const cursorRef = useRef<THREE.Group>(null)
|
||||
const previewRef = useRef<THREE.Group>(null)
|
||||
const rotationRef = useRef(0)
|
||||
const previousGridPosRef = useRef<[number, number] | null>(null)
|
||||
const currentLevelId = useViewer((state) => state.selection.levelId)
|
||||
|
||||
const previewGeometry = useMemo(() => createStairPreviewGeometry(), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentLevelId) return
|
||||
|
||||
// Reset rotation when tool activates
|
||||
rotationRef.current = 0
|
||||
if (previewRef.current) previewRef.current.rotation.y = 0
|
||||
|
||||
const onGridMove = (event: GridEvent) => {
|
||||
const gridX = Math.round(event.position[0] * 2) / 2
|
||||
const gridZ = Math.round(event.position[2] * 2) / 2
|
||||
const y = event.position[1]
|
||||
|
||||
if (cursorRef.current) {
|
||||
cursorRef.current.position.set(gridX, y + GRID_OFFSET, gridZ)
|
||||
}
|
||||
|
||||
if (previewRef.current) {
|
||||
previewRef.current.position.set(gridX, y, gridZ)
|
||||
}
|
||||
|
||||
if (
|
||||
previousGridPosRef.current &&
|
||||
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
|
||||
) {
|
||||
sfxEmitter.emit('sfx:grid-snap')
|
||||
}
|
||||
|
||||
previousGridPosRef.current = [gridX, gridZ]
|
||||
}
|
||||
|
||||
const onGridClick = (event: GridEvent) => {
|
||||
if (!currentLevelId) return
|
||||
|
||||
const gridX = Math.round(event.position[0] * 2) / 2
|
||||
const gridZ = Math.round(event.position[2] * 2) / 2
|
||||
const y = event.position[1]
|
||||
|
||||
commitStairPlacement(currentLevelId, [gridX, y, gridZ], rotationRef.current)
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
|
||||
const ROTATION_STEP = Math.PI / 4
|
||||
let rotationDelta = 0
|
||||
if (event.key === 'r' || event.key === 'R') rotationDelta = ROTATION_STEP
|
||||
else if (event.key === 't' || event.key === 'T') rotationDelta = -ROTATION_STEP
|
||||
|
||||
if (rotationDelta !== 0) {
|
||||
event.preventDefault()
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
rotationRef.current += rotationDelta
|
||||
if (previewRef.current) {
|
||||
previewRef.current.rotation.y = rotationRef.current
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onGridMove)
|
||||
emitter.on('grid:click', onGridClick)
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => {
|
||||
emitter.off('grid:move', onGridMove)
|
||||
emitter.off('grid:click', onGridClick)
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
}
|
||||
}, [currentLevelId])
|
||||
|
||||
return (
|
||||
<group>
|
||||
<CursorSphere ref={cursorRef} />
|
||||
|
||||
{/* 3D ghost preview — position/rotation updated imperatively */}
|
||||
<group ref={previewRef}>
|
||||
<mesh castShadow geometry={previewGeometry}>
|
||||
<meshStandardMaterial color="#818cf8" depthWrite={false} opacity={0.35} transparent />
|
||||
</mesh>
|
||||
</group>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { SiteBoundaryEditor } from './site/site-boundary-editor'
|
||||
import { SlabBoundaryEditor } from './slab/slab-boundary-editor'
|
||||
import { SlabHoleEditor } from './slab/slab-hole-editor'
|
||||
import { SlabTool } from './slab/slab-tool'
|
||||
import { StairTool } from './stair/stair-tool'
|
||||
import { WallTool } from './wall/wall-tool'
|
||||
import { WindowTool } from './window/window-tool'
|
||||
import { ZoneBoundaryEditor } from './zone/zone-boundary-editor'
|
||||
@@ -26,6 +27,7 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
|
||||
slab: SlabTool,
|
||||
ceiling: CeilingTool,
|
||||
roof: RoofTool,
|
||||
stair: StairTool,
|
||||
door: DoorTool,
|
||||
item: ItemTool,
|
||||
zone: ZoneTool,
|
||||
|
||||
@@ -25,6 +25,7 @@ export const tools: ToolConfig[] = [
|
||||
{ id: 'slab', iconSrc: '/icons/floor.png', label: 'Slab' },
|
||||
{ id: 'ceiling', iconSrc: '/icons/ceiling.png', label: 'Ceiling' },
|
||||
{ id: 'roof', iconSrc: '/icons/roof.png', label: 'Gable Roof' },
|
||||
{ id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
|
||||
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
|
||||
{ id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
|
||||
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
|
||||
|
||||
@@ -115,6 +115,14 @@ export function EditorCommands() {
|
||||
keywords: ['furniture', 'object', 'asset', 'furnish'],
|
||||
execute: () => activateTool('item'),
|
||||
},
|
||||
{
|
||||
id: 'editor.tool.stair',
|
||||
label: 'Stair Tool',
|
||||
group: 'Scene',
|
||||
icon: <ArrowRight className="h-4 w-4" />,
|
||||
keywords: ['stairs', 'staircase', 'flight', 'landing', 'steps'],
|
||||
execute: () => activateTool('stair'),
|
||||
},
|
||||
{
|
||||
id: 'editor.tool.zone',
|
||||
label: 'Zone Tool',
|
||||
@@ -342,7 +350,7 @@ export function EditorCommands() {
|
||||
icon: <Box className="h-4 w-4" />,
|
||||
keywords: ['export', 'glb', 'gltf', '3d', 'model', 'download'],
|
||||
execute: () => run(() => exportScene()),
|
||||
},
|
||||
} as const,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
|
||||
@@ -10,6 +10,8 @@ import { ReferencePanel } from './reference-panel'
|
||||
import { RoofPanel } from './roof-panel'
|
||||
import { RoofSegmentPanel } from './roof-segment-panel'
|
||||
import { SlabPanel } from './slab-panel'
|
||||
import { StairPanel } from './stair-panel'
|
||||
import { StairSegmentPanel } from './stair-segment-panel'
|
||||
import { WallPanel } from './wall-panel'
|
||||
import { WindowPanel } from './window-panel'
|
||||
|
||||
@@ -37,6 +39,10 @@ export function PanelManager() {
|
||||
return <RoofSegmentPanel />
|
||||
case 'slab':
|
||||
return <SlabPanel />
|
||||
case 'stair':
|
||||
return <StairPanel />
|
||||
case 'stair-segment':
|
||||
return <StairSegmentPanel />
|
||||
case 'ceiling':
|
||||
return <CeilingPanel />
|
||||
case 'wall':
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type MaterialSchema,
|
||||
type StairNode,
|
||||
StairNode as StairNodeSchema,
|
||||
type StairSegmentNode,
|
||||
StairSegmentNode as StairSegmentNodeSchema,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MaterialPicker } from '../controls/material-picker'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
export function StairPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const createNode = useScene((s) => s.createNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
|
||||
const selectedId = selectedIds[0]
|
||||
const node = selectedId
|
||||
? (nodes[selectedId as AnyNode['id']] as StairNode | undefined)
|
||||
: undefined
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<StairNode>) => {
|
||||
if (!selectedId) return
|
||||
updateNode(selectedId as AnyNode['id'], updates)
|
||||
},
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const handleMaterialChange = useCallback(
|
||||
(material: MaterialSchema) => {
|
||||
handleUpdate({ material })
|
||||
},
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
|
||||
const getLastSegmentFillDefaults = useCallback(() => {
|
||||
if (!node) return { fillToFloor: true }
|
||||
const children = node.children ?? []
|
||||
const lastChildId = children[children.length - 1]
|
||||
if (lastChildId) {
|
||||
const lastChild = nodes[lastChildId as AnyNodeId] as StairSegmentNode | undefined
|
||||
if (lastChild?.type === 'stair-segment') {
|
||||
return { fillToFloor: lastChild.fillToFloor }
|
||||
}
|
||||
}
|
||||
return { fillToFloor: true }
|
||||
}, [node, nodes])
|
||||
|
||||
const handleAddFlight = useCallback(() => {
|
||||
if (!node) return
|
||||
const { fillToFloor } = getLastSegmentFillDefaults()
|
||||
const segment = StairSegmentNodeSchema.parse({
|
||||
segmentType: 'stair',
|
||||
width: 1.0,
|
||||
length: 3.0,
|
||||
height: 2.5,
|
||||
stepCount: 10,
|
||||
attachmentSide: 'front',
|
||||
fillToFloor,
|
||||
thickness: 0.25,
|
||||
position: [0, 0, 0],
|
||||
})
|
||||
createNode(segment, node.id as AnyNodeId)
|
||||
}, [node, createNode, getLastSegmentFillDefaults])
|
||||
|
||||
const handleAddLanding = useCallback(() => {
|
||||
if (!node) return
|
||||
const { fillToFloor } = getLastSegmentFillDefaults()
|
||||
const segment = StairSegmentNodeSchema.parse({
|
||||
segmentType: 'landing',
|
||||
width: 1.0,
|
||||
length: 1.0,
|
||||
height: 0,
|
||||
stepCount: 0,
|
||||
attachmentSide: 'front',
|
||||
fillToFloor,
|
||||
thickness: 0.32,
|
||||
position: [0, 0, 0],
|
||||
})
|
||||
createNode(segment, node.id as AnyNodeId)
|
||||
}, [node, createNode, getLastSegmentFillDefaults])
|
||||
|
||||
const handleSelectSegment = useCallback(
|
||||
(segmentId: string) => {
|
||||
setSelection({ selectedIds: [segmentId as AnyNode['id']] })
|
||||
},
|
||||
[setSelection],
|
||||
)
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!node?.parentId) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
|
||||
let duplicateInfo = structuredClone(node) as any
|
||||
delete duplicateInfo.id
|
||||
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
|
||||
duplicateInfo.position = [
|
||||
duplicateInfo.position[0] + 1,
|
||||
duplicateInfo.position[1],
|
||||
duplicateInfo.position[2] + 1,
|
||||
]
|
||||
|
||||
try {
|
||||
const duplicate = StairNodeSchema.parse(duplicateInfo)
|
||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||
|
||||
// Also duplicate all child segments
|
||||
const nodesState = useScene.getState().nodes
|
||||
const children = node.children || []
|
||||
|
||||
for (const childId of 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 }
|
||||
const childDuplicate = StairSegmentNodeSchema.parse(childDuplicateInfo)
|
||||
useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
|
||||
}
|
||||
}
|
||||
|
||||
setSelection({ selectedIds: [] })
|
||||
setMovingNode(duplicate)
|
||||
} catch (e) {
|
||||
console.error('Failed to duplicate stair', e)
|
||||
}
|
||||
}, [node, setSelection, setMovingNode])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (node) {
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setMovingNode(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!(selectedId && node)) return
|
||||
sfxEmitter.emit('sfx:item-delete')
|
||||
const parentId = node.parentId
|
||||
useScene.getState().deleteNode(selectedId as AnyNodeId)
|
||||
if (parentId) {
|
||||
useScene.getState().dirtyNodes.add(parentId as AnyNodeId)
|
||||
}
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [selectedId, node, setSelection])
|
||||
|
||||
if (!node || node.type !== 'stair' || selectedIds.length !== 1) return null
|
||||
|
||||
const segments = (node.children ?? [])
|
||||
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
|
||||
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
icon="/icons/stairs.png"
|
||||
onClose={handleClose}
|
||||
title={node.name || 'Staircase'}
|
||||
width={300}
|
||||
>
|
||||
<PanelSection title="Segments">
|
||||
<div className="flex flex-col gap-1">
|
||||
{segments.map((seg, i) => (
|
||||
<button
|
||||
className="flex items-center justify-between rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-foreground text-sm transition-colors hover:bg-[#3e3e3e]"
|
||||
key={seg.id}
|
||||
onClick={() => handleSelectSegment(seg.id)}
|
||||
type="button"
|
||||
>
|
||||
<span className="truncate">{seg.name || `Segment ${i + 1}`}</span>
|
||||
<span className="text-muted-foreground text-xs capitalize">{seg.segmentType}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<ActionButton
|
||||
icon={<Plus className="h-3.5 w-3.5" />}
|
||||
label="Add flight"
|
||||
onClick={handleAddFlight}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={<Plus className="h-3.5 w-3.5" />}
|
||||
label="Add landing"
|
||||
onClick={handleAddLanding}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Position">
|
||||
<MetricControl
|
||||
label="X"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[0] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
/>
|
||||
<MetricControl
|
||||
label="Y"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[1] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
/>
|
||||
<MetricControl
|
||||
label="Z"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[2] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.position[2] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Rotation"
|
||||
max={180}
|
||||
min={-180}
|
||||
onChange={(degrees) => {
|
||||
handleUpdate({ rotation: (degrees * Math.PI) / 180 })
|
||||
}}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit="°"
|
||||
value={Math.round((node.rotation * 180) / Math.PI)}
|
||||
/>
|
||||
<div className="flex gap-1.5 px-1 pt-2 pb-1">
|
||||
<ActionButton
|
||||
label="-45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
handleUpdate({ rotation: node.rotation - Math.PI / 4 })
|
||||
}}
|
||||
/>
|
||||
<ActionButton
|
||||
label="+45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
handleUpdate({ rotation: node.rotation + Math.PI / 4 })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton
|
||||
icon={<Copy className="h-3.5 w-3.5" />}
|
||||
label="Duplicate"
|
||||
onClick={handleDuplicate}
|
||||
/>
|
||||
<ActionButton
|
||||
className="hover:bg-red-500/20"
|
||||
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
<PanelSection title="Material">
|
||||
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type AttachmentSide,
|
||||
type MaterialSchema,
|
||||
type StairSegmentNode,
|
||||
StairSegmentNode as StairSegmentNodeSchema,
|
||||
type StairSegmentType,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Copy, Move, Trash2 } from 'lucide-react'
|
||||
import { useCallback } from 'react'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MaterialPicker } from '../controls/material-picker'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SegmentedControl } from '../controls/segmented-control'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
const SEGMENT_TYPE_OPTIONS: { label: string; value: StairSegmentType }[] = [
|
||||
{ label: 'Flight', value: 'stair' },
|
||||
{ label: 'Landing', value: 'landing' },
|
||||
]
|
||||
|
||||
const ATTACHMENT_SIDE_OPTIONS: { label: string; value: AttachmentSide }[] = [
|
||||
{ label: 'Front', value: 'front' },
|
||||
{ label: 'Left', value: 'left' },
|
||||
{ label: 'Right', value: 'right' },
|
||||
]
|
||||
|
||||
export function StairSegmentPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
|
||||
const selectedId = selectedIds[0]
|
||||
const node = selectedId
|
||||
? (nodes[selectedId as AnyNode['id']] as StairSegmentNode | undefined)
|
||||
: undefined
|
||||
|
||||
// Check if this is the first segment in the parent stair
|
||||
const isFirstSegment = (() => {
|
||||
if (!node?.parentId) return true
|
||||
const parent = nodes[node.parentId as AnyNodeId]
|
||||
if (!parent || parent.type !== 'stair') return true
|
||||
const children = (parent as any).children ?? []
|
||||
return children[0] === node.id
|
||||
})()
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(updates: Partial<StairSegmentNode>) => {
|
||||
if (!selectedId) return
|
||||
updateNode(selectedId as AnyNode['id'], updates)
|
||||
},
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const handleMaterialChange = useCallback(
|
||||
(material: MaterialSchema) => {
|
||||
handleUpdate({ material })
|
||||
},
|
||||
[handleUpdate],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (node?.parentId) {
|
||||
setSelection({ selectedIds: [node.parentId] })
|
||||
}
|
||||
}, [node?.parentId, setSelection])
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
if (!node?.parentId) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
|
||||
let duplicateInfo = structuredClone(node) as any
|
||||
delete duplicateInfo.id
|
||||
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true }
|
||||
duplicateInfo.position = [
|
||||
duplicateInfo.position[0] + 1,
|
||||
duplicateInfo.position[1],
|
||||
duplicateInfo.position[2] + 1,
|
||||
]
|
||||
|
||||
try {
|
||||
const duplicate = StairSegmentNodeSchema.parse(duplicateInfo)
|
||||
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
|
||||
setSelection({ selectedIds: [] })
|
||||
setMovingNode(duplicate)
|
||||
} catch (e) {
|
||||
console.error('Failed to duplicate stair segment', e)
|
||||
}
|
||||
}, [node, setSelection, setMovingNode])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (node) {
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setMovingNode(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!(selectedId && node)) return
|
||||
sfxEmitter.emit('sfx:item-delete')
|
||||
const parentId = node.parentId
|
||||
useScene.getState().deleteNode(selectedId as AnyNodeId)
|
||||
if (parentId) {
|
||||
useScene.getState().dirtyNodes.add(parentId as AnyNodeId)
|
||||
setSelection({ selectedIds: [parentId] })
|
||||
} else {
|
||||
setSelection({ selectedIds: [] })
|
||||
}
|
||||
}, [selectedId, node, setSelection])
|
||||
|
||||
if (!node || node.type !== 'stair-segment' || selectedIds.length !== 1) return null
|
||||
|
||||
return (
|
||||
<PanelWrapper
|
||||
icon="/icons/stairs.png"
|
||||
onBack={handleBack}
|
||||
onClose={handleClose}
|
||||
title={node.name || 'Stair Segment'}
|
||||
width={300}
|
||||
>
|
||||
<PanelSection title="Type">
|
||||
<SegmentedControl
|
||||
onChange={(v) => {
|
||||
const updates: Partial<StairSegmentNode> = { segmentType: v }
|
||||
if (v === 'landing') {
|
||||
updates.height = 0
|
||||
updates.stepCount = 0
|
||||
updates.length = 1.0
|
||||
} else {
|
||||
updates.height = 2.5
|
||||
updates.stepCount = 10
|
||||
updates.length = 3.0
|
||||
}
|
||||
handleUpdate(updates)
|
||||
}}
|
||||
options={SEGMENT_TYPE_OPTIONS}
|
||||
value={node.segmentType}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
{!isFirstSegment && (
|
||||
<PanelSection title="Attachment">
|
||||
<SegmentedControl
|
||||
onChange={(v) => handleUpdate({ attachmentSide: v })}
|
||||
options={ATTACHMENT_SIDE_OPTIONS}
|
||||
value={node.attachmentSide}
|
||||
/>
|
||||
</PanelSection>
|
||||
)}
|
||||
|
||||
<PanelSection title="Dimensions">
|
||||
<SliderControl
|
||||
label="Width"
|
||||
max={5}
|
||||
min={0.5}
|
||||
onChange={(v) => handleUpdate({ width: v })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.width * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Length"
|
||||
max={10}
|
||||
min={0.5}
|
||||
onChange={(v) => handleUpdate({ length: v })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.length * 100) / 100}
|
||||
/>
|
||||
{node.segmentType === 'stair' && (
|
||||
<>
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={10}
|
||||
min={0.5}
|
||||
onChange={(v) => handleUpdate({ height: v })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
unit="m"
|
||||
value={Math.round(node.height * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Steps"
|
||||
max={30}
|
||||
min={2}
|
||||
onChange={(v) => handleUpdate({ stepCount: Math.round(v) })}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit=""
|
||||
value={node.stepCount}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Structure">
|
||||
<div className="flex items-center justify-between px-1 py-1">
|
||||
<span className="text-muted-foreground text-xs">Fill to floor</span>
|
||||
<button
|
||||
className={`relative h-5 w-10 rounded-full transition-colors ${
|
||||
node.fillToFloor ? 'bg-blue-500' : 'bg-[#3e3e3e]'
|
||||
}`}
|
||||
onClick={() => handleUpdate({ fillToFloor: !node.fillToFloor })}
|
||||
type="button"
|
||||
>
|
||||
<div
|
||||
className={`absolute top-1 h-3 w-3 rounded-full bg-white transition-transform ${
|
||||
node.fillToFloor ? 'left-6' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{!node.fillToFloor && (
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
max={1}
|
||||
min={0.05}
|
||||
onChange={(v) => handleUpdate({ thickness: v })}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round((node.thickness ?? 0.25) * 100) / 100}
|
||||
/>
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Position">
|
||||
<MetricControl
|
||||
label="X"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[0] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.position[0] * 100) / 100}
|
||||
/>
|
||||
<MetricControl
|
||||
label="Y"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[1] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.position[1] * 100) / 100}
|
||||
/>
|
||||
<MetricControl
|
||||
label="Z"
|
||||
max={50}
|
||||
min={-50}
|
||||
onChange={(v) => {
|
||||
const pos = [...node.position] as [number, number, number]
|
||||
pos[2] = v
|
||||
handleUpdate({ position: pos })
|
||||
}}
|
||||
precision={2}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={Math.round(node.position[2] * 100) / 100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Rotation"
|
||||
max={180}
|
||||
min={-180}
|
||||
onChange={(degrees) => {
|
||||
handleUpdate({ rotation: (degrees * Math.PI) / 180 })
|
||||
}}
|
||||
precision={0}
|
||||
step={1}
|
||||
unit="°"
|
||||
value={Math.round((node.rotation * 180) / Math.PI)}
|
||||
/>
|
||||
<div className="flex gap-1.5 px-1 pt-2 pb-1">
|
||||
<ActionButton
|
||||
label="-45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
handleUpdate({ rotation: node.rotation - Math.PI / 4 })
|
||||
}}
|
||||
/>
|
||||
<ActionButton
|
||||
label="+45°"
|
||||
onClick={() => {
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
handleUpdate({ rotation: node.rotation + Math.PI / 4 })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton
|
||||
icon={<Copy className="h-3.5 w-3.5" />}
|
||||
label="Duplicate"
|
||||
onClick={handleDuplicate}
|
||||
/>
|
||||
<ActionButton
|
||||
className="hover:bg-red-500/20"
|
||||
icon={<Trash2 className="h-3.5 w-3.5 text-red-400" />}
|
||||
label="Delete"
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
<PanelSection title="Material">
|
||||
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { type AnyNodeId, type StairNode, type StairSegmentNode, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { AnimatePresence } from 'motion/react'
|
||||
import Image from 'next/image'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import useEditor from '../../../../../store/use-editor'
|
||||
import { InlineRenameInput } from './inline-rename-input'
|
||||
import { focusTreeNode, handleTreeSelection, TreeNodeWrapper } from './tree-node'
|
||||
import { TreeNodeActions } from './tree-node-actions'
|
||||
import { DropIndicatorLine, useTreeNodeDrag } from './tree-node-drag'
|
||||
|
||||
interface StairTreeNodeProps {
|
||||
node: StairNode
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
export function StairTreeNode({ node, depth, isLast }: StairTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const isSelected = selectedIds.includes(node.id)
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
const { drag, dropTarget } = useTreeNodeDrag()
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
const handled = handleTreeSelection(e, node.id, selectedIds, setSelection)
|
||||
if (!handled && useEditor.getState().phase === 'furnish') {
|
||||
useEditor.getState().setPhase('structure')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDoubleClick = () => {
|
||||
focusTreeNode(node.id)
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHoveredId(node.id)
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHoveredId(null)
|
||||
}
|
||||
|
||||
const segments = (node.children ?? [])
|
||||
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
|
||||
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
|
||||
|
||||
const hasSelectedChild = segments.some((seg) => selectedIds.includes(seg.id))
|
||||
|
||||
useEffect(() => {
|
||||
if (isSelected || hasSelectedChild) {
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [isSelected, hasSelectedChild])
|
||||
|
||||
// Auto-expand when a segment is being dragged over this stair
|
||||
const isDropTarget = drag !== null && dropTarget?.parentId === node.id
|
||||
useEffect(() => {
|
||||
if (isDropTarget && !expanded) {
|
||||
setExpanded(true)
|
||||
}
|
||||
}, [isDropTarget, expanded])
|
||||
|
||||
const segmentCount = segments.length
|
||||
const defaultName = `Staircase (${segmentCount} segment${segmentCount !== 1 ? 's' : ''})`
|
||||
|
||||
// Hide the dragged segment from every stair while dragging
|
||||
const visibleSegments = drag ? segments.filter((seg) => seg.id !== drag.nodeId) : segments
|
||||
|
||||
const isValidDropTarget = drag !== null && drag.nodeId !== node.id
|
||||
|
||||
return (
|
||||
<div data-drop-target={node.id}>
|
||||
<TreeNodeWrapper
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
depth={depth}
|
||||
expanded={expanded}
|
||||
hasChildren={segments.length > 0}
|
||||
icon={
|
||||
<Image alt="" className="object-contain" height={14} src="/icons/stairs.png" width={14} />
|
||||
}
|
||||
isDropTarget={isValidDropTarget && isDropTarget}
|
||||
isHovered={isHovered || isDropTarget}
|
||||
isLast={isLast && !expanded}
|
||||
isSelected={isSelected}
|
||||
isVisible={node.visible !== false}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
defaultName={defaultName}
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
/>
|
||||
}
|
||||
nodeId={node.id}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onToggle={() => setExpanded(!expanded)}
|
||||
>
|
||||
{visibleSegments.map((seg, i) => {
|
||||
const showIndicatorBefore = isDropTarget && dropTarget?.insertIndex === i
|
||||
const showIndicatorAfter =
|
||||
isDropTarget &&
|
||||
i === visibleSegments.length - 1 &&
|
||||
dropTarget?.insertIndex !== undefined &&
|
||||
dropTarget.insertIndex > i
|
||||
|
||||
return (
|
||||
<div key={seg.id}>
|
||||
<AnimatePresence>
|
||||
{showIndicatorBefore && <DropIndicatorLine key="indicator-before" />}
|
||||
</AnimatePresence>
|
||||
<StairSegmentTreeNode
|
||||
depth={depth + 1}
|
||||
isLast={isLast && i === visibleSegments.length - 1 && !showIndicatorAfter}
|
||||
node={seg}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{showIndicatorAfter && <DropIndicatorLine key="indicator-after" />}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<AnimatePresence>
|
||||
{isDropTarget && visibleSegments.length === 0 && <DropIndicatorLine />}
|
||||
</AnimatePresence>
|
||||
</TreeNodeWrapper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StairSegmentTreeNode({
|
||||
node,
|
||||
depth,
|
||||
isLast,
|
||||
}: {
|
||||
node: StairSegmentNode
|
||||
depth: number
|
||||
isLast?: boolean
|
||||
}) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const selectedIds = useViewer((state) => state.selection.selectedIds)
|
||||
const isSelected = selectedIds.includes(node.id)
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id)
|
||||
const setSelection = useViewer((state) => state.setSelection)
|
||||
const setHoveredId = useViewer((state) => state.setHoveredId)
|
||||
const { startDrag, isDragging } = useTreeNodeDrag()
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (isDragging) return
|
||||
e.stopPropagation()
|
||||
handleTreeSelection(e, node.id, selectedIds, setSelection)
|
||||
}
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return
|
||||
const typeLabel = node.segmentType === 'stair' ? 'Flight' : 'Landing'
|
||||
const label = `${typeLabel} (${node.width.toFixed(1)}×${node.length.toFixed(1)}m)`
|
||||
startDrag(node.id, node.type, node.parentId as string, label, e.clientX, e.clientY)
|
||||
},
|
||||
[node.id, node.type, node.parentId, node.segmentType, node.width, node.length, startDrag],
|
||||
)
|
||||
|
||||
const typeLabel = node.segmentType === 'stair' ? 'Flight' : 'Landing'
|
||||
const defaultName = `${typeLabel} (${node.width.toFixed(1)}×${node.length.toFixed(1)}m)`
|
||||
|
||||
return (
|
||||
<div data-drop-child={node.id}>
|
||||
<TreeNodeWrapper
|
||||
actions={<TreeNodeActions node={node} />}
|
||||
depth={depth}
|
||||
expanded={false}
|
||||
hasChildren={false}
|
||||
icon={
|
||||
<Image
|
||||
alt=""
|
||||
className="object-contain opacity-60"
|
||||
height={14}
|
||||
src="/icons/stairs.png"
|
||||
width={14}
|
||||
/>
|
||||
}
|
||||
isDraggable
|
||||
isHovered={isHovered}
|
||||
isLast={isLast}
|
||||
isSelected={isSelected}
|
||||
isVisible={node.visible !== false}
|
||||
label={
|
||||
<InlineRenameInput
|
||||
defaultName={defaultName}
|
||||
isEditing={isEditing}
|
||||
node={node}
|
||||
onStartEditing={() => setIsEditing(true)}
|
||||
onStopEditing={() => setIsEditing(false)}
|
||||
/>
|
||||
}
|
||||
nodeId={node.id}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={() => focusTreeNode(node.id)}
|
||||
onMouseEnter={() => setHoveredId(node.id)}
|
||||
onMouseLeave={() => setHoveredId(null)}
|
||||
onPointerDown={handlePointerDown}
|
||||
onToggle={() => {}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -61,6 +61,7 @@ import { ItemTreeNode } from './item-tree-node'
|
||||
import { LevelTreeNode } from './level-tree-node'
|
||||
import { RoofTreeNode } from './roof-tree-node'
|
||||
import { SlabTreeNode } from './slab-tree-node'
|
||||
import { StairTreeNode } from './stair-tree-node'
|
||||
import { WallTreeNode } from './wall-tree-node'
|
||||
import { WindowTreeNode } from './window-tree-node'
|
||||
import { ZoneTreeNode } from './zone-tree-node'
|
||||
@@ -89,6 +90,8 @@ export function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
|
||||
return <WallTreeNode depth={depth} isLast={isLast} node={node as any} />
|
||||
case 'roof':
|
||||
return <RoofTreeNode depth={depth} isLast={isLast} node={node as any} />
|
||||
case 'stair':
|
||||
return <StairTreeNode depth={depth} isLast={isLast} node={node as any} />
|
||||
case 'item':
|
||||
return <ItemTreeNode depth={depth} isLast={isLast} node={node as any} />
|
||||
case 'door':
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
type Space,
|
||||
type StairNode,
|
||||
type StairSegmentNode,
|
||||
useScene,
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
@@ -79,7 +81,7 @@ type EditorState = {
|
||||
setCatalogCategory: (category: CatalogCategory | null) => void
|
||||
selectedItem: AssetInput | null
|
||||
setSelectedItem: (item: AssetInput) => void
|
||||
movingNode: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null
|
||||
movingNode: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | StairNode | StairSegmentNode | null
|
||||
setMovingNode: (
|
||||
node: ItemNode | WindowNode | DoorNode | RoofNode | RoofSegmentNode | null,
|
||||
) => void
|
||||
|
||||
@@ -11,8 +11,18 @@ export interface UploadEntry {
|
||||
resultUrl: string | null
|
||||
}
|
||||
|
||||
export type UploadHandler = (
|
||||
projectId: string,
|
||||
levelId: string,
|
||||
file: File,
|
||||
type: 'scan' | 'guide',
|
||||
) => void
|
||||
|
||||
interface UploadState {
|
||||
uploads: Record<string, UploadEntry>
|
||||
uploadHandler: UploadHandler | null
|
||||
registerUploadHandler: (handler: UploadHandler) => void
|
||||
unregisterUploadHandler: () => void
|
||||
startUpload: (levelId: string, assetType: 'scan' | 'guide', fileName: string) => void
|
||||
setProgress: (levelId: string, progress: number) => void
|
||||
setStatus: (levelId: string, status: UploadStatus) => void
|
||||
@@ -23,6 +33,9 @@ interface UploadState {
|
||||
|
||||
export const useUploadStore = create<UploadState>((set) => ({
|
||||
uploads: {},
|
||||
uploadHandler: null,
|
||||
registerUploadHandler: (handler) => set({ uploadHandler: handler }),
|
||||
unregisterUploadHandler: () => set({ uploadHandler: null }),
|
||||
|
||||
startUpload: (levelId, assetType, fileName) =>
|
||||
set((s) => ({
|
||||
|
||||
@@ -12,6 +12,8 @@ import { RoofSegmentRenderer } from './roof-segment/roof-segment-renderer'
|
||||
import { ScanRenderer } from './scan/scan-renderer'
|
||||
import { SiteRenderer } from './site/site-renderer'
|
||||
import { SlabRenderer } from './slab/slab-renderer'
|
||||
import { StairRenderer } from './stair/stair-renderer'
|
||||
import { StairSegmentRenderer } from './stair-segment/stair-segment-renderer'
|
||||
import { WallRenderer } from './wall/wall-renderer'
|
||||
import { WindowRenderer } from './window/window-renderer'
|
||||
import { ZoneRenderer } from './zone/zone-renderer'
|
||||
@@ -35,6 +37,8 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
|
||||
{node.type === 'zone' && <ZoneRenderer node={node} />}
|
||||
{node.type === 'roof' && <RoofRenderer node={node} />}
|
||||
{node.type === 'roof-segment' && <RoofSegmentRenderer node={node} />}
|
||||
{node.type === 'stair' && <StairRenderer node={node} />}
|
||||
{node.type === 'stair-segment' && <StairSegmentRenderer node={node} />}
|
||||
{node.type === 'scan' && <ScanRenderer node={node} />}
|
||||
{node.type === 'guide' && <GuideRenderer node={node} />}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { type StairSegmentNode, useRegistry, useScene } from '@pascal-app/core'
|
||||
import { useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import type * as THREE from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { createMaterial, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
|
||||
|
||||
export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
|
||||
const ref = useRef<THREE.Mesh>(null!)
|
||||
|
||||
useRegistry(node.id, 'stair-segment', ref)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
useScene.getState().markDirty(node.id)
|
||||
}, [node.id])
|
||||
|
||||
const handlers = useNodeEvents(node, 'stair-segment')
|
||||
|
||||
const material = useMemo(() => {
|
||||
const mat = node.material
|
||||
if (!mat) return DEFAULT_STAIR_MATERIAL
|
||||
return createMaterial(mat)
|
||||
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
|
||||
|
||||
return (
|
||||
<mesh
|
||||
material={material}
|
||||
position={node.position}
|
||||
ref={ref}
|
||||
rotation-y={node.rotation}
|
||||
visible={node.visible}
|
||||
{...handlers}
|
||||
>
|
||||
{/* StairSystem will replace this geometry in the next frame */}
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
</mesh>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { type StairNode, useRegistry, useScene } from '@pascal-app/core'
|
||||
import { useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import type * as THREE from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { createMaterial, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
|
||||
import { NodeRenderer } from '../node-renderer'
|
||||
|
||||
export const StairRenderer = ({ node }: { node: StairNode }) => {
|
||||
const ref = useRef<THREE.Group>(null!)
|
||||
|
||||
useRegistry(node.id, 'stair', ref)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
useScene.getState().markDirty(node.id)
|
||||
}, [node.id])
|
||||
|
||||
const handlers = useNodeEvents(node, 'stair')
|
||||
|
||||
const material = useMemo(() => {
|
||||
const mat = node.material
|
||||
if (!mat) return DEFAULT_STAIR_MATERIAL
|
||||
return createMaterial(mat)
|
||||
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
|
||||
|
||||
return (
|
||||
<group
|
||||
position={node.position}
|
||||
ref={ref}
|
||||
rotation-y={node.rotation}
|
||||
visible={node.visible}
|
||||
{...handlers}
|
||||
>
|
||||
<mesh castShadow material={material} name="merged-stair" receiveShadow>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
</mesh>
|
||||
<group name="segments-wrapper" visible={false}>
|
||||
{(node.children ?? []).map((childId) => (
|
||||
<NodeRenderer key={childId} nodeId={childId} />
|
||||
))}
|
||||
</group>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ItemSystem,
|
||||
RoofSystem,
|
||||
SlabSystem,
|
||||
StairSystem,
|
||||
WallSystem,
|
||||
WindowSystem,
|
||||
} from '@pascal-app/core'
|
||||
@@ -142,6 +143,7 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
<ItemSystem />
|
||||
<RoofSystem />
|
||||
<SlabSystem />
|
||||
<StairSystem />
|
||||
<WallSystem />
|
||||
<WindowSystem />
|
||||
<ZoneSystem />
|
||||
|
||||
@@ -19,6 +19,10 @@ import {
|
||||
type SiteNode,
|
||||
type SlabEvent,
|
||||
type SlabNode,
|
||||
type StairEvent,
|
||||
type StairNode,
|
||||
type StairSegmentEvent,
|
||||
type StairSegmentNode,
|
||||
type WallEvent,
|
||||
type WallNode,
|
||||
type WindowEvent,
|
||||
@@ -40,6 +44,8 @@ type NodeConfig = {
|
||||
ceiling: { node: CeilingNode; event: CeilingEvent }
|
||||
roof: { node: RoofNode; event: RoofEvent }
|
||||
'roof-segment': { node: RoofSegmentNode; event: RoofSegmentEvent }
|
||||
stair: { node: StairNode; event: StairEvent }
|
||||
'stair-segment': { node: StairSegmentNode; event: StairSegmentEvent }
|
||||
window: { node: WindowNode; event: WindowEvent }
|
||||
door: { node: DoorNode; event: DoorEvent }
|
||||
}
|
||||
|
||||
@@ -35,8 +35,8 @@ export function createMaterial(material?: MaterialSchema): THREE.MeshStandardMat
|
||||
}
|
||||
|
||||
export function createDefaultMaterial(
|
||||
color: string = '#ffffff',
|
||||
roughness: number = 0.9,
|
||||
color = '#ffffff',
|
||||
roughness = 0.9,
|
||||
): THREE.MeshStandardMaterial {
|
||||
return new THREE.MeshStandardMaterial({
|
||||
color,
|
||||
@@ -59,6 +59,7 @@ export const DEFAULT_WINDOW_MATERIAL = new THREE.MeshStandardMaterial({
|
||||
})
|
||||
export const DEFAULT_CEILING_MATERIAL = createDefaultMaterial('#f5f5dc', 0.95)
|
||||
export const DEFAULT_ROOF_MATERIAL = createDefaultMaterial('#808080', 0.85)
|
||||
export const DEFAULT_STAIR_MATERIAL = createDefaultMaterial('#ffffff', 0.9)
|
||||
|
||||
export function disposeMaterial(material: THREE.Material): void {
|
||||
material.dispose()
|
||||
|
||||
+4
@@ -12,6 +12,10 @@ type Outliner = {
|
||||
}
|
||||
type ViewerState = {
|
||||
selection: SelectionPath
|
||||
previewSelectedIds: BaseNode['id'][]
|
||||
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
|
||||
hoverHighlightMode: 'default' | 'delete'
|
||||
setHoverHighlightMode: (mode: 'default' | 'delete') => void
|
||||
hoveredId: AnyNode['id'] | ZoneNode['id'] | null
|
||||
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
|
||||
cameraMode: 'perspective' | 'orthographic'
|
||||
|
||||
@@ -20,6 +20,10 @@ type Outliner = {
|
||||
|
||||
type ViewerState = {
|
||||
selection: SelectionPath
|
||||
previewSelectedIds: BaseNode['id'][]
|
||||
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
|
||||
hoverHighlightMode: 'default' | 'delete'
|
||||
setHoverHighlightMode: (mode: 'default' | 'delete') => void
|
||||
hoveredId: AnyNode['id'] | ZoneNode['id'] | null
|
||||
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
|
||||
|
||||
@@ -75,6 +79,10 @@ const useViewer = create<ViewerState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
selection: { buildingId: null, levelId: null, zoneId: null, selectedIds: [] },
|
||||
previewSelectedIds: [],
|
||||
setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }),
|
||||
hoverHighlightMode: 'default',
|
||||
setHoverHighlightMode: (mode) => set({ hoverHighlightMode: mode }),
|
||||
hoveredId: null,
|
||||
setHoveredId: (id) => set({ hoveredId: id }),
|
||||
|
||||
@@ -164,7 +172,7 @@ const useViewer = create<ViewerState>()(
|
||||
if (updates.selectedIds === undefined) newSelection.selectedIds = []
|
||||
}
|
||||
|
||||
return { selection: newSelection }
|
||||
return { selection: newSelection, previewSelectedIds: [] }
|
||||
}),
|
||||
|
||||
resetSelection: () =>
|
||||
@@ -175,6 +183,7 @@ const useViewer = create<ViewerState>()(
|
||||
zoneId: null,
|
||||
selectedIds: [],
|
||||
},
|
||||
previewSelectedIds: [],
|
||||
}),
|
||||
|
||||
outliner: { selectedObjects: [], hoveredObjects: [] },
|
||||
|
||||
Reference in New Issue
Block a user