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:
co-authored by
Claude Opus 4.6
Aymeric Rabot
parent
682e2a1a12
commit
a205e4f778
@@ -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>(),
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user