From cdde88f9c02d4b2ca650056ba69e666193eb4050 Mon Sep 17 00:00:00 2001 From: sudhir Date: Thu, 14 May 2026 22:32:33 +0530 Subject: [PATCH] Clamp spiral stair openings at full sweep --- .../src/systems/stair/stair-opening-sync.ts | 91 ++++++++----------- .../renderers/floorplan-stair-layer.tsx | 13 ++- packages/editor/src/lib/floorplan/stairs.ts | 13 ++- 3 files changed, 62 insertions(+), 55 deletions(-) diff --git a/packages/core/src/systems/stair/stair-opening-sync.ts b/packages/core/src/systems/stair/stair-opening-sync.ts index 239f1caa..1153d61d 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.ts @@ -74,34 +74,7 @@ function normalizeExistingMetadata( 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 - }) -} +// (Removing expandPolygonRadially in favor of geometric expansion inside the polygon generators) function rotateXZ(x: number, z: number, angle: number): [number, number] { const cos = Math.cos(angle) @@ -424,17 +397,20 @@ function buildUnionPolygonsFromRects(rects: AxisAlignedRect[]): Point2D[][] { return polygons } -function getCurvedOpeningPolygon(stair: StairNode): Point2D[] { +function getCurvedOpeningPolygon(stair: StairNode, offset: number = 0): 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 innerRadius = Math.max(0.01, (stair.innerRadius ?? 0.9) - offset) + const outerRadius = (stair.innerRadius ?? 0.9) + width + offset const totalSweep = stair.sweepAngle ?? Math.PI / 2 - const openingSweep = - Math.sign(totalSweep || 1) * + const baseOpeningSweep = + Math.abs(totalSweep) * Math.max( - Math.abs(totalSweep) * CURVED_STAIR_SLAB_OPENING_RATIO, - Math.abs(totalSweep) / Math.max(stair.stepCount ?? 1, 1), + CURVED_STAIR_SLAB_OPENING_RATIO, + 1 / Math.max(stair.stepCount ?? 1, 1), ) + const angleOffset = offset / Math.max(innerRadius, 0.1) + const openingSweep = Math.sign(totalSweep || 1) * Math.min(Math.abs(totalSweep), baseOpeningSweep + angleOffset * 2) + const startAngle = totalSweep / 2 - openingSweep const endAngle = totalSweep / 2 const segmentCount = Math.max( @@ -466,8 +442,8 @@ function getCurvedOpeningPolygon(stair: StairNode): Point2D[] { 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) +function getSpiralOpeningPolygon(stair: StairNode, offset: number = 0): Point2D[] { + const radius = Math.max(0.05, stair.innerRadius ?? 0.9) + Math.max(stair.width ?? 1, 0.4) + offset const segmentCount = 48 return Array.from({ length: segmentCount }).map((_, index) => { @@ -476,6 +452,22 @@ function getSpiralOpeningPolygon(stair: StairNode): Point2D[] { }) } +function getSpiralLandingPolygon(stair: StairNode, offset: number = 0): Point2D[] { + const width = Math.max(stair.width ?? 1, 0.4) + const outerRadius = Math.max(0.05, (stair.innerRadius ?? 0.9) + width) + const depth = Math.max(stair.topLandingDepth ?? 0.9, 0.1) + const halfWidth = width / 2 + + const localPoints: Point2D[] = [ + [outerRadius - offset, -halfWidth - offset], + [outerRadius + depth + offset, -halfWidth - offset], + [outerRadius + depth + offset, halfWidth + offset], + [outerRadius - offset, halfWidth + offset], + ] + + return localPoints.map(([x, z]) => toWorldPlanPoint(stair, x, z)) +} + function getStraightOpeningPolygonsForSurface( stair: StairNode, nodes: Record, @@ -570,11 +562,16 @@ function getStairOpeningPolygons( } if (stair.stairType === 'curved') { - return [getCurvedOpeningPolygon(stair)] + return [getCurvedOpeningPolygon(stair, Math.max((stair.openingOffset ?? 0) - STAIR_SLAB_OPENING_TIGHTENING, 0.15))] } if (stair.stairType === 'spiral') { - return [getSpiralOpeningPolygon(stair)] + const offset = Math.max((stair.openingOffset ?? 0) - STAIR_SLAB_OPENING_TIGHTENING, 0.15) + const polygons = [getSpiralOpeningPolygon(stair, offset)] + if (stair.topLandingMode === 'integrated') { + polygons.push(getSpiralLandingPolygon(stair, offset)) + } + return polygons } if (typeof targetElevation === 'number') { @@ -703,13 +700,7 @@ export function syncAutoStairOpenings(nodes: Record) { 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), - ), + polygon, metadata: { source: 'stair' as const, stairId: stair.id, @@ -758,13 +749,7 @@ export function syncAutoStairOpenings(nodes: Record) { nodes, getTargetCeilingElevationForStair(stair, ceiling, ceilingLevelId, nodes), ).map((polygon) => ({ - polygon: - stair.stairType === 'straight' - ? polygon - : expandPolygonFromCentroid( - polygon, - Math.max((stair.openingOffset ?? 0) - STAIR_SLAB_OPENING_TIGHTENING, 0), - ), + polygon, metadata: { source: 'stair' as const, stairId: stair.id, diff --git a/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx b/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx index 8fd91ecd..deb71ffe 100644 --- a/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx +++ b/packages/editor/src/components/editor-2d/renderers/floorplan-stair-layer.tsx @@ -83,6 +83,14 @@ function getNormalizedFloorplanStairSweepAngle(stair: StairNode) { return baseSweepAngle } +function clampFloorplanCircularSweepAngle(sweepAngle: number) { + if (Math.abs(sweepAngle) >= Math.PI * 2) { + return Math.sign(sweepAngle || 1) * (Math.PI * 2 - 0.001) + } + + return sweepAngle +} + function getFloorplanStairStepCount(stair: StairNode, minimum: number) { return Math.max(minimum, Math.round(stair.stepCount ?? 10)) } @@ -139,7 +147,10 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({ const sectorStartAngle = -stair.rotation - normalizedSweepAngle / 2 const sectorEndAngle = sectorStartAngle + normalizedSweepAngle const spiralLandingSweep = getFloorplanSpiralLandingSweep(stair, normalizedSweepAngle) - const visualSectorEndAngle = sectorEndAngle + spiralLandingSweep + const visualSweepAngle = clampFloorplanCircularSweepAngle( + normalizedSweepAngle + spiralLandingSweep, + ) + const visualSectorEndAngle = sectorStartAngle + visualSweepAngle const stairCenter = { x: stair.position[0], y: stair.position[2], diff --git a/packages/editor/src/lib/floorplan/stairs.ts b/packages/editor/src/lib/floorplan/stairs.ts index 68188a6d..cbabc263 100644 --- a/packages/editor/src/lib/floorplan/stairs.ts +++ b/packages/editor/src/lib/floorplan/stairs.ts @@ -209,6 +209,14 @@ function getNormalizedFloorplanStairSweepAngle(stair: StairNode) { return baseSweepAngle } +function clampFloorplanCircularSweepAngle(sweepAngle: number) { + if (Math.abs(sweepAngle) >= Math.PI * 2) { + return Math.sign(sweepAngle || 1) * (Math.PI * 2 - 0.001) + } + + return sweepAngle +} + function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) { if ( (stair.stairType ?? 'straight') !== 'spiral' || @@ -230,8 +238,11 @@ function getFloorplanSpiralLandingSweep(stair: StairNode, sweepAngle: number) { function getFloorplanCurvedStairHitPolygon(stair: StairNode): Point2D[] { const stairType = stair.stairType ?? 'straight' const sweepAngle = getNormalizedFloorplanStairSweepAngle(stair) + const visualSweepAngle = clampFloorplanCircularSweepAngle( + sweepAngle + getFloorplanSpiralLandingSweep(stair, sweepAngle), + ) const startAngle = -stair.rotation - sweepAngle / 2 - const endAngle = startAngle + sweepAngle + getFloorplanSpiralLandingSweep(stair, sweepAngle) + const endAngle = startAngle + visualSweepAngle const center = { x: stair.position[0], y: stair.position[2],