${i?W`
@@ -5590,6 +5590,7 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="875c3d8be4f77f7032f939dd5360e29aba85
${t.where?W`
${this._t("gs.align_where",{s:t.where})}
`:U}
${e.rotated?W`
${this._t("gs.align_turned",{n:String(e.rotated)})}
`:U}
${e.removedDrafts?W`
${this._t("gs.align_removed_drafts",{n:String(e.removedDrafts)})}
`:U}
+ ${e.redundantDraftsRemoved?W`
${this._t("gs.optimize_redundant_drafts",{n:String(e.redundantDraftsRemoved)})}
`:U}
${c?W`
${this._t("gs.optimize_changes",{m:String(e.migrated),c:String(e.canonicalized),p:String(e.coordsCanonicalized),w:String(e.wallsMerged),s:String(e.spansMerged),i:String(e.partitionsMerged)})}
`:U}
${e.partitionsReconciled?W`
${this._t("gs.optimize_coincident_partitions",{n:String(e.partitionsReconciled)})}
`:U}
${e.openingsRehosted?W`
${this._t("gs.optimize_openings_rehosted",{n:String(e.openingsRehosted)})}
`:U}
@@ -6007,15 +6008,17 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="875c3d8be4f77f7032f939dd5360e29aba85
${""}
${this._editing?U:this._renderOpenWalls(d)}
${this._renderWallBodies(d)}
- ${this._markup?j`
${this._renderOpeningPlacementPreview()}`:U}
- ${x?this._renderOpeningDimensionGuides(x):U}
- ${x?.guide?this._renderOpeningCenterTick(x.guide):U}
${this._renderRoomHoverOutline(D)}
${""}
${this._editing?this._renderOpenWalls(d):U}
${this._markup?j`
${"draw"===this._tool?this._renderPlanSnapOverlay():this._renderHiddenWallDiagnosticOverlay()}`:U}
+ opacity="${R?.editorWeight??1}">${this._renderHiddenWallDiagnosticOverlay()}`:U}
+ ${this._markup?j`
${this._renderOpeningPlacementPreview()}`:U}
+ ${x?this._renderOpeningDimensionGuides(x):U}
+ ${x?.guide?this._renderOpeningCenterTick(x.guide):U}
+ ${this._markup&&"draw"===this._tool?j`
${this._renderPlanSnapOverlay()}`:U}
${d.hideOpenings&&!this._markup||p&&!p.floorSymbols?U:this._renderOpenings(d)}
${this._renderWallThickUi()}
${this._markup&&"resize"===this._tool?this._renderResizeLayer(b):U}
@@ -6467,7 +6470,7 @@ globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="875c3d8be4f77f7032f939dd5360e29aba85
data-kind="rotate" @pointerdown=${t=>this._physicalRotateDown(t,s)}
@pointermove=${t=>this._physicalRotateMove(t)}
@pointerup=${t=>this._physicalRotateUp(t)}>
- `})();return j`
${c}${h}${d}${u}${_}`}_renderHiddenWallDiagnosticOverlay(){if(!this._markup||"draw"===this._tool)return j``;const t=this._hiddenWallDiagnosticSnapshot().value;if(!t.segments.length)return j``;const e=Kr(5,this._cellCm,this._gridPitch);return j`
`})();return j`${c}${h}${d}${u}${_}`}_renderHiddenWallDiagnosticOverlay(){if(!this._markup)return j``;const t=this._hiddenWallDiagnosticSnapshot().value;if(!t.segments.length)return j``;const e=Kr(5,this._cellCm,this._gridPitch);return j`
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index f0decc02..25077b44 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -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
diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md
index a5fb2fa0..130e2176 100755
--- a/docs/CHANGELOG.ru.md
+++ b/docs/CHANGELOG.ru.md
@@ -9,11 +9,13 @@
## Не выпущено
- Оси и исходные узлы скрытых независимых стен и сохранённых цепочек теперь
- видны поверх всей кладки в редакторе Плана. «Оптимизировать планы» умеет
+ видны поверх всей кладки во всех инструментах Плана. «Оптимизировать планы» умеет
поглощать каждый точно покрытый участок одной или несколькими соседними
стенами комнат, сохраняет недоказанный остаток вместе с его проёмами и удаляет
цепочку только при полной избыточности. Это устраняет реальные невидимые
- блокеры Resize, не уменьшая толщину стен и не удаляя незавершённую работу
+ блокеры Resize, не уменьшая толщину стен и не удаляя незавершённую работу.
+ Сервер независимо доказывает всю удаляемую ось, включая стены без проёмов,
+ а отчёт Optimize отдельно называет избыточные сохранённые цепочки
([#296](https://github.com/Matysh/houseplan-card/issues/296)).
- При рисовании стен `Esc` теперь завершает все принятые отрезки как
независимые стены и отцепляется от последней точки, не удаляя геометрию и не
diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs
index f2d6e9fb..f2f4736f 100644
--- a/scripts/mutation-gate.mjs
+++ b/scripts/mutation-gate.mjs
@@ -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`${this._renderHiddenWallDiagnosticOverlay()}` : nothing}',
+ replace: ' ${this._markup ? svg`${this._renderHiddenWallDiagnosticOverlay()}` : 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 '
diff --git a/src/houseplan-card.ts b/src/houseplan-card.ts
index 1adea5f5..ed9f400d 100755
--- a/src/houseplan-card.ts
+++ b/src/houseplan-card.ts
@@ -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),
})}`
: nothing}
+ ${r.redundantDraftsRemoved
+ ? html`${this._t('gs.optimize_redundant_drafts', {
+ n: String(r.redundantDraftsRemoved),
+ })}
`
+ : nothing}
${modelMaintenance ? html`${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`${this._renderOpeningPlacementPreview()}` : 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`${this._tool === 'draw'
- ? this._renderPlanSnapOverlay()
- : this._renderHiddenWallDiagnosticOverlay()}` : nothing}
+ opacity="${modeVisual?.editorWeight ?? 1}">${this._renderHiddenWallDiagnosticOverlay()}
` : nothing}
+ ${this._markup ? svg`${this._renderOpeningPlacementPreview()}` : nothing}
+ ${opMeasure ? this._renderOpeningDimensionGuides(opMeasure) : nothing}
+ ${opMeasure?.guide ? this._renderOpeningCenterTick(opMeasure.guide) : nothing}
+ ${this._markup && this._tool === 'draw' ? svg`${this._renderPlanSnapOverlay()}` : 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);
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 3f8ebcd6..a4fcdad7 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -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}",
diff --git a/src/i18n/ru.json b/src/i18n/ru.json
index bd09a9dc..0ae1728c 100644
--- a/src/i18n/ru.json
+++ b/src/i18n/ru.json
@@ -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}",
diff --git a/src/plan-optimizer.ts b/src/plan-optimizer.ts
index 7f831257..242da9a2 100644
--- a/src/plan-optimizer.ts
+++ b/src/plan-optimizer.ts
@@ -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,
diff --git a/test/coincident-partitions.test.mjs b/test/coincident-partitions.test.mjs
index 77ce3933..1b443567 100644
--- a/test/coincident-partitions.test.mjs
+++ b/test/coincident-partitions.test.mjs
@@ -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);
diff --git a/test/fixtures/real-plan-second-floor.json b/test/fixtures/real-plan-second-floor.json
index 916df7dc..39ea73d0 100644
--- a/test/fixtures/real-plan-second-floor.json
+++ b/test/fixtures/real-plan-second-floor.json
@@ -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": []
diff --git a/test/model-invariants.test.mjs b/test/model-invariants.test.mjs
index 8f8f5f4e..0142c051 100644
--- a/test/model-invariants.test.mjs
+++ b/test/model-invariants.test.mjs
@@ -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'));
diff --git a/test/optimize-hidden-obstacles.test.mjs b/test/optimize-hidden-obstacles.test.mjs
index 7d988364..90af11a4 100644
--- a/test/optimize-hidden-obstacles.test.mjs
+++ b/test/optimize-hidden-obstacles.test.mjs
@@ -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]);
+});
diff --git a/test/plan-optimizer.test.mjs b/test/plan-optimizer.test.mjs
index 0b49f74a..412d03bb 100644
--- a/test/plan-optimizer.test.mjs
+++ b/test/plan-optimizer.test.mjs
@@ -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',
diff --git a/tests_backend/test_validation.py b/tests_backend/test_validation.py
index d506f2f3..8eda5ad7 100644
--- a/tests_backend/test_validation.py
+++ b/tests_backend/test_validation.py
@@ -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")