fix: reconcile hidden walls during Optimize

Issue: #296
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-24 19:10:27 +00:00
committed by claude[bot]
parent 033ad11bf7
commit 89ac96a8af
33 changed files with 1288 additions and 468 deletions
File diff suppressed because one or more lines are too long
+63 -35
View File
@@ -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
+7
View File
@@ -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;
+8
View File
@@ -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 },
+1 -1
View File
@@ -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 },
];
+18 -2
View File
@@ -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;
});
+104 -80
View File
File diff suppressed because one or more lines are too long
+9 -7
View File
@@ -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
+11
View File
@@ -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`
+7
View File
@@ -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
+7
View File
@@ -8,6 +8,13 @@
## Не выпущено
- Оси и исходные узлы скрытых независимых стен и сохранённых цепочек теперь
видны поверх всей кладки в редакторе Плана. «Оптимизировать планы» умеет
поглощать каждый точно покрытый участок одной или несколькими соседними
стенами комнат, сохраняет недоказанный остаток вместе с его проёмами и удаляет
цепочку только при полной избыточности. Это устраняет реальные невидимые
блокеры Resize, не уменьшая толщину стен и не удаляя незавершённую работу
([#296](https://github.com/Matysh/houseplan-card/issues/296)).
- При рисовании стен `Esc` теперь завершает все принятые отрезки как
независимые стены и отцепляется от последней точки, не удаляя геометрию и не
покидая инструмент. Следующий клик начинает новую цепочку, а `Ctrl/Cmd+Z`
+4
View File
@@ -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
View File
@@ -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
+8 -1
View File
@@ -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
View File
@@ -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
+9 -15
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}.",
+5
View File
@@ -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 || [],
+91
View File
@@ -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
View File
@@ -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;
+15 -8
View File
@@ -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
View File
@@ -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": []
+16 -1
View File
@@ -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);
+1 -1
View File
@@ -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'));
+119
View File
@@ -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);
});
+7 -5
View File
@@ -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);
});
+41
View File
@@ -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({
+7 -7
View File
@@ -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',
},
};
+8 -3
View File
@@ -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');
+52
View File
@@ -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")