feat: block unsafe Optimize geometry

Issue: #199
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-22 16:29:33 +03:00
parent add9dfe3a4
commit 482afb73eb
24 changed files with 1343 additions and 417 deletions
File diff suppressed because one or more lines are too long
@@ -0,0 +1,96 @@
// #199: same-process production-builder baseline versus the complete Optimize
// preflight wrapper on the deterministic 3-floor large-house fixture.
import { performance } from 'node:perf_hooks';
import { makeLargeHouseFixture, LARGE_HOUSE_COUNTS } from './fixtures/large-house.mjs';
import {
checkOptimizeGeometry,
prepareSpacePhysicalGeometryInputs,
} from '../test-build/plan-geometry-preflight.js';
import { spaceModels } from '../test-build/space-geometry.js';
import {
floorFootprintGeometry,
wallBodiesGeometry,
} from '../test-build/wall-thickness.js';
const WARMUPS = 3;
const SAMPLES = 20;
const ABSOLUTE_P95_MS = 250;
const RELATIVE_RATIO = 1.2;
const RELATIVE_NOISE_MS = 15;
const fixture = makeLargeHouseFixture();
const models = spaceModels(fixture.config);
const prepared = fixture.config.spaces.map((space, index) =>
prepareSpacePhysicalGeometryInputs(space, models[index]));
const directProductionPass = () => {
for (const input of prepared) {
const hasWalls = input.walls.length > 0 || input.physicalBodies.length > 0;
const united = hasWalls
? wallBodiesGeometry(
input.space.rooms, input.walls, input.openCuts, input.roomOpenings,
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
input.physicalBodies,
)
: null;
if (hasWalls && united == null) throw new Error(`baseline wall failure: ${input.space.id}`);
if (input.space.rooms.length && united?.paperGeom == null) {
const floor = floorFootprintGeometry(
input.space.rooms, input.walls, input.openCuts,
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
);
if (floor == null) throw new Error(`baseline floor failure: ${input.space.id}`);
}
}
};
const completePreflight = () => {
const result = checkOptimizeGeometry(fixture.config);
if (!result.ok || result.spaces.length !== LARGE_HOUSE_COUNTS.floors
|| result.spaces.some((space) => space.status !== 'ok')) {
throw new Error(`candidate preflight failure: ${JSON.stringify(result.spaces)}`);
}
};
const sample = (operation) => {
const start = performance.now();
operation();
return performance.now() - start;
};
const run = (operation) => {
for (let index = 0; index < WARMUPS; index++) operation();
return Array.from({ length: SAMPLES }, () => sample(operation));
};
const quantile = (values, ratio) => {
const sorted = [...values].sort((a, b) => a - b);
return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)];
};
const summary = (values) => ({
min: Math.min(...values),
median: quantile(values, 0.5),
p95: quantile(values, 0.95),
max: Math.max(...values),
});
const baseline = summary(run(directProductionPass));
const candidate = summary(run(completePreflight));
const relativeLimit = baseline.p95 * RELATIVE_RATIO + RELATIVE_NOISE_MS;
const pass = candidate.p95 <= ABSOLUTE_P95_MS && candidate.p95 <= relativeLimit;
const report = {
issue: 199,
fixture: LARGE_HOUSE_COUNTS,
warmups: WARMUPS,
samples: SAMPLES,
baseline,
candidate,
budgets: {
absoluteP95Ms: ABSOLUTE_P95_MS,
relativeRatio: RELATIVE_RATIO,
relativeNoiseMs: RELATIVE_NOISE_MS,
relativeLimitP95Ms: relativeLimit,
},
pass,
};
console.log(JSON.stringify(report, null, 2));
if (!pass) process.exitCode = 1;
+30 -1
View File
@@ -599,7 +599,36 @@ export async function prepareGoldenScenario(page, scenario) {
card.requestUpdate();
await card.updateComplete;
}
if (scenario.dialog === 'device') {
if (scenario.dialog === 'optimize-preflight') {
const names = [
'Ground floor', 'Garage', card._t('gs.align_preflight_space', { n: '3' }), 'Attic',
];
const failures = names.map((displayName, index) => ({
spaceId: `golden-failure-${index + 1}`,
displayName,
status: 'failed',
reason: 'wall-null',
}));
card._alignDialog = {
report: {}, config: card._serverCfg, layout: card._layout,
preflight: {
fingerprint: 'golden-optimize-preflight',
spaces: failures,
failures,
ok: false,
},
cm: 0, where: '', changed: true, busy: false,
};
card.requestUpdate();
await card.updateComplete;
const dialog = card.renderRoot.querySelector('hp-dialog');
const body = dialog?.querySelector('.body');
if (!body?.textContent?.includes(names[0])
|| !body.textContent.includes(card._t('gs.align_preflight_hint'))
|| dialog.querySelector('.btn.on')) {
throw new Error('golden Optimize preflight failure dialog is incomplete');
}
} else if (scenario.dialog === 'device') {
card._setMode('devices');
await card.updateComplete;
await settleMode(card);
+7 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 33;
export const GOLDEN_MATRIX_VERSION = 34;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -382,4 +382,10 @@ export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'backup-space-preview-mobile-ru', fixture: 'visual', space: 'golden-geometry',
dialog: 'backup-space', language: 'ru', theme: 'light',
viewport: { width: 390, height: 820 }, ...page },
{ id: 'optimize-preflight-dialog-dark-en', fixture: 'visual', space: 'golden-geometry',
dialog: 'optimize-preflight', language: 'en', theme: 'dark',
viewport: { width: 900, height: 760 }, ...page },
{ id: 'optimize-preflight-dialog-light-ru', fixture: 'visual', space: 'golden-geometry',
dialog: 'optimize-preflight', language: 'ru', theme: 'light',
viewport: { width: 900, height: 760 }, ...page },
]);
+152
View File
@@ -0,0 +1,152 @@
// #199: the production bundle must fail closed before the Optimize WS write,
// keep one exact-candidate preflight result, and preserve the existing green
// Preview → Apply / no-op contract.
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 920, height: 840 });
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
const clone = (value) => structuredClone(value);
const noisy = [[
[0.10000000000000002, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9],
]];
const makeSpace = (id, title) => ({
id, title, view_box: [0, 0, 1, 1], cell_cm: 5,
rooms: noisy.map((poly, index) => ({ id: `room-${id || index}`, poly })),
});
const original = {
model_version: 6,
spaces: [
makeSpace('alpha', 'Alpha'),
makeSpace('beta', ''),
makeSpace('', ''),
makeSpace('unsafe', '<img id="preflight-injection" src=x>'),
],
markers: [], settings: {},
};
const originalLayout = { marker: { s: 'alpha', x: 0.2, y: 0.2 } };
const baseCall = card.hass.callWS.bind(card.hass);
const sent = [];
card.hass = {
...card.hass,
callWS: async (message) => {
if (message.type === 'houseplan/plan/optimize') {
sent.push(clone(message));
return { ok: true, config_rev: 42, layout_rev: 24, can_undo: true };
}
return baseCall(message);
},
};
const setCandidate = (language = 'en') => {
card._config = { ...card._config, language };
card._serverCfg = clone(original);
card._layout = clone(originalLayout);
card._cfgRev = 10;
card._layoutRev = 11;
card._canOptimizeUndo = false;
card._undoKind = null;
card._modelCache = null;
card._frame = null;
card._space = 'alpha';
};
const originalCheck = card._checkOptimizeGeometry.bind(card);
let checks = 0;
let forceRed = true;
card._checkOptimizeGeometry = (config) => {
checks++;
const checked = originalCheck(config);
if (!forceRed) return checked;
const failures = checked.spaces.map((space) => ({
...space, status: 'failed', reason: 'wall-null',
}));
return { ...checked, spaces: failures, failures, ok: false };
};
setCandidate('en');
const beforeRed = {
config: clone(card._serverCfg), layout: clone(card._layout),
configRev: card._cfgRev, layoutRev: card._layoutRev,
canUndo: card._canOptimizeUndo, undoKind: card._undoKind,
};
card._openAlignDialog(); await card.updateComplete;
const englishText = card.renderRoot.querySelector('hp-dialog .body')?.textContent || '';
result.englishFailureNamesThreeAndCountsRest = englishText.includes(
'Could not safely verify the geometry of the following spaces: Alpha, beta, Space 3, and 1 more.',
);
result.englishFailureHasExactHint = englishText.includes(
'Plans were not changed. Update House Plan and try again. If the error persists, attach a space export to the bug report.',
);
result.failureTitleIsEscapedAndFourthNameHidden =
!card.renderRoot.querySelector('#preflight-injection')
&& !englishText.includes('<img') && !englishText.includes('unsafe');
result.failureRendersNoApply = !card.renderRoot.querySelector('hp-dialog .btn.on');
await card._runAlignToGrid(); await card.updateComplete;
result.redPreflightMakesZeroWrites = sent.length === 0;
result.redPreflightPreservesAllState =
JSON.stringify(card._serverCfg) === JSON.stringify(beforeRed.config)
&& JSON.stringify(card._layout) === JSON.stringify(beforeRed.layout)
&& card._cfgRev === beforeRed.configRev && card._layoutRev === beforeRed.layoutRev
&& card._canOptimizeUndo === beforeRed.canUndo && card._undoKind === beforeRed.undoKind;
// A green preview whose exact candidate changes must be checked again. A
// newly red result replaces the dialog and still cannot write.
forceRed = false;
setCandidate('en');
card._openAlignDialog(); await card.updateComplete;
const checksAfterGreenPreview = checks;
result.greenPreviewOffersApply = !!card.renderRoot.querySelector('hp-dialog .btn.on');
card._alignDialog.config.spaces[0].title = 'Changed after preview';
forceRed = true;
await card._runAlignToGrid(); await card.updateComplete;
result.changedFingerprintRechecks = checks === checksAfterGreenPreview + 1;
result.changedFingerprintFailsClosed = sent.length === 0
&& !card._alignDialog.preflight.ok
&& !card.renderRoot.querySelector('hp-dialog .btn.on');
// An unchanged green candidate reuses the preview result, performs exactly
// one existing atomic endpoint call, and keeps the existing Undo contract.
forceRed = false;
setCandidate('en');
const callsBeforeGreen = sent.length;
card._openAlignDialog(); await card.updateComplete;
const checksBeforeApply = checks;
const previewCandidate = clone(card._alignDialog.config);
await card._runAlignToGrid(); await card.updateComplete;
result.unchangedApplyDoesNotRecheck = checks === checksBeforeApply;
result.greenApplyMakesOneAtomicWrite = sent.length === callsBeforeGreen + 1
&& JSON.stringify(sent.at(-1).config) === JSON.stringify(previewCandidate);
result.greenApplyPreservesUndoContract = card._canOptimizeUndo === true
&& card._undoKind === 'optimize' && card._cfgRev === 42 && card._layoutRev === 24;
// The just-written candidate is now a no-op: it skips the geometry pass and
// retains the established message/absence of Apply.
const checksBeforeNoOp = checks;
card._openAlignDialog(); await card.updateComplete;
const noOpText = card.renderRoot.querySelector('hp-dialog .body')?.textContent || '';
result.noOpSkipsPreflight = checks === checksBeforeNoOp
&& card._alignDialog.preflight === null;
result.noOpKeepsExistingUi = noOpText.includes(
'All plans already use the current optimized data model.',
) && !card.renderRoot.querySelector('hp-dialog .btn.on');
card._alignDialog = null; await card.updateComplete;
// The same bounded copy and fallback/count policy is localized in Russian.
forceRed = true;
setCandidate('ru');
card._openAlignDialog(); await card.updateComplete;
const russianText = card.renderRoot.querySelector('hp-dialog .body')?.textContent || '';
result.russianFailureHasExactCopy = russianText.includes(
'Не удалось безопасно проверить геометрию следующих пространств: Alpha, beta, Пространство 3 и ещё 1.',
) && russianText.includes(
'Планы не изменены. Обновите House Plan и повторите. Если ошибка останется, приложите экспорт пространства к отчёту об ошибке.',
);
card.renderRoot.querySelector('hp-dialog')?.dispatchEvent(new CustomEvent(
'hp-close', { bubbles: true, composed: true },
));
await card.updateComplete;
result.failureCloseDoesNotWrite = card._alignDialog === null && sent.length === callsBeforeGreen + 1;
return result;
});
await finish(browser, checkAll(out));
File diff suppressed because one or more lines are too long
+118 -116
View File
File diff suppressed because one or more lines are too long
+11
View File
@@ -658,6 +658,17 @@ gaps.
| `houseplan/import/revalidate` | preview `token`, `duplicate_policy?` | refreshed bounded preview and current expected revisions |
| `houseplan/import/apply` | token, both expected revisions, content confirmation | crash-resumable paired config/layout commit; full import gets one-deep undo |
The normal frontend reaches `houseplan/plan/optimize` only after the exact
preview candidate passes `src/plan-geometry-preflight.ts`. That pure barrier
uses the same room/open-span/ordinary+hosted-opening projection,
`physicalBodyParts`, `wallBodiesGeometry` and `floorFootprintGeometry` as the
renderer for every space. The dialog retains statuses and a config fingerprint,
not polygon output or exception text; a mismatch before Apply triggers a fresh
check. A red result means zero WS calls. Python deliberately does not duplicate
`polyclip-ts`: the endpoint remains the independent permission/schema/revision
and crash-resumable atomicity boundary, not a consumer-supplied preflight
attestation.
Manual attachments upload over HTTP (streaming, transactional staging), not WS —
the old `houseplan/file/set` was removed in v1.10.0.
+12
View File
@@ -524,6 +524,18 @@ idempotence case in `test/plan-optimizer.test.mjs`:
`coordsCanonicalized: 0`, and returns objects deep-equal to the first run's;
* the report is an **upper bound**, not a sample (AUD-158B1-01).
Before a changed preview can expose Apply, `checkOptimizeGeometry(config)`
(`src/plan-geometry-preflight.ts`) runs the exact candidate through the shared
production input projection and canonical wall/floor boolean builders for every
space. `null` or an exception is a structural failure; an empty successful
geometry and an empty/image-only space are not. One failure blocks the whole
operation and the endpoint is not called. The dialog retains only bounded
statuses plus `contentFingerprint(candidate.config)`: unchanged Apply reuses
that result, while a changed fingerprint is checked again and fails closed.
This frontend barrier does not replace backend permission, schema, revision or
crash-recovery checks and is not a security attestation from an untrusted
client.
### The report is a promise
The confirmation is the decision gate in front of a geometry rewrite, so
+7
View File
@@ -2,6 +2,13 @@
## Unreleased
- “Optimize plans” now verifies the exact preview candidate with the same wall,
opening, partition, column and floor geometry builders used by the card. If
any space cannot be built safely, the preview names up to three affected
spaces, explains how to report the problem and offers only Cancel: config,
layout, revisions and the one-deep Undo snapshot remain untouched
([#199](https://github.com/Matysh/houseplan-card/issues/199)).
- Every config and device-layout write now canonicalizes persisted geometry to
nine decimal places without snapping it to the grid. This removes invisible
floating-point tails before they can break shared walls, room unions or Glow;
+8
View File
@@ -8,6 +8,14 @@
## Не выпущено
- «Оптимизировать планы» теперь проверяет точный кандидат из предпросмотра теми
же построителями стен, проёмов, перегородок, колонн и пола, которыми пользуется
карточка. Если геометрию хотя бы одного пространства нельзя безопасно
построить, диалог называет до трёх проблемных пространств, объясняет, как
сообщить об ошибке, и оставляет только «Отмена»: конфигурация, раскладка,
ревизии и одношаговая отмена не меняются
([#199](https://github.com/Matysh/houseplan-card/issues/199)).
- Каждая запись конфигурации и раскладки устройств теперь приводит геометрию к
девяти десятичным знакам, не привязывая её к сетке. Невидимые
floating-point-хвосты удаляются до того, как смогут сломать общие стены,
+1 -1
View File
@@ -22,7 +22,7 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
| Item | State |
|---|---|
| Version | **v1.66.0** everywhere (manifest, const.py, package.json, CARD_VERSION) — stable promotion of the published v1.66.0-beta.1 product line |
| Current local cycle | Post-v1.66.0 Unreleased development includes reliable Plan drawing (#228): exact Shift rays, ambiguous-node blocking, active axis/node chrome, room creation from an existing face with an explicit ≤2 cm repair, and room deletion with Keep/Delete walls consequences. Entity/parent marker deduplication (#226), explicit coordinate maintenance (#223) and canonical geometry on every write (#224) are also included. |
| Current local cycle | Post-v1.66.0 Unreleased development includes reliable Plan drawing (#228): exact Shift rays, ambiguous-node blocking, active axis/node chrome, room creation from an existing face with an explicit ≤2 cm repair, and room deletion with Keep/Delete walls consequences. Entity/parent marker deduplication (#226), explicit coordinate maintenance (#223), canonical geometry on every write (#224) and a fail-closed production-geometry preflight for the exact Optimize candidate (#199) are also included. |
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1. #122 Stage 2 ships in v1.64.0 and evolves the same hidden, expiring `iso` experiment with matte walls, a low exterior floor edge, restrained shared shadows and live vertical door/window/gate panels. Flat remains default; editors and `houseplan-space-card` remain flat; live floor effects and HA actions remain unchanged. Public activation remains a separate task. |
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records; their labels carry priority and workflow status (`PROCESS.md` §9). GitHub Projects is no longer used. `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
+17
View File
@@ -2096,6 +2096,23 @@ require hands on real hardware — they remain for the human pass.
[unit: align-grid + plan-optimizer + i18n; auto:
smoke_optimize_coordinate_canonicalization; mutation:
`snapn-returns-input-near-node`].
- [ ] **Optimize rejects an unrenderable candidate before writing (#199)**:
all spaces pass the shared production wall/opening/physical/floor input
projection. A forced wall/floor `null` or exception produces only a
bounded failure code; valid empty/image-only spaces remain allowed. One
failing floor removes Apply and makes zero Optimize WS calls without
changing config, layout, revisions or Undo. The first three safe names,
the remaining count and recovery hint are exact in RU/EN; unchanged Apply
reuses its fingerprint result, while a changed candidate is checked again.
The 3-floor/60-room/100-opening/60-partition/40-column fixture must stay
under 250 ms p95 and within direct-builder p95 × 1.2 + 15 ms
[unit: test/plan-geometry-preflight.test.mjs; auto:
smoke_optimize_geometry_preflight; benchmark:
benchmark_optimize_geometry_preflight; golden: dark/light failure dialog;
mutations: `optimize-preflight-bypassed`,
`optimize-preflight-active-space-only`,
`optimize-preflight-accepts-null`,
`optimize-preflight-renders-apply-on-failure`].
- [ ] **Every write prevents new ULP coordinate noise (#224)**: config/layout
schema, import, direct storage writers, startup recovery and maintenance
Undo produce the same nine-decimal allow-listed geometry as the frontend.
+9
View File
@@ -1366,6 +1366,15 @@ show_signal: true
честный предпросмотр «сдвинуто элементов — 0» с ненулевым числом очищенных
координат. Повторный Optimize над результатом ничего не предлагает.
Перед подтверждением House Plan дополнительно строит точную геометрию кандидата
для **всех** пространств — тем же способом, которым затем рисуются стены,
проёмы, перегородки, колонны и пол. Если проверка хотя бы одного пространства
не прошла, оптимизация целиком блокируется: вместо отчёта диалог называет до
трёх пространств и количество остальных, а кнопки «Оптимизировать» нет.
Конфигурация, позиции, ревизии и доступная серверная отмена при этом не
изменяются. Обновите House Plan и повторите; если сообщение осталось, приложите
экспорт названного пространства к отчёту об ошибке.
### Что оптимизация сохраняет
- неизвестные поля новых версий;
+11 -11
View File
@@ -1,7 +1,7 @@
{
"version": 1,
"fixture": "synthetic-only",
"sourceFingerprint": "ef5431f42e2c08425388d4d14df10de29040ea755ac64f81b734430729faae4f",
"sourceFingerprint": "967bdb37e447ec29a88d82707f5ff957e576be72fbe801445c6cbdec970e43eb",
"captureScriptSha256": "34f2219790d46efd8250e7a1bd829cb8fc0b0547e1260635fefa52407551b41b",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -13,7 +13,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ef5431f42e2c08425388d4d14df10de29040ea755ac64f81b734430729faae4f",
"sourceSha256": "967bdb37e447ec29a88d82707f5ff957e576be72fbe801445c6cbdec970e43eb",
"imageSha256": "2885f96e348b15ab7c696e56e99bddcd9bb2ee94218a883e5a2155f45f5042aa"
},
"view-touch": {
@@ -24,7 +24,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ef5431f42e2c08425388d4d14df10de29040ea755ac64f81b734430729faae4f",
"sourceSha256": "967bdb37e447ec29a88d82707f5ff957e576be72fbe801445c6cbdec970e43eb",
"imageSha256": "f62d8af3617c00a5e99511bd765980d2e27badf25047a1be34046b64195abe6c"
},
"space-create": {
@@ -35,7 +35,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ef5431f42e2c08425388d4d14df10de29040ea755ac64f81b734430729faae4f",
"sourceSha256": "967bdb37e447ec29a88d82707f5ff957e576be72fbe801445c6cbdec970e43eb",
"imageSha256": "c33a7279165a4cec6fa6fadb6fd08cd967e082a17fe101ef442d27d36ae59b6b"
},
"room-contour-close": {
@@ -46,7 +46,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ef5431f42e2c08425388d4d14df10de29040ea755ac64f81b734430729faae4f",
"sourceSha256": "967bdb37e447ec29a88d82707f5ff957e576be72fbe801445c6cbdec970e43eb",
"imageSha256": "4d63670c7bcca33da21a6cf17275786bed12e8422a50d02d4dabe59645f2cd82"
},
"plan-context-tray": {
@@ -57,7 +57,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ef5431f42e2c08425388d4d14df10de29040ea755ac64f81b734430729faae4f",
"sourceSha256": "967bdb37e447ec29a88d82707f5ff957e576be72fbe801445c6cbdec970e43eb",
"imageSha256": "a6c526fede11bc3503fd2384bd4a3f6afa008f481c5c47578f06cae6c81c6f9b"
},
"device-editor": {
@@ -68,7 +68,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ef5431f42e2c08425388d4d14df10de29040ea755ac64f81b734430729faae4f",
"sourceSha256": "967bdb37e447ec29a88d82707f5ff957e576be72fbe801445c6cbdec970e43eb",
"imageSha256": "9585b59add4d35b5a6f028ce5b720ef1b77a3f8b19436d192cd1a0e6fc637264"
},
"device-display-preview": {
@@ -79,7 +79,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ef5431f42e2c08425388d4d14df10de29040ea755ac64f81b734430729faae4f",
"sourceSha256": "967bdb37e447ec29a88d82707f5ff957e576be72fbe801445c6cbdec970e43eb",
"imageSha256": "cfc317da4628d079a116ff71311fbf06b1eb3b181928a5ea7ba888ab936a5e8b"
},
"background-editor": {
@@ -90,7 +90,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ef5431f42e2c08425388d4d14df10de29040ea755ac64f81b734430729faae4f",
"sourceSha256": "967bdb37e447ec29a88d82707f5ff957e576be72fbe801445c6cbdec970e43eb",
"imageSha256": "d7cfe70551d9260169df8efd832e32ed7df99c7f60b55d1fd4a8322bd4c47175"
},
"room-card": {
@@ -101,7 +101,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ef5431f42e2c08425388d4d14df10de29040ea755ac64f81b734430729faae4f",
"sourceSha256": "967bdb37e447ec29a88d82707f5ff957e576be72fbe801445c6cbdec970e43eb",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
},
"device-info": {
@@ -112,7 +112,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ef5431f42e2c08425388d4d14df10de29040ea755ac64f81b734430729faae4f",
"sourceSha256": "967bdb37e447ec29a88d82707f5ff957e576be72fbe801445c6cbdec970e43eb",
"imageSha256": "a06cbf83f09e2f67b3566d7c0b10e973db060c3d20786f26f74ada6a21937c3e"
}
}
+1
View File
@@ -16,6 +16,7 @@
"benchmark:large-house-plan-snap": "node demo/benchmark_large_house.mjs --profile=large-house-plan-snap-v1",
"benchmark:glow": "node demo/benchmark_glow.mjs",
"benchmark:compare": "node demo/performance/compare.mjs",
"benchmark:optimize-geometry-preflight": "node demo/benchmark_optimize_geometry_preflight.mjs",
"golden:capture": "node demo/golden/run.mjs --mode=capture",
"golden:verify": "node demo/golden/run.mjs --mode=verify",
"continuity:screencast": "node demo/screencast_visual_continuity.mjs",
+48
View File
@@ -229,6 +229,54 @@ export const MUTANTS = [
replace: ' config = _json_copy(payload.get("config"))',
}],
},
{
id: 'optimize-preflight-bypassed',
guard: 'node demo/smoke_optimize_geometry_preflight.mjs',
because: 'a red preview must remain a hard write barrier even if a caller invokes the '
+ 'private Apply method directly instead of clicking the deliberately absent button (#199)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' if (!d || d.busy || !this._serverCfg || !d.changed || !d.preflight?.ok) return;',
replace: ' if (!d || d.busy || !this._serverCfg || !d.changed) return;',
}],
},
{
id: 'optimize-preflight-active-space-only',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="one failed space blocks" '
+ 'test/plan-geometry-preflight.test.mjs',
because: 'Optimize is one whole-plan transaction, so a broken non-active floor must block '
+ 'the operation just as decisively as the first floor in config order (#199)',
patches: [{
file: 'src/plan-geometry-preflight.ts',
find: ' const rawSpaces = Array.isArray(config?.spaces) ? config.spaces : [];',
replace: ' const rawSpaces = Array.isArray(config?.spaces) ? config.spaces.slice(0, 1) : [];',
}],
},
{
id: 'optimize-preflight-accepts-null',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="null, exceptions and floor failure" '
+ 'test/plan-geometry-preflight.test.mjs',
because: 'wallBodiesGeometry null is a structural boolean failure, not an empty successful '
+ 'wall set that may be hidden behind a later floor fallback (#199)',
patches: [{
file: 'src/plan-geometry-preflight.ts',
find: ' if (united == null) {',
replace: ' if (false && united == null) {',
}],
},
{
id: 'optimize-preflight-renders-apply-on-failure',
guard: 'node demo/smoke_optimize_geometry_preflight.mjs',
because: 'the failure state is not a dismissible warning: rendering Apply invites a person '
+ 'to treat an unsafe whole-plan candidate as an accepted risk (#199)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' ${!d.changed || !d.preflight?.ok ? nothing : html`',
replace: ' ${!d.changed ? nothing : html`',
}],
},
{
id: 'union-failure-kills-space',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+57 -54
View File
@@ -149,7 +149,7 @@ import {
import {
hostedOpeningIntervalsOverlap, materializePartitionOpening,
partitionOpeningJambMargin, partitionOpeningNeedsStrictValidation,
partitionOpeningCut, partitionOpeningFace, partitionOpeningHasCompositeRoomWall,
partitionOpeningFace,
partitionPlacementIntervals, resolvePartitionOpeningCompat, resolvePartitionOpeningStrict,
type PartitionOpeningOrphanReason, type ResolvedPartitionOpening,
} from './partition-openings';
@@ -187,6 +187,16 @@ import {
clampCanvasR, clampCanvasN, type ContentItem, type Rect,
} from './space-geometry';
import { optimizePlans, type OptimizeReport } from './plan-optimizer';
import {
checkOptimizeGeometry,
geometryOpenCuts,
geometryOpenings,
geometryOpenPairs,
geometryPartitionOpeningCuts,
geometryRoomOpeningInputs,
type GeometryOpeningProjection,
type OptimizeGeometryPreflightResult,
} from './plan-geometry-preflight';
import {
canonicalizeConfigGeometry,
canonicalizeLayoutGeometry,
@@ -1707,6 +1717,7 @@ class HouseplanCard extends LitElement {
/** Optimization preview plus the exact pair, so commit cannot differ from it. */
private _alignDialog: {
report: OptimizeReport; config: any; layout: Record<string, any>;
preflight: OptimizeGeometryPreflightResult | null;
/** the promised maximum, in centimetres, ALREADY rounded up (AUD-158B1-01) */
cm: number;
/** the space that maximum belongs to, named only when there are several */
@@ -8588,16 +8599,11 @@ class HouseplanCard extends LitElement {
accept: (opening: OpeningCfg) => boolean = () => true,
): PartitionOpeningCut[] {
if (!space) return [];
const raw = this._curSpaceCfg?.id === space.id ? this._curSpaceCfg?.openings || [] : [];
const cuts: PartitionOpeningCut[] = [];
for (const opening of raw) {
if (!opening.host || !accept(opening)) continue;
const resolution = resolvePartitionOpeningCompat(
opening, space.partitions, NORM_W, this._cellCm, this._gridPitch,
);
if (resolution.resolved) cuts.push(partitionOpeningCut(resolution.resolved));
}
return cuts;
const config = this._curSpaceCfg?.id === space.id ? this._curSpaceCfg : null;
const openings = geometryOpenings(
config, space, this._cellCm, this._gridPitch, NORM_W,
);
return geometryPartitionOpeningCuts(openings, accept);
}
/**
@@ -8610,22 +8616,11 @@ class HouseplanCard extends LitElement {
): Array<{ x: number; y: number; angle: number; length: number }> {
if (!space) return [];
const openCuts = this._openPairs().flatMap((pair) => pair.segs);
const intervals = wallIntervals(
space.rooms, this._spaceWalls, openCuts,
return geometryRoomOpeningInputs(
openings as readonly GeometryOpeningProjection[],
space, this._spaceWalls, openCuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
);
return openings.flatMap((opening) => {
const input = {
x: opening.rx, y: opening.ry,
angle: Number(opening.angle) || 0,
length: opening.rlen,
};
if (!opening.host) return [input];
if (!opening.partitionHost) return [];
return partitionOpeningHasCompositeRoomWall(
opening.partitionHost, intervals, this._gridPitch * 0.0002,
) ? [input] : [];
});
}
private _openingFace(
@@ -10861,35 +10856,13 @@ class HouseplanCard extends LitElement {
const sp = this._curSpaceCfg;
const space = this._spaceModel();
if (!space) return [];
return resolveOpenCuts(
space.rooms,
(sp as any)?.open_spans as OpenSpanEntry[] | undefined,
NORM_W,
this._gridPitch * 0.02,
);
return geometryOpenCuts(sp, space, this._gridPitch, NORM_W);
}
/** Open cuts grouped by room pair (for per-room outline trimming). */
private _openPairs(): { a: RoomCfg; b: RoomCfg; segs: number[][] }[] {
const cuts = this._openCuts();
if (!cuts.length) return [];
const rooms = (this._spaceModel()?.rooms || []).filter((r) => r.id);
const eps = this._gridPitch * 0.02;
const res: { a: RoomCfg; b: RoomCfg; segs: number[][] }[] = [];
for (let i = 0; i < rooms.length; i++) {
for (let j = i + 1; j < rooms.length; j++) {
const pa = roomPoly(rooms[i]), pb = roomPoly(rooms[j]);
if (!pa || !pb) continue;
const shared = sharedBoundary(pa, pb, eps);
if (!shared.length) continue;
const segs = cuts.filter((cut) => {
const mid = [(cut[0] + cut[2]) / 2, (cut[1] + cut[3]) / 2];
return shared.some((sg) => distToSegment(mid, sg) < eps * 4);
});
if (segs.length) res.push({ a: rooms[i], b: rooms[j], segs });
}
}
return res;
return geometryOpenPairs(this._spaceModel()?.rooms || [], cuts, this._gridPitch);
}
/**
@@ -14900,10 +14873,19 @@ class HouseplanCard extends LitElement {
* Preview whole-plan maintenance. Nothing is written here: the pure run
* produces both the report and the exact config/layout pair to commit.
*/
private _checkOptimizeGeometry(config: ServerConfig): OptimizeGeometryPreflightResult {
return checkOptimizeGeometry(config, {
fallbackSpaceName: (index) => this._t('gs.align_preflight_space', {
n: String(index),
}),
});
}
private _openAlignDialog = (): void => {
if (!this._norm || !this._serverCfg) return;
const spaces = this._serverCfg.spaces || [];
const r = optimizePlans(this._serverCfg, this._layout || {});
const preflight = r.changed ? this._checkOptimizeGeometry(r.config) : null;
// The maximum geometry shift is an UPPER BOUND, not a sample. The run
// measured every element in the centimetres of ITS OWN space — converting
// one normalised maximum through the first space's `cell_cm` understated
@@ -14914,7 +14896,7 @@ class HouseplanCard extends LitElement {
const where = spaces.length > 1 && sp ? String(sp.title || sp.id) : '';
this._alignDialog = {
report: r.report, config: r.config, layout: r.layout, cm, where,
changed: r.changed, busy: false,
preflight, changed: r.changed, busy: false,
};
};
@@ -14923,8 +14905,15 @@ class HouseplanCard extends LitElement {
* one-deep snapshot that remains undoable until the next plan edit.
*/
private async _runAlignToGrid(): Promise<void> {
const d = this._alignDialog;
if (!d || d.busy || !this._serverCfg) return;
let d = this._alignDialog;
if (!d || d.busy || !this._serverCfg || !d.changed || !d.preflight?.ok) return;
const fingerprint = contentFingerprint(d.config);
if (d.preflight.fingerprint !== fingerprint) {
const preflight = this._checkOptimizeGeometry(d.config);
d = { ...d, preflight };
this._alignDialog = d;
if (!preflight.ok) return;
}
this._clearGeometryGesture();
this._alignDialog = { ...d, busy: true };
try {
@@ -15937,10 +15926,24 @@ class HouseplanCard extends LitElement {
private _renderAlignDialog(): TemplateResult {
const d = this._alignDialog!;
const r = d.report;
const failed = d.changed && !d.preflight?.ok;
const failures = d.preflight?.failures || [];
const visibleNames = failures.slice(0, 3).map((failure) => failure.displayName);
const spaces = visibleNames.length
? visibleNames.join(', ')
: this._t('gs.align_preflight_space', { n: '1' });
const remaining = Math.max(0, failures.length - visibleNames.length);
const more = remaining
? this._t('gs.align_preflight_more', { n: String(remaining) })
: '';
return html`<hp-dialog .hass=${this.hass} .title=${this._t('gs.align_title')} icon="mdi:broom"
dismiss-on-scrim @hp-close=${() => (this._alignDialog = null)}>
<div class="body">
${!d.changed
${failed
? html`
<p class="alignmsg">${this._t('gs.align_preflight_failed', { spaces, more })}</p>
<div class="rhint">${this._t('gs.align_preflight_hint')}</div>`
: !d.changed
? html`<p class="alignmsg">${this._t('gs.align_none')}</p>`
: html`
${r.moved ? html`<p class="alignmsg">${this._t('gs.align_count', {
@@ -15973,7 +15976,7 @@ class HouseplanCard extends LitElement {
<div class="row" slot="footer">
<span class="spacer"></span>
<button class="btn ghost" @click=${() => (this._alignDialog = null)}>${this._t('btn.cancel')}</button>
${!d.changed ? nothing : html`
${!d.changed || !d.preflight?.ok ? nothing : html`
<button class="btn on" @click=${this._runAlignToGrid} ?disabled=${d.busy}>
<ha-icon icon="mdi:check"></ha-icon>${d.busy ? '…' : this._t('gs.align_run')}
</button>`}
+4
View File
@@ -794,6 +794,10 @@
"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.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}",
"gs.align_preflight_more": ", and {n} more",
"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_glow_migration": "Legacy Glow: {spaces} spaces → no data fill + independent Glow; {rooms} rooms → inherited data fill + independent Glow.",
"gs.align_warn": "Elements deliberately placed between grid nodes will move. One undo is available after the operation, only until the next plan edit.",
+4
View File
@@ -794,6 +794,10 @@
"gs.align_where": "Наибольший сдвиг — в пространстве «{s}».",
"gs.align_turned": "Проёмов с исправлением угла: {n}.",
"gs.align_removed_drafts": "Схлопнувшиеся на сетке некорректные контуры удалены: {n}.",
"gs.align_preflight_failed": "Не удалось безопасно проверить геометрию следующих пространств: {spaces}{more}.",
"gs.align_preflight_hint": "Планы не изменены. Обновите House Plan и повторите. Если ошибка останется, приложите экспорт пространства к отчёту об ошибке.",
"gs.align_preflight_space": "Пространство {n}",
"gs.align_preflight_more": " и ещё {n}",
"gs.optimize_changes": "Миграций модели: {m}; обновлено пространств: {c}; устранён шум координат: {p}; объединено отрезков реальных стен: {w}; виртуальных: {s}; независимых: {i}.",
"gs.optimize_glow_migration": "Старый Glow: пространств — {spaces} → без заливки данных + независимый Glow; комнат — {rooms} → наследуемая заливка + независимый Glow.",
"gs.align_warn": "Элементы, намеренно поставленные между узлами, будут сдвинуты. После операции доступна одна отмена — только до следующего изменения плана.",
+337
View File
@@ -0,0 +1,337 @@
/**
* Pure production-geometry preparation and the explicit Optimize preflight.
*
* This module deliberately owns no Lit, DOM or Home Assistant state. The card
* uses the same preparation helpers for its renderer, so the safety check
* cannot quietly drift into a second, simplified geometry algorithm.
*/
import { distToSegment, roomPoly, sharedBoundary } from './logic';
import { resolveOpenCuts, type OpenSpanEntry } from './open-spans';
import {
partitionOpeningCut,
partitionOpeningHasCompositeRoomWall,
resolvePartitionOpeningCompat,
type ResolvedPartitionOpening,
} from './partition-openings';
import { physicalBodyParts, type PartitionOpeningCut } from './physical-geometry';
import {
floorFootprintGeometry,
wallBodiesGeometry,
wallIntervals,
type WallEntry,
} from './wall-thickness';
import {
GRID_PITCH,
GRID_STEP_N,
NORM_W,
spaceModels,
} from './space-geometry';
import type { OpeningCfg, ServerConfig, SpaceModel } from './types';
import { contentFingerprint } from './visual-continuity';
export interface GeometryOpeningProjection extends OpeningCfg {
rx: number;
ry: number;
rlen: number;
partitionHost?: ResolvedPartitionOpening;
}
export interface RoomOpeningGeometryInput {
x: number;
y: number;
angle: number;
length: number;
}
export interface SpacePhysicalGeometryInputs {
space: SpaceModel;
walls: WallEntry[];
openCuts: number[][];
openings: GeometryOpeningProjection[];
roomOpenings: RoomOpeningGeometryInput[];
partitionCuts: PartitionOpeningCut[];
physicalBodies: number[][][];
wallKeyPitch: number;
cellCm: number;
gridPitch: number;
coordScale: number;
}
export type OptimizeGeometryFailureReason =
| 'prepare-exception'
| 'wall-null'
| 'wall-exception'
| 'floor-null'
| 'floor-exception';
export type OptimizeSpaceGeometryStatus = 'ok' | 'failed' | 'not-applicable';
export interface OptimizeSpaceGeometryCheck {
spaceId: string;
displayName: string;
status: OptimizeSpaceGeometryStatus;
reason?: OptimizeGeometryFailureReason;
}
export interface OptimizeGeometryPreflightResult {
fingerprint: string;
spaces: OptimizeSpaceGeometryCheck[];
failures: Array<OptimizeSpaceGeometryCheck & {
status: 'failed'; reason: OptimizeGeometryFailureReason;
}>;
ok: boolean;
}
export interface CheckOptimizeGeometryOptions {
fallbackSpaceName?: (oneBasedIndex: number) => string;
prepareSpace?: typeof prepareSpacePhysicalGeometryInputs;
wallPass?: typeof wallBodiesGeometry;
floorPass?: typeof floorFootprintGeometry;
fingerprint?: typeof contentFingerprint;
}
/** Resolve explicit spans and the legacy open_to fallback exactly as the card. */
export function geometryOpenCuts(
spaceConfig: any,
space: Pick<SpaceModel, 'rooms'>,
gridPitch = GRID_PITCH,
coordScale = NORM_W,
): number[][] {
return resolveOpenCuts(
space.rooms,
spaceConfig?.open_spans as OpenSpanEntry[] | undefined,
coordScale,
gridPitch * 0.02,
);
}
/**
* Group open cuts by the two rooms that own their shared boundary. Wall
* masonry has historically consumed this filtered/ordered projection rather
* than the raw span array; both renderer and preflight share it here.
*/
export function geometryOpenPairs(
roomsInput: readonly any[],
cuts: readonly number[][],
gridPitch = GRID_PITCH,
): Array<{ a: any; b: any; segs: number[][] }> {
if (!cuts.length) return [];
const rooms = roomsInput.filter((room) => room?.id);
const eps = gridPitch * 0.02;
const result: Array<{ a: any; b: any; segs: number[][] }> = [];
for (let left = 0; left < rooms.length; left++) {
for (let right = left + 1; right < rooms.length; right++) {
const a = rooms[left], b = rooms[right];
const polygonA = roomPoly(a), polygonB = roomPoly(b);
if (!polygonA || !polygonB) continue;
const shared = sharedBoundary(polygonA, polygonB, eps);
if (!shared.length) continue;
const segs = cuts.filter((cut) => {
const midpoint = [(cut[0] + cut[2]) / 2, (cut[1] + cut[3]) / 2];
return shared.some((segment) => distToSegment(midpoint, segment) < eps * 4);
}).map((cut) => [...cut]);
if (segs.length) result.push({ a, b, segs });
}
}
return result;
}
/** Project stored openings into render units; invalid hosted records stay inert. */
export function geometryOpenings(
spaceConfig: any,
space: Pick<SpaceModel, 'partitions'>,
cellCm: number,
gridPitch = GRID_PITCH,
coordScale = NORM_W,
): GeometryOpeningProjection[] {
const raw = Array.isArray(spaceConfig?.openings) ? spaceConfig.openings : [];
return raw.flatMap((opening: OpeningCfg) => {
const fallback: GeometryOpeningProjection = {
...opening,
rx: Number(opening.x) * coordScale,
ry: Number(opening.y) * coordScale,
rlen: Number(opening.length) * coordScale,
};
if (!opening.host) return [fallback];
const resolution = resolvePartitionOpeningCompat(
opening, space.partitions, coordScale, cellCm, gridPitch,
);
if (!resolution.resolved) return [];
return [{
...opening,
rx: resolution.resolved.center[0],
ry: resolution.resolved.center[1],
rlen: resolution.resolved.length,
angle: resolution.resolved.angle,
partitionHost: resolution.resolved,
}];
});
}
export function geometryPartitionOpeningCuts(
openings: readonly GeometryOpeningProjection[],
accept: (opening: OpeningCfg) => boolean = () => true,
): PartitionOpeningCut[] {
return openings.flatMap((opening) => (
opening.host && opening.partitionHost && accept(opening)
? [partitionOpeningCut(opening.partitionHost)]
: []
));
}
/**
* Room masonry is cut by a hosted opening only for the exact composite wall
* rule used by the production card. Ordinary openings always remain inputs.
*/
export function geometryRoomOpeningInputs(
openings: readonly GeometryOpeningProjection[],
space: Pick<SpaceModel, 'rooms'>,
walls: readonly WallEntry[],
openCuts: number[][],
wallKeyPitch: number,
cellCm: number,
gridPitch = GRID_PITCH,
coordScale = NORM_W,
): RoomOpeningGeometryInput[] {
const intervals = wallIntervals(
space.rooms, [...walls], openCuts,
wallKeyPitch, cellCm, gridPitch, coordScale,
);
return openings.flatMap((opening) => {
const input = {
x: opening.rx,
y: opening.ry,
angle: Number(opening.angle) || 0,
length: opening.rlen,
};
if (!opening.host) return [input];
if (!opening.partitionHost) return [];
return partitionOpeningHasCompositeRoomWall(
opening.partitionHost, intervals, gridPitch * 0.0002,
) ? [input] : [];
});
}
/** Prepare every argument consumed by the production boolean passes. */
export function prepareSpacePhysicalGeometryInputs(
spaceConfig: any,
space: SpaceModel,
): SpacePhysicalGeometryInputs {
const wallKeyPitch = GRID_STEP_N;
const gridPitch = GRID_PITCH;
const coordScale = NORM_W;
const cellCm = Number.isFinite(Number(space.cellCm)) && Number(space.cellCm) > 0
? Number(space.cellCm) : 5;
const walls = Array.isArray(spaceConfig?.walls)
? spaceConfig.walls as WallEntry[] : [];
const cuts = geometryOpenCuts(spaceConfig, space, gridPitch, coordScale);
const openCuts = geometryOpenPairs(space.rooms, cuts, gridPitch)
.flatMap((pair) => pair.segs);
const openings = geometryOpenings(
spaceConfig, space, cellCm, gridPitch, coordScale,
);
const partitionCuts = geometryPartitionOpeningCuts(openings);
const physicalBodies = physicalBodyParts(
space, cellCm, gridPitch, gridPitch * 0.0002, partitionCuts,
).all;
const roomOpenings = geometryRoomOpeningInputs(
openings, space, walls, openCuts,
wallKeyPitch, cellCm, gridPitch, coordScale,
);
return {
space, walls, openCuts, openings, roomOpenings, partitionCuts,
physicalBodies, wallKeyPitch, cellCm, gridPitch, coordScale,
};
}
function safeDisplayName(
spaceConfig: any,
index: number,
fallback: (oneBasedIndex: number) => string,
): { spaceId: string; displayName: string } {
const title = typeof spaceConfig?.title === 'string' ? spaceConfig.title.trim() : '';
const id = spaceConfig?.id == null ? '' : String(spaceConfig.id).trim();
return {
spaceId: id,
displayName: title || id || fallback(index + 1),
};
}
/**
* Fail-closed renderability check for the exact config returned by Optimize.
* Geometry values never escape this call; the dialog retains only bounded
* statuses, names and the candidate fingerprint.
*/
export function checkOptimizeGeometry(
config: ServerConfig | any,
options: CheckOptimizeGeometryOptions = {},
): OptimizeGeometryPreflightResult {
const fingerprint = (options.fingerprint || contentFingerprint)(config);
const rawSpaces = Array.isArray(config?.spaces) ? config.spaces : [];
const fallback = options.fallbackSpaceName || ((index: number) => `Space ${index}`);
const prepare = options.prepareSpace || prepareSpacePhysicalGeometryInputs;
const buildWalls = options.wallPass || wallBodiesGeometry;
const buildFloor = options.floorPass || floorFootprintGeometry;
const spaces: OptimizeSpaceGeometryCheck[] = [];
for (let index = 0; index < rawSpaces.length; index++) {
const raw = rawSpaces[index];
const identity = safeDisplayName(raw, index, fallback);
let input: SpacePhysicalGeometryInputs;
try {
const model = spaceModels({ ...config, spaces: [raw] } as ServerConfig)[0];
if (!model) throw new Error('missing space model');
input = prepare(raw, model);
} catch {
spaces.push({ ...identity, status: 'failed', reason: 'prepare-exception' });
continue;
}
const hasWallPass = input.walls.length > 0 || input.physicalBodies.length > 0;
if (!input.space.rooms.length && !hasWallPass) {
spaces.push({ ...identity, status: 'not-applicable' });
continue;
}
let united: ReturnType<typeof wallBodiesGeometry> = null;
if (hasWallPass) {
try {
united = buildWalls(
input.space.rooms, input.walls, input.openCuts, input.roomOpenings,
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
input.physicalBodies,
);
} catch {
spaces.push({ ...identity, status: 'failed', reason: 'wall-exception' });
continue;
}
if (united == null) {
spaces.push({ ...identity, status: 'failed', reason: 'wall-null' });
continue;
}
}
if (input.space.rooms.length && united?.paperGeom == null) {
let floor: ReturnType<typeof floorFootprintGeometry>;
try {
floor = buildFloor(
input.space.rooms, input.walls, input.openCuts,
input.wallKeyPitch, input.cellCm, input.gridPitch, input.coordScale,
);
} catch {
spaces.push({ ...identity, status: 'failed', reason: 'floor-exception' });
continue;
}
if (floor == null) {
spaces.push({ ...identity, status: 'failed', reason: 'floor-null' });
continue;
}
}
spaces.push({ ...identity, status: 'ok' });
}
const failures = spaces.filter((space): space is typeof space & {
status: 'failed'; reason: OptimizeGeometryFailureReason;
} => space.status === 'failed');
return { fingerprint, spaces, failures, ok: failures.length === 0 };
}
+13 -1
View File
@@ -234,7 +234,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(GOLDEN_MATRIX_VERSION, 33);
assert.equal(GOLDEN_MATRIX_VERSION, 34);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
@@ -507,6 +507,18 @@ test('device dialog goldens expose the complete light-source controls at desktop
'the harness must activate the declared light-controls scenario flag');
});
test('issue #199 golden candidates cover the blocked Optimize dialog in both themes', () => {
const scenarios = GOLDEN_SCENARIOS.filter((scenario) =>
scenario.id.startsWith('optimize-preflight-dialog-'));
assert.equal(scenarios.length, 2);
assert.deepEqual(new Set(scenarios.map((scenario) => scenario.theme)), new Set(['dark', 'light']));
assert.deepEqual(new Set(scenarios.map((scenario) => scenario.language)), new Set(['en', 'ru']));
for (const scenario of scenarios) {
assert.equal(scenario.dialog, 'optimize-preflight');
assert.equal(scenario.capture, 'page');
}
});
test('golden harness neutralizes the shared pointer before every scenario', () => {
const harness = readFileSync(new URL('../demo/golden/harness.mjs', import.meta.url), 'utf8');
const reset = harness.indexOf('await page.mouse.move(0, 0);');
+163
View File
@@ -0,0 +1,163 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
checkOptimizeGeometry,
prepareSpacePhysicalGeometryInputs,
} from '../test-build/plan-geometry-preflight.js';
import { GRID_STEP_N, spaceModels } from '../test-build/space-geometry.js';
import { contentFingerprint } from '../test-build/visual-continuity.js';
import { wallKey } from '../test-build/wall-thickness.js';
const clone = (value) => structuredClone(value);
const room = (id = 'room') => ({
id,
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]],
});
const wall = (cm = 20) => ({
key: wallKey([0.1, 0.1], [0.9, 0.1], GRID_STEP_N),
a: [0.1, 0.1], b: [0.9, 0.1], cm,
});
const base = (spaces) => ({ model_version: 6, spaces, markers: [], settings: {} });
test('Optimize geometry preflight covers the production input matrix without mutating config', () => {
const config = base([
{
id: 'room-wall', title: 'Room wall', view_box: [0, 0, 1, 1], cell_cm: 5,
rooms: [room()], walls: [wall()],
openings: [{
id: 'ordinary', type: 'door', x: 0.5, y: 0.1,
angle: 0, length: 0.1,
}],
},
{
id: 'hosted', title: 'Hosted', view_box: [0, 0, 1, 1], cell_cm: 5,
rooms: [room('host-room')], walls: [wall(18)],
partitions: [{ id: 'partition', a: [0.1, 0.1], b: [0.9, 0.1], cm: 15 }],
openings: [{
id: 'hosted-opening', type: 'passage', x: 0, y: 0,
angle: 0, length: 0.1,
host: { kind: 'partition', id: 'partition', t: 0.5 },
}],
},
{
id: 'physical-only', title: 'Physical only', view_box: [0, 0, 1, 1], cell_cm: 5,
rooms: [],
partitions: [{ id: 'p', a: [0.1, 0.2], b: [0.8, 0.2], cm: 15 }],
room_drafts: [{
id: 'draft', points: [[0.1, 0.4], [0.8, 0.4]], segments: [{ cm: 12 }],
}],
wall_columns: [{ id: 'column', shape: 'square', center: [0.5, 0.6], cm: 20 }],
},
{
id: 'image-only', title: 'Image only', view_box: [0, 0, 1, 1],
plan_url: '/local/plan.png', rooms: [],
},
{ id: 'empty', title: 'Empty', view_box: [0, 0, 1, 1], rooms: [] },
{ id: 'floor-only', title: 'Floor only', view_box: [0, 0, 1, 1], rooms: [room('floor')] },
]);
const before = clone(config);
const result = checkOptimizeGeometry(config);
assert.deepEqual(config, before);
assert.equal(result.fingerprint, contentFingerprint(config));
assert.equal(result.ok, true);
assert.deepEqual(result.spaces.map(({ spaceId, status }) => ({ spaceId, status })), [
{ spaceId: 'room-wall', status: 'ok' },
{ spaceId: 'hosted', status: 'ok' },
{ spaceId: 'physical-only', status: 'ok' },
{ spaceId: 'image-only', status: 'not-applicable' },
{ spaceId: 'empty', status: 'not-applicable' },
{ spaceId: 'floor-only', status: 'ok' },
]);
});
test('production preparation resolves ordinary and hosted openings once for masonry and bodies', () => {
const raw = {
id: 'parity', title: 'Parity', view_box: [0, 0, 1, 1], cell_cm: 5,
rooms: [room()], walls: [wall()],
partitions: [{ id: 'partition', a: [0.1, 0.1], b: [0.9, 0.1], cm: 15 }],
openings: [
{ id: 'ordinary', type: 'door', x: 0.3, y: 0.1, angle: 0, length: 0.08 },
{
id: 'hosted', type: 'passage', x: 0, y: 0, angle: 0, length: 0.08,
host: { kind: 'partition', id: 'partition', t: 0.7 },
},
{
id: 'orphan', type: 'door', x: 0.4, y: 0.4, angle: 0, length: 0.08,
host: { kind: 'partition', id: 'missing', t: 0.5 },
},
],
};
const model = spaceModels(base([raw]))[0];
const input = prepareSpacePhysicalGeometryInputs(raw, model);
assert.deepEqual(input.openings.map((opening) => opening.id), ['ordinary', 'hosted']);
assert.equal(input.partitionCuts.length, 1);
assert.equal(input.roomOpenings.length, 2, 'coincident hosted wall cuts room masonry too');
assert.ok(input.physicalBodies.length >= 2, 'partition and its opening jamb bodies are retained');
assert.equal(input.wallKeyPitch, GRID_STEP_N);
assert.equal(input.cellCm, 5);
});
test('null, exceptions and floor failure are bounded while successful empty geometry stays green', () => {
const wallConfig = base([{
id: 'wall-space', title: 'Wall space', view_box: [0, 0, 1, 1],
rooms: [room()], walls: [wall()],
}]);
const floorConfig = base([{
id: 'floor-space', title: 'Floor space', view_box: [0, 0, 1, 1], rooms: [room()],
}]);
const wallNull = checkOptimizeGeometry(wallConfig, { wallPass: () => null });
assert.equal(wallNull.ok, false);
assert.equal(wallNull.failures[0].reason, 'wall-null');
const wallThrows = checkOptimizeGeometry(wallConfig, { wallPass: () => { throw new Error('secret'); } });
assert.equal(wallThrows.failures[0].reason, 'wall-exception');
assert.doesNotMatch(JSON.stringify(wallThrows), /secret/);
const successfulEmpty = checkOptimizeGeometry(wallConfig, {
wallPass: () => ({ geom: [], paperGeom: [], depthUnits: 0, openingIndex: null }),
floorPass: () => { throw new Error('must not run'); },
});
assert.equal(successfulEmpty.ok, true);
const floorNull = checkOptimizeGeometry(floorConfig, { floorPass: () => null });
assert.equal(floorNull.failures[0].reason, 'floor-null');
const floorThrows = checkOptimizeGeometry(floorConfig, {
floorPass: () => { throw new Error('private floor detail'); },
});
assert.equal(floorThrows.failures[0].reason, 'floor-exception');
assert.doesNotMatch(JSON.stringify(floorThrows), /private floor detail/);
const prepareThrows = checkOptimizeGeometry(wallConfig, {
prepareSpace: () => { throw new Error('private preparation detail'); },
});
assert.equal(prepareThrows.failures[0].reason, 'prepare-exception');
assert.doesNotMatch(JSON.stringify(prepareThrows), /private preparation detail/);
});
test('one failed space blocks the ordered whole-plan result and uses safe display fallbacks', () => {
const config = base([
{ id: 'good', title: ' Good floor ', view_box: [0, 0, 1, 1], rooms: [] },
{ id: 'bad-id', title: ' ', view_box: [0, 0, 1, 1], rooms: [room()] },
{ id: '', title: '', view_box: [0, 0, 1, 1], rooms: [room()] },
]);
const result = checkOptimizeGeometry(config, {
fallbackSpaceName: (index) => `Fallback ${index}`,
floorPass: (rooms) => rooms[0]?.id === 'room' ? null : [],
});
assert.equal(result.ok, false);
assert.deepEqual(result.spaces.map((space) => space.displayName), [
'Good floor', 'bad-id', 'Fallback 3',
]);
assert.deepEqual(result.failures.map(({ spaceId, displayName, reason }) => ({
spaceId, displayName, reason,
})), [
{ spaceId: 'bad-id', displayName: 'bad-id', reason: 'floor-null' },
{ spaceId: '', displayName: 'Fallback 3', reason: 'floor-null' },
]);
});
+1
View File
@@ -29,6 +29,7 @@
"src/serialized-write-queue.ts",
"src/align-grid.ts",
"src/plan-optimizer.ts",
"src/plan-geometry-preflight.ts",
"src/furniture.ts",
"src/floating-surface.ts", "src/floating-surface-controller.ts", "src/help-behavior.ts",
"src/hp-help.ts", "src/hp-dialog.ts", "src/hp-color-opacity.ts",