Skip to content

Commit cfb544a

Browse files
committed
fix: codify space-detection commit visibility
Fixes #586
1 parent 27adf9a commit cfb544a

4 files changed

Lines changed: 156 additions & 0 deletions

File tree

packages/core/src/lib/space-detection.test.ts

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { BuildingNode, CeilingNode, LevelNode, SlabNode, WallNode, ZoneNode } fr
33
import type { AnyNode, AnyNodeId } from '../schema/types'
44
import { resolveCeilingHeight } from '../services/level-height'
55
import { getCeilingClampBound } from '../services/storey'
6+
import { type SceneCommit, subscribeSceneCommits } from '../store/history-control'
7+
import useScene, { clearSceneHistory } from '../store/use-scene'
68
import {
79
detectSpacesForLevel,
810
initSpaceDetectionSync,
@@ -15,6 +17,16 @@ import {
1517
import { encodeTerrainField } from './terrain-codec'
1618
import { applyHeightPatch, createTerrainField, flattenPatch } from './terrain-field'
1719

20+
type RafFn = (callback: (time: number) => void) => number
21+
;(globalThis as unknown as { requestAnimationFrame?: RafFn }).requestAnimationFrame ??= (
22+
callback,
23+
) => {
24+
callback(0)
25+
return 0
26+
}
27+
;(globalThis as unknown as { cancelAnimationFrame?: (id: number) => void }).cancelAnimationFrame ??=
28+
() => {}
29+
1830
const square: Array<[number, number]> = [
1931
[0, 0],
2032
[4, 0],
@@ -43,6 +55,111 @@ function slab(elevation: number) {
4355
})
4456
}
4557

58+
describe('space detection scene commit boundary', () => {
59+
test('includes room reconciliation in the closing wall commit and undo step', () => {
60+
const buildingId = 'building_space_commit' as AnyNodeId
61+
const levelId = 'level_space_commit' as AnyNodeId
62+
const walls = [
63+
WallNode.parse({
64+
id: 'wall_space_commit_bottom',
65+
parentId: levelId,
66+
start: [0, 0],
67+
end: [4, 0],
68+
}),
69+
WallNode.parse({
70+
id: 'wall_space_commit_right',
71+
parentId: levelId,
72+
start: [4, 0],
73+
end: [4, 3],
74+
}),
75+
WallNode.parse({
76+
id: 'wall_space_commit_top',
77+
parentId: levelId,
78+
start: [4, 3],
79+
end: [0, 3],
80+
}),
81+
WallNode.parse({
82+
id: 'wall_space_commit_left',
83+
parentId: levelId,
84+
start: [0, 3],
85+
end: [0, 0],
86+
}),
87+
]
88+
const initialWalls = walls.slice(0, 3)
89+
const building = BuildingNode.parse({
90+
id: buildingId,
91+
children: [levelId],
92+
})
93+
const level = LevelNode.parse({
94+
id: levelId,
95+
parentId: buildingId,
96+
children: initialWalls.map((wall) => wall.id),
97+
level: 0,
98+
height: 2.5,
99+
})
100+
const initialNodes = Object.fromEntries(
101+
[building, level, ...initialWalls].map((node) => [node.id, node]),
102+
) as Record<AnyNodeId, AnyNode>
103+
104+
useScene.setState({
105+
nodes: initialNodes,
106+
rootNodeIds: [buildingId],
107+
dirtyNodes: new Set<AnyNodeId>(),
108+
collections: {},
109+
materials: {},
110+
installedPlugins: [],
111+
readOnly: false,
112+
} as never)
113+
clearSceneHistory()
114+
115+
const commits: SceneCommit[] = []
116+
const stopDetection = initSpaceDetectionSync(useScene, createEditorStoreStub())
117+
const stopCommits = subscribeSceneCommits((commit) => commits.push(commit))
118+
119+
try {
120+
const closingWall = walls[3]!
121+
useScene.getState().createNode(closingWall, levelId)
122+
123+
const liveNodes = useScene.getState().nodes
124+
const autoSlab = Object.values(liveNodes).find(
125+
(node): node is SlabNode => node.type === 'slab' && node.autoFromWalls,
126+
)
127+
const autoCeiling = Object.values(liveNodes).find(
128+
(node): node is CeilingNode => node.type === 'ceiling' && node.autoFromWalls,
129+
)
130+
expect(autoSlab).toBeDefined()
131+
expect(autoCeiling).toBeDefined()
132+
133+
const localCommits = commits.filter((commit) => commit.origin === 'local')
134+
expect(localCommits).toHaveLength(1)
135+
const currentNodes = localCommits[0]!.current.nodes
136+
expect(Object.keys(currentNodes).sort()).toEqual(Object.keys(liveNodes).sort())
137+
138+
const committedLevel = currentNodes[levelId] as LevelNode
139+
expect(committedLevel.children).toEqual(
140+
expect.arrayContaining([closingWall.id, autoSlab!.id, autoCeiling!.id]),
141+
)
142+
for (const wall of walls) {
143+
const committedWall = currentNodes[wall.id] as WallNode
144+
expect(committedWall.frontSide).toBe('interior')
145+
expect(committedWall.backSide).toBe('exterior')
146+
}
147+
expect(useScene.temporal.getState().pastStates).toHaveLength(1)
148+
149+
useScene.temporal.getState().undo()
150+
151+
const undoneNodes = useScene.getState().nodes
152+
expect(undoneNodes[closingWall.id]).toBeUndefined()
153+
expect(undoneNodes[autoSlab!.id]).toBeUndefined()
154+
expect(undoneNodes[autoCeiling!.id]).toBeUndefined()
155+
} finally {
156+
stopCommits()
157+
stopDetection()
158+
clearSceneHistory()
159+
}
160+
})
161+
})
162+
46163
describe('planAutoCeilingsForLevel', () => {
47164
test('creates auto ceilings height-less so they follow the level top', () => {
48165
const created = planAutoCeilingsForLevel([roomPolygon()], [], {

packages/core/src/lib/space-detection.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1633,6 +1633,11 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () =>
16331633
const previousSnapshots = levelStructureSnapshots(sceneStore.getState().nodes)
16341634
let isProcessing = false
16351635

1636+
// Keep reconciliation in this synchronous store subscription. Zundo emits
1637+
// the originating local SceneCommit only after subscribers return, so the
1638+
// history-paused derived writes below join that commit's current snapshot
1639+
// and undo step. Running from subscribeSceneCommits would cross the snapshot
1640+
// boundary, and the paused writes would emit no replacement commit.
16361641
const unsubscribe = sceneStore.subscribe((state: any) => {
16371642
if (isProcessing) return
16381643
if (getSceneHistoryPauseDepth() > 0) return

wiki/architecture/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Canonical rules for code that touches `packages/core`, `packages/viewer`, `packa
2323
| [spatial-queries](spatial-queries.md) | Placement validation (`canPlaceOnFloor`/`Wall`/`Ceiling`) for tools |
2424
| [node-schemas](node-schemas.md) | Zod schema pattern for node types, `createNode`, `updateNode` |
2525
| [vertical-model](vertical-model.md) | Stored level heights, plane-bound wall/ceiling tops, slab placement + thickness, support hosts, clamp rules, and the load migration |
26+
| [space-detection](space-detection.md) | Commit and replication contract for wall-driven room reconciliation |
2627
| [events](events.md) | Typed event bus — emitting and listening to node and grid events |
2728
| [creating-rules](creating-rules.md) | How to add or update a page in this folder |
2829

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Space Detection
2+
3+
*Commit and replication contract for wall-driven room reconciliation.*
4+
5+
Applies to: `packages/core/src/lib/space-detection.ts`, `packages/core/src/store/**`, and collaboration consumers of `SceneCommit`.
6+
7+
Space detection derives room state from wall geometry. Reconciliation updates wall side classifications, creates or updates automatic slabs and ceilings, and updates their level's `children`. Those derived writes are part of the wall edit that triggered them, not a later background operation.
8+
9+
## Local commit boundary
10+
11+
`initSpaceDetectionSync` must remain a synchronous scene-store subscriber. A local wall mutation and all reconciliation it triggers must finish before zundo emits the mutation's `SceneCommit` snapshot.
12+
13+
Reconciliation pauses scene history while applying derived writes. This keeps the triggering edit and its generated state in one undo step, while the outer tracked mutation still captures the final reconciled graph in `SceneCommit.current`. The emitted snapshot must therefore contain:
14+
15+
- the triggering wall edit;
16+
- reconciled `frontSide` and `backSide` values;
17+
- generated or updated automatic slabs and ceilings; and
18+
- the corresponding level `children` updates.
19+
20+
Do not schedule reconciliation from `subscribeSceneCommits`. Commit listeners run after the snapshot boundary. Because reconciliation writes are history-paused, moving the work there would neither amend the emitted snapshot nor produce a second local commit, leaving collaboration consumers unable to transmit the generated state.
21+
22+
## Host patch consumption
23+
24+
The originating client is the only client that reconciles a local wall edit and mints IDs for generated room surfaces. Collaboration transports the resulting before/current difference, including the generated nodes and parent updates.
25+
26+
Receiving clients apply that transmitted graph as a host patch. Host application is history-paused and may run while the scene is read-only, so space detection must not regenerate the room locally. The receiver consumes the originator's slab and ceiling IDs and records no local undo entry or local commit for the host change.
27+
28+
This two-sided contract prevents peers from independently minting different IDs for the same room:
29+
30+
1. Local wall edit → synchronous reconciliation → one complete local commit and one undo step.
31+
2. Host patch → apply the transmitted generated state → no local reconciliation or local history entry.
32+
33+
Changes to space-detection scheduling, history pausing, scene commit delivery, or host patch application must preserve both sides of this contract.

0 commit comments

Comments
 (0)