Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/core/src/commands/structure/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
92 changes: 92 additions & 0 deletions packages/core/src/commands/structure/mezzanine-transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}) {
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 } })
})
89 changes: 58 additions & 31 deletions packages/core/src/commands/structure/plan-mezzanine-stair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? '']
Expand All @@ -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))
Expand All @@ -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]),
Expand All @@ -62,73 +78,84 @@ 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',
uniformRisers: true,
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,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/schema/nodes/stair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/systems/stair/stair-clearance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]!
Expand Down
Loading