From a205e4f778c49ee8a9bce9c77aff16936b66c03d Mon Sep 17 00:00:00 2001 From: Sudhir Yadav Date: Tue, 14 Apr 2026 09:01:57 +0530 Subject: [PATCH] Feat/stairs fence update (#226) * feat: railing on the straight stairs and new fence * feat: added spiral and curved stairs with bug fix for fence * feat:fence are linked to each other ... so moving one move the other sharing the same coordinate * fix: update stair railing logic to include front-side attachments for terminal landings * Integrate fence rendering into the fence system * fix: pass nodeId instead of undefined node to WallTreeNode and FenceTreeNode TreeNode was passing `node` (undefined variable) instead of `nodeId` to WallTreeNode and FenceTreeNode, causing a runtime ReferenceError. Updated FenceTreeNode to accept nodeId and look up the node from the scene store internally, consistent with all other tree node components. Co-Authored-By: Claude Opus 4.6 * feat: update fence icon with new isometric design Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Aymeric Rabot Co-authored-by: Claude Opus 4.6 --- apps/editor/public/icons/fence.png | Bin 0 -> 105533 bytes bun.lock | 4 +- packages/core/src/events/bus.ts | 3 + .../hooks/scene-registry/scene-registry.ts | 1 + packages/core/src/index.ts | 2 + packages/core/src/schema/index.ts | 3 +- packages/core/src/schema/nodes/fence.ts | 35 + packages/core/src/schema/nodes/level.ts | 2 + packages/core/src/schema/nodes/stair.ts | 42 +- packages/core/src/schema/types.ts | 2 + packages/core/src/store/use-scene.ts | 4 +- .../core/src/systems/fence/fence-system.tsx | 145 ++++ .../core/src/systems/stair/stair-system.tsx | 558 +++++++++++- .../editor/floating-action-menu.tsx | 67 +- .../src/components/editor/floorplan-panel.tsx | 20 +- .../components/editor/selection-manager.tsx | 8 + .../systems/stair/stair-edit-system.tsx | 32 +- .../src/components/tools/door/door-math.ts | 2 +- .../components/tools/fence/fence-drafting.ts | 125 +++ .../src/components/tools/fence/fence-tool.tsx | 190 +++++ .../tools/fence/move-fence-tool.tsx | 223 +++++ .../src/components/tools/item/move-tool.tsx | 3 + .../tools/select/box-select-tool.tsx | 4 +- .../components/tools/stair/stair-defaults.ts | 10 + .../src/components/tools/stair/stair-tool.tsx | 23 + .../src/components/tools/tool-manager.tsx | 2 + .../components/tools/window/window-math.ts | 2 +- .../ui/action-menu/structure-tools.tsx | 1 + .../src/components/ui/panels/fence-panel.tsx | 184 ++++ .../components/ui/panels/panel-manager.tsx | 3 + .../src/components/ui/panels/stair-panel.tsx | 241 +++++- .../panels/site-panel/fence-tree-node.tsx | 65 ++ .../sidebar/panels/site-panel/tree-node.tsx | 3 + .../editor/src/components/viewer-overlay.tsx | 1 + .../editor/src/hooks/use-contextual-tools.ts | 12 +- packages/editor/src/store/use-editor.tsx | 4 + .../renderers/fence/fence-renderer.tsx | 22 + .../components/renderers/node-renderer.tsx | 2 + .../renderers/stair/stair-renderer.tsx | 799 +++++++++++++++++- .../viewer/src/components/viewer/index.tsx | 2 + .../components/viewer/selection-manager.tsx | 12 +- packages/viewer/src/hooks/use-node-events.ts | 3 + 42 files changed, 2774 insertions(+), 92 deletions(-) create mode 100644 apps/editor/public/icons/fence.png create mode 100644 packages/core/src/schema/nodes/fence.ts create mode 100644 packages/core/src/systems/fence/fence-system.tsx create mode 100644 packages/editor/src/components/tools/fence/fence-drafting.ts create mode 100644 packages/editor/src/components/tools/fence/fence-tool.tsx create mode 100644 packages/editor/src/components/tools/fence/move-fence-tool.tsx create mode 100644 packages/editor/src/components/ui/panels/fence-panel.tsx create mode 100644 packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx create mode 100644 packages/viewer/src/components/renderers/fence/fence-renderer.tsx diff --git a/apps/editor/public/icons/fence.png b/apps/editor/public/icons/fence.png new file mode 100644 index 0000000000000000000000000000000000000000..47f677f589573e7c750948caf388f4d2293f1e96 GIT binary patch literal 105533 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 z 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
*_zZZLnnm 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%0Dc&JB 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 literal 0 HcmV?d00001 diff --git a/bun.lock b/bun.lock index 8778a38c..35f9e541 100644 --- a/bun.lock +++ b/bun.lock @@ -48,7 +48,7 @@ }, "packages/core": { "name": "@pascal-app/core", - "version": "0.4.0", + "version": "0.5.0", "dependencies": { "dedent": "^1.7.1", "idb-keyval": "^6.2.2", @@ -166,7 +166,7 @@ }, "packages/viewer": { "name": "@pascal-app/viewer", - "version": "0.4.0", + "version": "0.5.0", "dependencies": { "polygon-clipping": "^0.15.7", "zustand": "^5", diff --git a/packages/core/src/events/bus.ts b/packages/core/src/events/bus.ts index 5592be3d..18bf9aed 100644 --- a/packages/core/src/events/bus.ts +++ b/packages/core/src/events/bus.ts @@ -4,6 +4,7 @@ import type { BuildingNode, CeilingNode, DoorNode, + FenceNode, ItemNode, LevelNode, RoofNode, @@ -41,6 +42,7 @@ export interface NodeEvent { } export type WallEvent = NodeEvent +export type FenceEvent = NodeEvent export type ItemEvent = NodeEvent export type SiteEvent = NodeEvent export type BuildingEvent = NodeEvent @@ -111,6 +113,7 @@ type ThumbnailEvents = { type EditorEvents = GridEvents & NodeEvents<'wall', WallEvent> & + NodeEvents<'fence', FenceEvent> & NodeEvents<'item', ItemEvent> & NodeEvents<'site', SiteEvent> & NodeEvents<'building', BuildingEvent> & diff --git a/packages/core/src/hooks/scene-registry/scene-registry.ts b/packages/core/src/hooks/scene-registry/scene-registry.ts index d6b2c0f0..c44e7909 100644 --- a/packages/core/src/hooks/scene-registry/scene-registry.ts +++ b/packages/core/src/hooks/scene-registry/scene-registry.ts @@ -15,6 +15,7 @@ export const sceneRegistry = { ceiling: new Set(), level: new Set(), wall: new Set(), + fence: new Set(), item: new Set(), slab: new Set(), zone: new Set(), diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 98f66d5a..1325c1ea 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,7 @@ export type { CeilingEvent, DoorEvent, EventSuffix, + FenceEvent, GridEvent, ItemEvent, LevelEvent, @@ -44,6 +45,7 @@ export { useInteractive, } from './store/use-interactive' export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' +export { FenceSystem } from './systems/fence/fence-system' export { clearSceneHistory, default as useScene } from './store/use-scene' export { CeilingSystem } from './systems/ceiling/ceiling-system' export { DoorSystem } from './systems/door/door-system' diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 927c48a5..26da7c49 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -15,6 +15,7 @@ export { export { BuildingNode } from './nodes/building' export { CeilingNode } from './nodes/ceiling' export { DoorNode, DoorSegment } from './nodes/door' +export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence' export { GuideNode } from './nodes/guide' export type { AnimationEffect, @@ -36,7 +37,7 @@ export { ScanNode } from './nodes/scan' // Nodes export { SiteNode } from './nodes/site' export { SlabNode } from './nodes/slab' -export { StairNode } from './nodes/stair' +export { StairNode, StairRailingMode, StairTopLandingMode, StairType } from './nodes/stair' export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment' export { WallNode } from './nodes/wall' export { WindowNode } from './nodes/window' diff --git a/packages/core/src/schema/nodes/fence.ts b/packages/core/src/schema/nodes/fence.ts new file mode 100644 index 00000000..3a42e9d8 --- /dev/null +++ b/packages/core/src/schema/nodes/fence.ts @@ -0,0 +1,35 @@ +import dedent from 'dedent' +import { z } from 'zod' +import { BaseNode, nodeType, objectId } from '../base' + +export const FenceStyle = z.enum(['slat', 'rail', 'privacy']) +export const FenceBaseStyle = z.enum(['floating', 'grounded']) + +export const FenceNode = BaseNode.extend({ + id: objectId('fence'), + type: nodeType('fence'), + start: z.tuple([z.number(), z.number()]), + end: z.tuple([z.number(), z.number()]), + height: z.number().default(1.8), + thickness: z.number().default(0.08), + baseHeight: z.number().default(0.22), + postSpacing: z.number().default(2), + postSize: z.number().default(0.1), + topRailHeight: z.number().default(0.04), + groundClearance: z.number().default(0), + edgeInset: z.number().default(0.015), + baseStyle: FenceBaseStyle.default('grounded'), + color: z.string().default('#ffffff'), + style: FenceStyle.default('slat'), +}).describe( + dedent` + Fence node - used to represent a fence segment in the building/site level coordinate system + - start/end: fence endpoints in level coordinate system + - height/thickness: overall fence dimensions in meters + - baseHeight/postSpacing/postSize/topRailHeight: exact geometric controls from the plan3D fence model + - groundClearance/edgeInset/baseStyle: fence support and inset configuration + - color/style: visual appearance options + `, +) + +export type FenceNode = z.infer diff --git a/packages/core/src/schema/nodes/level.ts b/packages/core/src/schema/nodes/level.ts index 9a834750..4761161c 100644 --- a/packages/core/src/schema/nodes/level.ts +++ b/packages/core/src/schema/nodes/level.ts @@ -2,6 +2,7 @@ import dedent from 'dedent' import { z } from 'zod' import { BaseNode, nodeType, objectId } from '../base' import { CeilingNode } from './ceiling' +import { FenceNode } from './fence' import { GuideNode } from './guide' import { RoofNode } from './roof' import { ScanNode } from './scan' @@ -17,6 +18,7 @@ export const LevelNode = BaseNode.extend({ .array( z.union([ WallNode.shape.id, + FenceNode.shape.id, ZoneNode.shape.id, SlabNode.shape.id, CeilingNode.shape.id, diff --git a/packages/core/src/schema/nodes/stair.ts b/packages/core/src/schema/nodes/stair.ts index 2901771e..9e3df380 100644 --- a/packages/core/src/schema/nodes/stair.ts +++ b/packages/core/src/schema/nodes/stair.ts @@ -4,6 +4,14 @@ import { BaseNode, nodeType, objectId } from '../base' import { MaterialSchema } from '../material' import { StairSegmentNode } from './stair-segment' +export const StairRailingMode = z.enum(['none', 'left', 'right', 'both']) +export const StairType = z.enum(['straight', 'curved', 'spiral']) +export const StairTopLandingMode = z.enum(['none', 'integrated']) + +export type StairRailingMode = z.infer +export type StairType = z.infer +export type StairTopLandingMode = z.infer + export const StairNode = BaseNode.extend({ id: objectId('stair'), type: nodeType('stair'), @@ -11,16 +19,44 @@ export const StairNode = BaseNode.extend({ position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]), // Rotation around Y axis in radians rotation: z.number().default(0), + stairType: StairType.default('straight'), + width: z.number().default(1.0), + totalRise: z.number().default(2.5), + stepCount: z.number().default(10), + thickness: z.number().default(0.25), + fillToFloor: z.boolean().default(true), + innerRadius: z.number().default(0.9), + sweepAngle: z.number().default(Math.PI / 2), + topLandingMode: StairTopLandingMode.default('none'), + topLandingDepth: z.number().default(0.9), + showCenterColumn: z.boolean().default(true), + showStepSupports: z.boolean().default(true), + railingMode: StairRailingMode.default('none'), + railingHeight: z.number().default(0.92), // 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. + Acts as a group that either holds one or more StairSegmentNodes (straight stairs) + or stores stair-level geometry properties for curved stairs. - position: center position of the stair group - rotation: rotation around Y axis - - children: array of StairSegmentNode IDs + - stairType: straight (segment-based), curved (arc-based), or spiral + - width: stair width + - totalRise: total stair height + - stepCount: number of visible steps + - thickness: stair slab / tread thickness + - fillToFloor: whether the stair mass fills down to the floor or uses tread thickness only + - innerRadius: inner curve radius for curved stairs + - sweepAngle: total curved stair sweep in radians + - topLandingMode: optional integrated top landing for spiral stairs + - topLandingDepth: depth used to size the integrated spiral top landing + - showCenterColumn: whether spiral stairs render a center column + - showStepSupports: whether spiral stairs render step support brackets + - railingMode: whether to render railings and on which side(s) + - railingHeight: top height of the railing above the stair surface + - children: array of StairSegmentNode IDs for straight stairs `, ) diff --git a/packages/core/src/schema/types.ts b/packages/core/src/schema/types.ts index d7b3538e..1b17a6b0 100644 --- a/packages/core/src/schema/types.ts +++ b/packages/core/src/schema/types.ts @@ -2,6 +2,7 @@ import z from 'zod' import { BuildingNode } from './nodes/building' import { CeilingNode } from './nodes/ceiling' import { DoorNode } from './nodes/door' +import { FenceNode } from './nodes/fence' import { GuideNode } from './nodes/guide' import { ItemNode } from './nodes/item' import { LevelNode } from './nodes/level' @@ -21,6 +22,7 @@ export const AnyNode = z.discriminatedUnion('type', [ BuildingNode, LevelNode, WallNode, + FenceNode, ItemNode, ZoneNode, SlabNode, diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts index d9cdc2d2..83803afa 100644 --- a/packages/core/src/store/use-scene.ts +++ b/packages/core/src/store/use-scene.ts @@ -378,8 +378,8 @@ useScene.temporal.subscribe((state) => { // Mark sibling nodes dirty so they can update their geometry // (e.g. adjacent walls need to recalculate miter/junction geometry) const parent = currentNodes[parentId] - if (parent && 'children' in parent) { - for (const childId of (parent as AnyNode & { children: string[] }).children) { + if (parent && 'children' in parent && Array.isArray(parent.children)) { + for (const childId of parent.children) { markDirty(childId as AnyNodeId) } } diff --git a/packages/core/src/systems/fence/fence-system.tsx b/packages/core/src/systems/fence/fence-system.tsx new file mode 100644 index 00000000..7f1de3bd --- /dev/null +++ b/packages/core/src/systems/fence/fence-system.tsx @@ -0,0 +1,145 @@ +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 { AnyNodeId, FenceNode } from '../../schema' +import useScene from '../../store/use-scene' + +type FencePart = { + position: [number, number, number] + scale: [number, number, number] +} + +function getStyleDefaults(style: FenceNode['style']) { + if (style === 'privacy') { + return { spacingFactor: 0.42, postFactor: 1.35, baseFactor: 1.2, topFactor: 1.2 } + } + + if (style === 'rail') { + return { spacingFactor: 0.68, postFactor: 0.8, baseFactor: 0.85, topFactor: 0.85 } + } + + return { spacingFactor: 0.3, postFactor: 0.55, baseFactor: 1, topFactor: 0.75 } +} + +function createFenceParts(fence: FenceNode): FencePart[] { + const parts: FencePart[] = [] + const length = Math.max( + Math.hypot(fence.end[0] - fence.start[0], fence.end[1] - fence.start[1]), + 0.01, + ) + const panelDepth = Math.max(fence.thickness, 0.03) + const clearance = Math.max(fence.groundClearance, 0) + const styleDefaults = getStyleDefaults(fence.style) + const baseHeight = Math.max(fence.baseHeight * styleDefaults.baseFactor, 0.04) + const topRailHeight = Math.max(fence.topRailHeight * styleDefaults.topFactor, 0.01) + const verticalHeight = Math.max(fence.height - baseHeight - topRailHeight, 0.08) + const postWidth = Math.max(fence.postSize * styleDefaults.postFactor, 0.01) + const spacing = Math.max(fence.postSpacing * styleDefaults.spacingFactor, postWidth * 1.2) + const edgeInset = Math.max(fence.edgeInset ?? 0.015, 0.005) + const isFloating = fence.baseStyle === 'floating' + const baseY = isFloating ? clearance : 0 + const effectiveBaseHeight = baseHeight + + if (!isFloating) { + parts.push({ + position: [0, baseY + effectiveBaseHeight / 2, 0], + scale: [length, effectiveBaseHeight, panelDepth * 1.05], + }) + parts.push({ + position: [0, baseY + effectiveBaseHeight + verticalHeight * 0.15, 0], + scale: [length, topRailHeight * 0.8, panelDepth * 0.35], + }) + } + + const count = Math.max(2, Math.floor((length - edgeInset * 2) / spacing) + 1) + const step = count > 1 ? (length - edgeInset * 2) / (count - 1) : 0 + const startX = -length / 2 + edgeInset + const verticalY = baseY + effectiveBaseHeight + verticalHeight / 2 + + for (let index = 0; index < count; index += 1) { + const x = count === 1 ? 0 : startX + step * index + let posX = x + const isEdgePost = index === 0 || index === count - 1 + if (count > 1) { + if (index === 0) posX = -length / 2 + edgeInset + postWidth / 2 + else if (index === count - 1) posX = length / 2 - edgeInset - postWidth / 2 + } + const postHeight = + isFloating && isEdgePost + ? effectiveBaseHeight + verticalHeight + topRailHeight + clearance + : verticalHeight + const postY = isFloating && isEdgePost ? postHeight / 2 : verticalY + + parts.push({ + position: [posX, postY, 0], + scale: [postWidth, postHeight, Math.max(panelDepth * 0.35, 0.012)], + }) + } + + parts.push({ + position: [0, baseY + effectiveBaseHeight + verticalHeight + topRailHeight / 2, 0], + scale: [length, topRailHeight, Math.max(panelDepth * 0.55, 0.018)], + }) + + if (isFloating) { + parts.push({ + position: [0, baseY + effectiveBaseHeight + topRailHeight / 2, 0], + scale: [length, topRailHeight, Math.max(panelDepth * 0.55, 0.018)], + }) + } + + return parts +} + +function generateFenceGeometry(fence: FenceNode) { + const parts = createFenceParts(fence) + const geometries = parts.map((part) => { + const geometry = new THREE.BoxGeometry(1, 1, 1) + geometry.scale(part.scale[0], part.scale[1], part.scale[2]) + geometry.translate(part.position[0], part.position[1], part.position[2]) + return geometry + }) + + const merged = mergeGeometries(geometries, false) ?? new THREE.BufferGeometry() + geometries.forEach((geometry) => geometry.dispose()) + merged.computeVertexNormals() + return merged +} + +function updateFenceGeometry(fenceId: FenceNode['id']) { + const node = useScene.getState().nodes[fenceId] + if (!node || node.type !== 'fence') return + + const mesh = sceneRegistry.nodes.get(fenceId) as THREE.Mesh | undefined + if (!mesh) return + + const newGeometry = generateFenceGeometry(node) + mesh.geometry.dispose() + mesh.geometry = newGeometry + + const centerX = (node.start[0] + node.end[0]) / 2 + const centerZ = (node.start[1] + node.end[1]) / 2 + const angle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]) + mesh.position.set(centerX, 0, centerZ) + mesh.rotation.set(0, -angle, 0) +} + +export const FenceSystem = () => { + const dirtyNodes = useScene((state) => state.dirtyNodes) + const clearDirty = useScene((state) => state.clearDirty) + + useFrame(() => { + if (dirtyNodes.size === 0) return + + const nodes = useScene.getState().nodes + dirtyNodes.forEach((id) => { + const node = nodes[id] + if (!node || node.type !== 'fence') return + updateFenceGeometry(id as FenceNode['id']) + clearDirty(id as AnyNodeId) + }) + }, 4) + + return null +} diff --git a/packages/core/src/systems/stair/stair-system.tsx b/packages/core/src/systems/stair/stair-system.tsx index d85a8c14..c799e9d1 100644 --- a/packages/core/src/systems/stair/stair-system.tsx +++ b/packages/core/src/systems/stair/stair-system.tsx @@ -304,15 +304,18 @@ function updateMergedStairGeometry( const mergedMesh = group.getObjectByName('merged-stair') as THREE.Mesh | undefined if (!mergedMesh) return + if (stairNode.stairType === 'curved' || stairNode.stairType === 'spiral') { + replaceMeshGeometry(mergedMesh, createEmptyGeometry()) + 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)) + replaceMeshGeometry(mergedMesh, createEmptyGeometry()) return } @@ -337,11 +340,8 @@ function updateMergedStairGeometry( geometries.push(geo) } - const merged = mergeGeometries(geometries, false) - if (merged) { - mergedMesh.geometry.dispose() - mergedMesh.geometry = merged - } + const merged = mergeGeometries(geometries, false) ?? createEmptyGeometry() + replaceMeshGeometry(mergedMesh, merged) // Dispose individual geometries for (const geo of geometries) { @@ -416,6 +416,548 @@ function rotateXZ(x: number, z: number, angle: number): [number, number] { return [x * cos + z * sin, -x * sin + z * cos] } +function createEmptyGeometry(): THREE.BufferGeometry { + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3)) + return geometry +} + +function replaceMeshGeometry(mesh: THREE.Mesh, geometry: THREE.BufferGeometry) { + mesh.geometry.dispose() + mesh.geometry = geometry +} + +type StairRailSide = 'left' | 'right' +type StairRailPathSide = StairRailSide | 'front' +type StairRailSidePath = { + side: StairRailPathSide + points: THREE.Vector3[] +} +type StairSegmentRailPath = { + segment: StairSegmentNode + sidePaths: StairRailSidePath[] + connectFromPrevious: boolean +} +type StairRailLayout = { + center: [number, number] + elevation: number + rotation: number + segment: StairSegmentNode +} + +function generateStairRailingGeometry( + stairNode: StairNode, + segments: StairSegmentNode[], + transforms: SegmentTransform[], +): THREE.BufferGeometry { + const railingMode = stairNode.railingMode ?? 'none' + if (railingMode === 'none') { + return createEmptyGeometry() + } + + const railHeight = Math.max(0.5, stairNode.railingHeight ?? 0.92) + const midRailHeight = Math.max(railHeight * 0.45, 0.35) + const railRadius = 0.022 + const postRadius = 0.018 + const inset = 0.06 + const landingInset = 0.08 + const geometries: THREE.BufferGeometry[] = [] + + const segmentRailPaths = buildStairRailPaths(segments, transforms, railingMode, inset, landingInset) + + for (const segmentRailPath of segmentRailPaths) { + for (const sidePath of segmentRailPath.sidePaths) { + const points = sidePath.points + if (points.length === 0) continue + + geometries.push(...buildBalusterGeometries(points, railHeight, postRadius)) + geometries.push(...buildOffsetRailSegmentGeometries(points, railHeight, railRadius)) + geometries.push( + ...buildOffsetRailSegmentGeometries(points, midRailHeight, railRadius * 0.8), + ) + } + } + + for (let index = 1; index < segmentRailPaths.length; index++) { + const previousPath = segmentRailPaths[index - 1] + const currentPath = segmentRailPaths[index] + if (!(previousPath && currentPath && currentPath.connectFromPrevious)) continue + if (previousPath.segment.segmentType === 'landing') continue + + for (const sidePath of currentPath.sidePaths) { + if (currentPath.segment.segmentType === 'landing') continue + const currentPoint = sidePath.points[0] + if (!currentPoint) continue + + const previousSidePath = [...previousPath.sidePaths] + .map((entry) => ({ + entry, + distance: entry.points.length + ? entry.points[entry.points.length - 1]!.distanceTo(currentPoint) + : Number.POSITIVE_INFINITY, + })) + .sort((left, right) => left.distance - right.distance)[0]?.entry + + const previousPoint = + previousSidePath && previousSidePath.points.length > 0 + ? previousSidePath.points[previousSidePath.points.length - 1] + : null + + if (!(previousPoint && currentPoint)) continue + + const connectorPoints = [previousPoint, currentPoint] + geometries.push(...buildOffsetRailSegmentGeometries(connectorPoints, railHeight, railRadius)) + geometries.push( + ...buildOffsetRailSegmentGeometries(connectorPoints, midRailHeight, railRadius * 0.8), + ) + } + } + + const merged = mergeGeometries(geometries, false) ?? createEmptyGeometry() + for (const geometry of geometries) { + geometry.dispose() + } + + return merged +} + +function buildStairRailPaths( + segments: StairSegmentNode[], + transforms: SegmentTransform[], + railingMode: 'left' | 'right' | 'both', + inset: number, + landingInset: number, +): StairSegmentRailPath[] { + const layouts = computeStairRailLayouts(segments, transforms) + + if (railingMode === 'both') { + const isStraightLineDoubleLandingLayout = + segments.length === 4 && + segments[0]?.segmentType === 'stair' && + segments[1]?.segmentType === 'landing' && + segments[2]?.segmentType === 'stair' && + segments[2]?.attachmentSide === 'front' && + segments[3]?.segmentType === 'landing' && + segments[3]?.attachmentSide === 'front' + + return layouts.map((layout, index) => { + const segment = layout.segment + const previousSegment = index > 0 ? segments[index - 1] : undefined + const nextSegment = index < segments.length - 1 ? segments[index + 1] : undefined + const hideLandingRailing = + segment.segmentType === 'landing' && + previousSegment?.segmentType === 'stair' && + nextSegment?.segmentType === 'stair' + const visualTurnSide = nextSegment?.attachmentSide + const sideCandidates = + hideLandingRailing + ? visualTurnSide === 'left' + ? (['front', 'right'] as const) + : visualTurnSide === 'right' + ? (['front', 'left'] as const) + : (['left', 'right'] as const) + : segment.segmentType === 'landing' + ? nextSegment?.segmentType === 'landing' && visualTurnSide === 'left' + ? (['front', 'right'] as const) + : nextSegment?.segmentType === 'landing' && visualTurnSide === 'right' + ? (['front', 'left'] as const) + : visualTurnSide === 'left' + ? (['right'] as const) + : visualTurnSide === 'right' + ? (['left'] as const) + : (['left', 'right'] as const) + : (['left', 'right'] as const) + const sidePaths = sideCandidates + .map((side) => + buildSegmentRailPath( + layout, + side, + previousSegment, + nextSegment, + inset, + landingInset, + ), + ) + .filter((entry): entry is StairRailSidePath => entry !== null) + + return { + segment, + sidePaths: + isStraightLineDoubleLandingLayout && index === 1 + ? ((['left', 'right'] as const) + .map((side) => + buildSegmentRailPath( + layout, + side, + previousSegment, + nextSegment, + inset, + landingInset, + ), + ) + .filter((entry): entry is StairRailSidePath => entry !== null)) + : sidePaths, + connectFromPrevious: + index > 0 && + !(previousSegment?.segmentType === 'landing' && segment.segmentType === 'landing'), + } + }) + } + + const isStraightLineDoubleLandingLayout = + segments.length === 4 && + segments[0]?.segmentType === 'stair' && + segments[1]?.segmentType === 'landing' && + segments[2]?.segmentType === 'stair' && + segments[2]?.attachmentSide === 'front' && + segments[3]?.segmentType === 'landing' && + segments[3]?.attachmentSide === 'front' + + const resolved: StairSegmentRailPath[] = [] + layouts.forEach((layout, index) => { + const segment = layout.segment + const previousSegment = index > 0 ? segments[index - 1] : undefined + const nextSegment = index < segments.length - 1 ? segments[index + 1] : undefined + const nextAttachmentSide = nextSegment?.attachmentSide + const isMiddleLandingBetweenFlights = + segment.segmentType === 'landing' && + previousSegment?.segmentType === 'stair' && + nextSegment?.segmentType === 'stair' + const suppressLandingRailing = + segment.segmentType === 'landing' && + nextSegment?.segmentType === 'landing' && + nextAttachmentSide === railingMode + const landingContinuesOnPreferredSide = + segment.segmentType === 'landing' + ? nextAttachmentSide == null || + nextAttachmentSide === 'front' || + nextAttachmentSide === railingMode + : true + + const sidePaths = + suppressLandingRailing + ? [] + : segment.segmentType !== 'landing' + ? [ + buildSegmentRailPath( + layout, + railingMode, + previousSegment, + nextSegment, + inset, + landingInset, + ), + ] + : isStraightLineDoubleLandingLayout + ? [ + buildSegmentRailPath( + layout, + railingMode, + previousSegment, + nextSegment, + inset, + landingInset, + ), + ] + : isMiddleLandingBetweenFlights && railingMode === 'left' + ? nextAttachmentSide === 'right' + ? [ + buildSegmentRailPath( + layout, + 'front', + previousSegment, + nextSegment, + inset, + landingInset, + ), + buildSegmentRailPath( + layout, + 'left', + previousSegment, + nextSegment, + inset, + landingInset, + ), + ] + : [] + : isMiddleLandingBetweenFlights && railingMode === 'right' + ? nextAttachmentSide === 'left' + ? [ + buildSegmentRailPath( + layout, + 'front', + previousSegment, + nextSegment, + inset, + landingInset, + ), + buildSegmentRailPath( + layout, + 'right', + previousSegment, + nextSegment, + inset, + landingInset, + ), + ] + : [] + : nextSegment?.segmentType === 'landing' && + nextAttachmentSide != null && + nextAttachmentSide !== 'front' && + nextAttachmentSide !== railingMode + ? [ + buildSegmentRailPath( + layout, + 'front', + previousSegment, + nextSegment, + inset, + landingInset, + ), + buildSegmentRailPath( + layout, + railingMode, + previousSegment, + nextSegment, + inset, + landingInset, + ), + ] + : [ + buildSegmentRailPath( + layout, + railingMode, + previousSegment, + nextSegment, + inset, + landingInset, + ), + ] + + resolved.push({ + segment, + sidePaths: sidePaths.filter((entry): entry is StairRailSidePath => entry !== null), + connectFromPrevious: + index > 0 && + !suppressLandingRailing && + sidePaths.length > 0 && + (segment.segmentType === 'landing' ? landingContinuesOnPreferredSide : true), + }) + }) + + return resolved +} + +function computeStairRailLayouts( + segments: StairSegmentNode[], + transforms: SegmentTransform[], +): StairRailLayout[] { + return segments.map((segment, index) => { + const transform = transforms[index]! + const [centerOffsetX, centerOffsetZ] = rotateXZ(0, segment.length / 2, transform.rotation) + + return { + center: [transform.position[0] + centerOffsetX, transform.position[2] + centerOffsetZ], + elevation: transform.position[1], + rotation: transform.rotation, + segment, + } + }) +} + +function buildSegmentRailPath( + layout: StairRailLayout, + side: StairRailPathSide, + previousSegment: StairSegmentNode | undefined, + nextSegment: StairSegmentNode | undefined, + inset: number, + landingInset: number, +): StairRailSidePath | null { + const segment = layout.segment + const segmentSteps = Math.max(1, segment.segmentType === 'landing' ? 1 : segment.stepCount) + const segmentStepDepth = segment.length / segmentSteps + const segmentStepHeight = segment.segmentType === 'landing' ? 0 : segment.height / segmentSteps + const segmentTopThickness = getSegmentTopThickness(segment) + const flightSideOffset = + side === 'left' ? segment.width / 2 - 0.045 : -segment.width / 2 + 0.045 + const flightStartX = + previousSegment?.segmentType === 'landing' ? -segment.length / 2 + landingInset : -segment.length / 2 + const flightEndX = + nextSegment?.segmentType === 'landing' ? segment.length / 2 - landingInset : segment.length / 2 + + if (segment.segmentType === 'landing') { + return buildLandingRailPathFromScratch( + layout, + side, + previousSegment, + nextSegment, + segmentTopThickness, + landingInset, + ) + } + + return { + side, + points: [ + ...(previousSegment?.segmentType === 'landing' + ? [] + : [ + toRailLayoutWorldPoint(layout, flightStartX, segmentTopThickness, flightSideOffset), + ]), + ...Array.from({ length: segmentSteps }).map((_, index) => + toRailLayoutWorldPoint( + layout, + -segment.length / 2 + segmentStepDepth * index + segmentStepDepth / 2, + segmentStepHeight * (index + 1), + flightSideOffset, + ), + ), + ...(nextSegment?.segmentType === 'landing' + ? [] + : [ + toRailLayoutWorldPoint(layout, flightEndX, segment.height, flightSideOffset), + ]), + ], + } +} + +function buildLandingRailPathFromScratch( + layout: StairRailLayout, + side: StairRailPathSide, + previousSegment: StairSegmentNode | undefined, + nextSegment: StairSegmentNode | undefined, + topY: number, + inset: number, +): StairRailSidePath | null { + const segment = layout.segment + const backX = -segment.length / 2 + inset + const frontX = segment.length / 2 - inset + const leftZ = segment.width / 2 - inset + const rightZ = -segment.width / 2 + inset + + const edgePoints = + side === 'left' + ? ([ + toRailLayoutWorldPoint(layout, backX, topY, leftZ), + toRailLayoutWorldPoint(layout, frontX, topY, leftZ), + ] as THREE.Vector3[]) + : side === 'right' + ? ([ + toRailLayoutWorldPoint(layout, backX, topY, rightZ), + toRailLayoutWorldPoint(layout, frontX, topY, rightZ), + ] as THREE.Vector3[]) + : ([ + // When the next flight turns, rail the visible leading edge nearest the turn opening. + toRailLayoutWorldPoint( + layout, + previousSegment?.segmentType === 'stair' && + nextSegment?.attachmentSide && + nextSegment.attachmentSide !== 'front' + ? backX + : frontX, + topY, + leftZ, + ), + toRailLayoutWorldPoint( + layout, + previousSegment?.segmentType === 'stair' && + nextSegment?.attachmentSide && + nextSegment.attachmentSide !== 'front' + ? backX + : frontX, + topY, + rightZ, + ), + ] as THREE.Vector3[]) + + return { + side, + points: edgePoints, + } +} + +function toRailLayoutWorldPoint( + layout: StairRailLayout, + localX: number, + localY: number, + localZ: number, +): THREE.Vector3 { + const [offsetX, offsetZ] = rotateXZ(localZ, localX, layout.rotation) + return new THREE.Vector3( + layout.center[0] + offsetX, + layout.elevation + localY, + layout.center[1] + offsetZ, + ) +} + +function buildOffsetRailSegmentGeometries( + points: THREE.Vector3[], + heightOffset: number, + radius: number, +): THREE.BufferGeometry[] { + const geometries: THREE.BufferGeometry[] = [] + + for (let index = 0; index < points.length - 1; index++) { + const start = points[index] + const end = points[index + 1] + if (!(start && end)) continue + + const segmentGeometry = createCylinderBetweenPoints( + start.clone().add(new THREE.Vector3(0, heightOffset, 0)), + end.clone().add(new THREE.Vector3(0, heightOffset, 0)), + radius, + 8, + ) + if (segmentGeometry) { + geometries.push(segmentGeometry) + } + } + + return geometries +} + +function buildBalusterGeometries( + points: THREE.Vector3[], + height: number, + radius: number, +): THREE.BufferGeometry[] { + const geometries: THREE.BufferGeometry[] = [] + + for (const point of points) { + const geometry = new THREE.CylinderGeometry(radius, radius, Math.max(height, 0.05), 8) + geometry.translate(point.x, point.y + height / 2, point.z) + geometries.push(geometry) + } + + return geometries +} + +function getSegmentTopThickness(segment: StairSegmentNode): number { + return Math.max(segment.thickness ?? 0.25, 0.02) +} + +function createCylinderBetweenPoints( + start: THREE.Vector3, + end: THREE.Vector3, + radius: number, + radialSegments: number, +): THREE.BufferGeometry | null { + const direction = new THREE.Vector3().subVectors(end, start) + const length = direction.length() + if (length <= 1e-5) return null + + const midpoint = new THREE.Vector3().addVectors(start, end).multiplyScalar(0.5) + const quaternion = new THREE.Quaternion().setFromUnitVectors( + new THREE.Vector3(0, 1, 0), + direction.clone().normalize(), + ) + + const geometry = new THREE.CylinderGeometry(radius, radius, length, radialSegments) + geometry.applyQuaternion(quaternion) + geometry.translate(midpoint.x, midpoint.y, midpoint.z) + return geometry +} + /** * Computes the absolute Y height of a segment by traversing the stair's segment chain. */ diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index 62345a2b..ea6e46b0 100755 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -5,6 +5,7 @@ import { type AnyNodeId, type CeilingNode, DoorNode, + FenceNode, ItemNode, RoofNode, RoofSegmentNode, @@ -33,6 +34,7 @@ const ALLOWED_TYPES = [ 'stair', 'stair-segment', 'wall', + 'fence', 'slab', 'ceiling', ] @@ -82,6 +84,7 @@ export function FloatingActionMenu() { node.type === 'item' || node.type === 'window' || node.type === 'door' || + node.type === 'fence' || node.type === 'roof' || node.type === 'roof-segment' || node.type === 'stair' || @@ -113,11 +116,18 @@ export function FloatingActionMenu() { duplicate = WindowNode.parse(duplicateInfo) } else if (node.type === 'item') { duplicate = ItemNode.parse(duplicateInfo) + } else if (node.type === 'fence') { + duplicate = FenceNode.parse(duplicateInfo) + duplicate.start = [duplicate.start[0] + 1, duplicate.start[1] + 1] + duplicate.end = [duplicate.end[0] + 1, duplicate.end[1] + 1] } else if (node.type === 'roof') { duplicate = RoofNode.parse(duplicateInfo) } else if (node.type === 'roof-segment') { duplicate = RoofSegmentNode.parse(duplicateInfo) } else if (node.type === 'stair') { + duplicateInfo.children = [] + duplicateInfo.metadata = { ...duplicateInfo.metadata } + delete duplicateInfo.metadata?.isNew duplicate = StairNode.parse(duplicateInfo) } else if (node.type === 'stair-segment') { duplicate = StairSegmentNode.parse(duplicateInfo) @@ -130,6 +140,8 @@ export function FloatingActionMenu() { if (duplicate) { if (duplicate.type === 'door' || duplicate.type === 'window') { useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) + } else if (duplicate.type === 'fence') { + useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) } else if ( duplicate.type === 'roof' || duplicate.type === 'roof-segment' || @@ -144,7 +156,35 @@ export function FloatingActionMenu() { duplicate.position[2] + 1, ] } - useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) + if (node.type === 'stair' && duplicate.type === 'stair') { + const nodesState = useScene.getState().nodes + const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [ + { node: duplicate, parentId: duplicate.parentId as AnyNodeId }, + ] + + for (const childId of node.children ?? []) { + const childNode = nodesState[childId] + if (childNode?.type !== 'stair-segment') { + continue + } + + let childDuplicateInfo = structuredClone(childNode) as any + delete childDuplicateInfo.id + childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata } + delete childDuplicateInfo.metadata?.isNew + + try { + const childDuplicate = StairSegmentNode.parse(childDuplicateInfo) + createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId }) + } catch (e) { + console.error('Failed to duplicate stair segment', e) + } + } + + useScene.getState().createNodes(createOps) + } else { + useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) + } // Duplicate children for roof nodes if (node.type === 'roof' && node.children) { @@ -166,36 +206,23 @@ 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 ( duplicate.type === 'item' || + duplicate.type === 'fence' || duplicate.type === 'window' || duplicate.type === 'door' || duplicate.type === 'roof' || duplicate.type === 'roof-segment' || - duplicate.type === 'stair' || duplicate.type === 'stair-segment' ) { setMovingNode(duplicate as any) + } else if (duplicate.type === 'stair') { + setSelection({ selectedIds: [duplicate.id as AnyNodeId] }) + } + if (duplicate.type !== 'stair') { + setSelection({ selectedIds: [] }) } - setSelection({ selectedIds: [] }) } }, [node, setMovingNode, setSelection], diff --git a/packages/editor/src/components/editor/floorplan-panel.tsx b/packages/editor/src/components/editor/floorplan-panel.tsx index 37133fdd..b9701612 100644 --- a/packages/editor/src/components/editor/floorplan-panel.tsx +++ b/packages/editor/src/components/editor/floorplan-panel.tsx @@ -8063,6 +8063,7 @@ export function FloorplanPanel() { ...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}), isNew: true, } + cloned.children = [] try { const duplicate = ItemNodeSchema.parse(cloned) @@ -8191,8 +8192,8 @@ export function FloorplanPanel() { delete cloned.id cloned.metadata = { ...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}), - isNew: true, } + delete (cloned.metadata as Record).isNew const nextPosition = Array.isArray(cloned.position) && cloned.position.length >= 3 @@ -8207,9 +8208,11 @@ export function FloorplanPanel() { try { const duplicate = StairNodeSchema.parse(cloned) - useScene.getState().createNode(duplicate, stair.parentId as AnyNodeId) - const nodesState = useScene.getState().nodes + const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [ + { node: duplicate, parentId: stair.parentId as AnyNodeId }, + ] + for (const childId of stair.children ?? []) { const childNode = nodesState[childId] if (childNode?.type !== 'stair-segment') { @@ -8222,19 +8225,20 @@ export function FloorplanPanel() { ...(typeof childClone.metadata === 'object' && childClone.metadata !== null ? childClone.metadata : {}), - isNew: true, } + delete (childClone.metadata as Record).isNew const childDuplicate = StairSegmentNodeSchema.parse(childClone) - useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId) + createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId }) } - setMovingNode(duplicate) - setSelection({ selectedIds: [] }) + useScene.getState().createNodes(createOps) + + setSelection({ selectedIds: [duplicate.id as AnyNodeId] }) } catch (error) { console.error('Failed to duplicate stair', error) } - }, [selectedStairEntry, setMovingNode, setSelection]) + }, [selectedStairEntry, setSelection]) const handleSelectedStairDuplicate = useCallback( (event: ReactMouseEvent) => { event.stopPropagation() diff --git a/packages/editor/src/components/editor/selection-manager.tsx b/packages/editor/src/components/editor/selection-manager.tsx index 04518047..3de50223 100755 --- a/packages/editor/src/components/editor/selection-manager.tsx +++ b/packages/editor/src/components/editor/selection-manager.tsx @@ -26,6 +26,7 @@ const isNodeInCurrentLevel = (node: AnyNode): boolean => { type SelectableNodeType = | 'wall' + | 'fence' | 'item' | 'building' | 'zone' @@ -186,6 +187,7 @@ const SELECTION_STRATEGIES: Record = { structure: { types: [ 'wall', + 'fence', 'item', 'zone', 'slab', @@ -238,6 +240,7 @@ const SELECTION_STRATEGIES: Record = { } if ( node.type === 'wall' || + node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling' || node.type === 'roof' || @@ -299,6 +302,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => { if ( node.type === 'wall' || + node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling' || node.type === 'roof' || @@ -443,6 +447,7 @@ export const SelectionManager = () => { const allTypes = [ 'wall', + 'fence', 'item', 'building', 'zone', @@ -537,6 +542,7 @@ export const SelectionManager = () => { } } else if ( node.type === 'wall' || + node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling' || node.type === 'roof' || @@ -586,6 +592,7 @@ export const SelectionManager = () => { const allTypes = [ 'wall', + 'fence', 'item', 'building', 'slab', @@ -657,6 +664,7 @@ export const SelectionManager = () => { const allTypes = [ 'wall', + 'fence', 'item', 'slab', 'ceiling', diff --git a/packages/editor/src/components/systems/stair/stair-edit-system.tsx b/packages/editor/src/components/systems/stair/stair-edit-system.tsx index 0edd1797..265d57b5 100644 --- a/packages/editor/src/components/systems/stair/stair-edit-system.tsx +++ b/packages/editor/src/components/systems/stair/stair-edit-system.tsx @@ -1,6 +1,6 @@ import { type AnyNodeId, type StairNode, sceneRegistry, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' -import { useEffect, useRef } from 'react' +import { useCallback, useEffect, useRef } from 'react' /** * Imperatively toggles the Three.js visibility of stair objects based on the @@ -17,6 +17,27 @@ import { useEffect, useRef } from 'react' */ export const StairEditSystem = () => { const selectedIds = useViewer((s) => s.selection.selectedIds) + const selectedStairSignature = useScene( + useCallback( + (state) => + selectedIds + .map((id) => { + const node = state.nodes[id as AnyNodeId] + if (!node) return null + if (node.type === 'stair') { + return `${node.id}:${node.stairType}` + } + if (node.type === 'stair-segment' && node.parentId) { + const parent = state.nodes[node.parentId as AnyNodeId] as StairNode | undefined + return parent?.type === 'stair' ? `${parent.id}:${parent.stairType}` : null + } + return null + }) + .filter(Boolean) + .join('|'), + [selectedIds], + ), + ) const prevActiveStairIds = useRef(new Set()) useEffect(() => { @@ -41,14 +62,15 @@ export const StairEditSystem = () => { const group = sceneRegistry.nodes.get(stairId) if (!group) continue + const stairNode = nodes[stairId as AnyNodeId] as StairNode | undefined + const isCurved = stairNode?.stairType === 'curved' || stairNode?.stairType === 'spiral' 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 + if (mergedMesh) mergedMesh.visible = !isActive && !isCurved + if (segmentsWrapper) segmentsWrapper.visible = isActive && !isCurved - const stairNode = nodes[stairId as AnyNodeId] as StairNode | undefined if (stairNode?.children?.length) { const wasActive = prevActiveStairIds.current.has(stairId) if (isActive !== wasActive) { @@ -63,7 +85,7 @@ export const StairEditSystem = () => { } prevActiveStairIds.current = activeStairIds - }, [selectedIds]) + }, [selectedIds, selectedStairSignature]) return null } diff --git a/packages/editor/src/components/tools/door/door-math.ts b/packages/editor/src/components/tools/door/door-math.ts index 2fa5d03c..9a2dc9df 100644 --- a/packages/editor/src/components/tools/door/door-math.ts +++ b/packages/editor/src/components/tools/door/door-math.ts @@ -70,7 +70,7 @@ export function hasWallChildOverlap( const newLeft = clampedX - halfW const newRight = clampedX + halfW - for (const childId of wallNode.children) { + for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) { if (childId === ignoreId) continue const child = nodes[childId as AnyNodeId] if (!child) continue diff --git a/packages/editor/src/components/tools/fence/fence-drafting.ts b/packages/editor/src/components/tools/fence/fence-drafting.ts new file mode 100644 index 00000000..f5ec1167 --- /dev/null +++ b/packages/editor/src/components/tools/fence/fence-drafting.ts @@ -0,0 +1,125 @@ +import { FenceNode, useScene, type WallNode } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { sfxEmitter } from '../../../lib/sfx-bus' +import { + type WallPlanPoint, + findWallSnapTarget, + isWallLongEnough, + snapPointTo45Degrees, + snapPointToGrid, +} from '../wall/wall-drafting' + +export type FencePlanPoint = WallPlanPoint + +type SegmentNode = { + start: FencePlanPoint + end: FencePlanPoint +} + +function distanceSquared(a: FencePlanPoint, b: FencePlanPoint): number { + const dx = a[0] - b[0] + const dz = a[1] - b[1] + return dx * dx + dz * dz +} + +function projectPointOntoSegment( + point: FencePlanPoint, + segment: SegmentNode, +): FencePlanPoint | null { + const [x1, z1] = segment.start + const [x2, z2] = segment.end + const dx = x2 - x1 + const dz = z2 - z1 + const lengthSquared = dx * dx + dz * dz + if (lengthSquared < 1e-9) { + return null + } + + const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared + if (t <= 0 || t >= 1) { + return null + } + + return [x1 + dx * t, z1 + dz * t] +} + +function findFenceSnapTarget( + point: FencePlanPoint, + fences: FenceNode[], + ignoreFenceIds: string[] = [], +): FencePlanPoint | null { + const radiusSquared = 0.35 ** 2 + const ignoredFenceIds = new Set(ignoreFenceIds) + let bestTarget: FencePlanPoint | null = null + let bestDistanceSquared = Number.POSITIVE_INFINITY + + for (const fence of fences) { + if (ignoredFenceIds.has(fence.id)) { + continue + } + + const candidates: Array = [ + fence.start, + fence.end, + projectPointOntoSegment(point, fence), + ] + + for (const candidate of candidates) { + if (!candidate) { + continue + } + + const candidateDistanceSquared = distanceSquared(point, candidate) + if ( + candidateDistanceSquared > radiusSquared || + candidateDistanceSquared >= bestDistanceSquared + ) { + continue + } + + bestTarget = candidate + bestDistanceSquared = candidateDistanceSquared + } + } + + return bestTarget +} + +export function snapFenceDraftPoint(args: { + point: FencePlanPoint + walls: WallNode[] + fences: FenceNode[] + start?: FencePlanPoint + angleSnap?: boolean + ignoreFenceIds?: string[] +}): FencePlanPoint { + const { point, walls, fences, start, angleSnap = false, ignoreFenceIds } = args + const basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point) + const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds) + + return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint +} + +export function createFenceOnCurrentLevel( + start: FencePlanPoint, + end: FencePlanPoint, +): FenceNode | null { + const currentLevelId = useViewer.getState().selection.levelId + const { createNode, nodes } = useScene.getState() + + if (!(currentLevelId && isWallLongEnough(start, end))) { + return null + } + + const fenceCount = Object.values(nodes).filter((node) => node.type === 'fence').length + const fence = FenceNode.parse({ + name: `Fence ${fenceCount + 1}`, + start, + end, + }) + + createNode(fence, currentLevelId) + sfxEmitter.emit('sfx:structure-build') + + return fence +} diff --git a/packages/editor/src/components/tools/fence/fence-tool.tsx b/packages/editor/src/components/tools/fence/fence-tool.tsx new file mode 100644 index 00000000..d7091fd1 --- /dev/null +++ b/packages/editor/src/components/tools/fence/fence-tool.tsx @@ -0,0 +1,190 @@ +import { + emitter, + type FenceNode, + type GridEvent, + type LevelNode, + useScene, + type WallNode, +} from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useRef } from 'react' +import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three' +import { markToolCancelConsumed } from '../../../hooks/use-keyboard' +import { EDITOR_LAYER } from '../../../lib/constants' +import { sfxEmitter } from '../../../lib/sfx-bus' +import { CursorSphere } from '../shared/cursor-sphere' +import { + createFenceOnCurrentLevel, + snapFenceDraftPoint, + type FencePlanPoint, +} from './fence-drafting' + +const FENCE_PREVIEW_HEIGHT = 1.8 + +const updateFencePreview = (mesh: Mesh, start: Vector3, end: Vector3) => { + const direction = new Vector3(end.x - start.x, 0, end.z - start.z) + const length = direction.length() + + if (length < 0.01) { + mesh.visible = false + return + } + + mesh.visible = true + direction.normalize() + + const shape = new Shape() + shape.moveTo(0, 0) + shape.lineTo(length, 0) + shape.lineTo(length, FENCE_PREVIEW_HEIGHT) + shape.lineTo(0, FENCE_PREVIEW_HEIGHT) + shape.closePath() + + const geometry = new ShapeGeometry(shape) + const angle = -Math.atan2(direction.z, direction.x) + + mesh.position.set(start.x, start.y, start.z) + mesh.rotation.y = angle + + if (mesh.geometry) { + mesh.geometry.dispose() + } + mesh.geometry = geometry +} + +const getCurrentLevelElements = (): { walls: WallNode[]; fences: FenceNode[] } => { + const currentLevelId = useViewer.getState().selection.levelId + const { nodes } = useScene.getState() + + if (!currentLevelId) return { walls: [], fences: [] } + + const levelNode = nodes[currentLevelId] + if (!levelNode || levelNode.type !== 'level') return { walls: [], fences: [] } + + const children = (levelNode as LevelNode).children.map((childId) => nodes[childId]) + + return { + walls: children.filter((node): node is WallNode => node?.type === 'wall'), + fences: children.filter((node): node is FenceNode => node?.type === 'fence'), + } +} + +export const FenceTool: React.FC = () => { + const cursorRef = useRef(null) + const previewRef = useRef(null!) + const startingPoint = useRef(new Vector3(0, 0, 0)) + const endingPoint = useRef(new Vector3(0, 0, 0)) + const buildingState = useRef(0) + const shiftPressed = useRef(false) + + useEffect(() => { + let previousFenceEnd: [number, number] | null = null + + const onGridMove = (event: GridEvent) => { + if (!(cursorRef.current && previewRef.current)) return + + const { walls, fences } = getCurrentLevelElements() + const localPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] + + if (buildingState.current === 1) { + const snappedLocal = snapFenceDraftPoint({ + point: localPoint, + walls, + fences, + start: [startingPoint.current.x, startingPoint.current.z], + angleSnap: !shiftPressed.current, + }) + endingPoint.current.set(snappedLocal[0], event.localPosition[1], snappedLocal[1]) + cursorRef.current.position.copy(endingPoint.current) + + const currentFenceEnd: [number, number] = [snappedLocal[0], snappedLocal[1]] + if ( + previousFenceEnd && + (currentFenceEnd[0] !== previousFenceEnd[0] || currentFenceEnd[1] !== previousFenceEnd[1]) + ) { + sfxEmitter.emit('sfx:grid-snap') + } + previousFenceEnd = currentFenceEnd + + updateFencePreview(previewRef.current, startingPoint.current, endingPoint.current) + } else { + const snappedPoint = snapFenceDraftPoint({ point: localPoint, walls, fences }) + cursorRef.current.position.set(snappedPoint[0], event.localPosition[1], snappedPoint[1]) + } + } + + const onGridClick = (event: GridEvent) => { + const { walls, fences } = getCurrentLevelElements() + const localClick: FencePlanPoint = [event.localPosition[0], event.localPosition[2]] + + if (buildingState.current === 0) { + const snappedStart = snapFenceDraftPoint({ point: localClick, walls, fences }) + startingPoint.current.set(snappedStart[0], event.localPosition[1], snappedStart[1]) + endingPoint.current.copy(startingPoint.current) + buildingState.current = 1 + previewRef.current.visible = true + } else { + const snappedEnd = snapFenceDraftPoint({ + point: localClick, + walls, + fences, + start: [startingPoint.current.x, startingPoint.current.z], + angleSnap: !shiftPressed.current, + }) + const dx = snappedEnd[0] - startingPoint.current.x + const dz = snappedEnd[1] - startingPoint.current.z + if (dx * dx + dz * dz < 0.01 * 0.01) return + createFenceOnCurrentLevel([startingPoint.current.x, startingPoint.current.z], snappedEnd) + previewRef.current.visible = false + buildingState.current = 0 + } + } + + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Shift') shiftPressed.current = true + } + + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Shift') shiftPressed.current = false + } + + const onCancel = () => { + if (buildingState.current === 1) { + markToolCancelConsumed() + buildingState.current = 0 + previewRef.current.visible = false + } + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('tool:cancel', onCancel) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + + return () => { + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('tool:cancel', onCancel) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + } + }, []) + + return ( + + + + + + + + ) +} diff --git a/packages/editor/src/components/tools/fence/move-fence-tool.tsx b/packages/editor/src/components/tools/fence/move-fence-tool.tsx new file mode 100644 index 00000000..4fb36354 --- /dev/null +++ b/packages/editor/src/components/tools/fence/move-fence-tool.tsx @@ -0,0 +1,223 @@ +'use client' + +import { type AnyNodeId, type FenceNode, emitter, type GridEvent, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useCallback, useEffect, useRef, useState } from 'react' +import { markToolCancelConsumed } from '../../../hooks/use-keyboard' +import { sfxEmitter } from '../../../lib/sfx-bus' +import useEditor from '../../../store/use-editor' +import { CursorSphere } from '../shared/cursor-sphere' + +function snap(value: number) { + return Math.round(value * 2) / 2 +} + +function samePoint(a: [number, number], b: [number, number]) { + return a[0] === b[0] && a[1] === b[1] +} + +type LinkedFenceSnapshot = { + id: FenceNode['id'] + start: [number, number] + end: [number, number] +} + +function getLinkedFenceSnapshots(args: { + fenceId: FenceNode['id'] + originalStart: [number, number] + originalEnd: [number, number] +}) { + const { fenceId, originalStart, originalEnd } = args + const { nodes } = useScene.getState() + const snapshots: LinkedFenceSnapshot[] = [] + + for (const node of Object.values(nodes)) { + if (!(node?.type === 'fence' && node.id !== fenceId)) { + continue + } + + if ( + !samePoint(node.start, originalStart) && + !samePoint(node.start, originalEnd) && + !samePoint(node.end, originalStart) && + !samePoint(node.end, originalEnd) + ) { + continue + } + + snapshots.push({ + id: node.id, + start: [...node.start] as [number, number], + end: [...node.end] as [number, number], + }) + } + + return snapshots +} + +function getLinkedFenceUpdates( + linkedFences: LinkedFenceSnapshot[], + originalStart: [number, number], + originalEnd: [number, number], + nextStart: [number, number], + nextEnd: [number, number], +) { + return linkedFences.map((fence) => ({ + id: fence.id, + start: samePoint(fence.start, originalStart) + ? nextStart + : samePoint(fence.start, originalEnd) + ? nextEnd + : fence.start, + end: samePoint(fence.end, originalStart) + ? nextStart + : samePoint(fence.end, originalEnd) + ? nextEnd + : fence.end, + })) +} + +export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => { + const previousGridPosRef = useRef<[number, number] | null>(null) + const originalStartRef = useRef<[number, number]>([...node.start] as [number, number]) + const originalEndRef = useRef<[number, number]>([...node.end] as [number, number]) + const linkedOriginalsRef = useRef( + getLinkedFenceSnapshots({ + fenceId: node.id, + originalStart: node.start, + originalEnd: node.end, + }), + ) + const dragAnchorRef = useRef<[number, number] | null>(null) + const nodeIdRef = useRef(node.id) + const previewRef = useRef<{ start: [number, number]; end: [number, number] } | null>(null) + + const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => { + const centerX = (node.start[0] + node.end[0]) / 2 + const centerZ = (node.start[1] + node.end[1]) / 2 + return [centerX, 0, centerZ] + }) + + const exitMoveMode = useCallback(() => { + useEditor.getState().setMovingNode(null) + }, []) + + useEffect(() => { + const nodeId = nodeIdRef.current + const originalStart = originalStartRef.current + const originalEnd = originalEndRef.current + + useScene.temporal.getState().pause() + let wasCommitted = false + + const applyNodePreview = (updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>) => { + useScene.getState().updateNodes( + updates.map((entry) => ({ + id: entry.id as AnyNodeId, + data: { start: entry.start, end: entry.end }, + })), + ) + for (const entry of updates) { + useScene.getState().markDirty(entry.id as AnyNodeId) + } + } + + const applyPreview = (nextStart: [number, number], nextEnd: [number, number]) => { + previewRef.current = { start: nextStart, end: nextEnd } + const centerX = (nextStart[0] + nextEnd[0]) / 2 + const centerZ = (nextStart[1] + nextEnd[1]) / 2 + setCursorLocalPos([centerX, 0, centerZ]) + applyNodePreview([ + { id: nodeId, start: nextStart, end: nextEnd }, + ...getLinkedFenceUpdates( + linkedOriginalsRef.current, + originalStart, + originalEnd, + nextStart, + nextEnd, + ), + ]) + } + + const onGridMove = (event: GridEvent) => { + const localX = snap(event.localPosition[0]) + const localZ = snap(event.localPosition[2]) + + if ( + previousGridPosRef.current && + (localX !== previousGridPosRef.current[0] || localZ !== previousGridPosRef.current[1]) + ) { + sfxEmitter.emit('sfx:grid-snap') + } + previousGridPosRef.current = [localX, localZ] + + const anchor = dragAnchorRef.current ?? [localX, localZ] + dragAnchorRef.current = anchor + + const deltaX = localX - anchor[0] + const deltaZ = localZ - anchor[1] + + const nextStart: [number, number] = [originalStart[0] + deltaX, originalStart[1] + deltaZ] + const nextEnd: [number, number] = [originalEnd[0] + deltaX, originalEnd[1] + deltaZ] + + applyPreview(nextStart, nextEnd) + } + + const onGridClick = (event: GridEvent) => { + const preview = previewRef.current ?? { start: originalStart, end: originalEnd } + + wasCommitted = true + useScene.temporal.getState().resume() + applyNodePreview([ + { id: nodeId, start: preview.start, end: preview.end }, + ...getLinkedFenceUpdates( + linkedOriginalsRef.current, + originalStart, + originalEnd, + preview.start, + preview.end, + ), + ]) + useScene.temporal.getState().pause() + + sfxEmitter.emit('sfx:item-place') + useViewer.getState().setSelection({ selectedIds: [nodeId] }) + exitMoveMode() + event.nativeEvent?.stopPropagation?.() + } + + const onCancel = () => { + applyNodePreview([ + { id: nodeId, start: originalStart, end: originalEnd }, + ...linkedOriginalsRef.current, + ]) + useViewer.getState().setSelection({ selectedIds: [nodeId] }) + useScene.temporal.getState().resume() + markToolCancelConsumed() + exitMoveMode() + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + emitter.on('tool:cancel', onCancel) + + return () => { + if (!wasCommitted) { + applyNodePreview([ + { id: nodeId, start: originalStart, end: originalEnd }, + ...linkedOriginalsRef.current, + ]) + } + useScene.temporal.getState().resume() + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + emitter.off('tool:cancel', onCancel) + } + }, [exitMoveMode]) + + return ( + + + + ) +} diff --git a/packages/editor/src/components/tools/item/move-tool.tsx b/packages/editor/src/components/tools/item/move-tool.tsx index 3ea47ddf..20cefbf5 100644 --- a/packages/editor/src/components/tools/item/move-tool.tsx +++ b/packages/editor/src/components/tools/item/move-tool.tsx @@ -1,6 +1,7 @@ import type { BuildingNode, DoorNode, + FenceNode, ItemNode, RoofNode, RoofSegmentNode, @@ -13,6 +14,7 @@ import { sfxEmitter } from '../../../lib/sfx-bus' import useEditor from '../../../store/use-editor' import { MoveBuildingContent } from '../building/move-building-tool' import { MoveDoorTool } from '../door/move-door-tool' +import { MoveFenceTool } from '../fence/move-fence-tool' import { MoveRoofTool } from '../roof/move-roof-tool' import { MoveWindowTool } from '../window/move-window-tool' import type { PlacementState } from './placement-types' @@ -86,6 +88,7 @@ export const MoveTool: React.FC = () => { return if (movingNode.type === 'door') return if (movingNode.type === 'window') return + if (movingNode.type === 'fence') return if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') return if (movingNode.type === 'stair' || movingNode.type === 'stair-segment') diff --git a/packages/editor/src/components/tools/select/box-select-tool.tsx b/packages/editor/src/components/tools/select/box-select-tool.tsx index 5166ca31..8dd700a6 100644 --- a/packages/editor/src/components/tools/select/box-select-tool.tsx +++ b/packages/editor/src/components/tools/select/box-select-tool.tsx @@ -197,7 +197,7 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] { const node = nodes[childId as AnyNodeId] if (!node) continue - if (node.type === 'wall') { + if (node.type === 'wall' || node.type === 'fence') { const wall = node as WallNode if ( segmentIntersectsBounds(wall.start[0], wall.start[1], wall.end[0], wall.end[1], bounds) @@ -205,7 +205,7 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] { result.push(wall.id) } // Check wall children (doors/windows) - for (const itemId of wall.children) { + for (const itemId of Array.isArray(wall.children) ? wall.children : []) { const child = nodes[itemId as AnyNodeId] if (!child) continue if ( diff --git a/packages/editor/src/components/tools/stair/stair-defaults.ts b/packages/editor/src/components/tools/stair/stair-defaults.ts index 6ccd1f5e..ace03f5e 100644 --- a/packages/editor/src/components/tools/stair/stair-defaults.ts +++ b/packages/editor/src/components/tools/stair/stair-defaults.ts @@ -1,3 +1,4 @@ +export const DEFAULT_STAIR_TYPE = 'straight' as const export const DEFAULT_STAIR_WIDTH = 1.0 export const DEFAULT_STAIR_LENGTH = 3.0 export const DEFAULT_STAIR_HEIGHT = 2.5 @@ -5,3 +6,12 @@ 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 +export const DEFAULT_CURVED_STAIR_INNER_RADIUS = 0.9 +export const DEFAULT_CURVED_STAIR_SWEEP_ANGLE = Math.PI / 2 +export const DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE = (400 * Math.PI) / 180 +export const DEFAULT_SPIRAL_TOP_LANDING_MODE = 'none' as const +export const DEFAULT_SPIRAL_TOP_LANDING_DEPTH = 0.9 +export const DEFAULT_SPIRAL_SHOW_CENTER_COLUMN = true +export const DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS = true +export const DEFAULT_STAIR_RAILING_MODE = 'right' as const +export const DEFAULT_STAIR_RAILING_HEIGHT = 0.92 diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index a9093611..2ed9031a 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -13,12 +13,21 @@ import * as THREE from 'three' import { sfxEmitter } from '../../../lib/sfx-bus' import { CursorSphere } from '../shared/cursor-sphere' import { + DEFAULT_CURVED_STAIR_INNER_RADIUS, + DEFAULT_CURVED_STAIR_SWEEP_ANGLE, + DEFAULT_SPIRAL_SHOW_CENTER_COLUMN, + DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS, + DEFAULT_SPIRAL_TOP_LANDING_DEPTH, + DEFAULT_SPIRAL_TOP_LANDING_MODE, DEFAULT_STAIR_ATTACHMENT_SIDE, DEFAULT_STAIR_FILL_TO_FLOOR, DEFAULT_STAIR_HEIGHT, DEFAULT_STAIR_LENGTH, + DEFAULT_STAIR_RAILING_HEIGHT, + DEFAULT_STAIR_RAILING_MODE, DEFAULT_STAIR_STEP_COUNT, DEFAULT_STAIR_THICKNESS, + DEFAULT_STAIR_TYPE, DEFAULT_STAIR_WIDTH, } from './stair-defaults' @@ -88,6 +97,20 @@ function commitStairPlacement( name, position, rotation, + stairType: DEFAULT_STAIR_TYPE, + width: DEFAULT_STAIR_WIDTH, + totalRise: DEFAULT_STAIR_HEIGHT, + stepCount: DEFAULT_STAIR_STEP_COUNT, + thickness: DEFAULT_STAIR_THICKNESS, + fillToFloor: DEFAULT_STAIR_FILL_TO_FLOOR, + innerRadius: DEFAULT_CURVED_STAIR_INNER_RADIUS, + sweepAngle: DEFAULT_CURVED_STAIR_SWEEP_ANGLE, + topLandingMode: DEFAULT_SPIRAL_TOP_LANDING_MODE, + topLandingDepth: DEFAULT_SPIRAL_TOP_LANDING_DEPTH, + showCenterColumn: DEFAULT_SPIRAL_SHOW_CENTER_COLUMN, + showStepSupports: DEFAULT_SPIRAL_SHOW_STEP_SUPPORTS, + railingHeight: DEFAULT_STAIR_RAILING_HEIGHT, + railingMode: DEFAULT_STAIR_RAILING_MODE, children: [segment.id], }) diff --git a/packages/editor/src/components/tools/tool-manager.tsx b/packages/editor/src/components/tools/tool-manager.tsx index c806c502..50373250 100644 --- a/packages/editor/src/components/tools/tool-manager.tsx +++ b/packages/editor/src/components/tools/tool-manager.tsx @@ -11,6 +11,7 @@ import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor' import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor' import { CeilingTool } from './ceiling/ceiling-tool' import { DoorTool } from './door/door-tool' +import { FenceTool } from './fence/fence-tool' import { ItemTool } from './item/item-tool' import { MoveTool } from './item/move-tool' import { RoofTool } from './roof/roof-tool' @@ -30,6 +31,7 @@ const tools: Record>> = { }, structure: { wall: WallTool, + fence: FenceTool, slab: SlabTool, ceiling: CeilingTool, roof: RoofTool, diff --git a/packages/editor/src/components/tools/window/window-math.ts b/packages/editor/src/components/tools/window/window-math.ts index 842634a2..182611a2 100644 --- a/packages/editor/src/components/tools/window/window-math.ts +++ b/packages/editor/src/components/tools/window/window-math.ts @@ -77,7 +77,7 @@ export function hasWallChildOverlap( const newLeft = clampedX - halfW const newRight = clampedX + halfW - for (const childId of wallNode.children) { + for (const childId of Array.isArray(wallNode.children) ? wallNode.children : []) { if (childId === ignoreId) continue const child = nodes[childId as AnyNodeId] if (!child) continue diff --git a/packages/editor/src/components/ui/action-menu/structure-tools.tsx b/packages/editor/src/components/ui/action-menu/structure-tools.tsx index 6e731cae..d0cf44b5 100644 --- a/packages/editor/src/components/ui/action-menu/structure-tools.tsx +++ b/packages/editor/src/components/ui/action-menu/structure-tools.tsx @@ -28,6 +28,7 @@ export const tools: ToolConfig[] = [ { 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: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' }, { id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' }, ] diff --git a/packages/editor/src/components/ui/panels/fence-panel.tsx b/packages/editor/src/components/ui/panels/fence-panel.tsx new file mode 100644 index 00000000..a61ff783 --- /dev/null +++ b/packages/editor/src/components/ui/panels/fence-panel.tsx @@ -0,0 +1,184 @@ +'use client' + +import { type AnyNode, type AnyNodeId, type FenceBaseStyle, type FenceNode, type FenceStyle, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import { useCallback } from 'react' +import { PanelSection } from '../controls/panel-section' +import { SegmentedControl } from '../controls/segmented-control' +import { SliderControl } from '../controls/slider-control' +import { PanelWrapper } from './panel-wrapper' + +const FENCE_STYLE_OPTIONS: { label: string; value: FenceStyle }[] = [ + { label: 'Slat', value: 'slat' }, + { label: 'Rail', value: 'rail' }, + { label: 'Privacy', value: 'privacy' }, +] + +const FENCE_BASE_STYLE_OPTIONS: { label: string; value: FenceBaseStyle }[] = [ + { label: 'Grounded', value: 'grounded' }, + { label: 'Floating', value: 'floating' }, +] + +export function FencePanel() { + 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 selectedId = selectedIds[0] + const node = selectedId ? (nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined + + const handleUpdate = useCallback( + (updates: Partial) => { + if (!selectedId) return + updateNode(selectedId as AnyNode['id'], updates) + useScene.getState().dirtyNodes.add(selectedId as AnyNodeId) + }, + [selectedId, updateNode], + ) + + const handleUpdateLength = useCallback( + (newLength: number) => { + if (!node || newLength <= 0) return + + const dx = node.end[0] - node.start[0] + const dz = node.end[1] - node.start[1] + const currentLength = Math.sqrt(dx * dx + dz * dz) + if (currentLength === 0) return + + const dirX = dx / currentLength + const dirZ = dz / currentLength + const newEnd: [number, number] = [ + node.start[0] + dirX * newLength, + node.start[1] + dirZ * newLength, + ] + + handleUpdate({ end: newEnd }) + }, + [node, handleUpdate], + ) + + const handleClose = useCallback(() => { + setSelection({ selectedIds: [] }) + }, [setSelection]) + + if (!node || node.type !== 'fence' || selectedIds.length !== 1) return null + + const dx = node.end[0] - node.start[0] + const dz = node.end[1] - node.start[1] + const length = Math.sqrt(dx * dx + dz * dz) + + return ( + + + handleUpdate({ style: value })} + options={FENCE_STYLE_OPTIONS} + value={node.style} + /> + handleUpdate({ baseStyle: value })} + options={FENCE_BASE_STYLE_OPTIONS} + value={node.baseStyle} + /> + + + + + handleUpdate({ height: Math.max(0.4, value) })} + precision={2} + step={0.05} + unit="m" + value={node.height} + /> + handleUpdate({ thickness: Math.max(0.03, value) })} + precision={3} + step={0.005} + unit="m" + value={node.thickness} + /> + + + + handleUpdate({ baseHeight: Math.max(0.04, value) })} + precision={3} + step={0.01} + unit="m" + value={node.baseHeight} + /> + handleUpdate({ topRailHeight: Math.max(0.01, value) })} + precision={3} + step={0.005} + unit="m" + value={node.topRailHeight} + /> + handleUpdate({ postSpacing: Math.max(0.2, value) })} + precision={2} + step={0.05} + unit="m" + value={node.postSpacing} + /> + handleUpdate({ postSize: Math.max(0.01, value) })} + precision={3} + step={0.005} + unit="m" + value={node.postSize} + /> + handleUpdate({ groundClearance: Math.max(0, value) })} + precision={3} + step={0.005} + unit="m" + value={node.groundClearance} + /> + handleUpdate({ edgeInset: Math.max(0.005, value) })} + precision={3} + step={0.005} + unit="m" + value={node.edgeInset} + /> + + + ) +} diff --git a/packages/editor/src/components/ui/panels/panel-manager.tsx b/packages/editor/src/components/ui/panels/panel-manager.tsx index 4455842c..7ff860c0 100755 --- a/packages/editor/src/components/ui/panels/panel-manager.tsx +++ b/packages/editor/src/components/ui/panels/panel-manager.tsx @@ -5,6 +5,7 @@ import { useViewer } from '@pascal-app/viewer' import useEditor from '../../../store/use-editor' import { CeilingPanel } from './ceiling-panel' import { DoorPanel } from './door-panel' +import { FencePanel } from './fence-panel' import { ItemPanel } from './item-panel' import { ReferencePanel } from './reference-panel' import { RoofPanel } from './roof-panel' @@ -47,6 +48,8 @@ export function PanelManager() { return case 'wall': return + case 'fence': + return case 'door': return case 'window': diff --git a/packages/editor/src/components/ui/panels/stair-panel.tsx b/packages/editor/src/components/ui/panels/stair-panel.tsx index a6dd60f9..c91e9684 100644 --- a/packages/editor/src/components/ui/panels/stair-panel.tsx +++ b/packages/editor/src/components/ui/panels/stair-panel.tsx @@ -5,6 +5,9 @@ import { type AnyNodeId, type MaterialSchema, type StairNode, + type StairRailingMode, + type StairTopLandingMode, + type StairType, StairNode as StairNodeSchema, type StairSegmentNode, StairSegmentNode as StairSegmentNodeSchema, @@ -15,19 +18,41 @@ 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 { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults' 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 { ToggleControl } from '../controls/toggle-control' import { PanelWrapper } from './panel-wrapper' +const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [ + { label: 'None', value: 'none' }, + { label: 'Left', value: 'left' }, + { label: 'Right', value: 'right' }, + { label: 'Both', value: 'both' }, +] + +const STAIR_TYPE_OPTIONS: { label: string; value: StairType }[] = [ + { label: 'Straight', value: 'straight' }, + { label: 'Curved', value: 'curved' }, + { label: 'Spiral', value: 'spiral' }, +] + +const TOP_LANDING_MODE_OPTIONS: { label: string; value: StairTopLandingMode }[] = [ + { label: 'None', value: 'none' }, + { label: 'Integrated', value: 'integrated' }, +] + 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 createNodes = useScene((s) => s.createNodes) const setMovingNode = useEditor((s) => s.setMovingNode) const selectedId = selectedIds[0] @@ -114,7 +139,8 @@ export function StairPanel() { let duplicateInfo = structuredClone(node) as any delete duplicateInfo.id - duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true } + duplicateInfo.metadata = { ...duplicateInfo.metadata } + duplicateInfo.children = [] duplicateInfo.position = [ duplicateInfo.position[0] + 1, duplicateInfo.position[1], @@ -123,29 +149,31 @@ export function StairPanel() { 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 || [] + const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [ + { node: duplicate, parentId: duplicate.parentId as AnyNodeId }, + ] 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 } + childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata } const childDuplicate = StairSegmentNodeSchema.parse(childDuplicateInfo) - useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId) + createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId }) } } - setSelection({ selectedIds: [] }) - setMovingNode(duplicate) + createNodes(createOps) + + setSelection({ selectedIds: [duplicate.id as AnyNode['id']] }) } catch (e) { console.error('Failed to duplicate stair', e) } - }, [node, setSelection, setMovingNode]) + }, [createNodes, node, setSelection]) const handleMove = useCallback(() => { if (node) { @@ -179,34 +207,159 @@ export function StairPanel() { title={node.name || 'Staircase'} width={300} > - -

- {segments.map((seg, i) => ( - - ))} -
-
- } - label="Add flight" - onClick={handleAddFlight} - /> - } - label="Add landing" - onClick={handleAddLanding} - /> -
+ + + handleUpdate( + value === 'spiral' && node.stairType !== 'spiral' + ? { + stairType: value, + sweepAngle: DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE, + position: [node.position[0], 0, node.position[2]], + } + : { stairType: value }, + ) + } + options={STAIR_TYPE_OPTIONS} + value={node.stairType ?? 'straight'} + /> + {node.stairType === 'straight' && ( + +
+ {segments.map((seg, i) => ( + + ))} +
+
+ } + label="Add flight" + onClick={handleAddFlight} + /> + } + label="Add landing" + onClick={handleAddLanding} + /> +
+
+ )} + + {(node.stairType === 'curved' || node.stairType === 'spiral') && ( + + handleUpdate({ width: value })} + precision={2} + step={0.05} + unit="m" + value={Math.round((node.width ?? 1) * 100) / 100} + /> + handleUpdate({ totalRise: value })} + precision={2} + step={0.05} + unit="m" + value={Math.round((node.totalRise ?? 2.5) * 100) / 100} + /> + handleUpdate({ stepCount: Math.max(2, Math.round(value)) })} + precision={0} + step={1} + unit="" + value={Math.max(2, Math.round(node.stepCount ?? 10))} + /> + {node.stairType !== 'spiral' && ( + handleUpdate({ fillToFloor: checked })} + /> + )} + {(node.stairType === 'spiral' || !(node.fillToFloor ?? true)) && ( + handleUpdate({ thickness: value })} + precision={2} + step={0.01} + unit="m" + value={Math.round((node.thickness ?? 0.25) * 100) / 100} + /> + )} + handleUpdate({ innerRadius: value })} + precision={2} + step={0.05} + unit="m" + value={Math.round((node.innerRadius ?? 0.9) * 100) / 100} + /> + handleUpdate({ sweepAngle: (degrees * Math.PI) / 180 })} + precision={0} + step={1} + unit="°" + value={Math.round(((node.sweepAngle ?? Math.PI / 2) * 180) / Math.PI)} + /> + {node.stairType === 'spiral' && ( + <> + handleUpdate({ topLandingMode: value })} + options={TOP_LANDING_MODE_OPTIONS} + value={node.topLandingMode ?? 'none'} + /> + {(node.topLandingMode ?? 'none') === 'integrated' && ( + handleUpdate({ topLandingDepth: value })} + precision={2} + step={0.05} + unit="m" + value={Math.round((node.topLandingDepth ?? 0.9) * 100) / 100} + /> + )} + handleUpdate({ showCenterColumn: checked })} + /> + handleUpdate({ showStepSupports: checked })} + /> + + )} + + )} + + + handleUpdate({ railingMode: value })} + options={RAILING_MODE_OPTIONS} + value={node.railingMode ?? 'none'} + /> + {(node.railingMode ?? 'none') !== 'none' && ( + handleUpdate({ railingHeight: value })} + precision={2} + step={0.02} + unit="m" + value={Math.round((node.railingHeight ?? 0.92) * 100) / 100} + /> + )} + + } label="Move" onClick={handleMove} /> diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx new file mode 100644 index 00000000..0e2206fa --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/fence-tree-node.tsx @@ -0,0 +1,65 @@ +import { type AnyNodeId, type FenceNode, useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' +import Image from 'next/image' +import { 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' + +interface FenceTreeNodeProps { + nodeId: AnyNodeId + depth: number + isLast?: boolean +} + +export function FenceTreeNode({ nodeId, depth, isLast }: FenceTreeNodeProps) { + const node = useScene((state) => state.nodes[nodeId]) as FenceNode | undefined + const [isEditing, setIsEditing] = useState(false) + const selectedIds = useViewer((state) => state.selection.selectedIds) + const isSelected = selectedIds.includes(nodeId) + const isHovered = useViewer((state) => state.hoveredId === nodeId) + const setSelection = useViewer((state) => state.setSelection) + const setHoveredId = useViewer((state) => state.setHoveredId) + + if (!node) return null + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation() + const handled = handleTreeSelection(e, nodeId, selectedIds, setSelection) + if (!handled && useEditor.getState().phase === 'furnish') { + useEditor.getState().setPhase('structure') + } + } + + return ( + } + depth={depth} + expanded={false} + hasChildren={false} + icon={ + + } + isHovered={isHovered} + isLast={isLast} + isSelected={isSelected} + isVisible={node.visible !== false} + label={ + setIsEditing(true)} + onStopEditing={() => setIsEditing(false)} + /> + } + nodeId={nodeId} + onClick={handleClick} + onDoubleClick={() => focusTreeNode(nodeId)} + onMouseEnter={() => setHoveredId(nodeId)} + onMouseLeave={() => setHoveredId(null)} + onToggle={() => {}} + /> + ) +} diff --git a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx index f2b069b1..eabf547e 100644 --- a/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx +++ b/packages/editor/src/components/ui/sidebar/panels/site-panel/tree-node.tsx @@ -57,6 +57,7 @@ import { cn } from '../../../../../lib/utils' import { BuildingTreeNode } from './building-tree-node' import { CeilingTreeNode } from './ceiling-tree-node' import { DoorTreeNode } from './door-tree-node' +import { FenceTreeNode } from './fence-tree-node' import { ItemTreeNode } from './item-tree-node' import { LevelTreeNode } from './level-tree-node' import { RoofTreeNode } from './roof-tree-node' @@ -88,6 +89,8 @@ export function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) { return case 'wall': return + case 'fence': + return case 'roof': return case 'stair': diff --git a/packages/editor/src/components/viewer-overlay.tsx b/packages/editor/src/components/viewer-overlay.tsx index 06f19a13..72b89c7f 100755 --- a/packages/editor/src/components/viewer-overlay.tsx +++ b/packages/editor/src/components/viewer-overlay.tsx @@ -62,6 +62,7 @@ const wallModeConfig = { const getNodeName = (node: AnyNode): string => { if ('name' in node && node.name) return node.name if (node.type === 'wall') return 'Wall' + if (node.type === 'fence') return 'Fence' if (node.type === 'item') return (node as { asset: { name: string } }).asset?.name || 'Item' if (node.type === 'slab') return 'Slab' if (node.type === 'ceiling') return 'Ceiling' diff --git a/packages/editor/src/hooks/use-contextual-tools.ts b/packages/editor/src/hooks/use-contextual-tools.ts index 50beff88..e24ec6fe 100644 --- a/packages/editor/src/hooks/use-contextual-tools.ts +++ b/packages/editor/src/hooks/use-contextual-tools.ts @@ -16,7 +16,15 @@ export function useContextualTools() { } // Default tools when nothing is selected - const defaultTools: StructureTool[] = ['wall', 'slab', 'ceiling', 'roof', 'door', 'window'] + const defaultTools: StructureTool[] = [ + 'wall', + 'fence', + 'slab', + 'ceiling', + 'roof', + 'door', + 'window', + ] if (selection.selectedIds.length === 0) { return defaultTools @@ -29,7 +37,7 @@ export function useContextualTools() { // If a wall is selected, prioritize wall-hosted elements if (selectedTypes.has('wall')) { - return ['window', 'door', 'wall'] as StructureTool[] + return ['window', 'door', 'wall', 'fence'] as StructureTool[] } // If a slab is selected, prioritize slab editing diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index dd4dd2f0..6ace07b8 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -4,6 +4,7 @@ import type { AssetInput } from '@pascal-app/core' import { type BuildingNode, type DoorNode, + type FenceNode, type ItemNode, type LevelNode, type RoofNode, @@ -33,6 +34,7 @@ export type Mode = 'select' | 'edit' | 'delete' | 'build' // Structure mode tools (building elements) export type StructureTool = | 'wall' + | 'fence' | 'room' | 'custom-room' | 'slab' @@ -85,6 +87,7 @@ type EditorState = { | ItemNode | WindowNode | DoorNode + | FenceNode | RoofNode | RoofSegmentNode | StairNode @@ -96,6 +99,7 @@ type EditorState = { | ItemNode | WindowNode | DoorNode + | FenceNode | RoofNode | RoofSegmentNode | StairNode diff --git a/packages/viewer/src/components/renderers/fence/fence-renderer.tsx b/packages/viewer/src/components/renderers/fence/fence-renderer.tsx new file mode 100644 index 00000000..008e083d --- /dev/null +++ b/packages/viewer/src/components/renderers/fence/fence-renderer.tsx @@ -0,0 +1,22 @@ +import { type FenceNode, useRegistry, useScene } from '@pascal-app/core' +import { useLayoutEffect, useMemo, useRef } from 'react' +import type { Mesh } from 'three' +import { useNodeEvents } from '../../../hooks/use-node-events' +import { DEFAULT_STAIR_MATERIAL } from '../../../lib/materials' + +export const FenceRenderer = ({ node }: { node: FenceNode }) => { + const ref = useRef(null!) + const handlers = useNodeEvents(node, 'fence') + const material = useMemo(() => DEFAULT_STAIR_MATERIAL, []) + + useRegistry(node.id, 'fence', ref) + useLayoutEffect(() => { + useScene.getState().markDirty(node.id) + }, [node.id]) + + return ( + + + + ) +} diff --git a/packages/viewer/src/components/renderers/node-renderer.tsx b/packages/viewer/src/components/renderers/node-renderer.tsx index 1347e372..827f6893 100644 --- a/packages/viewer/src/components/renderers/node-renderer.tsx +++ b/packages/viewer/src/components/renderers/node-renderer.tsx @@ -4,6 +4,7 @@ import { type AnyNode, useScene } from '@pascal-app/core' import { BuildingRenderer } from './building/building-renderer' import { CeilingRenderer } from './ceiling/ceiling-renderer' import { DoorRenderer } from './door/door-renderer' +import { FenceRenderer } from './fence/fence-renderer' import { GuideRenderer } from './guide/guide-renderer' import { ItemRenderer } from './item/item-renderer' import { LevelRenderer } from './level/level-renderer' @@ -32,6 +33,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { {node.type === 'item' && } {node.type === 'slab' && } {node.type === 'wall' && } + {node.type === 'fence' && } {node.type === 'door' && } {node.type === 'window' && } {node.type === 'zone' && } diff --git a/packages/viewer/src/components/renderers/stair/stair-renderer.tsx b/packages/viewer/src/components/renderers/stair/stair-renderer.tsx index efeb24ad..d90d5a3c 100644 --- a/packages/viewer/src/components/renderers/stair/stair-renderer.tsx +++ b/packages/viewer/src/components/renderers/stair/stair-renderer.tsx @@ -1,10 +1,40 @@ -import { type StairNode, useRegistry, useScene } from '@pascal-app/core' +import { type AnyNodeId, type StairNode, type StairSegmentNode, useRegistry, useScene } from '@pascal-app/core' import { useLayoutEffect, useMemo, useRef } from 'react' -import type * as THREE from 'three' +import * as THREE from 'three' import { useNodeEvents } from '../../../hooks/use-node-events' import { createMaterial, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials' import { NodeRenderer } from '../node-renderer' +type SegmentTransform = { + position: [number, number, number] + rotation: number +} + +type StairRailPathSide = 'left' | 'right' | 'front' + +type StairRailSidePath = { + side: StairRailPathSide + points: [number, number, number][] +} + +type StairSegmentRailPath = { + layout: StairRailLayout + sidePaths: StairRailSidePath[] + connectFromPrevious: boolean +} + +type StairRailLayout = { + center: [number, number] + elevation: number + rotation: number + segment: StairSegmentNode +} + +type LandingChainNextStair = { + nextStairLayout?: StairRailLayout + isTerminalLandingBeforeStair: boolean +} + export const StairRenderer = ({ node }: { node: StairNode }) => { const ref = useRef(null!) @@ -34,6 +64,10 @@ export const StairRenderer = ({ node }: { node: StairNode }) => { + {node.stairType === 'curved' || node.stairType === 'spiral' ? ( + + ) : null} + {(node.children ?? []).map((childId) => ( @@ -42,3 +76,764 @@ export const StairRenderer = ({ node }: { node: StairNode }) => { ) } + +function StairRailings({ stair, material }: { stair: StairNode; material: THREE.Material }) { + const nodes = useScene((state) => state.nodes) + + const segments = useMemo( + () => + (stair.children ?? []) + .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined) + .filter((node): node is StairSegmentNode => node?.type === 'stair-segment' && node.visible !== false), + [nodes, stair.children], + ) + + const railPaths = useMemo(() => buildStairRailPaths(segments, stair.railingMode ?? 'none'), [segments, stair.railingMode]) + + const railHeight = stair.railingHeight ?? 0.92 + const midRailHeight = Math.max(railHeight * 0.45, 0.35) + const railRadius = 0.022 + const balusterRadius = 0.018 + + if ((stair.railingMode ?? 'none') === 'none') { + return null + } + + if (stair.stairType === 'curved' || stair.stairType === 'spiral') { + const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10)) + const sweepAngle = stair.sweepAngle ?? (stair.stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2) + const stepSweep = sweepAngle / stepCount + const stepHeight = Math.max(stair.totalRise ?? 2.5, 0.1) / stepCount + const innerRadius = Math.max(stair.stairType === 'spiral' ? 0.05 : 0.2, stair.innerRadius ?? 0.9) + const outerRadius = innerRadius + Math.max(stair.width ?? 1, 0.4) + const leftRadius = sweepAngle >= 0 ? innerRadius + 0.04 : outerRadius - 0.04 + const rightRadius = sweepAngle >= 0 ? outerRadius - 0.04 : innerRadius + 0.04 + const radii = + stair.railingMode === 'both' + ? [leftRadius, rightRadius] + : stair.railingMode === 'left' + ? [leftRadius] + : stair.railingMode === 'right' + ? [rightRadius] + : [] + + return ( + + {radii.map((radius, sideIndex) => { + const sidePoints = Array.from({ length: stepCount }).map((_, index) => { + const angle = -sweepAngle / 2 + stepSweep * index + stepSweep / 2 + return [ + Math.cos(angle) * radius, + stair.stairType === 'spiral' + ? stepHeight * index + Math.max(stair.thickness ?? 0.25, 0.02) + : stepHeight * (index + 1), + Math.sin(angle) * radius, + ] as [number, number, number] + }) + + return ( + + {sidePoints.map((point, pointIndex) => ( + + ))} + {sidePoints.slice(0, -1).map((point, pointIndex) => { + const nextPoint = sidePoints[pointIndex + 1] + if (!nextPoint) return null + + return ( + + + + + ) + })} + + ) + })} + + ) + } + + if (railPaths.length === 0) { + return null + } + + return ( + + {railPaths.map((segmentPath, index) => ( + + {segmentPath.sidePaths.map((sidePath, sideIndex) => ( + + {sidePath.points.map((point, pointIndex) => ( + + ))} + {sidePath.points.slice(0, -1).map((point, pointIndex) => { + const nextPoint = sidePath.points[pointIndex + 1] + if (!nextPoint) return null + + return ( + + + + + ) + })} + + ))} + + ))} + {railPaths.slice(1).map((segmentPath, index) => { + const previousPath = railPaths[index] + if (!previousPath || !segmentPath.connectFromPrevious) return null + if (previousPath.layout.segment.segmentType === 'landing') return null + if (segmentPath.layout.segment.segmentType === 'landing') return null + + return segmentPath.sidePaths.map((sidePath, sideIndex) => { + const currentPoint = sidePath.points[0] + if (!currentPoint) return null + + const currentWorldPoint = toWorldRailPoint(segmentPath.layout, currentPoint) + const previousSidePath = [...previousPath.sidePaths] + .map((entry) => { + const lastPoint = entry.points[entry.points.length - 1] + return { + entry, + distance: lastPoint ? distance3(toWorldRailPoint(previousPath.layout, lastPoint), currentWorldPoint) : Number.POSITIVE_INFINITY, + } + }) + .sort((left, right) => left.distance - right.distance)[0]?.entry + const previousPoint = + previousSidePath && previousSidePath.points.length + ? previousSidePath.points[previousSidePath.points.length - 1] + : null + + if (!(previousPoint && currentPoint)) { + return null + } + + const previousWorldPoint = toWorldRailPoint(previousPath.layout, previousPoint) + + return ( + + + + + ) + }) + })} + + ) +} + +const BALUSTER_GEOMETRY = new THREE.CylinderGeometry(1, 1, 1, 8) +const RAIL_GEOMETRY = new THREE.CylinderGeometry(1, 1, 1, 8) + +function RailSegment({ + start, + end, + radius, + material, +}: { + start: [number, number, number] + end: [number, number, number] + radius: number + material: THREE.Material +}) { + const startVector = useMemo(() => new THREE.Vector3(...start), [start]) + const endVector = useMemo(() => new THREE.Vector3(...end), [end]) + const direction = useMemo(() => endVector.clone().sub(startVector), [endVector, startVector]) + const length = Math.max(direction.length(), 0.01) + const quaternion = useMemo( + () => new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction.clone().normalize()), + [direction], + ) + const midpoint = useMemo(() => startVector.clone().add(endVector).multiplyScalar(0.5), [endVector, startVector]) + + return ( + + ) +} + +function CurvedStairBody({ stair, material }: { stair: StairNode; material: THREE.Material }) { + const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10)) + const totalRise = Math.max(stair.totalRise ?? 2.5, 0.1) + const stepHeight = totalRise / stepCount + const isSpiral = stair.stairType === 'spiral' + const innerRadius = Math.max(isSpiral ? 0.05 : 0.2, stair.innerRadius ?? 0.9) + const outerRadius = innerRadius + Math.max(stair.width ?? 1, 0.4) + const sweepAngle = stair.sweepAngle ?? (isSpiral ? Math.PI * 2 : Math.PI / 2) + const stepSweep = sweepAngle / stepCount + const thickness = Math.max(stair.thickness ?? 0.25, 0.02) + const fillToFloor = stair.fillToFloor ?? true + const spiralColumnRadius = Math.max(0.05, Math.min(innerRadius * 0.72, innerRadius - 0.03)) + const spiralColumnHeight = totalRise + thickness + const spiralLandingDepth = Math.max(0.3, stair.topLandingDepth ?? Math.max((stair.width ?? 1) * 0.9, 0.8)) + const spiralLandingSweep = + isSpiral && (stair.topLandingMode ?? 'none') === 'integrated' + ? Math.min(Math.PI * 0.75, spiralLandingDepth / Math.max(innerRadius + (stair.width ?? 1) / 2, 0.1)) * + Math.sign(sweepAngle || 1) + : 0 + const spiralLastStepTop = stepHeight * Math.max(stepCount - 1, 0) + thickness + const spiralLandingThickness = + isSpiral && (stair.topLandingMode ?? 'none') === 'integrated' + ? Math.max(0.02, totalRise - spiralLastStepTop) + : 0 + + return ( + + {isSpiral && (stair.showCenterColumn ?? true) ? ( + + + + ) : null} + {Array.from({ length: stepCount }).map((_, index) => { + const currentHeight = stepHeight * (index + 1) + const actualStepHeight = isSpiral ? thickness : fillToFloor ? Math.max(currentHeight, thickness) : thickness + const startAngle = -sweepAngle / 2 + stepSweep * index + const endAngle = startAngle + stepSweep + const stepY = isSpiral ? stepHeight * index : fillToFloor ? 0 : Math.max(currentHeight - thickness, 0) + const midAngle = startAngle + stepSweep / 2 + + return ( + + {isSpiral && (stair.showStepSupports ?? true) ? ( + + + + ) : null} + + + ) + })} + {isSpiral && (stair.topLandingMode ?? 'none') === 'integrated' ? ( + + ) : null} + + ) +} + +function CurvedStepMesh({ + innerRadius, + outerRadius, + startAngle, + endAngle, + stepHeight, + thickness, + positionY, + material, +}: { + innerRadius: number + outerRadius: number + startAngle: number + endAngle: number + stepHeight: number + thickness: number + positionY: number + material: THREE.Material +}) { + const geometry = useMemo( + () => buildCurvedStepGeometry(innerRadius, outerRadius, startAngle, endAngle, Math.max(stepHeight, thickness)), + [endAngle, innerRadius, outerRadius, startAngle, stepHeight, thickness], + ) + + return +} + +function buildCurvedStepGeometry( + innerRadius: number, + outerRadius: number, + startAngle: number, + endAngle: number, + height: number, +) { + const clampedHeight = Math.max(height, 0.02) + const y0 = 0 + const y1 = clampedHeight + const sweepAngle = endAngle - startAngle + const sweepDirection = Math.sign(sweepAngle) || 1 + const segmentCount = Math.max(4, Math.min(24, Math.ceil(Math.abs(sweepAngle) / (Math.PI / 18) + Math.max(0, (outerRadius - innerRadius) * 3)))) + + const positions: number[] = [] + const normals: number[] = [] + + const pointOnArc = (radius: number, angle: number, y: number) => + new THREE.Vector3(Math.cos(angle) * radius, y, Math.sin(angle) * radius) + + const pushTriangle = (a: THREE.Vector3, b: THREE.Vector3, c: THREE.Vector3, normal: THREE.Vector3) => { + const edgeAB = b.clone().sub(a) + const edgeAC = c.clone().sub(a) + const faceNormal = edgeAB.cross(edgeAC) + const ordered = faceNormal.dot(normal) >= 0 ? [a, b, c] : [a, c, b] + for (const point of ordered) { + positions.push(point.x, point.y, point.z) + normals.push(normal.x, normal.y, normal.z) + } + } + + const pushQuad = (a: THREE.Vector3, b: THREE.Vector3, c: THREE.Vector3, d: THREE.Vector3, normal: THREE.Vector3) => { + pushTriangle(a, b, c, normal) + pushTriangle(a, c, d, normal) + } + + const upNormal = new THREE.Vector3(0, 1, 0) + const downNormal = new THREE.Vector3(0, -1, 0) + + for (let index = 0; index < segmentCount; index++) { + const t0 = index / segmentCount + const t1 = (index + 1) / segmentCount + const segStart = startAngle + sweepAngle * t0 + const segEnd = startAngle + sweepAngle * t1 + const midAngle = (segStart + segEnd) / 2 + + const innerStartBottom = pointOnArc(innerRadius, segStart, y0) + const innerEndBottom = pointOnArc(innerRadius, segEnd, y0) + const outerStartBottom = pointOnArc(outerRadius, segStart, y0) + const outerEndBottom = pointOnArc(outerRadius, segEnd, y0) + const innerStartTop = pointOnArc(innerRadius, segStart, y1) + const innerEndTop = pointOnArc(innerRadius, segEnd, y1) + const outerStartTop = pointOnArc(outerRadius, segStart, y1) + const outerEndTop = pointOnArc(outerRadius, segEnd, y1) + + const outerNormal = new THREE.Vector3(Math.cos(midAngle), 0, Math.sin(midAngle)).normalize() + const innerNormal = new THREE.Vector3(-Math.cos(midAngle), 0, -Math.sin(midAngle)).normalize() + + pushQuad(innerStartTop, outerStartTop, outerEndTop, innerEndTop, upNormal) + pushQuad(innerStartBottom, innerEndBottom, outerEndBottom, outerStartBottom, downNormal) + pushQuad(innerStartBottom, innerStartTop, innerEndTop, innerEndBottom, innerNormal) + pushQuad(outerStartBottom, outerEndBottom, outerEndTop, outerStartTop, outerNormal) + } + + const startInnerBottom = pointOnArc(innerRadius, startAngle, y0) + const startOuterBottom = pointOnArc(outerRadius, startAngle, y0) + const startInnerTop = pointOnArc(innerRadius, startAngle, y1) + const startOuterTop = pointOnArc(outerRadius, startAngle, y1) + const endInnerBottom = pointOnArc(innerRadius, endAngle, y0) + const endOuterBottom = pointOnArc(outerRadius, endAngle, y0) + const endInnerTop = pointOnArc(innerRadius, endAngle, y1) + const endOuterTop = pointOnArc(outerRadius, endAngle, y1) + const startNormal = new THREE.Vector3( + sweepDirection * Math.sin(startAngle), + 0, + -sweepDirection * Math.cos(startAngle), + ).normalize() + const endNormal = new THREE.Vector3( + -sweepDirection * Math.sin(endAngle), + 0, + sweepDirection * Math.cos(endAngle), + ).normalize() + + pushQuad(startInnerBottom, startOuterBottom, startOuterTop, startInnerTop, startNormal) + pushQuad(endInnerBottom, endInnerTop, endOuterTop, endOuterBottom, endNormal) + + const geometry = new THREE.BufferGeometry() + geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) + geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3)) + geometry.computeVertexNormals() + return geometry +} + +function buildStairRailPaths( + segments: StairSegmentNode[], + railingMode: StairNode['railingMode'], +): StairSegmentRailPath[] { + if (!segments.length || railingMode === 'none') return [] + + const layouts = computeStairRailLayouts(segments) + const landingInset = 0.08 + + if (railingMode === 'both') { + const isStraightLineDoubleLandingLayout = + layouts.length === 4 && + layouts[0]?.segment.segmentType === 'stair' && + layouts[1]?.segment.segmentType === 'landing' && + layouts[2]?.segment.segmentType === 'stair' && + layouts[2]?.segment.attachmentSide === 'front' && + layouts[3]?.segment.segmentType === 'landing' && + layouts[3]?.segment.attachmentSide === 'front' + + return layouts.map((layout, index) => { + const previousLayout = index > 0 ? layouts[index - 1] : undefined + const nextLayout = layouts[index + 1] + const { nextStairLayout, isTerminalLandingBeforeStair } = resolveLandingChainNextStair(layouts, index) + const hideLandingRailing = + layout.segment.segmentType === 'landing' && + previousLayout?.segment.segmentType === 'stair' && + nextLayout?.segment.segmentType === 'stair' + const visualTurnSide = + isTerminalLandingBeforeStair && nextStairLayout?.segment.attachmentSide + ? nextStairLayout.segment.attachmentSide + : nextLayout?.segment.attachmentSide + const sideCandidates = + isTerminalLandingBeforeStair && layout.segment.segmentType === 'landing' + ? visualTurnSide === 'left' + ? (['front', 'right'] as const) + : visualTurnSide === 'right' + ? (['front', 'left'] as const) + : (['left', 'right'] as const) + : hideLandingRailing + ? visualTurnSide === 'left' + ? (['front', 'right'] as const) + : visualTurnSide === 'right' + ? (['front', 'left'] as const) + : (['left', 'right'] as const) + : layout.segment.segmentType === 'landing' + ? nextLayout?.segment.segmentType === 'landing' && visualTurnSide === 'left' + ? (['front', 'right'] as const) + : nextLayout?.segment.segmentType === 'landing' && visualTurnSide === 'right' + ? (['front', 'left'] as const) + : visualTurnSide === 'left' + ? (['right'] as const) + : visualTurnSide === 'right' + ? (['left'] as const) + : (['left', 'right'] as const) + : (['left', 'right'] as const) + + return { + layout, + sidePaths: + isStraightLineDoubleLandingLayout && index === 1 + ? (['left', 'right'] as const).map((side) => buildSegmentRailPath(layouts, index, side, landingInset)) + : sideCandidates.map((side) => buildSegmentRailPath(layouts, index, side, landingInset)), + connectFromPrevious: + index > 0 && + !(previousLayout?.segment.segmentType === 'landing' && layout.segment.segmentType === 'landing'), + } + }) + } + + const isStraightLineDoubleLandingLayout = + layouts.length === 4 && + layouts[0]?.segment.segmentType === 'stair' && + layouts[1]?.segment.segmentType === 'landing' && + layouts[2]?.segment.segmentType === 'stair' && + layouts[2]?.segment.attachmentSide === 'front' && + layouts[3]?.segment.segmentType === 'landing' && + layouts[3]?.segment.attachmentSide === 'front' + + return layouts.map((layout, index) => { + const previousLayout = index > 0 ? layouts[index - 1] : undefined + const nextLayout = layouts[index + 1] + const { nextStairLayout, isTerminalLandingBeforeStair } = resolveLandingChainNextStair(layouts, index) + const isMiddleLandingBetweenFlights = + layout.segment.segmentType === 'landing' && + previousLayout?.segment.segmentType === 'stair' && + nextLayout?.segment.segmentType === 'stair' + const nextAttachmentSide = nextLayout?.segment.attachmentSide + const terminalNextAttachmentSide = nextStairLayout?.segment.attachmentSide + const suppressMiddleLandingOnPreferredTurnSide = + isMiddleLandingBetweenFlights && + nextAttachmentSide != null && + nextAttachmentSide !== 'front' && + nextAttachmentSide === railingMode + const suppressLandingRailing = + (layout.segment.segmentType === 'landing' && + nextLayout?.segment.segmentType === 'landing' && + nextAttachmentSide === railingMode) || + suppressMiddleLandingOnPreferredTurnSide + const landingContinuesOnPreferredSide = + layout.segment.segmentType === 'landing' + ? nextAttachmentSide == null || nextAttachmentSide === 'front' || nextAttachmentSide === railingMode + : true + + const sideCandidates = + suppressLandingRailing + ? ([] as StairRailPathSide[]) + : layout.segment.segmentType !== 'landing' + ? [railingMode] + : isTerminalLandingBeforeStair + ? railingMode === 'left' + ? terminalNextAttachmentSide === 'right' + ? (['front', 'left'] as const) + : terminalNextAttachmentSide === 'front' || terminalNextAttachmentSide == null + ? (['left'] as const) + : ([] as StairRailPathSide[]) + : railingMode === 'right' + ? terminalNextAttachmentSide === 'left' + ? (['front', 'right'] as const) + : terminalNextAttachmentSide === 'front' || terminalNextAttachmentSide == null + ? (['right'] as const) + : ([] as StairRailPathSide[]) + : [railingMode] + : isStraightLineDoubleLandingLayout + ? [railingMode] + : isMiddleLandingBetweenFlights && railingMode === 'left' + ? nextAttachmentSide === 'right' + ? (['front', 'left'] as const) + : (['left'] as const) + : isMiddleLandingBetweenFlights && railingMode === 'right' + ? nextAttachmentSide === 'left' + ? (['front', 'right'] as const) + : (['right'] as const) + : nextLayout?.segment.segmentType === 'landing' && + nextAttachmentSide != null && + nextAttachmentSide !== 'front' && + nextAttachmentSide !== railingMode + ? (['front', railingMode] as StairRailPathSide[]) + : [railingMode] + + return { + layout, + sidePaths: sideCandidates.map((side) => buildSegmentRailPath(layouts, index, side, landingInset)), + connectFromPrevious: + index > 0 && + !suppressLandingRailing && + sideCandidates.length > 0 && + (layout.segment.segmentType === 'landing' ? landingContinuesOnPreferredSide : true), + } + }) +} + +function resolveLandingChainNextStair(layouts: StairRailLayout[], index: number): LandingChainNextStair { + const layout = layouts[index] + if (!layout || layout.segment.segmentType !== 'landing') { + return { isTerminalLandingBeforeStair: false } + } + + let cursor = index + while (cursor + 1 < layouts.length && layouts[cursor + 1]?.segment.segmentType === 'landing') { + cursor += 1 + } + + const nextStairLayout = + cursor + 1 < layouts.length && layouts[cursor + 1]?.segment.segmentType === 'stair' + ? layouts[cursor + 1] + : undefined + + return { + nextStairLayout, + isTerminalLandingBeforeStair: Boolean(nextStairLayout) && cursor === index, + } +} + +function computeStairRailLayouts(segments: StairSegmentNode[]): StairRailLayout[] { + const transforms = computeSegmentTransforms(segments) + return segments.map((segment, index) => { + const transform = transforms[index]! + const [centerOffsetX, centerOffsetZ] = rotateXZ(0, segment.length / 2, transform.rotation) + return { + center: [transform.position[0] + centerOffsetX, transform.position[2] + centerOffsetZ], + elevation: transform.position[1], + rotation: transform.rotation, + segment, + } + }) +} + +function buildSegmentRailPath( + layouts: StairRailLayout[], + layoutIndex: number, + side: StairRailPathSide, + landingInset: number, +): StairRailSidePath { + const layout = layouts[layoutIndex]! + const segment = layout.segment + const previousLayout = layoutIndex > 0 ? layouts[layoutIndex - 1] : undefined + const nextLayout = layoutIndex >= 0 ? layouts[layoutIndex + 1] : undefined + const steps = Math.max(1, segment.segmentType === 'landing' ? 1 : segment.stepCount) + const stepDepth = segment.length / steps + const stepHeight = segment.segmentType === 'landing' ? 0 : segment.height / steps + const flightSideOffset = side === 'left' ? segment.width / 2 - 0.045 : -segment.width / 2 + 0.045 + const flightStartX = + previousLayout?.segment.segmentType === 'landing' ? -segment.length / 2 + landingInset : -segment.length / 2 + const flightEndX = + nextLayout?.segment.segmentType === 'landing' ? segment.length / 2 - landingInset : segment.length / 2 + const landingFrontX = + previousLayout?.segment.segmentType === 'stair' && + segment.attachmentSide && + segment.attachmentSide !== 'front' + ? -segment.length / 2 + landingInset + : segment.length / 2 - landingInset + + if (segment.segmentType === 'landing') { + const backX = -segment.length / 2 + landingInset + const frontX = segment.length / 2 - landingInset + const leftZ = segment.width / 2 - landingInset + const rightZ = -segment.width / 2 + landingInset + + return { + side, + points: + side === 'left' + ? [ + [backX, 0, leftZ], + [frontX, 0, leftZ], + ] + : side === 'right' + ? [ + [backX, 0, rightZ], + [frontX, 0, rightZ], + ] + : [ + [landingFrontX, 0, leftZ], + [landingFrontX, 0, rightZ], + ], + } + } + + return { + side, + points: [ + ...(previousLayout?.segment.segmentType === 'landing' ? [] : ([[flightStartX, stepHeight > 0 ? stepHeight : 0, flightSideOffset]] as [number, number, number][])), + ...Array.from({ length: steps }).map( + (_, index) => + [ + -segment.length / 2 + stepDepth * index + stepDepth / 2, + stepHeight * (index + 1), + flightSideOffset, + ] as [number, number, number], + ), + ...(nextLayout?.segment.segmentType === 'landing' + ? [] + : ([[flightEndX, segment.height, flightSideOffset]] as [number, number, number][])), + ], + } +} + +function toWorldRailPoint(layout: StairRailLayout, point: [number, number, number]): [number, number, number] { + const [localX, localY, localZ] = point + const [offsetX, offsetZ] = rotateXZ(localZ, localX, layout.rotation) + return [layout.center[0] + offsetX, layout.elevation + localY, layout.center[1] + offsetZ] +} + +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 }) + continue + } + + 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) + 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 + } + + 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 +} + +function rotateXZ(x: number, z: number, angle: number): [number, number] { + const cos = Math.cos(angle) + const sin = Math.sin(angle) + return [x * cos + z * sin, -x * sin + z * cos] +} + +function distance3(a: [number, number, number], b: [number, number, number]) { + return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]) +} diff --git a/packages/viewer/src/components/viewer/index.tsx b/packages/viewer/src/components/viewer/index.tsx index 1cf3b243..ec1d97bf 100644 --- a/packages/viewer/src/components/viewer/index.tsx +++ b/packages/viewer/src/components/viewer/index.tsx @@ -3,6 +3,7 @@ import { CeilingSystem, DoorSystem, + FenceSystem, ItemSystem, RoofSystem, SlabSystem, @@ -138,6 +139,7 @@ const Viewer: React.FC = ({ {/* Core systems */} + diff --git a/packages/viewer/src/components/viewer/selection-manager.tsx b/packages/viewer/src/components/viewer/selection-manager.tsx index 9174d02d..81424157 100644 --- a/packages/viewer/src/components/viewer/selection-manager.tsx +++ b/packages/viewer/src/components/viewer/selection-manager.tsx @@ -29,6 +29,7 @@ type SelectableNodeType = | 'level' | 'zone' | 'wall' + | 'fence' | 'window' | 'door' | 'item' @@ -138,6 +139,13 @@ const isNodeInZone = (node: AnyNode, levelId: string, zoneId: string): boolean = return startIn || endIn } + if (node.type === 'fence') { + const fence = node as { start: [number, number]; end: [number, number] } + const startIn = pointInPolygonWithTolerance(fence.start[0], fence.start[1], zone.polygon) + const endIn = pointInPolygonWithTolerance(fence.end[0], fence.end[1], zone.polygon) + return startIn || endIn + } + if (node.type === 'slab' || node.type === 'ceiling') { const poly = (node as { polygon: [number, number][] }).polygon if (!poly?.length) return false @@ -221,7 +229,7 @@ const getStrategy = (): SelectionStrategy | null => { // Zone selected -> can select/hover contents (walls, items, slabs, ceilings, roofs, windows, doors) return { - types: ['wall', 'item', 'slab', 'ceiling', 'roof', 'roof-segment', 'window', 'door'], + types: ['wall', 'fence', 'item', 'slab', 'ceiling', 'roof', 'roof-segment', 'window', 'door'], handleClick: (node, nativeEvent) => { let nodeToSelect = node if (node.type === 'roof-segment' && node.parentId) { @@ -248,6 +256,7 @@ const getStrategy = (): SelectionStrategy | null => { isValid: (node) => { const validTypes = [ 'wall', + 'fence', 'item', 'slab', 'ceiling', @@ -303,6 +312,7 @@ export const SelectionManager = () => { 'level', 'zone', 'wall', + 'fence', 'item', 'slab', 'ceiling', diff --git a/packages/viewer/src/hooks/use-node-events.ts b/packages/viewer/src/hooks/use-node-events.ts index 46cec9d3..d247ece1 100644 --- a/packages/viewer/src/hooks/use-node-events.ts +++ b/packages/viewer/src/hooks/use-node-events.ts @@ -7,6 +7,8 @@ import { type DoorNode, type EventSuffix, emitter, + type FenceEvent, + type FenceNode, type ItemEvent, type ItemNode, type LevelEvent, @@ -37,6 +39,7 @@ type NodeConfig = { site: { node: SiteNode; event: SiteEvent } item: { node: ItemNode; event: ItemEvent } wall: { node: WallNode; event: WallEvent } + fence: { node: FenceNode; event: FenceEvent } building: { node: BuildingNode; event: BuildingEvent } level: { node: LevelNode; event: LevelEvent } zone: { node: ZoneNode; event: ZoneEvent }