fix: reconcile outer walls before resize

Issue: #281
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-24 10:43:33 +03:00
parent a3e5f30a3c
commit 07bd1a6781
18 changed files with 538 additions and 58 deletions
+25 -19
View File
@@ -1,6 +1,6 @@
/**
* Lossless explicit-Optimize reconciliation for an independent partition that
* is exactly the same centred physical wall as one solid shared room edge.
* is exactly the same centred physical wall as one solid room edge.
* Runtime renderers never call this module and it never mutates its inputs.
*/
import {
@@ -180,13 +180,14 @@ export function reconcileCoincidentPartitions(
[b[0] / keyScale, b[1] / keyScale],
options.pitch,
);
const sharedByKey = new Map<string, WallInterval[]>();
const solidByKey = new Map<string, WallInterval[]>();
for (const interval of intervals) {
if (interval.kind !== 'shared' || interval.open || !(interval.cm > 0)) continue;
if ((interval.kind !== 'shared' && interval.kind !== 'outer')
|| interval.open || !(interval.cm > 0)) continue;
const key = segmentKey(interval.a, interval.b);
const bucket = sharedByKey.get(key);
const bucket = solidByKey.get(key);
if (bucket) bucket.push(interval);
else sharedByKey.set(key, [interval]);
else solidByKey.set(key, [interval]);
}
const modelById = new Map(model.partitions.map((partition) => [partition.id, partition]));
let walls = walls0;
@@ -204,21 +205,25 @@ export function reconcileCoincidentPartitions(
partition.b[0] - partition.a[0], partition.b[1] - partition.a[1],
) <= eps) continue;
const owners = (sharedByKey.get(segmentKey(partition.a, partition.b)) || [])
const owners = (solidByKey.get(segmentKey(partition.a, partition.b)) || [])
.filter((interval) => sameSegment(
interval.a, interval.b, partition.a, partition.b, eps,
));
if (owners.length !== 2) continue;
if (!owners.length) continue;
const byRoom = new Map<string, WallInterval>();
for (const owner of owners) byRoom.set(owner.roomId, owner);
if (byRoom.size !== 2) continue;
const shared = [...byRoom.values()];
if (shared.some((interval) => !sameSegment(
const solid = [...byRoom.values()];
const kinds = new Set(solid.map((interval) => interval.kind));
if (kinds.size !== 1) continue;
const kind = solid[0]?.kind;
if ((kind === 'shared' && byRoom.size !== 2)
|| (kind === 'outer' && byRoom.size !== 1)) continue;
if (solid.some((interval) => !sameSegment(
interval.a, interval.b, partition.a, partition.b, eps,
))) continue;
const sharedCms = new Set(shared.map((interval) => interval.cm));
if (sharedCms.size !== 1) continue;
const roomCm = shared[0].cm;
const solidCms = new Set(solid.map((interval) => interval.cm));
if (solidCms.size !== 1) continue;
const roomCm = solid[0].cm;
const anotherPartition = model.partitions.some((other) => (
other.id !== partition.id
@@ -244,7 +249,7 @@ export function reconcileCoincidentPartitions(
}));
if (resolvedHosted.some((item) => !item.resolved)) continue;
if (resolvedHosted.some((item) => !partitionOpeningHasCompositeRoomWall(
item.resolved!, shared, eps,
item.resolved!, solid, eps,
))) continue;
const finalCm = Math.max(roomCm, partition.cm);
@@ -282,7 +287,7 @@ export function reconcileCoincidentPartitions(
model.rooms, nextWalls, openCuts,
options.pitch, options.cellCm, options.gridPitch, options.coordScale,
);
const roomIds = new Set(shared.map((interval) => interval.roomId));
const roomIds = new Set(solid.map((interval) => interval.roomId));
const associationsOk = nextHosted.every((opening) => {
const association = resolveOpeningWallAssociation(index, {
x: opening.x * options.coordScale,
@@ -290,10 +295,11 @@ export function reconcileCoincidentPartitions(
angle: opening.angle,
length: opening.length * options.coordScale,
}, true);
return !!association.negative?.full && !!association.positive?.full
&& roomIds.has(association.negative.roomId)
&& roomIds.has(association.positive.roomId)
&& association.negative.roomId !== association.positive.roomId;
const full = [association.negative, association.positive]
.filter((side): side is NonNullable<typeof association.negative> => !!side?.full);
const associated = new Set(full.map((side) => side.roomId));
return full.length === roomIds.size && associated.size === roomIds.size
&& [...roomIds].every((roomId) => associated.has(roomId));
});
if (!associationsOk) continue;
+1
View File
@@ -8403,6 +8403,7 @@ class HouseplanCard extends LitElement {
return {
minDim: this._cmToUnits(MIN_ROOM_CM),
eps: this._gridPitch * 0.05,
step: this._gridPitch,
movingHalf: cm > 0 ? wallCmToUnits(cm, this._cellCm, this._gridPitch) / 2 : 0,
obstacles: this._rszObstacles(),
thicknessConflict: exactCms.size > 1,
+18
View File
@@ -46,6 +46,8 @@ export type SafeResizeObstacle =
| { kind: 'circle'; center: number[]; radius: number };
export interface SafeResizeOptions extends StopOpts {
/** One snapped editor step; eligibility must permit a non-zero neighbour. */
step?: number;
/** Physical half-depth of the moving wall in render units. */
movingHalf?: number;
/** Independent walls, drafts and columns. They are immutable hard stops. */
@@ -706,6 +708,22 @@ export function resolveSafeResize(
if (!validateSafeResize(rooms, openings, plan, 0, opts)) {
return { enabled: false, reason: 'invalid-geometry' };
}
const step = Math.abs(Number(opts.step));
if (Number.isFinite(step) && step > eps) {
const neighbours = [-step, step];
if (!neighbours.some((delta) => validateSafeResize(
rooms, openings, plan, delta, opts,
))) {
const withoutObstacles = { ...opts, obstacles: [] };
if (neighbours.some((delta) => validateSafeResize(
rooms, openings, plan, delta, withoutObstacles,
))) return { enabled: false, reason: 'duplicate-physical-wall' };
if (openings.length && neighbours.some((delta) => validateSafeResize(
rooms, [], plan, delta, opts,
))) return { enabled: false, reason: 'opening-conflict' };
return { enabled: false, reason: 'invalid-geometry' };
}
}
return { enabled: true, plan };
}