From 10192eb5f1fa900179ce646a71ec3f7548776a18 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 26 May 2026 14:49:55 -0400 Subject: [PATCH] core(space-detection): add pause/resume refcount primitive 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. --- packages/core/src/index.ts | 3 + .../src/lib/space-detection-pause.test.ts | 55 +++++++++++++++++++ packages/core/src/lib/space-detection.ts | 34 ++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 packages/core/src/lib/space-detection-pause.test.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4f96ffd90..c5b3bc85b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -56,7 +56,10 @@ export { type AutoSlabSyncPlan, detectSpacesForLevel, initSpaceDetectionSync, + isSpaceDetectionPaused, + pauseSpaceDetection, planAutoSlabsForLevel, + resumeSpaceDetection, type Space, wallTouchesOthers, } from './lib/space-detection' diff --git a/packages/core/src/lib/space-detection-pause.test.ts b/packages/core/src/lib/space-detection-pause.test.ts new file mode 100644 index 000000000..44df9a755 --- /dev/null +++ b/packages/core/src/lib/space-detection-pause.test.ts @@ -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) + }) +}) diff --git a/packages/core/src/lib/space-detection.ts b/packages/core/src/lib/space-detection.ts index 94a6e47b7..befbb6ba5 100644 --- a/packages/core/src/lib/space-detection.ts +++ b/packages/core/src/lib/space-detection.ts @@ -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() 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() for (const levelId of new Set([...previousSnapshots.keys(), ...currentSnapshots.keys()])) { if ((previousSnapshots.get(levelId) ?? '') !== (currentSnapshots.get(levelId) ?? '')) {