import { applySafeResize, clampSafeResize, safeResizePointerDisplacement, validateSafeResize, type SafeOpeningIn, type SafeResizeOptions, type SafeResizePlan, type SafeResizeResolution, } from './resize'; import { checkWallRecordsPreserved, type WallRecordLike, } from './wall-record-preservation'; export interface ResizeControllerRoom { id: string; poly: number[][]; wall_ids?: string[]; } export interface ResizeProjection { preview: TPreview; beforeWalls: readonly WallRecordLike[]; afterWalls: readonly WallRecordLike[]; artifact: TArtifact | null; } export type ResizeProjectionResult = | { ok: true; value: ResizeProjection } | { ok: false; // 'junction-limit' is #329: the step would ADD a wall-junction violation. reason: 'missing-context' | 'wall-metadata' | 'physical-geometry' | 'junction-limit' }; export type ResizeMoveOutcome = | { kind: 'accepted'; preview: TPreview; labels: TLabels; artifact: TArtifact | null } | { kind: 'rejected'; notify: boolean } | { kind: 'no-op' }; export type ResizeFinishOutcome = | { kind: 'commit'; preview: TPreview; before: TBefore } | { kind: 'rejected'; reason: 'stale-snapshot' | 'invalid-topology' | 'invalid-candidate' | 'wall-records' } | { kind: 'no-op' }; export type ResizeCancelOutcome = | { kind: 'cancelled'; restoreWallUnion: TWallUnion | null; restoreEpoch: number | null } | { kind: 'no-op' }; export interface ResizeBeginInput { pointerId: number; start: [number, number]; roomId: string; plan: SafeResizePlan; options: SafeResizeOptions; rooms: ResizeControllerRoom[]; openings: SafeOpeningIn[]; snapshotIdentity: string; before: TBefore; wallUnionBefore: TWallUnion | null; epochBefore: number; } interface AcceptedPreview { preview: TPreview; labels: TLabels | null; artifact: TArtifact | null; beforeWalls: readonly WallRecordLike[]; afterWalls: readonly WallRecordLike[]; } interface ResizeSession { pointerId: number; start: [number, number]; roomId: string; plan: SafeResizePlan; options: SafeResizeOptions; rooms: ResizeControllerRoom[]; openings: SafeOpeningIn[]; snapshotIdentity: string; before: TBefore; moved: boolean; delta: number; changedRoomIds: string[]; rejectionNotified: boolean; wallUnionBefore: TWallUnion | null; epochBefore: number; accepted: AcceptedPreview | null; } /** * Synchronous state machine for the safe fixed-topology Resize transaction. * Browser events, Lit rendering and config/history writes deliberately stay in * the host; this class owns every mutable piece of the gesture itself. */ export class ResizeController { private _selectedRoomId: string | null = null; private _session: ResizeSession | null = null; private _eligibility: { context: string; values: Map } | null = null; get dragging(): boolean { return this._session !== null; } ownsPointer(pointerId: number): boolean { return this._session?.pointerId === pointerId; } get selectedRoomId(): string | null { return this._selectedRoomId; } get snapshotIdentity(): string | null { return this._session?.snapshotIdentity || null; } get activePointerId(): number | null { return this._session?.pointerId ?? null; } get moved(): boolean { return this._session?.moved === true; } get delta(): number { return this._session?.delta || 0; } get rooms(): readonly ResizeControllerRoom[] | null { return this._session?.rooms || null; } get openings(): readonly SafeOpeningIn[] | null { return this._session?.openings || null; } get plan(): SafeResizePlan | null { return this._session?.plan || null; } get preview(): TPreview | null { return this._session?.accepted?.preview || null; } get liveLabels(): TLabels | null { return this._session?.accepted?.labels || null; } selectRoom(roomId: string | null): void { this._selectedRoomId = roomId; } restoreSelection(roomId: string | null): void { this._selectedRoomId = roomId; } /** Preserve the historical two-step Escape behavior of latent selection. */ escapeIdle(): 'selection-cleared' | 'exit-tool' { if (this._selectedRoomId) { this._selectedRoomId = null; return 'selection-cleared'; } return 'exit-tool'; } resolve( context: string, key: string, compute: () => SafeResizeResolution, ): SafeResizeResolution { if (!this._eligibility || this._eligibility.context !== context) { this._eligibility = { context, values: new Map() }; } const cached = this._eligibility.values.get(key); if (cached) return cached; const value = compute(); this._eligibility.values.set(key, value); return value; } begin(input: ResizeBeginInput): boolean { if (this._session) return false; this._session = { pointerId: input.pointerId, start: [...input.start], roomId: input.roomId, plan: input.plan, options: input.options, rooms: input.rooms, openings: input.openings, snapshotIdentity: input.snapshotIdentity, before: input.before, moved: false, delta: 0, changedRoomIds: [...input.plan.roomIds], rejectionNotified: false, wallUnionBefore: input.wallUnionBefore, epochBefore: input.epochBefore, accepted: null, }; return true; } move(input: { pointerId: number; point: [number, number]; step: number; snap: (point: [number, number]) => [number, number]; project: ( snapshotIdentity: string, polys: Record, openings: Record, changedRoomIds: readonly string[], rooms: readonly ResizeControllerRoom[], ) => ResizeProjectionResult; measure: ( candidate: { polys: Record; openings: Record }, plan: SafeResizePlan, ) => TLabels; publish: (preview: TPreview | null, artifact: TArtifact | null) => void; }): ResizeMoveOutcome { const session = this._session; if (!session || session.pointerId !== input.pointerId) return { kind: 'no-op' }; const dRaw = safeResizePointerDisplacement(session.start, input.point, session.plan.n); const snapped = input.snap([ session.plan.a[0] + session.plan.n[0] * dRaw, session.plan.a[1] + session.plan.n[1] * dRaw, ]); const wanted = (snapped[0] - session.plan.a[0]) * session.plan.n[0] + (snapped[1] - session.plan.a[1]) * session.plan.n[1]; const delta = clampSafeResize( session.rooms, session.openings, session.plan, wanted, input.step, session.options, ); if (session.moved && delta === session.delta) return { kind: 'no-op' }; const candidate = applySafeResize(session.rooms, session.openings, session.plan, delta); let projected: ResizeProjectionResult; try { projected = input.project( session.snapshotIdentity, candidate.polys, candidate.openings, session.changedRoomIds, session.rooms, ); if (!projected.ok) return this._projectionRejected(session); if (checkWallRecordsPreserved( projected.value.beforeWalls, projected.value.afterWalls, { exactMultiplicity: true }, ).length) return this._projectionRejected(session); } catch { return this._projectionRejected(session); } const previous = session.accepted; const accepted: AcceptedPreview = { preview: projected.value.preview, labels: null, artifact: projected.value.artifact, beforeWalls: projected.value.beforeWalls, afterWalls: projected.value.afterWalls, }; // Expose the exact candidate while the host derives labels from its normal // render model. Both assignments are synchronous; a failed adapter rolls // back the complete accepted tuple before control returns to the browser. session.accepted = accepted; try { input.publish(accepted.preview, accepted.artifact); accepted.labels = input.measure(candidate, session.plan); } catch { session.accepted = previous; try { input.publish(previous?.preview || null, previous?.artifact || null); } catch { /* bounded */ } return this._projectionRejected(session); } session.moved = true; session.delta = delta; return { kind: 'accepted', preview: projected.value.preview, labels: accepted.labels, artifact: projected.value.artifact, }; } finish(input: { pointerId: number; currentSnapshotIdentity: string; validatePreview: (preview: TPreview) => boolean; }): ResizeFinishOutcome { const session = this._session; if (!session || session.pointerId !== input.pointerId) return { kind: 'no-op' }; const accepted = session.accepted; const wantedCommit = session.moved && Math.abs(session.delta) > 1e-9; let outcome: ResizeFinishOutcome; if (!wantedCommit || !accepted) { outcome = { kind: 'no-op' }; } else if (input.currentSnapshotIdentity !== session.snapshotIdentity) { outcome = { kind: 'rejected', reason: 'stale-snapshot' }; } else if (!validateSafeResize( session.rooms, session.openings, session.plan, session.delta, session.options, )) { outcome = { kind: 'rejected', reason: 'invalid-topology' }; } else if (checkWallRecordsPreserved( accepted.beforeWalls, accepted.afterWalls, { exactMultiplicity: true }, ).length) { outcome = { kind: 'rejected', reason: 'wall-records' }; } else { let valid = false; try { valid = input.validatePreview(accepted.preview); } catch { valid = false; } outcome = valid ? { kind: 'commit', preview: accepted.preview, before: session.before } : { kind: 'rejected', reason: 'invalid-candidate' }; } this._session = null; if (outcome.kind === 'commit') this._eligibility = null; return outcome; } cancel( currentSnapshotIdentity: string, pointerId?: number, ): ResizeCancelOutcome { const session = this._session; if (!session || (pointerId !== undefined && session.pointerId !== pointerId)) { return { kind: 'no-op' }; } this._session = null; this._eligibility = null; const snapshotMatches = currentSnapshotIdentity === session.snapshotIdentity; return { kind: 'cancelled', restoreWallUnion: snapshotMatches ? session.wallUnionBefore : null, restoreEpoch: snapshotMatches ? session.epochBefore : null, }; } reset(): void { this._selectedRoomId = null; this._session = null; this._eligibility = null; } private _projectionRejected( session: ResizeSession, ): ResizeMoveOutcome { const notify = !session.rejectionNotified; session.rejectionNotified = true; return { kind: 'rejected', notify }; } }