fix: close #296 review gaps

Issue: #296
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-24 22:50:00 +03:00
parent 8d165fd1c2
commit b9db5d4750
18 changed files with 544 additions and 73 deletions
File diff suppressed because one or more lines are too long
+234
View File
@@ -170,6 +170,219 @@ def _segments_cover_target(segments: list[tuple[list, list]], target_a: list, ta
return False
_OPTIMIZE_PARTITION_KEYS = {"id", "a", "b", "cm"}
def _known_optimize_partition(partition: dict) -> bool:
return isinstance(partition, dict) and set(partition).issubset(_OPTIMIZE_PARTITION_KEYS)
def _axis_range(a: list, b: list, axis_a: list, axis_b: list) -> tuple[float, float] | None:
"""Project one positive collinear overlap onto ``axis_a -> axis_b``."""
if not (isinstance(a, list) and len(a) == 2
and isinstance(b, list) and len(b) == 2):
return None
if (_line_distance(a, axis_a, axis_b) > _OPTIMIZE_REHOST_EPSILON
or _line_distance(b, axis_a, axis_b) > _OPTIMIZE_REHOST_EPSILON):
return None
_, _, length = _segment_metrics(axis_a, axis_b)
lo, hi = sorted((_projection(a, axis_a, axis_b),
_projection(b, axis_a, axis_b)))
lo, hi = max(0.0, lo), min(length, hi)
return (lo, hi) if hi - lo > _OPTIMIZE_REHOST_EPSILON else None
def _axis_point(a: list, b: list, along: float) -> list[float]:
dx, dy, length = _segment_metrics(a, b)
return [float(a[0]) + dx / length * along,
float(a[1]) + dy / length * along]
def _optimize_partition_needs_proof(old_partition: dict, space: dict) -> bool:
"""Select #296 removal/splitting, not ordinary Optimize grid alignment."""
partition_id = str(old_partition.get("id", ""))
current = next((item for item in space.get("partitions") or []
if str(item.get("id", "")) == partition_id), None)
if current == old_partition:
return False
if current is None:
return True
if not (_known_optimize_partition(old_partition)
and _known_optimize_partition(current)):
return False
try:
if float(current.get("cm")) != float(old_partition.get("cm")):
return False
old_a, old_b = old_partition["a"], old_partition["b"]
current_range = _axis_range(current["a"], current["b"], old_a, old_b)
_, _, old_length = _segment_metrics(old_a, old_b)
except (KeyError, TypeError, ValueError):
return False
return current_range is not None and (
current_range[0] > _OPTIMIZE_REHOST_EPSILON
or current_range[1] < old_length - _OPTIMIZE_REHOST_EPSILON
)
def _safe_optimize_partition_delta(space: dict, old_partition: dict) -> bool:
"""Independently prove every removed atom of one old partition axis."""
if not _known_optimize_partition(old_partition):
return False
try:
old_a, old_b = old_partition["a"], old_partition["b"]
old_cm = float(old_partition["cm"])
_, _, length = _segment_metrics(old_a, old_b)
except (KeyError, TypeError, ValueError):
return False
if length <= _OPTIMIZE_REHOST_EPSILON or old_cm <= 0:
return False
rooms: list[tuple[str, list[tuple[list, list]]]] = []
for room in space.get("rooms") or []:
poly = _room_polygon(room)
if len(poly) < 3:
return False
rooms.append((str(room.get("id", "")), [
(poly[index], poly[(index + 1) % len(poly)])
for index in range(len(poly))
]))
candidate_partitions = space.get("partitions") or []
retained: list[tuple[float, float]] = []
breakpoints = [0.0, length]
def note_segment(a: list, b: list) -> tuple[float, float] | None:
projected = _axis_range(a, b, old_a, old_b)
if projected:
breakpoints.extend(projected)
return projected
for partition in candidate_partitions:
try:
projected = note_segment(partition["a"], partition["b"])
candidate_cm = float(partition["cm"])
except (KeyError, TypeError, ValueError):
return False
if projected and _known_optimize_partition(partition) and candidate_cm == old_cm:
retained.append(projected)
for _, edges in rooms:
for edge_a, edge_b in edges:
note_segment(edge_a, edge_b)
for collection in (space.get("walls") or [], space.get("open_spans") or []):
for segment in collection:
try:
note_segment(segment["a"], segment["b"])
except (KeyError, TypeError, ValueError):
return False
points = sorted(set(round(point, 12) for point in breakpoints
if -_OPTIMIZE_REHOST_EPSILON <= point
<= length + _OPTIMIZE_REHOST_EPSILON))
for lo, hi in zip(points, points[1:]):
if hi - lo <= _OPTIMIZE_REHOST_EPSILON:
continue
if any(start <= lo + _OPTIMIZE_REHOST_EPSILON
and end >= hi - _OPTIMIZE_REHOST_EPSILON
for start, end in retained):
continue
target_a, target_b = _axis_point(old_a, old_b, lo), _axis_point(old_a, old_b, hi)
owners = {room_id for room_id, edges in rooms
if _segments_cover_target(edges, target_a, target_b)}
collinear = {room_id for room_id, edges in rooms
if any(_segments_overlap_on_axis(
target_a, target_b, edge_a, edge_b
) for edge_a, edge_b in edges)}
if len(owners) not in (1, 2) or collinear != 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, partition["a"], partition["b"]
) for partition in candidate_partitions):
return False
covering_walls = []
for wall in space.get("walls") or []:
try:
wall_a, wall_b = wall["a"], wall["b"]
if _segment_covers(wall_a, wall_b, target_a, target_b):
covering_walls.append((
_segment_metrics(wall_a, wall_b)[2], float(wall["cm"]),
))
except (KeyError, TypeError, ValueError):
return False
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 < old_cm:
return False
return True
def _hosted_opening_geometry(opening: dict, partition: dict) -> tuple[float, float, float, float] | None:
host = opening.get("host")
try:
if not isinstance(host, dict) or host.get("kind") != "partition":
return None
t = float(host["t"])
a, b = partition["a"], partition["b"]
dx, dy, length = _segment_metrics(a, b)
opening_length = float(opening["length"])
except (KeyError, TypeError, ValueError):
return None
if not 0 <= t <= 1 or length <= _OPTIMIZE_REHOST_EPSILON or opening_length <= 0:
return None
angle = math.degrees(math.atan2(dy, dx))
if angle >= 90:
angle -= 180
elif angle < -90:
angle += 180
return float(a[0]) + dx * t, float(a[1]) + dy * t, angle, opening_length
def _safe_optimize_residual_rehost(
space: dict, old_space: dict, opening: dict, old_opening: dict,
) -> bool:
"""Prove a hosted opening rebound to a newly-created residual id."""
old_host, new_host = old_opening.get("host"), opening.get("host")
if not (isinstance(old_host, dict) and isinstance(new_host, dict)
and old_host.get("kind") == new_host.get("kind") == "partition"):
return False
old_partition = next((item for item in old_space.get("partitions") or []
if str(item.get("id", "")) == str(old_host.get("id", ""))), None)
new_partition = next((item for item in space.get("partitions") or []
if str(item.get("id", "")) == str(new_host.get("id", ""))), None)
if old_partition is None or new_partition is None:
return False
if not _safe_optimize_partition_delta(space, old_partition):
return False
ignored = {"host", "x", "y", "angle"}
if ({key: value for key, value in old_opening.items() if key not in ignored}
!= {key: value for key, value in opening.items() if key not in ignored}):
return False
old_geometry = _hosted_opening_geometry(old_opening, old_partition)
new_geometry = _hosted_opening_geometry(opening, new_partition)
if old_geometry is None or new_geometry is None:
return False
old_x, old_y, old_angle, old_length = old_geometry
new_x, new_y, new_angle, new_length = new_geometry
try:
explicit_x, explicit_y = float(opening["x"]), float(opening["y"])
explicit_angle = float(opening["angle"])
except (KeyError, TypeError, ValueError):
return False
return (
abs(old_x - new_x) <= _OPTIMIZE_REHOST_EPSILON
and abs(old_y - new_y) <= _OPTIMIZE_REHOST_EPSILON
and abs(old_length - new_length) <= _OPTIMIZE_REHOST_EPSILON
and _angle_delta_mod_180(old_angle, new_angle) <= 1e-7
and abs(explicit_x - new_x) <= _OPTIMIZE_REHOST_EPSILON
and abs(explicit_y - new_y) <= _OPTIMIZE_REHOST_EPSILON
and _angle_delta_mod_180(explicit_angle, new_angle) <= 1e-7
)
def _safe_optimize_partition_rehost(
space: dict, old_space: dict, opening: dict, old_opening: dict,
) -> bool:
@@ -182,6 +395,8 @@ def _safe_optimize_partition_rehost(
if str(item.get("id", "")) == partition_id), None)
if old_partition is None:
return False
if not _safe_optimize_partition_delta(space, old_partition):
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):
return False
@@ -322,6 +537,17 @@ def validate_partition_opening_hosts(
continue
old_host = (old or {}).get("host")
old_partition = old_partitions.get(str((old_host or {}).get("id", "")))
if (allow_optimize_rehost and old and old_space
and isinstance(old_host, dict)
and old_host.get("kind") == "partition"
and isinstance(host, dict) and host.get("kind") == "partition"
and old_host.get("id") != host.get("id")
and not _safe_optimize_residual_rehost(
space, old_space, opening, old
)):
raise PartitionOpeningHostError(
f"space={space_id}; opening={opening_id}; residual host changed"
)
ax, ay = partition["a"]
bx, by = partition["b"]
span = ((bx - ax) ** 2 + (by - ay) ** 2) ** 0.5
@@ -352,6 +578,14 @@ def validate_partition_opening_hosts(
raise PartitionOpeningJambMarginError(
space_id, opening_id, margin, margin_cm
)
if allow_optimize_rehost and old_space:
for old_partition in old_space.get("partitions") or []:
if (_optimize_partition_needs_proof(old_partition, space)
and not _safe_optimize_partition_delta(space, old_partition)):
raise PartitionOpeningHostError(
f"space={space_id}; partition={old_partition.get('id', '')}; "
"optimize delta unproved"
)
PASSAGE_FORBIDDEN_FIELDS = {"contact", "lock", "invert", "flip_h", "flip_v"}
+1 -1
View File
@@ -57,7 +57,7 @@ const arg = (name, fallback) => {
* Когда #296 закроется, число здесь станет нулём, и тест это потребует.
*/
const PLANS = [
{ file: 'real-plan-second-floor.json', debt: 3 },
{ file: 'real-plan-second-floor.json', debt: 1 },
{ file: 'real-plan-first-floor.json', debt: 0 },
];
+10
View File
@@ -246,6 +246,16 @@ const out = await page.evaluate(async () => {
|| !!(editorVirtualWalls.compareDocumentPosition(diagnostic) & Node.DOCUMENT_POSITION_FOLLOWING));
result.hiddenDiagnosticIsPointerTransparent = diagnostic?.getAttribute('pointer-events') === 'none'
&& getComputedStyle(diagnostic).pointerEvents === 'none';
card._activateMarkupTool('draw');
await update();
const drawDiagnostic = root().querySelector('[data-hp="hidden-wall-diagnostic"]');
const drawSnap = overlay();
result.drawToolKeepsHiddenDiagnosticAndSnapOverlay = !!drawDiagnostic && !!drawSnap
&& drawDiagnostic.querySelectorAll('.hidden-wall-line').length === 1
&& drawDiagnostic.querySelectorAll('.hidden-wall-node').length === 2;
result.hiddenDiagnosticPaintsBeforeTransientSnap = !!(
drawDiagnostic.compareDocumentPosition(drawSnap) & Node.DOCUMENT_POSITION_FOLLOWING
);
card._setMode('view');
await update();
result.viewHasNoOverlay = !overlay()
+13 -10
View File
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -3,11 +3,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
and original endpoints visible above all masonry in every Plan tool. “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
blockers without shrinking walls or deleting unfinished work. The server
independently proves the complete removed axis, including walls without
openings, and the Optimize report names redundant saved chains separately
([#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
+4 -2
View File
@@ -9,11 +9,13 @@
## Не выпущено
- Оси и исходные узлы скрытых независимых стен и сохранённых цепочек теперь
видны поверх всей кладки в редакторе Плана. «Оптимизировать планы» умеет
видны поверх всей кладки во всех инструментах Плана. «Оптимизировать планы» умеет
поглощать каждый точно покрытый участок одной или несколькими соседними
стенами комнат, сохраняет недоказанный остаток вместе с его проёмами и удаляет
цепочку только при полной избыточности. Это устраняет реальные невидимые
блокеры Resize, не уменьшая толщину стен и не удаляя незавершённую работу
блокеры Resize, не уменьшая толщину стен и не удаляя незавершённую работу.
Сервер независимо доказывает всю удаляемую ось, включая стены без проёмов,
а отчёт Optimize отдельно называет избыточные сохранённые цепочки
([#296](https://github.com/Matysh/houseplan-card/issues/296)).
- При рисовании стен `Esc` теперь завершает все принятые отрезки как
независимые стены и отцепляется от последней точки, не удаляя геометрию и не
+45
View File
@@ -1264,6 +1264,51 @@ export const MUTANTS = [
replace: ' const residualRuns: PieceRun[] = [];',
}],
},
{
id: 'optimizer-coincident-max-thickness-lost',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="max thickness independently" '
+ 'test/optimize-hidden-obstacles.test.mjs',
because: 'each absorbed piece must retain the thicker of room masonry and the hidden '
+ 'partition instead of silently thinning an authored wall (#296)',
patches: [{
file: 'src/coincident-partitions.ts',
find: ' const finalCm = proofOk ? Math.max(roomCm, source.cm) : source.cm;',
replace: ' const finalCm = proofOk ? roomCm : source.cm;',
}],
},
{
id: 'optimizer-backend-trusts-frontend-delta',
guard: 'node scripts/backend-test-guard.mjs '
+ 'backend_proves_full_partition_delta_without_openings tests_backend/test_validation.py',
because: 'the backend must reconstruct every removed partition atom independently; '
+ 'trusting the frontend candidate lets a no-opening partition disappear without masonry (#296)',
patches: [{
file: 'custom_components/houseplan/validation.py',
find: 'def _safe_optimize_partition_delta(space: dict, old_partition: dict) -> bool:\n'
+ ' """Independently prove every removed atom of one old partition axis."""\n'
+ ' if not _known_optimize_partition(old_partition):',
replace: 'def _safe_optimize_partition_delta(space: dict, old_partition: dict) -> bool:\n'
+ ' """Independently prove every removed atom of one old partition axis."""\n'
+ ' return True\n'
+ ' if not _known_optimize_partition(old_partition):',
}],
},
{
id: 'hidden-diagnostic-under-virtual-walls',
guard: 'node demo/smoke_plan_snap_overlay.mjs',
because: 'hidden wall axes and source endpoints must remain visible above every real and '
+ 'virtual wall body while staying below transient tool previews (#296)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' ${this._editing ? this._renderOpenWalls(disp) : nothing}\n'
+ ' ${this._markup ? svg`<g class="hp-editor-only-layer"\n'
+ ' opacity="${modeVisual?.editorWeight ?? 1}">${this._renderHiddenWallDiagnosticOverlay()}</g>` : nothing}',
replace: ' ${this._markup ? svg`<g class="hp-editor-only-layer"\n'
+ ' opacity="${modeVisual?.editorWeight ?? 1}">${this._renderHiddenWallDiagnosticOverlay()}</g>` : nothing}\n'
+ ' ${this._editing ? this._renderOpenWalls(disp) : nothing}',
}],
},
{
id: 'atomic-child-thickness-parent-fallback',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+14 -9
View File
@@ -16423,7 +16423,7 @@ class HouseplanCard extends LitElement {
+ r.positionsRemapped + r.markersDetached;
const modelMaintenance = r.migrated + r.canonicalized + r.coordsCanonicalized
+ r.wallsMerged + r.spansMerged + r.partitionsMerged
+ r.partitionsReconciled + r.openingsRehosted;
+ r.partitionsReconciled + r.openingsRehosted + r.redundantDraftsRemoved;
const gridWarning = r.moved + r.rotated + r.removedDrafts
+ r.coordsCanonicalized + r.wallsStraightened;
const straightenCm = Math.ceil(r.maxStraightenShiftCm * 10) / 10;
@@ -16512,6 +16512,11 @@ class HouseplanCard extends LitElement {
n: String(r.removedDrafts),
})}</p>`
: nothing}
${r.redundantDraftsRemoved
? html`<p class="alignmsg">${this._t('gs.optimize_redundant_drafts', {
n: String(r.redundantDraftsRemoved),
})}</p>`
: nothing}
${modelMaintenance ? html`<p class="alignmsg">${this._t('gs.optimize_changes', {
m: String(r.migrated), c: String(r.canonicalized),
p: String(r.coordsCanonicalized), w: String(r.wallsMerged),
@@ -17379,19 +17384,19 @@ 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._renderOpeningPlacementPreview()}</g>` : nothing}
${opMeasure ? this._renderOpeningDimensionGuides(opMeasure) : nothing}
${opMeasure?.guide ? this._renderOpeningCenterTick(opMeasure.guide) : nothing}
${this._renderRoomHoverOutline(roomHover)}
${''/* Editors: saved virtual boundaries and the live two-click
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}
opacity="${modeVisual?.editorWeight ?? 1}">${this._renderHiddenWallDiagnosticOverlay()}</g>` : nothing}
${this._markup ? svg`<g class="hp-editor-only-layer"
opacity="${modeVisual?.editorWeight ?? 1}">${this._renderOpeningPlacementPreview()}</g>` : nothing}
${opMeasure ? this._renderOpeningDimensionGuides(opMeasure) : nothing}
${opMeasure?.guide ? this._renderOpeningCenterTick(opMeasure.guide) : nothing}
${this._markup && this._tool === 'draw' ? svg`<g class="hp-editor-only-layer"
opacity="${modeVisual?.editorWeight ?? 1}">${this._renderPlanSnapOverlay()}</g>` : nothing}
${disp.hideOpenings && !this._markup
? nothing
: isoLayers && !isoLayers.floorSymbols
@@ -19580,7 +19585,7 @@ class HouseplanCard extends LitElement {
}
private _renderHiddenWallDiagnosticOverlay(): TemplateResult {
if (!this._markup || this._tool === 'draw') return svg`` as unknown as TemplateResult;
if (!this._markup) 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);
+1
View File
@@ -811,6 +811,7 @@
"gs.align_where": "The largest shift is in “{s}”.",
"gs.align_turned": "Openings whose angle is corrected: {n}.",
"gs.align_removed_drafts": "Invalid outlines collapsed by the grid and removed: {n}.",
"gs.optimize_redundant_drafts": "Saved wall chains hidden by solid room walls and removed: {n}.",
"gs.align_preflight_failed": "Could not safely verify the geometry of the following spaces: {spaces}{more}.",
"gs.align_preflight_hint": "Plans were not changed. Update House Plan and try again. If the error persists, attach a space export to the bug report.",
"gs.align_preflight_space": "Space {n}",
+1
View File
@@ -811,6 +811,7 @@
"gs.align_where": "Наибольший сдвиг — в пространстве «{s}».",
"gs.align_turned": "Проёмов с исправлением угла: {n}.",
"gs.align_removed_drafts": "Схлопнувшиеся на сетке некорректные контуры удалены: {n}.",
"gs.optimize_redundant_drafts": "Сохранённые цепочки стен, полностью скрытые стенами комнат, удалены: {n}.",
"gs.align_preflight_failed": "Не удалось безопасно проверить геометрию следующих пространств: {spaces}{more}.",
"gs.align_preflight_hint": "Планы не изменены. Обновите House Plan и повторите. Если ошибка останется, приложите экспорт пространства к отчёту об ошибке.",
"gs.align_preflight_space": "Пространство {n}",
+5 -1
View File
@@ -66,6 +66,8 @@ export interface OptimizeReport extends AlignReport, SpaceReferenceReport {
partitionsReconciled: number;
/** Hosted openings materialised onto the coincident shared room wall. */
openingsRehosted: number;
/** Saved wall chains removed because solid room masonry covers every segment. */
redundantDraftsRemoved: number;
/** Unique physical near-axis walls accepted for explicit straightening. */
wallsStraightened: number;
/** Near-axis walls found but rejected by structural safety checks. */
@@ -498,6 +500,7 @@ export function optimizePlans(
let partitionsMerged = 0;
let partitionsReconciled = 0;
let openingsRehosted = 0;
let redundantDraftsRemoved = 0;
let canonicalized = 0;
for (let i = 0; i < config.spaces.length; i++) {
const before = beforeSpaces[i];
@@ -632,7 +635,7 @@ export function optimizePlans(
else delete space.walls;
}
if (reconciled.removedDrafts) {
alignReport.removedDrafts += reconciled.removedDrafts;
redundantDraftsRemoved += reconciled.removedDrafts;
if (reconciled.roomDrafts.length) space.room_drafts = reconciled.roomDrafts;
else delete space.room_drafts;
}
@@ -709,6 +712,7 @@ export function optimizePlans(
partitionsMerged: changed ? partitionsMerged : 0,
partitionsReconciled: changed ? partitionsReconciled : 0,
openingsRehosted: changed ? openingsRehosted : 0,
redundantDraftsRemoved: changed ? redundantDraftsRemoved : 0,
wallsStraightened: changed ? wallsStraightened : 0,
wallsStraightenSkipped,
maxStraightenShiftCm: changed ? maxStraightenShiftCm : 0,
+2 -1
View File
@@ -205,7 +205,8 @@ test('issue 296 removes a fully hidden saved chain before reconciling its partit
segments: [{ cm: 15 }],
}];
const result = optimize(input);
assert.equal(result.report.removedDrafts, 1);
assert.equal(result.report.removedDrafts, 0);
assert.equal(result.report.redundantDraftsRemoved, 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,38 +520,6 @@
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": []
+1 -1
View File
@@ -643,7 +643,7 @@ test('реальный план: перегородка поверх наруж
// Долг фикстуры, а не «допустимо»: `partition-mt2on9ou-0` лежит на наружной
// стене трёх комнат и выключает ресайз Мастера с/у и Гардеробной. Число здесь
// обязано стать нулём вместе с закрытием #296 — иначе починка пройдёт молча.
const debt = { 'real-plan-second-floor.json': 3, 'real-plan-first-floor.json': 0 };
const debt = { 'real-plan-second-floor.json': 1, 'real-plan-first-floor.json': 0 };
for (const [file, expected] of Object.entries(debt)) {
const { config } = readModel(
readFileSync(resolve(repoRoot, 'test/fixtures', file), 'utf8'));
+124 -2
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { optimizePlans } from '../test-build/plan-optimizer.js';
import { reconcileCoincidentPartitions } from '../test-build/coincident-partitions.js';
import { GRID_PITCH, GRID_STEP_N as S } from '../test-build/space-geometry.js';
import { wallIntervals, wallKey } from '../test-build/wall-thickness.js';
@@ -10,6 +11,15 @@ const clone = (value) => structuredClone(value);
const fixture = JSON.parse(readFileSync(
new URL('./fixtures/real-plan-second-floor.json', import.meta.url), 'utf8',
));
fixture.space.partitions.push({
id: 'partition-room-mt7ijuyq-0',
a: [-0.354166667, 2.2875], b: [-0.354166667, 3.866666667], cm: 20,
});
fixture.space.room_drafts = [{
id: 'draft-mt7igts5',
points: [[-0.354166667, 3.866666667], [1.058333333, 3.866666667]],
segments: [{ cm: 30 }],
}];
const configOf = (space) => ({ model_version: 7, spaces: [space], markers: [], settings: {} });
@@ -20,7 +30,8 @@ test('issue 296 real second floor removes all three hidden blockers in one Optim
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);
assert.equal(result.report.removedDrafts, 0);
assert.equal(result.report.redundantDraftsRemoved, 1);
const space = result.config.spaces[0];
assert.equal(space.partitions, undefined);
assert.equal(space.room_drafts, undefined);
@@ -38,6 +49,7 @@ test('issue 296 real second floor removes all three hidden blockers in one Optim
assert.equal(second.changed, false);
assert.equal(second.report.partitionsReconciled, 0);
assert.equal(second.report.removedDrafts, 0);
assert.equal(second.report.redundantDraftsRemoved, 0);
assert.deepEqual(second.config, result.config);
});
@@ -84,7 +96,8 @@ test('issue 296 draft cleanup is all-or-nothing and preserves legal unfinished w
],
};
const result = optimizePlans(configOf(space), {});
assert.equal(result.report.removedDrafts, 1);
assert.equal(result.report.removedDrafts, 0);
assert.equal(result.report.redundantDraftsRemoved, 1);
assert.deepEqual(result.config.spaces[0].room_drafts.map((draft) => draft.id), [
'free', 'partial', 'thicker',
]);
@@ -117,3 +130,112 @@ test('issue 296 an opening across a structural breakpoint keeps the source parti
assert.equal(result.config.spaces[0].openings[0].host.id, 'host');
assert.equal(result.config.spaces[0].openings[0].host.t, 0.5);
});
test('issue 296 keeps an opening wholly on a residual with exact absolute geometry', () => {
const space = {
id: 'residual-opening', title: 'Residual opening', view_box: [-0.5, -0.5, 2, 2], 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: 20 }],
partitions: [{ id: 'host', a: [-0.25, 0], b: [1.25, 0], cm: 20 }],
openings: [{
id: 'door', type: 'door', x: 0, y: 0, angle: 0, length: 0.1,
host: { kind: 'partition', id: 'host', t: 0.9 },
}],
};
const result = optimizePlans(configOf(space), {});
const opening = result.config.spaces[0].openings[0];
assert.match(opening.host.id, /^host~r-/);
assert.equal(opening.x, 1.1);
assert.equal(opening.y, 0);
assert.equal(opening.angle, 0);
assert.equal(result.report.openingsRehosted, 0);
});
test('issue 296 computes max thickness independently for disjoint safe pieces', () => {
const space = {
id: 'piece-thickness', title: 'Piece thickness', view_box: [-0.2, -0.2, 3.4, 1.4], cell_cm: 5,
rooms: [
{ id: 'left', name: 'left', area: null, poly: [[0, 0], [1, 0], [1, 1], [0, 1]] },
{ id: 'right', name: 'right', area: null, poly: [[2, 0], [3, 0], [3, 1], [2, 1]] },
],
walls: [
{ key: wallKey([0, 0], [1, 0], S), a: [0, 0], b: [1, 0], cm: 15 },
{ key: wallKey([2, 0], [3, 0], S), a: [2, 0], b: [3, 0], cm: 25 },
],
partitions: [{ id: 'split', a: [0, 0], b: [3, 0], cm: 20 }],
};
const result = optimizePlans(configOf(space), {});
assert.equal(result.report.partitionsReconciled, 2);
assert.deepEqual(result.config.spaces[0].partitions.map((item) => [item.a, item.b]), [
[[1, 0], [2, 0]],
]);
const bottom = wallIntervals(
result.config.spaces[0].rooms, result.config.spaces[0].walls, [], S, 5, GRID_PITCH, 1,
).filter((item) => item.a[1] === 0 && item.b[1] === 0);
assert.equal(bottom.find((item) => Math.min(item.a[0], item.b[0]) === 0)?.cm, 20);
assert.equal(bottom.find((item) => Math.min(item.a[0], item.b[0]) === 2)?.cm, 25);
});
test('issue 296 applies column and unfinished-draft blockers to each piece and preserves work', () => {
const base = {
id: 'piece-blockers', title: 'Piece blockers', view_box: [-0.2, -0.2, 3.4, 1.4], cell_cm: 5,
rooms: [
{ id: 'left', name: 'left', area: null, poly: [[0, 0], [1, 0], [1, 1], [0, 1]] },
{ id: 'right', name: 'right', area: null, poly: [[2, 0], [3, 0], [3, 1], [2, 1]] },
],
walls: [], partitions: [{ id: 'split', a: [0, 0], b: [3, 0], cm: 15 }],
wall_columns: [{ id: 'column', shape: 'circle', center: [0.5, 0], cm: 20 }],
};
const columnResult = optimizePlans(configOf(base), {});
assert.equal(columnResult.report.partitionsReconciled, 1);
assert.deepEqual(columnResult.config.spaces[0].partitions[0].a, [0, 0]);
assert.deepEqual(columnResult.config.spaces[0].partitions[0].b, [2, 0]);
const draftSpace = clone(base);
delete draftSpace.wall_columns;
draftSpace.rooms = [base.rooms[0]];
draftSpace.partitions = [{ id: 'blocked', a: [0, 0], b: [1, 0], cm: 15 }];
draftSpace.room_drafts = [{
id: 'unfinished', points: [[0, 0], [0.5, 0]], segments: [{ cm: 30 }],
}];
const draftResult = optimizePlans(configOf(draftSpace), {});
assert.equal(draftResult.report.partitionsReconciled, 0);
assert.equal(draftResult.report.redundantDraftsRemoved, 0);
assert.deepEqual(draftResult.config.spaces[0].room_drafts, draftSpace.room_drafts);
});
test('issue 296 fails closed at MAX_PARTITIONS and MAX_WALLS', () => {
const room = { id: 'room', name: 'room', area: null,
poly: [[0, 0], [1, 0], [1, 1], [0, 1]] };
const target = { id: 'a-target', a: [-0.25, 0], b: [1.25, 0], cm: 15 };
const partitions = [target, ...Array.from({ length: 1999 }, (_, index) => ({
id: `z-${index}`, a: [10 + index * 2, 10], b: [11 + index * 2, 10], cm: 15,
}))];
const partitionLimited = reconcileCoincidentPartitions(
{ partitions, openings: [], room_drafts: [] },
{ rooms: [room], partitions, room_drafts: [], wall_columns: [] },
[], [], { pitch: S, cellCm: 5, gridPitch: GRID_PITCH, coordScale: 1 },
);
assert.equal(partitionLimited.partitionsReconciled, 0);
assert.deepEqual(partitionLimited.partitions[0], target);
const count = 501;
const polygon = Array.from({ length: count }, (_, index) => {
const angle = 2 * Math.PI * index / count;
return [10 + Math.cos(angle), 10 + Math.sin(angle)];
});
const walls = Array.from({ length: count - 1 }, (_, index) => ({
key: wallKey(polygon[index + 1], polygon[(index + 2) % count], S),
a: polygon[index + 1], b: polygon[(index + 2) % count], cm: 15,
}));
const edgePartition = { id: 'edge', a: polygon[0], b: polygon[1], cm: 20 };
const wallLimited = reconcileCoincidentPartitions(
{ partitions: [edgePartition], openings: [], room_drafts: [] },
{ rooms: [{ id: 'many', name: 'many', poly: polygon }],
partitions: [edgePartition], room_drafts: [], wall_columns: [] },
walls, [], { pitch: S, cellCm: 5, gridPitch: GRID_PITCH, coordScale: 1 },
);
assert.equal(wallLimited.partitionsReconciled, 0);
assert.deepEqual(wallLimited.partitions, [edgePartition]);
});
+1
View File
@@ -43,6 +43,7 @@ const assertNoPersistedChanges = (result) => {
'moved', 'coordsCanonicalized', 'rotated', 'removedDrafts', 'migrated',
'glowSpacesMigrated', 'glowRoomsMigrated', 'canonicalized', 'wallsMerged',
'spansMerged', 'partitionsMerged', 'partitionsReconciled', 'openingsRehosted',
'redundantDraftsRemoved',
'wallsStraightened',
'spaceRefsRemapped', 'roomRefsRemapped',
'positionsRemapped', 'markersDetached', 'orphanRoomLabelsRemoved',
+71 -2
View File
@@ -1837,8 +1837,8 @@ def test_issue_296_backend_accepts_only_a_composite_room_edge_rehost():
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],
"key": "0.500000,0.000000@0.0000", "cm": 20,
"a": [0, 0], "b": [1, 0],
}],
"openings": [{
"id": "window", "type": "window", "x": 0.25, "y": 0,
@@ -1871,6 +1871,75 @@ def test_issue_296_backend_accepts_only_a_composite_room_edge_rehost():
)
def test_issue_296_backend_proves_full_partition_delta_without_openings_and_residual_rehost():
room = {
"id": "room", "name": "Room", "area": None,
"poly": [[0, 0], [1, 0], [1, 1], [0, 1]],
}
partition = {"id": "host", "a": [0, 0], "b": [1, 0], "cm": 20}
previous = {"spaces": [{
"id": "floor", "rooms": [room], "partitions": [partition], "openings": [],
}]}
candidate = {"spaces": [{
"id": "floor", "rooms": [room], "partitions": [], "openings": [],
"walls": [{"a": [0, 0], "b": [1, 0], "cm": 20}],
}]}
v.validate_partition_opening_hosts(candidate, previous, allow_optimize_rehost=True)
for mutator in (
lambda space: space.update(walls=[]),
lambda space: space["walls"][0].update(b=[0.5, 0]),
):
changed = json.loads(json.dumps(candidate))
mutator(changed["spaces"][0])
with pytest.raises(v.PartitionOpeningHostError, match="partition=host"):
v.validate_partition_opening_hosts(
changed, previous, allow_optimize_rehost=True
)
unknown_previous = json.loads(json.dumps(previous))
unknown_previous["spaces"][0]["partitions"][0]["future_semantics"] = True
with pytest.raises(v.PartitionOpeningHostError, match="partition=host"):
v.validate_partition_opening_hosts(
candidate, unknown_previous, allow_optimize_rehost=True
)
middle_room = {
"id": "middle", "name": "Middle", "area": None,
"poly": [[0.25, 0], [0.75, 0], [0.75, 1], [0.25, 1]],
}
hosted = {
"id": "door", "type": "door", "x": 0, "y": 0,
"angle": 0, "length": 0.1,
"host": {"kind": "partition", "id": "host", "t": 0.875},
}
residual_previous = {"spaces": [{
"id": "floor", "cell_cm": 5, "rooms": [middle_room],
"partitions": [partition], "openings": [hosted],
}]}
residual_candidate = {"spaces": [{
"id": "floor", "cell_cm": 5, "rooms": [middle_room],
"walls": [{"a": [0.25, 0], "b": [0.75, 0], "cm": 20}],
"partitions": [
{"id": "host", "a": [0, 0], "b": [0.25, 0], "cm": 20},
{"id": "host~r-test", "a": [0.75, 0], "b": [1, 0], "cm": 20},
],
"openings": [{
**hosted, "x": 0.875, "y": 0, "angle": 0,
"host": {"kind": "partition", "id": "host~r-test", "t": 0.5},
}],
}]}
v.validate_partition_opening_hosts(
residual_candidate, residual_previous, allow_optimize_rehost=True
)
shifted = json.loads(json.dumps(residual_candidate))
shifted["spaces"][0]["openings"][0]["x"] = 0.88
with pytest.raises(v.PartitionOpeningHostError, match="opening=door"):
v.validate_partition_opening_hosts(
shifted, residual_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")