Files
editor/packages/nodes/src/stair/renderer.tsx
T
bf89b5bcf2 feat: vertical building model — stored level heights, wall inversion, decks (#526)
* feat(core): stored storey heights groundwork — pure slab-support module + level height schema

Extract pointInPolygon/computeWallSlabSupport and friends into a cycle-free
packages/core/src/systems/slab/slab-support.ts (severs level-height ->
spatial-grid-manager -> use-scene), add deriveLegacyLevelHeight as the pure
mesh-free equivalent of the viewer's stacked level height, and add the
optional LevelNode.height field plus the storey service (getStoredLevelHeight,
getLevelElevations per-building prefix sums). No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(editor): storey height badge + edit popover on level rows

Each floating-level-selector row shows its storey height; clicking opens a
popover with 2.5/3.0/3.5 presets and a free slider writing level.height.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(core): vertical-model load migration — stored heights, ordinal compaction, wall-top classification

Pass 3 in migrateNodes: derive and store each legacy level's exact stacked
height (never snapped), compact ordinals per building anchored at zero so
basements stay basements, classify wall tops against the derived plane
(|plane - top| < 0.20 strictly -> plane-bound, else explicit height
materialized), and drop the blind totalRise 2.5 stair default on legacy
scenes only. Epsilon and strictness validated by a prod census.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: pin wall tops to the storey plane; stored heights become the only vertical truth

Wall-top inversion: a wall without an explicit height now tops out at its
storey plane (resolveWallTop); slabs lift only the base. Window/door caps
resolve the real top through the same slab election instead of Infinity.
All level stacking (viewer, elevator, first-person, stair openings, MCP
scene queries) reads stored LevelNode.height; the four divergent live
derivations and level.metadata.height are deleted. Stair totalRise becomes
optional and derives from the storey height when absent. MCP create-level
stops writing its elevation param into the ordinal. Level creation sites
write explicit heights; templates carry their true derived heights.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: clamp slabs/ceilings under the storey plane; sweep wall-height fallbacks; wall Top control

Slab elevation writes clamp to plane − MIN_WALL_HEIGHT when plane-bound
walls elect the slab (pure clampSlabElevationForWalls + registry handle
bounds + shared panels for 2D/3D parity); ceiling heights clamp under the
plane and auto-ceilings derive from resolved wall tops. Every remaining
wall.height ?? 2.5 fallback resolves through resolveWallTop /
resolveWallEffectiveHeight (panels, overlays, measurements, quantities,
spatial grid, MCP reports); template walls matching their storey become
plane-bound. Wall panel gains a Top control (Follows storey / Custom
height) derived purely from height presence; the store update path now
deletes keys passed as explicit undefined so plane-binding round-trips.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(core): persisted support hosts — schema, host-preferring election, rendered-polygon unification

Floor-placed nodes and walls gain a nullable supportSlabId. Elections
surface the winning slab (getSlabSupportForItem, candidates query) and
prefer a still-valid persisted host, falling back silently when the host
is gone or reshaped away; deleting the host strips references in the same
undo commit. Item-side support now tests the rendered slab polygon (like
walls) through a per-level cache invalidated by the spatial-grid sync.
Also adds the resolveStairTotalRise unit tests from the stage-1 gates.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: persist support hosts at commit; thread wall host preference everywhere

Floor-placed commits (draft pipeline, per-kind creation tools, registry
move tool) and wall create/move/endpoint commits persist supportSlabId
via shared resolveSupportSlabPatch helpers — only when overlapping
supports disagree on elevation, clearing it otherwise or when the node
leaves the floor. WallSlabSupport surfaces electedSlabId; every wall
support read site passes wall.supportSlabId as the preferred host.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: split slab into placement + thickness; pools become explicit recess intent

slab.elevation stays the walking surface; new thickness grows downward so
the solid occupies [elevation − thickness, elevation]. Migration writes
thickness := elevation for solids (byte-identical intervals, including
degenerate zero) and recessed: true for legacy negative pools. Geometry
branches on recessed instead of the elevation sign; presets keep today's
intervals; free elevation edits move the body without coupling thickness
(the deck semantic). Dead viewer SlabSystem component deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: clamp ceilings to covering-slab undersides across levels

getLevelAbove + getCoveringSlabUndersideAt give the first cross-level
query; ceiling writes clamp to min(storey plane, lowest covering
underside) − 0.01, and the space-detection reconcile now clamps manual
ceilings down (never up) when a deck above intrudes — a flush deck
reactively lowers the ceiling below it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: mezzanine and balcony build-tab tools

One-gesture composites over the kernel: draw a deck footprint and commit
deck slab + railings + stair (mezzanine) or deck + railings (balcony) in
a single undo step. Fences gain supportSlabId and lift onto their host
deck; railing runs split around the stair mouth; edges near wall
centerlines are treated as closed. Stairs target the deck via explicit
totalRise with no level-to-level opening sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: adaptive slab vertical editing; level vocabulary in UI copy

Dragging a grounded slab's top stretches it (elevation and thickness move
together — gaps impossible); floating decks move with thickness preserved
and land grounded at zero; pools keep the drag-through-zero gesture.
User-facing copy says level, not storey (Follows level, Level height).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: clamp plane-bound wall tops to covering-slab undersides; fix vertical reactivity

getWallPlaneTop samples the wall span against the level above's slabs, so
a thick or flush upper floor shortens the walls below it instead of
colliding (automatic attach, no dialog). Level-height edits now dirty the
level's walls, stairs, ceilings, and fences; covering-slab changes dirty
the level below. The effective-height helper triplicated across editor
overlays moved to core.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: ceilings follow the level top by default

ceiling.height becomes optional — absent means the ceiling tracks
min(level top, covering slab underside) − 0.01 live, so level-height
edits no longer require ceiling fixes. Ceiling panel gains the same
Follows level / Custom height control as walls; auto-from-walls ceilings
are created height-less and their height-derivation machinery is deleted;
migration drops stored heights within 0.20 of the bound on legacy scenes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: wall plane clamp missed max-side boundary walls

Auto slabs derive their polygon from wall centerlines, so covering-clamp
samples sat exactly on the boundary where ray-cast point-in-polygon is
side-dependent (min edges in, max edges out) — walls clamped or not by
orientation. getWallPlaneTop now clips the wall's thickness band against
the covering polygon (boundary-inclusive, arc-aware) and the ceiling
bound's point sampling gained an explicit on-boundary test. Verified
against the reported repro scene.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: stairs attach to decks; pointer-decided placement surface

Stairs gain deckSlabId — rise follows the deck's elevation live (straight
flights re-converge via a write-sync mirroring auto-openings), the panel
shows a unified To destination with Follows deck / Custom rise, and the
mezzanine tool attaches instead of baking a stale rise. Item placement
under an elevated deck no longer flickers: grid events fed a feedback
loop (the grid plane rode the ghost's elected height), so the support
election is now capped at the surface the pointer ray actually hits,
with a ground sentinel keeping under-deck commits deterministic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: remove the mezzanine/balcony composite tools

Decks ship as catalog presets instead; the kernel the presets rely on
(fence deck-hosting, stair deck attachment, clone remaps, pointer-decided
placement) all stays. The tool code lives at e30042db for reference.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: gate wall adoption to grounded slabs; panel moves, drag stretches then unsticks

Floating decks keep their drawn polygon (and stop being seam candidates
for grounded neighbors) instead of growing into nearby walls. Panel
elevation edits are pure placement; the viewport drag stretches a
grounded slab up to 0.4 m then unsticks it into a thin deck.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: deck-attached stairs land flush with the deck surface

The rise now subtracts the stair's own elected base (same election the
visual lift uses), so base + rise always equals the deck walking surface;
the auto-sync defers a microtask so it reads a settled spatial grid and
re-converges on both deck and base-slab moves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: draw walls and fences on elevated decks

Wall and fence draw tools now publish the pointed surface, so the draw
plane rides the deck top (no more perspective-skewed floor hits) and
previews sit on the deck. Fences gain real support election: a pure
resolveFenceSupportSlabPatch persists the deck host at draw and reshape
commits; wall commits thread the pointer cap so aiming under a deck
elects the floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: unit-aware height presets

Level, ceiling, and slab preset buttons show clean values per display
system (8/9/10 ft storeys, 8'-9' ceilings, whole-inch slab steps) instead
of converted metric labels; metric presets unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: stairs converge to their resolved rise; deck attachment disables the cutout

syncStairRises now converges every follows-mode straight stair (level or
deck) plus deck-attached custom rises — detaching a stair from a deck
re-derives its height, and ordinary stairs finally track level-height
changes. Attaching via the panel writes slabOpeningMode none and hides
the cutout controls; detaching restores the destination cutout and
clears the stale explicit rise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: stacked-slab move hopping — one ray, one surface, one XZ

The hop was hysteresis: the election consumed the riding grid plane's
perspective-skewed hit, giving two self-consistent fixed points for one
pointer ray. getPointedSupportSurface now returns the ray's crossing of
the pointed surface and both the support cap and the cursor XZ derive
from that single computation, so items stay on the surface the pointer
aims at and sit exactly under the cursor across storeys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: single stair per click; tools restore select mode on exit

The stair tool subscribed to both node clicks (synthesized on pointerup)
and the native-click grid event with none of the guards sibling tools
carry — one physical click over any node surface committed twice. A
commit gate + follow-up click swallow fix the double dispatch, and the
stair and column tools now restore select mode on exit instead of
leaving the dead build-mode-without-tool state that ignored every click.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat: compact multi-selection panel with host footer slot

Selecting multiple nodes now docks the collapsed-by-default panel on the
right: N selected header, kind breakdown, and Duplicate/Delete mirroring
the floating pill. A new multiSelectionFooter slot lets the host app dock
actions below it, exactly like inspectorFooter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 09:57:30 -04:00

1314 lines
43 KiB
TypeScript

'use client'
import {
type AnyNodeId,
resolveStairTotalRise,
type StairNode,
type StairSegmentNode,
useLiveNodeOverrides,
useRegistry,
useScene,
} from '@pascal-app/core'
import {
getStairBodyMaterials,
getStairRailingMaterial,
NodeRenderer,
resolveMaterialRef,
resolveSlotDefaultMaterial,
type StairBodyMaterials,
useNodeEvents,
useViewer,
} from '@pascal-app/viewer'
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
import * as THREE from 'three'
import { createPlaceholderGeometry } from '../shared/placeholder-geometry'
import {
STAIR_BODY_SLOT_DEFAULT,
STAIR_RAILING_SLOT_DEFAULT,
STAIR_TREADS_SLOT_DEFAULT,
type StairSlotId,
} from './slots'
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: rawNode }: { node: StairNode }) => {
const ref = useRef<THREE.Group>(null!)
// Merge any live drag override into the node so curved/spiral geometry
// (built declaratively in JSX below) rebuilds on every drag tick. The
// resize arrows publish to `useLiveNodeOverrides` and only commit to
// zustand on release — subscribing here turns those override writes
// into the React re-renders that drive the visible mesh update.
const liveOverride = useLiveNodeOverrides((s) => s.overrides.get(rawNode.id))
const node = useMemo<StairNode>(
() => (liveOverride ? ({ ...rawNode, ...liveOverride } as StairNode) : rawNode),
[rawNode, liveOverride],
)
const isSegmentBasedStair = node.stairType === 'straight'
useRegistry(node.id, 'stair', ref)
useLayoutEffect(() => {
useScene.getState().markDirty(node.id)
}, [node.id])
const handlers = useNodeEvents(node, 'stair')
const shading = useViewer((s) => s.shading)
const textures = useViewer((s) => s.textures)
const colorPreset = useViewer((s) => s.colorPreset)
const sceneMaterials = useScene((s) => s.materials)
const baseBodyMaterials = useMemo(
() => getStairBodyMaterials(node, shading, textures, colorPreset),
[node, shading, textures, colorPreset],
)
const bodyMaterials = useMemo<StairBodyMaterials>(
() => [
resolveStairSlotMaterial(
node,
'treads',
STAIR_TREADS_SLOT_DEFAULT,
baseBodyMaterials[STAIR_TREAD_MATERIAL_INDEX],
sceneMaterials,
shading,
textures,
),
resolveStairSlotMaterial(
node,
'body',
STAIR_BODY_SLOT_DEFAULT,
baseBodyMaterials[STAIR_SIDE_MATERIAL_INDEX],
sceneMaterials,
shading,
textures,
),
],
[baseBodyMaterials, node, sceneMaterials, shading, textures],
)
const baseRailingMaterial = useMemo(
() => getStairRailingMaterial(node, shading, textures, colorPreset),
[node, shading, textures, colorPreset],
)
const railingMaterial = useMemo(
() =>
resolveStairSlotMaterial(
node,
'railing',
STAIR_RAILING_SLOT_DEFAULT,
baseRailingMaterial,
sceneMaterials,
shading,
textures,
),
[baseRailingMaterial, node, sceneMaterials, shading, textures],
)
// 2 groups map 1:1 to the stair body's 2-material array (treads + body).
const straightPlaceholderGeometry = useMemo(() => createPlaceholderGeometry(2), [])
useEffect(() => {
return () => {
straightPlaceholderGeometry.dispose()
}
}, [straightPlaceholderGeometry])
return (
<group
position-x={node.position[0]}
position-z={node.position[2]}
ref={ref}
rotation-y={node.rotation}
visible={node.visible}
{...handlers}
>
{isSegmentBasedStair ? (
<mesh
castShadow
geometry={straightPlaceholderGeometry}
material={bodyMaterials}
name="merged-stair"
receiveShadow
userData={STAIR_BODY_SLOT_USER_DATA}
/>
) : null}
{isSegmentBasedStair ? null : <CurvedStairBody bodyMaterials={bodyMaterials} stair={node} />}
<StairRailings material={railingMaterial} stair={node} />
{isSegmentBasedStair ? (
<group name="segments-wrapper" visible={false}>
{(node.children ?? []).map((childId) => (
<NodeRenderer key={childId} nodeId={childId} />
))}
</group>
) : null}
</group>
)
}
function StairRailings({ stair, material }: { stair: StairNode; material: THREE.Material }) {
const nodes = useScene((state) => state.nodes)
// Stair segments' width/length/height arrow handles publish drag values to
// `useLiveNodeOverrides` and only commit to zustand on release. Subscribing
// here and merging each child segment's override means the railing tracks
// the drag in real time instead of freezing at the pre-drag values.
const overrides = useLiveNodeOverrides((s) => s.overrides)
const segments = useMemo(
() =>
(stair.children ?? [])
.map((childId) => {
const base = nodes[childId as AnyNodeId] as StairSegmentNode | undefined
if (!base) return undefined
const override = overrides.get(childId as AnyNodeId)
return (override ? { ...base, ...override } : base) as StairSegmentNode
})
.filter(
(node): node is StairSegmentNode =>
node?.type === 'stair-segment' && node.visible !== false,
),
[nodes, overrides, 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(resolveStairTotalRise(stair, nodes), 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}
name="stair-railing-baluster"
position={[point[0], point[1] + railHeight / 2, point[2]]}
receiveShadow
scale={[balusterRadius, railHeight, balusterRadius]}
userData={STAIR_RAILING_SLOT_USER_DATA}
/>
))}
{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}
name="stair-railing-baluster"
position={[point[2], point[1] + railHeight / 2, point[0]]}
receiveShadow
scale={[balusterRadius, railHeight, balusterRadius]}
userData={STAIR_RAILING_SLOT_USER_DATA}
/>
))}
{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?.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)
const STAIR_TREAD_MATERIAL_INDEX = 0
const STAIR_SIDE_MATERIAL_INDEX = 1
const STAIR_BODY_SLOT_IDS: StairSlotId[] = ['treads', 'body']
const STAIR_BODY_SLOT_USER_DATA = { slotIds: STAIR_BODY_SLOT_IDS }
const STAIR_BODY_SINGLE_SLOT_USER_DATA = { slotId: 'body' satisfies StairSlotId }
const STAIR_RAILING_SLOT_USER_DATA = { slotId: 'railing' satisfies StairSlotId }
type SceneMaterials = Parameters<typeof resolveMaterialRef>[1]
type ViewerShading = Parameters<typeof resolveMaterialRef>[2]
function hasMaterialSpec(material: unknown, materialPreset: unknown): boolean {
return material !== undefined || typeof materialPreset === 'string'
}
function hasLegacyStairSlotMaterial(node: StairNode, slotId: StairSlotId): boolean {
const hasWhole = hasMaterialSpec(node.material, node.materialPreset)
const hasTread = hasMaterialSpec(node.treadMaterial, node.treadMaterialPreset)
const hasSide = hasMaterialSpec(node.sideMaterial, node.sideMaterialPreset)
const hasRailing = hasMaterialSpec(node.railingMaterial, node.railingMaterialPreset)
if (slotId === 'treads') return hasTread || hasSide || hasWhole
if (slotId === 'body') return hasSide || hasTread || hasWhole
return hasRailing || hasTread || hasSide || hasWhole
}
function resolveStairSlotMaterial(
node: StairNode,
slotId: StairSlotId,
defaultRef: string,
baseMaterial: THREE.Material,
sceneMaterials: SceneMaterials,
shading: ViewerShading,
textures: boolean,
): THREE.Material {
if (!textures) return baseMaterial
const slotMaterial = resolveMaterialRef(node.slots?.[slotId], sceneMaterials, shading)
if (slotMaterial) return slotMaterial
if (hasLegacyStairSlotMaterial(node, slotId)) return baseMaterial
return resolveSlotDefaultMaterial(defaultRef, shading)
}
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}
name="stair-railing-rail"
position={[midpoint.x, midpoint.y, midpoint.z]}
quaternion={quaternion}
receiveShadow
scale={[Math.max(radius, 0.01), length, Math.max(radius, 0.01)]}
userData={STAIR_RAILING_SLOT_USER_DATA}
/>
)
}
function CurvedStairBody({
stair,
bodyMaterials,
}: {
stair: StairNode
bodyMaterials: StairBodyMaterials
}) {
const nodes = useScene((state) => state.nodes)
const sideMaterial = bodyMaterials[1]
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
const totalRise = Math.max(resolveStairTotalRise(stair, nodes), 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) ? (
<SpiralColumnMesh
height={spiralColumnHeight}
material={sideMaterial}
radius={spiralColumnRadius}
/>
) : 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) ? (
<SpiralStepSupportMesh
innerRadius={innerRadius}
material={sideMaterial}
midAngle={midAngle}
spiralColumnRadius={spiralColumnRadius}
thickness={thickness}
/>
) : null}
<CurvedStepMesh
endAngle={endAngle}
innerRadius={innerRadius}
material={bodyMaterials}
outerRadius={outerRadius}
positionY={0}
startAngle={startAngle}
stepHeight={actualStepHeight}
thickness={thickness}
/>
</group>
)
})}
{isSpiral && (stair.topLandingMode ?? 'none') === 'integrated' ? (
<CurvedStepMesh
endAngle={sweepAngle / 2 + spiralLandingSweep}
innerRadius={innerRadius}
material={bodyMaterials}
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 | THREE.Material[]
}) {
const geometry = useMemo(
() =>
buildCurvedStepGeometry(
innerRadius,
outerRadius,
startAngle,
endAngle,
Math.max(stepHeight, thickness),
),
[endAngle, innerRadius, outerRadius, startAngle, stepHeight, thickness],
)
// Dispose the prior BufferGeometry as soon as a new one supersedes it.
// Resize drags (in 2D or 3D) rebuild this geometry every pointer move;
// without explicit disposal, WebGPU keeps a stale pipeline reference to
// the old vertex buffer and flags "Vertex buffer slot 0 required by
// [RenderPipeline ...MeshLambertNodeMaterial...] was not set" on the
// submit after the swap. Same mitigation as guide/renderer.tsx.
useEffect(
() => () => {
geometry.dispose()
},
[geometry],
)
return (
<mesh
castShadow
geometry={geometry}
material={material}
position-y={positionY}
receiveShadow
userData={STAIR_BODY_SLOT_USER_DATA}
/>
)
}
/**
* Spiral center column. The cylinder is rebuilt whenever
* `spiralColumnRadius` changes — i.e. on every tick of an inner-radius
* drag. We pass the geometry as a prop (avoiding R3F's empty-placeholder
* frame from inline JSX) and dispose the prior one on swap, matching the
* pattern in guide/renderer.tsx. Without this WebGPU flags
* "Vertex buffer slot 0 ... was not set" on Lambert mid-resize.
*/
function SpiralColumnMesh({
radius,
height,
material,
}: {
radius: number
height: number
material: THREE.Material | THREE.Material[]
}) {
const geometry = useMemo(
() => new THREE.CylinderGeometry(radius, radius, height, 10),
[radius, height],
)
useEffect(
() => () => {
geometry.dispose()
},
[geometry],
)
return (
<mesh
castShadow
geometry={geometry}
material={material}
name="stair-side"
position={[0, height / 2, 0]}
receiveShadow
userData={STAIR_BODY_SINGLE_SLOT_USER_DATA}
/>
)
}
/**
* Spiral step support — the small box wedged between the column and the
* inner rim of each step. Same prop-+-dispose pattern as
* `SpiralColumnMesh`: the box dimensions change every inner-radius tick
* (`innerRadius - spiralColumnRadius`), so inline-JSX geometry would
* trigger the Lambert vertex-buffer error.
*/
function SpiralStepSupportMesh({
innerRadius,
spiralColumnRadius,
midAngle,
thickness,
material,
}: {
innerRadius: number
spiralColumnRadius: number
midAngle: number
thickness: number
material: THREE.Material | THREE.Material[]
}) {
const sizeX = Math.max(0.04, innerRadius - spiralColumnRadius + 0.04)
const sizeY = Math.max(thickness * 0.55, 0.025)
const sizeZ = Math.max(0.04, Math.min(0.12, sizeY * 1.5))
const geometry = useMemo(() => new THREE.BoxGeometry(sizeX, sizeY, sizeZ), [sizeX, sizeY, sizeZ])
useEffect(
() => () => {
geometry.dispose()
},
[geometry],
)
const radial = spiralColumnRadius + sizeX / 2 - 0.02
return (
<mesh
castShadow
geometry={geometry}
material={material}
name="stair-side"
position={[Math.cos(midAngle) * radial, sizeY / 2, Math.sin(midAngle) * radial]}
receiveShadow
rotation-y={-midAngle}
userData={STAIR_BODY_SINGLE_SLOT_USER_DATA}
/>
)
}
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 uvs: number[] = []
const triangleMaterialIndices: number[] = []
const pointOnArc = (radius: number, angle: number, y: number) =>
new THREE.Vector3(Math.cos(angle) * radius, y, Math.sin(angle) * radius)
const pushUv = (point: THREE.Vector3, normal: THREE.Vector3, materialIndex: number) => {
if (materialIndex === STAIR_TREAD_MATERIAL_INDEX) {
const angle = Math.atan2(point.z, point.x)
const arcOffset = (angle - startAngle) * Math.max((innerRadius + outerRadius) * 0.5, 0.01)
uvs.push(arcOffset, Math.sqrt(point.x * point.x + point.z * point.z) - innerRadius)
return
}
const absX = Math.abs(normal.x)
const absY = Math.abs(normal.y)
const absZ = Math.abs(normal.z)
if (absY >= absX && absY >= absZ) {
uvs.push(point.x, point.z)
} else if (absX >= absZ) {
uvs.push(point.z, point.y)
} else {
uvs.push(point.x, point.y)
}
}
const pushTriangle = (
a: THREE.Vector3,
b: THREE.Vector3,
c: THREE.Vector3,
normal: THREE.Vector3,
materialIndex: number,
) => {
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)
pushUv(point, normal, materialIndex)
}
triangleMaterialIndices.push(materialIndex)
}
const pushQuad = (
a: THREE.Vector3,
b: THREE.Vector3,
c: THREE.Vector3,
d: THREE.Vector3,
normal: THREE.Vector3,
materialIndex: number,
) => {
pushTriangle(a, b, c, normal, materialIndex)
pushTriangle(a, c, d, normal, materialIndex)
}
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,
STAIR_TREAD_MATERIAL_INDEX,
)
pushQuad(
innerStartBottom,
innerEndBottom,
outerEndBottom,
outerStartBottom,
downNormal,
STAIR_SIDE_MATERIAL_INDEX,
)
pushQuad(
innerStartBottom,
innerStartTop,
innerEndTop,
innerEndBottom,
innerNormal,
STAIR_SIDE_MATERIAL_INDEX,
)
pushQuad(
outerStartBottom,
outerEndBottom,
outerEndTop,
outerStartTop,
outerNormal,
STAIR_SIDE_MATERIAL_INDEX,
)
}
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,
STAIR_SIDE_MATERIAL_INDEX,
)
pushQuad(
endInnerBottom,
endInnerTop,
endOuterTop,
endOuterBottom,
endNormal,
STAIR_SIDE_MATERIAL_INDEX,
)
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3))
geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3))
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2))
geometry.clearGroups()
let currentMaterial = triangleMaterialIndices[0]
let groupStart = 0
for (let triangleIndex = 1; triangleIndex < triangleMaterialIndices.length; triangleIndex++) {
const materialIndex = triangleMaterialIndices[triangleIndex]
if (materialIndex === currentMaterial) continue
geometry.addGroup(groupStart * 3, (triangleIndex - groupStart) * 3, currentMaterial)
groupStart = triangleIndex
currentMaterial = materialIndex
}
if (triangleMaterialIndices.length > 0) {
geometry.addGroup(
groupStart * 3,
(triangleMaterialIndices.length - groupStart) * 3,
currentMaterial ?? STAIR_SIDE_MATERIAL_INDEX,
)
}
geometry.setAttribute('uv2', new THREE.Float32BufferAttribute(uvs.slice(), 2))
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?.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])
}
export default StairRenderer