From 07ee4d8680afebf270afc97d466b61d5f8d3e358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Resul=20=C3=96v=C3=BCr?= <92828257+ovurrsl@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:57:39 +0000 Subject: [PATCH] feat(mezzanine): add stairs inside a mezzanine, cutting its own deck - planMezzanineStair takes a placement: 'outside' (default, unchanged) or 'inside'. An inside flight starts at an open deck edge with free host floor in front of it, climbs under the deck and must fit its run plus a landing of at least the stair width; walls under the deck (mezzanineHostClearance), other mezzanines, items and stairs block it like they block outside runs - Stair openings filter per slab: a deck stair (deckSlabId) with the cutout on cuts only its own deck and never a host ceiling or the floors above; other stairs also consider open decks on their source level, where clearance decides the cut, keeping intermediate landings that meet such a deck - Inside/Outside entry points in both the room panel and the room action pill; the stair panel shows a "Cut Through Deck" toggle for deck stairs - Update the slabOpeningMode description and the destination doc comment Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FX8vGAP5g43AHR1jixUeXr --- packages/core/src/commands/structure/index.ts | 6 +- .../structure/mezzanine-transform.test.ts | 92 ++++++++++ .../structure/plan-mezzanine-stair.ts | 89 ++++++---- packages/core/src/schema/nodes/stair.ts | 2 +- .../core/src/systems/stair/stair-clearance.ts | 2 +- .../systems/stair/stair-opening-sync.test.ts | 168 ++++++++++++++++++ .../src/systems/stair/stair-opening-sync.ts | 99 ++++++++--- .../src/components/editor/room-controls.tsx | 35 ++-- .../src/components/ui/panels/room-panel.tsx | 28 +-- .../editor/src/lib/mezzanine-actions.test.ts | 41 ++++- packages/editor/src/lib/mezzanine-stairs.ts | 15 +- packages/nodes/src/stair/destination.ts | 6 +- packages/nodes/src/stair/panel.tsx | 52 +++--- 13 files changed, 515 insertions(+), 120 deletions(-) diff --git a/packages/core/src/commands/structure/index.ts b/packages/core/src/commands/structure/index.ts index 643cf6f6b8..854b723a05 100644 --- a/packages/core/src/commands/structure/index.ts +++ b/packages/core/src/commands/structure/index.ts @@ -24,7 +24,11 @@ export { } from './floor-opening' export { type LockOutsideFacesInput, lockOutsideFaces } from './lock-outside-faces' export { mergeZones } from './merge-zones' -export { type MezzanineStairPlan, planMezzanineStair } from './plan-mezzanine-stair' +export { + type MezzanineStairPlacement, + type MezzanineStairPlan, + planMezzanineStair, +} from './plan-mezzanine-stair' export { planWallDeletion } from './plan-wall-deletion' export { rebaseFloorReference } from './rebase-floor-reference' export type { MezzanineEdgeInput } from './resize-mezzanine' diff --git a/packages/core/src/commands/structure/mezzanine-transform.test.ts b/packages/core/src/commands/structure/mezzanine-transform.test.ts index ff15b54cf0..e652cce4e2 100644 --- a/packages/core/src/commands/structure/mezzanine-transform.test.ts +++ b/packages/core/src/commands/structure/mezzanine-transform.test.ts @@ -438,3 +438,95 @@ test('move and duplicate canonicalize signed zero in content poses', () => { expect(hasNegativeZero(plan.changes)).toBe(false) } }) + +function insidePlan(polygon: [number, number][], extra: Record = {}) { + const f = mezzanineFixture() + const mezzanine = createMezzanine(f.before, { hostZoneId: f.host.id, polygon, mintId: f.mintId }) + const nodes = { ...f.apply(f.before, mezzanine), ...extra } as typeof f.nodes + const plan = planMezzanineStair(nodes, mezzanine.zoneId, 'inside') + const created = (type: string) => + plan.changes.flatMap((c) => (c.op === 'create' && c.node.type === type ? [c.node] : []))[0] + return { + nodes, + plan, + stair: created('stair') as StairNode | undefined, + segment: created('stair-segment') as StairSegmentNode | undefined, + deck: Object.values(nodes).find( + (n): n is SlabNode => n.type === 'slab' && !!n.zoneIds?.includes(mezzanine.zoneId), + )!, + } +} + +/** Where the flight's top step ends, and the point one stair width further on. */ +function topAndBeyond(stair: StairNode, segment: StairSegmentNode) { + const dir = [Math.sin(stair.rotation), Math.cos(stair.rotation)] as const + const at = (d: number) => [stair.position[0] + dir[0] * d, stair.position[2] + dir[1] * d] + return { top: at(segment.length), beyond: at(segment.length + stair.width) } +} + +const inBox = (polygon: [number, number][], [x, z]: number[]) => { + const xs = polygon.map(([px]) => px) + const zs = polygon.map(([, pz]) => pz) + return ( + x! >= Math.min(...xs) - 1e-6 && + x! <= Math.max(...xs) + 1e-6 && + z! >= Math.min(...zs) - 1e-6 && + z! <= Math.max(...zs) + 1e-6 + ) +} + +test('inside stair climbs from an open edge through its own deck and leaves a top landing', () => { + const { plan, stair, segment, deck } = insidePlan([ + [1.5, 0.1], + [7.9, 0.1], + [7.9, 3], + [1.5, 3], + ]) + expect(plan.conflicts).toBeUndefined() + // The west edge is the only one with free host floor in front of it. + expect(plan.edgeIndex).toBe(3) + expect(stair).toMatchObject({ deckSlabId: deck.id, slabOpeningMode: 'destination' }) + expect(stair!.rotation).toBeCloseTo(Math.PI / 2) + expect(stair!.position[0]).toBeCloseTo(1.5) + expect(segment!.length / segment!.stepCount).toBeGreaterThanOrEqual(0.25) + const { top, beyond } = topAndBeyond(stair!, segment!) + expect(inBox(deck.polygon, top)).toBe(true) + expect(inBox(deck.polygon, beyond)).toBe(true) +}) + +test('inside stair shortens its run to keep a landing on a shallow deck', () => { + const { plan, stair, segment, deck } = insidePlan([ + [1.5, 0.1], + [6.2, 0.1], + [6.2, 3], + [1.5, 3], + ]) + expect(plan.conflicts).toBeUndefined() + expect(segment!.length).toBeLessThan(4.7 - stair!.width + 1e-3) + expect(segment!.length / segment!.stepCount).toBeGreaterThanOrEqual(0.25) + expect(inBox(deck.polygon, topAndBeyond(stair!, segment!).beyond)).toBe(true) +}) + +test('inside stair respects walls under the deck and refuses without writes', () => { + const f = mezzanineFixture() + const wall = { ...f.walls[0]!, id: 'wall_under_deck' as const, start: [3, 0], end: [3, 3.5] } + const polygon: [number, number][] = [ + [1.5, 0.1], + [7.9, 0.1], + [7.9, 3], + [1.5, 3], + ] + expect(insidePlan(polygon).plan.conflicts).toBeUndefined() + const { nodes, plan } = insidePlan(polygon, { [wall.id]: wall }) + expect(plan).toMatchObject({ changes: [], conflicts: [{ code: 'no-room-for-stair' }] }) + expect(nodes[wall.id]).toBe(wall) +}) + +test('outside placement is the default and leaves the deck uncut', () => { + const f = mezzanineFixture() + const byDefault = planMezzanineStair(f.nodes, f.zone.id) + const outside = planMezzanineStair(f.nodes, f.zone.id, 'outside') + expect(outside.edgeIndex).toBe(byDefault.edgeIndex) + const stair = outside.changes.find((c) => c.op === 'create' && c.node.type === 'stair') + expect(stair).toMatchObject({ node: { slabOpeningMode: 'none', rotation: -Math.PI / 2 } }) +}) diff --git a/packages/core/src/commands/structure/plan-mezzanine-stair.ts b/packages/core/src/commands/structure/plan-mezzanine-stair.ts index c81891d4c6..d35c638b90 100644 --- a/packages/core/src/commands/structure/plan-mezzanine-stair.ts +++ b/packages/core/src/commands/structure/plan-mezzanine-stair.ts @@ -16,11 +16,23 @@ import { } from './shared' import { mezzanineHostClearance } from './validate-mezzanine' +export type MezzanineStairPlacement = 'outside' | 'inside' + export type MezzanineStairPlan = StructurePlan & { stairId?: string; edgeIndex?: number } +/** + * Plans a straight stair from the host floor up to a mezzanine deck. + * + * - `outside` (default): the flight stands on free host floor beside the deck + * and arrives flush with one of its edges. + * - `inside`: the flight starts at an edge, climbs under the deck and arrives + * through an opening the deck cuts for it (`slabOpeningMode: 'destination'`), + * leaving at least a stair width of deck beyond the top step. + */ export function planMezzanineStair( nodes: StructureNodes, mezzanineZoneId: string, + placement: MezzanineStairPlacement = 'outside', ): MezzanineStairPlan { const zone = requireZone(nodes, mezzanineZoneId) const host = nodes[zone.hostZoneId ?? ''] @@ -30,7 +42,9 @@ export function planMezzanineStair( conflict( 'no-room-for-stair', [zone.id], - 'No mezzanine edge has enough free host floor for a straight stair.', + placement === 'inside' + ? 'No mezzanine edge has enough free deck for a straight stair to climb through.' + : 'No mezzanine edge has enough free host floor for a straight stair.', ) const slabs = Object.values(nodes).filter((node) => node.type === 'slab') const deck = slabs.find((node) => node.zoneIds?.includes(zone.id)) @@ -43,10 +57,12 @@ export function planMezzanineStair( const preferredRun = defaults.length const minimumRun = stepCount * DEFAULT_STAIR_DESIGN_TARGETS.minimumGoing const width = defaults.width + const landingDepth = width const obstacles: Point[][] = [] for (const node of Object.values(nodes)) { if (node.parentId !== zone.parentId) continue - if (node.type === 'zone' && node.floor?.support === 'open') obstacles.push(node.polygon) + if (node.type === 'zone' && node.floor?.support === 'open' && node.id !== zone.id) + obstacles.push(node.polygon) if (node.type === 'item' && !node.asset.attachTo) obstacles.push( planFootprintCorners(node.position, getScaledDimensions(node), node.rotation[1]), @@ -62,58 +78,72 @@ export function planMezzanineStair( ]) } } - const free = difference(mezzanineHostClearance(nodes, host), union(obstacles)) + const clear = difference(mezzanineHostClearance(nodes, host), union(obstacles)) + const outside = difference(clear, zone.polygon) + const inside = intersection(clear, zone.polygon) + const within = (polygon: Point[], region: typeof clear) => + area(difference(polygon, region)) <= 1e-6 && + area(difference(polygon, { outer: floor.polygon, holes: floor.holes })) <= 1e-6 const winding = Math.sign( zone.polygon.reduce((sum, p, i) => { const q = zone.polygon[(i + 1) % zone.polygon.length]! return sum + p[0] * q[1] - q[0] * p[1] }, 0), ) - const candidates: { edgeIndex: number; arrival: Point; normal: Point; freeRun: number }[] = [] + // `direction` is the walking direction up the flight; it always points into the deck. + const candidates: { edgeIndex: number; edgePoint: Point; direction: Point; freeRun: number }[] = + [] const extent = Math.hypot( Math.max(...host.polygon.map(([x]) => x)) - Math.min(...host.polygon.map(([x]) => x)), Math.max(...host.polygon.map(([, z]) => z)) - Math.min(...host.polygon.map(([, z]) => z)), ) + const requiredDepth = placement === 'inside' ? minimumRun + landingDepth : minimumRun for (const [edgeIndex, start] of zone.polygon.entries()) { const end = zone.polygon[(edgeIndex + 1) % zone.polygon.length]! const length = Math.hypot(end[0] - start[0], end[1] - start[1]) if (length < width) continue const tangent: Point = [(end[0] - start[0]) / length, (end[1] - start[1]) / length] - const normal: Point = [winding * tangent[1], -winding * tangent[0]] + const direction: Point = [-winding * tangent[1], winding * tangent[0]] for (const along of [...new Set([length / 2, width / 2, length - width / 2])]) { - const arrival: Point = [start[0] + tangent[0] * along, start[1] + tangent[1] * along] - const footprint = (depth: number): Point[] => + const edgePoint: Point = [start[0] + tangent[0] * along, start[1] + tangent[1] * along] + // A strip of the stair's width from the edge, `from` to `to` metres into the deck. + const strip = (from: number, to: number): Point[] => [ - [-width / 2, 0], - [width / 2, 0], - [width / 2, depth], - [-width / 2, depth], + [-width / 2, from], + [width / 2, from], + [width / 2, to], + [-width / 2, to], ].map(([x, z]) => [ - arrival[0] + tangent[0] * x! + normal[0] * z!, - arrival[1] + tangent[1] * x! + normal[1] * z!, + edgePoint[0] + tangent[0] * x! + direction[0] * z!, + edgePoint[1] + tangent[1] * x! + direction[1] * z!, ]) - const fits = (depth: number) => { - const polygon = footprint(depth) - return ( - area(difference(polygon, free)) <= 1e-6 && - area(difference(polygon, { outer: floor.polygon, holes: floor.holes })) <= 1e-6 && - area(intersection(polygon, zone.polygon)) <= 1e-6 - ) - } - if (!fits(minimumRun)) continue - let lo = minimumRun, + const fits = + placement === 'inside' + ? (depth: number) => within(strip(0, depth), inside) + : (depth: number) => within(strip(-depth, 0), outside) + if (!fits(requiredDepth)) continue + // The bottom step of an inside stair needs free host floor in front of it. + if (placement === 'inside' && !within(strip(-width, 0), outside)) continue + let lo = requiredDepth, hi = extent for (let i = 0; i < 16; i++) { const mid = (lo + hi) / 2 if (fits(mid)) lo = mid else hi = mid } - candidates.push({ edgeIndex, arrival, normal, freeRun: lo }) + candidates.push({ edgeIndex, edgePoint, direction, freeRun: lo }) } } const best = candidates.sort((a, b) => b.freeRun - a.freeRun)[0] if (!best) return refuse() - const run = Math.min(preferredRun, best.freeRun) + const run = Math.min( + preferredRun, + placement === 'inside' ? best.freeRun - landingDepth : best.freeRun, + ) + const bottom: Point = + placement === 'inside' + ? best.edgePoint + : [best.edgePoint[0] - best.direction[0] * run, best.edgePoint[1] - best.direction[1] * run] const stair = StairNode.parse({ parentId: zone.parentId, name: 'Mezzanine stair', @@ -121,14 +151,11 @@ export function planMezzanineStair( fromLevelId: zone.parentId, supportSlabId: floor.id, deckSlabId: deck.id, + slabOpeningMode: placement === 'inside' ? 'destination' : 'none', width, stepCount, - position: [ - best.arrival[0] + best.normal[0] * run + 0, - 0, - best.arrival[1] + best.normal[1] * run + 0, - ], - rotation: Math.atan2(-best.normal[0], -best.normal[1]) + 0, + position: [bottom[0] + 0, 0, bottom[1] + 0], + rotation: Math.atan2(best.direction[0], best.direction[1]) + 0, }) const segment = StairSegmentNode.parse({ ...defaults, diff --git a/packages/core/src/schema/nodes/stair.ts b/packages/core/src/schema/nodes/stair.ts index 91a2ef50f4..d9b3b13e6d 100644 --- a/packages/core/src/schema/nodes/stair.ts +++ b/packages/core/src/schema/nodes/stair.ts @@ -126,7 +126,7 @@ export const StairNode = BaseNode.extend({ - stairType: straight (segment-based), curved (arc-based), or spiral - fromLevelId / toLevelId: source and destination levels used for auto slab cutouts - deckSlabId: destination deck (slab) — the rise derives from its elevation while set - - slabOpeningMode: whether a destination-level slab opening is generated for this stair + - slabOpeningMode: 'destination' cuts the stair's headroom clearance out of the surfaces it climbs through: the floors of the levels it climbs into and open mezzanine decks on its source level, or, for a deck stair (deckSlabId), only its own deck. 'none' cuts nothing - openingOffset: extra opening expansion applied after the cutout polygon is computed - width: stair width - totalRise: total stair height diff --git a/packages/core/src/systems/stair/stair-clearance.ts b/packages/core/src/systems/stair/stair-clearance.ts index c42fb5d38a..c2702cd0b8 100644 --- a/packages/core/src/systems/stair/stair-clearance.ts +++ b/packages/core/src/systems/stair/stair-clearance.ts @@ -585,7 +585,7 @@ function expand(region: Polygon, offset: number): Polygon[] { } /** Surface-hole rings cannot contain islands; split annular cuts while preserving their centre. */ -function openingRings(regions: Polygon[]): Ring[] { +export function openingRings(regions: Polygon[]): Ring[] { return regions.flatMap((region) => { if (!region.holes.length) return [region.outer] const hole = region.holes[0]! diff --git a/packages/core/src/systems/stair/stair-opening-sync.test.ts b/packages/core/src/systems/stair/stair-opening-sync.test.ts index 2ff511ff4a..4d6e480c64 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.test.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.test.ts @@ -636,3 +636,171 @@ test('an unrelated slab edit preserves a loaded stair opening with historical ge }).has(stair.id), ).toBe(true) }) + +describe('mezzanine deck openings', () => { + const box = (x0: number, z0: number, x1: number, z1: number): [number, number][] => [ + [x0, z0], + [x1, z0], + [x1, z1], + [x0, z1], + ] + + function insideDeckStair(levelHeight: number) { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ + name: 'Ground', + level: 0, + parentId: building.id, + height: levelHeight, + }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const deck = SlabNode.parse({ + name: 'Deck', + parentId: ground.id, + support: 'open', + elevation: 2.5, + polygon: box(1.5, 0.1, 7.9, 3), + }) + const hostCeiling = CeilingNode.parse({ + name: 'Host ceiling', + parentId: ground.id, + height: levelHeight - 0.1, + polygon: box(0, 0, 8, 6), + }) + const upperFloor = SlabNode.parse({ + name: 'Upper floor', + parentId: upper.id, + polygon: box(0, 0, 8, 6), + }) + const segment = StairSegmentNode.parse({ + parentId: 'stair_inside', + width: 1, + length: 3.92, + height: 2.5, + stepCount: 14, + }) + const stair = StairNode.parse({ + id: 'stair_inside', + parentId: ground.id, + position: [1.5, 0, 1.55], + rotation: Math.PI / 2, + fromLevelId: ground.id, + toLevelId: null, + deckSlabId: deck.id, + slabOpeningMode: 'destination', + children: [segment.id], + }) + const nodes = Object.fromEntries( + [building, ground, upper, deck, hostCeiling, upperFloor, stair, segment].map((node) => [ + node.id, + node, + ]), + ) as Record + return { nodes, deck, hostCeiling, upperFloor, stair } + } + + test('a deck stair with the cutout on cuts only its own deck, under the flight', () => { + const { nodes, deck, stair } = insideDeckStair(5) + const updates = syncAutoStairOpenings(nodes) + expect(updates.map((update) => update.id)).toEqual([deck.id]) + const hole = updates[0]!.data.holes![0]! + expect(updates[0]!.data.holeMetadata).toEqual([{ source: 'stair', stairId: stair.id }]) + const xs = hole.map(([x]) => x) + // The cut ends at the top step, leaving the rest of the deck as a landing. + expect(Math.max(...xs)).toBeCloseTo(1.5 + 3.92) + expect(Math.min(...xs)).toBeGreaterThanOrEqual(1.5) + }) + + test('a deck stair never cuts the host ceiling or the floor above, even within headroom', () => { + // 2.5 m deck + 2 m headroom reaches past a 4 m storey's ceiling and floor above. + const { nodes, deck, hostCeiling, upperFloor, stair } = insideDeckStair(4) + const ids = syncAutoStairOpenings(nodes).map((update) => update.id) + expect(ids).toContain(deck.id) + expect(ids).not.toContain(hostCeiling.id) + expect(ids).not.toContain(upperFloor.id) + // Without the deck link the same flight would cut both. + const ordinary = { ...stair, deckSlabId: undefined, toLevelId: null } + const unlinked = syncAutoStairOpenings({ ...nodes, [stair.id]: ordinary }).map((u) => u.id) + expect(unlinked).toContain(hostCeiling.id) + expect(unlinked).toContain(upperFloor.id) + }) + + test('a deck stair with the cutout off cuts nothing', () => { + const { nodes, stair } = insideDeckStair(5) + const outside = { ...stair, slabOpeningMode: 'none' as const } + expect(syncAutoStairOpenings({ ...nodes, [stair.id]: outside })).toEqual([]) + }) + + test('an L stair keeps its intermediate landing on a deck it meets, and cuts only its stairwell above', () => { + const building = BuildingNode.parse({ name: 'Building' }) + const ground = LevelNode.parse({ name: 'Ground', level: 0, parentId: building.id, height: 3 }) + const upper = LevelNode.parse({ name: 'Upper', level: 1, parentId: building.id }) + const upperFloor = SlabNode.parse({ + name: 'Upper floor', + parentId: upper.id, + polygon: box(-2, -2, 8, 8), + }) + // A split-level deck flush with the landing (1.5 m) that the landing runs onto. + const deck = SlabNode.parse({ + name: 'Split-level deck', + parentId: ground.id, + support: 'open', + elevation: 1.5, + polygon: box(-1, 2.7, 4, 6), + }) + const first = StairSegmentNode.parse({ + parentId: 'stair_l', + width: 1, + length: 2.5, + height: 1.5, + stepCount: 9, + }) + const landing = StairSegmentNode.parse({ + parentId: 'stair_l', + segmentType: 'landing', + width: 2, + length: 1, + height: 0, + stepCount: 0, + }) + const second = StairSegmentNode.parse({ + parentId: 'stair_l', + width: 1, + length: 2.5, + height: 1.5, + stepCount: 9, + attachmentSide: 'left', + }) + const stair = StairNode.parse({ + id: 'stair_l', + parentId: ground.id, + position: [2, 0, 0.2], + fromLevelId: ground.id, + toLevelId: upper.id, + slabOpeningMode: 'destination', + children: [first.id, landing.id, second.id], + }) + const nodes = Object.fromEntries( + [building, ground, upper, upperFloor, deck, stair, first, landing, second].map((node) => [ + node.id, + node, + ]), + ) as Record + const updates = syncAutoStairOpenings(nodes) + const landingCentre: [number, number] = [2, 3.2] + const contains = (ring: [number, number][], [x, z]: [number, number]) => { + let inside = false + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const [xi, zi] = ring[i]! + const [xj, zj] = ring[j]! + if (zi > z !== zj > z && x < ((xj - xi) * (z - zi)) / (zj - zi) + xi) inside = !inside + } + return inside + } + const deckHoles = updates.find((update) => update.id === deck.id)?.data.holes ?? [] + expect(deckHoles.some((hole) => contains(hole, landingCentre))).toBe(false) + // The storey above still opens over the whole flight, landing included (headroom). + const floorHoles = updates.find((update) => update.id === upperFloor.id)?.data.holes ?? [] + expect(floorHoles.some((hole) => contains(hole, landingCentre))).toBe(true) + }) +}) diff --git a/packages/core/src/systems/stair/stair-opening-sync.ts b/packages/core/src/systems/stair/stair-opening-sync.ts index cf347d6092..5b4e067f2a 100644 --- a/packages/core/src/systems/stair/stair-opening-sync.ts +++ b/packages/core/src/systems/stair/stair-opening-sync.ts @@ -11,7 +11,7 @@ import type { } from '../../schema' import { resolveCeilingHeight } from '../../services/level-height' import { getLevelElevations } from '../../services/storey' -import { stairClearanceOpening } from './stair-clearance' +import { openingRings, resolveStairWalkingSurfaces, stairClearanceOpening } from './stair-clearance' import { resolveStairTotalRise } from './stair-rise-query' const buildingLevelsMemo = new WeakMap[]>>() @@ -242,24 +242,56 @@ function isCoveredByExistingHole(existingHoles: Point2D[][], autoHole: Point2D[] return area(difference(autoHole, union(existingHoles))) <= 1e-6 } -function getStairOpeningPolygons( +/** The surface's top in the building frame that stair walking surfaces use. */ +function stairSurfaceTop( stair: StairNode, nodes: Record, targetElevation: number, - openingOffset: number, - thickness: number, ) { - if ((stair.slabOpeningMode ?? 'none') !== 'destination') return [] const { fromLevelId } = getResolvedStairLevelIds(stair, nodes) const source = fromLevelId ? getLevelElevations(nodes as Record).get(fromLevelId) : undefined - const underside = (source?.baseY ?? 0) + stair.position[1] + targetElevation - thickness - return stairClearanceOpening(stair, nodes, underside, openingOffset, underside + thickness) + return (source?.baseY ?? 0) + stair.position[1] + targetElevation +} + +function getStairOpeningPolygons( + stair: StairNode, + nodes: Record, + targetElevation: number, + openingOffset: number, + thickness: number, +) { + if ((stair.slabOpeningMode ?? 'none') !== 'destination') return [] + const top = stairSurfaceTop(stair, nodes, targetElevation) + return stairClearanceOpening(stair, nodes, top - thickness, openingOffset, top) } type OpeningResolver = typeof getStairOpeningPolygons +/** + * A deck on the stair's own level that an intermediate landing meets (a + * split-level landing) is walked on, not climbed through: keep it under the + * landing even though the landing's headroom reaches it. + */ +function keepingIntermediateLandings(resolveOpening: OpeningResolver): OpeningResolver { + return (stair, nodes, targetElevation, openingOffset, thickness) => { + const rings = resolveOpening(stair, nodes, targetElevation, openingOffset, thickness) + if (!rings?.length || stair.stairType !== 'straight') return rings + const top = stairSurfaceTop(stair, nodes, targetElevation) + const lastSegmentId = stair.children.at(-1) + const landings = resolveStairWalkingSurfaces(stair, nodes) + .filter( + (surface) => + surface.kind === 'landing' && + surface.nodeId !== lastSegmentId && + surface.top >= top - 1e-6, + ) + .map((surface) => surface.region) + return landings.length ? openingRings(difference(union(rings), union(landings))) : rings + } +} + function getApplicableStairOpeningPolygons( stair: StairNode, nodes: Record, @@ -331,18 +363,39 @@ function getTargetCeilingElevationForStair( return ceilingElevation.baseY - fromElevation.baseY + ceilingHeight - (stair.position[1] ?? 0) } +function isSourceLevelDeck( + stair: StairNode, + slab: SlabNode, + slabLevelId: string, + nodes: Record, +) { + return ( + !stair.deckSlabId && + slab.support === 'open' && + slabLevelId === getResolvedStairLevelIds(stair, nodes).fromLevelId + ) +} + +/** + * A deck stair only ever cuts its own deck. Any other stair cuts the floors of + * the levels it climbs into, plus open-supported decks (mezzanines) on its + * source level; clearance geometry decides whether and where those are cut. + */ function shouldApplyStairToSlab( stair: StairNode, + slab: SlabNode, slabLevelId: string, nodes: Record, ) { + if (stair.deckSlabId) return stair.deckSlabId === slab.id + if (!isInStairBuildingScope(stair, slabLevelId, nodes)) return false + + if (isSourceLevelDeck(stair, slab, slabLevelId, nodes)) return true const { fromLevelId, toLevelId } = getResolvedStairLevelIds(stair, nodes) const fromLevel = getLevelNumber(fromLevelId, nodes) const toLevel = getLevelNumber(toLevelId, nodes) const slabLevel = getLevelNumber(slabLevelId, nodes) - if (!isInStairBuildingScope(stair, slabLevelId, nodes)) return false - if (slabLevel === undefined) { return toLevelId === slabLevelId } @@ -361,13 +414,14 @@ function shouldApplyStairToCeiling( ceilingLevelId: string, nodes: Record, ) { + // A deck stair arrives on its deck inside the room, below the room's ceiling. + if (stair.deckSlabId || !isInStairBuildingScope(stair, ceilingLevelId, nodes)) return false + const { fromLevelId, toLevelId } = getResolvedStairLevelIds(stair, nodes) const fromLevel = getLevelNumber(fromLevelId, nodes) const toLevel = getLevelNumber(toLevelId, nodes) const ceilingLevel = getLevelNumber(ceilingLevelId, nodes) - if (!isInStairBuildingScope(stair, ceilingLevelId, nodes)) return false - if (ceilingLevel === undefined) { return fromLevelId === ceilingLevelId } @@ -394,18 +448,12 @@ export function syncAutoStairOpenings( (node): node is CeilingNode => node.type === 'ceiling', ) const updates: Array<{ id: AnyNodeId; data: Partial }> = [] - const slabStairsByLevel = new Map() const ceilingStairsByLevel = new Map() - const stairsFor = (levelId: string, ceiling: boolean) => { - const cache = ceiling ? ceilingStairsByLevel : slabStairsByLevel - let selected = cache.get(levelId) + const ceilingStairsFor = (levelId: string) => { + let selected = ceilingStairsByLevel.get(levelId) if (!selected) { - selected = stairs.filter((stair) => - ceiling - ? shouldApplyStairToCeiling(stair, levelId, nodes) - : shouldApplyStairToSlab(stair, levelId, nodes), - ) - cache.set(levelId, selected) + selected = stairs.filter((stair) => shouldApplyStairToCeiling(stair, levelId, nodes)) + ceilingStairsByLevel.set(levelId, selected) } return selected } @@ -420,7 +468,8 @@ export function syncAutoStairOpenings( const preservedHolePolygons = preservedHoles.map((entry) => entry.polygon) const unresolved = new Set() - const stairHoles = stairsFor(slabLevelId, false) + const stairHoles = stairs + .filter((stair) => shouldApplyStairToSlab(stair, slab, slabLevelId, nodes)) .flatMap((stair) => { const polygons = getApplicableStairOpeningPolygons( stair, @@ -428,7 +477,9 @@ export function syncAutoStairOpenings( getTargetSlabElevationForStair(stair, slab, slabLevelId, nodes), slab.polygon, slab.thickness, - resolveOpening, + isSourceLevelDeck(stair, slab, slabLevelId, nodes) + ? keepingIntermediateLandings(resolveOpening) + : resolveOpening, ) if (polygons === null) { unresolved.add(stair.id) @@ -481,7 +532,7 @@ export function syncAutoStairOpenings( const preservedHolePolygons = preservedHoles.map((entry) => entry.polygon) const unresolved = new Set() - const stairHoles = stairsFor(ceilingLevelId, true) + const stairHoles = ceilingStairsFor(ceilingLevelId) .flatMap((stair) => { const polygons = getApplicableStairOpeningPolygons( stair, diff --git a/packages/editor/src/components/editor/room-controls.tsx b/packages/editor/src/components/editor/room-controls.tsx index 04bbe65394..80deeeccec 100644 --- a/packages/editor/src/components/editor/room-controls.tsx +++ b/packages/editor/src/components/editor/room-controls.tsx @@ -157,9 +157,9 @@ export function RoomActionMenu({ room }: { room: RoomSelectionRecord }) { // A mezzanine moves, turns and copies inside its host (core refuses what // leaves it); it has no walls to divide, and it gets its own stairs. const mezzanine = !!room.mezzanine - const addStairs = (event: MouseEvent) => { + const addStairs = (placement: 'outside' | 'inside') => (event: MouseEvent) => { event.stopPropagation() - const result = addMezzanineStairs(zoneId) + const result = addMezzanineStairs(zoneId, placement) if (!result.ok) showRoomNotice({ zoneId, message: result.message }) } return ( @@ -178,19 +178,24 @@ export function RoomActionMenu({ room }: { room: RoomSelectionRecord }) { onRotateLeft={turn('left')} onRotateRight={turn('right')} > - {mezzanine && ( - - - - - - )} + {mezzanine && + (['outside', 'inside'] as const).map((placement) => ( + + + + + + ))} {!mezzanine && ( { - const result = addMezzanineStairs(zoneId) - setStairMessage(result.ok ? null : result.message) - }} - type="button" - > - Add stairs - +
+ {(['outside', 'inside'] as const).map((placement) => ( + + ))} +
} expanded={false} icon="/icons/stairs.webp" diff --git a/packages/editor/src/lib/mezzanine-actions.test.ts b/packages/editor/src/lib/mezzanine-actions.test.ts index fe957418b0..f0e23fd80e 100644 --- a/packages/editor/src/lib/mezzanine-actions.test.ts +++ b/packages/editor/src/lib/mezzanine-actions.test.ts @@ -10,6 +10,8 @@ import { initSpaceDetectionSync, LevelNode, type Point, + type StairNode, + syncAutoStairOpenings, useScene, type ZoneNode, } from '@pascal-app/core' @@ -291,7 +293,7 @@ describe('pushing a mezzanine edge', () => { describe('adding stairs', () => { test('places one stair up to the mezzanine and selects it, in one step', () => { - const result = addMezzanineStairs(mezzanineId) + const result = addMezzanineStairs(mezzanineId, 'outside') expect(result.ok).toBe(true) const stairId = (result as { stairId: string }).stairId expect(nodes()[stairId as AnyNodeId]?.type).toBe('stair') @@ -334,9 +336,42 @@ describe('adding stairs', () => { } as never, ) clearSceneHistory() - const result = addMezzanineStairs(mezzanineId) - expect(result).toEqual({ ok: false, message: MEZZANINE_NO_STAIR_MESSAGE }) + for (const placement of ['outside', 'inside'] as const) { + const result = addMezzanineStairs(mezzanineId, placement) + expect(result).toEqual({ ok: false, message: MEZZANINE_NO_STAIR_MESSAGE }) + } expect(Object.values(nodes()).some((node) => node.type === 'stair')).toBe(false) expect(history()).toBe(0) }) + + test('inside, the flight climbs through its own deck in one step, keeping the host ceiling whole', () => { + // Deep enough for a flight plus a landing, with free floor west of it. + useScene.getState().updateNode( + mezzanineId as AnyNodeId, + { + polygon: [ + [1.5, 1], + [7, 1], + [7, 4], + [1.5, 4], + ], + } as never, + ) + clearSceneHistory() + const result = addMezzanineStairs(mezzanineId, 'inside') + expect(result.ok).toBe(true) + const stairId = (result as { stairId: string }).stairId + const stair = nodes()[stairId as AnyNodeId] as StairNode + const deck = Object.values(nodes()).find( + (node) => node.type === 'slab' && node.support === 'open', + )! + expect(stair).toMatchObject({ deckSlabId: deck.id, slabOpeningMode: 'destination' }) + expect(stair.position[0]).toBeCloseTo(1.5) + expect(useViewer.getState().selection.selectedIds).toEqual([stairId]) + expect(history()).toBe(1) + const cut = syncAutoStairOpenings(nodes()).map((update) => update.id) + expect(cut).toEqual([deck.id]) + expect(runHistoryShortcut('undo')).toBe(true) + expect(nodes()[stairId as AnyNodeId]).toBeUndefined() + }) }) diff --git a/packages/editor/src/lib/mezzanine-stairs.ts b/packages/editor/src/lib/mezzanine-stairs.ts index e2b38ec9e7..c9c775235f 100644 --- a/packages/editor/src/lib/mezzanine-stairs.ts +++ b/packages/editor/src/lib/mezzanine-stairs.ts @@ -1,5 +1,6 @@ import { type AnyNodeId, + type MezzanineStairPlacement, planMezzanineStair, runAsSingleSceneHistoryStep, useScene, @@ -13,17 +14,21 @@ import { sfxEmitter } from './sfx-bus' export type MezzanineStairResult = { ok: true; stairId: string } | { ok: false; message: string } /** - * "Add stairs": core places a straight flight from the host floor up to one of - * the mezzanine's open edges (its railing opens there), as one undo step. The - * new stair is selected, like a new mezzanine is, so it can be adjusted at once. + * "Add stairs": core places a straight flight from the host floor up to the + * mezzanine, as one undo step — outside, arriving at one of its open edges (its + * railing opens there), or inside, climbing through an opening cut in the deck. + * The new stair is selected, like a new mezzanine is, so it can be adjusted at once. */ -export function addMezzanineStairs(zoneId: string): MezzanineStairResult { +export function addMezzanineStairs( + zoneId: string, + placement: MezzanineStairPlacement, +): MezzanineStairResult { const nodes = useScene.getState().nodes if (useScene.getState().readOnly || !nodes[zoneId as AnyNodeId]) return { ok: false, message: MEZZANINE_NO_STAIR_MESSAGE } let plan: ReturnType try { - plan = planMezzanineStair(nodes, zoneId) + plan = planMezzanineStair(nodes, zoneId, placement) } catch { return { ok: false, message: MEZZANINE_NO_STAIR_MESSAGE } } diff --git a/packages/nodes/src/stair/destination.ts b/packages/nodes/src/stair/destination.ts index 41b6148e71..64a2712eb6 100644 --- a/packages/nodes/src/stair/destination.ts +++ b/packages/nodes/src/stair/destination.ts @@ -4,8 +4,10 @@ import type { AnyNode, StairNode } from '@pascal-app/core' * Computes the stair patch for a destination ("To") switch. * * Attaching to a deck clears any explicit custom rise (the rise follows the - * deck's elevation from now on) and disables the auto cutout — a deck stair - * lands ON its destination slab, not through it. Switching a deck-attached + * deck's elevation from now on) and turns the cutout off, because a stair + * attached from beside the deck lands ON its edge. A deck stair whose cutout + * is on climbs through the deck instead: it cuts only its own deck, never the + * floors above or the host room's ceiling. Switching a deck-attached * stair back to a level clears both again so the rise re-derives from the * storey height, and restores `slabOpeningMode: 'destination'` — the * placement default (the schema default is 'none', but the stair tool places diff --git a/packages/nodes/src/stair/panel.tsx b/packages/nodes/src/stair/panel.tsx index 946d5b6b5e..a95114abf1 100644 --- a/packages/nodes/src/stair/panel.tsx +++ b/packages/nodes/src/stair/panel.tsx @@ -169,6 +169,10 @@ export default function StairPanel() { const updates: Partial = { slabOpeningMode: checked ? 'destination' : 'none', } + if (node.deckSlabId) { + handleUpdate(updates) + return + } const sceneNodes = useScene.getState().nodes const fromLevelId = resolveStairFromLevelId(sceneNodes, node) if (checked && fromLevelId) updates.fromLevelId = fromLevelId @@ -579,13 +583,11 @@ export default function StairPanel() {
- {attachedDeck ? null : ( - - )} +
@@ -666,27 +668,25 @@ export default function StairPanel() { ) : null} {attachedDeck ? null : ( - <> - handleAutoCutoutChange(value === 'destination')} - options={STAIR_SLAB_OPENING_OPTIONS} - value={node.slabOpeningMode ?? 'none'} - /> - - {(node.slabOpeningMode ?? 'none') === 'destination' ? ( - handleUpdate({ openingOffset: value })} - precision={2} - step={0.01} - unit="m" - value={node.openingOffset ?? 0} - /> - ) : null} - + handleAutoCutoutChange(value === 'destination')} + options={STAIR_SLAB_OPENING_OPTIONS} + value={node.slabOpeningMode ?? 'none'} + /> )} + {(node.slabOpeningMode ?? 'none') === 'destination' ? ( + handleUpdate({ openingOffset: value })} + precision={2} + step={0.01} + unit="m" + value={node.openingOffset ?? 0} + /> + ) : null} + {node.stairType === 'spiral' && ( <>