mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 20:29:00 +00:00
fix: prevent mixed-role walls during resize
Issue: #289 User-Visible: yes
This commit is contained in:
+1
-1
@@ -87,7 +87,7 @@
|
||||
"resize.disabled.diagonal": "This wall is angled; Resize supports only horizontal and vertical walls",
|
||||
"resize.disabled.side-angle": "This wall does not have two perpendicular side walls",
|
||||
"resize.disabled.duplicate-physical-wall": "An independent wall, unfinished outline, or column overlaps this wall",
|
||||
"resize.disabled.partial-shared": "The neighbouring room shares only part of this wall",
|
||||
"resize.disabled.partial-shared": "Only part of a shared wall cannot be moved",
|
||||
"resize.disabled.unequal-shared": "The neighbouring room has a different wall length or endpoints",
|
||||
"resize.disabled.multiple-rooms": "Moving this wall would affect more than two rooms",
|
||||
"resize.disabled.thickness-conflict": "The wall thickness cannot be preserved safely",
|
||||
|
||||
+1
-1
@@ -87,7 +87,7 @@
|
||||
"resize.disabled.diagonal": "Стена расположена под углом: Resize поддерживает только горизонтальные и вертикальные стены",
|
||||
"resize.disabled.side-angle": "У этой стены нет двух перпендикулярных примыкающих стен",
|
||||
"resize.disabled.duplicate-physical-wall": "Стену перекрывает перегородка, незавершённый контур или колонна",
|
||||
"resize.disabled.partial-shared": "Соседняя комната использует только часть этой стены",
|
||||
"resize.disabled.partial-shared": "Нельзя сдвинуть только часть общей стены",
|
||||
"resize.disabled.unequal-shared": "У соседней комнаты другая длина стены или другие конечные точки",
|
||||
"resize.disabled.multiple-rooms": "Перемещение затронуло бы больше двух комнат",
|
||||
"resize.disabled.thickness-conflict": "Толщину стены нельзя безопасно сохранить",
|
||||
|
||||
+210
-1
@@ -63,6 +63,38 @@ export interface SafeResizePlan extends EdgeDragPlan {
|
||||
edgeByRoom: Record<string, number>;
|
||||
topology: Record<string, number>;
|
||||
movingOpeningIds: string[];
|
||||
/** Prepared physical-owner profiles for the two side walls of every owner. */
|
||||
sideOwnership: SafeResizeSideOwnership[];
|
||||
}
|
||||
|
||||
export interface SafeResizeOwnershipInterval {
|
||||
roomId: string;
|
||||
edge: number;
|
||||
lo: number;
|
||||
hi: number;
|
||||
}
|
||||
|
||||
export interface SafeResizeOwnershipRun {
|
||||
lo: number;
|
||||
hi: number;
|
||||
owners: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* One side edge whose moving endpoint follows the selected wall. Intervals are
|
||||
* collected once from the immutable gesture snapshot; candidate validation
|
||||
* only adjusts intervals belonging to the one/two changed rooms.
|
||||
*/
|
||||
export interface SafeResizeSideOwnership {
|
||||
roomId: string;
|
||||
edge: number;
|
||||
movedEndpoint: 0 | 1;
|
||||
axis: Axis;
|
||||
line: number;
|
||||
fixed: number;
|
||||
moving: number;
|
||||
intervals: SafeResizeOwnershipInterval[];
|
||||
baseline: SafeResizeOwnershipRun[];
|
||||
}
|
||||
|
||||
export type SafeResizeResolution =
|
||||
@@ -624,6 +656,168 @@ function obstacleOverlaysMovingEdge(
|
||||
return collinearOverlapLength(a, b, obstacle.a, obstacle.b, eps) > eps;
|
||||
}
|
||||
|
||||
const axisCoordinate = (point: number[], axis: Axis): number => (
|
||||
axis === 'h' ? point[0] : point[1]
|
||||
);
|
||||
|
||||
const lineCoordinate = (point: number[], axis: Axis): number => (
|
||||
axis === 'h' ? point[1] : point[0]
|
||||
);
|
||||
|
||||
function ownershipIntervalsOnLine(
|
||||
rooms: RoomIn[], axis: Axis, line: number, eps: number,
|
||||
): SafeResizeOwnershipInterval[] {
|
||||
const intervals: SafeResizeOwnershipInterval[] = [];
|
||||
for (const room of rooms) {
|
||||
for (let edge = 0; edge < room.poly.length; edge++) {
|
||||
const a = room.poly[edge];
|
||||
const b = room.poly[(edge + 1) % room.poly.length];
|
||||
if (axisOf(a, b, eps) !== axis
|
||||
|| Math.abs(lineCoordinate(a, axis) - line) > eps
|
||||
|| Math.abs(lineCoordinate(b, axis) - line) > eps) continue;
|
||||
const ca = axisCoordinate(a, axis);
|
||||
const cb = axisCoordinate(b, axis);
|
||||
const lo = Math.min(ca, cb), hi = Math.max(ca, cb);
|
||||
if (hi - lo > eps) intervals.push({ roomId: room.id, edge, lo, hi });
|
||||
}
|
||||
}
|
||||
return intervals.sort((a, b) => a.lo - b.lo || a.hi - b.hi
|
||||
|| a.roomId.localeCompare(b.roomId) || a.edge - b.edge);
|
||||
}
|
||||
|
||||
function ownershipRuns(
|
||||
intervals: SafeResizeOwnershipInterval[], lo: number, hi: number, eps: number,
|
||||
): SafeResizeOwnershipRun[] {
|
||||
const breaks = [lo, hi];
|
||||
for (const interval of intervals) {
|
||||
const start = Math.max(lo, interval.lo);
|
||||
const end = Math.min(hi, interval.hi);
|
||||
if (end - start > eps) breaks.push(start, end);
|
||||
}
|
||||
const sorted = [...new Set(breaks)].sort((a, b) => a - b);
|
||||
const runs: SafeResizeOwnershipRun[] = [];
|
||||
for (let i = 0; i + 1 < sorted.length; i++) {
|
||||
const start = sorted[i], end = sorted[i + 1];
|
||||
if (end - start <= eps) continue;
|
||||
const mid = (start + end) / 2;
|
||||
const owners = [...new Set(intervals.filter((interval) => (
|
||||
mid > interval.lo - eps && mid < interval.hi + eps
|
||||
)).map((interval) => interval.roomId))].sort();
|
||||
runs.push({ lo: start, hi: end, owners });
|
||||
}
|
||||
return runs;
|
||||
}
|
||||
|
||||
function buildSideOwnership(
|
||||
rooms: RoomIn[], roomId: string, edge: number, movedEndpoint: 0 | 1, eps: number,
|
||||
): SafeResizeSideOwnership | null {
|
||||
const room = rooms.find((candidate) => candidate.id === roomId);
|
||||
if (!room) return null;
|
||||
const a = room.poly[edge];
|
||||
const b = room.poly[(edge + 1) % room.poly.length];
|
||||
const axis = axisOf(a, b, eps);
|
||||
if (!axis) return null;
|
||||
const fixedPoint = movedEndpoint === 0 ? b : a;
|
||||
const movingPoint = movedEndpoint === 0 ? a : b;
|
||||
const fixed = axisCoordinate(fixedPoint, axis);
|
||||
const moving = axisCoordinate(movingPoint, axis);
|
||||
const line = lineCoordinate(fixedPoint, axis);
|
||||
const intervals = ownershipIntervalsOnLine(rooms, axis, line, eps);
|
||||
const baseline = ownershipRuns(intervals, Math.min(fixed, moving), Math.max(fixed, moving), eps);
|
||||
if (!baseline.length || baseline.some((run) => run.owners.length < 1 || run.owners.length > 2)) {
|
||||
return null;
|
||||
}
|
||||
return { roomId, edge, movedEndpoint, axis, line, fixed, moving, intervals, baseline };
|
||||
}
|
||||
|
||||
function candidateOwnershipIntervals(
|
||||
profile: SafeResizeSideOwnership, result: EdgeDragResult, eps: number,
|
||||
): SafeResizeOwnershipInterval[] {
|
||||
const intervals: SafeResizeOwnershipInterval[] = [];
|
||||
for (const source of profile.intervals) {
|
||||
const poly = result.polys[source.roomId];
|
||||
if (!poly) {
|
||||
intervals.push(source);
|
||||
continue;
|
||||
}
|
||||
if (source.edge < 0 || source.edge >= poly.length) continue;
|
||||
const a = poly[source.edge];
|
||||
const b = poly[(source.edge + 1) % poly.length];
|
||||
if (axisOf(a, b, eps) !== profile.axis
|
||||
|| Math.abs(lineCoordinate(a, profile.axis) - profile.line) > eps
|
||||
|| Math.abs(lineCoordinate(b, profile.axis) - profile.line) > eps) continue;
|
||||
const ca = axisCoordinate(a, profile.axis);
|
||||
const cb = axisCoordinate(b, profile.axis);
|
||||
const lo = Math.min(ca, cb), hi = Math.max(ca, cb);
|
||||
if (hi - lo > eps) intervals.push({ ...source, lo, hi });
|
||||
}
|
||||
return intervals;
|
||||
}
|
||||
|
||||
function ownerCountAt(intervals: SafeResizeOwnershipInterval[], coordinate: number, eps: number): number {
|
||||
return new Set(intervals.filter((interval) => (
|
||||
coordinate > interval.lo - eps && coordinate < interval.hi + eps
|
||||
)).map((interval) => interval.roomId)).size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve the physical role (outer/shared) of every atomic side-wall run.
|
||||
* Extending a shared run into outer space, or shortening it so the neighbour
|
||||
* keeps an outer continuation, is the mixed-role corruption from #289.
|
||||
*/
|
||||
function sideOwnershipPreserved(
|
||||
result: EdgeDragResult, plan: SafeResizePlan, eps: number,
|
||||
): boolean {
|
||||
for (const profile of plan.sideOwnership) {
|
||||
const nextRoom = result.polys[profile.roomId];
|
||||
if (!nextRoom) return false;
|
||||
const a = nextRoom[profile.edge];
|
||||
const b = nextRoom[(profile.edge + 1) % nextRoom.length];
|
||||
if (axisOf(a, b, eps) !== profile.axis) return false;
|
||||
const nextMoving = axisCoordinate(profile.movedEndpoint === 0 ? a : b, profile.axis);
|
||||
const candidate = candidateOwnershipIntervals(profile, result, eps);
|
||||
const terminalRun = profile.moving >= profile.fixed
|
||||
? profile.baseline[profile.baseline.length - 1]
|
||||
: profile.baseline[0];
|
||||
const terminalRole = terminalRun.owners.length;
|
||||
if (terminalRole < 1 || terminalRole > 2) return false;
|
||||
|
||||
const lo = Math.min(profile.fixed, profile.moving, nextMoving);
|
||||
const hi = Math.max(profile.fixed, profile.moving, nextMoving);
|
||||
const breaks = [lo, hi, profile.fixed, profile.moving, nextMoving];
|
||||
for (const interval of profile.intervals) breaks.push(interval.lo, interval.hi);
|
||||
for (const interval of candidate) breaks.push(interval.lo, interval.hi);
|
||||
const sorted = [...new Set(breaks.filter((value) => value >= lo && value <= hi))]
|
||||
.sort((x, y) => x - y);
|
||||
const oldLo = Math.min(profile.fixed, profile.moving);
|
||||
const oldHi = Math.max(profile.fixed, profile.moving);
|
||||
const newLo = Math.min(profile.fixed, nextMoving);
|
||||
const newHi = Math.max(profile.fixed, nextMoving);
|
||||
for (let i = 0; i + 1 < sorted.length; i++) {
|
||||
const start = sorted[i], end = sorted[i + 1];
|
||||
if (end - start <= eps) continue;
|
||||
const mid = (start + end) / 2;
|
||||
const oldOwns = mid > oldLo - eps && mid < oldHi + eps;
|
||||
const newOwns = mid > newLo - eps && mid < newHi + eps;
|
||||
if (!oldOwns && !newOwns) continue;
|
||||
const oldRole = ownerCountAt(profile.intervals, mid, eps);
|
||||
const nextRole = ownerCountAt(candidate, mid, eps);
|
||||
if (oldRole > 2 || nextRole > 2) return false;
|
||||
if (oldOwns && newOwns) {
|
||||
if (oldRole !== nextRole) return false;
|
||||
} else if (!oldOwns && newOwns) {
|
||||
// Empty space may receive a new outer continuation. Existing physical
|
||||
// material, however, must keep its role: an outer wall cannot become
|
||||
// shared merely because this room grows onto it (and vice versa).
|
||||
if (nextRole !== terminalRole || (oldRole > 0 && oldRole !== nextRole)) return false;
|
||||
} else if (oldRole === 2 ? nextRole !== 2 : nextRole > 1) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Resolve eligibility once at gesture start. Partial shared stretches and a
|
||||
* third owner fail closed; the old vertex-insertion cascade is never planned. */
|
||||
export function resolveSafeResize(
|
||||
@@ -701,9 +895,20 @@ export function resolveSafeResize(
|
||||
const topology = Object.fromEntries(roomIds.map((id) => [
|
||||
id, rooms.find((candidate) => candidate.id === id)!.poly.length,
|
||||
]));
|
||||
const sideOwnership: SafeResizeSideOwnership[] = [];
|
||||
for (const id of roomIds) {
|
||||
const owner = rooms.find((candidate) => candidate.id === id)!;
|
||||
const ownerEdge = edgeByRoom[id];
|
||||
const prev = (ownerEdge - 1 + owner.poly.length) % owner.poly.length;
|
||||
const next = (ownerEdge + 1) % owner.poly.length;
|
||||
const before = buildSideOwnership(rooms, id, prev, 1, eps);
|
||||
const after = buildSideOwnership(rooms, id, next, 0, eps);
|
||||
if (!before || !after) return { enabled: false, reason: 'partial-shared' };
|
||||
sideOwnership.push(before, after);
|
||||
}
|
||||
const plan: SafeResizePlan = {
|
||||
roomId, edge, a: [...a], b: [...b], n: edgeNormal(room.poly, edge),
|
||||
roomIds, edgeByRoom, topology, movingOpeningIds,
|
||||
roomIds, edgeByRoom, topology, movingOpeningIds, sideOwnership,
|
||||
};
|
||||
if (!validateSafeResize(rooms, openings, plan, 0, opts)) {
|
||||
return { enabled: false, reason: 'invalid-geometry' };
|
||||
@@ -714,6 +919,9 @@ export function resolveSafeResize(
|
||||
if (!neighbours.some((delta) => validateSafeResize(
|
||||
rooms, openings, plan, delta, opts,
|
||||
))) {
|
||||
if (!neighbours.some((delta) => sideOwnershipPreserved(
|
||||
applySafeResize(rooms, openings, plan, delta), plan, eps,
|
||||
))) return { enabled: false, reason: 'partial-shared' };
|
||||
const withoutObstacles = { ...opts, obstacles: [] };
|
||||
if (neighbours.some((delta) => validateSafeResize(
|
||||
rooms, openings, plan, delta, withoutObstacles,
|
||||
@@ -796,6 +1004,7 @@ export function validateSafeResize(
|
||||
if (!Number.isFinite(d) || plan.roomIds.length < 1 || plan.roomIds.length > 2) return false;
|
||||
const result = applySafeResize(rooms, openings, plan, d);
|
||||
if (Object.keys(result.polys).length !== plan.roomIds.length) return false;
|
||||
if (!sideOwnershipPreserved(result, plan, eps)) return false;
|
||||
const changed = new Set(plan.roomIds);
|
||||
const polyOf = (room: RoomIn) => result.polys[room.id] || room.poly;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user