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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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) ?? '')) {
|
||||
|
||||
Reference in New Issue
Block a user