fix: keep editor writers at optimizer fixed point

Issue: #477
User-Visible: yes
This commit is contained in:
Matysh
2026-09-06 17:52:05 +00:00
committed by claude[bot]
parent 80ff541cef
commit a979699a57
58 changed files with 1924 additions and 637 deletions
+5
View File
@@ -256,6 +256,11 @@ export function alignAllToGrid(
// ---- decor ---------------------------------------------------------
for (const sh of sp.decor || []) {
// #383 deliberately made these transforms continuous. Their saved
// provenance no longer tells placement from resize, so Optimize must
// preserve the whole authored transform instead of moving a corner or
// changing the size behind the user's back (#477).
if (sh.kind === 'furniture' || sh.kind === 'image') continue;
total++;
let d = 0;
if (sh.kind === 'line') {
+14
View File
@@ -39,6 +39,13 @@ export interface CoincidentPartitionOptions {
coordScale: number;
/** Room creation consumes every coincident carrier, including duplicates. */
allowCoincidentPartitions?: boolean;
/**
* Optional writer-owned scope. Optimize and room acceptance omit it and
* retain their full-space behaviour; a completed wall chain supplies the
* surviving ids which came from that chain so unrelated partitions are not
* rewritten as a side effect of finishing the gesture (#477).
*/
partitionIds?: readonly string[];
}
interface AxisRange { lo: number; hi: number }
@@ -222,9 +229,16 @@ export function reconcileCoincidentPartitions(
let openings = openings0;
let partitionsReconciled = 0;
let openingsRehosted = 0;
// `undefined` means the full-space Optimize/room-acceptance sweep. An
// explicitly supplied empty list is still a bounded writer scope: none of
// its seeds survived, so it must not adopt unrelated historical debt.
const partitionIds = options.partitionIds
? new Set(options.partitionIds)
: null;
const modelById = new Map(model.partitions.map((partition) => [partition.id, partition]));
for (const rawPartition of [...partitions0].sort((a, b) => a.id.localeCompare(b.id))) {
if (partitionIds && !partitionIds.has(rawPartition.id)) continue;
if (!partitions.some((item) => item.id === rawPartition.id)) continue;
if (!rawPartitionKnown(rawPartition)) continue;
const source = modelById.get(rawPartition.id);
+87 -1
View File
@@ -10,6 +10,7 @@ import {
import { spacePhysicalGeometryFingerprint } from './plan-geometry-preflight';
import {
isSinglePartitionAppend, wallChainLiveCandidateSpace, wallChainLiveSeed,
type WallChainLiveSeed,
} from './draft-live-preflight';
const NORM_W = 1000;
@@ -34,7 +35,10 @@ export interface WallChainLiveCommitRuntime<
Violation,
> {
host: WallChainLiveHost<State, Geometry>;
_commitPhysicalGeometry: (name: string, before: State | null) => boolean;
_commitPhysicalGeometry: (
name: string, before: State | null,
additionalAuthoredPoints?: readonly (readonly number[])[], recordHistory?: boolean,
) => boolean;
_clearGeometryGesture: () => void;
_restoreGeometryStateInConfig: (config: ServerConfig, state: State) => boolean;
_restoreGeometryStateLocal: (state: State) => boolean;
@@ -48,6 +52,88 @@ export interface WallChainLiveCommitRuntime<
_saveConfig: () => void;
}
/**
* #477 finish transaction for a chain which has already been normalised on a
* clone. It uses the same production physical/junction proof as a terminal
* click, centred on the pre-normalisation seed. Unlike a click it records no
* extra history command: all segment commands already exist.
*/
export function commitWallChainFinishGeometry<
State extends { spaceId: string },
Geometry extends JunctionSharedGeometry,
Violation,
>(
runtime: WallChainLiveCommitRuntime<State, Geometry, Violation>,
name: string,
before: State | null,
seed: WallChainLiveSeed | null,
): boolean {
const { host } = runtime;
const liveCandidate = host._serverCfg;
const fallback = () => runtime._commitPhysicalGeometry(name, before, [], false);
if (!before || !liveCandidate || !seed
|| Number(liveCandidate.model_version || 0) !== WALL_SEGMENT_MODEL_VERSION) return fallback();
let committedCandidate: ServerConfig;
try {
committedCandidate = commitWallSegmentModel(liveCandidate).config;
} catch (error) {
runtime._clearGeometryGesture();
runtime._restoreGeometryStateLocal(before);
runtime._showWallModelMigrationBlocked(error);
return false;
}
const committedSpace = committedCandidate.spaces.find((space) => space.id === before.spaceId);
const previousConfig = JSON.parse(JSON.stringify(liveCandidate)) as ServerConfig;
if (!committedSpace || !runtime._restoreGeometryStateInConfig(previousConfig, before)) {
return fallback();
}
const previousSpace = previousConfig.spaces.find((space) => space.id === before.spaceId);
const candidateProjection = wallChainLiveCandidateSpace(committedSpace, seed);
const previousProjection = wallChainLiveCandidateSpace(previousSpace, seed);
if (!candidateProjection || !previousProjection) return fallback();
const localCandidate = {
...committedCandidate, spaces: [candidateProjection.space],
} as unknown as ServerConfig;
const localPrevious = {
...previousConfig, spaces: [previousProjection.space],
} as unknown as ServerConfig;
let geometry: Geometry | null = null;
let safe = false;
try {
const authoredPoints = host._path.length >= 2
? host._path.map((point) => [point[0] / NORM_W, point[1] / NORM_W]) : [];
safe = wallModelOffGridValueCount(committedSpace)
<= wallModelOffGridValueCount(before, authoredPoints)
&& host._checkSpacePhysicalGeometry(
localCandidate, before.spaceId, (value) => { geometry = value; },
).ok;
} catch {
safe = false;
}
if (!safe) return rejectUnsafe(runtime, before);
const introduced = runtime._junctionLimitsIntroduced(
localCandidate, localPrevious, before.spaceId, geometry, candidateProjection.roomIds,
);
if (introduced.length) {
runtime._clearGeometryGesture();
runtime._restoreGeometryStateLocal(before);
host._showToast(runtime._junctionLimitLabel(introduced[0]));
return false;
}
adoptWallSegmentModelCandidateInPlace(liveCandidate, committedCandidate);
const acceptedSpace = liveCandidate.spaces.find((space) => space.id === before.spaceId);
const pending = host._pendingPhysicalWrites.get(before.spaceId);
host._pendingPhysicalWrites.set(before.spaceId, {
before: pending?.before || before,
fingerprint: spacePhysicalGeometryFingerprint(acceptedSpace),
});
runtime._saveConfig();
return true;
}
const rejectUnsafe = <State extends { spaceId: string }, Geometry extends JunctionSharedGeometry,
Violation>(runtime: WallChainLiveCommitRuntime<State, Geometry, Violation>, before: State): false => {
runtime._clearGeometryGesture();
+5
View File
@@ -403,6 +403,7 @@ import {
reorderSpaceIds,
} from './space-order';
import { applyOpeningMoves, mergeCollinearPartitions, spaceMergeGeometry } from './wall-merge';
import type { MarkerRoomReferenceSnapshot } from './room-reference-transaction';
const CARD_VERSION = '1.73.0-beta.1';
const ENTRY_BUILD_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__';
@@ -562,6 +563,10 @@ interface SpaceGeometryState {
plan_x?: number; plan_y?: number; plan_scale?: number;
plan_scale_x?: number; plan_scale_y?: number; plan_angle?: number;
};
/** Session-only: current-writer normalisation scope for wall-chain history. */
wallChainSeedIds?: string[];
/** Session-only: exact room fields touched by Delete/Merge room. */
roomReferences?: MarkerRoomReferenceSnapshot[];
}
const fireEvent = (node: EventTarget, type: string, detail?: unknown) => {
const ev = new Event(type, { bubbles: true, composed: true }) as any;
+171 -16
View File
@@ -63,7 +63,10 @@ import {
type ResizeProjectionResult,
} from './resize-controller';
import { resizeLiveCandidateSpace, resizeLiveJunctionRoomIds } from './resize-live-preflight';
import { commitWallChainSegmentGeometry } from './draft-live-commit';
import {
commitWallChainFinishGeometry, commitWallChainSegmentGeometry,
} from './draft-live-commit';
import { wallChainLiveSeed } from './draft-live-preflight';
import {
placeResizeAreaLabel, resizeMeasuredEdges,
type ResizeAreaPlacement,
@@ -368,6 +371,11 @@ import {
} from './space-order';
import { applyOpeningMoves, mergeCollinearPartitions, spaceMergeGeometry } from './wall-merge';
import { reconcileCoincidentPartitions } from './coincident-partitions';
import { finalizeWallChainSpace } from './writer-fixed-point';
import {
captureMarkerRoomReferences, restoreMarkerRoomReferences,
rewriteMarkerRoomReferences, type MarkerRoomReferenceSnapshot,
} from './room-reference-transaction';
const CARD_VERSION = '1.73.0-beta.1';
@@ -717,6 +725,10 @@ interface SpaceGeometryState {
plan_x?: number; plan_y?: number; plan_scale?: number;
plan_scale_x?: number; plan_scale_y?: number; plan_angle?: number;
};
/** Session-only: current-writer normalisation scope for wall-chain history. */
wallChainSeedIds?: string[];
/** Session-only: exact room fields touched by Delete/Merge room. */
roomReferences?: MarkerRoomReferenceSnapshot[];
}
/** Tools of the decor (background) editor. `furniture` is the library
* (docs/FURNITURE.md): it opens a palette and places a symbol at real size. */
@@ -1469,10 +1481,64 @@ public _mergeSpacePartitions(sp: any, seedIds?: string[]): number {
coordScale: NORM_W, cellCm: this.host._cellCm, gridPitch: this.host._gridPitch,
});
return result.merged;
}
public _finalizeWallChainPartitions(seedIds: readonly string[]): boolean {
if (!seedIds.length) return true;
const sp = this.host._curSpaceCfg;
const model = this.host._spaceModel();
const before = this._geometrySnapshot();
if (!sp || !model || !before) return false;
const seed = seedIds.map((id) => wallChainLiveSeed(sp, id)).find(Boolean) || null;
const session = {
path: this.host._path.map((point) => [...point]),
chainId: this.host._activeWallChainId,
ids: [...this.host._activeWallChainPartitionIds],
cms: [...this.host._wallChainSegmentCms],
redo: this.host._wallChainRedo.map((item) => ({
...item, point: [...item.point],
})),
closingCm: this.host._closingWallCm,
};
let result;
try {
result = finalizeWallChainSpace(
sp, model, this.host._openCuts(), seedIds,
{
pitch: this.host._wallKeyPitch,
cellCm: this.host._cellCm,
gridPitch: this.host._gridPitch,
coordScale: NORM_W,
},
);
} catch {
this.host._showToast(this.host._t('toast.geometry_unsafe'));
return false;
}
if (!result.report.changed) return true;
const config = this.host._serverCfg;
const index = config?.spaces?.findIndex((space) => space.id === before.spaceId) ?? -1;
if (index < 0) return false;
config!.spaces[index] = result.space;
const committed = commitWallChainFinishGeometry(
this, this.host._t('history.wall_segment'), before, seed,
);
if (committed) return true;
// The common transaction clears gestures on refusal. A finish failure is
// fail-closed: keep the visible chain so the requested navigation can be
// retried after the underlying geometry is fixed.
this.host._path = session.path;
this.host._activeWallChainId = session.chainId;
this.host._activeWallChainPartitionIds = session.ids;
this.host._wallChainSegmentCms = session.cms;
this.host._wallChainRedo = session.redo;
this.host._closingWallCm = session.closingCm;
return false;
}
public _finishWallChain(): boolean {
if (this.host._tool !== 'draw' || this.host._wallFaceBatch || this.host._roomDialog) return true;
if (!this._finalizeWallChainPartitions(this.host._activeWallChainPartitionIds)) return false;
this.host._path = [];
this.host._activeWallChainId = null;
this.host._activeWallChainPartitionIds = [];
@@ -1852,6 +1918,15 @@ public _recordGeometry(name: string, before: SpaceGeometryState | null): void {
if (!before) return;
const after = this._geometrySnapshot(before.spaceId);
if (!after || JSON.stringify(before) === JSON.stringify(after)) return;
if (before.wallChainSeedIds) {
after.wallChainSeedIds = [...this.host._activeWallChainPartitionIds];
}
if (before.roomReferences) {
after.roomReferences = captureMarkerRoomReferences(
this.host._serverCfg?.markers || [],
before.roomReferences.map((reference) => reference.markerId),
);
}
this.host._geometryHistory.push({ name, before, after });
this.host.requestUpdate();
}
@@ -1893,9 +1968,53 @@ public _restoreGeometryStateInConfig(
for (const key of ['plan_x', 'plan_y', 'plan_scale', 'plan_scale_x', 'plan_scale_y', 'plan_angle'] as const)
delete (sp as any)[key];
Object.assign(sp, copy(state.plan_transform || {}));
if (state.roomReferences) {
config.markers ||= [];
restoreMarkerRoomReferences(config.markers, state.roomReferences);
}
return true;
}
public _canonicalWallChainHistoryState(state: SpaceGeometryState): SpaceGeometryState | null {
if (this.host._activeWallChainId || !state.wallChainSeedIds?.length) return state;
if (!this.host._serverCfg) return null;
const candidate = JSON.parse(JSON.stringify(this.host._serverCfg));
if (!this._restoreGeometryStateInConfig(candidate, state)) return null;
let committed: ServerConfig;
try { committed = commitWallSegmentModel(candidate).config; }
catch { return null; }
const raw = committed.spaces?.find((space) => space.id === state.spaceId);
const model = selectSpaceModelById(spaceModels(committed), state.spaceId);
if (!raw || !model) return null;
const result = finalizeWallChainSpace(
raw,
model,
geometryOpenCuts(raw, model, GRID_PITCH, NORM_W),
state.wallChainSeedIds,
{
pitch: GRID_STEP_N,
cellCm: Number(raw.cell_cm) > 0 ? Number(raw.cell_cm) : 5,
gridPitch: GRID_PITCH,
coordScale: NORM_W,
},
);
const index = committed.spaces.findIndex((space) => space.id === state.spaceId);
committed.spaces[index] = result.space;
// The finalizer can replace partitions with authoritative wall segments.
// Cross that model boundary once more before recording the history state,
// exactly as the production finish transaction does. Otherwise a Redo
// could restore the pre-commit carrier representation and Optimize would
// find work immediately afterwards.
let finalized: ServerConfig;
try { finalized = commitWallSegmentModel(committed).config; }
catch { return null; }
const normalized = this._geometrySnapshotFromConfig(finalized, state.spaceId);
if (!normalized) return null;
normalized.wallChainSeedIds = [...state.wallChainSeedIds];
if (state.roomReferences) normalized.roomReferences = JSON.parse(JSON.stringify(state.roomReferences));
return normalized;
}
public _restoreGeometryStateLocal(state: SpaceGeometryState): boolean {
if (!this._restoreGeometryStateInConfig(this.host._serverCfg, state)) return false;
this.host._cfgEpoch++;
@@ -2031,9 +2150,17 @@ public _commitPhysicalGeometry(
name: string,
before: SpaceGeometryState | null,
additionalAuthoredPoints: readonly (readonly number[])[] = [],
recordHistory = true,
): boolean {
if (!before || !this.host._serverCfg) return false;
const liveCandidate = this.host._serverCfg;
// The public guard already selects one space internally. Give it the
// bounded carrier as well so a writer finish cannot accidentally make a
// future preflight implementation inspect unrelated floors (#477/#461).
const physicalCandidateFor = (config: ServerConfig): ServerConfig => {
const space = config?.spaces?.find((item) => item.id === before.spaceId);
return space ? { ...config, spaces: [space] } : config;
};
const editedState = this._geometrySnapshotFromConfig(liveCandidate, before.spaceId);
const liveSpace = liveCandidate.spaces.find((space) => space.id === before.spaceId);
if (!liveSpace || spacePhysicalGeometryFingerprint(before)
@@ -2042,7 +2169,11 @@ public _commitPhysicalGeometry(
// repair/atomise a degraded legacy projection, but it must never be used to
// make an otherwise rejected user edit look safe.
let legacySafe = false;
try { legacySafe = this.host._checkSpacePhysicalGeometry(liveCandidate, before.spaceId).ok; }
try {
legacySafe = this.host._checkSpacePhysicalGeometry(
physicalCandidateFor(liveCandidate), before.spaceId,
).ok;
}
catch { legacySafe = false; }
if (!legacySafe) {
this._clearGeometryGesture();
@@ -2068,6 +2199,8 @@ public _commitPhysicalGeometry(
throw new WallSegmentModelError('invalid-room', before.spaceId);
const baseline = commitWallSegmentModel(baselineSource).config;
historyBefore = this._geometrySnapshotFromConfig(baseline, before.spaceId) || before;
historyBefore.wallChainSeedIds = before.wallChainSeedIds;
historyBefore.roomReferences = before.roomReferences;
const editedWithIdentity = JSON.parse(JSON.stringify(baseline));
if (!editedState || !this._restoreGeometryStateInConfig(
editedWithIdentity, editedState, true,
@@ -2112,7 +2245,9 @@ public _commitPhysicalGeometry(
authoredPoints.push(...additionalAuthoredPoints.map((point) => [point[0], point[1]]));
safe = wallModelOffGridValueCount(afterSpace)
<= wallModelOffGridValueCount(historyBefore, authoredPoints)
&& this.host._checkSpacePhysicalGeometry(committedCandidate, before.spaceId).ok;
&& this.host._checkSpacePhysicalGeometry(
physicalCandidateFor(committedCandidate), before.spaceId,
).ok;
} catch {
safe = false;
}
@@ -2137,7 +2272,7 @@ public _commitPhysicalGeometry(
return false;
}
adoptWallSegmentModelCandidateInPlace(liveCandidate, committedCandidate);
this._recordGeometry(name, historyBefore);
if (recordHistory) this._recordGeometry(name, historyBefore);
const afterSpace = liveCandidate.spaces.find((space) => space.id === before.spaceId);
const pending = this.host._pendingPhysicalWrites.get(before.spaceId);
this.host._pendingPhysicalWrites.set(before.spaceId, {
@@ -2222,17 +2357,18 @@ public _stagePointerCancel(ev: PointerEvent): void {
public _applyGeometryState(
state: SpaceGeometryState, allowHistoryBoundaryRepair = false,
): boolean {
if (!this.host._canCommitSpace(state.spaceId)) return false;
const before = this._geometrySnapshot(state.spaceId);
if (!before || !this._restoreGeometryStateLocal(state)) return false;
const target = this._canonicalWallChainHistoryState(state);
if (!target || !this.host._canCommitSpace(target.spaceId)) return false;
const before = this._geometrySnapshot(target.spaceId);
if (!before || !this._restoreGeometryStateLocal(target)) return false;
const restoredCandidate = this.host._serverCfg;
const physicalChanged = spacePhysicalGeometryFingerprint(before)
!== spacePhysicalGeometryFingerprint(state);
!== spacePhysicalGeometryFingerprint(target);
if (physicalChanged) {
let safe = false;
try {
const check = restoredCandidate
? this.host._checkSpacePhysicalGeometry(restoredCandidate, state.spaceId)
? this.host._checkSpacePhysicalGeometry(restoredCandidate, target.spaceId)
: null;
safe = !!check?.ok || !!(allowHistoryBoundaryRepair
&& check?.reason === 'wall-degraded-extra');
@@ -2255,7 +2391,7 @@ public _applyGeometryState(
let safe = false;
try {
const check = committedCandidate
? this.host._checkSpacePhysicalGeometry(committedCandidate, state.spaceId)
? this.host._checkSpacePhysicalGeometry(committedCandidate, target.spaceId)
: null;
// A history snapshot can predate the write-time wall degradation that
// canonicalized its command. Restore that one repairable baseline so
@@ -2271,16 +2407,16 @@ public _applyGeometryState(
return false;
}
adoptWallSegmentModelCandidateInPlace(restoredCandidate, committedCandidate);
const afterSpace = restoredCandidate?.spaces.find((space) => space.id === state.spaceId);
const pending = this.host._pendingPhysicalWrites.get(state.spaceId);
this.host._pendingPhysicalWrites.set(state.spaceId, {
const afterSpace = restoredCandidate?.spaces.find((space) => space.id === target.spaceId);
const pending = this.host._pendingPhysicalWrites.get(target.spaceId);
this.host._pendingPhysicalWrites.set(target.spaceId, {
before: pending?.before || before,
fingerprint: spacePhysicalGeometryFingerprint(afterSpace),
});
} else adoptWallSegmentModelCandidateInPlace(restoredCandidate, committedCandidate);
this._clearGeometryGesture();
if (this.host._space !== state.spaceId) {
this.host._commitSpace(state.spaceId);
if (this.host._space !== target.spaceId) {
this.host._commitSpace(target.spaceId);
this.host._saveNav();
this.host._restoreZoom();
}
@@ -2522,6 +2658,7 @@ public _markupClick(ev: MouseEvent): void {
const beforeCms = [...this.host._wallChainSegmentCms];
const beforeGraphSources = this._wallGraphSources([]);
const before = this._geometrySnapshot();
if (before) before.wallChainSeedIds = [...beforeIds];
const id = `partition-${crypto.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}`;
sp.partitions ||= [];
sp.partitions.push({
@@ -5122,7 +5259,9 @@ public _renderPlanSecondary(): EditorSecondaryModel | null {
${drawHint ? html`<span class="hint">${drawHint}</span>` : nothing}
${hintKey ? html`<span class="hint">${this.host._t(hintKey as any)}</span>` : nothing}
${this.host._tool === 'draw' && this.host._path.length
? html`<button class="btn ghost" @click=${() => this._runEditorContext(contextId, () => this._cancelPath())}>
? html`<button class="btn ghost" @click=${() => this._runEditorContext(contextId, () => {
if (this._finishWallChain()) this.host.requestUpdate();
})}>
${this.host._t('btn.reset')}
</button>` : nothing}`,
};
@@ -5511,6 +5650,10 @@ public _confirmRoomDelete = (keepWalls: boolean): void => {
return;
}
const before = this._geometrySnapshot();
const referenceRewrite = rewriteMarkerRoomReferences(
this.host._serverCfg?.markers || [], { kind: 'delete', roomId: dialog.roomId },
);
if (before && referenceRewrite.changed) before.roomReferences = referenceRewrite.before;
const materializedWalls = materializeWallIntervals(
space.rooms, this.host._spaceWalls, openCuts,
this.host._wallKeyPitch, this.host._cellCm, this.host._gridPitch, NORM_W,
@@ -5565,6 +5708,9 @@ public _confirmRoomDelete = (keepWalls: boolean): void => {
if (!sp.openings.length) delete sp.openings;
}
sp.rooms = sp.rooms.filter((r: any) => r.id !== room.id);
if (referenceRewrite.changed && this.host._serverCfg) {
this.host._serverCfg.markers = referenceRewrite.markers;
}
this.host._cfgEpoch++;
const normalized = this.host._normalizeWalls(materializedWalls, this.host._openCuts());
if (normalized.length) sp.walls = normalized;
@@ -6413,6 +6559,10 @@ public _commitMerge(): void {
const H = this.host._spaceH;
const keepId = d.pick === 'a' ? d.aId : d.bId;
const dropId = d.pick === 'a' ? d.bId : d.aId;
const referenceRewrite = rewriteMarkerRoomReferences(
this.host._serverCfg?.markers || [], { kind: 'merge', dropId, keepId },
);
if (before && referenceRewrite.changed) before.roomReferences = referenceRewrite.before;
const keep = sp.rooms.find((r: any) => r.id === keepId);
if (!keep) {
this.host._mergeDialog = null;
@@ -6422,6 +6572,9 @@ public _commitMerge(): void {
keep.poly = d.poly.map((p) => [p[0] / NORM_W, p[1] / H]);
delete keep.x; delete keep.y; delete keep.w; delete keep.h; // a merged room is never a rect
sp.rooms = sp.rooms.filter((r: any) => r.id !== dropId);
if (referenceRewrite.changed && this.host._serverCfg) {
this.host._serverCfg.markers = referenceRewrite.markers;
}
const committed = this._commitPhysicalGeometry(this.host._t('history.merge_rooms'), before);
this.host._mergeDialog = null;
this.host._regSignature = '';
@@ -6725,6 +6878,7 @@ public _applyWallFaceBatch(): void {
const effectiveActivePath = this._activePathWithRepair(batch.activePath, repairs[0]);
if (!accepted.length) {
if (!this._finalizeWallChainPartitions(batch.activePartitionIds)) return;
this.host._path = [];
this.host._activeWallChainId = null;
this.host._activeWallChainPartitionIds = [];
@@ -7562,6 +7716,7 @@ public _keepClosedAsPartitions = (): void => {
if (!this.host._contourClosed || this.host._pendingSplit || !this.host._curSpaceCfg) return;
// Every accepted edge is already an ordinary partition. Rejecting all
// detected faces only ends the session; it must not duplicate the chain.
if (!this._finalizeWallChainPartitions(this.host._activeWallChainPartitionIds)) return;
this.host._roomDialog = false;
this.host._path = [];
this.host._activeWallChainId = null;
+119
View File
@@ -0,0 +1,119 @@
/** Room-reference part of Delete/Merge room transactions (#477). */
import type { Marker } from './types';
export interface MarkerRoomReferenceSnapshot {
markerId: string;
roomIdPresent: boolean;
roomId?: Marker['room_id'];
vacuumPresent: boolean;
segmentMapPresent: boolean;
segmentMap?: Record<string, string>;
}
export interface RoomReferenceRewriteResult {
markers: Marker[];
before: MarkerRoomReferenceSnapshot[];
after: MarkerRoomReferenceSnapshot[];
changed: number;
}
const clone = <T>(value: T): T => (
value === undefined ? value : JSON.parse(JSON.stringify(value))
);
const own = (value: unknown, key: string): boolean => (
!!value && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, key)
);
export function captureMarkerRoomReferences(
markers: readonly Marker[], markerIds?: readonly string[],
): MarkerRoomReferenceSnapshot[] {
const wanted = markerIds ? new Set(markerIds) : null;
return (markers || [])
.filter((marker) => typeof marker?.id === 'string' && (!wanted || wanted.has(marker.id)))
.map((marker) => {
const vacuum = marker.vacuum;
return {
markerId: marker.id,
roomIdPresent: own(marker, 'room_id'),
...(own(marker, 'room_id') ? { roomId: clone(marker.room_id) } : {}),
vacuumPresent: own(marker, 'vacuum'),
segmentMapPresent: own(vacuum, 'segment_map'),
...(own(vacuum, 'segment_map')
? { segmentMap: clone(vacuum?.segment_map) }
: {}),
};
})
.sort((left, right) => left.markerId.localeCompare(right.markerId));
}
/** Restore only room-related fields; unrelated marker/vacuum edits survive. */
export function restoreMarkerRoomReferences(
markers: Marker[], snapshots: readonly MarkerRoomReferenceSnapshot[],
): void {
const byId = new Map((markers || []).map((marker) => [marker?.id, marker]));
for (const snapshot of snapshots) {
const marker = byId.get(snapshot.markerId);
if (!marker) continue;
if (snapshot.roomIdPresent) marker.room_id = clone(snapshot.roomId);
else delete marker.room_id;
if (snapshot.segmentMapPresent) {
if (!marker.vacuum || typeof marker.vacuum !== 'object' || Array.isArray(marker.vacuum)) {
marker.vacuum = {};
}
marker.vacuum.segment_map = clone(snapshot.segmentMap) || {};
continue;
}
if (marker.vacuum && typeof marker.vacuum === 'object' && !Array.isArray(marker.vacuum)) {
delete marker.vacuum.segment_map;
if (!snapshot.vacuumPresent && Object.keys(marker.vacuum).length === 0) delete marker.vacuum;
}
}
}
export function rewriteMarkerRoomReferences(
markersIn: readonly Marker[],
operation: { kind: 'delete'; roomId: string } | { kind: 'merge'; dropId: string; keepId: string },
): RoomReferenceRewriteResult {
const markers: Marker[] = clone([...(markersIn || [])]);
const touched = new Set<string>();
for (const marker of markers) {
if (typeof marker?.id !== 'string') continue;
let changed = false;
if (operation.kind === 'delete') {
if (marker.room_id === operation.roomId) {
delete marker.room_id;
changed = true;
}
} else if (marker.room_id === operation.dropId) {
marker.room_id = operation.keepId;
changed = true;
}
const segmentMap = marker.vacuum?.segment_map;
if (segmentMap && typeof segmentMap === 'object' && !Array.isArray(segmentMap)) {
for (const key of Object.keys(segmentMap)) {
if (operation.kind === 'delete' && segmentMap[key] === operation.roomId) {
delete segmentMap[key];
changed = true;
} else if (operation.kind === 'merge' && segmentMap[key] === operation.dropId) {
segmentMap[key] = operation.keepId;
changed = true;
}
}
if (operation.kind === 'delete' && Object.keys(segmentMap).length === 0) {
if (marker.vacuum) delete marker.vacuum.segment_map;
if (marker.vacuum && Object.keys(marker.vacuum).length === 0) delete marker.vacuum;
}
}
if (changed) touched.add(marker.id);
}
const ids = [...touched].sort((left, right) => left.localeCompare(right));
return {
markers,
before: captureMarkerRoomReferences(markersIn, ids),
after: captureMarkerRoomReferences(markers, ids),
changed: ids.length,
};
}
+141
View File
@@ -0,0 +1,141 @@
/**
* Lossless current-writer normalisation (#477).
*
* This module owns no UI and never mutates its inputs. A wall-chain finish
* can therefore build and validate the complete candidate before the live
* config, history or pending-write fingerprint sees any of it.
*/
import { reconcileCoincidentPartitions } from './coincident-partitions';
import type {
OpeningCfg, PartitionCfg, RoomCfg, SpaceModel, WallColumnCfg,
} from './types';
import type { WallEntry } from './wall-thickness';
import {
applyOpeningMoves, mergeCollinearPartitions, spaceMergeGeometry,
} from './wall-merge';
export interface WallChainFinalizerOptions {
pitch: number;
cellCm: number;
gridPitch: number;
coordScale: number;
}
export interface WallChainFinalizerReport {
partitionsMerged: number;
partitionsReconciled: number;
openingsRehosted: number;
changed: boolean;
}
export interface WriterFixedPointSpace extends Record<string, unknown> {
rooms?: RoomCfg[];
partitions?: PartitionCfg[];
openings?: OpeningCfg[];
walls?: WallEntry[];
wall_columns?: WallColumnCfg[];
}
export interface WallChainFinalizerResult {
space: WriterFixedPointSpace;
report: WallChainFinalizerReport;
}
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
/** Ids from the active chain after all deterministic merge remaps. */
function survivingSeedIds(
partitions: readonly PartitionCfg[],
seedIds: readonly string[],
moves: readonly { fromId: string; toId: string }[],
): string[] {
const present = new Set(partitions.map((partition) => partition.id));
const mapped = new Map(moves.map((move) => [move.fromId, move.toId]));
return [...new Set(seedIds
.map((id) => mapped.get(id) || id)
.filter((id) => present.has(id)))]
.sort((left, right) => left.localeCompare(right));
}
/**
* Canonicalise only the connected partition component touched by one finished
* wall chain. Full-space maintenance remains the responsibility of Optimize.
*/
export function finalizeWallChainSpace(
rawSpace: WriterFixedPointSpace,
model: Pick<SpaceModel, 'rooms' | 'partitions' | 'wall_columns'>,
openCuts: number[][],
seedIds: readonly string[],
options: WallChainFinalizerOptions,
): WallChainFinalizerResult {
const space = clone(rawSpace);
const seeds = [...new Set(seedIds.filter((id) => typeof id === 'string' && id))];
const emptyReport: WallChainFinalizerReport = {
partitionsMerged: 0,
partitionsReconciled: 0,
openingsRehosted: 0,
changed: false,
};
if (!seeds.length || !Array.isArray(space?.partitions)) {
return { space, report: emptyReport };
}
const before = JSON.stringify(space);
const merged = mergeCollinearPartitions(space.partitions as PartitionCfg[], {
pitch: options.pitch,
seedIds: seeds,
geometry: spaceMergeGeometry(space),
});
if (merged.merged) space.partitions = merged.partitions;
const mergedOpenings = (Array.isArray(space.openings) ? space.openings : []) as OpeningCfg[];
if (merged.merged) {
applyOpeningMoves(mergedOpenings, merged.partitions, merged.openingMoves, {
coordScale: options.coordScale,
cellCm: options.cellCm,
gridPitch: options.gridPitch,
});
}
const surviving = survivingSeedIds(merged.partitions, seeds, merged.openingMoves);
const mergedModel = {
rooms: model.rooms,
wall_columns: model.wall_columns,
partitions: merged.partitions.map((partition) => ({
...partition,
a: [partition.a[0] * options.coordScale, partition.a[1] * options.coordScale],
b: [partition.b[0] * options.coordScale, partition.b[1] * options.coordScale],
})),
};
const reconciled = reconcileCoincidentPartitions(
space,
mergedModel,
space.walls as WallEntry[] | undefined,
openCuts,
{
...options,
partitionIds: surviving,
},
);
// A no-op finish must be byte-equivalent: in particular it must not turn an
// authored empty array into an absent field merely because the shared
// reconciliation helper returns canonical empty collections. Adopt those
// collections only when the proof actually consumed a partition.
if (reconciled.partitionsReconciled) {
if (reconciled.walls.length) space.walls = reconciled.walls;
else delete space.walls;
if (reconciled.partitions.length) space.partitions = reconciled.partitions;
else delete space.partitions;
if (reconciled.openings.length) space.openings = reconciled.openings;
else delete space.openings;
}
return {
space,
report: {
partitionsMerged: merged.merged,
partitionsReconciled: reconciled.partitionsReconciled,
openingsRehosted: reconciled.openingsRehosted,
changed: JSON.stringify(space) !== before,
},
};
}