mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix: reconcile hidden walls during Optimize
Issue: #296 User-Visible: yes
This commit is contained in:
committed by
claude[bot]
parent
033ad11bf7
commit
89ac96a8af
File diff suppressed because one or more lines are too long
@@ -145,20 +145,42 @@ def _angle_delta_mod_180(first: float, second: float) -> float:
|
||||
return abs((first - second + 90.0) % 180.0 - 90.0)
|
||||
|
||||
|
||||
def _segments_cover_target(segments: list[tuple[list, list]], target_a: list, target_b: list) -> bool:
|
||||
"""Whether a collinear union covers one complete positive target span."""
|
||||
dx, dy, length = _segment_metrics(target_a, target_b)
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON:
|
||||
return False
|
||||
ranges = []
|
||||
for a, b in segments:
|
||||
if (_line_distance(a, target_a, target_b) > _OPTIMIZE_REHOST_EPSILON
|
||||
or _line_distance(b, target_a, target_b) > _OPTIMIZE_REHOST_EPSILON):
|
||||
continue
|
||||
lo, hi = sorted((_projection(a, target_a, target_b),
|
||||
_projection(b, target_a, target_b)))
|
||||
lo, hi = max(0.0, lo), min(length, hi)
|
||||
if hi - lo > _OPTIMIZE_REHOST_EPSILON:
|
||||
ranges.append((lo, hi))
|
||||
reached = 0.0
|
||||
for lo, hi in sorted(ranges):
|
||||
if lo > reached + _OPTIMIZE_REHOST_EPSILON:
|
||||
return False
|
||||
reached = max(reached, hi)
|
||||
if reached >= length - _OPTIMIZE_REHOST_EPSILON:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _safe_optimize_partition_rehost(
|
||||
space: dict, old_space: dict, opening: dict, old_opening: dict,
|
||||
) -> bool:
|
||||
"""Independently prove the exact #276 partition-to-room-wall transition."""
|
||||
"""Independently prove the #276/#296 partition-to-room-wall transition."""
|
||||
old_host = old_opening.get("host")
|
||||
if not isinstance(old_host, dict) or old_host.get("kind") != "partition":
|
||||
return False
|
||||
partition_id = str(old_host.get("id", ""))
|
||||
old_partition = next((item for item in old_space.get("partitions") or []
|
||||
if str(item.get("id", "")) == partition_id), None)
|
||||
if old_partition is None or any(
|
||||
str(item.get("id", "")) == partition_id
|
||||
for item in space.get("partitions") or []
|
||||
):
|
||||
if old_partition is None:
|
||||
return False
|
||||
a, b = old_partition.get("a"), old_partition.get("b")
|
||||
if not (isinstance(a, list) and len(a) == 2 and isinstance(b, list) and len(b) == 2):
|
||||
@@ -167,36 +189,6 @@ def _safe_optimize_partition_rehost(
|
||||
if length <= _OPTIMIZE_REHOST_EPSILON:
|
||||
return False
|
||||
|
||||
owners: set[str] = set()
|
||||
collinear_rooms: set[str] = set()
|
||||
for room in space.get("rooms") or []:
|
||||
poly = _room_polygon(room)
|
||||
room_id = str(room.get("id", ""))
|
||||
edges = [(poly[index], poly[(index + 1) % len(poly)])
|
||||
for index in range(len(poly))]
|
||||
if any(_segment_covers(edge_a, edge_b, a, b) for edge_a, edge_b in edges):
|
||||
owners.add(room_id)
|
||||
if any(_segments_overlap_on_axis(a, b, edge_a, edge_b)
|
||||
for edge_a, edge_b in edges):
|
||||
collinear_rooms.add(room_id)
|
||||
if len(owners) not in (1, 2) or collinear_rooms != owners:
|
||||
return False
|
||||
if any(_segments_overlap_on_axis(a, b, span["a"], span["b"])
|
||||
for span in space.get("open_spans") or []):
|
||||
return False
|
||||
|
||||
covering_walls = []
|
||||
for wall in space.get("walls") or []:
|
||||
wall_a, wall_b = wall.get("a"), wall.get("b")
|
||||
if not (isinstance(wall_a, list) and isinstance(wall_b, list)):
|
||||
continue
|
||||
if _segment_covers(wall_a, wall_b, a, b):
|
||||
covering_walls.append((_segment_metrics(wall_a, wall_b)[2], float(wall["cm"])))
|
||||
effective_cm = min(covering_walls, key=lambda item: item[0])[1] \
|
||||
if covering_walls else _DEFAULT_ROOM_WALL_CM
|
||||
if effective_cm + _OPTIMIZE_REHOST_EPSILON < float(old_partition.get("cm", 0)):
|
||||
return False
|
||||
|
||||
ignored = {"host", "x", "y", "angle"}
|
||||
old_stable = {key: value for key, value in old_opening.items() if key not in ignored}
|
||||
new_stable = {key: value for key, value in opening.items() if key not in ignored}
|
||||
@@ -226,6 +218,42 @@ def _safe_optimize_partition_rehost(
|
||||
or along + opening_length / 2 > length + _OPTIMIZE_REHOST_EPSILON):
|
||||
return False
|
||||
|
||||
half_dx, half_dy = dx / length * opening_length / 2, dy / length * opening_length / 2
|
||||
target_a = [expected_x - half_dx, expected_y - half_dy]
|
||||
target_b = [expected_x + half_dx, expected_y + half_dy]
|
||||
owners: set[str] = set()
|
||||
collinear_rooms: set[str] = set()
|
||||
for room in space.get("rooms") or []:
|
||||
poly = _room_polygon(room)
|
||||
room_id = str(room.get("id", ""))
|
||||
edges = [(poly[index], poly[(index + 1) % len(poly)])
|
||||
for index in range(len(poly))]
|
||||
if _segments_cover_target(edges, target_a, target_b):
|
||||
owners.add(room_id)
|
||||
if any(_segments_overlap_on_axis(target_a, target_b, edge_a, edge_b)
|
||||
for edge_a, edge_b in edges):
|
||||
collinear_rooms.add(room_id)
|
||||
if len(owners) not in (1, 2) or collinear_rooms != owners:
|
||||
return False
|
||||
if any(_segments_overlap_on_axis(target_a, target_b, span["a"], span["b"])
|
||||
for span in space.get("open_spans") or []):
|
||||
return False
|
||||
if any(_segments_overlap_on_axis(target_a, target_b, item["a"], item["b"])
|
||||
for item in space.get("partitions") or []):
|
||||
return False
|
||||
|
||||
covering_walls = []
|
||||
for wall in space.get("walls") or []:
|
||||
wall_a, wall_b = wall.get("a"), wall.get("b")
|
||||
if not (isinstance(wall_a, list) and isinstance(wall_b, list)):
|
||||
continue
|
||||
if _segment_covers(wall_a, wall_b, target_a, target_b):
|
||||
covering_walls.append((_segment_metrics(wall_a, wall_b)[2], float(wall["cm"])))
|
||||
effective_cm = min(covering_walls, key=lambda item: item[0])[1] \
|
||||
if covering_walls else _DEFAULT_ROOM_WALL_CM
|
||||
if effective_cm + _OPTIMIZE_REHOST_EPSILON < float(old_partition.get("cm", 0)):
|
||||
return False
|
||||
|
||||
for other in space.get("openings") or []:
|
||||
if other is opening or str(other.get("id", "")) == str(opening.get("id", "")):
|
||||
continue
|
||||
|
||||
@@ -83,6 +83,13 @@ export function prepareGoldenFixture(scenario) {
|
||||
space.settings = {
|
||||
fill_mode: 'none', show_borders: true, show_names: true,
|
||||
};
|
||||
if (scenario.hiddenWallDiagnostics) {
|
||||
space.room_drafts = [{
|
||||
id: 'hidden-saved-chain',
|
||||
points: [[0, 0], [0, 1]],
|
||||
segments: [{ cm: 15 }],
|
||||
}];
|
||||
}
|
||||
if (state !== 'before') {
|
||||
delete space.partitions;
|
||||
space.walls[0].cm = state === 'thin' ? 10 : state === 'thick' ? 30 : 20;
|
||||
|
||||
@@ -165,6 +165,14 @@ const decorLayerProbes = {
|
||||
};
|
||||
|
||||
export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
{ id: 'hidden-wall-diagnostics-plan-light', fixture: 'visual',
|
||||
space: 'golden-coincident-partition', coincidentPartition: 'before',
|
||||
hiddenWallDiagnostics: true, mode: 'plan', theme: 'light',
|
||||
viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'hidden-wall-diagnostics-plan-dark', fixture: 'visual',
|
||||
space: 'golden-coincident-partition', coincidentPartition: 'before',
|
||||
hiddenWallDiagnostics: true, mode: 'plan', theme: 'dark',
|
||||
viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'coincident-partition-before-dark', fixture: 'visual',
|
||||
space: 'golden-coincident-partition', coincidentPartition: 'before',
|
||||
mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
|
||||
@@ -57,7 +57,7 @@ const arg = (name, fallback) => {
|
||||
* Когда #296 закроется, число здесь станет нулём, и тест это потребует.
|
||||
*/
|
||||
const PLANS = [
|
||||
{ file: 'real-plan-second-floor.json', debt: 1 },
|
||||
{ file: 'real-plan-second-floor.json', debt: 3 },
|
||||
{ file: 'real-plan-first-floor.json', debt: 0 },
|
||||
];
|
||||
|
||||
|
||||
@@ -228,12 +228,28 @@ const out = await page.evaluate(async () => {
|
||||
partitions: card._curSpaceCfg.partitions,
|
||||
}) === gestureGeometry;
|
||||
|
||||
card._curSpaceCfg.partitions.push({
|
||||
id: 'hidden-partition', a: [0.1, 0.1], b: [0.5, 0.1], cm: 15,
|
||||
});
|
||||
card._modelCache = null;
|
||||
card._cfgEpoch++;
|
||||
card._activateMarkupTool('select');
|
||||
await update();
|
||||
result.otherPlanToolsHaveNoOverlay = !overlay();
|
||||
const diagnostic = root().querySelector('[data-hp="hidden-wall-diagnostic"]');
|
||||
result.otherPlanToolsHaveHiddenDiagnostic = !overlay() && !!diagnostic
|
||||
&& diagnostic.querySelectorAll('.hidden-wall-line').length === 1
|
||||
&& diagnostic.querySelectorAll('.hidden-wall-node').length === 2;
|
||||
const editorVirtualWalls = root().querySelector('.openwalls');
|
||||
result.hiddenDiagnosticAboveAllWallBodies = !!diagnostic && !!wallBodies
|
||||
&& !!(wallBodies.compareDocumentPosition(diagnostic) & Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
&& (!editorVirtualWalls
|
||||
|| !!(editorVirtualWalls.compareDocumentPosition(diagnostic) & Node.DOCUMENT_POSITION_FOLLOWING));
|
||||
result.hiddenDiagnosticIsPointerTransparent = diagnostic?.getAttribute('pointer-events') === 'none'
|
||||
&& getComputedStyle(diagnostic).pointerEvents === 'none';
|
||||
card._setMode('view');
|
||||
await update();
|
||||
result.viewHasNoOverlay = !overlay();
|
||||
result.viewHasNoOverlay = !overlay()
|
||||
&& !root().querySelector('[data-hp="hidden-wall-diagnostic"]');
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
Vendored
+104
-80
File diff suppressed because one or more lines are too long
@@ -567,14 +567,16 @@ colour and other presentation edits bypass this structural check, allowing an
|
||||
old degraded plan to be exported or corrected without a background migration.
|
||||
|
||||
`reconcileCoincidentPartitions()` is an explicit-Optimize-only structural
|
||||
canonicalizer (#276). It consumes canonical shared wall intervals and the
|
||||
canonicalizer (#276/#296). It consumes canonical room-wall intervals and the
|
||||
partition-opening compatibility resolver; it does not implement a second
|
||||
nearest-wall model. An independent wall is removed only after an exact
|
||||
endpoint-to-endpoint, two-room, uniform-solid proof. Its hosted openings are
|
||||
materialised onto the ordinary room wall, and `max(roomCm, partitionCm)` keeps
|
||||
the original centred physical union envelope. Unknown partition semantics,
|
||||
partial/composite matches, overlapping openings and adjacent independent
|
||||
bodies fail closed. The candidate then crosses the existing whole-plan
|
||||
nearest-wall model. A source axis is atomized at solid interval and opening
|
||||
boundaries. Exact one-owner outer or two-owner shared spans may be absorbed;
|
||||
ambiguous spans are recombined into deterministic residual partitions and keep
|
||||
their hosted openings. Converted openings are materialised onto ordinary room
|
||||
walls, and `max(roomCm, partitionCm)` keeps the original centred physical union
|
||||
envelope. Saved drafts use a separate all-or-nothing full-coverage proof.
|
||||
Unknown partition semantics, gaps, overlapping openings and adjacent
|
||||
independent bodies fail closed. The candidate then crosses the existing whole-plan
|
||||
geometry preflight and one atomic Optimize write/Undo boundary. No render or
|
||||
ordinary save path invokes this pass, so `PLAN_MODEL_VERSION` remains unchanged.
|
||||
`OptimizeDependencies` is a narrow test/benchmark seam: production uses the
|
||||
|
||||
@@ -683,6 +683,17 @@ current anchor is excluded to prevent zero-length segments. The static geometry
|
||||
is cached by structural editor state; pointer movement changes at most the
|
||||
single active candidate and never writes config, layout or storage.
|
||||
|
||||
A separate diagnostic projection is present throughout the Plan editor (#296),
|
||||
including tools other than **Walls**. For every saved draft or independent wall
|
||||
segment with a positive exact collinear overlap against another wall, it keeps
|
||||
that source segment's complete axis and original endpoints visible. It is
|
||||
painted after every real and virtual wall body and before openings, selection
|
||||
chrome and transient previews. The layer is `pointer-events:none`,
|
||||
`aria-hidden`, absent from View and cached by structural revision; it neither
|
||||
deduplicates source identities nor participates in the architectural snap
|
||||
resolver above. The 1 CSS px non-scaling axis and physical 5 cm nodes therefore
|
||||
diagnose an otherwise invisible Resize blocker without changing any hit target.
|
||||
|
||||
## Planar wall faces
|
||||
|
||||
Every completed Walls segment is first persisted in the active `room_drafts`
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Hidden independent wall sections and saved wall chains now keep their axes
|
||||
and original endpoints visible above all masonry in the Plan editor. “Optimize
|
||||
plans” can absorb every exactly covered section into one or more consecutive
|
||||
room walls, retain any unproven residual and its openings, and remove a saved
|
||||
chain only when all of it is redundant. This clears real invisible Resize
|
||||
blockers without shrinking walls or deleting unfinished work
|
||||
([#296](https://github.com/Matysh/houseplan-card/issues/296)).
|
||||
- While drawing Walls, `Esc` now finishes all accepted segments as independent
|
||||
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
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Оси и исходные узлы скрытых независимых стен и сохранённых цепочек теперь
|
||||
видны поверх всей кладки в редакторе Плана. «Оптимизировать планы» умеет
|
||||
поглощать каждый точно покрытый участок одной или несколькими соседними
|
||||
стенами комнат, сохраняет недоказанный остаток вместе с его проёмами и удаляет
|
||||
цепочку только при полной избыточности. Это устраняет реальные невидимые
|
||||
блокеры Resize, не уменьшая толщину стен и не удаляя незавершённую работу
|
||||
([#296](https://github.com/Matysh/houseplan-card/issues/296)).
|
||||
- При рисовании стен `Esc` теперь завершает все принятые отрезки как
|
||||
независимые стены и отцепляется от последней точки, не удаляя геометрию и не
|
||||
покидая инструмент. Следующий клик начинает новую цепочку, а `Ctrl/Cmd+Z`
|
||||
|
||||
@@ -189,6 +189,10 @@ building the preview frame itself.
|
||||
blocks a zero-range handle, Optimize safely rehosts its windows and removes
|
||||
the blocker, then the same production Resize gesture changes exactly two
|
||||
rooms;
|
||||
- `test/optimize-hidden-obstacles.test.mjs`: a real plan's composite hidden
|
||||
independent walls and wholly redundant saved chain are removed by explicit
|
||||
Optimize, while partial/free residuals remain and the post-Optimize Resize
|
||||
audit loses only the proven `duplicate-physical-wall` blockers (#296);
|
||||
- `demo/benchmark_safe_resize.mjs`: same-run pointer and cached pointerup budgets;
|
||||
- `demo/benchmark_safe_resize_render.mjs`: warm 20-room/80-handle layer p95
|
||||
and exactly one geometry snapshot per rendered frame;
|
||||
|
||||
+16
-8
@@ -694,14 +694,22 @@ change is canceled before Undo history or server storage is touched and the
|
||||
card reports that the wall geometry could not be built safely. Titles, colours,
|
||||
markers and other non-geometry settings remain editable.
|
||||
|
||||
When an old plan contains an independent wall exactly on top of one complete
|
||||
solid boundary shared by two rooms, Optimize can replace the duplicate with
|
||||
the single room wall. Doors, windows and gates hosted by that independent wall
|
||||
stay in place and keep their contact/lock settings. The resulting thickness is
|
||||
the wider original thickness, so the visible masonry does not shrink during
|
||||
maintenance; use **Thickness** afterwards to choose a new value or **Boundary**
|
||||
to make the shared wall virtual. Partial, composite and ambiguous overlaps are
|
||||
left unchanged for manual correction.
|
||||
When an old plan contains an independent wall exactly on top of solid room
|
||||
masonry, Optimize can absorb each proven covered section even when consecutive
|
||||
room-wall intervals form the cover. Free or ambiguous residual sections remain
|
||||
independent walls with stable identities. Doors, windows and gates stay in
|
||||
place: each is reattached to the room wall or to the retained residual that
|
||||
still hosts it. The resulting thickness is the wider original thickness, so
|
||||
visible masonry does not shrink. A saved unfinished wall chain is removed only
|
||||
when every one of its segments is fully redundant; a free, partly covered or
|
||||
thicker chain remains byte-for-byte unchanged. The report counts absorbed
|
||||
independent-wall sections and removed whole chains, not source records.
|
||||
|
||||
In the Plan editor, an independent wall or saved chain hidden under other wall
|
||||
bodies retains a thin centre axis and its original endpoint nodes above the
|
||||
masonry. These pointer-transparent diagnostics do not change snapping or
|
||||
selection and are absent from View. They disappear after Apply only when the
|
||||
corresponding independent geometry was safely absorbed or removed.
|
||||
|
||||
Old positions are classified before Apply. A position whose room label, device
|
||||
or light-group owner is proven absent is removed automatically and counted by a
|
||||
|
||||
@@ -1420,7 +1420,7 @@ show_signal: true
|
||||
| Устройства и подписи комнат | Позиции округляются к сетке |
|
||||
| Проёмы | Возвращаются на ближайшую стену, смещение вдоль стены округляется, угол исправляется |
|
||||
| Стены | Одинаковые соседние участки толщины объединяются. Изолированный участок другой толщины короче половины шага сетки также схлопывается, если с обеих сторон находятся участки одной толщины той же прямой стены. Допустим один T-узел комнаты: он и перпендикулярная стена не двигаются. Два топологических узла или граница проёма защищают участок |
|
||||
| Перегородки | Коллинеарные соседние отрезки одинаковой толщины сращиваются в одну запись; узел сохраняется, если в нём сходится третья стена, стена комнаты, колонна или конец черновика. Если отдельная перегородка точно endpoint-to-endpoint совпадает с одной сплошной общей стеной двух комнат, она преобразуется в эту стену, а размещённые на ней проёмы перепривязываются без сдвига и потери датчиков. Сохраняется большая исходная толщина. Частичные и неоднозначные совпадения не меняются |
|
||||
| Перегородки и сохранённые цепочки | Коллинеарные соседние отрезки одинаковой толщины сращиваются; топологический узел сохраняется. Каждый участок отдельной стены, точно покрытый одной или несколькими соседними сплошными стенами комнат, поглощается ими, а недоказанные остатки и их проёмы сохраняются. Проёмы на поглощённой части перепривязываются без сдвига и потери датчиков; сохраняется большая исходная толщина. Незавершённая цепочка удаляется только целиком и только если каждый её сегмент полностью избыточен; свободная, частично покрытая или более толстая цепочка не меняется |
|
||||
| Виртуальные стены | Соседние/перекрывающиеся участки объединяются и приводятся к общей границе |
|
||||
| Ссылки устройств | Точная подпись независимого импорта восстанавливает пространство, комнату и позицию. Иначе реальное устройство следует однозначной Area HA либо теряет только мёртвую привязку; настройки маркера сохраняются |
|
||||
| Забытые позиции | Позиции доказанно отсутствующих подписей комнат, устройств и групповых меток удаляются и считаются по понятным категориям |
|
||||
@@ -1483,6 +1483,13 @@ show_signal: true
|
||||
обычным способом, а общую стену — сделать виртуальной. До явного подтверждения
|
||||
предпросмотра и для неполного/неоднозначного совпадения ничего не меняется.
|
||||
|
||||
Отчёт считает поглощённые максимальные участки независимых стен, а не число
|
||||
исходных записей, и отдельно — только целиком удалённые сохранённые цепочки.
|
||||
В редакторе Плана ось и исходные узлы независимой стены или цепочки, скрытой под
|
||||
другой кладкой, рисуются поверх всех стен. Этот диагностический слой не
|
||||
перехватывает указатель, не меняет привязку и отсутствует в режиме просмотра;
|
||||
после Apply он исчезает только вместе с доказанно избыточной геометрией.
|
||||
|
||||
<!-- docs-section: multiple-cards -->
|
||||
|
||||
## 20. Хранение, совместная работа и резервные копии
|
||||
|
||||
+14
-10
@@ -403,16 +403,20 @@ delete requires cascade confirmation, and malformed/orphan hosts remain opaque.
|
||||
Opening cuts change physical masonry, not the structural wall axes used for
|
||||
room-face detection (#185).
|
||||
|
||||
Explicit Optimize has one stricter reconciliation pass (#276/#281). It may
|
||||
remove a partition only when its axis is endpoint-to-endpoint identical to one
|
||||
uniform, solid outer interval owned by one room or shared interval owned by
|
||||
exactly two rooms, and no draft, column, second partition or conflicting
|
||||
opening makes the result ambiguous. Hosted openings are materialised at the
|
||||
same centre/angle as ordinary room-wall openings. The canonical thickness is
|
||||
`max(roomCm, partitionCm)`, which is exactly the union envelope of the centred
|
||||
coincident bodies. The pass is immutable, idempotent and followed by the common
|
||||
whole-plan geometry preflight; rendering, Resize and ordinary Save never
|
||||
perform it implicitly.
|
||||
Explicit Optimize has one stricter reconciliation pass (#276/#281/#296). It
|
||||
atomizes an independent wall at consecutive solid room-wall and hosted-opening
|
||||
boundaries. Every positive section is proved independently: one outer owner or
|
||||
exactly two shared owners with one effective thickness, and no draft, column,
|
||||
second partition or conflicting opening. Proven sections are absorbed even
|
||||
when several consecutive room intervals cover the source; unproven sections
|
||||
are recombined into deterministic residual partitions. Hosted openings are
|
||||
materialised at the same centre/angle on a proven room wall or rebound to the
|
||||
single residual that contains them. The canonical thickness is
|
||||
`max(roomCm, partitionCm)`, exactly the union envelope of centred coincident
|
||||
bodies. A saved draft is removed only all-or-nothing when every segment has the
|
||||
same complete solid proof. The pass is immutable, idempotent and followed by
|
||||
the common whole-plan geometry preflight; rendering, Resize and ordinary Save
|
||||
never perform it implicitly.
|
||||
|
||||
`physicalBodySet()` separates raw draft/partition/column bodies from computed
|
||||
junction patches and their joined geometry. Raw bodies remain authoritative for
|
||||
|
||||
@@ -1247,27 +1247,21 @@ export const MUTANTS = [
|
||||
+ 'would leave a dangling host and make the door disappear after explicit Optimize (#276)',
|
||||
patches: [{
|
||||
file: 'src/coincident-partitions.ts',
|
||||
find: ' openings = openings.map((opening) => replacement.get(opening.id) || opening);',
|
||||
replace: ' openings = openings.map((opening) => opening);',
|
||||
find: ' const nextOpenings = openings.map((opening) => openingReplacement.get(opening.id) || opening);',
|
||||
replace: ' const nextOpenings = openings.map((opening) => opening);',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'optimizer-coincident-partial-accepted',
|
||||
id: 'optimizer-coincident-residual-dropped',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="partial and ambiguous" '
|
||||
+ 'test/plan-optimizer.test.mjs',
|
||||
because: 'a merely collinear partial partition is not the same physical wall and must never '
|
||||
+ 'be deleted just because its hosted opening happens to fit the longer room boundary (#276)',
|
||||
+ '&& node --test --test-name-pattern="covered middle" '
|
||||
+ 'test/optimize-hidden-obstacles.test.mjs',
|
||||
because: 'piecewise Optimize may absorb only the exactly covered middle of an independent '
|
||||
+ 'wall and must preserve both free residual spans with stable identifiers (#296)',
|
||||
patches: [{
|
||||
file: 'src/coincident-partitions.ts',
|
||||
find: ' const owners = (solidByKey.get(segmentKey(partition.a, partition.b)) || [])\n'
|
||||
+ ' .filter((interval) => sameSegment(\n'
|
||||
+ ' interval.a, interval.b, partition.a, partition.b, eps,\n'
|
||||
+ ' ));',
|
||||
replace: ' const owners = (solidByKey.get(segmentKey(partition.a, partition.b)) || [])\n'
|
||||
+ ' .filter((interval) => collinearOverlap(\n'
|
||||
+ ' interval.a, interval.b, partition.a, partition.b, eps,\n'
|
||||
+ ' ) > eps);',
|
||||
find: ' const residualRuns = runs.filter((run) => !run.safe);',
|
||||
replace: ' const residualRuns: PieceRun[] = [];',
|
||||
}],
|
||||
},
|
||||
{
|
||||
|
||||
+429
-193
@@ -1,31 +1,37 @@
|
||||
/**
|
||||
* Lossless explicit-Optimize reconciliation for an independent partition that
|
||||
* is exactly the same centred physical wall as one solid room edge.
|
||||
* Lossless explicit-Optimize reconciliation for independent walls and saved
|
||||
* wall chains which are physically hidden by canonical room masonry.
|
||||
* Runtime renderers never call this module and it never mutates its inputs.
|
||||
*/
|
||||
import {
|
||||
materializePartitionOpening,
|
||||
partitionOpeningHasCompositeRoomWall,
|
||||
partitionOpeningJambMargin,
|
||||
resolvePartitionOpeningCompat,
|
||||
resolvePartitionOpeningStrict,
|
||||
type ResolvedPartitionOpening,
|
||||
} from './partition-openings';
|
||||
import {
|
||||
openingWallIndex,
|
||||
normalizeWallIntervals,
|
||||
resolveOpeningWallAssociation,
|
||||
setWallThickness,
|
||||
wallKey,
|
||||
DRAW_WALL_DEFAULT_CM,
|
||||
wallCmToUnits,
|
||||
wallIntervals,
|
||||
type WallEntry,
|
||||
type WallInterval,
|
||||
} from './wall-thickness';
|
||||
import type { OpeningCfg, PartitionCfg, SpaceModel } from './types';
|
||||
import type { OpeningCfg, PartitionCfg, RoomDraftCfg, SpaceModel } from './types';
|
||||
|
||||
export interface CoincidentPartitionResult {
|
||||
walls: WallEntry[];
|
||||
partitions: PartitionCfg[];
|
||||
openings: OpeningCfg[];
|
||||
roomDrafts: RoomDraftCfg[];
|
||||
partitionsReconciled: number;
|
||||
openingsRehosted: number;
|
||||
removedDrafts: number;
|
||||
}
|
||||
|
||||
export interface CoincidentPartitionOptions {
|
||||
@@ -35,84 +41,54 @@ export interface CoincidentPartitionOptions {
|
||||
coordScale: number;
|
||||
}
|
||||
|
||||
const samePoint = (a: readonly number[], b: readonly number[], eps: number): boolean => (
|
||||
Math.hypot(a[0] - b[0], a[1] - b[1]) <= eps
|
||||
interface AxisRange { lo: number; hi: number }
|
||||
interface AtomicPiece extends AxisRange {
|
||||
a: [number, number]; b: [number, number];
|
||||
safe: boolean; signature: string; roomIds: string[];
|
||||
roomCm: number; finalCm: number;
|
||||
}
|
||||
interface PieceRun extends AxisRange {
|
||||
a: [number, number]; b: [number, number];
|
||||
safe: boolean; signature: string; roomIds: string[]; finalCm: number;
|
||||
}
|
||||
|
||||
const MAX_WALLS = 500;
|
||||
const MAX_PARTITIONS = 2000;
|
||||
|
||||
const finitePoint = (point: readonly number[] | null | undefined): point is readonly [number, number] => (
|
||||
!!point && point.length >= 2 && Number.isFinite(point[0]) && Number.isFinite(point[1])
|
||||
);
|
||||
|
||||
const sameSegment = (
|
||||
a: readonly number[], b: readonly number[], c: readonly number[], d: readonly number[],
|
||||
eps: number,
|
||||
): boolean => (
|
||||
(samePoint(a, c, eps) && samePoint(b, d, eps))
|
||||
|| (samePoint(a, d, eps) && samePoint(b, c, eps))
|
||||
const comparePoint = (a: readonly number[], b: readonly number[]): number => (
|
||||
a[0] - b[0] || a[1] - b[1]
|
||||
);
|
||||
const pointAt = (
|
||||
origin: readonly number[], ux: number, uy: number, along: number,
|
||||
): [number, number] => [origin[0] + ux * along, origin[1] + uy * along];
|
||||
const projection = (
|
||||
point: readonly number[], origin: readonly number[], ux: number, uy: number,
|
||||
): number => (point[0] - origin[0]) * ux + (point[1] - origin[1]) * uy;
|
||||
|
||||
const segmentProjection = (
|
||||
point: readonly number[], a: readonly number[], ux: number, uy: number,
|
||||
): number => (point[0] - a[0]) * ux + (point[1] - a[1]) * uy;
|
||||
|
||||
const collinearOverlap = (
|
||||
a: readonly number[], b: readonly number[], c: readonly number[], d: readonly number[],
|
||||
eps: number,
|
||||
): number => {
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const collinearRange = (
|
||||
axisA: readonly number[], axisB: readonly number[],
|
||||
otherA: readonly number[], otherB: readonly number[], eps: number,
|
||||
): AxisRange | null => {
|
||||
const dx = axisB[0] - axisA[0], dy = axisB[1] - axisA[1];
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (!(length > eps)) return 0;
|
||||
if (!(length > eps)) return null;
|
||||
const ux = dx / length, uy = dy / length;
|
||||
const cross = (point: readonly number[]) => Math.abs(
|
||||
(point[0] - a[0]) * uy - (point[1] - a[1]) * ux
|
||||
const across = (point: readonly number[]) => Math.abs(
|
||||
(point[0] - axisA[0]) * uy - (point[1] - axisA[1]) * ux
|
||||
);
|
||||
if (cross(c) > eps || cross(d) > eps) return 0;
|
||||
const lo = Math.max(0, Math.min(
|
||||
segmentProjection(c, a, ux, uy), segmentProjection(d, a, ux, uy),
|
||||
));
|
||||
const hi = Math.min(length, Math.max(
|
||||
segmentProjection(c, a, ux, uy), segmentProjection(d, a, ux, uy),
|
||||
));
|
||||
return Math.max(0, hi - lo);
|
||||
};
|
||||
|
||||
const openingGeometry = (
|
||||
opening: OpeningCfg,
|
||||
partitions: readonly PartitionCfg[],
|
||||
options: CoincidentPartitionOptions,
|
||||
): { center: [number, number]; angle: number; length: number } | null => {
|
||||
if (opening.host) {
|
||||
const resolved = resolvePartitionOpeningCompat(
|
||||
opening, partitions, options.coordScale, options.cellCm, options.gridPitch,
|
||||
).resolved;
|
||||
return resolved ? {
|
||||
center: resolved.center,
|
||||
angle: resolved.angle,
|
||||
length: resolved.length,
|
||||
} : null;
|
||||
}
|
||||
const x = Number(opening.x) * options.coordScale;
|
||||
const y = Number(opening.y) * options.coordScale;
|
||||
const angle = Number(opening.angle);
|
||||
const length = Number(opening.length) * options.coordScale;
|
||||
return [x, y, angle, length].every(Number.isFinite) && length > 0
|
||||
? { center: [x, y], angle, length }
|
||||
: null;
|
||||
};
|
||||
|
||||
const openingsOverlap = (
|
||||
first: { center: [number, number]; angle: number; length: number },
|
||||
second: { center: [number, number]; angle: number; length: number },
|
||||
eps: number,
|
||||
): boolean => {
|
||||
const rad = first.angle * Math.PI / 180;
|
||||
const ux = Math.cos(rad), uy = Math.sin(rad);
|
||||
const otherRad = second.angle * Math.PI / 180;
|
||||
const vx = Math.cos(otherRad), vy = Math.sin(otherRad);
|
||||
if (Math.abs(ux * vy - uy * vx) > 1e-6) return false;
|
||||
const dx = second.center[0] - first.center[0];
|
||||
const dy = second.center[1] - first.center[1];
|
||||
if (Math.abs(dx * uy - dy * ux) > eps) return false;
|
||||
const along = dx * ux + dy * uy;
|
||||
return Math.max(-first.length / 2, along - second.length / 2)
|
||||
< Math.min(first.length / 2, along + second.length / 2) - eps;
|
||||
if (across(otherA) > eps || across(otherB) > eps) return null;
|
||||
const first = projection(otherA, axisA, ux, uy);
|
||||
const second = projection(otherB, axisA, ux, uy);
|
||||
const lo = Math.max(0, Math.min(first, second));
|
||||
const hi = Math.min(length, Math.max(first, second));
|
||||
return hi - lo > eps ? { lo, hi } : null;
|
||||
};
|
||||
const rangesOverlap = (first: AxisRange, second: AxisRange, eps: number): boolean => (
|
||||
Math.min(first.hi, second.hi) - Math.max(first.lo, second.lo) > eps
|
||||
);
|
||||
|
||||
const rawPartitionKnown = (partition: any): boolean => {
|
||||
const known = new Set(['id', 'a', 'b', 'cm']);
|
||||
@@ -120,26 +96,21 @@ const rawPartitionKnown = (partition: any): boolean => {
|
||||
};
|
||||
|
||||
const columnBlocks = (
|
||||
partition: PartitionCfg,
|
||||
columns: readonly any[],
|
||||
options: CoincidentPartitionOptions,
|
||||
eps: number,
|
||||
partition: PartitionCfg, columns: readonly any[],
|
||||
options: CoincidentPartitionOptions, eps: number,
|
||||
): boolean => {
|
||||
const dx = partition.b[0] - partition.a[0];
|
||||
const dy = partition.b[1] - partition.a[1];
|
||||
const dx = partition.b[0] - partition.a[0], dy = partition.b[1] - partition.a[1];
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (!(length > eps)) return true;
|
||||
const ux = dx / length, uy = dy / length;
|
||||
const wallHalf = wallCmToUnits(partition.cm, options.cellCm, options.gridPitch) / 2;
|
||||
return columns.some((column) => {
|
||||
const centre = column?.center;
|
||||
if (!Array.isArray(centre) || centre.length < 2) return true;
|
||||
const along = segmentProjection(centre, partition.a, ux, uy);
|
||||
if (!finitePoint(centre)) return true;
|
||||
const along = projection(centre, partition.a, ux, uy);
|
||||
const across = Math.abs(
|
||||
(centre[0] - partition.a[0]) * uy - (centre[1] - partition.a[1]) * ux
|
||||
);
|
||||
// A rotated square fits inside its circumcircle; using it here is a
|
||||
// deliberately conservative ambiguity guard, never an auto-delete test.
|
||||
const radius = wallCmToUnits(Number(column.cm), options.cellCm, options.gridPitch)
|
||||
* (column.shape === 'square' ? Math.SQRT1_2 : 0.5);
|
||||
if (!Number.isFinite(radius) || !(radius > 0)) return true;
|
||||
@@ -148,10 +119,125 @@ const columnBlocks = (
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Reconcile every independently provable candidate in deterministic id order.
|
||||
* A candidate which fails any check is left byte-equivalent.
|
||||
*/
|
||||
const openingGeometry = (
|
||||
opening: OpeningCfg, partitions: readonly PartitionCfg[],
|
||||
options: CoincidentPartitionOptions,
|
||||
): { center: [number, number]; angle: number; length: number } | null => {
|
||||
if (opening.host) {
|
||||
const resolved = resolvePartitionOpeningCompat(
|
||||
opening, partitions, options.coordScale, options.cellCm, options.gridPitch,
|
||||
).resolved;
|
||||
return resolved ? { center: resolved.center, angle: resolved.angle, length: resolved.length } : null;
|
||||
}
|
||||
const x = Number(opening.x) * options.coordScale;
|
||||
const y = Number(opening.y) * options.coordScale;
|
||||
const angle = Number(opening.angle);
|
||||
const length = Number(opening.length) * options.coordScale;
|
||||
return [x, y, angle, length].every(Number.isFinite) && length > 0
|
||||
? { center: [x, y], angle, length } : null;
|
||||
};
|
||||
const openingsOverlap = (
|
||||
first: { center: [number, number]; angle: number; length: number },
|
||||
second: { center: [number, number]; angle: number; length: number }, eps: number,
|
||||
): boolean => {
|
||||
const rad = first.angle * Math.PI / 180;
|
||||
const ux = Math.cos(rad), uy = Math.sin(rad);
|
||||
const otherRad = second.angle * Math.PI / 180;
|
||||
const vx = Math.cos(otherRad), vy = Math.sin(otherRad);
|
||||
if (Math.abs(ux * vy - uy * vx) > 1e-6) return false;
|
||||
const dx = second.center[0] - first.center[0], dy = second.center[1] - first.center[1];
|
||||
if (Math.abs(dx * uy - dy * ux) > eps) return false;
|
||||
const along = dx * ux + dy * uy;
|
||||
return Math.max(-first.length / 2, along - second.length / 2)
|
||||
< Math.min(first.length / 2, along + second.length / 2) - eps;
|
||||
};
|
||||
|
||||
const scaledPartition = (partition: PartitionCfg, scale: number): PartitionCfg => ({
|
||||
...partition,
|
||||
a: [partition.a[0] * scale, partition.a[1] * scale],
|
||||
b: [partition.b[0] * scale, partition.b[1] * scale],
|
||||
});
|
||||
const fnv1a = (value: string): string => {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
hash ^= value.charCodeAt(index);
|
||||
hash = Math.imul(hash, 0x01000193) >>> 0;
|
||||
}
|
||||
return hash.toString(36);
|
||||
};
|
||||
const residualId = (
|
||||
sourceId: string, a: readonly number[], b: readonly number[], occupied: Set<string>,
|
||||
): string => {
|
||||
const token = fnv1a(`${sourceId}|${a[0].toFixed(9)},${a[1].toFixed(9)}`
|
||||
+ `|${b[0].toFixed(9)},${b[1].toFixed(9)}`);
|
||||
const suffix = `~r-${token}`;
|
||||
const base = `${sourceId.slice(0, Math.max(1, 64 - suffix.length))}${suffix}`;
|
||||
let candidate = base;
|
||||
for (let index = 2; occupied.has(candidate); index++) {
|
||||
const extra = `-${index}`;
|
||||
candidate = `${base.slice(0, 64 - extra.length)}${extra}`;
|
||||
}
|
||||
occupied.add(candidate);
|
||||
return candidate;
|
||||
};
|
||||
const mergePieces = (pieces: AtomicPiece[], eps: number): PieceRun[] => {
|
||||
const runs: PieceRun[] = [];
|
||||
for (const piece of pieces) {
|
||||
const previous = runs[runs.length - 1];
|
||||
if (previous && Math.abs(previous.hi - piece.lo) <= eps
|
||||
&& previous.safe === piece.safe
|
||||
&& (!piece.safe || previous.signature === piece.signature)) {
|
||||
previous.hi = piece.hi;
|
||||
previous.b = piece.b;
|
||||
continue;
|
||||
}
|
||||
runs.push({
|
||||
lo: piece.lo, hi: piece.hi, a: piece.a, b: piece.b,
|
||||
safe: piece.safe, signature: piece.signature,
|
||||
roomIds: piece.roomIds, finalCm: piece.finalCm,
|
||||
});
|
||||
}
|
||||
return runs;
|
||||
};
|
||||
|
||||
/** Point count and closure are deliberately irrelevant. */
|
||||
const redundantDraftIds = (
|
||||
rawDrafts: readonly RoomDraftCfg[], modelDrafts: readonly RoomDraftCfg[],
|
||||
intervals: readonly WallInterval[], eps: number,
|
||||
): Set<string> => {
|
||||
const rawById = new Map(rawDrafts.map((draft) => [draft.id, draft]));
|
||||
const out = new Set<string>();
|
||||
for (const draft of modelDrafts) {
|
||||
const raw = rawById.get(draft.id);
|
||||
if (!raw || !Array.isArray(draft.points) || draft.points.length < 2
|
||||
|| draft.segments.length !== draft.points.length - 1) continue;
|
||||
let safe = true;
|
||||
for (let index = 0; index + 1 < draft.points.length && safe; index++) {
|
||||
const a = draft.points[index], b = draft.points[index + 1];
|
||||
const cm = Number(draft.segments[index]?.cm);
|
||||
if (!finitePoint(a) || !finitePoint(b) || !(cm > 0)) { safe = false; break; }
|
||||
const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||||
if (!(length > eps)) { safe = false; break; }
|
||||
const coverage = intervals.flatMap((interval) => {
|
||||
const effectiveCm = interval.cm > 0 ? interval.cm : DRAW_WALL_DEFAULT_CM;
|
||||
if (interval.open || (interval.kind !== 'outer' && interval.kind !== 'shared')
|
||||
|| effectiveCm + eps < cm) return [];
|
||||
const range = collinearRange(a, b, interval.a, interval.b, eps);
|
||||
return range ? [range] : [];
|
||||
}).sort((first, second) => first.lo - second.lo || first.hi - second.hi);
|
||||
let reached = 0;
|
||||
for (const range of coverage) {
|
||||
if (range.lo > reached + eps) break;
|
||||
reached = Math.max(reached, range.hi);
|
||||
if (reached >= length - eps) break;
|
||||
}
|
||||
if (reached < length - eps) safe = false;
|
||||
}
|
||||
if (safe) out.add(draft.id);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
export function reconcileCoincidentPartitions(
|
||||
rawSpace: any,
|
||||
model: Pick<SpaceModel, 'rooms' | 'partitions' | 'room_drafts' | 'wall_columns'>,
|
||||
@@ -162,84 +248,58 @@ export function reconcileCoincidentPartitions(
|
||||
const walls0 = wallsInput || [];
|
||||
const partitions0 = (Array.isArray(rawSpace?.partitions) ? rawSpace.partitions : []) as PartitionCfg[];
|
||||
const openings0 = (Array.isArray(rawSpace?.openings) ? rawSpace.openings : []) as OpeningCfg[];
|
||||
if (!partitions0.length) return {
|
||||
walls: walls0,
|
||||
partitions: partitions0,
|
||||
openings: openings0,
|
||||
partitionsReconciled: 0,
|
||||
openingsRehosted: 0,
|
||||
const roomDrafts0 = (Array.isArray(rawSpace?.room_drafts)
|
||||
? rawSpace.room_drafts : []) as RoomDraftCfg[];
|
||||
const empty = {
|
||||
walls: walls0, partitions: partitions0, openings: openings0, roomDrafts: roomDrafts0,
|
||||
partitionsReconciled: 0, openingsRehosted: 0, removedDrafts: 0,
|
||||
};
|
||||
if (!partitions0.length && !roomDrafts0.length) return empty;
|
||||
|
||||
const eps = Math.max(options.gridPitch * 0.0002, 1e-9);
|
||||
const intervals = wallIntervals(
|
||||
model.rooms, walls0, openCuts,
|
||||
options.pitch, options.cellCm, options.gridPitch, options.coordScale,
|
||||
);
|
||||
const keyScale = options.coordScale > 0 ? options.coordScale : 1;
|
||||
const segmentKey = (a: readonly number[], b: readonly number[]) => wallKey(
|
||||
[a[0] / keyScale, a[1] / keyScale],
|
||||
[b[0] / keyScale, b[1] / keyScale],
|
||||
options.pitch,
|
||||
);
|
||||
const solidByKey = new Map<string, WallInterval[]>();
|
||||
for (const interval of intervals) {
|
||||
if ((interval.kind !== 'shared' && interval.kind !== 'outer')
|
||||
|| interval.open || !(interval.cm > 0)) continue;
|
||||
const key = segmentKey(interval.a, interval.b);
|
||||
const bucket = solidByKey.get(key);
|
||||
if (bucket) bucket.push(interval);
|
||||
else solidByKey.set(key, [interval]);
|
||||
}
|
||||
const modelById = new Map(model.partitions.map((partition) => [partition.id, partition]));
|
||||
let walls = walls0;
|
||||
let partitions = partitions0;
|
||||
let openings = openings0;
|
||||
const initialIntervals = wallIntervals(
|
||||
model.rooms, walls, openCuts,
|
||||
options.pitch, options.cellCm, options.gridPitch, options.coordScale,
|
||||
);
|
||||
const removedDraftIds = redundantDraftIds(
|
||||
roomDrafts0, model.room_drafts, initialIntervals, eps,
|
||||
);
|
||||
const roomDrafts = roomDrafts0.filter((draft) => !removedDraftIds.has(draft.id));
|
||||
let partitionsReconciled = 0;
|
||||
let openingsRehosted = 0;
|
||||
|
||||
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 (!partitions.some((item) => item.id === rawPartition.id)) continue;
|
||||
if (!rawPartitionKnown(rawPartition)) continue;
|
||||
const partition = modelById.get(rawPartition.id);
|
||||
if (!partition || !Number.isFinite(partition.cm) || !(partition.cm > 0)) continue;
|
||||
if (!Array.isArray(partition.a) || !Array.isArray(partition.b)) continue;
|
||||
if (Math.hypot(
|
||||
partition.b[0] - partition.a[0], partition.b[1] - partition.a[1],
|
||||
) <= eps) continue;
|
||||
const source = modelById.get(rawPartition.id);
|
||||
if (!source || !finitePoint(source.a) || !finitePoint(source.b)
|
||||
|| !Number.isFinite(source.cm) || !(source.cm > 0)) continue;
|
||||
|
||||
const owners = (solidByKey.get(segmentKey(partition.a, partition.b)) || [])
|
||||
.filter((interval) => sameSegment(
|
||||
interval.a, interval.b, partition.a, partition.b, eps,
|
||||
));
|
||||
if (!owners.length) continue;
|
||||
const byRoom = new Map<string, WallInterval>();
|
||||
for (const owner of owners) byRoom.set(owner.roomId, owner);
|
||||
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 solidCms = new Set(solid.map((interval) => interval.cm));
|
||||
if (solidCms.size !== 1) continue;
|
||||
const roomCm = solid[0].cm;
|
||||
const [origin, end] = comparePoint(source.a, source.b) <= 0
|
||||
? [[source.a[0], source.a[1]], [source.b[0], source.b[1]]]
|
||||
: [[source.b[0], source.b[1]], [source.a[0], source.a[1]]];
|
||||
const dx = end[0] - origin[0], dy = end[1] - origin[1];
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (!(length > eps)) continue;
|
||||
const ux = dx / length, uy = dy / length;
|
||||
|
||||
const anotherPartition = model.partitions.some((other) => (
|
||||
other.id !== partition.id
|
||||
&& collinearOverlap(partition.a, partition.b, other.a, other.b, eps) > eps
|
||||
));
|
||||
if (anotherPartition) continue;
|
||||
const draftOverlap = model.room_drafts.some((draft) => (
|
||||
(draft.points || []).some((point, index) => index + 1 < draft.points.length
|
||||
&& collinearOverlap(
|
||||
partition.a, partition.b, point, draft.points[index + 1], eps,
|
||||
) > eps)
|
||||
));
|
||||
if (draftOverlap || columnBlocks(partition, model.wall_columns, options, eps)) continue;
|
||||
const intervals = wallIntervals(
|
||||
model.rooms, walls, openCuts,
|
||||
options.pitch, options.cellCm, options.gridPitch, options.coordScale,
|
||||
);
|
||||
const intervalRanges = intervals.flatMap((interval) => {
|
||||
if (interval.open || (interval.kind !== 'outer' && interval.kind !== 'shared')) return [];
|
||||
const range = collinearRange(origin, end, interval.a, interval.b, eps);
|
||||
return range ? [{ interval, range }] : [];
|
||||
});
|
||||
if (!intervalRanges.length) continue;
|
||||
|
||||
const hosted = openings.filter((opening) => opening.host?.kind === 'partition'
|
||||
&& opening.host.id === partition.id);
|
||||
&& opening.host.id === source.id);
|
||||
const resolvedHosted = hosted.map((opening) => ({
|
||||
opening,
|
||||
resolved: resolvePartitionOpeningCompat(
|
||||
@@ -248,36 +308,209 @@ export function reconcileCoincidentPartitions(
|
||||
).resolved,
|
||||
}));
|
||||
if (resolvedHosted.some((item) => !item.resolved)) continue;
|
||||
if (resolvedHosted.some((item) => !partitionOpeningHasCompositeRoomWall(
|
||||
item.resolved!, solid, eps,
|
||||
))) continue;
|
||||
|
||||
const finalCm = Math.max(roomCm, partition.cm);
|
||||
const nextWalls = finalCm === roomCm ? walls : setWallThickness(
|
||||
walls, partition.a, partition.b, finalCm, options.pitch, options.coordScale,
|
||||
);
|
||||
const nextHosted = resolvedHosted.map(({ opening, resolved }) => {
|
||||
const materialized = materializePartitionOpening(
|
||||
opening, resolved!, options.coordScale,
|
||||
);
|
||||
const { host: _host, ...ordinary } = materialized;
|
||||
return ordinary as OpeningCfg;
|
||||
});
|
||||
const nextGeometries = nextHosted.map((opening) => openingGeometry(
|
||||
opening, model.partitions, options,
|
||||
const structuralBreakpoints = [0, length];
|
||||
for (const { range } of intervalRanges) structuralBreakpoints.push(range.lo, range.hi);
|
||||
const breakpoints = structuralBreakpoints.slice();
|
||||
const openingRanges = new Map<string, AxisRange>();
|
||||
for (const { opening, resolved } of resolvedHosted) {
|
||||
const centre = projection(resolved!.center, origin, ux, uy);
|
||||
const range = { lo: centre - resolved!.length / 2, hi: centre + resolved!.length / 2 };
|
||||
openingRanges.set(opening.id, range);
|
||||
breakpoints.push(Math.max(0, range.lo), Math.min(length, range.hi));
|
||||
}
|
||||
const sortedBreakpoints = [...new Set(breakpoints
|
||||
.filter((value) => Number.isFinite(value) && value >= -eps && value <= length + eps)
|
||||
.map((value) => Math.max(0, Math.min(length, value)).toFixed(9)))]
|
||||
.map(Number).sort((a, b) => a - b);
|
||||
|
||||
const currentModelPartitions = partitions.map((partition) => scaledPartition(
|
||||
partition, options.coordScale,
|
||||
));
|
||||
if (nextGeometries.some((geometry) => !geometry)) continue;
|
||||
let overlap = false;
|
||||
for (let i = 0; i < nextGeometries.length; i++) {
|
||||
for (let j = i + 1; j < nextGeometries.length; j++) {
|
||||
if (openingsOverlap(nextGeometries[i]!, nextGeometries[j]!, eps)) overlap = true;
|
||||
const otherPartitions = currentModelPartitions.filter((item) => item.id !== source.id);
|
||||
const activeDrafts = model.room_drafts.filter((draft) => !removedDraftIds.has(draft.id));
|
||||
const pieces: AtomicPiece[] = [];
|
||||
for (let index = 0; index + 1 < sortedBreakpoints.length; index++) {
|
||||
const lo = sortedBreakpoints[index], hi = sortedBreakpoints[index + 1];
|
||||
if (!(hi - lo > eps)) continue;
|
||||
const a = pointAt(origin, ux, uy, lo), b = pointAt(origin, ux, uy, hi);
|
||||
const owners = intervalRanges.filter(({ range }) => (
|
||||
range.lo <= lo + eps && range.hi >= hi - eps
|
||||
)).map(({ interval }) => interval);
|
||||
const byRoom = new Map<string, WallInterval>();
|
||||
for (const owner of owners) byRoom.set(owner.roomId, owner);
|
||||
const solid = [...byRoom.values()];
|
||||
const kinds = new Set(solid.map((owner) => owner.kind));
|
||||
const cms = new Set(solid.map((owner) => (
|
||||
owner.cm > 0 ? owner.cm : DRAW_WALL_DEFAULT_CM
|
||||
)));
|
||||
const kind = solid[0]?.kind;
|
||||
const roomIds = [...byRoom.keys()].sort();
|
||||
const proofOk = kinds.size === 1 && cms.size === 1
|
||||
&& ((kind === 'outer' && roomIds.length === 1)
|
||||
|| (kind === 'shared' && roomIds.length === 2));
|
||||
const piecePartition: PartitionCfg = { id: source.id, a, b, cm: source.cm };
|
||||
const blockedByPartition = otherPartitions.some((other) => (
|
||||
!!collinearRange(a, b, other.a, other.b, eps)
|
||||
));
|
||||
const blockedByDraft = activeDrafts.some((draft) => draft.points.some((point, at) => (
|
||||
at + 1 < draft.points.length
|
||||
&& !!collinearRange(a, b, point, draft.points[at + 1], eps)
|
||||
)));
|
||||
const safe = proofOk && !blockedByPartition && !blockedByDraft
|
||||
&& !columnBlocks(piecePartition, model.wall_columns, options, eps);
|
||||
const roomCm = proofOk
|
||||
? (solid[0].cm > 0 ? solid[0].cm : DRAW_WALL_DEFAULT_CM)
|
||||
: 0;
|
||||
const finalCm = proofOk ? Math.max(roomCm, source.cm) : source.cm;
|
||||
pieces.push({
|
||||
lo, hi, a, b, safe,
|
||||
signature: safe ? `${kind}|${roomIds.join(',')}|${finalCm}` : '',
|
||||
roomIds, roomCm, finalCm,
|
||||
});
|
||||
}
|
||||
if (!pieces.some((piece) => piece.safe)) continue;
|
||||
|
||||
const rehosted = new Map<string, { resolved: ResolvedPartitionOpening; signature: string }>();
|
||||
for (const { opening, resolved } of resolvedHosted) {
|
||||
const range = openingRanges.get(opening.id)!;
|
||||
const crossesStructural = structuralBreakpoints.some((point) => (
|
||||
point > range.lo + eps && point < range.hi - eps
|
||||
));
|
||||
const touched = pieces.filter((piece) => rangesOverlap(piece, range, eps));
|
||||
const signatures = new Set(touched.filter((piece) => piece.safe)
|
||||
.map((piece) => piece.signature));
|
||||
const canRehost = !crossesStructural && touched.length > 0
|
||||
&& touched.every((piece) => piece.safe) && signatures.size === 1
|
||||
&& partitionOpeningHasCompositeRoomWall(resolved!, intervals, eps);
|
||||
if (canRehost) {
|
||||
rehosted.set(opening.id, { resolved: resolved!, signature: [...signatures][0] });
|
||||
continue;
|
||||
}
|
||||
const jamb = partitionOpeningJambMargin(source, options.cellCm, options.gridPitch);
|
||||
const protectedRange = { lo: range.lo - jamb, hi: range.hi + jamb };
|
||||
for (const piece of pieces) {
|
||||
if (rangesOverlap(piece, protectedRange, eps)) {
|
||||
piece.safe = false;
|
||||
piece.signature = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
const hostedIds = new Set(hosted.map((opening) => opening.id));
|
||||
for (const opening of openings) {
|
||||
if (hostedIds.has(opening.id)) continue;
|
||||
const geometry = openingGeometry(opening, model.partitions, options);
|
||||
if (geometry && nextGeometries.some((candidate) => openingsOverlap(candidate!, geometry, eps))) {
|
||||
|
||||
// A later unsafe hosted opening can invalidate an earlier tentative
|
||||
// conversion on the same atomic pieces. Re-check to a fixed point and
|
||||
// protect every invalidated opening's jambs for residual re-hosting.
|
||||
let rehostInvalidated = true;
|
||||
while (rehostInvalidated) {
|
||||
rehostInvalidated = false;
|
||||
for (const [openingId, item] of [...rehosted.entries()]) {
|
||||
const range = openingRanges.get(openingId)!;
|
||||
const touched = pieces.filter((piece) => rangesOverlap(piece, range, eps));
|
||||
if (touched.length > 0 && touched.every((piece) => piece.safe)
|
||||
&& touched.every((piece) => piece.signature === item.signature)) continue;
|
||||
rehosted.delete(openingId);
|
||||
const jamb = partitionOpeningJambMargin(source, options.cellCm, options.gridPitch);
|
||||
const protectedRange = { lo: range.lo - jamb, hi: range.hi + jamb };
|
||||
for (const piece of pieces) {
|
||||
if (rangesOverlap(piece, protectedRange, eps)) {
|
||||
piece.safe = false;
|
||||
piece.signature = '';
|
||||
}
|
||||
}
|
||||
rehostInvalidated = true;
|
||||
}
|
||||
}
|
||||
|
||||
const runs = mergePieces(pieces, eps);
|
||||
const safeRuns = runs.filter((run) => run.safe);
|
||||
const residualRuns = runs.filter((run) => !run.safe);
|
||||
if (!safeRuns.length) continue;
|
||||
if (partitions.length - 1 + residualRuns.length > MAX_PARTITIONS) continue;
|
||||
|
||||
let nextWalls = walls;
|
||||
for (const run of safeRuns) {
|
||||
const [runA, runB] = comparePoint(source.a, source.b) <= 0
|
||||
? [run.a, run.b] : [run.b, run.a];
|
||||
nextWalls = setWallThickness(
|
||||
nextWalls, runA, runB, run.finalCm, options.pitch, options.coordScale,
|
||||
);
|
||||
}
|
||||
nextWalls = normalizeWallIntervals(
|
||||
model.rooms, nextWalls, openCuts,
|
||||
options.pitch, options.cellCm, options.gridPitch, options.coordScale,
|
||||
);
|
||||
if (nextWalls.length > MAX_WALLS) continue;
|
||||
|
||||
const occupied = new Set(partitions.map((item) => item.id));
|
||||
occupied.delete(source.id);
|
||||
const nextResiduals: PartitionCfg[] = residualRuns.map((run, index) => {
|
||||
const a = [run.a[0] / options.coordScale, run.a[1] / options.coordScale];
|
||||
const b = [run.b[0] / options.coordScale, run.b[1] / options.coordScale];
|
||||
const id = index === 0 ? source.id : residualId(source.id, a, b, occupied);
|
||||
occupied.add(id);
|
||||
return { id, a, b, cm: source.cm };
|
||||
});
|
||||
const sourceIndex = partitions.findIndex((item) => item.id === source.id);
|
||||
const nextPartitions = partitions.slice();
|
||||
nextPartitions.splice(sourceIndex, 1, ...nextResiduals);
|
||||
const nextModelPartitions = nextPartitions.map((partition) => scaledPartition(
|
||||
partition, options.coordScale,
|
||||
));
|
||||
|
||||
const openingReplacement = new Map<string, OpeningCfg>();
|
||||
let residualBindingsOk = true;
|
||||
for (const { opening, resolved } of resolvedHosted) {
|
||||
const converted = rehosted.get(opening.id);
|
||||
if (converted) {
|
||||
const materialized = materializePartitionOpening(
|
||||
opening, converted.resolved, options.coordScale,
|
||||
);
|
||||
const { host: _host, ...ordinary } = materialized;
|
||||
openingReplacement.set(opening.id, ordinary as OpeningCfg);
|
||||
continue;
|
||||
}
|
||||
const range = openingRanges.get(opening.id)!;
|
||||
const residualIndex = residualRuns.findIndex((run) => (
|
||||
run.lo <= range.lo + eps && run.hi >= range.hi - eps
|
||||
));
|
||||
const residual = nextResiduals[residualIndex];
|
||||
const residualModel = residual && nextModelPartitions.find((item) => item.id === residual.id);
|
||||
if (!residual || !residualModel) { residualBindingsOk = false; break; }
|
||||
const residualDx = residualModel.b[0] - residualModel.a[0];
|
||||
const residualDy = residualModel.b[1] - residualModel.a[1];
|
||||
const residualLength = Math.hypot(residualDx, residualDy);
|
||||
const centre = projection(
|
||||
resolved!.center, residualModel.a, residualDx / residualLength, residualDy / residualLength,
|
||||
);
|
||||
const rebound = {
|
||||
...materializePartitionOpening(opening, resolved!, options.coordScale),
|
||||
host: { kind: 'partition' as const, id: residual.id, t: centre / residualLength },
|
||||
};
|
||||
if (!resolvePartitionOpeningStrict(
|
||||
rebound, nextModelPartitions,
|
||||
options.coordScale, options.cellCm, options.gridPitch,
|
||||
).resolved) { residualBindingsOk = false; break; }
|
||||
openingReplacement.set(opening.id, rebound);
|
||||
}
|
||||
if (!residualBindingsOk) continue;
|
||||
|
||||
const nextOpenings = openings.map((opening) => openingReplacement.get(opening.id) || opening);
|
||||
const convertedOpenings = [...rehosted.keys()].map((id) => openingReplacement.get(id)!);
|
||||
const convertedGeometry = convertedOpenings.map((opening) => openingGeometry(
|
||||
opening, nextModelPartitions, options,
|
||||
));
|
||||
if (convertedGeometry.some((geometry) => !geometry)) continue;
|
||||
let overlap = false;
|
||||
for (let first = 0; first < convertedGeometry.length; first++) {
|
||||
for (let second = first + 1; second < convertedGeometry.length; second++) {
|
||||
if (openingsOverlap(convertedGeometry[first]!, convertedGeometry[second]!, eps)) overlap = true;
|
||||
}
|
||||
}
|
||||
const convertedIds = new Set(convertedOpenings.map((opening) => opening.id));
|
||||
for (const opening of nextOpenings) {
|
||||
if (convertedIds.has(opening.id)) continue;
|
||||
const geometry = openingGeometry(opening, nextModelPartitions, options);
|
||||
if (geometry && convertedGeometry.some((candidate) => openingsOverlap(candidate!, geometry, eps))) {
|
||||
overlap = true;
|
||||
}
|
||||
}
|
||||
@@ -287,29 +520,32 @@ export function reconcileCoincidentPartitions(
|
||||
model.rooms, nextWalls, openCuts,
|
||||
options.pitch, options.cellCm, options.gridPitch, options.coordScale,
|
||||
);
|
||||
const roomIds = new Set(solid.map((interval) => interval.roomId));
|
||||
const associationsOk = nextHosted.every((opening) => {
|
||||
const associationsOk = [...rehosted.entries()].every(([openingId, item]) => {
|
||||
const replacement = openingReplacement.get(openingId)!;
|
||||
const association = resolveOpeningWallAssociation(index, {
|
||||
x: opening.x * options.coordScale,
|
||||
y: opening.y * options.coordScale,
|
||||
angle: opening.angle,
|
||||
length: opening.length * options.coordScale,
|
||||
x: replacement.x * options.coordScale,
|
||||
y: replacement.y * options.coordScale,
|
||||
angle: replacement.angle,
|
||||
length: replacement.length * options.coordScale,
|
||||
}, true);
|
||||
const full = [association.negative, association.positive]
|
||||
.filter((side): side is NonNullable<typeof association.negative> => !!side?.full);
|
||||
const expected = item.signature.split('|')[1].split(',');
|
||||
const associated = new Set(full.map((side) => side.roomId));
|
||||
return full.length === roomIds.size && associated.size === roomIds.size
|
||||
&& [...roomIds].every((roomId) => associated.has(roomId));
|
||||
return full.length === expected.length && associated.size === expected.length
|
||||
&& expected.every((roomId) => associated.has(roomId));
|
||||
});
|
||||
if (!associationsOk) continue;
|
||||
|
||||
walls = nextWalls;
|
||||
partitions = partitions.filter((item) => item.id !== partition.id);
|
||||
const replacement = new Map(nextHosted.map((opening) => [opening.id, opening]));
|
||||
openings = openings.map((opening) => replacement.get(opening.id) || opening);
|
||||
partitionsReconciled++;
|
||||
openingsRehosted += nextHosted.length;
|
||||
partitions = nextPartitions;
|
||||
openings = nextOpenings;
|
||||
partitionsReconciled += safeRuns.length;
|
||||
openingsRehosted += rehosted.size;
|
||||
}
|
||||
|
||||
return { walls, partitions, openings, partitionsReconciled, openingsRehosted };
|
||||
return {
|
||||
walls, partitions, openings, roomDrafts,
|
||||
partitionsReconciled, openingsRehosted, removedDrafts: removedDraftIds.size,
|
||||
};
|
||||
}
|
||||
|
||||
+57
-3
@@ -157,7 +157,9 @@ import {
|
||||
type PartitionOpeningOrphanReason, type ResolvedPartitionOpening,
|
||||
} from './partition-openings';
|
||||
import {
|
||||
buildPlanSnapGeometry, resolvePlanSnapResult, resolveStrictPlanSnap,
|
||||
buildHiddenWallDiagnosticGeometry, buildPlanSnapGeometry,
|
||||
resolvePlanSnapResult, resolveStrictPlanSnap,
|
||||
type HiddenWallDiagnosticGeometry,
|
||||
type PlanSnapCandidate, type PlanSnapEndpoint, type PlanSnapGeometry, type PlanSnapSegment,
|
||||
} from './plan-snap-overlay';
|
||||
import {
|
||||
@@ -1571,6 +1573,9 @@ class HouseplanCard extends LitElement {
|
||||
} | null = null;
|
||||
private _planSnapGeometryCache: { key: string; value: PlanSnapGeometry } | null = null;
|
||||
private _planStructuralGeometryCache: { key: string; value: PlanSnapGeometry } | null = null;
|
||||
private _hiddenWallDiagnosticCache: {
|
||||
key: string; value: HiddenWallDiagnosticGeometry;
|
||||
} | null = null;
|
||||
private _physicalBodiesCache: {
|
||||
key: string; drafts: number[][][]; partitions: number[][][];
|
||||
columns: number[][][]; patches: number[][][]; all: number[][][];
|
||||
@@ -3562,6 +3567,7 @@ class HouseplanCard extends LitElement {
|
||||
this._saveConfigDebounced.cancel();
|
||||
this._frame = null;
|
||||
this._planSnapGeometryCache = null;
|
||||
this._hiddenWallDiagnosticCache = null;
|
||||
this._decorSnapCache = null;
|
||||
this._commitSpace('', true);
|
||||
}
|
||||
@@ -6863,6 +6869,30 @@ class HouseplanCard extends LitElement {
|
||||
return this._planSnapGeometryCache;
|
||||
}
|
||||
|
||||
/** Independent sources hidden under another wall, without snap deduplication. */
|
||||
private _hiddenWallDiagnosticSnapshot(): {
|
||||
key: string; value: HiddenWallDiagnosticGeometry;
|
||||
} {
|
||||
const space = this._spaceModel();
|
||||
if (!space) {
|
||||
return { key: `${this._space}|hidden-empty`, value: { segments: [], endpoints: [] } };
|
||||
}
|
||||
const key = [
|
||||
'hidden', this._space, this._cfgEpoch, this._activeDraftId || '',
|
||||
space.rooms.length, space.room_drafts.length, space.partitions.length,
|
||||
].join('|');
|
||||
if (this._hiddenWallDiagnosticCache?.key === key) {
|
||||
return this._hiddenWallDiagnosticCache;
|
||||
}
|
||||
const value = buildHiddenWallDiagnosticGeometry({
|
||||
space,
|
||||
activeDraftId: this._activeDraftId,
|
||||
epsilon: this._gridPitch * 0.0002,
|
||||
});
|
||||
this._hiddenWallDiagnosticCache = { key, value };
|
||||
return this._hiddenWallDiagnosticCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Room-face topology deliberately ignores door/window/gate/passage slots:
|
||||
* an opening cuts masonry but does not remove the owning wall from a room
|
||||
@@ -17349,8 +17379,6 @@ class HouseplanCard extends LitElement {
|
||||
inside thick jambs without changing the stored span. */}
|
||||
${!this._editing ? this._renderOpenWalls(disp) : nothing}
|
||||
${this._renderWallBodies(disp)}
|
||||
${this._markup ? svg`<g class="hp-editor-only-layer"
|
||||
opacity="${modeVisual?.editorWeight ?? 1}">${this._renderPlanSnapOverlay()}</g>` : nothing}
|
||||
${this._markup ? svg`<g class="hp-editor-only-layer"
|
||||
opacity="${modeVisual?.editorWeight ?? 1}">${this._renderOpeningPlacementPreview()}</g>` : nothing}
|
||||
${opMeasure ? this._renderOpeningDimensionGuides(opMeasure) : nothing}
|
||||
@@ -17360,6 +17388,10 @@ class HouseplanCard extends LitElement {
|
||||
preview deliberately paint AFTER real wall bodies. Their
|
||||
full centreline geometry remains visible for editing. */}
|
||||
${this._editing ? this._renderOpenWalls(disp) : nothing}
|
||||
${this._markup ? svg`<g class="hp-editor-only-layer"
|
||||
opacity="${modeVisual?.editorWeight ?? 1}">${this._tool === 'draw'
|
||||
? this._renderPlanSnapOverlay()
|
||||
: this._renderHiddenWallDiagnosticOverlay()}</g>` : nothing}
|
||||
${disp.hideOpenings && !this._markup
|
||||
? nothing
|
||||
: isoLayers && !isoLayers.floorSymbols
|
||||
@@ -19547,6 +19579,28 @@ class HouseplanCard extends LitElement {
|
||||
return svg`<g class="physical-editor">${draftSegs}${partitions}${columns}${ghost}${chrome}</g>`;
|
||||
}
|
||||
|
||||
private _renderHiddenWallDiagnosticOverlay(): TemplateResult {
|
||||
if (!this._markup || this._tool === 'draw') return svg`` as unknown as TemplateResult;
|
||||
const geometry = this._hiddenWallDiagnosticSnapshot().value;
|
||||
if (!geometry.segments.length) return svg`` as unknown as TemplateResult;
|
||||
const radius = wallCmToUnits(5, this._cellCm, this._gridPitch);
|
||||
return svg`<g class="hidden-wall-diagnostic" data-hp="hidden-wall-diagnostic"
|
||||
data-segment-count=${geometry.segments.length}
|
||||
data-endpoint-count=${geometry.endpoints.length}
|
||||
aria-hidden="true" pointer-events="none">
|
||||
${geometry.segments.map((segment) => svg`<line class="hidden-wall-line"
|
||||
data-key=${segment.key} data-source-kind=${segment.sourceKind}
|
||||
data-source-id=${segment.sourceId}
|
||||
x1=${segment.a[0]} y1=${segment.a[1]} x2=${segment.b[0]} y2=${segment.b[1]}
|
||||
vector-effect="non-scaling-stroke" pointer-events="none"></line>`)}
|
||||
${geometry.endpoints.map((endpoint) => svg`<circle class="hidden-wall-node"
|
||||
data-key=${endpoint.key} data-source-kind=${endpoint.sourceKind}
|
||||
data-source-id=${endpoint.sourceId}
|
||||
cx=${endpoint.point[0]} cy=${endpoint.point[1]} r=${radius}
|
||||
pointer-events="none"></circle>`)}
|
||||
</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
private _renderPlanSnapOverlay(): TemplateResult {
|
||||
if (!this._markup || this._tool !== 'draw') {
|
||||
return svg`` as unknown as TemplateResult;
|
||||
|
||||
+1
-1
@@ -818,7 +818,7 @@
|
||||
"gs.optimize_changes": "Model migrations: {m}; spaces updated: {c}; noisy coordinate values removed: {p}; merged real-wall fragments: {w}; virtual fragments: {s}; independent walls: {i}.",
|
||||
"gs.optimize_lattice_summary": "Noisy coordinate values canonicalized: {n}; maximum movement: {cm} cm.",
|
||||
"gs.optimize_lattice_space": "{space}: coordinate values canonicalized: {n}; off-grid values left unchanged: {far}.",
|
||||
"gs.optimize_coincident_partitions": "Coincident partitions converted to room walls: {n}.",
|
||||
"gs.optimize_coincident_partitions": "Hidden independent wall sections absorbed into room walls: {n}.",
|
||||
"gs.optimize_openings_rehosted": "Openings reattached to room walls: {n}.",
|
||||
"gs.optimize_walls_straightened": "Walls straightened: {n}; maximum movement: {cm} cm.",
|
||||
"gs.optimize_walls_straightened_where": "Largest wall correction: {s}.",
|
||||
|
||||
+1
-1
@@ -818,7 +818,7 @@
|
||||
"gs.optimize_changes": "Миграций модели: {m}; обновлено пространств: {c}; устранён шум координат: {p}; объединено отрезков реальных стен: {w}; виртуальных: {s}; независимых: {i}.",
|
||||
"gs.optimize_lattice_summary": "Канонизировано шумовых значений координат: {n}; максимальный сдвиг: {cm} см.",
|
||||
"gs.optimize_lattice_space": "{space}: канонизировано значений координат: {n}; оставлено значений вне сетки: {far}.",
|
||||
"gs.optimize_coincident_partitions": "Совпадающие перегородки преобразованы в стены: {n}.",
|
||||
"gs.optimize_coincident_partitions": "Скрытые участки независимых стен поглощены стенами комнат: {n}.",
|
||||
"gs.optimize_openings_rehosted": "Проёмы перепривязаны к стенам комнат: {n}.",
|
||||
"gs.optimize_walls_straightened": "Выпрямлено стен: {n}; максимальное перемещение: {cm} см.",
|
||||
"gs.optimize_walls_straightened_where": "Максимальная правка стены: {s}.",
|
||||
|
||||
@@ -631,6 +631,11 @@ export function optimizePlans(
|
||||
if (reconciled.walls.length) space.walls = reconciled.walls;
|
||||
else delete space.walls;
|
||||
}
|
||||
if (reconciled.removedDrafts) {
|
||||
alignReport.removedDrafts += reconciled.removedDrafts;
|
||||
if (reconciled.roomDrafts.length) space.room_drafts = reconciled.roomDrafts;
|
||||
else delete space.room_drafts;
|
||||
}
|
||||
}
|
||||
const canonicalAfter = JSON.stringify({
|
||||
spans: space.open_spans || [],
|
||||
|
||||
@@ -21,6 +21,16 @@ export interface PlanSnapGeometry {
|
||||
endpoints: PlanSnapEndpoint[];
|
||||
}
|
||||
|
||||
export interface HiddenWallDiagnosticEndpoint extends PlanSnapEndpoint {
|
||||
sourceKind: 'draft' | 'partition';
|
||||
sourceId: string;
|
||||
}
|
||||
|
||||
export interface HiddenWallDiagnosticGeometry {
|
||||
segments: PlanSnapSegment[];
|
||||
endpoints: HiddenWallDiagnosticEndpoint[];
|
||||
}
|
||||
|
||||
export interface PlanSnapExtraEndpoint {
|
||||
point: readonly number[];
|
||||
key: string;
|
||||
@@ -128,6 +138,87 @@ function touches(point: readonly number[], segment: readonly number[], epsilon:
|
||||
|| pointsEqual(point, [segment[2], segment[3]], epsilon);
|
||||
}
|
||||
|
||||
function positiveCollinearOverlap(
|
||||
first: SourceSegment, second: SourceSegment, epsilon: number,
|
||||
): boolean {
|
||||
const dx = first.b[0] - first.a[0];
|
||||
const dy = first.b[1] - first.a[1];
|
||||
const length = Math.hypot(dx, dy);
|
||||
if (!(length > epsilon)) return false;
|
||||
const ux = dx / length, uy = dy / length;
|
||||
const across = (point: readonly number[]) => Math.abs(
|
||||
(point[0] - first.a[0]) * uy - (point[1] - first.a[1]) * ux
|
||||
);
|
||||
if (across(second.a) > epsilon || across(second.b) > epsilon) return false;
|
||||
const firstAlong = (second.a[0] - first.a[0]) * ux
|
||||
+ (second.a[1] - first.a[1]) * uy;
|
||||
const secondAlong = (second.b[0] - first.a[0]) * ux
|
||||
+ (second.b[1] - first.a[1]) * uy;
|
||||
return Math.min(length, Math.max(firstAlong, secondAlong))
|
||||
- Math.max(0, Math.min(firstAlong, secondAlong)) > epsilon;
|
||||
}
|
||||
|
||||
/**
|
||||
* Preserve the identity of an independent source which is visually hidden by
|
||||
* another wall. Unlike buildPlanSnapGeometry this projection deliberately does
|
||||
* not deduplicate a room-owned axis over a partition/draft-owned one.
|
||||
*/
|
||||
export function buildHiddenWallDiagnosticGeometry(options: {
|
||||
space: Pick<SpaceModel, 'rooms' | 'room_drafts' | 'partitions'>;
|
||||
activeDraftId?: string | null;
|
||||
epsilon?: number;
|
||||
}): HiddenWallDiagnosticGeometry {
|
||||
const epsilon = options.epsilon ?? DEFAULT_EPSILON;
|
||||
const sources: SourceSegment[] = [];
|
||||
for (const [index, segment] of roomEdges(options.space.rooms).entries()) {
|
||||
if (segment.length < 4) continue;
|
||||
sources.push({
|
||||
a: [segment[0], segment[1]], b: [segment[2], segment[3]],
|
||||
kind: 'room', id: `room-edge-${index}`, cuts: [],
|
||||
});
|
||||
}
|
||||
for (const draft of options.space.room_drafts || []) {
|
||||
if (draft.id === options.activeDraftId) continue;
|
||||
for (let index = 0; index + 1 < draft.points.length; index++) {
|
||||
const a = draft.points[index], b = draft.points[index + 1];
|
||||
if (!finitePoint(a) || !finitePoint(b) || pointsEqual(a, b, epsilon)) continue;
|
||||
sources.push({
|
||||
a: [a[0], a[1]], b: [b[0], b[1]],
|
||||
kind: 'draft', id: `${draft.id}:${index}`, cuts: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const partition of options.space.partitions || []) {
|
||||
if (!finitePoint(partition.a) || !finitePoint(partition.b)
|
||||
|| pointsEqual(partition.a, partition.b, epsilon)) continue;
|
||||
sources.push({
|
||||
a: [partition.a[0], partition.a[1]],
|
||||
b: [partition.b[0], partition.b[1]],
|
||||
kind: 'partition', id: partition.id, cuts: [],
|
||||
});
|
||||
}
|
||||
|
||||
const hidden = sources.filter((source) => source.kind !== 'room'
|
||||
&& sources.some((other) => other !== source
|
||||
&& positiveCollinearOverlap(source, other, epsilon)));
|
||||
const segments = hidden.map((source): PlanSnapSegment => {
|
||||
const [a, b] = canonicalPair(source.a, source.b);
|
||||
return {
|
||||
a, b, key: `hidden|${segmentKey(source, a, b)}`,
|
||||
sourceKind: source.kind, sourceId: source.id,
|
||||
};
|
||||
}).sort((a, b) => a.key.localeCompare(b.key));
|
||||
const endpoints = hidden.flatMap((source): HiddenWallDiagnosticEndpoint[] => (
|
||||
[source.a, source.b].map((point, index) => ({
|
||||
point: [point[0], point[1]],
|
||||
key: `hidden|${sourceKey(source)}|endpoint-${index}`,
|
||||
sourceKind: source.kind as 'draft' | 'partition',
|
||||
sourceId: source.id,
|
||||
}))
|
||||
)).sort((a, b) => a.key.localeCompare(b.key));
|
||||
return { segments, endpoints };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the immutable architectural axes used by both the overlay and snap resolver.
|
||||
* Opening/open-span cuts apply to room-owned walls, while hosted opening cuts apply
|
||||
|
||||
+16
-6
@@ -1863,10 +1863,13 @@ export const cardStyles = css`
|
||||
stroke: #04121f;
|
||||
}
|
||||
.plan-snap-overlay,
|
||||
.plan-snap-overlay * {
|
||||
.plan-snap-overlay *,
|
||||
.hidden-wall-diagnostic,
|
||||
.hidden-wall-diagnostic * {
|
||||
pointer-events: none;
|
||||
}
|
||||
.plan-snap-line {
|
||||
.plan-snap-line,
|
||||
.hidden-wall-line {
|
||||
fill: none;
|
||||
stroke: color-mix(in srgb, var(--hp-accent) 82%, white 18%);
|
||||
/* Explicit non-scaling-stroke in the SVG keeps this one screen pixel. */
|
||||
@@ -1874,7 +1877,8 @@ export const cardStyles = css`
|
||||
stroke-linecap: round;
|
||||
opacity: 0.92;
|
||||
}
|
||||
.plan-snap-node {
|
||||
.plan-snap-node,
|
||||
.hidden-wall-node {
|
||||
fill: var(--ha-card-background, var(--card-background-color, #fff));
|
||||
stroke: color-mix(in srgb, var(--hp-accent) 88%, #07131c 12%);
|
||||
stroke-width: 1;
|
||||
@@ -1898,7 +1902,11 @@ export const cardStyles = css`
|
||||
.plan-snap-line {
|
||||
stroke: color-mix(in srgb, var(--hp-accent) 72%, white 28%);
|
||||
}
|
||||
.plan-snap-node {
|
||||
.hidden-wall-line {
|
||||
stroke: color-mix(in srgb, var(--hp-accent) 72%, white 28%);
|
||||
}
|
||||
.plan-snap-node,
|
||||
.hidden-wall-node {
|
||||
fill: #17242c;
|
||||
stroke: #9bdcf5;
|
||||
}
|
||||
@@ -1912,12 +1920,14 @@ export const cardStyles = css`
|
||||
}
|
||||
}
|
||||
@media (forced-colors: active) {
|
||||
.plan-snap-line {
|
||||
.plan-snap-line,
|
||||
.hidden-wall-line {
|
||||
stroke: CanvasText;
|
||||
opacity: 1;
|
||||
forced-color-adjust: auto;
|
||||
}
|
||||
.plan-snap-node {
|
||||
.plan-snap-node,
|
||||
.hidden-wall-node {
|
||||
fill: Canvas;
|
||||
stroke: CanvasText;
|
||||
forced-color-adjust: auto;
|
||||
|
||||
@@ -165,7 +165,7 @@ test('issue 276 reconciliation is owned by explicit Optimize and called once per
|
||||
'render/pointer modules must not import or invoke the Optimize-only pass');
|
||||
});
|
||||
|
||||
test('issue 276 fails closed for an orphan host, overlap, draft, column and unknown partition data', () => {
|
||||
test('issue 276 fails closed for an orphan host, overlap, column and unknown partition data', () => {
|
||||
const variants = [];
|
||||
|
||||
const orphan = clone(fixture);
|
||||
@@ -179,13 +179,6 @@ test('issue 276 fails closed for an orphan host, overlap, draft, column and unkn
|
||||
});
|
||||
variants.push(['overlapping opening', overlap]);
|
||||
|
||||
const draft = clone(fixture);
|
||||
draft.spaces[0].room_drafts = [{
|
||||
id: 'draft', points: [[0.5041666666666667, 0.2], [0.5041666666666667, 0.8]],
|
||||
segments: [{ cm: 15 }],
|
||||
}];
|
||||
variants.push(['overlapping draft', draft]);
|
||||
|
||||
const column = clone(fixture);
|
||||
column.spaces[0].wall_columns = [{
|
||||
id: 'column', shape: 'circle', center: [0.5041666666666667, 0.5], cm: 20,
|
||||
@@ -203,3 +196,17 @@ test('issue 276 fails closed for an orphan host, overlap, draft, column and unkn
|
||||
assert.ok(result.config.spaces[0].openings[0].host, name);
|
||||
}
|
||||
});
|
||||
|
||||
test('issue 296 removes a fully hidden saved chain before reconciling its partition', () => {
|
||||
const input = clone(fixture);
|
||||
input.spaces[0].room_drafts = [{
|
||||
id: 'hidden-draft',
|
||||
points: [[0.5041666666666667, 0.2], [0.5041666666666667, 0.8]],
|
||||
segments: [{ cm: 15 }],
|
||||
}];
|
||||
const result = optimize(input);
|
||||
assert.equal(result.report.removedDrafts, 1);
|
||||
assert.equal(result.report.partitionsReconciled, 1);
|
||||
assert.equal(result.config.spaces[0].room_drafts, undefined);
|
||||
assert.equal(result.config.spaces[0].partitions, undefined);
|
||||
});
|
||||
|
||||
+32
@@ -520,6 +520,38 @@
|
||||
3.866666667
|
||||
],
|
||||
"cm": 30
|
||||
},
|
||||
{
|
||||
"id": "partition-room-mt7ijuyq-0",
|
||||
"a": [
|
||||
-0.354166667,
|
||||
2.2875
|
||||
],
|
||||
"b": [
|
||||
-0.354166667,
|
||||
3.866666667
|
||||
],
|
||||
"cm": 20
|
||||
}
|
||||
],
|
||||
"room_drafts": [
|
||||
{
|
||||
"id": "draft-mt7igts5",
|
||||
"points": [
|
||||
[
|
||||
-0.354166667,
|
||||
3.866666667
|
||||
],
|
||||
[
|
||||
1.058333333,
|
||||
3.866666667
|
||||
]
|
||||
],
|
||||
"segments": [
|
||||
{
|
||||
"cm": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"openings": []
|
||||
|
||||
@@ -256,7 +256,9 @@ test('corner Split golden captures before, thin and thick facade states', () =>
|
||||
});
|
||||
|
||||
test('issue 276 golden captures 5 cm offsets and hosted door before/after 10/30/virtual', () => {
|
||||
const scenarios = GOLDEN_SCENARIOS.filter((scenario) => scenario.coincidentPartition);
|
||||
const scenarios = GOLDEN_SCENARIOS.filter((scenario) => (
|
||||
scenario.coincidentPartition && !scenario.hiddenWallDiagnostics
|
||||
));
|
||||
assert.deepEqual(
|
||||
scenarios.map((scenario) => scenario.coincidentPartition),
|
||||
['before', 'thin', 'thick', 'virtual'],
|
||||
@@ -285,6 +287,19 @@ test('issue 276 golden captures 5 cm offsets and hosted door before/after 10/30/
|
||||
}
|
||||
});
|
||||
|
||||
test('issue 296 golden shows hidden partition and saved-chain diagnostics in both themes', () => {
|
||||
const scenarios = GOLDEN_SCENARIOS.filter((scenario) => scenario.hiddenWallDiagnostics);
|
||||
assert.deepEqual(scenarios.map((scenario) => scenario.theme).sort(), ['dark', 'light']);
|
||||
for (const scenario of scenarios) {
|
||||
assert.equal(scenario.mode, 'plan');
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
assert.equal(space.partitions.length, 1);
|
||||
assert.equal(space.room_drafts.length, 1);
|
||||
assert.deepEqual(space.room_drafts[0].points, [[0, 0], [0, 1]]);
|
||||
}
|
||||
});
|
||||
|
||||
test('filled opening golden has a pixel-level seam detector', () => {
|
||||
const scenario = GOLDEN_SCENARIOS.find((item) => item.id === 'openings-filled-tunnel-dark');
|
||||
assert.ok(scenario);
|
||||
|
||||
@@ -643,7 +643,7 @@ test('реальный план: перегородка поверх наруж
|
||||
// Долг фикстуры, а не «допустимо»: `partition-mt2on9ou-0` лежит на наружной
|
||||
// стене трёх комнат и выключает ресайз Мастера с/у и Гардеробной. Число здесь
|
||||
// обязано стать нулём вместе с закрытием #296 — иначе починка пройдёт молча.
|
||||
const debt = { 'real-plan-second-floor.json': 1, 'real-plan-first-floor.json': 0 };
|
||||
const debt = { 'real-plan-second-floor.json': 3, 'real-plan-first-floor.json': 0 };
|
||||
for (const [file, expected] of Object.entries(debt)) {
|
||||
const { config } = readModel(
|
||||
readFileSync(resolve(repoRoot, 'test/fixtures', file), 'utf8'));
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { optimizePlans } from '../test-build/plan-optimizer.js';
|
||||
import { GRID_PITCH, GRID_STEP_N as S } from '../test-build/space-geometry.js';
|
||||
import { wallIntervals, wallKey } from '../test-build/wall-thickness.js';
|
||||
|
||||
const clone = (value) => structuredClone(value);
|
||||
const fixture = JSON.parse(readFileSync(
|
||||
new URL('./fixtures/real-plan-second-floor.json', import.meta.url), 'utf8',
|
||||
));
|
||||
|
||||
const configOf = (space) => ({ model_version: 7, spaces: [space], markers: [], settings: {} });
|
||||
|
||||
test('issue 296 real second floor removes all three hidden blockers in one Optimize', () => {
|
||||
const input = configOf(clone(fixture.space));
|
||||
const before = clone(input);
|
||||
const result = optimizePlans(input, {});
|
||||
assert.deepEqual(input, before, 'preview must stay immutable');
|
||||
assert.equal(result.changed, true);
|
||||
assert.ok(result.report.partitionsReconciled >= 2);
|
||||
assert.equal(result.report.removedDrafts, 1);
|
||||
const space = result.config.spaces[0];
|
||||
assert.equal(space.partitions, undefined);
|
||||
assert.equal(space.room_drafts, undefined);
|
||||
|
||||
const leftProfile = wallIntervals(
|
||||
space.rooms, space.walls, [], S, space.cell_cm, GRID_PITCH, 1,
|
||||
).filter((interval) => Math.abs(interval.a[0] + 1.6708333333333334) < 1e-8
|
||||
&& Math.abs(interval.b[0] + 1.6708333333333334) < 1e-8
|
||||
&& Math.min(interval.a[1], interval.b[1]) >= 1.2666666666666666 - 1e-8
|
||||
&& Math.max(interval.a[1], interval.b[1]) <= 3.8666666666666667 + 1e-8);
|
||||
assert.ok(leftProfile.length >= 3);
|
||||
assert.deepEqual(new Set(leftProfile.map((interval) => interval.cm)), new Set([30]));
|
||||
|
||||
const second = optimizePlans(result.config, result.layout);
|
||||
assert.equal(second.changed, false);
|
||||
assert.equal(second.report.partitionsReconciled, 0);
|
||||
assert.equal(second.report.removedDrafts, 0);
|
||||
assert.deepEqual(second.config, result.config);
|
||||
});
|
||||
|
||||
test('issue 296 reconciles the covered middle and keeps deterministic disjoint residuals', () => {
|
||||
const space = {
|
||||
id: 'partial', title: 'Partial', view_box: [-0.3, -0.3, 1.6, 1.6], cell_cm: 5,
|
||||
rooms: [{
|
||||
id: 'room', name: 'room', area: null,
|
||||
poly: [[0, 0], [1, 0], [1, 1], [0, 1]],
|
||||
}],
|
||||
walls: [{ key: wallKey([0, 0], [1, 0], S), a: [0, 0], b: [1, 0], cm: 15 }],
|
||||
partitions: [{ id: 'long-wall', a: [-0.25, 0], b: [1.25, 0], cm: 20 }],
|
||||
};
|
||||
const first = optimizePlans(configOf(space), {});
|
||||
assert.equal(first.report.partitionsReconciled, 1);
|
||||
assert.equal(first.config.spaces[0].partitions.length, 2);
|
||||
assert.equal(first.config.spaces[0].partitions[0].id, 'long-wall');
|
||||
assert.match(first.config.spaces[0].partitions[1].id, /^long-wall~r-/);
|
||||
assert.deepEqual(first.config.spaces[0].partitions.map((partition) => [partition.a, partition.b]), [
|
||||
[[-0.25, 0], [0, 0]],
|
||||
[[1, 0], [1.25, 0]],
|
||||
]);
|
||||
const ids = first.config.spaces[0].partitions.map((partition) => partition.id);
|
||||
const second = optimizePlans(first.config, first.layout);
|
||||
assert.equal(second.changed, false);
|
||||
assert.deepEqual(second.config.spaces[0].partitions.map((partition) => partition.id), ids);
|
||||
});
|
||||
|
||||
test('issue 296 draft cleanup is all-or-nothing and preserves legal unfinished work', () => {
|
||||
const space = {
|
||||
id: 'drafts', title: 'Drafts', view_box: [-0.2, -0.2, 1.4, 1.4], cell_cm: 5,
|
||||
rooms: [{
|
||||
id: 'room', name: 'room', area: null,
|
||||
poly: [[0, 0], [1, 0], [1, 1], [0, 1]],
|
||||
}],
|
||||
walls: [
|
||||
{ key: wallKey([0, 1], [1, 1], S), a: [0, 1], b: [1, 1], cm: 15 },
|
||||
],
|
||||
room_drafts: [
|
||||
{ id: 'hidden', points: [[0, 0], [1, 0]], segments: [{ cm: 15 }] },
|
||||
{ id: 'free', points: [[0, 0.5], [1, 0.5]], segments: [{ cm: 15 }] },
|
||||
{ id: 'partial', points: [[-0.1, 0], [0.5, 0]], segments: [{ cm: 15 }] },
|
||||
{ id: 'thicker', points: [[0, 1], [1, 1]], segments: [{ cm: 30 }] },
|
||||
],
|
||||
};
|
||||
const result = optimizePlans(configOf(space), {});
|
||||
assert.equal(result.report.removedDrafts, 1);
|
||||
assert.deepEqual(result.config.spaces[0].room_drafts.map((draft) => draft.id), [
|
||||
'free', 'partial', 'thicker',
|
||||
]);
|
||||
assert.deepEqual(result.config.spaces[0].room_drafts,
|
||||
space.room_drafts.filter((draft) => draft.id !== 'hidden'));
|
||||
});
|
||||
|
||||
test('issue 296 an opening across a structural breakpoint keeps the source partition intact', () => {
|
||||
const space = {
|
||||
id: 'opening-boundary', title: 'Opening boundary', view_box: [0, 0, 1, 1], cell_cm: 5,
|
||||
rooms: [
|
||||
{ id: 'left', name: 'left', area: null,
|
||||
poly: [[0, 0], [0.5, 0], [0.5, 1], [0, 1]] },
|
||||
{ id: 'right', name: 'right', area: null,
|
||||
poly: [[0.5, 0], [1, 0], [1, 1], [0.5, 1]] },
|
||||
],
|
||||
walls: [
|
||||
{ key: wallKey([0, 0], [0.5, 0], S), a: [0, 0], b: [0.5, 0], cm: 20 },
|
||||
{ key: wallKey([0.5, 0], [1, 0], S), a: [0.5, 0], b: [1, 0], cm: 20 },
|
||||
],
|
||||
partitions: [{ id: 'host', a: [0, 0], b: [1, 0], cm: 20 }],
|
||||
openings: [{
|
||||
id: 'door', type: 'door', x: 0, y: 0, angle: 0, length: 0.2,
|
||||
host: { kind: 'partition', id: 'host', t: 0.5 },
|
||||
}],
|
||||
};
|
||||
const result = optimizePlans(configOf(space), {});
|
||||
assert.equal(result.report.partitionsReconciled, 0);
|
||||
assert.deepEqual(result.config.spaces[0].partitions, space.partitions);
|
||||
assert.equal(result.config.spaces[0].openings[0].host.id, 'host');
|
||||
assert.equal(result.config.spaces[0].openings[0].host.t, 0.5);
|
||||
});
|
||||
@@ -121,12 +121,12 @@ test('Optimize uses the exact max envelope for nested coincident thicknesses', (
|
||||
}
|
||||
});
|
||||
|
||||
test('Optimize leaves partial and ambiguous coincident partitions untouched', () => {
|
||||
test('Optimize reconciles fully hidden subspans but leaves ambiguous partitions untouched', () => {
|
||||
const partial = coincidentPartitionConfig({ partial: true });
|
||||
const partialResult = optimizePlans(partial, {});
|
||||
assert.equal(partialResult.report.partitionsReconciled, 0);
|
||||
assert.equal(partialResult.config.spaces[0].partitions.length, 1);
|
||||
assert.ok(partialResult.config.spaces[0].openings[0].host);
|
||||
assert.equal(partialResult.report.partitionsReconciled, 1);
|
||||
assert.equal(partialResult.config.spaces[0].partitions, undefined);
|
||||
assert.equal(partialResult.config.spaces[0].openings[0].host, undefined);
|
||||
|
||||
const ambiguous = coincidentPartitionConfig();
|
||||
ambiguous.spaces[0].partitions.push({
|
||||
@@ -818,5 +818,7 @@ test('issue 229 a node on the side of a room survives the sweep', () => {
|
||||
};
|
||||
const result = optimizePlans(config, {});
|
||||
assert.equal(result.report.partitionsMerged, 0, 'the room side holds the node');
|
||||
assert.equal(result.config.spaces[0].partitions.length, 2);
|
||||
assert.equal(result.report.partitionsReconciled, 2,
|
||||
'the two node-bounded records are then independently absorbed by #296');
|
||||
assert.equal(result.config.spaces[0].partitions, undefined);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildHiddenWallDiagnosticGeometry,
|
||||
buildPlanSnapGeometry,
|
||||
findSharedRoomSnapSegment,
|
||||
resolvePlanSnap,
|
||||
@@ -154,6 +155,46 @@ test('a completed room remains the authority for a coincident deduplicated axis'
|
||||
assert.equal(shared?.sourceKind, 'room');
|
||||
});
|
||||
|
||||
test('issue 296 diagnostic projection preserves hidden independent source identity', () => {
|
||||
const input = space({
|
||||
rooms: [{ id: 'room', x: 0, y: 0, w: 100, h: 100 }],
|
||||
room_drafts: [{
|
||||
id: 'draft', points: [[25, 0], [75, 0]], segments: [{ cm: 15 }],
|
||||
}],
|
||||
partitions: [
|
||||
{ id: 'hidden', a: [0, 0], b: [100, 0], cm: 15 },
|
||||
{ id: 'free', a: [0, 50], b: [100, 50], cm: 15 },
|
||||
],
|
||||
});
|
||||
const diagnostic = buildHiddenWallDiagnosticGeometry({ space: input });
|
||||
assert.deepEqual(diagnostic.segments.map((segment) => segment.sourceId), [
|
||||
'draft:0', 'hidden',
|
||||
]);
|
||||
assert.equal(diagnostic.endpoints.length, 4,
|
||||
'source endpoints must not be deduplicated into room authority');
|
||||
assert.deepEqual(new Set(diagnostic.endpoints.map((endpoint) => endpoint.sourceId)),
|
||||
new Set(['draft:0', 'hidden']));
|
||||
assert.equal(diagnostic.segments.some((segment) => segment.sourceId === 'free'), false);
|
||||
|
||||
const snap = buildPlanSnapGeometry({ space: input });
|
||||
assert.equal(findSharedRoomSnapSegment(snap, [0, 0], [100, 0])?.sourceKind, 'room',
|
||||
'diagnostics must not change the established snap authority');
|
||||
});
|
||||
|
||||
test('issue 296 active drafts and point-only contacts are not diagnostic walls', () => {
|
||||
const diagnostic = buildHiddenWallDiagnosticGeometry({
|
||||
space: space({
|
||||
rooms: [{ id: 'room', x: 0, y: 0, w: 100, h: 100 }],
|
||||
room_drafts: [{
|
||||
id: 'active', points: [[0, 0], [100, 0]], segments: [{ cm: 15 }],
|
||||
}],
|
||||
partitions: [{ id: 'touch', a: [100, 0], b: [150, 0], cm: 15 }],
|
||||
}),
|
||||
activeDraftId: 'active',
|
||||
});
|
||||
assert.deepEqual(diagnostic, { segments: [], endpoints: [] });
|
||||
});
|
||||
|
||||
test('endpoint wins over a closer line and tie resolution is stable', () => {
|
||||
const geometry = buildPlanSnapGeometry({
|
||||
space: space({
|
||||
|
||||
@@ -125,23 +125,23 @@ const EXPECTED = {
|
||||
},
|
||||
'real-plan-second-floor.json': {
|
||||
total: 37,
|
||||
enabled: 5,
|
||||
enabled: 9,
|
||||
disabled: {
|
||||
diagonal: 0, 'side-angle': 3, 'duplicate-physical-wall': 7,
|
||||
'partial-shared': 15, 'unequal-shared': 7, 'multiple-rooms': 0,
|
||||
diagonal: 0, 'side-angle': 3, 'duplicate-physical-wall': 0,
|
||||
'partial-shared': 18, 'unequal-shared': 7, 'multiple-rooms': 0,
|
||||
'thickness-conflict': 0, 'opening-conflict': 0, 'invalid-geometry': 0,
|
||||
},
|
||||
byRoom: {
|
||||
'room-a': 'partial-shared partial-shared enabled partial-shared',
|
||||
'room-b': 'partial-shared unequal-shared enabled partial-shared',
|
||||
'room-c': 'duplicate-physical-wall duplicate-physical-wall side-angle partial-shared',
|
||||
'room-d': 'duplicate-physical-wall duplicate-physical-wall partial-shared duplicate-physical-wall',
|
||||
'room-e': 'duplicate-physical-wall unequal-shared unequal-shared duplicate-physical-wall',
|
||||
'room-c': 'partial-shared enabled side-angle partial-shared',
|
||||
'room-d': 'partial-shared enabled partial-shared enabled',
|
||||
'room-e': 'partial-shared unequal-shared unequal-shared enabled',
|
||||
'room-f': 'side-angle unequal-shared enabled partial-shared side-angle',
|
||||
'room-g': 'enabled unequal-shared partial-shared partial-shared',
|
||||
'room-h': 'partial-shared unequal-shared partial-shared enabled partial-shared unequal-shared partial-shared partial-shared',
|
||||
},
|
||||
idDigest: 'f734d6d9df20244eb7bf0f72937ae9af3d9a2c1984aea89d2b5b884c8536b9e6',
|
||||
idDigest: 'dc6abb9c55fbcc81e32a53b1b76eee35ca51033b71ecd08433551e2af38cf6f2',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -87,9 +87,6 @@ test('issue 281 disables the old zero-range handle and enables it after Optimize
|
||||
|
||||
test('issue 281 leaves unsafe outer duplicates as hard stops', () => {
|
||||
const variants = [];
|
||||
const partial = clone(source);
|
||||
partial.spaces[0].partitions[0].b = [0.4, 0];
|
||||
variants.push(partial);
|
||||
const unknown = clone(source);
|
||||
unknown.spaces[0].partitions[0].future_semantics = true;
|
||||
variants.push(unknown);
|
||||
@@ -109,6 +106,14 @@ test('issue 281 leaves unsafe outer duplicates as hard stops', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('issue 296 removes a shorter outer duplicate which is entirely hidden', () => {
|
||||
const partial = clone(source);
|
||||
partial.spaces[0].partitions[0].b = [0.4, 0];
|
||||
const result = optimizePlans(partial, {});
|
||||
assert.equal(result.config.spaces[0].partitions
|
||||
?.some((partition) => partition.id === 'top-left') ?? false, false);
|
||||
});
|
||||
|
||||
test('issue 281 private exact fixture has no enabled zero-range handle', (t) => {
|
||||
const path = 'C:\\Temp\\44.json';
|
||||
if (!existsSync(path)) return t.skip('private #281 fixture is not present');
|
||||
|
||||
@@ -1819,6 +1819,58 @@ def test_optimize_accepts_proved_outer_rehost_for_resize_preparation():
|
||||
)
|
||||
|
||||
|
||||
def test_issue_296_backend_accepts_only_a_composite_room_edge_rehost():
|
||||
room = {
|
||||
"id": "room", "name": "Room", "area": None,
|
||||
"poly": [[0, 0], [0.5, 0], [1, 0], [1, 1], [0, 1]],
|
||||
}
|
||||
partition = {"id": "host", "a": [0, 0], "b": [1, 0], "cm": 20}
|
||||
hosted = {
|
||||
"id": "window", "type": "window", "x": 0, "y": 0,
|
||||
"angle": 0, "length": 0.1,
|
||||
"host": {"kind": "partition", "id": "host", "t": 0.25},
|
||||
}
|
||||
previous = {"spaces": [{
|
||||
"id": "floor", "rooms": [room], "partitions": [partition],
|
||||
"openings": [hosted],
|
||||
}]}
|
||||
candidate = {"spaces": [{
|
||||
"id": "floor", "rooms": [room], "partitions": [],
|
||||
"walls": [{
|
||||
"key": "0.250000,0.000000@0.0000", "cm": 20,
|
||||
"a": [0.2, 0], "b": [0.3, 0],
|
||||
}],
|
||||
"openings": [{
|
||||
"id": "window", "type": "window", "x": 0.25, "y": 0,
|
||||
"angle": 0, "length": 0.1,
|
||||
}],
|
||||
}]}
|
||||
v.validate_partition_opening_hosts(
|
||||
candidate, previous, allow_optimize_rehost=True
|
||||
)
|
||||
|
||||
for mutate in ("gap", "thin", "residual", "third-owner"):
|
||||
changed = json.loads(json.dumps(candidate))
|
||||
space = changed["spaces"][0]
|
||||
if mutate == "gap":
|
||||
space["open_spans"] = [{"a": [0.24, 0], "b": [0.26, 0]}]
|
||||
elif mutate == "thin":
|
||||
space["walls"][0]["cm"] = 10
|
||||
elif mutate == "residual":
|
||||
space["partitions"] = [{
|
||||
"id": "host", "a": [0.22, 0], "b": [0.28, 0], "cm": 20,
|
||||
}]
|
||||
else:
|
||||
space["rooms"].append({
|
||||
"id": "partial", "name": "Partial", "area": None,
|
||||
"poly": [[0.23, 0], [0.27, 0], [0.27, -0.2], [0.23, -0.2]],
|
||||
})
|
||||
with pytest.raises(v.PartitionOpeningHostError, match="opening=window"):
|
||||
v.validate_partition_opening_hosts(
|
||||
changed, previous, allow_optimize_rehost=True
|
||||
)
|
||||
|
||||
|
||||
def test_optimize_rehost_validation_is_atomic_across_the_batch():
|
||||
root = os.path.dirname(os.path.dirname(__file__))
|
||||
fixture_dir = os.path.join(root, "test", "fixtures")
|
||||
|
||||
Reference in New Issue
Block a user