Implement stair-driven slab hole cutouts
This commit is contained in:
@@ -49,8 +49,15 @@ export { ScanNode } from './nodes/scan'
|
||||
// Nodes
|
||||
export { SiteNode } from './nodes/site'
|
||||
export { SlabNode } from './nodes/slab'
|
||||
export { StairNode, StairRailingMode, StairTopLandingMode, StairType } from './nodes/stair'
|
||||
export {
|
||||
StairNode,
|
||||
StairRailingMode,
|
||||
StairSlabOpeningMode,
|
||||
StairTopLandingMode,
|
||||
StairType,
|
||||
} from './nodes/stair'
|
||||
export { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
|
||||
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
|
||||
export { WallNode } from './nodes/wall'
|
||||
export { WindowNode } from './nodes/window'
|
||||
export { ZoneNode } from './nodes/zone'
|
||||
|
||||
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { MaterialSchema } from '../material'
|
||||
import { ItemNode } from './item'
|
||||
import { SurfaceHoleMetadata } from './surface-hole-metadata'
|
||||
|
||||
export const CeilingNode = BaseNode.extend({
|
||||
id: objectId('ceiling'),
|
||||
@@ -12,6 +13,7 @@ export const CeilingNode = BaseNode.extend({
|
||||
materialPreset: z.string().optional(),
|
||||
polygon: z.array(z.tuple([z.number(), z.number()])),
|
||||
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),
|
||||
holeMetadata: z.array(SurfaceHoleMetadata).default([]),
|
||||
height: z.number().default(2.5), // Height in meters
|
||||
autoFromWalls: z.boolean().default(false),
|
||||
}).describe(
|
||||
@@ -19,6 +21,7 @@ export const CeilingNode = BaseNode.extend({
|
||||
Ceiling node - used to represent a ceiling in the building
|
||||
- polygon: array of [x, z] points defining the ceiling boundary
|
||||
- holes: array of polygons representing holes in the ceiling
|
||||
- holeMetadata: metadata parallel to holes, used to preserve manual and stair-managed cutouts
|
||||
- autoFromWalls: whether the ceiling is automatically generated from a closed wall loop
|
||||
`,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import { MaterialSchema } from '../material'
|
||||
import { SurfaceHoleMetadata } from './surface-hole-metadata'
|
||||
|
||||
export const SlabNode = BaseNode.extend({
|
||||
id: objectId('slab'),
|
||||
@@ -10,12 +11,15 @@ export const SlabNode = BaseNode.extend({
|
||||
materialPreset: z.string().optional(),
|
||||
polygon: z.array(z.tuple([z.number(), z.number()])),
|
||||
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]),
|
||||
holeMetadata: z.array(SurfaceHoleMetadata).default([]),
|
||||
elevation: z.number().default(0.05), // Elevation in meters
|
||||
autoFromWalls: z.boolean().default(false),
|
||||
}).describe(
|
||||
dedent`
|
||||
Slab node - used to represent a slab/floor in the building
|
||||
- polygon: array of [x, z] points defining the slab boundary
|
||||
- holes: array of [x, z] polygons representing cutouts in the slab
|
||||
- holeMetadata: metadata parallel to holes, used to preserve manual and stair-managed cutouts
|
||||
- elevation: elevation in meters
|
||||
- autoFromWalls: whether the slab is automatically generated from a closed wall loop
|
||||
`,
|
||||
|
||||
@@ -7,10 +7,12 @@ 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 const StairSlabOpeningMode = z.enum(['none', 'destination'])
|
||||
|
||||
export type StairRailingMode = z.infer<typeof StairRailingMode>
|
||||
export type StairType = z.infer<typeof StairType>
|
||||
export type StairTopLandingMode = z.infer<typeof StairTopLandingMode>
|
||||
export type StairSlabOpeningMode = z.infer<typeof StairSlabOpeningMode>
|
||||
|
||||
export const StairNode = BaseNode.extend({
|
||||
id: objectId('stair'),
|
||||
@@ -21,6 +23,10 @@ export const StairNode = BaseNode.extend({
|
||||
// Rotation around Y axis in radians
|
||||
rotation: z.number().default(0),
|
||||
stairType: StairType.default('straight'),
|
||||
fromLevelId: z.string().nullable().default(null),
|
||||
toLevelId: z.string().nullable().default(null),
|
||||
slabOpeningMode: StairSlabOpeningMode.default('none'),
|
||||
openingOffset: z.number().default(0),
|
||||
width: z.number().default(1.0),
|
||||
totalRise: z.number().default(2.5),
|
||||
stepCount: z.number().default(10),
|
||||
@@ -44,6 +50,9 @@ export const StairNode = BaseNode.extend({
|
||||
- position: center position of the stair group
|
||||
- rotation: rotation around Y axis
|
||||
- stairType: straight (segment-based), curved (arc-based), or spiral
|
||||
- fromLevelId / toLevelId: source and destination levels used for auto slab cutouts
|
||||
- slabOpeningMode: whether a destination-level slab opening is generated for this stair
|
||||
- openingOffset: extra opening expansion applied after the cutout polygon is computed
|
||||
- width: stair width
|
||||
- totalRise: total stair height
|
||||
- stepCount: number of visible steps
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const SurfaceHoleMetadata = z.object({
|
||||
source: z.enum(['manual', 'stair']).default('manual'),
|
||||
stairId: z.string().optional(),
|
||||
})
|
||||
|
||||
export type SurfaceHoleMetadata = z.infer<typeof SurfaceHoleMetadata>
|
||||
@@ -0,0 +1,627 @@
|
||||
import type { AnyNode, AnyNodeId, LevelNode, SlabNode, StairNode, StairSegmentNode } from '../../schema'
|
||||
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
|
||||
import { DEFAULT_WALL_HEIGHT } from '../wall/wall-footprint'
|
||||
|
||||
type Point2D = [number, number]
|
||||
|
||||
type SurfaceHoleMetadata = {
|
||||
source: 'manual' | 'stair'
|
||||
stairId?: string
|
||||
}
|
||||
|
||||
type SegmentTransform = {
|
||||
position: [number, number, number]
|
||||
rotation: number
|
||||
}
|
||||
|
||||
type StraightStairLayout = {
|
||||
segment: StairSegmentNode
|
||||
transform: SegmentTransform
|
||||
topElevation: number
|
||||
}
|
||||
|
||||
type AxisAlignedRect = {
|
||||
minX: number
|
||||
maxX: number
|
||||
minZ: number
|
||||
maxZ: number
|
||||
}
|
||||
|
||||
const CURVED_STAIR_SLAB_OPENING_RATIO = 0.8
|
||||
const STRAIGHT_STAIR_TARGET_THRESHOLD_MIN = 0.35
|
||||
const STAIR_SLAB_OPENING_TIGHTENING = 0
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value))
|
||||
}
|
||||
|
||||
function pointsEqual(a: Point2D, b: Point2D, tolerance = 1e-5) {
|
||||
const dx = a[0] - b[0]
|
||||
const dz = a[1] - b[1]
|
||||
return dx * dx + dz * dz <= tolerance * tolerance
|
||||
}
|
||||
|
||||
function polygonsEqual(left: Point2D[][], right: Point2D[][]) {
|
||||
if (left.length !== right.length) return false
|
||||
return left.every((polygon, polygonIndex) => {
|
||||
const other = right[polygonIndex]
|
||||
if (!(other && polygon.length === other.length)) return false
|
||||
return polygon.every((point, pointIndex) => {
|
||||
const otherPoint = other[pointIndex]
|
||||
if (!otherPoint) return false
|
||||
return pointsEqual(point, otherPoint)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function metadataEqual(left: SurfaceHoleMetadata[], right: SurfaceHoleMetadata[]) {
|
||||
if (left.length !== right.length) return false
|
||||
return left.every(
|
||||
(entry, index) =>
|
||||
entry.source === right[index]?.source && (entry.stairId ?? null) === (right[index]?.stairId ?? null),
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeExistingMetadata(
|
||||
holes: Point2D[][],
|
||||
metadata: SurfaceHoleMetadata[] | undefined,
|
||||
): SurfaceHoleMetadata[] {
|
||||
return holes.map((_, index) => metadata?.[index] ?? { source: 'manual' })
|
||||
}
|
||||
|
||||
function expandPolygonFromCentroid(polygon: Point2D[], offset: number) {
|
||||
if (Math.abs(offset) < 1e-6) {
|
||||
return polygon.map(([x, z]) => [x, z] as Point2D)
|
||||
}
|
||||
|
||||
const centroid = polygon.reduce(
|
||||
(acc, [x, z]) => {
|
||||
acc.x += x
|
||||
acc.z += z
|
||||
return acc
|
||||
},
|
||||
{ x: 0, z: 0 },
|
||||
)
|
||||
centroid.x /= Math.max(polygon.length, 1)
|
||||
centroid.z /= Math.max(polygon.length, 1)
|
||||
|
||||
return polygon.map(([x, z]) => {
|
||||
const dx = x - centroid.x
|
||||
const dz = z - centroid.z
|
||||
const length = Math.hypot(dx, dz)
|
||||
if (length < 1e-6) {
|
||||
return [x, z] as Point2D
|
||||
}
|
||||
|
||||
const scale = Math.max(0.1, (length + offset) / length)
|
||||
return [centroid.x + dx * scale, centroid.z + dz * scale] as Point2D
|
||||
})
|
||||
}
|
||||
|
||||
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 computeSegmentTransforms(segments: StairSegmentNode[]): SegmentTransform[] {
|
||||
const transforms: SegmentTransform[] = []
|
||||
let currentX = 0
|
||||
let currentY = 0
|
||||
let currentZ = 0
|
||||
let currentRot = 0
|
||||
|
||||
for (let index = 0; index < segments.length; index++) {
|
||||
const segment = segments[index]
|
||||
if (!segment) continue
|
||||
|
||||
if (index === 0) {
|
||||
transforms.push({
|
||||
position: [currentX, currentY, currentZ],
|
||||
rotation: currentRot,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const previous = segments[index - 1]
|
||||
if (!previous) continue
|
||||
|
||||
let attachX = 0
|
||||
let attachZ = 0
|
||||
let rotationDelta = 0
|
||||
|
||||
switch (segment.attachmentSide) {
|
||||
case 'front':
|
||||
attachX = 0
|
||||
attachZ = previous.length
|
||||
break
|
||||
case 'left':
|
||||
attachX = previous.width / 2
|
||||
attachZ = previous.length / 2
|
||||
rotationDelta = Math.PI / 2
|
||||
break
|
||||
case 'right':
|
||||
attachX = -previous.width / 2
|
||||
attachZ = previous.length / 2
|
||||
rotationDelta = -Math.PI / 2
|
||||
break
|
||||
}
|
||||
|
||||
const [deltaX, deltaZ] = rotateXZ(attachX, attachZ, currentRot)
|
||||
currentX += deltaX
|
||||
currentY += previous.height
|
||||
currentZ += deltaZ
|
||||
currentRot += rotationDelta
|
||||
|
||||
transforms.push({
|
||||
position: [currentX, currentY, currentZ],
|
||||
rotation: currentRot,
|
||||
})
|
||||
}
|
||||
|
||||
return transforms
|
||||
}
|
||||
|
||||
function getLevelNumber(levelId: string | null, nodes: Record<string, AnyNode>) {
|
||||
if (!levelId) return undefined
|
||||
const node = nodes[levelId as AnyNodeId]
|
||||
return node?.type === 'level' ? node.level : undefined
|
||||
}
|
||||
|
||||
function getResolvedStairLevelIds(stair: StairNode, nodes: Record<string, AnyNode>) {
|
||||
const parentLevelId = resolveLevelId(stair, nodes)
|
||||
const fromLevelId = stair.fromLevelId ?? parentLevelId
|
||||
const toLevelId = stair.toLevelId ?? fromLevelId
|
||||
return { fromLevelId, toLevelId }
|
||||
}
|
||||
|
||||
function resolveStraightSegments(stair: StairNode, nodes: Record<string, AnyNode>) {
|
||||
return (stair.children ?? [])
|
||||
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
|
||||
.filter((segment): segment is StairSegmentNode => segment?.type === 'stair-segment' && segment.visible !== false)
|
||||
}
|
||||
|
||||
function toWorldPlanPoint(stair: StairNode, localX: number, localZ: number): Point2D {
|
||||
const [worldX, worldZ] = rotateXZ(localX, localZ, stair.rotation ?? 0)
|
||||
return [stair.position[0] + worldX, stair.position[2] + worldZ]
|
||||
}
|
||||
|
||||
function getStraightStairLayouts(stair: StairNode, nodes: Record<string, AnyNode>): StraightStairLayout[] {
|
||||
const segments = resolveStraightSegments(stair, nodes)
|
||||
const transforms = computeSegmentTransforms(segments)
|
||||
|
||||
return segments.map((segment, index) => {
|
||||
const transform = transforms[index] ?? {
|
||||
position: [0, 0, 0] as [number, number, number],
|
||||
rotation: 0,
|
||||
}
|
||||
|
||||
return {
|
||||
segment,
|
||||
transform,
|
||||
topElevation: transform.position[1] + (segment.segmentType === 'stair' ? segment.height : 0),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function getStraightSegmentFootprintPolygon(stair: StairNode, layout: StraightStairLayout): Point2D[] {
|
||||
return getStraightSegmentSlicePolygon(stair, layout, 0, layout.segment.length)
|
||||
}
|
||||
|
||||
function getStraightSegmentLocalSlicePolygon(
|
||||
layout: StraightStairLayout,
|
||||
startAlong: number,
|
||||
endAlong: number,
|
||||
): Point2D[] {
|
||||
const { segment, transform } = layout
|
||||
const clampedStart = clamp(startAlong, 0, segment.length)
|
||||
const clampedEnd = clamp(endAlong, clampedStart, segment.length)
|
||||
const sliceLength = Math.max(clampedEnd - clampedStart, 1e-4)
|
||||
const sliceCenterAlong = clampedStart + sliceLength / 2
|
||||
const [centerOffsetX, centerOffsetZ] = rotateXZ(0, sliceCenterAlong, transform.rotation)
|
||||
const centerX = transform.position[0] + centerOffsetX
|
||||
const centerZ = transform.position[2] + centerOffsetZ
|
||||
const halfWidth = segment.width / 2
|
||||
const halfLength = sliceLength / 2
|
||||
const corners: Point2D[] = [
|
||||
[-halfWidth, -halfLength],
|
||||
[halfWidth, -halfLength],
|
||||
[halfWidth, halfLength],
|
||||
[-halfWidth, halfLength],
|
||||
]
|
||||
|
||||
return corners.map(([localWidth, localLength]) => {
|
||||
const [offsetX, offsetZ] = rotateXZ(localWidth, localLength, transform.rotation)
|
||||
return [centerX + offsetX, centerZ + offsetZ]
|
||||
})
|
||||
}
|
||||
|
||||
function getStraightSegmentSlicePolygon(
|
||||
stair: StairNode,
|
||||
layout: StraightStairLayout,
|
||||
startAlong: number,
|
||||
endAlong: number,
|
||||
): Point2D[] {
|
||||
return getStraightSegmentLocalSlicePolygon(layout, startAlong, endAlong).map(([x, z]) => toWorldPlanPoint(stair, x, z))
|
||||
}
|
||||
|
||||
function getStraightFlightOpeningDepth(stair: StairNode, segment: StairSegmentNode) {
|
||||
const treadDepth = Math.max(0.2, segment.length / Math.max(segment.stepCount || stair.stepCount || 10, 1))
|
||||
return Math.min(segment.length, Math.max(treadDepth * 6, segment.length * 0.62, 1.8))
|
||||
}
|
||||
|
||||
function polygonArea(points: Point2D[]) {
|
||||
let area = 0
|
||||
for (let index = 0; index < points.length; index += 1) {
|
||||
const current = points[index]
|
||||
const next = points[(index + 1) % points.length]
|
||||
if (!current || !next) continue
|
||||
area += current[0] * next[1] - next[0] * current[1]
|
||||
}
|
||||
return area / 2
|
||||
}
|
||||
|
||||
function getAxisAlignedRectFromPolygon(polygon: Point2D[]): AxisAlignedRect | null {
|
||||
if (polygon.length < 4) return null
|
||||
const xs = polygon.map(([x]) => x)
|
||||
const zs = polygon.map(([, z]) => z)
|
||||
const minX = Math.min(...xs)
|
||||
const maxX = Math.max(...xs)
|
||||
const minZ = Math.min(...zs)
|
||||
const maxZ = Math.max(...zs)
|
||||
if (!(maxX > minX && maxZ > minZ)) return null
|
||||
return { minX, maxX, minZ, maxZ }
|
||||
}
|
||||
|
||||
function expandRect(rect: AxisAlignedRect, offset: number): AxisAlignedRect {
|
||||
if (offset <= 1e-6) {
|
||||
return rect
|
||||
}
|
||||
|
||||
return {
|
||||
minX: rect.minX - offset,
|
||||
maxX: rect.maxX + offset,
|
||||
minZ: rect.minZ - offset,
|
||||
maxZ: rect.maxZ + offset,
|
||||
}
|
||||
}
|
||||
|
||||
function buildUnionPolygonsFromRects(rects: AxisAlignedRect[]): Point2D[][] {
|
||||
if (rects.length === 0) return []
|
||||
|
||||
const xs = Array.from(new Set(rects.flatMap((rect) => [rect.minX, rect.maxX]).map((value) => Number(value.toFixed(6))))).sort(
|
||||
(a, b) => a - b,
|
||||
)
|
||||
const zs = Array.from(new Set(rects.flatMap((rect) => [rect.minZ, rect.maxZ]).map((value) => Number(value.toFixed(6))))).sort(
|
||||
(a, b) => a - b,
|
||||
)
|
||||
if (xs.length < 2 || zs.length < 2) return []
|
||||
|
||||
const occupied = new Set<string>()
|
||||
for (let xi = 0; xi < xs.length - 1; xi += 1) {
|
||||
for (let zi = 0; zi < zs.length - 1; zi += 1) {
|
||||
const cx = (xs[xi]! + xs[xi + 1]!) / 2
|
||||
const cz = (zs[zi]! + zs[zi + 1]!) / 2
|
||||
if (
|
||||
rects.some((rect) => cx > rect.minX && cx < rect.maxX && cz > rect.minZ && cz < rect.maxZ)
|
||||
) {
|
||||
occupied.add(`${xi}:${zi}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const edgeMap = new Map<string, Point2D>()
|
||||
const addEdge = (start: Point2D, end: Point2D) => {
|
||||
edgeMap.set(`${start[0]},${start[1]}`, end)
|
||||
}
|
||||
|
||||
for (let xi = 0; xi < xs.length - 1; xi += 1) {
|
||||
for (let zi = 0; zi < zs.length - 1; zi += 1) {
|
||||
if (!occupied.has(`${xi}:${zi}`)) continue
|
||||
|
||||
const x0 = xs[xi]!
|
||||
const x1 = xs[xi + 1]!
|
||||
const z0 = zs[zi]!
|
||||
const z1 = zs[zi + 1]!
|
||||
|
||||
if (!occupied.has(`${xi}:${zi - 1}`)) addEdge([x0, z0], [x1, z0])
|
||||
if (!occupied.has(`${xi + 1}:${zi}`)) addEdge([x1, z0], [x1, z1])
|
||||
if (!occupied.has(`${xi}:${zi + 1}`)) addEdge([x1, z1], [x0, z1])
|
||||
if (!occupied.has(`${xi - 1}:${zi}`)) addEdge([x0, z1], [x0, z0])
|
||||
}
|
||||
}
|
||||
|
||||
const polygons: Point2D[][] = []
|
||||
while (edgeMap.size > 0) {
|
||||
const firstEntry = edgeMap.entries().next().value as [string, Point2D] | undefined
|
||||
if (!firstEntry) break
|
||||
const [startKey] = firstEntry
|
||||
const startParts = startKey.split(',').map(Number)
|
||||
const sx = startParts[0]
|
||||
const sz = startParts[1]
|
||||
if (sx === undefined || sz === undefined) {
|
||||
edgeMap.delete(startKey)
|
||||
continue
|
||||
}
|
||||
const start: Point2D = [sx, sz]
|
||||
const polygon: Point2D[] = [start]
|
||||
let current = start
|
||||
|
||||
while (true) {
|
||||
const currentKey = `${current[0]},${current[1]}`
|
||||
const next = edgeMap.get(currentKey)
|
||||
if (!next) break
|
||||
edgeMap.delete(currentKey)
|
||||
if (pointsEqual(next, start)) {
|
||||
break
|
||||
}
|
||||
polygon.push(next)
|
||||
current = next
|
||||
}
|
||||
|
||||
if (polygon.length >= 3) {
|
||||
polygons.push(polygonArea(polygon) < 0 ? [...polygon].reverse() : polygon)
|
||||
}
|
||||
}
|
||||
|
||||
return polygons
|
||||
}
|
||||
|
||||
function getCurvedOpeningPolygon(stair: StairNode): Point2D[] {
|
||||
const width = Math.max(stair.width ?? 1, 0.4)
|
||||
const innerRadius = Math.max(0.2, stair.innerRadius ?? 0.9)
|
||||
const outerRadius = innerRadius + width
|
||||
const totalSweep = stair.sweepAngle ?? Math.PI / 2
|
||||
const openingSweep =
|
||||
Math.sign(totalSweep || 1) *
|
||||
Math.max(
|
||||
Math.abs(totalSweep) * CURVED_STAIR_SLAB_OPENING_RATIO,
|
||||
Math.abs(totalSweep) / Math.max(stair.stepCount ?? 1, 1),
|
||||
)
|
||||
const startAngle = totalSweep / 2 - openingSweep
|
||||
const endAngle = totalSweep / 2
|
||||
const segmentCount = Math.max(
|
||||
10,
|
||||
Math.min(
|
||||
32,
|
||||
Math.ceil(Math.abs(openingSweep) / (Math.PI / 24) + Math.max(stair.stepCount ?? 1, 1) * 0.5),
|
||||
),
|
||||
)
|
||||
const outerPoints: Point2D[] = []
|
||||
const innerPoints: Point2D[] = []
|
||||
|
||||
for (let index = 0; index <= segmentCount; index++) {
|
||||
const t = index / segmentCount
|
||||
const angle = startAngle + (endAngle - startAngle) * t
|
||||
outerPoints.push(toWorldPlanPoint(stair, Math.cos(angle) * outerRadius, Math.sin(angle) * outerRadius))
|
||||
}
|
||||
|
||||
for (let index = segmentCount; index >= 0; index--) {
|
||||
const t = index / segmentCount
|
||||
const angle = startAngle + (endAngle - startAngle) * t
|
||||
innerPoints.push(toWorldPlanPoint(stair, Math.cos(angle) * innerRadius, Math.sin(angle) * innerRadius))
|
||||
}
|
||||
|
||||
return [...outerPoints, ...innerPoints]
|
||||
}
|
||||
|
||||
function getSpiralOpeningPolygon(stair: StairNode): Point2D[] {
|
||||
const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4)
|
||||
const segmentCount = 48
|
||||
|
||||
return Array.from({ length: segmentCount }).map((_, index) => {
|
||||
const angle = (index / segmentCount) * Math.PI * 2
|
||||
return toWorldPlanPoint(stair, Math.cos(angle) * radius, Math.sin(angle) * radius)
|
||||
})
|
||||
}
|
||||
|
||||
function getStraightOpeningPolygonsForSurface(
|
||||
stair: StairNode,
|
||||
nodes: Record<string, AnyNode>,
|
||||
targetElevation: number,
|
||||
) {
|
||||
const layouts = getStraightStairLayouts(stair, nodes)
|
||||
if (layouts.length === 0) return []
|
||||
|
||||
const riserHeight = (stair.totalRise ?? 2.5) / Math.max(stair.stepCount ?? 10, 1)
|
||||
const targetThreshold = Math.max(riserHeight * 2, STRAIGHT_STAIR_TARGET_THRESHOLD_MIN)
|
||||
const openingOffset = Math.max(stair.openingOffset ?? 0, 0)
|
||||
const openingRects: AxisAlignedRect[] = []
|
||||
|
||||
for (let index = 0; index < layouts.length; index += 1) {
|
||||
const layout = layouts[index]
|
||||
if (!layout) continue
|
||||
|
||||
const { segment, transform } = layout
|
||||
const segmentStartElevation = transform.position[1]
|
||||
const segmentTopElevation = layout.topElevation
|
||||
|
||||
if (segment.segmentType === 'stair') {
|
||||
const minElevation = Math.min(segmentStartElevation, segmentTopElevation) - targetThreshold
|
||||
const maxElevation = Math.max(segmentStartElevation, segmentTopElevation) + targetThreshold
|
||||
|
||||
if (targetElevation >= minElevation && targetElevation <= maxElevation) {
|
||||
const openingDepth = getStraightFlightOpeningDepth(stair, segment)
|
||||
const climbRatio =
|
||||
segment.height > 1e-6 ? clamp((targetElevation - segmentStartElevation) / segment.height, 0, 1) : 1
|
||||
const intersectionAlong = climbRatio * segment.length
|
||||
const flightRect = getAxisAlignedRectFromPolygon(
|
||||
getStraightSegmentLocalSlicePolygon(layout, Math.max(0, intersectionAlong - openingDepth), segment.length),
|
||||
)
|
||||
if (flightRect) openingRects.push(expandRect(flightRect, openingOffset))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (Math.abs(targetElevation - segmentStartElevation) > targetThreshold) {
|
||||
continue
|
||||
}
|
||||
|
||||
const landingRects: AxisAlignedRect[] = []
|
||||
const landingRect = getAxisAlignedRectFromPolygon(getStraightSegmentLocalSlicePolygon(layout, 0, layout.segment.length))
|
||||
if (landingRect) landingRects.push(expandRect(landingRect, openingOffset))
|
||||
const previous = layouts[index - 1]
|
||||
if (previous?.segment.segmentType === 'stair') {
|
||||
const previousDepth = getStraightFlightOpeningDepth(stair, previous.segment)
|
||||
const previousRect = getAxisAlignedRectFromPolygon(
|
||||
getStraightSegmentLocalSlicePolygon(
|
||||
previous,
|
||||
Math.max(0, previous.segment.length - previousDepth),
|
||||
previous.segment.length,
|
||||
),
|
||||
)
|
||||
if (previousRect) landingRects.push(expandRect(previousRect, openingOffset))
|
||||
}
|
||||
|
||||
const next = layouts[index + 1]
|
||||
if (next?.segment.segmentType === 'stair') {
|
||||
const nextDepth = getStraightFlightOpeningDepth(stair, next.segment)
|
||||
const nextRect = getAxisAlignedRectFromPolygon(
|
||||
getStraightSegmentLocalSlicePolygon(next, 0, Math.min(next.segment.length, nextDepth)),
|
||||
)
|
||||
if (nextRect) landingRects.push(expandRect(nextRect, openingOffset))
|
||||
}
|
||||
|
||||
openingRects.push(...landingRects)
|
||||
}
|
||||
|
||||
if (openingRects.length > 0) {
|
||||
const unionPolygons = buildUnionPolygonsFromRects(openingRects).map((polygon) =>
|
||||
polygon.map(([x, z]) => toWorldPlanPoint(stair, x, z)),
|
||||
)
|
||||
if (unionPolygons.length > 0) {
|
||||
return unionPolygons
|
||||
}
|
||||
}
|
||||
|
||||
let fallbackLayout = layouts[layouts.length - 1]
|
||||
for (let index = layouts.length - 1; index >= 0; index -= 1) {
|
||||
const layout = layouts[index]
|
||||
if (layout?.segment.segmentType === 'stair') {
|
||||
fallbackLayout = layout
|
||||
break
|
||||
}
|
||||
}
|
||||
return fallbackLayout ? [getStraightSegmentFootprintPolygon(stair, fallbackLayout)] : []
|
||||
}
|
||||
|
||||
function getStairOpeningPolygons(
|
||||
stair: StairNode,
|
||||
nodes: Record<string, AnyNode>,
|
||||
targetElevation?: number,
|
||||
) {
|
||||
if ((stair.slabOpeningMode ?? 'none') !== 'destination') {
|
||||
return []
|
||||
}
|
||||
|
||||
if (stair.stairType === 'curved') {
|
||||
return [getCurvedOpeningPolygon(stair)]
|
||||
}
|
||||
|
||||
if (stair.stairType === 'spiral') {
|
||||
return [getSpiralOpeningPolygon(stair)]
|
||||
}
|
||||
|
||||
if (typeof targetElevation === 'number') {
|
||||
return getStraightOpeningPolygonsForSurface(stair, nodes, targetElevation)
|
||||
}
|
||||
|
||||
return getStraightOpeningPolygonsForSurface(
|
||||
stair,
|
||||
nodes,
|
||||
Math.max(...getStraightStairLayouts(stair, nodes).map((layout) => layout.topElevation), 0),
|
||||
)
|
||||
}
|
||||
|
||||
function getTargetSlabElevationForStair(
|
||||
stair: StairNode,
|
||||
slab: SlabNode,
|
||||
slabLevelId: string,
|
||||
nodes: Record<string, AnyNode>,
|
||||
) {
|
||||
const { fromLevelId } = getResolvedStairLevelIds(stair, nodes)
|
||||
const fromLevel = getLevelNumber(fromLevelId, nodes)
|
||||
const slabLevel = getLevelNumber(slabLevelId, nodes)
|
||||
|
||||
if (fromLevel === undefined || slabLevel === undefined) {
|
||||
return slab.elevation ?? 0.05
|
||||
}
|
||||
|
||||
return (
|
||||
(slabLevel - fromLevel) * DEFAULT_WALL_HEIGHT +
|
||||
(slab.elevation ?? 0.05) -
|
||||
(stair.position[1] ?? 0)
|
||||
)
|
||||
}
|
||||
|
||||
function shouldApplyStairToSlab(stair: StairNode, slabLevelId: string, nodes: Record<string, AnyNode>) {
|
||||
const { fromLevelId, toLevelId } = getResolvedStairLevelIds(stair, nodes)
|
||||
const fromLevel = getLevelNumber(fromLevelId, nodes)
|
||||
const toLevel = getLevelNumber(toLevelId, nodes)
|
||||
const slabLevel = getLevelNumber(slabLevelId, nodes)
|
||||
|
||||
if (slabLevel === undefined) {
|
||||
return toLevelId === slabLevelId
|
||||
}
|
||||
|
||||
if (fromLevel === undefined || toLevel === undefined) {
|
||||
return toLevelId === slabLevelId
|
||||
}
|
||||
|
||||
const minLevel = Math.min(fromLevel, toLevel)
|
||||
const maxLevel = Math.max(fromLevel, toLevel)
|
||||
return slabLevel > minLevel && slabLevel <= maxLevel
|
||||
}
|
||||
|
||||
export function syncAutoStairOpenings(nodes: Record<string, AnyNode>) {
|
||||
const stairs = Object.values(nodes).filter((node): node is StairNode => node.type === 'stair' && node.visible !== false)
|
||||
const slabs = Object.values(nodes).filter((node): node is SlabNode => node.type === 'slab')
|
||||
const updates: Array<{ id: AnyNodeId; data: Partial<SlabNode> }> = []
|
||||
|
||||
for (const slab of slabs) {
|
||||
const slabLevelId = resolveLevelId(slab, nodes)
|
||||
const existingHoles = slab.holes ?? []
|
||||
const existingMetadata = normalizeExistingMetadata(existingHoles, slab.holeMetadata)
|
||||
const manualHoles = existingHoles.filter((_hole, index) => existingMetadata[index]?.source !== 'stair')
|
||||
const manualMetadata = existingMetadata
|
||||
.filter((entry) => entry.source !== 'stair')
|
||||
.map((entry) => ({ ...entry }))
|
||||
|
||||
const stairHoles = stairs
|
||||
.filter((stair) => shouldApplyStairToSlab(stair, slabLevelId, nodes))
|
||||
.flatMap((stair) =>
|
||||
getStairOpeningPolygons(
|
||||
stair,
|
||||
nodes,
|
||||
getTargetSlabElevationForStair(stair, slab, slabLevelId, nodes),
|
||||
).map((polygon) => ({
|
||||
polygon:
|
||||
stair.stairType === 'straight'
|
||||
? polygon
|
||||
: expandPolygonFromCentroid(
|
||||
polygon,
|
||||
Math.max((stair.openingOffset ?? 0) - STAIR_SLAB_OPENING_TIGHTENING, 0),
|
||||
),
|
||||
metadata: {
|
||||
source: 'stair' as const,
|
||||
stairId: stair.id,
|
||||
},
|
||||
})),
|
||||
)
|
||||
|
||||
const nextHoles = [...manualHoles, ...stairHoles.map((hole) => hole.polygon)]
|
||||
const nextMetadata = [...manualMetadata, ...stairHoles.map((hole) => hole.metadata)]
|
||||
|
||||
if (!polygonsEqual(existingHoles, nextHoles) || !metadataEqual(existingMetadata, nextMetadata)) {
|
||||
updates.push({
|
||||
id: slab.id,
|
||||
data: {
|
||||
holes: nextHoles,
|
||||
holeMetadata: nextMetadata,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return updates
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry'
|
||||
@@ -6,6 +7,7 @@ import { spatialGridManager } from '../../hooks/spatial-grid/spatial-grid-manage
|
||||
import { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
|
||||
import type { AnyNode, AnyNodeId, StairNode, StairSegmentNode } from '../../schema'
|
||||
import useScene from '../../store/use-scene'
|
||||
import { syncAutoStairOpenings } from './stair-opening-sync'
|
||||
|
||||
const pendingStairUpdates = new Set<AnyNodeId>()
|
||||
const MAX_STAIRS_PER_FRAME = 2
|
||||
@@ -19,6 +21,26 @@ export const StairSystem = () => {
|
||||
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
||||
const clearDirty = useScene((state) => state.clearDirty)
|
||||
const rootNodeIds = useScene((state) => state.rootNodeIds)
|
||||
const syncingAutoOpeningsRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const applyUpdates = (updates: ReturnType<typeof syncAutoStairOpenings>) => {
|
||||
if (updates.length === 0) return
|
||||
syncingAutoOpeningsRef.current = true
|
||||
useScene.getState().updateNodes(updates)
|
||||
queueMicrotask(() => {
|
||||
syncingAutoOpeningsRef.current = false
|
||||
})
|
||||
}
|
||||
|
||||
applyUpdates(syncAutoStairOpenings(useScene.getState().nodes))
|
||||
|
||||
return useScene.subscribe((state, prevState) => {
|
||||
if (syncingAutoOpeningsRef.current) return
|
||||
if (state.nodes === prevState.nodes) return
|
||||
applyUpdates(syncAutoStairOpenings(state.nodes))
|
||||
})
|
||||
}, [])
|
||||
|
||||
useFrame(() => {
|
||||
if (rootNodeIds.length === 0) {
|
||||
|
||||
@@ -356,8 +356,15 @@ export function FloatingActionMenu() {
|
||||
[cx + holeSize, cz + holeSize],
|
||||
[cx - holeSize, cz + holeSize],
|
||||
]
|
||||
const currentHoles = (node as SlabNode | CeilingNode).holes || []
|
||||
updateNode(selectedId as AnyNodeId, { holes: [...currentHoles, newHole] })
|
||||
const surfaceNode = node as SlabNode | CeilingNode
|
||||
const currentHoles = surfaceNode.holes || []
|
||||
const currentMetadata = currentHoles.map(
|
||||
(_, index) => surfaceNode.holeMetadata?.[index] ?? { source: 'manual' as const },
|
||||
)
|
||||
updateNode(selectedId as AnyNodeId, {
|
||||
holes: [...currentHoles, newHole],
|
||||
holeMetadata: [...currentMetadata, { source: 'manual' }],
|
||||
})
|
||||
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
|
||||
// Re-assert selection so the node stays selected
|
||||
setSelection({ selectedIds: [selectedId] })
|
||||
|
||||
@@ -93,11 +93,21 @@ function commitStairPlacement(
|
||||
position: [0, 0, 0],
|
||||
})
|
||||
|
||||
const sortedLevels = Object.values(nodes)
|
||||
.filter((node): node is LevelNode => node.type === 'level')
|
||||
.sort((left, right) => left.level - right.level)
|
||||
const currentLevelIndex = sortedLevels.findIndex((level) => level.id === levelId)
|
||||
const nextLevelId = sortedLevels[currentLevelIndex + 1]?.id ?? levelId
|
||||
|
||||
const stair = StairNode.parse({
|
||||
name,
|
||||
position,
|
||||
rotation,
|
||||
stairType: DEFAULT_STAIR_TYPE,
|
||||
fromLevelId: levelId,
|
||||
toLevelId: nextLevelId,
|
||||
slabOpeningMode: 'destination',
|
||||
openingOffset: 0.08,
|
||||
width: DEFAULT_STAIR_WIDTH,
|
||||
totalRise: DEFAULT_STAIR_HEIGHT,
|
||||
stepCount: DEFAULT_STAIR_STEP_COUNT,
|
||||
|
||||
@@ -86,7 +86,13 @@ export function CeilingPanel() {
|
||||
[cx - holeSize, cz + holeSize],
|
||||
]
|
||||
const currentHoles = node?.holes || []
|
||||
handleUpdate({ holes: [...currentHoles, newHole] })
|
||||
const currentMetadata = currentHoles.map(
|
||||
(_, index) => node?.holeMetadata?.[index] ?? { source: 'manual' as const },
|
||||
)
|
||||
handleUpdate({
|
||||
holes: [...currentHoles, newHole],
|
||||
holeMetadata: [...currentMetadata, { source: 'manual' }],
|
||||
})
|
||||
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
|
||||
}, [node, selectedId, handleUpdate, setEditingHole])
|
||||
|
||||
@@ -102,13 +108,18 @@ export function CeilingPanel() {
|
||||
(index: number) => {
|
||||
if (!selectedId) return
|
||||
const currentHoles = node?.holes || []
|
||||
if (node?.holeMetadata?.[index]?.source === 'stair') return
|
||||
const newHoles = currentHoles.filter((_, i) => i !== index)
|
||||
handleUpdate({ holes: newHoles })
|
||||
const currentMetadata = currentHoles.map(
|
||||
(_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const },
|
||||
)
|
||||
const newMetadata = currentMetadata.filter((_, i) => i !== index)
|
||||
handleUpdate({ holes: newHoles, holeMetadata: newMetadata })
|
||||
if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
|
||||
setEditingHole(null)
|
||||
}
|
||||
},
|
||||
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
|
||||
[selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole, setEditingHole],
|
||||
)
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
@@ -126,8 +137,11 @@ export function CeilingPanel() {
|
||||
const n = polygon.length
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n
|
||||
area += polygon[i]?.[0] * polygon[j]?.[1]
|
||||
area -= polygon[j]?.[0] * polygon[i]?.[1]
|
||||
const current = polygon[i]
|
||||
const next = polygon[j]
|
||||
if (!(current && next)) continue
|
||||
area += current[0] * next[1]
|
||||
area -= next[0] * current[1]
|
||||
}
|
||||
return Math.abs(area) / 2
|
||||
}
|
||||
@@ -174,6 +188,8 @@ export function CeilingPanel() {
|
||||
const holeArea = calculateArea(hole)
|
||||
const isEditing =
|
||||
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
||||
const source = node.holeMetadata?.[index]?.source ?? 'manual'
|
||||
const isAutoHole = source === 'stair'
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
|
||||
@@ -190,7 +206,8 @@ export function CeilingPanel() {
|
||||
Hole {index + 1} {isEditing && '(Editing)'}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{holeArea.toFixed(2)} m² · {hole.length} pts
|
||||
{holeArea.toFixed(2)} m² · {hole.length} pts ·{' '}
|
||||
{isAutoHole ? 'Auto stair cutout' : 'Manual'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -200,6 +217,10 @@ export function CeilingPanel() {
|
||||
label="Done"
|
||||
onClick={() => setEditingHole(null)}
|
||||
/>
|
||||
) : isAutoHole ? (
|
||||
<div className="rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] text-muted-foreground">
|
||||
Auto
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
|
||||
@@ -84,7 +84,13 @@ export function SlabPanel() {
|
||||
[cx - holeSize, cz + holeSize],
|
||||
]
|
||||
const currentHoles = node?.holes || []
|
||||
handleUpdate({ holes: [...currentHoles, newHole] })
|
||||
const currentMetadata = currentHoles.map(
|
||||
(_, index) => node?.holeMetadata?.[index] ?? { source: 'manual' as const },
|
||||
)
|
||||
handleUpdate({
|
||||
holes: [...currentHoles, newHole],
|
||||
holeMetadata: [...currentMetadata, { source: 'manual' }],
|
||||
})
|
||||
setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
|
||||
}, [node, selectedId, handleUpdate, setEditingHole])
|
||||
|
||||
@@ -100,13 +106,18 @@ export function SlabPanel() {
|
||||
(index: number) => {
|
||||
if (!selectedId) return
|
||||
const currentHoles = node?.holes || []
|
||||
if (node?.holeMetadata?.[index]?.source === 'stair') return
|
||||
const newHoles = currentHoles.filter((_, i) => i !== index)
|
||||
handleUpdate({ holes: newHoles })
|
||||
const currentMetadata = currentHoles.map(
|
||||
(_, metadataIndex) => node?.holeMetadata?.[metadataIndex] ?? { source: 'manual' as const },
|
||||
)
|
||||
const newMetadata = currentMetadata.filter((_, i) => i !== index)
|
||||
handleUpdate({ holes: newHoles, holeMetadata: newMetadata })
|
||||
if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
|
||||
setEditingHole(null)
|
||||
}
|
||||
},
|
||||
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole],
|
||||
[selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole, setEditingHole],
|
||||
)
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
@@ -124,8 +135,11 @@ export function SlabPanel() {
|
||||
const n = polygon.length
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n
|
||||
area += polygon[i]?.[0] * polygon[j]?.[1]
|
||||
area -= polygon[j]?.[0] * polygon[i]?.[1]
|
||||
const current = polygon[i]
|
||||
const next = polygon[j]
|
||||
if (!(current && next)) continue
|
||||
area += current[0] * next[1]
|
||||
area -= next[0] * current[1]
|
||||
}
|
||||
return Math.abs(area) / 2
|
||||
}
|
||||
@@ -173,6 +187,8 @@ export function SlabPanel() {
|
||||
const holeArea = calculateArea(hole)
|
||||
const isEditing =
|
||||
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
|
||||
const source = node.holeMetadata?.[index]?.source ?? 'manual'
|
||||
const isAutoHole = source === 'stair'
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${
|
||||
@@ -189,7 +205,8 @@ export function SlabPanel() {
|
||||
Hole {index + 1} {isEditing && '(Editing)'}
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{holeArea.toFixed(2)} m² · {hole.length} pts
|
||||
{holeArea.toFixed(2)} m² · {hole.length} pts ·{' '}
|
||||
{isAutoHole ? 'Auto stair cutout' : 'Manual'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -199,6 +216,10 @@ export function SlabPanel() {
|
||||
label="Done"
|
||||
onClick={() => setEditingHole(null)}
|
||||
/>
|
||||
) : isAutoHole ? (
|
||||
<div className="rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] text-muted-foreground">
|
||||
Auto
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
type LevelNode,
|
||||
type MaterialSchema,
|
||||
type StairNode,
|
||||
type StairRailingMode,
|
||||
type StairSlabOpeningMode,
|
||||
type StairTopLandingMode,
|
||||
type StairType,
|
||||
StairNode as StairNodeSchema,
|
||||
@@ -21,6 +23,7 @@ import useEditor from '../../../store/use-editor'
|
||||
import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { MaterialPicker } from '../controls/material-picker'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SegmentedControl } from '../controls/segmented-control'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
@@ -45,6 +48,11 @@ const TOP_LANDING_MODE_OPTIONS: { label: string; value: StairTopLandingMode }[]
|
||||
{ label: 'Integrated', value: 'integrated' },
|
||||
]
|
||||
|
||||
const STAIR_SLAB_OPENING_OPTIONS: { label: string; value: StairSlabOpeningMode }[] = [
|
||||
{ label: 'None', value: 'none' },
|
||||
{ label: 'Destination', value: 'destination' },
|
||||
]
|
||||
|
||||
export function StairPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
@@ -202,6 +210,11 @@ export function StairPanel() {
|
||||
|
||||
if (!node || node.type !== 'stair' || selectedIds.length !== 1) return null
|
||||
|
||||
const levels = Object.values(nodes)
|
||||
.filter((entry): entry is LevelNode => entry.type === 'level')
|
||||
.sort((left, right) => left.level - right.level)
|
||||
const resolvedFromLevelId = node.fromLevelId ?? node.parentId ?? levels[0]?.id ?? null
|
||||
const resolvedToLevelId = node.toLevelId ?? resolvedFromLevelId
|
||||
const segments = (node.children ?? [])
|
||||
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
|
||||
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
|
||||
@@ -231,6 +244,63 @@ export function StairPanel() {
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Opening">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||
From Level
|
||||
</div>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
|
||||
onChange={(event) => handleUpdate({ fromLevelId: event.target.value })}
|
||||
value={resolvedFromLevelId ?? ''}
|
||||
>
|
||||
{levels.map((level) => (
|
||||
<option key={level.id} value={level.id}>
|
||||
{level.name || `Level ${level.level + 1}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
|
||||
To Level
|
||||
</div>
|
||||
<select
|
||||
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
|
||||
onChange={(event) => handleUpdate({ toLevelId: event.target.value })}
|
||||
value={resolvedToLevelId ?? ''}
|
||||
>
|
||||
{levels.map((level) => (
|
||||
<option key={level.id} value={level.id}>
|
||||
{level.name || `Level ${level.level + 1}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<SegmentedControl
|
||||
onChange={(value) => handleUpdate({ slabOpeningMode: value as StairSlabOpeningMode })}
|
||||
options={STAIR_SLAB_OPENING_OPTIONS}
|
||||
value={node.slabOpeningMode ?? 'none'}
|
||||
/>
|
||||
|
||||
{(node.slabOpeningMode ?? 'none') === 'destination' ? (
|
||||
<MetricControl
|
||||
label="Opening Offset"
|
||||
max={0.5}
|
||||
min={0}
|
||||
onChange={(value) => handleUpdate({ openingOffset: value })}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={Math.round((node.openingOffset ?? 0) * 100) / 100}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
{node.stairType === 'straight' && (
|
||||
<PanelSection title="Segments">
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
Reference in New Issue
Block a user