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
+3
View File
@@ -4,6 +4,7 @@ import type {
BuildingNode,
CeilingNode,
DoorNode,
FenceNode,
ItemNode,
LevelNode,
RoofNode,
@@ -41,6 +42,7 @@ export interface NodeEvent<T extends AnyNode = AnyNode> {
}
export type WallEvent = NodeEvent<WallNode>
export type FenceEvent = NodeEvent<FenceNode>
export type ItemEvent = NodeEvent<ItemNode>
export type SiteEvent = NodeEvent<SiteNode>
export type BuildingEvent = NodeEvent<BuildingNode>
@@ -111,6 +113,7 @@ type ThumbnailEvents = {
type EditorEvents = GridEvents &
NodeEvents<'wall', WallEvent> &
NodeEvents<'fence', FenceEvent> &
NodeEvents<'item', ItemEvent> &
NodeEvents<'site', SiteEvent> &
NodeEvents<'building', BuildingEvent> &
@@ -15,6 +15,7 @@ export const sceneRegistry = {
ceiling: new Set<string>(),
level: new Set<string>(),
wall: new Set<string>(),
fence: new Set<string>(),
item: new Set<string>(),
slab: new Set<string>(),
zone: new Set<string>(),
+2
View File
@@ -4,6 +4,7 @@ export type {
CeilingEvent,
DoorEvent,
EventSuffix,
FenceEvent,
GridEvent,
ItemEvent,
LevelEvent,
@@ -44,6 +45,7 @@ export {
useInteractive,
} from './store/use-interactive'
export { default as useLiveTransforms, type LiveTransform } from './store/use-live-transforms'
export { FenceSystem } from './systems/fence/fence-system'
export { clearSceneHistory, default as useScene } from './store/use-scene'
export { CeilingSystem } from './systems/ceiling/ceiling-system'
export { DoorSystem } from './systems/door/door-system'
+2 -1
View File
@@ -15,6 +15,7 @@ export {
export { BuildingNode } from './nodes/building'
export { CeilingNode } from './nodes/ceiling'
export { DoorNode, DoorSegment } from './nodes/door'
export { FenceBaseStyle, FenceNode, FenceStyle } from './nodes/fence'
export { GuideNode } from './nodes/guide'
export type {
AnimationEffect,
@@ -36,7 +37,7 @@ export { ScanNode } from './nodes/scan'
// Nodes
export { SiteNode } from './nodes/site'
export { SlabNode } from './nodes/slab'
export { StairNode } from './nodes/stair'
export { StairNode, StairRailingMode, StairTopLandingMode, StairType } from './nodes/stair'
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
export { WallNode } from './nodes/wall'
export { WindowNode } from './nodes/window'
+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 { BaseNode, nodeType, objectId } from '../base'
import { CeilingNode } from './ceiling'
import { FenceNode } from './fence'
import { GuideNode } from './guide'
import { RoofNode } from './roof'
import { ScanNode } from './scan'
@@ -17,6 +18,7 @@ export const LevelNode = BaseNode.extend({
.array(
z.union([
WallNode.shape.id,
FenceNode.shape.id,
ZoneNode.shape.id,
SlabNode.shape.id,
CeilingNode.shape.id,
+39 -3
View File
@@ -4,6 +4,14 @@ import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material'
import { StairSegmentNode } from './stair-segment'
export const StairRailingMode = z.enum(['none', 'left', 'right', 'both'])
export const StairType = z.enum(['straight', 'curved', 'spiral'])
export const StairTopLandingMode = z.enum(['none', 'integrated'])
export type StairRailingMode = z.infer<typeof StairRailingMode>
export type StairType = z.infer<typeof StairType>
export type StairTopLandingMode = z.infer<typeof StairTopLandingMode>
export const StairNode = BaseNode.extend({
id: objectId('stair'),
type: nodeType('stair'),
@@ -11,16 +19,44 @@ export const StairNode = BaseNode.extend({
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
// Rotation around Y axis in radians
rotation: z.number().default(0),
stairType: StairType.default('straight'),
width: z.number().default(1.0),
totalRise: z.number().default(2.5),
stepCount: z.number().default(10),
thickness: z.number().default(0.25),
fillToFloor: z.boolean().default(true),
innerRadius: z.number().default(0.9),
sweepAngle: z.number().default(Math.PI / 2),
topLandingMode: StairTopLandingMode.default('none'),
topLandingDepth: z.number().default(0.9),
showCenterColumn: z.boolean().default(true),
showStepSupports: z.boolean().default(true),
railingMode: StairRailingMode.default('none'),
railingHeight: z.number().default(0.92),
// Child stair segment IDs
children: z.array(StairSegmentNode.shape.id).default([]),
}).describe(
dedent`
Stair node - a container for stair segments.
Acts as a group that holds one or more StairSegmentNodes (flights and landings).
Segments chain together based on their attachmentSide to form complex staircase shapes.
Acts as a group that either holds one or more StairSegmentNodes (straight stairs)
or stores stair-level geometry properties for curved stairs.
- position: center position of the stair group
- rotation: rotation around Y axis
- children: array of StairSegmentNode IDs
- stairType: straight (segment-based), curved (arc-based), or spiral
- width: stair width
- totalRise: total stair height
- stepCount: number of visible steps
- thickness: stair slab / tread thickness
- fillToFloor: whether the stair mass fills down to the floor or uses tread thickness only
- innerRadius: inner curve radius for curved stairs
- sweepAngle: total curved stair sweep in radians
- topLandingMode: optional integrated top landing for spiral stairs
- topLandingDepth: depth used to size the integrated spiral top landing
- showCenterColumn: whether spiral stairs render a center column
- showStepSupports: whether spiral stairs render step support brackets
- railingMode: whether to render railings and on which side(s)
- railingHeight: top height of the railing above the stair surface
- children: array of StairSegmentNode IDs for straight stairs
`,
)
+2
View File
@@ -2,6 +2,7 @@ import z from 'zod'
import { BuildingNode } from './nodes/building'
import { CeilingNode } from './nodes/ceiling'
import { DoorNode } from './nodes/door'
import { FenceNode } from './nodes/fence'
import { GuideNode } from './nodes/guide'
import { ItemNode } from './nodes/item'
import { LevelNode } from './nodes/level'
@@ -21,6 +22,7 @@ export const AnyNode = z.discriminatedUnion('type', [
BuildingNode,
LevelNode,
WallNode,
FenceNode,
ItemNode,
ZoneNode,
SlabNode,
+2 -2
View File
@@ -378,8 +378,8 @@ useScene.temporal.subscribe((state) => {
// Mark sibling nodes dirty so they can update their geometry
// (e.g. adjacent walls need to recalculate miter/junction geometry)
const parent = currentNodes[parentId]
if (parent && 'children' in parent) {
for (const childId of (parent as AnyNode & { children: string[] }).children) {
if (parent && 'children' in parent && Array.isArray(parent.children)) {
for (const childId of parent.children) {
markDirty(childId as AnyNodeId)
}
}
@@ -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
if (!mergedMesh) return
if (stairNode.stairType === 'curved' || stairNode.stairType === 'spiral') {
replaceMeshGeometry(mergedMesh, createEmptyGeometry())
return
}
const children = stairNode.children ?? []
const segments = children
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
if (segments.length === 0) {
mergedMesh.geometry.dispose()
mergedMesh.geometry = new THREE.BufferGeometry()
mergedMesh.geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
replaceMeshGeometry(mergedMesh, createEmptyGeometry())
return
}
@@ -337,11 +340,8 @@ function updateMergedStairGeometry(
geometries.push(geo)
}
const merged = mergeGeometries(geometries, false)
if (merged) {
mergedMesh.geometry.dispose()
mergedMesh.geometry = merged
}
const merged = mergeGeometries(geometries, false) ?? createEmptyGeometry()
replaceMeshGeometry(mergedMesh, merged)
// Dispose individual geometries
for (const geo of geometries) {
@@ -416,6 +416,548 @@ function rotateXZ(x: number, z: number, angle: number): [number, number] {
return [x * cos + z * sin, -x * sin + z * cos]
}
function createEmptyGeometry(): THREE.BufferGeometry {
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
return geometry
}
function replaceMeshGeometry(mesh: THREE.Mesh, geometry: THREE.BufferGeometry) {
mesh.geometry.dispose()
mesh.geometry = geometry
}
type StairRailSide = 'left' | 'right'
type StairRailPathSide = StairRailSide | 'front'
type StairRailSidePath = {
side: StairRailPathSide
points: THREE.Vector3[]
}
type StairSegmentRailPath = {
segment: StairSegmentNode
sidePaths: StairRailSidePath[]
connectFromPrevious: boolean
}
type StairRailLayout = {
center: [number, number]
elevation: number
rotation: number
segment: StairSegmentNode
}
function generateStairRailingGeometry(
stairNode: StairNode,
segments: StairSegmentNode[],
transforms: SegmentTransform[],
): THREE.BufferGeometry {
const railingMode = stairNode.railingMode ?? 'none'
if (railingMode === 'none') {
return createEmptyGeometry()
}
const railHeight = Math.max(0.5, stairNode.railingHeight ?? 0.92)
const midRailHeight = Math.max(railHeight * 0.45, 0.35)
const railRadius = 0.022
const postRadius = 0.018
const inset = 0.06
const landingInset = 0.08
const geometries: THREE.BufferGeometry[] = []
const segmentRailPaths = buildStairRailPaths(segments, transforms, railingMode, inset, landingInset)
for (const segmentRailPath of segmentRailPaths) {
for (const sidePath of segmentRailPath.sidePaths) {
const points = sidePath.points
if (points.length === 0) continue
geometries.push(...buildBalusterGeometries(points, railHeight, postRadius))
geometries.push(...buildOffsetRailSegmentGeometries(points, railHeight, railRadius))
geometries.push(
...buildOffsetRailSegmentGeometries(points, midRailHeight, railRadius * 0.8),
)
}
}
for (let index = 1; index < segmentRailPaths.length; index++) {
const previousPath = segmentRailPaths[index - 1]
const currentPath = segmentRailPaths[index]
if (!(previousPath && currentPath && currentPath.connectFromPrevious)) continue
if (previousPath.segment.segmentType === 'landing') continue
for (const sidePath of currentPath.sidePaths) {
if (currentPath.segment.segmentType === 'landing') continue
const currentPoint = sidePath.points[0]
if (!currentPoint) continue
const previousSidePath = [...previousPath.sidePaths]
.map((entry) => ({
entry,
distance: entry.points.length
? entry.points[entry.points.length - 1]!.distanceTo(currentPoint)
: Number.POSITIVE_INFINITY,
}))
.sort((left, right) => left.distance - right.distance)[0]?.entry
const previousPoint =
previousSidePath && previousSidePath.points.length > 0
? previousSidePath.points[previousSidePath.points.length - 1]
: null
if (!(previousPoint && currentPoint)) continue
const connectorPoints = [previousPoint, currentPoint]
geometries.push(...buildOffsetRailSegmentGeometries(connectorPoints, railHeight, railRadius))
geometries.push(
...buildOffsetRailSegmentGeometries(connectorPoints, midRailHeight, railRadius * 0.8),
)
}
}
const merged = mergeGeometries(geometries, false) ?? createEmptyGeometry()
for (const geometry of geometries) {
geometry.dispose()
}
return merged
}
function buildStairRailPaths(
segments: StairSegmentNode[],
transforms: SegmentTransform[],
railingMode: 'left' | 'right' | 'both',
inset: number,
landingInset: number,
): StairSegmentRailPath[] {
const layouts = computeStairRailLayouts(segments, transforms)
if (railingMode === 'both') {
const isStraightLineDoubleLandingLayout =
segments.length === 4 &&
segments[0]?.segmentType === 'stair' &&
segments[1]?.segmentType === 'landing' &&
segments[2]?.segmentType === 'stair' &&
segments[2]?.attachmentSide === 'front' &&
segments[3]?.segmentType === 'landing' &&
segments[3]?.attachmentSide === 'front'
return layouts.map((layout, index) => {
const segment = layout.segment
const previousSegment = index > 0 ? segments[index - 1] : undefined
const nextSegment = index < segments.length - 1 ? segments[index + 1] : undefined
const hideLandingRailing =
segment.segmentType === 'landing' &&
previousSegment?.segmentType === 'stair' &&
nextSegment?.segmentType === 'stair'
const visualTurnSide = nextSegment?.attachmentSide
const sideCandidates =
hideLandingRailing
? visualTurnSide === 'left'
? (['front', 'right'] as const)
: visualTurnSide === 'right'
? (['front', 'left'] as const)
: (['left', 'right'] as const)
: segment.segmentType === 'landing'
? nextSegment?.segmentType === 'landing' && visualTurnSide === 'left'
? (['front', 'right'] as const)
: nextSegment?.segmentType === 'landing' && visualTurnSide === 'right'
? (['front', 'left'] as const)
: visualTurnSide === 'left'
? (['right'] as const)
: visualTurnSide === 'right'
? (['left'] as const)
: (['left', 'right'] as const)
: (['left', 'right'] as const)
const sidePaths = sideCandidates
.map((side) =>
buildSegmentRailPath(
layout,
side,
previousSegment,
nextSegment,
inset,
landingInset,
),
)
.filter((entry): entry is StairRailSidePath => entry !== null)
return {
segment,
sidePaths:
isStraightLineDoubleLandingLayout && index === 1
? ((['left', 'right'] as const)
.map((side) =>
buildSegmentRailPath(
layout,
side,
previousSegment,
nextSegment,
inset,
landingInset,
),
)
.filter((entry): entry is StairRailSidePath => entry !== null))
: sidePaths,
connectFromPrevious:
index > 0 &&
!(previousSegment?.segmentType === 'landing' && segment.segmentType === 'landing'),
}
})
}
const isStraightLineDoubleLandingLayout =
segments.length === 4 &&
segments[0]?.segmentType === 'stair' &&
segments[1]?.segmentType === 'landing' &&
segments[2]?.segmentType === 'stair' &&
segments[2]?.attachmentSide === 'front' &&
segments[3]?.segmentType === 'landing' &&
segments[3]?.attachmentSide === 'front'
const resolved: StairSegmentRailPath[] = []
layouts.forEach((layout, index) => {
const segment = layout.segment
const previousSegment = index > 0 ? segments[index - 1] : undefined
const nextSegment = index < segments.length - 1 ? segments[index + 1] : undefined
const nextAttachmentSide = nextSegment?.attachmentSide
const isMiddleLandingBetweenFlights =
segment.segmentType === 'landing' &&
previousSegment?.segmentType === 'stair' &&
nextSegment?.segmentType === 'stair'
const suppressLandingRailing =
segment.segmentType === 'landing' &&
nextSegment?.segmentType === 'landing' &&
nextAttachmentSide === railingMode
const landingContinuesOnPreferredSide =
segment.segmentType === 'landing'
? nextAttachmentSide == null ||
nextAttachmentSide === 'front' ||
nextAttachmentSide === railingMode
: true
const sidePaths =
suppressLandingRailing
? []
: segment.segmentType !== 'landing'
? [
buildSegmentRailPath(
layout,
railingMode,
previousSegment,
nextSegment,
inset,
landingInset,
),
]
: isStraightLineDoubleLandingLayout
? [
buildSegmentRailPath(
layout,
railingMode,
previousSegment,
nextSegment,
inset,
landingInset,
),
]
: isMiddleLandingBetweenFlights && railingMode === 'left'
? nextAttachmentSide === 'right'
? [
buildSegmentRailPath(
layout,
'front',
previousSegment,
nextSegment,
inset,
landingInset,
),
buildSegmentRailPath(
layout,
'left',
previousSegment,
nextSegment,
inset,
landingInset,
),
]
: []
: isMiddleLandingBetweenFlights && railingMode === 'right'
? nextAttachmentSide === 'left'
? [
buildSegmentRailPath(
layout,
'front',
previousSegment,
nextSegment,
inset,
landingInset,
),
buildSegmentRailPath(
layout,
'right',
previousSegment,
nextSegment,
inset,
landingInset,
),
]
: []
: nextSegment?.segmentType === 'landing' &&
nextAttachmentSide != null &&
nextAttachmentSide !== 'front' &&
nextAttachmentSide !== railingMode
? [
buildSegmentRailPath(
layout,
'front',
previousSegment,
nextSegment,
inset,
landingInset,
),
buildSegmentRailPath(
layout,
railingMode,
previousSegment,
nextSegment,
inset,
landingInset,
),
]
: [
buildSegmentRailPath(
layout,
railingMode,
previousSegment,
nextSegment,
inset,
landingInset,
),
]
resolved.push({
segment,
sidePaths: sidePaths.filter((entry): entry is StairRailSidePath => entry !== null),
connectFromPrevious:
index > 0 &&
!suppressLandingRailing &&
sidePaths.length > 0 &&
(segment.segmentType === 'landing' ? landingContinuesOnPreferredSide : true),
})
})
return resolved
}
function computeStairRailLayouts(
segments: StairSegmentNode[],
transforms: SegmentTransform[],
): StairRailLayout[] {
return segments.map((segment, index) => {
const transform = transforms[index]!
const [centerOffsetX, centerOffsetZ] = rotateXZ(0, segment.length / 2, transform.rotation)
return {
center: [transform.position[0] + centerOffsetX, transform.position[2] + centerOffsetZ],
elevation: transform.position[1],
rotation: transform.rotation,
segment,
}
})
}
function buildSegmentRailPath(
layout: StairRailLayout,
side: StairRailPathSide,
previousSegment: StairSegmentNode | undefined,
nextSegment: StairSegmentNode | undefined,
inset: number,
landingInset: number,
): StairRailSidePath | null {
const segment = layout.segment
const segmentSteps = Math.max(1, segment.segmentType === 'landing' ? 1 : segment.stepCount)
const segmentStepDepth = segment.length / segmentSteps
const segmentStepHeight = segment.segmentType === 'landing' ? 0 : segment.height / segmentSteps
const segmentTopThickness = getSegmentTopThickness(segment)
const flightSideOffset =
side === 'left' ? segment.width / 2 - 0.045 : -segment.width / 2 + 0.045
const flightStartX =
previousSegment?.segmentType === 'landing' ? -segment.length / 2 + landingInset : -segment.length / 2
const flightEndX =
nextSegment?.segmentType === 'landing' ? segment.length / 2 - landingInset : segment.length / 2
if (segment.segmentType === 'landing') {
return buildLandingRailPathFromScratch(
layout,
side,
previousSegment,
nextSegment,
segmentTopThickness,
landingInset,
)
}
return {
side,
points: [
...(previousSegment?.segmentType === 'landing'
? []
: [
toRailLayoutWorldPoint(layout, flightStartX, segmentTopThickness, flightSideOffset),
]),
...Array.from({ length: segmentSteps }).map((_, index) =>
toRailLayoutWorldPoint(
layout,
-segment.length / 2 + segmentStepDepth * index + segmentStepDepth / 2,
segmentStepHeight * (index + 1),
flightSideOffset,
),
),
...(nextSegment?.segmentType === 'landing'
? []
: [
toRailLayoutWorldPoint(layout, flightEndX, segment.height, flightSideOffset),
]),
],
}
}
function buildLandingRailPathFromScratch(
layout: StairRailLayout,
side: StairRailPathSide,
previousSegment: StairSegmentNode | undefined,
nextSegment: StairSegmentNode | undefined,
topY: number,
inset: number,
): StairRailSidePath | null {
const segment = layout.segment
const backX = -segment.length / 2 + inset
const frontX = segment.length / 2 - inset
const leftZ = segment.width / 2 - inset
const rightZ = -segment.width / 2 + inset
const edgePoints =
side === 'left'
? ([
toRailLayoutWorldPoint(layout, backX, topY, leftZ),
toRailLayoutWorldPoint(layout, frontX, topY, leftZ),
] as THREE.Vector3[])
: side === 'right'
? ([
toRailLayoutWorldPoint(layout, backX, topY, rightZ),
toRailLayoutWorldPoint(layout, frontX, topY, rightZ),
] as THREE.Vector3[])
: ([
// When the next flight turns, rail the visible leading edge nearest the turn opening.
toRailLayoutWorldPoint(
layout,
previousSegment?.segmentType === 'stair' &&
nextSegment?.attachmentSide &&
nextSegment.attachmentSide !== 'front'
? backX
: frontX,
topY,
leftZ,
),
toRailLayoutWorldPoint(
layout,
previousSegment?.segmentType === 'stair' &&
nextSegment?.attachmentSide &&
nextSegment.attachmentSide !== 'front'
? backX
: frontX,
topY,
rightZ,
),
] as THREE.Vector3[])
return {
side,
points: edgePoints,
}
}
function toRailLayoutWorldPoint(
layout: StairRailLayout,
localX: number,
localY: number,
localZ: number,
): THREE.Vector3 {
const [offsetX, offsetZ] = rotateXZ(localZ, localX, layout.rotation)
return new THREE.Vector3(
layout.center[0] + offsetX,
layout.elevation + localY,
layout.center[1] + offsetZ,
)
}
function buildOffsetRailSegmentGeometries(
points: THREE.Vector3[],
heightOffset: number,
radius: number,
): THREE.BufferGeometry[] {
const geometries: THREE.BufferGeometry[] = []
for (let index = 0; index < points.length - 1; index++) {
const start = points[index]
const end = points[index + 1]
if (!(start && end)) continue
const segmentGeometry = createCylinderBetweenPoints(
start.clone().add(new THREE.Vector3(0, heightOffset, 0)),
end.clone().add(new THREE.Vector3(0, heightOffset, 0)),
radius,
8,
)
if (segmentGeometry) {
geometries.push(segmentGeometry)
}
}
return geometries
}
function buildBalusterGeometries(
points: THREE.Vector3[],
height: number,
radius: number,
): THREE.BufferGeometry[] {
const geometries: THREE.BufferGeometry[] = []
for (const point of points) {
const geometry = new THREE.CylinderGeometry(radius, radius, Math.max(height, 0.05), 8)
geometry.translate(point.x, point.y + height / 2, point.z)
geometries.push(geometry)
}
return geometries
}
function getSegmentTopThickness(segment: StairSegmentNode): number {
return Math.max(segment.thickness ?? 0.25, 0.02)
}
function createCylinderBetweenPoints(
start: THREE.Vector3,
end: THREE.Vector3,
radius: number,
radialSegments: number,
): THREE.BufferGeometry | null {
const direction = new THREE.Vector3().subVectors(end, start)
const length = direction.length()
if (length <= 1e-5) return null
const midpoint = new THREE.Vector3().addVectors(start, end).multiplyScalar(0.5)
const quaternion = new THREE.Quaternion().setFromUnitVectors(
new THREE.Vector3(0, 1, 0),
direction.clone().normalize(),
)
const geometry = new THREE.CylinderGeometry(radius, radius, length, radialSegments)
geometry.applyQuaternion(quaternion)
geometry.translate(midpoint.x, midpoint.y, midpoint.z)
return geometry
}
/**
* Computes the absolute Y height of a segment by traversing the stair's segment chain.
*/