Wholesale move of every remaining kind into its own subdirectory under
`packages/nodes/src/`, finishing the registry-driven migration. Each
kind now ships its definition, schema (re-exported from core), and any
of `geometry` / `renderer` / `system` / `floorplan` / `tool` /
`move-tool` / `panel` / `floorplan-move` / `floorplan-affordances` /
`parametrics` / `preview` it needs — no per-kind code remains under
`packages/editor/src/components/tools/` or
`packages/viewer/src/components/renderers/`.
Deleted (replaced by registry-driven equivalents):
- `tools/{ceiling,column,door,fence,item,slab,spawn,wall,window}/...`
(boundary editors, hole editors, placement tools, move tools,
endpoint movers, curve tools, helpers, math libs)
- `ui/helpers/{ceiling,slab,wall}-helper.tsx`
- `ui/panels/{column,door,elevator,item,roof,roof-segment,spawn,
stair,stair-segment,wall,window}-panel.tsx`
- `viewer/src/components/renderers/{building,ceiling,column,door,
elevator,fence,guide,item,level,roof,roof-segment,scan,site,slab,
spawn,stair,stair-segment,wall,window,zone}-renderer.tsx`
- `viewer/src/components/viewer/legacy-system.tsx`
Added under `packages/nodes/src/`:
- `building/`, `column/`, `elevator/`, `guide/`, `level/`, `roof/`,
`roof-segment/`, `scan/`, `shared/`, `site/`, `stair/`,
`stair-segment/` packages with definition + schema + renderer / system
/ floorplan / panel as appropriate.
- New `floorplan-move.ts` for every kind that supports 2D moves
(ceiling, door, item, shelf, slab, window) — single registry-driven
dispatch path via `def.floorplanMoveTarget`.
- New `floorplan-affordances.ts` for kinds with polygon / endpoint
drags (ceiling, fence, slab, wall) — using the shared
`polygon-vertex-affordance` factories.
- New per-kind `panel.tsx` for kinds with custom inspector content
(door, item, shelf, spawn, wall, window).
- New per-kind `tool.tsx` for placement (door, item, shelf, window).
- New per-kind `move-tool.tsx` for kinds with custom 3D move flows
(door, item, slab, window).
Coordinator + manager updates in `packages/editor/`:
- `tool-manager.tsx` resolves tools from the registry only — no
hardcoded type→component map.
- `panel-manager.tsx` resolves inspector panels the same way.
- `placement-{coordinator,strategies,types}.ts` extended with
shelf-surface placement.
- `selection-manager.tsx` adds the registry-selectable fallback.
- `floorplan-panel.tsx`, `floorplan-background-placement.ts`,
`floorplan-render-context.tsx` updated for the registry layer's new
contract (props, affordance dispatch, render context).
Viewer updates:
- `viewer/index.tsx` drops legacy renderer mounts.
- `node-renderer.tsx` resolves by registry only.
- `scene-bvh.tsx`, `use-node-events.ts`, `level-system.tsx`,
`wall-cutout.tsx`, `zone-system.tsx`, `materials.ts` adjusted for
the registry-only world.
Sidebar tree nodes for ceiling / fence / slab / shelf / tree-node
updated to read from the registered nodes instead of the deleted
legacy renderer trees.
Wiki: new `plugin-authoring.md` page, README index updated.
Tests in `packages/nodes/src/index.test.ts` validate every registered
kind has the required shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1132 lines
37 KiB
TypeScript
1132 lines
37 KiB
TypeScript
'use client'
|
|
|
|
import {
|
|
type AnyNodeId,
|
|
type StairNode,
|
|
type StairSegmentNode,
|
|
useRegistry,
|
|
useScene,
|
|
} from '@pascal-app/core'
|
|
import {
|
|
createMaterial,
|
|
createMaterialFromPresetRef,
|
|
DEFAULT_STAIR_MATERIAL,
|
|
getStairBodyMaterials,
|
|
getStairRailingMaterial,
|
|
NodeRenderer,
|
|
type StairBodyMaterials,
|
|
useNodeEvents,
|
|
} from '@pascal-app/viewer'
|
|
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
|
import * as THREE from 'three'
|
|
|
|
type SegmentTransform = {
|
|
position: [number, number, number]
|
|
rotation: number
|
|
}
|
|
|
|
type StairRailPathSide = 'left' | 'right' | 'front'
|
|
|
|
type StairRailSidePath = {
|
|
side: StairRailPathSide
|
|
points: [number, number, number][]
|
|
}
|
|
|
|
type StairSegmentRailPath = {
|
|
layout: StairRailLayout
|
|
sidePaths: StairRailSidePath[]
|
|
connectFromPrevious: boolean
|
|
}
|
|
|
|
type StairRailLayout = {
|
|
center: [number, number]
|
|
elevation: number
|
|
rotation: number
|
|
segment: StairSegmentNode
|
|
}
|
|
|
|
type LandingChainNextStair = {
|
|
nextStairLayout?: StairRailLayout
|
|
isTerminalLandingBeforeStair: boolean
|
|
}
|
|
|
|
export const StairRenderer = ({ node }: { node: StairNode }) => {
|
|
const ref = useRef<THREE.Group>(null!)
|
|
const isSegmentBasedStair = node.stairType === 'straight'
|
|
|
|
useRegistry(node.id, 'stair', ref)
|
|
|
|
useLayoutEffect(() => {
|
|
useScene.getState().markDirty(node.id)
|
|
}, [node.id])
|
|
|
|
const handlers = useNodeEvents(node, 'stair')
|
|
|
|
const material = useMemo(() => {
|
|
const presetMaterial = createMaterialFromPresetRef(node.materialPreset)
|
|
if (presetMaterial) return presetMaterial
|
|
const mat = node.material
|
|
if (!mat) return DEFAULT_STAIR_MATERIAL
|
|
return createMaterial(mat)
|
|
}, [
|
|
node.materialPreset,
|
|
node.material,
|
|
node.material?.preset,
|
|
node.material?.properties,
|
|
node.material?.texture,
|
|
])
|
|
|
|
const straightBodyMaterials = useMemo(() => getStairBodyMaterials(node), [node])
|
|
|
|
const railingMaterial = useMemo(() => getStairRailingMaterial(node), [node])
|
|
|
|
const straightPlaceholderGeometry = useMemo(() => {
|
|
const geometry = new THREE.BufferGeometry()
|
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
|
geometry.addGroup(0, 0, 0)
|
|
geometry.addGroup(0, 0, 1)
|
|
return geometry
|
|
}, [])
|
|
|
|
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={straightBodyMaterials}
|
|
name="merged-stair"
|
|
receiveShadow
|
|
/>
|
|
) : null}
|
|
{isSegmentBasedStair ? null : (
|
|
<CurvedStairBody bodyMaterials={straightBodyMaterials} 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)
|
|
|
|
const segments = useMemo(
|
|
() =>
|
|
(stair.children ?? [])
|
|
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
|
|
.filter(
|
|
(node): node is StairSegmentNode =>
|
|
node?.type === 'stair-segment' && node.visible !== false,
|
|
),
|
|
[nodes, stair.children],
|
|
)
|
|
|
|
const railPaths = useMemo(
|
|
() => buildStairRailPaths(segments, stair.railingMode ?? 'none'),
|
|
[segments, stair.railingMode],
|
|
)
|
|
|
|
const railHeight = stair.railingHeight ?? 0.92
|
|
const midRailHeight = Math.max(railHeight * 0.45, 0.35)
|
|
const railRadius = 0.022
|
|
const balusterRadius = 0.018
|
|
|
|
if ((stair.railingMode ?? 'none') === 'none') {
|
|
return null
|
|
}
|
|
|
|
if (stair.stairType === 'curved' || stair.stairType === 'spiral') {
|
|
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
|
|
const sweepAngle =
|
|
stair.sweepAngle ?? (stair.stairType === 'spiral' ? Math.PI * 2 : Math.PI / 2)
|
|
const stepSweep = sweepAngle / stepCount
|
|
const stepHeight = Math.max(stair.totalRise ?? 2.5, 0.1) / stepCount
|
|
const innerRadius = Math.max(
|
|
stair.stairType === 'spiral' ? 0.05 : 0.2,
|
|
stair.innerRadius ?? 0.9,
|
|
)
|
|
const outerRadius = innerRadius + Math.max(stair.width ?? 1, 0.4)
|
|
const leftRadius = sweepAngle >= 0 ? innerRadius + 0.04 : outerRadius - 0.04
|
|
const rightRadius = sweepAngle >= 0 ? outerRadius - 0.04 : innerRadius + 0.04
|
|
const radii =
|
|
stair.railingMode === 'both'
|
|
? [leftRadius, rightRadius]
|
|
: stair.railingMode === 'left'
|
|
? [leftRadius]
|
|
: stair.railingMode === 'right'
|
|
? [rightRadius]
|
|
: []
|
|
|
|
return (
|
|
<group name="stair-railing">
|
|
{radii.map((radius, sideIndex) => {
|
|
const sidePoints = Array.from({ length: stepCount }).map((_, index) => {
|
|
const angle = -sweepAngle / 2 + stepSweep * index + stepSweep / 2
|
|
return [
|
|
Math.cos(angle) * radius,
|
|
stair.stairType === 'spiral'
|
|
? stepHeight * index + Math.max(stair.thickness ?? 0.25, 0.02)
|
|
: stepHeight * (index + 1),
|
|
Math.sin(angle) * radius,
|
|
] as [number, number, number]
|
|
})
|
|
|
|
return (
|
|
<group key={`${stair.id}-curved-railing-${sideIndex}`}>
|
|
{sidePoints.map((point, pointIndex) => (
|
|
<mesh
|
|
castShadow
|
|
geometry={BALUSTER_GEOMETRY}
|
|
key={`${stair.id}-curved-baluster-${sideIndex}-${pointIndex}`}
|
|
material={material}
|
|
name="stair-railing-baluster"
|
|
position={[point[0], point[1] + railHeight / 2, point[2]]}
|
|
receiveShadow
|
|
scale={[balusterRadius, railHeight, balusterRadius]}
|
|
/>
|
|
))}
|
|
{sidePoints.slice(0, -1).map((point, pointIndex) => {
|
|
const nextPoint = sidePoints[pointIndex + 1]
|
|
if (!nextPoint) return null
|
|
|
|
return (
|
|
<group key={`${stair.id}-curved-rail-${sideIndex}-${pointIndex}`}>
|
|
<RailSegment
|
|
end={[nextPoint[0], nextPoint[1] + railHeight, nextPoint[2]]}
|
|
material={material}
|
|
radius={railRadius}
|
|
start={[point[0], point[1] + railHeight, point[2]]}
|
|
/>
|
|
<RailSegment
|
|
end={[nextPoint[0], nextPoint[1] + midRailHeight, nextPoint[2]]}
|
|
material={material}
|
|
radius={railRadius * 0.8}
|
|
start={[point[0], point[1] + midRailHeight, point[2]]}
|
|
/>
|
|
</group>
|
|
)
|
|
})}
|
|
</group>
|
|
)
|
|
})}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
if (railPaths.length === 0) {
|
|
return null
|
|
}
|
|
|
|
return (
|
|
<group name="stair-railing">
|
|
{railPaths.map((segmentPath, index) => (
|
|
<group
|
|
key={`${segmentPath.layout.segment.id}-railing`}
|
|
position={[
|
|
segmentPath.layout.center[0],
|
|
segmentPath.layout.elevation,
|
|
segmentPath.layout.center[1],
|
|
]}
|
|
rotation-y={segmentPath.layout.rotation}
|
|
>
|
|
{segmentPath.sidePaths.map((sidePath, sideIndex) => (
|
|
<group key={`${segmentPath.layout.segment.id}-${sidePath.side}-${sideIndex}`}>
|
|
{sidePath.points.map((point, pointIndex) => (
|
|
<mesh
|
|
castShadow
|
|
geometry={BALUSTER_GEOMETRY}
|
|
key={`${segmentPath.layout.segment.id}-${sidePath.side}-baluster-${pointIndex}`}
|
|
material={material}
|
|
name="stair-railing-baluster"
|
|
position={[point[2], point[1] + railHeight / 2, point[0]]}
|
|
receiveShadow
|
|
scale={[balusterRadius, railHeight, balusterRadius]}
|
|
/>
|
|
))}
|
|
{sidePath.points.slice(0, -1).map((point, pointIndex) => {
|
|
const nextPoint = sidePath.points[pointIndex + 1]
|
|
if (!nextPoint) return null
|
|
|
|
return (
|
|
<group
|
|
key={`${segmentPath.layout.segment.id}-${sidePath.side}-rail-${pointIndex}`}
|
|
>
|
|
<RailSegment
|
|
end={[nextPoint[2], nextPoint[1] + railHeight, nextPoint[0]]}
|
|
material={material}
|
|
radius={railRadius}
|
|
start={[point[2], point[1] + railHeight, point[0]]}
|
|
/>
|
|
<RailSegment
|
|
end={[nextPoint[2], nextPoint[1] + midRailHeight, nextPoint[0]]}
|
|
material={material}
|
|
radius={railRadius * 0.8}
|
|
start={[point[2], point[1] + midRailHeight, point[0]]}
|
|
/>
|
|
</group>
|
|
)
|
|
})}
|
|
</group>
|
|
))}
|
|
</group>
|
|
))}
|
|
{railPaths.slice(1).map((segmentPath, index) => {
|
|
const previousPath = railPaths[index]
|
|
if (!(previousPath && segmentPath.connectFromPrevious)) return null
|
|
if (previousPath.layout.segment.segmentType === 'landing') return null
|
|
if (segmentPath.layout.segment.segmentType === 'landing') return null
|
|
|
|
return segmentPath.sidePaths.map((sidePath, sideIndex) => {
|
|
const currentPoint = sidePath.points[0]
|
|
if (!currentPoint) return null
|
|
|
|
const currentWorldPoint = toWorldRailPoint(segmentPath.layout, currentPoint)
|
|
const previousSidePath = [...previousPath.sidePaths]
|
|
.map((entry) => {
|
|
const lastPoint = entry.points[entry.points.length - 1]
|
|
return {
|
|
entry,
|
|
distance: lastPoint
|
|
? distance3(toWorldRailPoint(previousPath.layout, lastPoint), currentWorldPoint)
|
|
: Number.POSITIVE_INFINITY,
|
|
}
|
|
})
|
|
.sort((left, right) => left.distance - right.distance)[0]?.entry
|
|
const previousPoint = previousSidePath?.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
|
|
|
|
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)]}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function CurvedStairBody({
|
|
stair,
|
|
bodyMaterials,
|
|
}: {
|
|
stair: StairNode
|
|
bodyMaterials: StairBodyMaterials
|
|
}) {
|
|
const sideMaterial = bodyMaterials[1]
|
|
const stepCount = Math.max(2, Math.round(stair.stepCount ?? 10))
|
|
const totalRise = Math.max(stair.totalRise ?? 2.5, 0.1)
|
|
const stepHeight = totalRise / stepCount
|
|
const isSpiral = stair.stairType === 'spiral'
|
|
const innerRadius = Math.max(isSpiral ? 0.05 : 0.2, stair.innerRadius ?? 0.9)
|
|
const outerRadius = innerRadius + Math.max(stair.width ?? 1, 0.4)
|
|
const sweepAngle = stair.sweepAngle ?? (isSpiral ? Math.PI * 2 : Math.PI / 2)
|
|
const stepSweep = sweepAngle / stepCount
|
|
const thickness = Math.max(stair.thickness ?? 0.25, 0.02)
|
|
const fillToFloor = stair.fillToFloor ?? true
|
|
const spiralColumnRadius = Math.max(0.05, Math.min(innerRadius * 0.72, innerRadius - 0.03))
|
|
const spiralColumnHeight = totalRise + thickness
|
|
const spiralLandingDepth = Math.max(
|
|
0.3,
|
|
stair.topLandingDepth ?? Math.max((stair.width ?? 1) * 0.9, 0.8),
|
|
)
|
|
const spiralLandingSweep =
|
|
isSpiral && (stair.topLandingMode ?? 'none') === 'integrated'
|
|
? Math.min(
|
|
Math.PI * 0.75,
|
|
spiralLandingDepth / Math.max(innerRadius + (stair.width ?? 1) / 2, 0.1),
|
|
) * Math.sign(sweepAngle || 1)
|
|
: 0
|
|
const spiralLastStepTop = stepHeight * Math.max(stepCount - 1, 0) + thickness
|
|
const spiralLandingThickness =
|
|
isSpiral && (stair.topLandingMode ?? 'none') === 'integrated'
|
|
? Math.max(0.02, totalRise - spiralLastStepTop)
|
|
: 0
|
|
|
|
return (
|
|
<group name={isSpiral ? 'spiral-stair' : 'curved-stair'}>
|
|
{isSpiral && (stair.showCenterColumn ?? true) ? (
|
|
<mesh
|
|
castShadow
|
|
material={sideMaterial}
|
|
name="stair-side"
|
|
position={[0, spiralColumnHeight / 2, 0]}
|
|
receiveShadow
|
|
>
|
|
<cylinderGeometry
|
|
args={[spiralColumnRadius, spiralColumnRadius, spiralColumnHeight, 10]}
|
|
/>
|
|
</mesh>
|
|
) : null}
|
|
{Array.from({ length: stepCount }).map((_, index) => {
|
|
const currentHeight = stepHeight * (index + 1)
|
|
const actualStepHeight = isSpiral
|
|
? thickness
|
|
: fillToFloor
|
|
? Math.max(currentHeight, thickness)
|
|
: thickness
|
|
const startAngle = -sweepAngle / 2 + stepSweep * index
|
|
const endAngle = startAngle + stepSweep
|
|
const stepY = isSpiral
|
|
? stepHeight * index
|
|
: fillToFloor
|
|
? 0
|
|
: Math.max(currentHeight - thickness, 0)
|
|
const midAngle = startAngle + stepSweep / 2
|
|
|
|
return (
|
|
<group
|
|
key={`${stair.id}-${isSpiral ? 'spiral' : 'curved'}-step-${index}`}
|
|
position-y={stepY}
|
|
>
|
|
{isSpiral && (stair.showStepSupports ?? true) ? (
|
|
<mesh
|
|
castShadow
|
|
material={sideMaterial}
|
|
name="stair-side"
|
|
position={[
|
|
Math.cos(midAngle) *
|
|
(spiralColumnRadius +
|
|
Math.max(0.04, innerRadius - spiralColumnRadius + 0.04) / 2 -
|
|
0.02),
|
|
Math.max(thickness * 0.55, 0.025) / 2,
|
|
Math.sin(midAngle) *
|
|
(spiralColumnRadius +
|
|
Math.max(0.04, innerRadius - spiralColumnRadius + 0.04) / 2 -
|
|
0.02),
|
|
]}
|
|
receiveShadow
|
|
rotation-y={-midAngle}
|
|
>
|
|
<boxGeometry
|
|
args={[
|
|
Math.max(0.04, innerRadius - spiralColumnRadius + 0.04),
|
|
Math.max(thickness * 0.55, 0.025),
|
|
Math.max(0.04, Math.min(0.12, Math.max(thickness * 0.55, 0.025) * 1.5)),
|
|
]}
|
|
/>
|
|
</mesh>
|
|
) : null}
|
|
<CurvedStepMesh
|
|
endAngle={endAngle}
|
|
innerRadius={innerRadius}
|
|
material={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],
|
|
)
|
|
|
|
return (
|
|
<mesh castShadow geometry={geometry} material={material} position-y={positionY} receiveShadow />
|
|
)
|
|
}
|
|
|
|
function buildCurvedStepGeometry(
|
|
innerRadius: number,
|
|
outerRadius: number,
|
|
startAngle: number,
|
|
endAngle: number,
|
|
height: number,
|
|
) {
|
|
const clampedHeight = Math.max(height, 0.02)
|
|
const y0 = 0
|
|
const y1 = clampedHeight
|
|
const sweepAngle = endAngle - startAngle
|
|
const sweepDirection = Math.sign(sweepAngle) || 1
|
|
const segmentCount = Math.max(
|
|
4,
|
|
Math.min(
|
|
24,
|
|
Math.ceil(
|
|
Math.abs(sweepAngle) / (Math.PI / 18) + Math.max(0, (outerRadius - innerRadius) * 3),
|
|
),
|
|
),
|
|
)
|
|
|
|
const positions: number[] = []
|
|
const normals: number[] = []
|
|
const 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 || 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
|