Implement stair-driven slab hole cutouts

This commit is contained in:
sudhir
2026-04-17 13:13:02 +05:30
parent 0effda29fe
commit b314a8fbf8
12 changed files with 824 additions and 15 deletions
+8 -1
View File
@@ -49,8 +49,15 @@ export { ScanNode } from './nodes/scan'
// Nodes // Nodes
export { SiteNode } from './nodes/site' export { SiteNode } from './nodes/site'
export { SlabNode } from './nodes/slab' 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 { AttachmentSide, StairSegmentNode, StairSegmentType } from './nodes/stair-segment'
export { SurfaceHoleMetadata } from './nodes/surface-hole-metadata'
export { WallNode } from './nodes/wall' export { WallNode } from './nodes/wall'
export { WindowNode } from './nodes/window' export { WindowNode } from './nodes/window'
export { ZoneNode } from './nodes/zone' export { ZoneNode } from './nodes/zone'
@@ -3,6 +3,7 @@ import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material' import { MaterialSchema } from '../material'
import { ItemNode } from './item' import { ItemNode } from './item'
import { SurfaceHoleMetadata } from './surface-hole-metadata'
export const CeilingNode = BaseNode.extend({ export const CeilingNode = BaseNode.extend({
id: objectId('ceiling'), id: objectId('ceiling'),
@@ -12,6 +13,7 @@ export const CeilingNode = BaseNode.extend({
materialPreset: z.string().optional(), materialPreset: z.string().optional(),
polygon: z.array(z.tuple([z.number(), z.number()])), polygon: z.array(z.tuple([z.number(), z.number()])),
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), 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 height: z.number().default(2.5), // Height in meters
autoFromWalls: z.boolean().default(false), autoFromWalls: z.boolean().default(false),
}).describe( }).describe(
@@ -19,6 +21,7 @@ export const CeilingNode = BaseNode.extend({
Ceiling node - used to represent a ceiling in the building Ceiling node - used to represent a ceiling in the building
- polygon: array of [x, z] points defining the ceiling boundary - polygon: array of [x, z] points defining the ceiling boundary
- holes: array of polygons representing holes in the ceiling - 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 - autoFromWalls: whether the ceiling is automatically generated from a closed wall loop
`, `,
) )
+4
View File
@@ -2,6 +2,7 @@ import dedent from 'dedent'
import { z } from 'zod' import { z } from 'zod'
import { BaseNode, nodeType, objectId } from '../base' import { BaseNode, nodeType, objectId } from '../base'
import { MaterialSchema } from '../material' import { MaterialSchema } from '../material'
import { SurfaceHoleMetadata } from './surface-hole-metadata'
export const SlabNode = BaseNode.extend({ export const SlabNode = BaseNode.extend({
id: objectId('slab'), id: objectId('slab'),
@@ -10,12 +11,15 @@ export const SlabNode = BaseNode.extend({
materialPreset: z.string().optional(), materialPreset: z.string().optional(),
polygon: z.array(z.tuple([z.number(), z.number()])), polygon: z.array(z.tuple([z.number(), z.number()])),
holes: z.array(z.array(z.tuple([z.number(), z.number()]))).default([]), 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 elevation: z.number().default(0.05), // Elevation in meters
autoFromWalls: z.boolean().default(false), autoFromWalls: z.boolean().default(false),
}).describe( }).describe(
dedent` dedent`
Slab node - used to represent a slab/floor in the building Slab node - used to represent a slab/floor in the building
- polygon: array of [x, z] points defining the slab boundary - 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 - elevation: elevation in meters
- autoFromWalls: whether the slab is automatically generated from a closed wall loop - autoFromWalls: whether the slab is automatically generated from a closed wall loop
`, `,
+9
View File
@@ -7,10 +7,12 @@ import { StairSegmentNode } from './stair-segment'
export const StairRailingMode = z.enum(['none', 'left', 'right', 'both']) export const StairRailingMode = z.enum(['none', 'left', 'right', 'both'])
export const StairType = z.enum(['straight', 'curved', 'spiral']) export const StairType = z.enum(['straight', 'curved', 'spiral'])
export const StairTopLandingMode = z.enum(['none', 'integrated']) export const StairTopLandingMode = z.enum(['none', 'integrated'])
export const StairSlabOpeningMode = z.enum(['none', 'destination'])
export type StairRailingMode = z.infer<typeof StairRailingMode> export type StairRailingMode = z.infer<typeof StairRailingMode>
export type StairType = z.infer<typeof StairType> export type StairType = z.infer<typeof StairType>
export type StairTopLandingMode = z.infer<typeof StairTopLandingMode> export type StairTopLandingMode = z.infer<typeof StairTopLandingMode>
export type StairSlabOpeningMode = z.infer<typeof StairSlabOpeningMode>
export const StairNode = BaseNode.extend({ export const StairNode = BaseNode.extend({
id: objectId('stair'), id: objectId('stair'),
@@ -21,6 +23,10 @@ export const StairNode = BaseNode.extend({
// Rotation around Y axis in radians // Rotation around Y axis in radians
rotation: z.number().default(0), rotation: z.number().default(0),
stairType: StairType.default('straight'), 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), width: z.number().default(1.0),
totalRise: z.number().default(2.5), totalRise: z.number().default(2.5),
stepCount: z.number().default(10), stepCount: z.number().default(10),
@@ -44,6 +50,9 @@ export const StairNode = BaseNode.extend({
- position: center position of the stair group - position: center position of the stair group
- rotation: rotation around Y axis - rotation: rotation around Y axis
- stairType: straight (segment-based), curved (arc-based), or spiral - 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 - width: stair width
- totalRise: total stair height - totalRise: total stair height
- stepCount: number of visible steps - 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 { useFrame } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import * as THREE from 'three' import * as THREE from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { sceneRegistry } from '../../hooks/scene-registry/scene-registry' 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 { resolveLevelId } from '../../hooks/spatial-grid/spatial-grid-sync'
import type { AnyNode, AnyNodeId, StairNode, StairSegmentNode } from '../../schema' import type { AnyNode, AnyNodeId, StairNode, StairSegmentNode } from '../../schema'
import useScene from '../../store/use-scene' import useScene from '../../store/use-scene'
import { syncAutoStairOpenings } from './stair-opening-sync'
const pendingStairUpdates = new Set<AnyNodeId>() const pendingStairUpdates = new Set<AnyNodeId>()
const MAX_STAIRS_PER_FRAME = 2 const MAX_STAIRS_PER_FRAME = 2
@@ -19,6 +21,26 @@ export const StairSystem = () => {
const dirtyNodes = useScene((state) => state.dirtyNodes) const dirtyNodes = useScene((state) => state.dirtyNodes)
const clearDirty = useScene((state) => state.clearDirty) const clearDirty = useScene((state) => state.clearDirty)
const rootNodeIds = useScene((state) => state.rootNodeIds) 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(() => { useFrame(() => {
if (rootNodeIds.length === 0) { if (rootNodeIds.length === 0) {
@@ -356,8 +356,15 @@ export function FloatingActionMenu() {
[cx + holeSize, cz + holeSize], [cx + holeSize, cz + holeSize],
[cx - holeSize, cz + holeSize], [cx - holeSize, cz + holeSize],
] ]
const currentHoles = (node as SlabNode | CeilingNode).holes || [] const surfaceNode = node as SlabNode | CeilingNode
updateNode(selectedId as AnyNodeId, { holes: [...currentHoles, newHole] }) 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 }) setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
// Re-assert selection so the node stays selected // Re-assert selection so the node stays selected
setSelection({ selectedIds: [selectedId] }) setSelection({ selectedIds: [selectedId] })
@@ -93,11 +93,21 @@ function commitStairPlacement(
position: [0, 0, 0], 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({ const stair = StairNode.parse({
name, name,
position, position,
rotation, rotation,
stairType: DEFAULT_STAIR_TYPE, stairType: DEFAULT_STAIR_TYPE,
fromLevelId: levelId,
toLevelId: nextLevelId,
slabOpeningMode: 'destination',
openingOffset: 0.08,
width: DEFAULT_STAIR_WIDTH, width: DEFAULT_STAIR_WIDTH,
totalRise: DEFAULT_STAIR_HEIGHT, totalRise: DEFAULT_STAIR_HEIGHT,
stepCount: DEFAULT_STAIR_STEP_COUNT, stepCount: DEFAULT_STAIR_STEP_COUNT,
@@ -86,7 +86,13 @@ export function CeilingPanel() {
[cx - holeSize, cz + holeSize], [cx - holeSize, cz + holeSize],
] ]
const currentHoles = node?.holes || [] 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 }) setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
}, [node, selectedId, handleUpdate, setEditingHole]) }, [node, selectedId, handleUpdate, setEditingHole])
@@ -102,13 +108,18 @@ export function CeilingPanel() {
(index: number) => { (index: number) => {
if (!selectedId) return if (!selectedId) return
const currentHoles = node?.holes || [] const currentHoles = node?.holes || []
if (node?.holeMetadata?.[index]?.source === 'stair') return
const newHoles = currentHoles.filter((_, i) => i !== index) 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) { if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
setEditingHole(null) setEditingHole(null)
} }
}, },
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole], [selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole, setEditingHole],
) )
const handleMove = useCallback(() => { const handleMove = useCallback(() => {
@@ -126,8 +137,11 @@ export function CeilingPanel() {
const n = polygon.length const n = polygon.length
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const j = (i + 1) % n const j = (i + 1) % n
area += polygon[i]?.[0] * polygon[j]?.[1] const current = polygon[i]
area -= polygon[j]?.[0] * polygon[i]?.[1] const next = polygon[j]
if (!(current && next)) continue
area += current[0] * next[1]
area -= next[0] * current[1]
} }
return Math.abs(area) / 2 return Math.abs(area) / 2
} }
@@ -174,6 +188,8 @@ export function CeilingPanel() {
const holeArea = calculateArea(hole) const holeArea = calculateArea(hole)
const isEditing = const isEditing =
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
const source = node.holeMetadata?.[index]?.source ?? 'manual'
const isAutoHole = source === 'stair'
return ( return (
<div <div
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${ 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)'} Hole {index + 1} {isEditing && '(Editing)'}
</p> </p>
<p className="text-[10px] text-muted-foreground"> <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> </p>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -200,6 +217,10 @@ export function CeilingPanel() {
label="Done" label="Done"
onClick={() => setEditingHole(null)} onClick={() => setEditingHole(null)}
/> />
) : isAutoHole ? (
<div className="rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] text-muted-foreground">
Auto
</div>
) : ( ) : (
<> <>
<button <button
@@ -84,7 +84,13 @@ export function SlabPanel() {
[cx - holeSize, cz + holeSize], [cx - holeSize, cz + holeSize],
] ]
const currentHoles = node?.holes || [] 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 }) setEditingHole({ nodeId: selectedId, holeIndex: currentHoles.length })
}, [node, selectedId, handleUpdate, setEditingHole]) }, [node, selectedId, handleUpdate, setEditingHole])
@@ -100,13 +106,18 @@ export function SlabPanel() {
(index: number) => { (index: number) => {
if (!selectedId) return if (!selectedId) return
const currentHoles = node?.holes || [] const currentHoles = node?.holes || []
if (node?.holeMetadata?.[index]?.source === 'stair') return
const newHoles = currentHoles.filter((_, i) => i !== index) 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) { if (editingHole?.nodeId === selectedId && editingHole?.holeIndex === index) {
setEditingHole(null) setEditingHole(null)
} }
}, },
[selectedId, node?.holes, handleUpdate, editingHole, setEditingHole], [selectedId, node?.holes, node?.holeMetadata, handleUpdate, editingHole, setEditingHole],
) )
const handleMove = useCallback(() => { const handleMove = useCallback(() => {
@@ -124,8 +135,11 @@ export function SlabPanel() {
const n = polygon.length const n = polygon.length
for (let i = 0; i < n; i++) { for (let i = 0; i < n; i++) {
const j = (i + 1) % n const j = (i + 1) % n
area += polygon[i]?.[0] * polygon[j]?.[1] const current = polygon[i]
area -= polygon[j]?.[0] * polygon[i]?.[1] const next = polygon[j]
if (!(current && next)) continue
area += current[0] * next[1]
area -= next[0] * current[1]
} }
return Math.abs(area) / 2 return Math.abs(area) / 2
} }
@@ -173,6 +187,8 @@ export function SlabPanel() {
const holeArea = calculateArea(hole) const holeArea = calculateArea(hole)
const isEditing = const isEditing =
editingHole?.nodeId === selectedId && editingHole?.holeIndex === index editingHole?.nodeId === selectedId && editingHole?.holeIndex === index
const source = node.holeMetadata?.[index]?.source ?? 'manual'
const isAutoHole = source === 'stair'
return ( return (
<div <div
className={`flex items-center justify-between rounded-lg border p-2 transition-colors ${ 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)'} Hole {index + 1} {isEditing && '(Editing)'}
</p> </p>
<p className="text-[10px] text-muted-foreground"> <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> </p>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -199,6 +216,10 @@ export function SlabPanel() {
label="Done" label="Done"
onClick={() => setEditingHole(null)} onClick={() => setEditingHole(null)}
/> />
) : isAutoHole ? (
<div className="rounded-md bg-[#2C2C2E] px-2 py-1 text-[10px] text-muted-foreground">
Auto
</div>
) : ( ) : (
<> <>
<button <button
@@ -3,9 +3,11 @@
import { import {
type AnyNode, type AnyNode,
type AnyNodeId, type AnyNodeId,
type LevelNode,
type MaterialSchema, type MaterialSchema,
type StairNode, type StairNode,
type StairRailingMode, type StairRailingMode,
type StairSlabOpeningMode,
type StairTopLandingMode, type StairTopLandingMode,
type StairType, type StairType,
StairNode as StairNodeSchema, 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 { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
import { ActionButton, ActionGroup } from '../controls/action-button' import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker' import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section' import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control' import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control' import { SliderControl } from '../controls/slider-control'
@@ -45,6 +48,11 @@ const TOP_LANDING_MODE_OPTIONS: { label: string; value: StairTopLandingMode }[]
{ label: 'Integrated', value: 'integrated' }, { label: 'Integrated', value: 'integrated' },
] ]
const STAIR_SLAB_OPENING_OPTIONS: { label: string; value: StairSlabOpeningMode }[] = [
{ label: 'None', value: 'none' },
{ label: 'Destination', value: 'destination' },
]
export function StairPanel() { export function StairPanel() {
const selectedIds = useViewer((s) => s.selection.selectedIds) const selectedIds = useViewer((s) => s.selection.selectedIds)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
@@ -202,6 +210,11 @@ export function StairPanel() {
if (!node || node.type !== 'stair' || selectedIds.length !== 1) return null 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 ?? []) const segments = (node.children ?? [])
.map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined) .map((childId) => nodes[childId as AnyNodeId] as StairSegmentNode | undefined)
.filter((n): n is StairSegmentNode => n?.type === 'stair-segment') .filter((n): n is StairSegmentNode => n?.type === 'stair-segment')
@@ -231,6 +244,63 @@ export function StairPanel() {
/> />
</PanelSection> </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' && ( {node.stairType === 'straight' && (
<PanelSection title="Segments"> <PanelSection title="Segments">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">