mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix: preserve wall ownership through compaction
Issue: #299 User-Visible: yes
This commit is contained in:
@@ -570,6 +570,14 @@ reuses ordinary opening projection and wall/open-span rekeying. Unique physical
|
|||||||
count, maximum centimetres and skipped candidates stay separate from ordinary
|
count, maximum centimetres and skipped candidates stay separate from ordinary
|
||||||
grid movement; no load/save migration invokes this repair.
|
grid movement; no load/save migration invokes this repair.
|
||||||
|
|
||||||
|
`normalizeWallIntervals()` compacts atomic real-wall intervals only when both
|
||||||
|
their centimetre thickness and ownership signature match (#299). The signature
|
||||||
|
is `outer(A)` or the stable sorted pair `shared(A,B)`; an outer/shared transition,
|
||||||
|
a change of shared pair, or ambiguous multi-owner geometry is a hard breakpoint.
|
||||||
|
Explicit Optimize and the room-deletion transaction call this same normalizer,
|
||||||
|
so neither path can create one saved record whose physical role changes halfway
|
||||||
|
through its exact span. Ambiguous ownership fails closed per atom.
|
||||||
|
|
||||||
All physical-geometry writers share the same transaction boundary (#278).
|
All physical-geometry writers share the same transaction boundary (#278).
|
||||||
`checkSpacePhysicalGeometry()` validates the exact candidate through canonical
|
`checkSpacePhysicalGeometry()` validates the exact candidate through canonical
|
||||||
wall and floor builders before history or save. A failed or degraded candidate
|
wall and floor builders before history or save. A failed or degraded candidate
|
||||||
|
|||||||
+3
-1
@@ -497,7 +497,9 @@ orchestrator. It converts only legacy fields with an exact lossless
|
|||||||
mapping, materialises legacy `open_to`, calls the grid projection,
|
mapping, materialises legacy `open_to`, calls the grid projection,
|
||||||
rekeys exact wall/open-span endpoints onto the moved rooms, merges
|
rekeys exact wall/open-span endpoints onto the moved rooms, merges
|
||||||
touching virtual spans per room pair, compacts consecutive real-wall
|
touching virtual spans per room pair, compacts consecutive real-wall
|
||||||
intervals of equal thickness and stamps `model_version`. Unknown fields
|
intervals only when their thickness and physical ownership both match, and
|
||||||
|
stamps `model_version`. Outer/shared transitions and changes of shared-room
|
||||||
|
pair remain exact breakpoints even at equal thickness. Unknown fields
|
||||||
are preserved and every pass is idempotent.
|
are preserved and every pass is idempotent.
|
||||||
|
|
||||||
The explicit pass also repairs pre-existing near-axis room walls, saved wall
|
The explicit pass also repairs pre-existing near-axis room walls, saved wall
|
||||||
|
|||||||
@@ -22,6 +22,11 @@
|
|||||||
keep both areas visible with leader lines, without covering each other or the
|
keep both areas visible with leader lines, without covering each other or the
|
||||||
room-settings button
|
room-settings button
|
||||||
([#300](https://github.com/Matysh/houseplan-card/issues/300)).
|
([#300](https://github.com/Matysh/houseplan-card/issues/300)).
|
||||||
|
- “Optimize plans” and room deletion no longer combine an equal wall thickness
|
||||||
|
across a shared-to-outer boundary or between different pairs of rooms. The
|
||||||
|
saved wall profile keeps each physical role at its exact breakpoint, avoiding
|
||||||
|
a record that changes meaning halfway through its span
|
||||||
|
([#299](https://github.com/Matysh/houseplan-card/issues/299)).
|
||||||
- While drawing Walls, `Esc` now finishes all accepted segments as independent
|
- While drawing Walls, `Esc` now finishes all accepted segments as independent
|
||||||
walls and releases the last point without deleting geometry or leaving the
|
walls and releases the last point without deleting geometry or leaving the
|
||||||
tool. The next click starts a new chain; `Ctrl/Cmd+Z` remains the shortcut
|
tool. The next click starts a new chain; `Ctrl/Cmd+Z` remains the shortcut
|
||||||
|
|||||||
@@ -27,6 +27,11 @@
|
|||||||
затронутой комнаты с её стороны. Для общих и узких комнат обе площади остаются
|
затронутой комнаты с её стороны. Для общих и узких комнат обе площади остаются
|
||||||
видимыми с выносными линиями и не перекрывают друг друга или кнопку настроек
|
видимыми с выносными линиями и не перекрывают друг друга или кнопку настроек
|
||||||
комнаты ([#300](https://github.com/Matysh/houseplan-card/issues/300)).
|
комнаты ([#300](https://github.com/Matysh/houseplan-card/issues/300)).
|
||||||
|
- «Оптимизировать планы» и удаление комнаты больше не склеивают одинаковую
|
||||||
|
толщину через границу общей и наружной стены или между разными парами комнат.
|
||||||
|
Профиль стены сохраняет каждую физическую роль до её точной границы, поэтому
|
||||||
|
одна запись больше не меняет смысл посередине своего участка
|
||||||
|
([#299](https://github.com/Matysh/houseplan-card/issues/299)).
|
||||||
- При рисовании стен `Esc` теперь завершает все принятые отрезки как
|
- При рисовании стен `Esc` теперь завершает все принятые отрезки как
|
||||||
независимые стены и отцепляется от последней точки, не удаляя геометрию и не
|
независимые стены и отцепляется от последней точки, не удаляя геометрию и не
|
||||||
покидая инструмент. Следующий клик начинает новую цепочку, а `Ctrl/Cmd+Z`
|
покидая инструмент. Следующий клик начинает новую цепочку, а `Ctrl/Cmd+Z`
|
||||||
|
|||||||
@@ -2054,6 +2054,14 @@ require hands on real hardware — they remain for the human pass.
|
|||||||
become partly shared and partly outer
|
become partly shared and partly outer
|
||||||
[unit: resize.test + fixture 289-mixed-role-resize; auto:
|
[unit: resize.test + fixture 289-mixed-role-resize; auto:
|
||||||
smoke_room_resize; mutation: safe-resize-side-ownership-bypassed]
|
smoke_room_resize; mutation: safe-resize-side-ownership-bypassed]
|
||||||
|
- [ ] Wall compaction preserves physical ownership (#299): equal thickness on
|
||||||
|
`shared(A,B) -> outer(A)` and `shared(A,B) -> shared(A,C)` remains split
|
||||||
|
at the exact role breakpoint, while equal neighbouring atoms inside one
|
||||||
|
role still compact. Optimize on `real-plan-first-floor.json` is immutable,
|
||||||
|
invariant-clean and idempotent; real-plan edit-walk seeds 1 and 3 exercise
|
||||||
|
Optimize and Delete-room/Keep-walls without producing a mixed-role record
|
||||||
|
[unit: wall-thickness + plan-optimizer; auto: smoke_edit_walk seeds 1/3;
|
||||||
|
mutation: wall-compaction-owner-role-bypassed]
|
||||||
- [ ] Live badges while dragging: lengths of the dragged wall + both
|
- [ ] Live badges while dragging: lengths of the dragged wall + both
|
||||||
adjacent walls, and the m² area at the room centre; dragging a shared
|
adjacent walls, and the m² area at the room centre; dragging a shared
|
||||||
wall shows BOTH areas; all numbers update continuously
|
wall shows BOTH areas; all numbers update continuously
|
||||||
|
|||||||
+5
-2
@@ -332,7 +332,7 @@ Other operations edit existing geometry:
|
|||||||
| Split | Cuts a room from one wall to another; the larger part keeps the original room |
|
| Split | Cuts a room from one wall to another; the larger part keeps the original room |
|
||||||
| Resize | Moves one eligible horizontal/vertical wall without changing room topology. Live labels report the two changing **inner** side-wall dimensions, highlight those walls, and place each affected room's area beside its side of the moving wall |
|
| Resize | Moves one eligible horizontal/vertical wall without changing room topology. Live labels report the two changing **inner** side-wall dimensions, highlight those walls, and place each affected room's area beside its side of the moving wall |
|
||||||
| Thickness | Changes one physical span or every wall of a room |
|
| Thickness | Changes one physical span or every wall of a room |
|
||||||
| Delete room | Deletes only the selected room after confirmation |
|
| Delete room | Deletes the room after choosing whether its exclusive physical walls remain; shared walls always remain |
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -687,7 +687,10 @@ pull intentional off-grid or diagonal geometry to a node. Current ordinary
|
|||||||
edits apply the same invisible boundary automatically, so the noise cannot
|
edits apply the same invisible boundary automatically, so the noise cannot
|
||||||
return after a later room, opening, decor or marker-position save.
|
return after a later room, opening, decor or marker-position save.
|
||||||
|
|
||||||
Equal neighbouring wall-thickness fragments are compacted. Optimize may also
|
Equal neighbouring wall-thickness fragments are compacted only while they have
|
||||||
|
the same physical role: one outer room or the same pair of shared rooms. A
|
||||||
|
shared-to-outer transition or a change of shared-room pair stays as an exact
|
||||||
|
breakpoint even when the thickness is equal. Optimize may also
|
||||||
remove a different-thickness fragment shorter than half a grid step when equal
|
remove a different-thickness fragment shorter than half a grid step when equal
|
||||||
pieces of the same straight wall prove the replacement. This includes a
|
pieces of the same straight wall prove the replacement. This includes a
|
||||||
fragment touching exactly one room T-junction: the junction and perpendicular
|
fragment touching exactly one room T-junction: the junction and perpendicular
|
||||||
|
|||||||
@@ -447,7 +447,9 @@ Undo оптимизации.
|
|||||||
толщиной, а их двери/окна перепривязываются к ним. Виртуальные и нулевые участки
|
толщиной, а их двери/окна перепривязываются к ним. Виртуальные и нулевые участки
|
||||||
не превращаются в кладку. При варианте **«Удалить комнату и стены»** удаляются
|
не превращаются в кладку. При варианте **«Удалить комнату и стены»** удаляются
|
||||||
только проёмы, принадлежавшие эксклюзивной стене этой комнаты; общая стена,
|
только проёмы, принадлежавшие эксклюзивной стене этой комнаты; общая стена,
|
||||||
явная совпадающая перегородка и их проёмы сохраняются.
|
явная совпадающая перегородка и их проёмы сохраняются. Оставшиеся записи
|
||||||
|
толщины нормализуются уже по новому составу комнат: одинаковая толщина не
|
||||||
|
склеивается через переход «общая ↔ наружная» или между разными парами комнат.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -1427,7 +1429,7 @@ show_signal: true
|
|||||||
| Декор и мебель | Положение и размеры округляются к сетке |
|
| Декор и мебель | Положение и размеры округляются к сетке |
|
||||||
| Устройства и подписи комнат | Позиции округляются к сетке |
|
| Устройства и подписи комнат | Позиции округляются к сетке |
|
||||||
| Проёмы | Возвращаются на ближайшую стену, смещение вдоль стены округляется, угол исправляется |
|
| Проёмы | Возвращаются на ближайшую стену, смещение вдоль стены округляется, угол исправляется |
|
||||||
| Стены | Одинаковые соседние участки толщины объединяются. Изолированный участок другой толщины короче половины шага сетки также схлопывается, если с обеих сторон находятся участки одной толщины той же прямой стены. Допустим один T-узел комнаты: он и перпендикулярная стена не двигаются. Два топологических узла или граница проёма защищают участок |
|
| Стены | Одинаковые соседние участки толщины объединяются только в одной физической роли: наружная стена одной комнаты либо общая стена той же пары комнат. Переход «общая ↔ наружная» и смена пары комнат остаются точными границами даже при одинаковой толщине. Изолированный участок другой толщины короче половины шага сетки также схлопывается, если с обеих сторон находятся участки одной толщины той же прямой стены. Допустим один T-узел комнаты: он и перпендикулярная стена не двигаются. Два топологических узла или граница проёма защищают участок |
|
||||||
| Перегородки и сохранённые цепочки | Коллинеарные соседние отрезки одинаковой толщины сращиваются; топологический узел сохраняется. Каждый участок отдельной стены, точно покрытый одной или несколькими соседними сплошными стенами комнат, поглощается ими, а недоказанные остатки и их проёмы сохраняются. Проёмы на поглощённой части перепривязываются без сдвига и потери датчиков; сохраняется большая исходная толщина. Незавершённая цепочка удаляется только целиком и только если каждый её сегмент полностью избыточен; свободная, частично покрытая или более толстая цепочка не меняется |
|
| Перегородки и сохранённые цепочки | Коллинеарные соседние отрезки одинаковой толщины сращиваются; топологический узел сохраняется. Каждый участок отдельной стены, точно покрытый одной или несколькими соседними сплошными стенами комнат, поглощается ими, а недоказанные остатки и их проёмы сохраняются. Проёмы на поглощённой части перепривязываются без сдвига и потери датчиков; сохраняется большая исходная толщина. Незавершённая цепочка удаляется только целиком и только если каждый её сегмент полностью избыточен; свободная, частично покрытая или более толстая цепочка не меняется |
|
||||||
| Виртуальные стены | Соседние/перекрывающиеся участки объединяются и приводятся к общей границе |
|
| Виртуальные стены | Соседние/перекрывающиеся участки объединяются и приводятся к общей границе |
|
||||||
| Ссылки устройств | Точная подпись независимого импорта восстанавливает пространство, комнату и позицию. Иначе реальное устройство следует однозначной Area HA либо теряет только мёртвую привязку; настройки маркера сохраняются |
|
| Ссылки устройств | Точная подпись независимого импорта восстанавливает пространство, комнату и позицию. Иначе реальное устройство следует однозначной Area HA либо теряет только мёртвую привязку; настройки маркера сохраняются |
|
||||||
|
|||||||
+13
-3
@@ -43,8 +43,10 @@ grades a different stored key as an observation, not a violation: valid exact
|
|||||||
endpoints now prove that the record is resolvable even when its compatibility
|
endpoints now prove that the record is resolvable even when its compatibility
|
||||||
key is old or unparsable. Exact endpoints make a thickness boundary independent of whichever
|
key is old or unparsable. Exact endpoints make a thickness boundary independent of whichever
|
||||||
room topology later happens to split the same straight line. Normalisation
|
room topology later happens to split the same straight line. Normalisation
|
||||||
merges consecutive solid pieces into each maximal run of equal thickness; a
|
merges consecutive solid pieces only inside a maximal run of equal thickness
|
||||||
different thickness or a virtual gap remains a real break. Likewise,
|
with the same physical ownership: one outer room or the same sorted pair of
|
||||||
|
shared rooms. A different thickness, virtual gap, outer/shared transition or
|
||||||
|
change of shared-room pair remains a real break. Likewise,
|
||||||
touching/overlapping `open_spans` of the same room pair are stored as one span;
|
touching/overlapping `open_spans` of the same room pair are stored as one span;
|
||||||
pair ownership remains a hard boundary so Split can derive exact `open_to` links.
|
pair ownership remains a hard boundary so Split can derive exact `open_to` links.
|
||||||
When a maximal wall run crosses a collinear vertex belonging to another room,
|
When a maximal wall run crosses a collinear vertex belonging to another room,
|
||||||
@@ -324,7 +326,10 @@ gaps and zero-thickness edges are not materialised. **Delete walls** removes the
|
|||||||
room without that conversion and cascades only openings owned by its exclusive
|
room without that conversion and cascades only openings owned by its exclusive
|
||||||
walls. Shared masonry, existing partitions, partition-hosted openings and their
|
walls. Shared masonry, existing partitions, partition-hosted openings and their
|
||||||
physical thickness remain intact in both cases. The room, wall profile,
|
physical thickness remain intact in both cases. The room, wall profile,
|
||||||
partitions and openings are one Undo/Redo and persistence transaction.
|
partitions and openings are one Undo/Redo and persistence transaction. The
|
||||||
|
remaining wall profile is normalised against its post-delete ownership, so an
|
||||||
|
equal thickness cannot be compacted across an outer/shared boundary or across
|
||||||
|
two different shared-room pairs.
|
||||||
|
|
||||||
## 7. Out of scope
|
## 7. Out of scope
|
||||||
|
|
||||||
@@ -354,6 +359,11 @@ the real `349 / 120 / 5` short-ray handoff to a 20 cm shared wall at
|
|||||||
`cell_cm: 1/5/30`, reversed endpoints and permuted input (#288);
|
`cell_cm: 1/5/30`, reversed endpoints and permuted input (#288);
|
||||||
exact parent-run thickness inherited by atomic children when
|
exact parent-run thickness inherited by atomic children when
|
||||||
closing a virtual neighbour, without partial-span leakage (#201).
|
closing a virtual neighbour, without partial-span leakage (#201).
|
||||||
|
Role-aware compaction tests keep `shared(A,B)`, `outer(A)` and `shared(A,C)` as
|
||||||
|
separate records even at equal thickness, while equal neighbouring atoms within
|
||||||
|
one role still compact; the real first-floor fixture proves explicit Optimize
|
||||||
|
is invariant-free and idempotent (#299). The edit-walk real-plan seeds 1 and 3
|
||||||
|
exercise the same Optimize and Keep-walls entry points.
|
||||||
Browser: seamless frame; fill not in hatch; m² drops with thickness; partial
|
Browser: seamless frame; fill not in hatch; m² drops with thickness; partial
|
||||||
shared walls, mixed-thickness shared walls and walls containing a partial
|
shared walls, mixed-thickness shared walls and walls containing a partial
|
||||||
virtual stretch keep a visible disabled Resize handle and cannot split or
|
virtual stretch keep a visible disabled Resize handle and cannot split or
|
||||||
|
|||||||
@@ -1299,6 +1299,19 @@ export const MUTANTS = [
|
|||||||
+ ' const eps = Math.max(pitch * scale * 0.02, 1e-9);',
|
+ ' const eps = Math.max(pitch * scale * 0.02, 1e-9);',
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'wall-compaction-owner-role-bypassed',
|
||||||
|
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||||
|
+ '&& node --test --test-name-pattern="#299" test/wall-thickness.test.mjs',
|
||||||
|
because: 'equal centimetres must not merge shared(A,B) with outer(A) or shared(A,C); '
|
||||||
|
+ 'dropping the owner signature recreates the mixed-role wall record from #299',
|
||||||
|
patches: [{
|
||||||
|
file: 'src/wall-thickness.ts',
|
||||||
|
find: ' if (pr.kinds[next] === null || pr.cms[next] !== cm\n'
|
||||||
|
+ ' || ownerSignatureFor(nextKey) !== ownerSignature) break;',
|
||||||
|
replace: ' if (pr.kinds[next] === null || pr.cms[next] !== cm) break;',
|
||||||
|
}],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'optimizer-single-topology-island-blocked',
|
id: 'optimizer-single-topology-island-blocked',
|
||||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||||
|
|||||||
+39
-9
@@ -2142,18 +2142,41 @@ export function normalizeWallIntervals(
|
|||||||
coordScale = 1,
|
coordScale = 1,
|
||||||
): WallEntry[] {
|
): WallEntry[] {
|
||||||
if (!walls?.length) return [];
|
if (!walls?.length) return [];
|
||||||
const atomic: WallInterval[] = [];
|
type OwnedInterval = WallInterval & { ownerSignature: string };
|
||||||
|
const resolved = wallIntervals(
|
||||||
|
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||||||
|
);
|
||||||
|
const ownersByKey = new Map<string, Set<string>>();
|
||||||
|
for (const interval of resolved) {
|
||||||
|
if (interval.open || !interval.kind || !interval.roomId) continue;
|
||||||
|
const owners = ownersByKey.get(interval.key) || new Set<string>();
|
||||||
|
owners.add(interval.roomId);
|
||||||
|
ownersByKey.set(interval.key, owners);
|
||||||
|
}
|
||||||
|
const ownerSignatureFor = (key: string): string => {
|
||||||
|
const owners = [...(ownersByKey.get(key) || [])].sort();
|
||||||
|
// A physical wall has one outer owner or two shared owners. Invalid
|
||||||
|
// multi-owner geometry is preserved fail-closed, one atom at a time: it
|
||||||
|
// must not become the bridge which compacts two otherwise separate roles.
|
||||||
|
if (owners.length !== 1 && owners.length !== 2) return `ambiguous:${key}`;
|
||||||
|
return `${owners.length === 1 ? 'outer' : 'shared'}:${owners.join('|')}`;
|
||||||
|
};
|
||||||
|
const atomic: OwnedInterval[] = [];
|
||||||
const atomicKeys = new Set<string>();
|
const atomicKeys = new Set<string>();
|
||||||
for (const iv of wallIntervals(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale)) {
|
for (const iv of resolved) {
|
||||||
if (iv.open || !(iv.cm > 0) || atomicKeys.has(iv.key)) continue;
|
if (iv.open || !(iv.cm > 0) || atomicKeys.has(iv.key)) continue;
|
||||||
atomicKeys.add(iv.key);
|
atomicKeys.add(iv.key);
|
||||||
atomic.push(iv);
|
atomic.push({ ...iv, ownerSignature: ownerSignatureFor(iv.key) });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compact every maximal solid run of one thickness. This still restores one
|
// Compact every maximal solid run of one thickness AND one physical owner
|
||||||
// whole-edge entry when all children agree, but retains an exact breakpoint
|
// role. Equal centimetres cannot bridge shared(A,B) to outer(A), nor one
|
||||||
// when neighbouring real intervals intentionally have different thicknesses.
|
// shared pair to another: that creates a record whose thickness changes
|
||||||
const parents: Array<{ a: number[]; b: number[]; key: string; cm: number; len: number }> = [];
|
// meaning halfway through its own span (#299).
|
||||||
|
const parents: Array<{
|
||||||
|
a: number[]; b: number[]; key: string; cm: number; len: number;
|
||||||
|
ownerSignature: string;
|
||||||
|
}> = [];
|
||||||
for (const room of rooms || []) {
|
for (const room of rooms || []) {
|
||||||
if (!room?.id) continue;
|
if (!room?.id) continue;
|
||||||
const pr = roomWallProfile(rooms, room.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
|
const pr = roomWallProfile(rooms, room.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
|
||||||
@@ -2168,10 +2191,16 @@ export function normalizeWallIntervals(
|
|||||||
const first = children[at];
|
const first = children[at];
|
||||||
const cm = pr.cms[first];
|
const cm = pr.cms[first];
|
||||||
if (!(cm > 0) || pr.kinds[first] === null) { at++; continue; }
|
if (!(cm > 0) || pr.kinds[first] === null) { at++; continue; }
|
||||||
|
const firstKey = keyOf(pr.poly[first], pr.poly[(first + 1) % pr.poly.length], pitch, coordScale);
|
||||||
|
const ownerSignature = ownerSignatureFor(firstKey);
|
||||||
let end = at;
|
let end = at;
|
||||||
while (end + 1 < children.length) {
|
while (end + 1 < children.length) {
|
||||||
const next = children[end + 1];
|
const next = children[end + 1];
|
||||||
if (pr.kinds[next] === null || pr.cms[next] !== cm) break;
|
const nextKey = keyOf(
|
||||||
|
pr.poly[next], pr.poly[(next + 1) % pr.poly.length], pitch, coordScale,
|
||||||
|
);
|
||||||
|
if (pr.kinds[next] === null || pr.cms[next] !== cm
|
||||||
|
|| ownerSignatureFor(nextKey) !== ownerSignature) break;
|
||||||
end++;
|
end++;
|
||||||
}
|
}
|
||||||
const last = children[end];
|
const last = children[end];
|
||||||
@@ -2179,7 +2208,7 @@ export function normalizeWallIntervals(
|
|||||||
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||||||
if (len > 0) parents.push({
|
if (len > 0) parents.push({
|
||||||
a: [a[0], a[1]], b: [b[0], b[1]],
|
a: [a[0], a[1]], b: [b[0], b[1]],
|
||||||
key: keyOf(a, b, pitch, coordScale), cm, len,
|
key: keyOf(a, b, pitch, coordScale), cm, len, ownerSignature,
|
||||||
});
|
});
|
||||||
at = end + 1;
|
at = end + 1;
|
||||||
}
|
}
|
||||||
@@ -2194,6 +2223,7 @@ export function normalizeWallIntervals(
|
|||||||
for (const parent of parents) {
|
for (const parent of parents) {
|
||||||
const matches = atomic.filter((iv) => (
|
const matches = atomic.filter((iv) => (
|
||||||
!covered.has(iv.key) && iv.cm === parent.cm &&
|
!covered.has(iv.key) && iv.cm === parent.cm &&
|
||||||
|
iv.ownerSignature === parent.ownerSignature &&
|
||||||
angleClose(segAngle(iv.a, iv.b), segAngle(parent.a, parent.b)) &&
|
angleClose(segAngle(iv.a, iv.b), segAngle(parent.a, parent.b)) &&
|
||||||
distToSeg(iv.a[0], iv.a[1], parent.a[0], parent.a[1], parent.b[0], parent.b[1]) <= tol &&
|
distToSeg(iv.a[0], iv.a[1], parent.a[0], parent.a[1], parent.b[0], parent.b[1]) <= tol &&
|
||||||
distToSeg(iv.b[0], iv.b[1], parent.a[0], parent.a[1], parent.b[0], parent.b[1]) <= tol
|
distToSeg(iv.b[0], iv.b[1], parent.a[0], parent.a[1], parent.b[0], parent.b[1]) <= tol
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { GRID_PITCH, GRID_STEP_N as S, NORM_W } from '../test-build/space-geomet
|
|||||||
import {
|
import {
|
||||||
wallBodiesGeometry, wallIntervals, wallKey,
|
wallBodiesGeometry, wallIntervals, wallKey,
|
||||||
} from '../test-build/wall-thickness.js';
|
} from '../test-build/wall-thickness.js';
|
||||||
|
import { checkMixedRoleRecords, checkWallKeys } from '../scripts/model-invariants.mjs';
|
||||||
|
|
||||||
const room = (id, x0, x1, openTo) => ({
|
const room = (id, x0, x1, openTo) => ({
|
||||||
id,
|
id,
|
||||||
@@ -36,6 +37,10 @@ const wallKeyRoundtripFixture = JSON.parse(readFileSync(
|
|||||||
new URL('./fixtures/258-wall-key-roundtrip.json', import.meta.url),
|
new URL('./fixtures/258-wall-key-roundtrip.json', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
));
|
));
|
||||||
|
const realFirstFloorFixture = JSON.parse(readFileSync(
|
||||||
|
new URL('./fixtures/real-plan-first-floor.json', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
));
|
||||||
|
|
||||||
const assertNoPersistedChanges = (result) => {
|
const assertNoPersistedChanges = (result) => {
|
||||||
assert.equal(result.changed, false);
|
assert.equal(result.changed, false);
|
||||||
@@ -235,15 +240,20 @@ test('issue 273 Optimize collapses the beta.5 island beside one T-node', () => {
|
|||||||
assert.deepEqual(config, before, 'preview must not mutate the T-node source');
|
assert.deepEqual(config, before, 'preview must not mutate the T-node source');
|
||||||
assert.equal(first.changed, true);
|
assert.equal(first.changed, true);
|
||||||
assert.equal(first.report.canonicalized, 1);
|
assert.equal(first.report.canonicalized, 1);
|
||||||
assert.equal(first.report.wallsMerged, 2);
|
assert.equal(first.report.wallsMerged, 0,
|
||||||
|
'role breakpoints may keep the record count even after the micro island is repaired');
|
||||||
const canonicalBefore = canonicalizeConfigGeometry(before);
|
const canonicalBefore = canonicalizeConfigGeometry(before);
|
||||||
assert.deepEqual(first.config.spaces[0].rooms, canonicalBefore.spaces[0].rooms,
|
assert.deepEqual(first.config.spaces[0].rooms, canonicalBefore.spaces[0].rooms,
|
||||||
'T coordinate and perpendicular incident room stay byte-equivalent');
|
'T coordinate and perpendicular incident room stay byte-equivalent');
|
||||||
assert.equal(first.config.spaces[0].walls.length, 1);
|
assert.equal(first.config.spaces[0].walls.length, 3);
|
||||||
assert.equal(first.config.spaces[0].walls[0].cm, 22);
|
assert.ok(first.config.spaces[0].walls.every((wall) => wall.cm === 22),
|
||||||
|
'the micro thickness is repaired without merging outer/shared owner roles');
|
||||||
const exactNode = 83 / 240;
|
const exactNode = 83 / 240;
|
||||||
assert.deepEqual(first.config.spaces[0].walls[0].a, [0.8, exactNode]);
|
const roleBreaks = first.config.spaces[0].walls
|
||||||
assert.deepEqual(first.config.spaces[0].walls[0].b, [0.95, exactNode]);
|
.flatMap((wall) => [wall.a?.[0], wall.b?.[0]])
|
||||||
|
.filter(Number.isFinite);
|
||||||
|
assert.ok(roleBreaks.includes(203 / 240), 'outer→shared owner boundary stays exact');
|
||||||
|
assert.ok(roleBreaks.includes(213 / 240), 'shared→outer owner boundary stays exact');
|
||||||
|
|
||||||
const afterIntervals = wallIntervals(
|
const afterIntervals = wallIntervals(
|
||||||
first.config.spaces[0].rooms, first.config.spaces[0].walls, [], S, 5, S,
|
first.config.spaces[0].rooms, first.config.spaces[0].walls, [], S, 5, S,
|
||||||
@@ -264,6 +274,33 @@ test('issue 273 Optimize collapses the beta.5 island beside one T-node', () => {
|
|||||||
assert.deepEqual(second.config, first.config);
|
assert.deepEqual(second.config, first.config);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('#299 Optimize keeps the real first-floor shared/outer role breakpoint', () => {
|
||||||
|
const config = {
|
||||||
|
model_version: PLAN_MODEL_VERSION,
|
||||||
|
spaces: [structuredClone(realFirstFloorFixture.space)],
|
||||||
|
markers: [], settings: {},
|
||||||
|
};
|
||||||
|
const before = structuredClone(config);
|
||||||
|
const first = optimizePlans(config, {});
|
||||||
|
assert.deepEqual(config, before, 'Optimize preview must not mutate the real fixture');
|
||||||
|
assert.equal(first.changed, true);
|
||||||
|
assert.deepEqual(checkMixedRoleRecords(first.config), []);
|
||||||
|
assert.deepEqual(checkWallKeys(first.config, { notes: [] }), []);
|
||||||
|
|
||||||
|
const y = 83 / 240;
|
||||||
|
const line = first.config.spaces[0].walls.filter((wall) => wall.a && wall.b
|
||||||
|
&& Math.abs(wall.a[1] - y) < 1e-9 && Math.abs(wall.b[1] - y) < 1e-9);
|
||||||
|
assert.equal(line.length, 2, 'micro cleanup keeps one exact record per owner role');
|
||||||
|
assert.ok(line.every((wall) => wall.cm === 22));
|
||||||
|
assert.ok(line.every((wall) => wall.a[0] === 213 / 240 || wall.b[0] === 213 / 240),
|
||||||
|
'both runs stop at the exact shared/outer boundary');
|
||||||
|
|
||||||
|
const second = optimizePlans(first.config, first.layout);
|
||||||
|
assertNoPersistedChanges(second);
|
||||||
|
assert.deepEqual(second.config, first.config);
|
||||||
|
assert.deepEqual(second.layout, first.layout);
|
||||||
|
});
|
||||||
|
|
||||||
test('Optimize canonicalizes the six-room ULP source without claiming a visible move', () => {
|
test('Optimize canonicalizes the six-room ULP source without claiming a visible move', () => {
|
||||||
const config = {
|
const config = {
|
||||||
model_version: PLAN_MODEL_VERSION,
|
model_version: PLAN_MODEL_VERSION,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { polygonArea, paperRoomShapes, splitRoomPath, sharedBoundary } from '../
|
|||||||
import { resolveOpenCuts } from '../test-build/open-spans.js';
|
import { resolveOpenCuts } from '../test-build/open-spans.js';
|
||||||
import { GRID_PITCH, NORM_W } from '../test-build/space-geometry.js';
|
import { GRID_PITCH, NORM_W } from '../test-build/space-geometry.js';
|
||||||
import { geometryArea } from '../test-build/physical-geometry.js';
|
import { geometryArea } from '../test-build/physical-geometry.js';
|
||||||
import { checkWallRecordsPreserved } from '../scripts/model-invariants.mjs';
|
import { checkMixedRoleRecords, checkWallRecordsPreserved } from '../scripts/model-invariants.mjs';
|
||||||
import { difference, intersection, union } from 'polyclip-ts';
|
import { difference, intersection, union } from 'polyclip-ts';
|
||||||
|
|
||||||
const closeTo = (got, want, tol = 1e-6) =>
|
const closeTo = (got, want, tol = 1e-6) =>
|
||||||
@@ -918,18 +918,57 @@ test('a compacted exact wall covers a shorter collinear side in another room', (
|
|||||||
assert.ok(right && right.half > 0, 'hover/body profile must use the real inner face');
|
assert.ok(right && right.half > 0, 'hover/body profile must use the real inner face');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('equal solid atomic pieces compact back to one whole-wall key', () => {
|
test('#299 equal thickness does not compact across a shared-to-outer role boundary', () => {
|
||||||
const rooms = partialRooms();
|
const rooms = partialRooms();
|
||||||
const walls = [
|
const walls = [
|
||||||
{ key: wallKey([5, 0], [5, 4], pitch), cm: 30 },
|
{ key: wallKey([5, 0], [5, 4], pitch), cm: 30 },
|
||||||
{ key: wallKey([5, 4], [5, 10], pitch), cm: 30 },
|
{ key: wallKey([5, 4], [5, 10], pitch), cm: 30 },
|
||||||
];
|
];
|
||||||
const next = normalizeWallIntervals(rooms, walls, [], pitch, cellCm, GRID_PITCH);
|
const next = normalizeWallIntervals(rooms, walls, [], pitch, cellCm, GRID_PITCH);
|
||||||
|
assert.equal(next.length, 2);
|
||||||
|
assert.ok(next.some((wall) => wall.key === wallKey([5, 0], [5, 4], pitch)));
|
||||||
|
assert.ok(next.some((wall) => wall.key === wallKey([5, 4], [5, 10], pitch)));
|
||||||
|
assert.ok(next.every((wall) => wall.cm === 30));
|
||||||
|
assert.deepEqual(checkMixedRoleRecords({ spaces: [{ id: 'roles', rooms, walls: next }] }), []);
|
||||||
|
|
||||||
|
const reversed = normalizeWallIntervals(
|
||||||
|
[...rooms].reverse(), [...walls].reverse(), [], pitch, cellCm, GRID_PITCH,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
reversed.map((wall) => wall.key).sort(),
|
||||||
|
next.map((wall) => wall.key).sort(),
|
||||||
|
'room/input order must not change the physical role breakpoint',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#299 the shared owner pair is part of the compaction role', () => {
|
||||||
|
const rooms = [
|
||||||
|
{ id: 'a', poly: [[0, 0], [5, 0], [5, 10], [0, 10]] },
|
||||||
|
{ id: 'b', poly: [[5, 0], [10, 0], [10, 5], [5, 5]] },
|
||||||
|
{ id: 'c', poly: [[5, 5], [10, 5], [10, 10], [5, 10]] },
|
||||||
|
];
|
||||||
|
const walls = [
|
||||||
|
{ key: wallKey([5, 0], [5, 5], pitch), cm: 22 },
|
||||||
|
{ key: wallKey([5, 5], [5, 10], pitch), cm: 22 },
|
||||||
|
];
|
||||||
|
const next = normalizeWallIntervals(rooms, walls, [], pitch, cellCm, GRID_PITCH);
|
||||||
|
const vertical = next.filter((wall) => wall.a?.[0] === 5 && wall.b?.[0] === 5);
|
||||||
|
assert.equal(vertical.length, 2);
|
||||||
|
assert.ok(vertical.some((wall) => wall.key === wallKey([5, 0], [5, 5], pitch)));
|
||||||
|
assert.ok(vertical.some((wall) => wall.key === wallKey([5, 5], [5, 10], pitch)));
|
||||||
|
assert.deepEqual(checkMixedRoleRecords({ spaces: [{ id: 'pairs', rooms, walls: next }] }), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('#299 equal neighbouring outer atoms of one room still compact', () => {
|
||||||
|
const rooms = [{ id: 'a', poly: [[0, 0], [5, 0], [5, 10], [0, 10]] }];
|
||||||
|
const walls = [
|
||||||
|
{ key: wallKey([5, 0], [5, 4], pitch), a: [5, 0], b: [5, 4], cm: 30 },
|
||||||
|
{ key: wallKey([5, 4], [5, 10], pitch), a: [5, 4], b: [5, 10], cm: 30 },
|
||||||
|
];
|
||||||
|
const next = normalizeWallIntervals(rooms, walls, [], pitch, cellCm, GRID_PITCH);
|
||||||
assert.equal(next.length, 1);
|
assert.equal(next.length, 1);
|
||||||
assert.equal(next[0].key, wallKey([5, 0], [5, 10], pitch));
|
assert.equal(next[0].key, wallKey([5, 0], [5, 10], pitch));
|
||||||
assert.equal(next[0].cm, 30);
|
assert.equal(next[0].cm, 30);
|
||||||
assert.deepEqual(next[0].a, [5, 0]);
|
|
||||||
assert.deepEqual(next[0].b, [5, 10]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('different solid thicknesses remain separate atomic keys', () => {
|
test('different solid thicknesses remain separate atomic keys', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user