Skip to content

Commit a52e90c

Browse files
committed
feat(core): let a vertical opening answer per surface, not per level
A level's slab and its ceiling sit at opposite ends of the level, so a shaft crossing floors 1-3 cuts slabs 2 and 3 but ceilings 1 and 2. The predicate saw only the level id, so a kind had one answer for both surfaces and was necessarily wrong at one end: slab semantics leave the bottom floor's ceiling sealed across the shaft, ceiling semantics cut a hole in the ceiling above the top stop. The elevator has always had two predicates for exactly this reason; the capability handed plugins a single one. Pass the surface kind — the sync already holds the surface node, so it costs one argument — and the two sides can express the same rule. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T5bdAFduH4BkPCjvtJgFzn
1 parent f261795 commit a52e90c

3 files changed

Lines changed: 64 additions & 4 deletions

File tree

packages/core/src/registry/types.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2177,11 +2177,23 @@ export type VerticalOpeningConfig = {
21772177
*/
21782178
polygon: (node: AnyNode, nodes: Readonly<Record<string, AnyNode>>) => Array<[number, number]>
21792179
/**
2180-
* Whether this node passes through the given level's floor. Called once per
2180+
* Whether this node passes through the given surface. Called once per
21812181
* candidate surface, so the kind decides its own service range — a lift
21822182
* serving floors 1-3 answers true for 2 and 3 and false for 4.
2183-
*/
2184-
servesLevel: (node: AnyNode, levelId: string, nodes: Readonly<Record<string, AnyNode>>) => boolean
2183+
*
2184+
* `surface` matters because a level's slab and its ceiling sit at opposite
2185+
* ends of the level: for a shaft spanning floors 1-3 the cut slabs are 2 and
2186+
* 3, while the cut ceilings are 1 and 2. A predicate that cannot tell them
2187+
* apart is wrong at one end or the other — it either leaves the ceiling of
2188+
* the bottom floor sealed across the shaft, or cuts a hole in the ceiling of
2189+
* the top one.
2190+
*/
2191+
servesLevel: (
2192+
node: AnyNode,
2193+
levelId: string,
2194+
nodes: Readonly<Record<string, AnyNode>>,
2195+
surface: 'slab' | 'ceiling',
2196+
) => boolean
21852197
}
21862198

21872199
export type FloorPlacedConfig = {

packages/core/src/systems/elevator/elevator-opening-sync.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,4 +305,52 @@ describe('verticalOpening capability', () => {
305305
expect(update?.data.holeMetadata?.[0]?.source).toBe('manual')
306306
expect(update?.data.holeMetadata?.[1]?.source).toBe('verticalOpening')
307307
})
308+
309+
/**
310+
* A level's slab and its ceiling sit at opposite ends of the level, so a
311+
* shaft crosses different sets of them: floors 1-3 means slabs 2 and 3 but
312+
* ceilings 1 and 2. Without the surface argument the kind answers one
313+
* question for both and is wrong at one end — here, sealing the ceiling
314+
* across the shaft or cutting the ceiling of the top floor.
315+
*/
316+
test('lets the kind answer per surface', () => {
317+
registerNode({
318+
kind: 'test:lift',
319+
schemaVersion: 1,
320+
schema: {} as never,
321+
category: 'furnish',
322+
defaults: () => ({}) as never,
323+
capabilities: {
324+
verticalOpening: {
325+
polygon: () => [
326+
[-1, -1],
327+
[1, -1],
328+
[1, 1],
329+
[-1, 1],
330+
],
331+
servesLevel: (_node, _levelId, _nodes, surface) => surface === 'slab',
332+
},
333+
},
334+
presentation: { label: 'Lift', icon: { kind: 'iconify', name: 'lucide:square' } },
335+
} as never)
336+
337+
const { nodes, slabId } = sceneWithLift()
338+
const ground = Object.values(nodes).find((node) => node.type === 'level')!
339+
const ceiling = CeilingNode.parse({
340+
name: 'Ceiling',
341+
parentId: ground.id,
342+
polygon: [
343+
[-10, -10],
344+
[10, -10],
345+
[10, 10],
346+
[-10, 10],
347+
],
348+
})
349+
nodes[ceiling.id] = ceiling as AnyNode
350+
351+
const updates = syncAutoElevatorOpenings(nodes)
352+
353+
expect(updates.find((u) => u.id === slabId)?.data.holes).toHaveLength(1)
354+
expect(updates.find((u) => u.id === ceiling.id)).toBeUndefined()
355+
})
308356
})

packages/core/src/systems/elevator/elevator-opening-sync.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ export function syncAutoElevatorOpenings(nodes: Record<string, AnyNode>) {
232232
}))
233233

234234
const declaredHoles = openingOwners
235-
.filter(({ node, config }) => config.servesLevel(node, levelId, nodes))
235+
.filter(({ node, config }) => config.servesLevel(node, levelId, nodes, surface.type))
236236
.map(({ node, config }) => ({
237237
polygon: config.polygon(node, nodes) as Point2D[],
238238
metadata: { ownerId: node.id, source: 'verticalOpening' as const },

0 commit comments

Comments
 (0)