From b314a8fbf8083b6174ee70f0395ac114e30ee679 Mon Sep 17 00:00:00 2001 From: sudhir Date: Fri, 17 Apr 2026 13:13:02 +0530 Subject: [PATCH] Implement stair-driven slab hole cutouts --- packages/core/src/schema/index.ts | 9 +- packages/core/src/schema/nodes/ceiling.ts | 3 + packages/core/src/schema/nodes/slab.ts | 4 + packages/core/src/schema/nodes/stair.ts | 9 + .../src/schema/nodes/surface-hole-metadata.ts | 8 + .../src/systems/stair/stair-opening-sync.ts | 627 ++++++++++++++++++ .../core/src/systems/stair/stair-system.tsx | 22 + .../editor/floating-action-menu.tsx | 11 +- .../src/components/tools/stair/stair-tool.tsx | 10 + .../components/ui/panels/ceiling-panel.tsx | 33 +- .../src/components/ui/panels/slab-panel.tsx | 33 +- .../src/components/ui/panels/stair-panel.tsx | 70 ++ 12 files changed, 824 insertions(+), 15 deletions(-) create mode 100644 packages/core/src/schema/nodes/surface-hole-metadata.ts create mode 100644 packages/core/src/systems/stair/stair-opening-sync.ts diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 675a0966..82086641 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -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' diff --git a/packages/core/src/schema/nodes/ceiling.ts b/packages/core/src/schema/nodes/ceiling.ts index d4de71b6..599422a6 100644 --- a/packages/core/src/schema/nodes/ceiling.ts +++ b/packages/core/src/schema/nodes/ceiling.ts @@ -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 `, ) diff --git a/packages/core/src/schema/nodes/slab.ts b/packages/core/src/schema/nodes/slab.ts index b93bf9f6..a13b303f 100644 --- a/packages/core/src/schema/nodes/slab.ts +++ b/packages/core/src/schema/nodes/slab.ts @@ -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 `, diff --git a/packages/core/src/schema/nodes/stair.ts b/packages/core/src/schema/nodes/stair.ts index 124f25fc..f6390b34 100644 --- a/packages/core/src/schema/nodes/stair.ts +++ b/packages/core/src/schema/nodes/stair.ts @@ -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 export type StairType = z.infer export type StairTopLandingMode = z.infer +export type StairSlabOpeningMode = z.infer 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 diff --git a/packages/core/src/schema/nodes/surface-hole-metadata.ts b/packages/core/src/schema/nodes/surface-hole-metadata.ts new file mode 100644 index 00000000..6e5358cb --- /dev/null +++ b/packages/core/src/schema/nodes/surface-hole-metadata.ts @@ -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 diff --git a/packages/core/src/systems/stair/stair-opening-sync.ts b/packages/core/src/systems/stair/stair-opening-sync.ts new file mode 100644 index 00000000..b87d5d57 --- /dev/null +++ b/packages/core/src/systems/stair/stair-opening-sync.ts @@ -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) { + if (!levelId) return undefined + const node = nodes[levelId as AnyNodeId] + return node?.type === 'level' ? node.level : undefined +} + +function getResolvedStairLevelIds(stair: StairNode, nodes: Record) { + const parentLevelId = resolveLevelId(stair, nodes) + const fromLevelId = stair.fromLevelId ?? parentLevelId + const toLevelId = stair.toLevelId ?? fromLevelId + return { fromLevelId, toLevelId } +} + +function resolveStraightSegments(stair: StairNode, nodes: Record) { + 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): 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() + 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() + 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, + 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, + 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, +) { + 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) { + 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) { + 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 }> = [] + + 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 +} diff --git a/packages/core/src/systems/stair/stair-system.tsx b/packages/core/src/systems/stair/stair-system.tsx index c799e9d1..db0bc3d2 100644 --- a/packages/core/src/systems/stair/stair-system.tsx +++ b/packages/core/src/systems/stair/stair-system.tsx @@ -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() 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) => { + 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) { diff --git a/packages/editor/src/components/editor/floating-action-menu.tsx b/packages/editor/src/components/editor/floating-action-menu.tsx index db8a00c6..5bf751a1 100755 --- a/packages/editor/src/components/editor/floating-action-menu.tsx +++ b/packages/editor/src/components/editor/floating-action-menu.tsx @@ -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] }) diff --git a/packages/editor/src/components/tools/stair/stair-tool.tsx b/packages/editor/src/components/tools/stair/stair-tool.tsx index 2ed9031a..db51c3ec 100644 --- a/packages/editor/src/components/tools/stair/stair-tool.tsx +++ b/packages/editor/src/components/tools/stair/stair-tool.tsx @@ -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, diff --git a/packages/editor/src/components/ui/panels/ceiling-panel.tsx b/packages/editor/src/components/ui/panels/ceiling-panel.tsx index 13e56dd6..ae70b37a 100755 --- a/packages/editor/src/components/ui/panels/ceiling-panel.tsx +++ b/packages/editor/src/components/ui/panels/ceiling-panel.tsx @@ -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 (

- {holeArea.toFixed(2)} m² · {hole.length} pts + {holeArea.toFixed(2)} m² · {hole.length} pts ·{' '} + {isAutoHole ? 'Auto stair cutout' : 'Manual'}

@@ -200,6 +217,10 @@ export function CeilingPanel() { label="Done" onClick={() => setEditingHole(null)} /> + ) : isAutoHole ? ( +
+ Auto +
) : ( <>