mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-06 22:49:16 +00:00
fix: keep editor writers at optimizer fixed point
Issue: #477 User-Visible: yes
This commit is contained in:
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user