core(space-detection): add pause/resume refcount primitive (#336)

initSpaceDetectionSync subscribes to every scene mutation and auto-derives
slabs/ceilings from wall topology. When a host app drives explicit
slab/ceiling creation (e.g. the community editor's AI create_room flow),
the auto-sync races those nodes and the polygon-signature de-dupe is
fragile enough that duplicates leak through.

Expose pauseSpaceDetection / resumeSpaceDetection / isSpaceDetectionPaused
mirroring the existing pauseSceneHistory refcount in store/history-control.
While paused, the subscriber rolls previousSnapshots forward so resume
does NOT trigger a backfill that would re-introduce the very duplicates
the host paused to avoid.

No behavior change for callers that don't touch the new functions.
This commit is contained in:
Wassim SAMAD
2026-05-26 15:04:48 -04:00
committed by GitHub
parent f5ff111708
commit 814d94336b
3 changed files with 92 additions and 0 deletions
+3
View File
@@ -56,7 +56,10 @@ export {
type AutoSlabSyncPlan,
detectSpacesForLevel,
initSpaceDetectionSync,
isSpaceDetectionPaused,
pauseSpaceDetection,
planAutoSlabsForLevel,
resumeSpaceDetection,
type Space,
wallTouchesOthers,
} from './lib/space-detection'
@@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
import {
isSpaceDetectionPaused,
pauseSpaceDetection,
resumeSpaceDetection,
} from './space-detection'
// The pause flag is module-level (matching the existing pauseSceneHistory
// refcount in store/history-control.ts). Reset it before/after each test so
// leftover depth from one case can't bleed into another.
function drain() {
for (let i = 0; i < 64 && isSpaceDetectionPaused(); i += 1) {
resumeSpaceDetection()
}
}
beforeEach(drain)
afterEach(drain)
describe('space-detection pause primitive', () => {
test('defaults to not paused', () => {
expect(isSpaceDetectionPaused()).toBe(false)
})
test('pauseSpaceDetection flips the flag, resumeSpaceDetection clears it', () => {
pauseSpaceDetection()
expect(isSpaceDetectionPaused()).toBe(true)
resumeSpaceDetection()
expect(isSpaceDetectionPaused()).toBe(false)
})
test('refcount — pause depth survives mismatched resumes from a second source', () => {
pauseSpaceDetection()
pauseSpaceDetection()
expect(isSpaceDetectionPaused()).toBe(true)
resumeSpaceDetection()
expect(isSpaceDetectionPaused()).toBe(true)
resumeSpaceDetection()
expect(isSpaceDetectionPaused()).toBe(false)
})
test('resume is a no-op when not currently paused', () => {
expect(isSpaceDetectionPaused()).toBe(false)
resumeSpaceDetection()
resumeSpaceDetection()
expect(isSpaceDetectionPaused()).toBe(false)
pauseSpaceDetection()
expect(isSpaceDetectionPaused()).toBe(true)
resumeSpaceDetection()
expect(isSpaceDetectionPaused()).toBe(false)
})
})
+34
View File
@@ -884,6 +884,29 @@ function runSpaceDetection(
editorStore.getState().setSpaces(nextSpaces)
}
// Refcount of outstanding pause requests, matching the pauseSceneHistory
// pattern. The community editor flips this off while the AI is actively
// mutating the scene so the wall-driven auto slab/ceiling sync doesn't race
// `create_room`'s explicit slabs/ceilings (see plan
// `ai-pause-space-detection`).
let spaceDetectionPauseDepth = 0
/** Pause the wall-driven auto slab/ceiling sync. Refcounted — pair with `resumeSpaceDetection`. */
export function pauseSpaceDetection(): void {
spaceDetectionPauseDepth += 1
}
/** Resume the wall-driven auto slab/ceiling sync. No-op if not currently paused. */
export function resumeSpaceDetection(): void {
if (spaceDetectionPauseDepth === 0) return
spaceDetectionPauseDepth -= 1
}
/** True iff the wall-driven auto slab/ceiling sync is currently paused. */
export function isSpaceDetectionPaused(): boolean {
return spaceDetectionPauseDepth > 0
}
export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () => void {
const previousSnapshots = new Map<string, string>()
let isProcessing = false
@@ -909,6 +932,17 @@ export function initSpaceDetectionSync(sceneStore: any, editorStore: any): () =>
currentSnapshots.set(levelId, levelWallSnapshot(walls))
}
// Paused: roll the snapshot forward so we don't backfill (and re-duplicate)
// every paused change once detection resumes. Whatever the AI built while
// paused becomes the new baseline; only future changes will reconcile.
if (spaceDetectionPauseDepth > 0) {
previousSnapshots.clear()
for (const [levelId, snapshot] of currentSnapshots.entries()) {
previousSnapshots.set(levelId, snapshot)
}
return
}
const levelsToUpdate = new Set<string>()
for (const levelId of new Set([...previousSnapshots.keys(), ...currentSnapshots.keys()])) {
if ((previousSnapshots.get(levelId) ?? '') !== (currentSnapshots.get(levelId) ?? '')) {