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 <noreply@anthropic.com>

* feat: update fence icon with new isometric design

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Aymeric Rabot <aymeric@pascal.app>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Sudhir Yadav
2026-04-13 23:31:57 -04:00
committed by GitHub
co-authored by Claude Opus 4.6 Aymeric Rabot
parent 682e2a1a12
commit a205e4f778
42 changed files with 2774 additions and 92 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

+2 -2
View File
@@ -48,7 +48,7 @@
}, },
"packages/core": { "packages/core": {
"name": "@pascal-app/core", "name": "@pascal-app/core",
"version": "0.4.0", "version": "0.5.0",
"dependencies": { "dependencies": {
"dedent": "^1.7.1", "dedent": "^1.7.1",
"idb-keyval": "^6.2.2", "idb-keyval": "^6.2.2",
@@ -166,7 +166,7 @@
}, },
"packages/viewer": { "packages/viewer": {
"name": "@pascal-app/viewer", "name": "@pascal-app/viewer",
"version": "0.4.0", "version": "0.5.0",
"dependencies": { "dependencies": {
"polygon-clipping": "^0.15.7", "polygon-clipping": "^0.15.7",
"zustand": "^5", "zustand": "^5",
+3
View File
@@ -4,6 +4,7 @@ import type {
BuildingNode, BuildingNode,
CeilingNode, CeilingNode,
DoorNode, DoorNode,
FenceNode,
ItemNode, ItemNode,
LevelNode, LevelNode,
RoofNode, RoofNode,
@@ -41,6 +42,7 @@ export interface NodeEvent<T extends AnyNode = AnyNode> {
} }
export type WallEvent = NodeEvent<WallNode> export type WallEvent = NodeEvent<WallNode>
export type FenceEvent = NodeEvent<FenceNode>
export type ItemEvent = NodeEvent<ItemNode> export type ItemEvent = NodeEvent<ItemNode>
export type SiteEvent = NodeEvent<SiteNode> export type SiteEvent = NodeEvent<SiteNode>
export type BuildingEvent = NodeEvent<BuildingNode> export type BuildingEvent = NodeEvent<BuildingNode>
@@ -111,6 +113,7 @@ type ThumbnailEvents = {
type EditorEvents = GridEvents & type EditorEvents = GridEvents &
NodeEvents<'wall', WallEvent> & NodeEvents<'wall', WallEvent> &
NodeEvents<'fence', FenceEvent> &
NodeEvents<'item', ItemEvent> & NodeEvents<'item', ItemEvent> &
NodeEvents<'site', SiteEvent> & NodeEvents<'site', SiteEvent> &
NodeEvents<'building', BuildingEvent> & NodeEvents<'building', BuildingEvent> &
@@ -15,6 +15,7 @@ export const sceneRegistry = {
ceiling: new Set<string>(), ceiling: new Set<string>(),
level: new Set<string>(), level: new Set<string>(),
wall: new Set<string>(), wall: new Set<string>(),
fence: new Set<string>(),
item: new Set<string>(), item: new Set<string>(),
slab: new Set<string>(), slab: new Set<string>(),
zone: new Set<string>(), zone: new Set<string>(),
+2
View File
@@ -4,6 +4,7 @@ export type {
CeilingEvent, CeilingEvent,
DoorEvent, DoorEvent,
EventSuffix, EventSuffix,
FenceEvent,
GridEvent, GridEvent,
ItemEvent, ItemEvent,
LevelEvent, LevelEvent,
@@ -44,6 +45,7 @@ export {
useInteractive, useInteractive,
} from './store/use-interactive' } from './store/use-interactive'
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms' 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 { clearSceneHistory, default as useScene } from './store/use-scene'
export { CeilingSystem } from './systems/ceiling/ceiling-system' export { CeilingSystem } from './systems/ceiling/ceiling-system'
export { DoorSystem } from './systems/door/door-system' export { DoorSystem } from './systems/door/door-system'
+2 -1
View File
@@ -15,6 +15,7 @@ export {
export { BuildingNode } from './nodes/building' export { BuildingNode } from './nodes/building'
export { CeilingNode } from './nodes/ceiling' export { CeilingNode } from './nodes/ceiling'
export { DoorNode, DoorSegment } from './nodes/door' export { DoorNode, DoorSegment } from './nodes/door'
export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
export { GuideNode } from './nodes/guide' export { GuideNode } from './nodes/guide'
export type { export type {
AnimationEffect, AnimationEffect,
@@ -36,7 +37,7 @@ export { ScanNode } from './nodes/scan'
// Nodes // Nodes
export { SiteNode } from './nodes/site' export { SiteNode } from './nodes/site'
export { SlabNode } from './nodes/slab' 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 { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
export { WallNode } from './nodes/wall' export { WallNode } from './nodes/wall'
export { WindowNode } from './nodes/window' export { WindowNode } from './nodes/window'
+35
View File
@@ -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<typeof FenceNode>
+2
View File
@@ -2,6 +2,7 @@ import dedent from 'dedent'
import { z } from 'zod' import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
import { CeilingNode } from './ceiling' import { CeilingNode } from './ceiling'
import { FenceNode } from './fence'
import { GuideNode } from './guide' import { GuideNode } from './guide'
import { RoofNode } from './roof' import { RoofNode } from './roof'
import { ScanNode } from './scan' import { ScanNode } from './scan'
@@ -17,6 +18,7 @@ export const LevelNode = BaseNode.extend({
.array( .array(
z.union([ z.union([
WallNode.shape.id, WallNode.shape.id,
FenceNode.shape.id,
ZoneNode.shape.id, ZoneNode.shape.id,
SlabNode.shape.id, SlabNode.shape.id,
CeilingNode.shape.id, CeilingNode.shape.id,
+39 -3
View File
@@ -4,6 +4,14 @@ import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material' import { MaterialSchema } from '../material'
import { StairSegmentNode } from './stair-segment' 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<typeof StairRailingMode>
export type StairType = z.infer<typeof StairType>
export type StairTopLandingMode = z.infer<typeof StairTopLandingMode>
export const StairNode = BaseNode.extend({ export const StairNode = BaseNode.extend({
id: objectId('stair'), id: objectId('stair'),
type: nodeType('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]), position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians // Rotation around Y axis in radians
rotation: z.number().default(0), 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 // Child stair segment IDs
children: z.array(StairSegmentNode.shape.id).default([]), children: z.array(StairSegmentNode.shape.id).default([]),
}).describe( }).describe(
dedent` dedent`
Stair node - a container for stair segments. Stair node - a container for stair segments.
Acts as a group that holds one or more StairSegmentNodes (flights and landings). Acts as a group that either holds one or more StairSegmentNodes (straight stairs)
Segments chain together based on their attachmentSide to form complex staircase shapes. or stores stair-level geometry properties for curved stairs.
- position: center position of the stair group - position: center position of the stair group
- rotation: rotation around Y axis - 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
`, `,
) )
+2
View File
@@ -2,6 +2,7 @@ import z from 'zod'
import { BuildingNode } from './nodes/building' import { BuildingNode } from './nodes/building'
import { CeilingNode } from './nodes/ceiling' import { CeilingNode } from './nodes/ceiling'
import { DoorNode } from './nodes/door' import { DoorNode } from './nodes/door'
import { FenceNode } from './nodes/fence'
import { GuideNode } from './nodes/guide' import { GuideNode } from './nodes/guide'
import { ItemNode } from './nodes/item' import { ItemNode } from './nodes/item'
import { LevelNode } from './nodes/level' import { LevelNode } from './nodes/level'
@@ -21,6 +22,7 @@ export const AnyNode = z.discriminatedUnion('type', [
BuildingNode, BuildingNode,
LevelNode, LevelNode,
WallNode, WallNode,
FenceNode,
ItemNode, ItemNode,
ZoneNode, ZoneNode,
SlabNode, SlabNode,
+2 -2
View File
@@ -378,8 +378,8 @@ useScene.temporal.subscribe((state) => {
// Mark sibling nodes dirty so they can update their geometry // Mark sibling nodes dirty so they can update their geometry
// (e.g. adjacent walls need to recalculate miter/junction geometry) // (e.g. adjacent walls need to recalculate miter/junction geometry)
const parent = currentNodes[parentId] const parent = currentNodes[parentId]
if (parent && 'children' in parent) { if (parent && 'children' in parent && Array.isArray(parent.children)) {
for (const childId of (parent as AnyNode & { children: string[] }).children) { for (const childId of parent.children) {
markDirty(childId as AnyNodeId) markDirty(childId as AnyNodeId)
} }
} }
@@ -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
}
@@ -304,15 +304,18 @@ function updateMergedStairGeometry(
const mergedMesh = group.getObjectByName('merged-stair') as THREE.Mesh | undefined const mergedMesh = group.getObjectByName('merged-stair') as THREE.Mesh | undefined
if (!mergedMesh) return if (!mergedMesh) return
if (stairNode.stairType === 'curved' || stairNode.stairType === 'spiral') {
replaceMeshGeometry(mergedMesh, createEmptyGeometry())
return
}
const children = stairNode.children ?? [] const children = stairNode.children ?? []
const segments = children const segments = children
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined) .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment') .filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
if (segments.length === 0) { if (segments.length === 0) {
mergedMesh.geometry.dispose() replaceMeshGeometry(mergedMesh, createEmptyGeometry())
mergedMesh.geometry = new THREE.BufferGeometry()
mergedMesh.geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
return return
} }
@@ -337,11 +340,8 @@ function updateMergedStairGeometry(
geometries.push(geo) geometries.push(geo)
} }
const merged = mergeGeometries(geometries, false) const merged = mergeGeometries(geometries, false) ?? createEmptyGeometry()
if (merged) { replaceMeshGeometry(mergedMesh, merged)
mergedMesh.geometry.dispose()
mergedMesh.geometry = merged
}
// Dispose individual geometries // Dispose individual geometries
for (const geo of 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] 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. * Computes the absolute Y height of a segment by traversing the stair's segment chain.
*/ */
@@ -5,6 +5,7 @@ import {
type AnyNodeId, type AnyNodeId,
type CeilingNode, type CeilingNode,
DoorNode, DoorNode,
FenceNode,
ItemNode, ItemNode,
RoofNode, RoofNode,
RoofSegmentNode, RoofSegmentNode,
@@ -33,6 +34,7 @@ const ALLOWED_TYPES = [
'stair', 'stair',
'stair-segment', 'stair-segment',
'wall', 'wall',
'fence',
'slab', 'slab',
'ceiling', 'ceiling',
] ]
@@ -82,6 +84,7 @@ export function FloatingActionMenu() {
node.type === 'item' || node.type === 'item' ||
node.type === 'window' || node.type === 'window' ||
node.type === 'door' || node.type === 'door' ||
node.type === 'fence' ||
node.type === 'roof' || node.type === 'roof' ||
node.type === 'roof-segment' || node.type === 'roof-segment' ||
node.type === 'stair' || node.type === 'stair' ||
@@ -113,11 +116,18 @@ export function FloatingActionMenu() {
duplicate = WindowNode.parse(duplicateInfo) duplicate = WindowNode.parse(duplicateInfo)
} else if (node.type === 'item') { } else if (node.type === 'item') {
duplicate = ItemNode.parse(duplicateInfo) 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') { } else if (node.type === 'roof') {
duplicate = RoofNode.parse(duplicateInfo) duplicate = RoofNode.parse(duplicateInfo)
} else if (node.type === 'roof-segment') { } else if (node.type === 'roof-segment') {
duplicate = RoofSegmentNode.parse(duplicateInfo) duplicate = RoofSegmentNode.parse(duplicateInfo)
} else if (node.type === 'stair') { } else if (node.type === 'stair') {
duplicateInfo.children = []
duplicateInfo.metadata = { ...duplicateInfo.metadata }
delete duplicateInfo.metadata?.isNew
duplicate = StairNode.parse(duplicateInfo) duplicate = StairNode.parse(duplicateInfo)
} else if (node.type === 'stair-segment') { } else if (node.type === 'stair-segment') {
duplicate = StairSegmentNode.parse(duplicateInfo) duplicate = StairSegmentNode.parse(duplicateInfo)
@@ -130,6 +140,8 @@ export function FloatingActionMenu() {
if (duplicate) { if (duplicate) {
if (duplicate.type === 'door' || duplicate.type === 'window') { if (duplicate.type === 'door' || duplicate.type === 'window') {
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId) useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
} else if (duplicate.type === 'fence') {
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
} else if ( } else if (
duplicate.type === 'roof' || duplicate.type === 'roof' ||
duplicate.type === 'roof-segment' || duplicate.type === 'roof-segment' ||
@@ -144,7 +156,35 @@ export function FloatingActionMenu() {
duplicate.position[2] + 1, duplicate.position[2] + 1,
] ]
} }
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) useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
}
// Duplicate children for roof nodes // Duplicate children for roof nodes
if (node.type === 'roof' && node.children) { if (node.type === 'roof' && node.children) {
@@ -166,37 +206,24 @@ export function FloatingActionMenu() {
} }
// Duplicate children for stair nodes // Duplicate children for stair nodes
if (node.type === 'stair' && node.children) {
const nodesState = useScene.getState().nodes
for (const childId of node.children) {
const childNode = nodesState[childId]
if (childNode && childNode.type === 'stair-segment') {
let childDuplicateInfo = structuredClone(childNode) as any
delete childDuplicateInfo.id
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true }
try {
const childDuplicate = StairSegmentNode.parse(childDuplicateInfo)
useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId)
} catch (e) {
console.error('Failed to duplicate stair segment', e)
}
}
}
}
} }
if ( if (
duplicate.type === 'item' || duplicate.type === 'item' ||
duplicate.type === 'fence' ||
duplicate.type === 'window' || duplicate.type === 'window' ||
duplicate.type === 'door' || duplicate.type === 'door' ||
duplicate.type === 'roof' || duplicate.type === 'roof' ||
duplicate.type === 'roof-segment' || duplicate.type === 'roof-segment' ||
duplicate.type === 'stair' ||
duplicate.type === 'stair-segment' duplicate.type === 'stair-segment'
) { ) {
setMovingNode(duplicate as any) 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], [node, setMovingNode, setSelection],
) )
@@ -8063,6 +8063,7 @@ export function FloorplanPanel() {
...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}), ...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}),
isNew: true, isNew: true,
} }
cloned.children = []
try { try {
const duplicate = ItemNodeSchema.parse(cloned) const duplicate = ItemNodeSchema.parse(cloned)
@@ -8191,8 +8192,8 @@ export function FloorplanPanel() {
delete cloned.id delete cloned.id
cloned.metadata = { cloned.metadata = {
...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}), ...(typeof cloned.metadata === 'object' && cloned.metadata !== null ? cloned.metadata : {}),
isNew: true,
} }
delete (cloned.metadata as Record<string, unknown>).isNew
const nextPosition = const nextPosition =
Array.isArray(cloned.position) && cloned.position.length >= 3 Array.isArray(cloned.position) && cloned.position.length >= 3
@@ -8207,9 +8208,11 @@ export function FloorplanPanel() {
try { try {
const duplicate = StairNodeSchema.parse(cloned) const duplicate = StairNodeSchema.parse(cloned)
useScene.getState().createNode(duplicate, stair.parentId as AnyNodeId)
const nodesState = useScene.getState().nodes const nodesState = useScene.getState().nodes
const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [
{ node: duplicate, parentId: stair.parentId as AnyNodeId },
]
for (const childId of stair.children ?? []) { for (const childId of stair.children ?? []) {
const childNode = nodesState[childId] const childNode = nodesState[childId]
if (childNode?.type !== 'stair-segment') { if (childNode?.type !== 'stair-segment') {
@@ -8222,19 +8225,20 @@ export function FloorplanPanel() {
...(typeof childClone.metadata === 'object' && childClone.metadata !== null ...(typeof childClone.metadata === 'object' && childClone.metadata !== null
? childClone.metadata ? childClone.metadata
: {}), : {}),
isNew: true,
} }
delete (childClone.metadata as Record<string, unknown>).isNew
const childDuplicate = StairSegmentNodeSchema.parse(childClone) const childDuplicate = StairSegmentNodeSchema.parse(childClone)
useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId) createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId })
} }
setMovingNode(duplicate) useScene.getState().createNodes(createOps)
setSelection({ selectedIds: [] })
setSelection({ selectedIds: [duplicate.id as AnyNodeId] })
} catch (error) { } catch (error) {
console.error('Failed to duplicate stair', error) console.error('Failed to duplicate stair', error)
} }
}, [selectedStairEntry, setMovingNode, setSelection]) }, [selectedStairEntry, setSelection])
const handleSelectedStairDuplicate = useCallback( const handleSelectedStairDuplicate = useCallback(
(event: ReactMouseEvent<HTMLButtonElement>) => { (event: ReactMouseEvent<HTMLButtonElement>) => {
event.stopPropagation() event.stopPropagation()
@@ -26,6 +26,7 @@ const isNodeInCurrentLevel = (node: AnyNode): boolean => {
type SelectableNodeType = type SelectableNodeType =
| 'wall' | 'wall'
| 'fence'
| 'item' | 'item'
| 'building' | 'building'
| 'zone' | 'zone'
@@ -186,6 +187,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
structure: { structure: {
types: [ types: [
'wall', 'wall',
'fence',
'item', 'item',
'zone', 'zone',
'slab', 'slab',
@@ -238,6 +240,7 @@ const SELECTION_STRATEGIES: Record<string, SelectionStrategy> = {
} }
if ( if (
node.type === 'wall' || node.type === 'wall' ||
node.type === 'fence' ||
node.type === 'slab' || node.type === 'slab' ||
node.type === 'ceiling' || node.type === 'ceiling' ||
node.type === 'roof' || node.type === 'roof' ||
@@ -299,6 +302,7 @@ const getSelectionTarget = (node: AnyNode): SelectionTarget | null => {
if ( if (
node.type === 'wall' || node.type === 'wall' ||
node.type === 'fence' ||
node.type === 'slab' || node.type === 'slab' ||
node.type === 'ceiling' || node.type === 'ceiling' ||
node.type === 'roof' || node.type === 'roof' ||
@@ -443,6 +447,7 @@ export const SelectionManager = () => {
const allTypes = [ const allTypes = [
'wall', 'wall',
'fence',
'item', 'item',
'building', 'building',
'zone', 'zone',
@@ -537,6 +542,7 @@ export const SelectionManager = () => {
} }
} else if ( } else if (
node.type === 'wall' || node.type === 'wall' ||
node.type === 'fence' ||
node.type === 'slab' || node.type === 'slab' ||
node.type === 'ceiling' || node.type === 'ceiling' ||
node.type === 'roof' || node.type === 'roof' ||
@@ -586,6 +592,7 @@ export const SelectionManager = () => {
const allTypes = [ const allTypes = [
'wall', 'wall',
'fence',
'item', 'item',
'building', 'building',
'slab', 'slab',
@@ -657,6 +664,7 @@ export const SelectionManager = () => {
const allTypes = [ const allTypes = [
'wall', 'wall',
'fence',
'item', 'item',
'slab', 'slab',
'ceiling', 'ceiling',
@@ -1,6 +1,6 @@
import { type AnyNodeId, type StairNode, sceneRegistry, useScene } from '@pascal-app/core' import { type AnyNodeId, type StairNode, sceneRegistry, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect, useRef } from 'react' import { useCallback, useEffect, useRef } from 'react'
/** /**
* Imperatively toggles the Three.js visibility of stair objects based on the * Imperatively toggles the Three.js visibility of stair objects based on the
@@ -17,6 +17,27 @@ import { useEffect, useRef } from 'react'
*/ */
export const StairEditSystem = () => { export const StairEditSystem = () => {
const selectedIds = useViewer((s) => s.selection.selectedIds) 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<string>()) const prevActiveStairIds = useRef(new Set<string>())
useEffect(() => { useEffect(() => {
@@ -41,14 +62,15 @@ export const StairEditSystem = () => {
const group = sceneRegistry.nodes.get(stairId) const group = sceneRegistry.nodes.get(stairId)
if (!group) continue 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 mergedMesh = group.getObjectByName('merged-stair')
const segmentsWrapper = group.getObjectByName('segments-wrapper') const segmentsWrapper = group.getObjectByName('segments-wrapper')
const isActive = activeStairIds.has(stairId) const isActive = activeStairIds.has(stairId)
if (mergedMesh) mergedMesh.visible = !isActive if (mergedMesh) mergedMesh.visible = !isActive && !isCurved
if (segmentsWrapper) segmentsWrapper.visible = isActive if (segmentsWrapper) segmentsWrapper.visible = isActive && !isCurved
const stairNode = nodes[stairId as AnyNodeId] as StairNode | undefined
if (stairNode?.children?.length) { if (stairNode?.children?.length) {
const wasActive = prevActiveStairIds.current.has(stairId) const wasActive = prevActiveStairIds.current.has(stairId)
if (isActive !== wasActive) { if (isActive !== wasActive) {
@@ -63,7 +85,7 @@ export const StairEditSystem = () => {
} }
prevActiveStairIds.current = activeStairIds prevActiveStairIds.current = activeStairIds
}, [selectedIds]) }, [selectedIds, selectedStairSignature])
return null return null
} }
@@ -70,7 +70,7 @@ export function hasWallChildOverlap(
const newLeft = clampedX - halfW const newLeft = clampedX - halfW
const newRight = 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 if (childId === ignoreId) continue
const child = nodes[childId as AnyNodeId] const child = nodes[childId as AnyNodeId]
if (!child) continue if (!child) continue
@@ -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<FencePlanPoint | null> = [
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
}
@@ -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<Group>(null)
const previewRef = useRef<Mesh>(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 (
<group>
<CursorSphere ref={cursorRef} height={FENCE_PREVIEW_HEIGHT} />
<mesh layers={EDITOR_LAYER} ref={previewRef} renderOrder={1} visible={false}>
<shapeGeometry />
<meshBasicMaterial
color="#ffffff"
depthTest={false}
depthWrite={false}
opacity={0.45}
side={DoubleSide}
transparent
/>
</mesh>
</group>
)
}
@@ -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 (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
@@ -1,6 +1,7 @@
import type { import type {
BuildingNode, BuildingNode,
DoorNode, DoorNode,
FenceNode,
ItemNode, ItemNode,
RoofNode, RoofNode,
RoofSegmentNode, RoofSegmentNode,
@@ -13,6 +14,7 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { MoveBuildingContent } from '../building/move-building-tool' import { MoveBuildingContent } from '../building/move-building-tool'
import { MoveDoorTool } from '../door/move-door-tool' import { MoveDoorTool } from '../door/move-door-tool'
import { MoveFenceTool } from '../fence/move-fence-tool'
import { MoveRoofTool } from '../roof/move-roof-tool' import { MoveRoofTool } from '../roof/move-roof-tool'
import { MoveWindowTool } from '../window/move-window-tool' import { MoveWindowTool } from '../window/move-window-tool'
import type { PlacementState } from './placement-types' import type { PlacementState } from './placement-types'
@@ -86,6 +88,7 @@ export const MoveTool: React.FC = () => {
return <MoveBuildingContent node={movingNode as BuildingNode} /> return <MoveBuildingContent node={movingNode as BuildingNode} />
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} /> if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} /> if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment') if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} /> return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment') if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
@@ -197,7 +197,7 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
const node = nodes[childId as AnyNodeId] const node = nodes[childId as AnyNodeId]
if (!node) continue if (!node) continue
if (node.type === 'wall') { if (node.type === 'wall' || node.type === 'fence') {
const wall = node as WallNode const wall = node as WallNode
if ( if (
segmentIntersectsBounds(wall.start[0], wall.start[1], wall.end[0], wall.end[1], bounds) 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) result.push(wall.id)
} }
// Check wall children (doors/windows) // 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] const child = nodes[itemId as AnyNodeId]
if (!child) continue if (!child) continue
if ( if (
@@ -1,3 +1,4 @@
export const DEFAULT_STAIR_TYPE = 'straight' as const
export const DEFAULT_STAIR_WIDTH = 1.0 export const DEFAULT_STAIR_WIDTH = 1.0
export const DEFAULT_STAIR_LENGTH = 3.0 export const DEFAULT_STAIR_LENGTH = 3.0
export const DEFAULT_STAIR_HEIGHT = 2.5 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_ATTACHMENT_SIDE = 'front' as const
export const DEFAULT_STAIR_FILL_TO_FLOOR = true export const DEFAULT_STAIR_FILL_TO_FLOOR = true
export const DEFAULT_STAIR_THICKNESS = 0.25 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
@@ -13,12 +13,21 @@ import * as THREE from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import { CursorSphere } from '../shared/cursor-sphere' import { CursorSphere } from '../shared/cursor-sphere'
import { 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_ATTACHMENT_SIDE,
DEFAULT_STAIR_FILL_TO_FLOOR, DEFAULT_STAIR_FILL_TO_FLOOR,
DEFAULT_STAIR_HEIGHT, DEFAULT_STAIR_HEIGHT,
DEFAULT_STAIR_LENGTH, DEFAULT_STAIR_LENGTH,
DEFAULT_STAIR_RAILING_HEIGHT,
DEFAULT_STAIR_RAILING_MODE,
DEFAULT_STAIR_STEP_COUNT, DEFAULT_STAIR_STEP_COUNT,
DEFAULT_STAIR_THICKNESS, DEFAULT_STAIR_THICKNESS,
DEFAULT_STAIR_TYPE,
DEFAULT_STAIR_WIDTH, DEFAULT_STAIR_WIDTH,
} from './stair-defaults' } from './stair-defaults'
@@ -88,6 +97,20 @@ function commitStairPlacement(
name, name,
position, position,
rotation, 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], children: [segment.id],
}) })
@@ -11,6 +11,7 @@ import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor' import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
import { CeilingTool } from './ceiling/ceiling-tool' import { CeilingTool } from './ceiling/ceiling-tool'
import { DoorTool } from './door/door-tool' import { DoorTool } from './door/door-tool'
import { FenceTool } from './fence/fence-tool'
import { ItemTool } from './item/item-tool' import { ItemTool } from './item/item-tool'
import { MoveTool } from './item/move-tool' import { MoveTool } from './item/move-tool'
import { RoofTool } from './roof/roof-tool' import { RoofTool } from './roof/roof-tool'
@@ -30,6 +31,7 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
}, },
structure: { structure: {
wall: WallTool, wall: WallTool,
fence: FenceTool,
slab: SlabTool, slab: SlabTool,
ceiling: CeilingTool, ceiling: CeilingTool,
roof: RoofTool, roof: RoofTool,
@@ -77,7 +77,7 @@ export function hasWallChildOverlap(
const newLeft = clampedX - halfW const newLeft = clampedX - halfW
const newRight = 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 if (childId === ignoreId) continue
const child = nodes[childId as AnyNodeId] const child = nodes[childId as AnyNodeId]
if (!child) continue if (!child) continue
@@ -28,6 +28,7 @@ export const tools: ToolConfig[] = [
{ id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' }, { id: 'stair', iconSrc: '/icons/stairs.png', label: 'Stairs' },
{ id: 'door', iconSrc: '/icons/door.png', label: 'Door' }, { id: 'door', iconSrc: '/icons/door.png', label: 'Door' },
{ id: 'window', iconSrc: '/icons/window.png', label: 'Window' }, { id: 'window', iconSrc: '/icons/window.png', label: 'Window' },
{ id: 'fence', iconSrc: '/icons/fence.png', label: 'Fence' },
{ id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' }, { id: 'zone', iconSrc: '/icons/zone.png', label: 'Zone' },
] ]
@@ -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<FenceNode>) => {
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 (
<PanelWrapper icon="/icons/build.png" onClose={handleClose} title={node.name || 'Fence'} width={300}>
<PanelSection title="Style">
<SegmentedControl
onChange={(value) => handleUpdate({ style: value })}
options={FENCE_STYLE_OPTIONS}
value={node.style}
/>
<SegmentedControl
className="mt-2"
onChange={(value) => handleUpdate({ baseStyle: value })}
options={FENCE_BASE_STYLE_OPTIONS}
value={node.baseStyle}
/>
</PanelSection>
<PanelSection title="Dimensions">
<SliderControl
label="Length"
max={50}
min={0.1}
onChange={handleUpdateLength}
precision={2}
step={0.01}
unit="m"
value={length}
/>
<SliderControl
label="Height"
max={4}
min={0.4}
onChange={(value) => handleUpdate({ height: Math.max(0.4, value) })}
precision={2}
step={0.05}
unit="m"
value={node.height}
/>
<SliderControl
label="Thickness"
max={0.5}
min={0.03}
onChange={(value) => handleUpdate({ thickness: Math.max(0.03, value) })}
precision={3}
step={0.005}
unit="m"
value={node.thickness}
/>
</PanelSection>
<PanelSection title="Structure">
<SliderControl
label="Base Height"
max={1}
min={0.04}
onChange={(value) => handleUpdate({ baseHeight: Math.max(0.04, value) })}
precision={3}
step={0.01}
unit="m"
value={node.baseHeight}
/>
<SliderControl
label="Top Rail"
max={0.25}
min={0.01}
onChange={(value) => handleUpdate({ topRailHeight: Math.max(0.01, value) })}
precision={3}
step={0.005}
unit="m"
value={node.topRailHeight}
/>
<SliderControl
label="Post Spacing"
max={5}
min={0.2}
onChange={(value) => handleUpdate({ postSpacing: Math.max(0.2, value) })}
precision={2}
step={0.05}
unit="m"
value={node.postSpacing}
/>
<SliderControl
label="Post Size"
max={0.4}
min={0.01}
onChange={(value) => handleUpdate({ postSize: Math.max(0.01, value) })}
precision={3}
step={0.005}
unit="m"
value={node.postSize}
/>
<SliderControl
label="Ground Clear"
max={0.6}
min={0}
onChange={(value) => handleUpdate({ groundClearance: Math.max(0, value) })}
precision={3}
step={0.005}
unit="m"
value={node.groundClearance}
/>
<SliderControl
label="Edge Inset"
max={0.25}
min={0.005}
onChange={(value) => handleUpdate({ edgeInset: Math.max(0.005, value) })}
precision={3}
step={0.005}
unit="m"
value={node.edgeInset}
/>
</PanelSection>
</PanelWrapper>
)
}
@@ -5,6 +5,7 @@ import { useViewer } from '@pascal-app/viewer'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { CeilingPanel } from './ceiling-panel' import { CeilingPanel } from './ceiling-panel'
import { DoorPanel } from './door-panel' import { DoorPanel } from './door-panel'
import { FencePanel } from './fence-panel'
import { ItemPanel } from './item-panel' import { ItemPanel } from './item-panel'
import { ReferencePanel } from './reference-panel' import { ReferencePanel } from './reference-panel'
import { RoofPanel } from './roof-panel' import { RoofPanel } from './roof-panel'
@@ -47,6 +48,8 @@ export function PanelManager() {
return <CeilingPanel /> return <CeilingPanel />
case 'wall': case 'wall':
return <WallPanel /> return <WallPanel />
case 'fence':
return <FencePanel />
case 'door': case 'door':
return <DoorPanel /> return <DoorPanel />
case 'window': case 'window':
@@ -5,6 +5,9 @@ import {
type AnyNodeId, type AnyNodeId,
type MaterialSchema, type MaterialSchema,
type StairNode, type StairNode,
type StairRailingMode,
type StairTopLandingMode,
type StairType,
StairNode as StairNodeSchema, StairNode as StairNodeSchema,
type StairSegmentNode, type StairSegmentNode,
StairSegmentNode as StairSegmentNodeSchema, StairSegmentNode as StairSegmentNodeSchema,
@@ -15,19 +18,41 @@ import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react' import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus' import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor' 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 { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker' import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control' import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper' 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() { export function StairPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedIds = useViewer((s) => s.selection.selectedIds)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes) const nodes = useScene((s) => s.nodes)
const updateNode = useScene((s) => s.updateNode) const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode) const createNode = useScene((s) => s.createNode)
const createNodes = useScene((s) => s.createNodes)
const setMovingNode = useEditor((s) => s.setMovingNode) const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedId = selectedIds[0] const selectedId = selectedIds[0]
@@ -114,7 +139,8 @@ export function StairPanel() {
let duplicateInfo = structuredClone(node) as any let duplicateInfo = structuredClone(node) as any
delete duplicateInfo.id delete duplicateInfo.id
duplicateInfo.metadata = { ...duplicateInfo.metadata, isNew: true } duplicateInfo.metadata = { ...duplicateInfo.metadata }
duplicateInfo.children = []
duplicateInfo.position = [ duplicateInfo.position = [
duplicateInfo.position[0] + 1, duplicateInfo.position[0] + 1,
duplicateInfo.position[1], duplicateInfo.position[1],
@@ -123,29 +149,31 @@ export function StairPanel() {
try { try {
const duplicate = StairNodeSchema.parse(duplicateInfo) const duplicate = StairNodeSchema.parse(duplicateInfo)
useScene.getState().createNode(duplicate, duplicate.parentId as AnyNodeId)
// Also duplicate all child segments
const nodesState = useScene.getState().nodes const nodesState = useScene.getState().nodes
const children = node.children || [] const children = node.children || []
const createOps: { node: AnyNode; parentId?: AnyNodeId }[] = [
{ node: duplicate, parentId: duplicate.parentId as AnyNodeId },
]
for (const childId of children) { for (const childId of children) {
const childNode = nodesState[childId] const childNode = nodesState[childId]
if (childNode && childNode.type === 'stair-segment') { if (childNode && childNode.type === 'stair-segment') {
let childDuplicateInfo = structuredClone(childNode) as any let childDuplicateInfo = structuredClone(childNode) as any
delete childDuplicateInfo.id delete childDuplicateInfo.id
childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata, isNew: true } childDuplicateInfo.metadata = { ...childDuplicateInfo.metadata }
const childDuplicate = StairSegmentNodeSchema.parse(childDuplicateInfo) const childDuplicate = StairSegmentNodeSchema.parse(childDuplicateInfo)
useScene.getState().createNode(childDuplicate, duplicate.id as AnyNodeId) createOps.push({ node: childDuplicate, parentId: duplicate.id as AnyNodeId })
} }
} }
setSelection({ selectedIds: [] }) createNodes(createOps)
setMovingNode(duplicate)
setSelection({ selectedIds: [duplicate.id as AnyNode['id']] })
} catch (e) { } catch (e) {
console.error('Failed to duplicate stair', e) console.error('Failed to duplicate stair', e)
} }
}, [node, setSelection, setMovingNode]) }, [createNodes, node, setSelection])
const handleMove = useCallback(() => { const handleMove = useCallback(() => {
if (node) { if (node) {
@@ -179,6 +207,25 @@ export function StairPanel() {
title={node.name || 'Staircase'} title={node.name || 'Staircase'}
width={300} width={300}
> >
<PanelSection title="Type">
<SegmentedControl
onChange={(value) =>
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'}
/>
</PanelSection>
{node.stairType === 'straight' && (
<PanelSection title="Segments"> <PanelSection title="Segments">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{segments.map((seg, i) => ( {segments.map((seg, i) => (
@@ -206,6 +253,112 @@ export function StairPanel() {
/> />
</div> </div>
</PanelSection> </PanelSection>
)}
{(node.stairType === 'curved' || node.stairType === 'spiral') && (
<PanelSection title="Geometry">
<MetricControl
label="Width"
max={10}
min={0.4}
onChange={(value) => handleUpdate({ width: value })}
precision={2}
step={0.05}
unit="m"
value={Math.round((node.width ?? 1) * 100) / 100}
/>
<MetricControl
label="Rise"
max={10}
min={0.2}
onChange={(value) => handleUpdate({ totalRise: value })}
precision={2}
step={0.05}
unit="m"
value={Math.round((node.totalRise ?? 2.5) * 100) / 100}
/>
<MetricControl
label="Steps"
max={32}
min={2}
onChange={(value) => 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' && (
<ToggleControl
checked={node.fillToFloor ?? true}
label="Fit To Floor"
onChange={(checked) => handleUpdate({ fillToFloor: checked })}
/>
)}
{(node.stairType === 'spiral' || !(node.fillToFloor ?? true)) && (
<MetricControl
label="Thickness"
max={1}
min={0.02}
onChange={(value) => handleUpdate({ thickness: value })}
precision={2}
step={0.01}
unit="m"
value={Math.round((node.thickness ?? 0.25) * 100) / 100}
/>
)}
<MetricControl
label="Inner Radius"
max={10}
min={node.stairType === 'spiral' ? 0.05 : 0.2}
onChange={(value) => handleUpdate({ innerRadius: value })}
precision={2}
step={0.05}
unit="m"
value={Math.round((node.innerRadius ?? 0.9) * 100) / 100}
/>
<SliderControl
label="Sweep"
max={node.stairType === 'spiral' ? 720 : 270}
min={node.stairType === 'spiral' ? -720 : -270}
onChange={(degrees) => 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' && (
<>
<SegmentedControl
onChange={(value) => handleUpdate({ topLandingMode: value })}
options={TOP_LANDING_MODE_OPTIONS}
value={node.topLandingMode ?? 'none'}
/>
{(node.topLandingMode ?? 'none') === 'integrated' && (
<MetricControl
label="Top Landing"
max={5}
min={0.3}
onChange={(value) => handleUpdate({ topLandingDepth: value })}
precision={2}
step={0.05}
unit="m"
value={Math.round((node.topLandingDepth ?? 0.9) * 100) / 100}
/>
)}
<ToggleControl
checked={node.showCenterColumn ?? true}
label="Center Column"
onChange={(checked) => handleUpdate({ showCenterColumn: checked })}
/>
<ToggleControl
checked={node.showStepSupports ?? true}
label="Step Supports"
onChange={(checked) => handleUpdate({ showStepSupports: checked })}
/>
</>
)}
</PanelSection>
)}
<PanelSection title="Position"> <PanelSection title="Position">
<MetricControl <MetricControl
@@ -280,6 +433,26 @@ export function StairPanel() {
</div> </div>
</PanelSection> </PanelSection>
<PanelSection title="Railing">
<SegmentedControl
onChange={(value) => handleUpdate({ railingMode: value })}
options={RAILING_MODE_OPTIONS}
value={node.railingMode ?? 'none'}
/>
{(node.railingMode ?? 'none') !== 'none' && (
<SliderControl
label="Height"
max={1.4}
min={0.7}
onChange={(value) => handleUpdate({ railingHeight: value })}
precision={2}
step={0.02}
unit="m"
value={Math.round((node.railingHeight ?? 0.92) * 100) / 100}
/>
)}
</PanelSection>
<PanelSection title="Actions"> <PanelSection title="Actions">
<ActionGroup> <ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} /> <ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
@@ -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 (
<TreeNodeWrapper
actions={<TreeNodeActions node={node} />}
depth={depth}
expanded={false}
hasChildren={false}
icon={
<Image alt="" className="object-contain" height={14} src="/icons/fence.png" width={14} />
}
isHovered={isHovered}
isLast={isLast}
isSelected={isSelected}
isVisible={node.visible !== false}
label={
<InlineRenameInput
defaultName="Fence"
isEditing={isEditing}
node={node}
onStartEditing={() => setIsEditing(true)}
onStopEditing={() => setIsEditing(false)}
/>
}
nodeId={nodeId}
onClick={handleClick}
onDoubleClick={() => focusTreeNode(nodeId)}
onMouseEnter={() => setHoveredId(nodeId)}
onMouseLeave={() => setHoveredId(null)}
onToggle={() => {}}
/>
)
}
@@ -57,6 +57,7 @@ import { cn } from '../../../../../lib/utils'
import { BuildingTreeNode } from './building-tree-node' import { BuildingTreeNode } from './building-tree-node'
import { CeilingTreeNode } from './ceiling-tree-node' import { CeilingTreeNode } from './ceiling-tree-node'
import { DoorTreeNode } from './door-tree-node' import { DoorTreeNode } from './door-tree-node'
import { FenceTreeNode } from './fence-tree-node'
import { ItemTreeNode } from './item-tree-node' import { ItemTreeNode } from './item-tree-node'
import { LevelTreeNode } from './level-tree-node' import { LevelTreeNode } from './level-tree-node'
import { RoofTreeNode } from './roof-tree-node' import { RoofTreeNode } from './roof-tree-node'
@@ -88,6 +89,8 @@ export function TreeNode({ nodeId, depth = 0, isLast }: TreeNodeProps) {
return <SlabTreeNode depth={depth} isLast={isLast} nodeId={nodeId} /> return <SlabTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'wall': case 'wall':
return <WallTreeNode depth={depth} isLast={isLast} nodeId={nodeId} /> return <WallTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'fence':
return <FenceTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'roof': case 'roof':
return <RoofTreeNode depth={depth} isLast={isLast} nodeId={nodeId} /> return <RoofTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'stair': case 'stair':
@@ -62,6 +62,7 @@ const wallModeConfig = {
const getNodeName = (node: AnyNode): string => { const getNodeName = (node: AnyNode): string => {
if ('name' in node && node.name) return node.name if ('name' in node && node.name) return node.name
if (node.type === 'wall') return 'Wall' 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 === 'item') return (node as { asset: { name: string } }).asset?.name || 'Item'
if (node.type === 'slab') return 'Slab' if (node.type === 'slab') return 'Slab'
if (node.type === 'ceiling') return 'Ceiling' if (node.type === 'ceiling') return 'Ceiling'
@@ -16,7 +16,15 @@ export function useContextualTools() {
} }
// Default tools when nothing is selected // 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) { if (selection.selectedIds.length === 0) {
return defaultTools return defaultTools
@@ -29,7 +37,7 @@ export function useContextualTools() {
// If a wall is selected, prioritize wall-hosted elements // If a wall is selected, prioritize wall-hosted elements
if (selectedTypes.has('wall')) { 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 // If a slab is selected, prioritize slab editing
+4
View File
@@ -4,6 +4,7 @@ import type { AssetInput } from '@pascal-app/core'
import { import {
type BuildingNode, type BuildingNode,
type DoorNode, type DoorNode,
type FenceNode,
type ItemNode, type ItemNode,
type LevelNode, type LevelNode,
type RoofNode, type RoofNode,
@@ -33,6 +34,7 @@ export type Mode = 'select' | 'edit' | 'delete' | 'build'
// Structure mode tools (building elements) // Structure mode tools (building elements)
export type StructureTool = export type StructureTool =
| 'wall' | 'wall'
| 'fence'
| 'room' | 'room'
| 'custom-room' | 'custom-room'
| 'slab' | 'slab'
@@ -85,6 +87,7 @@ type EditorState = {
| ItemNode | ItemNode
| WindowNode | WindowNode
| DoorNode | DoorNode
| FenceNode
| RoofNode | RoofNode
| RoofSegmentNode | RoofSegmentNode
| StairNode | StairNode
@@ -96,6 +99,7 @@ type EditorState = {
| ItemNode | ItemNode
| WindowNode | WindowNode
| DoorNode | DoorNode
| FenceNode
| RoofNode | RoofNode
| RoofSegmentNode | RoofSegmentNode
| StairNode | StairNode
@@ -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<Mesh>(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 (
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible} {...handlers}>
<boxGeometry args={[0, 0, 0]} />
</mesh>
)
}
@@ -4,6 +4,7 @@ import { type AnyNode, useScene } from '@pascal-app/core'
import { BuildingRenderer } from './building/building-renderer' import { BuildingRenderer } from './building/building-renderer'
import { CeilingRenderer } from './ceiling/ceiling-renderer' import { CeilingRenderer } from './ceiling/ceiling-renderer'
import { DoorRenderer } from './door/door-renderer' import { DoorRenderer } from './door/door-renderer'
import { FenceRenderer } from './fence/fence-renderer'
import { GuideRenderer } from './guide/guide-renderer' import { GuideRenderer } from './guide/guide-renderer'
import { ItemRenderer } from './item/item-renderer' import { ItemRenderer } from './item/item-renderer'
import { LevelRenderer } from './level/level-renderer' import { LevelRenderer } from './level/level-renderer'
@@ -32,6 +33,7 @@ export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
{node.type === 'item' && <ItemRenderer node={node} />} {node.type === 'item' && <ItemRenderer node={node} />}
{node.type === 'slab' && <SlabRenderer node={node} />} {node.type === 'slab' && <SlabRenderer node={node} />}
{node.type === 'wall' && <WallRenderer node={node} />} {node.type === 'wall' && <WallRenderer node={node} />}
{node.type === 'fence' && <FenceRenderer node={node} />}
{node.type === 'door' && <DoorRenderer node={node} />} {node.type === 'door' && <DoorRenderer node={node} />}
{node.type === 'window' && <WindowRenderer node={node} />} {node.type === 'window' && <WindowRenderer node={node} />}
{node.type === 'zone' && <ZoneRenderer node={node} />} {node.type === 'zone' && <ZoneRenderer node={node} />}
@@ -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 { useLayoutEffect, useMemo, useRef } from 'react'
import type * as THREE from 'three' import * as THREE from 'three'
import { useNodeEvents } from '../../../hooks/use-node-events' import { useNodeEvents } from '../../../hooks/use-node-events'
import { createMaterial, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials' import { createMaterial, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
import { NodeRenderer } from '../node-renderer' 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 }) => { export const StairRenderer = ({ node }: { node: StairNode }) => {
const ref = useRef<THREE.Group>(null!) const ref = useRef<THREE.Group>(null!)
@@ -34,6 +64,10 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
<mesh castShadow material={material} name="merged-stair" receiveShadow> <mesh castShadow material={material} name="merged-stair" receiveShadow>
<boxGeometry args={[0, 0, 0]} /> <boxGeometry args={[0, 0, 0]} />
</mesh> </mesh>
{node.stairType === 'curved' || node.stairType === 'spiral' ? (
<CurvedStairBody material={material} stair={node} />
) : null}
<StairRailings material={material} stair={node} />
<group name="segments-wrapper" visible={false}> <group name="segments-wrapper" visible={false}>
{(node.children ?? []).map((childId) => ( {(node.children ?? []).map((childId) => (
<NodeRenderer key={childId} nodeId={childId} /> <NodeRenderer key={childId} nodeId={childId} />
@@ -42,3 +76,764 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
</group> </group>
) )
} }
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 (
<group name="stair-railing">
{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 (
<group key={`${stair.id}-curved-railing-${sideIndex}`}>
{sidePoints.map((point, pointIndex) => (
<mesh
castShadow
geometry={BALUSTER_GEOMETRY}
key={`${stair.id}-curved-baluster-${sideIndex}-${pointIndex}`}
material={material}
position={[point[0], point[1] + railHeight / 2, point[2]]}
receiveShadow
scale={[balusterRadius, railHeight, balusterRadius]}
/>
))}
{sidePoints.slice(0, -1).map((point, pointIndex) => {
const nextPoint = sidePoints[pointIndex + 1]
if (!nextPoint) return null
return (
<group key={`${stair.id}-curved-rail-${sideIndex}-${pointIndex}`}>
<RailSegment
end={[nextPoint[0], nextPoint[1] + railHeight, nextPoint[2]]}
material={material}
radius={railRadius}
start={[point[0], point[1] + railHeight, point[2]]}
/>
<RailSegment
end={[nextPoint[0], nextPoint[1] + midRailHeight, nextPoint[2]]}
material={material}
radius={railRadius * 0.8}
start={[point[0], point[1] + midRailHeight, point[2]]}
/>
</group>
)
})}
</group>
)
})}
</group>
)
}
if (railPaths.length === 0) {
return null
}
return (
<group name="stair-railing">
{railPaths.map((segmentPath, index) => (
<group
key={`${segmentPath.layout.segment.id}-railing`}
position={[segmentPath.layout.center[0], segmentPath.layout.elevation, segmentPath.layout.center[1]]}
rotation-y={segmentPath.layout.rotation}
>
{segmentPath.sidePaths.map((sidePath, sideIndex) => (
<group key={`${segmentPath.layout.segment.id}-${sidePath.side}-${sideIndex}`}>
{sidePath.points.map((point, pointIndex) => (
<mesh
castShadow
geometry={BALUSTER_GEOMETRY}
key={`${segmentPath.layout.segment.id}-${sidePath.side}-baluster-${pointIndex}`}
material={material}
position={[point[2], point[1] + railHeight / 2, point[0]]}
receiveShadow
scale={[balusterRadius, railHeight, balusterRadius]}
/>
))}
{sidePath.points.slice(0, -1).map((point, pointIndex) => {
const nextPoint = sidePath.points[pointIndex + 1]
if (!nextPoint) return null
return (
<group key={`${segmentPath.layout.segment.id}-${sidePath.side}-rail-${pointIndex}`}>
<RailSegment
end={[nextPoint[2], nextPoint[1] + railHeight, nextPoint[0]]}
material={material}
radius={railRadius}
start={[point[2], point[1] + railHeight, point[0]]}
/>
<RailSegment
end={[nextPoint[2], nextPoint[1] + midRailHeight, nextPoint[0]]}
material={material}
radius={railRadius * 0.8}
start={[point[2], point[1] + midRailHeight, point[0]]}
/>
</group>
)
})}
</group>
))}
</group>
))}
{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 (
<group key={`${previousPath.layout.segment.id}-${segmentPath.layout.segment.id}-${sideIndex}`}>
<RailSegment
end={[currentWorldPoint[0], currentWorldPoint[1] + railHeight, currentWorldPoint[2]]}
material={material}
radius={railRadius}
start={[previousWorldPoint[0], previousWorldPoint[1] + railHeight, previousWorldPoint[2]]}
/>
<RailSegment
end={[currentWorldPoint[0], currentWorldPoint[1] + midRailHeight, currentWorldPoint[2]]}
material={material}
radius={railRadius * 0.8}
start={[previousWorldPoint[0], previousWorldPoint[1] + midRailHeight, previousWorldPoint[2]]}
/>
</group>
)
})
})}
</group>
)
}
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 (
<mesh
castShadow
geometry={RAIL_GEOMETRY}
material={material}
position={[midpoint.x, midpoint.y, midpoint.z]}
quaternion={quaternion}
receiveShadow
scale={[Math.max(radius, 0.01), length, Math.max(radius, 0.01)]}
/>
)
}
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 (
<group name={isSpiral ? 'spiral-stair' : 'curved-stair'}>
{isSpiral && (stair.showCenterColumn ?? true) ? (
<mesh castShadow receiveShadow material={material} position={[0, spiralColumnHeight / 2, 0]}>
<cylinderGeometry args={[spiralColumnRadius, spiralColumnRadius, spiralColumnHeight, 10]} />
</mesh>
) : 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 (
<group key={`${stair.id}-${isSpiral ? 'spiral' : 'curved'}-step-${index}`} position-y={stepY}>
{isSpiral && (stair.showStepSupports ?? true) ? (
<mesh
castShadow
material={material}
position={[
Math.cos(midAngle) * (spiralColumnRadius + Math.max(0.04, innerRadius - spiralColumnRadius + 0.04) / 2 - 0.02),
Math.max(thickness * 0.55, 0.025) / 2,
Math.sin(midAngle) * (spiralColumnRadius + Math.max(0.04, innerRadius - spiralColumnRadius + 0.04) / 2 - 0.02),
]}
receiveShadow
rotation-y={-midAngle}
>
<boxGeometry
args={[
Math.max(0.04, innerRadius - spiralColumnRadius + 0.04),
Math.max(thickness * 0.55, 0.025),
Math.max(0.04, Math.min(0.12, Math.max(thickness * 0.55, 0.025) * 1.5)),
]}
/>
</mesh>
) : null}
<CurvedStepMesh
endAngle={endAngle}
innerRadius={innerRadius}
material={material}
outerRadius={outerRadius}
positionY={0}
startAngle={startAngle}
stepHeight={actualStepHeight}
thickness={thickness}
/>
</group>
)
})}
{isSpiral && (stair.topLandingMode ?? 'none') === 'integrated' ? (
<CurvedStepMesh
endAngle={sweepAngle / 2 + spiralLandingSweep}
innerRadius={innerRadius}
material={material}
outerRadius={outerRadius}
positionY={spiralLastStepTop}
startAngle={sweepAngle / 2}
stepHeight={spiralLandingThickness}
thickness={spiralLandingThickness}
/>
) : null}
</group>
)
}
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 <mesh castShadow geometry={geometry} material={material} position-y={positionY} receiveShadow />
}
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])
}
@@ -3,6 +3,7 @@
import { import {
CeilingSystem, CeilingSystem,
DoorSystem, DoorSystem,
FenceSystem,
ItemSystem, ItemSystem,
RoofSystem, RoofSystem,
SlabSystem, SlabSystem,
@@ -138,6 +139,7 @@ const Viewer: React.FC<ViewerProps> = ({
{/* Core systems */} {/* Core systems */}
<CeilingSystem /> <CeilingSystem />
<DoorSystem /> <DoorSystem />
<FenceSystem />
<ItemSystem /> <ItemSystem />
<RoofSystem /> <RoofSystem />
<SlabSystem /> <SlabSystem />
@@ -29,6 +29,7 @@ type SelectableNodeType =
| 'level' | 'level'
| 'zone' | 'zone'
| 'wall' | 'wall'
| 'fence'
| 'window' | 'window'
| 'door' | 'door'
| 'item' | 'item'
@@ -138,6 +139,13 @@ const isNodeInZone = (node: AnyNode, levelId: string, zoneId: string): boolean =
return startIn || endIn 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') { if (node.type === 'slab' || node.type === 'ceiling') {
const poly = (node as { polygon: [number, number][] }).polygon const poly = (node as { polygon: [number, number][] }).polygon
if (!poly?.length) return false 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) // Zone selected -> can select/hover contents (walls, items, slabs, ceilings, roofs, windows, doors)
return { 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) => { handleClick: (node, nativeEvent) => {
let nodeToSelect = node let nodeToSelect = node
if (node.type === 'roof-segment' && node.parentId) { if (node.type === 'roof-segment' && node.parentId) {
@@ -248,6 +256,7 @@ const getStrategy = (): SelectionStrategy | null => {
isValid: (node) => { isValid: (node) => {
const validTypes = [ const validTypes = [
'wall', 'wall',
'fence',
'item', 'item',
'slab', 'slab',
'ceiling', 'ceiling',
@@ -303,6 +312,7 @@ export const SelectionManager = () => {
'level', 'level',
'zone', 'zone',
'wall', 'wall',
'fence',
'item', 'item',
'slab', 'slab',
'ceiling', 'ceiling',
@@ -7,6 +7,8 @@ import {
type DoorNode, type DoorNode,
type EventSuffix, type EventSuffix,
emitter, emitter,
type FenceEvent,
type FenceNode,
type ItemEvent, type ItemEvent,
type ItemNode, type ItemNode,
type LevelEvent, type LevelEvent,
@@ -37,6 +39,7 @@ type NodeConfig = {
site: { node: SiteNode; event: SiteEvent } site: { node: SiteNode; event: SiteEvent }
item: { node: ItemNode; event: ItemEvent } item: { node: ItemNode; event: ItemEvent }
wall: { node: WallNode; event: WallEvent } wall: { node: WallNode; event: WallEvent }
fence: { node: FenceNode; event: FenceEvent }
building: { node: BuildingNode; event: BuildingEvent } building: { node: BuildingNode; event: BuildingEvent }
level: { node: LevelNode; event: LevelEvent } level: { node: LevelNode; event: LevelEvent }
zone: { node: ZoneNode; event: ZoneEvent } zone: { node: ZoneNode; event: ZoneEvent }