mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
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
@@ -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);
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
|
||||
@@ -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));
|
||||
+118
-116
File diff suppressed because one or more lines are too long
Vendored
+118
-116
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +8,14 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- «Оптимизировать планы» теперь проверяет точный кандидат из предпросмотра теми
|
||||
же построителями стен, проёмов, перегородок, колонн и пола, которыми пользуется
|
||||
карточка. Если геометрию хотя бы одного пространства нельзя безопасно
|
||||
построить, диалог называет до трёх проблемных пространств, объясняет, как
|
||||
сообщить об ошибке, и оставляет только «Отмена»: конфигурация, раскладка,
|
||||
ревизии и одношаговая отмена не меняются
|
||||
([#199](https://github.com/Matysh/houseplan-card/issues/199)).
|
||||
|
||||
- Каждая запись конфигурации и раскладки устройств теперь приводит геометрию к
|
||||
девяти десятичным знакам, не привязывая её к сетке. Невидимые
|
||||
floating-point-хвосты удаляются до того, как смогут сломать общие стены,
|
||||
|
||||
+1
-1
@@ -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) |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1366,6 +1366,15 @@ show_signal: true
|
||||
честный предпросмотр «сдвинуто элементов — 0» с ненулевым числом очищенных
|
||||
координат. Повторный Optimize над результатом ничего не предлагает.
|
||||
|
||||
Перед подтверждением House Plan дополнительно строит точную геометрию кандидата
|
||||
для **всех** пространств — тем же способом, которым затем рисуются стены,
|
||||
проёмы, перегородки, колонны и пол. Если проверка хотя бы одного пространства
|
||||
не прошла, оптимизация целиком блокируется: вместо отчёта диалог называет до
|
||||
трёх пространств и количество остальных, а кнопки «Оптимизировать» нет.
|
||||
Конфигурация, позиции, ревизии и доступная серверная отмена при этом не
|
||||
изменяются. Обновите House Plan и повторите; если сообщение осталось, приложите
|
||||
экспорт названного пространства к отчёту об ошибке.
|
||||
|
||||
### Что оптимизация сохраняет
|
||||
|
||||
- неизвестные поля новых версий;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>`}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "Элементы, намеренно поставленные между узлами, будут сдвинуты. После операции доступна одна отмена — только до следующего изменения плана.",
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);');
|
||||
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user