mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +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();
|
card.requestUpdate();
|
||||||
await card.updateComplete;
|
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');
|
card._setMode('devices');
|
||||||
await card.updateComplete;
|
await card.updateComplete;
|
||||||
await settleMode(card);
|
await settleMode(card);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
|
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
|
||||||
|
|
||||||
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
|
/** 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 stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
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',
|
{ id: 'backup-space-preview-mobile-ru', fixture: 'visual', space: 'golden-geometry',
|
||||||
dialog: 'backup-space', language: 'ru', theme: 'light',
|
dialog: 'backup-space', language: 'ru', theme: 'light',
|
||||||
viewport: { width: 390, height: 820 }, ...page },
|
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/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 |
|
| `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 —
|
Manual attachments upload over HTTP (streaming, transactional staging), not WS —
|
||||||
the old `houseplan/file/set` was removed in v1.10.0.
|
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;
|
`coordsCanonicalized: 0`, and returns objects deep-equal to the first run's;
|
||||||
* the report is an **upper bound**, not a sample (AUD-158B1-01).
|
* 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 report is a promise
|
||||||
|
|
||||||
The confirmation is the decision gate in front of a geometry rewrite, so
|
The confirmation is the decision gate in front of a geometry rewrite, so
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
- Every config and device-layout write now canonicalizes persisted geometry to
|
||||||
nine decimal places without snapping it to the grid. This removes invisible
|
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;
|
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-хвосты удаляются до того, как смогут сломать общие стены,
|
floating-point-хвосты удаляются до того, как смогут сломать общие стены,
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
|
|||||||
| Item | State |
|
| 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 |
|
| 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. |
|
| 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 |
|
| 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) |
|
| 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:
|
[unit: align-grid + plan-optimizer + i18n; auto:
|
||||||
smoke_optimize_coordinate_canonicalization; mutation:
|
smoke_optimize_coordinate_canonicalization; mutation:
|
||||||
`snapn-returns-input-near-node`].
|
`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
|
- [ ] **Every write prevents new ULP coordinate noise (#224)**: config/layout
|
||||||
schema, import, direct storage writers, startup recovery and maintenance
|
schema, import, direct storage writers, startup recovery and maintenance
|
||||||
Undo produce the same nine-decimal allow-listed geometry as the frontend.
|
Undo produce the same nine-decimal allow-listed geometry as the frontend.
|
||||||
|
|||||||
@@ -1366,6 +1366,15 @@ show_signal: true
|
|||||||
честный предпросмотр «сдвинуто элементов — 0» с ненулевым числом очищенных
|
честный предпросмотр «сдвинуто элементов — 0» с ненулевым числом очищенных
|
||||||
координат. Повторный Optimize над результатом ничего не предлагает.
|
координат. Повторный Optimize над результатом ничего не предлагает.
|
||||||
|
|
||||||
|
Перед подтверждением House Plan дополнительно строит точную геометрию кандидата
|
||||||
|
для **всех** пространств — тем же способом, которым затем рисуются стены,
|
||||||
|
проёмы, перегородки, колонны и пол. Если проверка хотя бы одного пространства
|
||||||
|
не прошла, оптимизация целиком блокируется: вместо отчёта диалог называет до
|
||||||
|
трёх пространств и количество остальных, а кнопки «Оптимизировать» нет.
|
||||||
|
Конфигурация, позиции, ревизии и доступная серверная отмена при этом не
|
||||||
|
изменяются. Обновите House Plan и повторите; если сообщение осталось, приложите
|
||||||
|
экспорт названного пространства к отчёту об ошибке.
|
||||||
|
|
||||||
### Что оптимизация сохраняет
|
### Что оптимизация сохраняет
|
||||||
|
|
||||||
- неизвестные поля новых версий;
|
- неизвестные поля новых версий;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"fixture": "synthetic-only",
|
"fixture": "synthetic-only",
|
||||||
"sourceFingerprint": "ef5431f42e2c08425388d4d14df10de29040ea755ac64f81b734430729faae4f",
|
"sourceFingerprint": "967bdb37e447ec29a88d82707f5ff957e576be72fbe801445c6cbdec970e43eb",
|
||||||
"captureScriptSha256": "34f2219790d46efd8250e7a1bd829cb8fc0b0547e1260635fefa52407551b41b",
|
"captureScriptSha256": "34f2219790d46efd8250e7a1bd829cb8fc0b0547e1260635fefa52407551b41b",
|
||||||
"command": "npm run build && node demo/docs/capture.mjs",
|
"command": "npm run build && node demo/docs/capture.mjs",
|
||||||
"scenarios": {
|
"scenarios": {
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
},
|
},
|
||||||
"theme": "dark",
|
"theme": "dark",
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"sourceSha256": "ef5431f42e2c08425388d4d14df10de29040ea755ac64f81b734430729faae4f",
|
"sourceSha256": "967bdb37e447ec29a88d82707f5ff957e576be72fbe801445c6cbdec970e43eb",
|
||||||
"imageSha256": "2885f96e348b15ab7c696e56e99bddcd9bb2ee94218a883e5a2155f45f5042aa"
|
"imageSha256": "2885f96e348b15ab7c696e56e99bddcd9bb2ee94218a883e5a2155f45f5042aa"
|
||||||
},
|
},
|
||||||
"view-touch": {
|
"view-touch": {
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
},
|
},
|
||||||
"theme": "dark",
|
"theme": "dark",
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"sourceSha256": "ef5431f42e2c08425388d4d14df10de29040ea755ac64f81b734430729faae4f",
|
"sourceSha256": "967bdb37e447ec29a88d82707f5ff957e576be72fbe801445c6cbdec970e43eb",
|
||||||
"imageSha256": "f62d8af3617c00a5e99511bd765980d2e27badf25047a1be34046b64195abe6c"
|
"imageSha256": "f62d8af3617c00a5e99511bd765980d2e27badf25047a1be34046b64195abe6c"
|
||||||
},
|
},
|
||||||
"space-create": {
|
"space-create": {
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
},
|
},
|
||||||
"theme": "dark",
|
"theme": "dark",
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"sourceSha256": "ef5431f42e2c08425388d4d14df10de29040ea755ac64f81b734430729faae4f",
|
"sourceSha256": "967bdb37e447ec29a88d82707f5ff957e576be72fbe801445c6cbdec970e43eb",
|
||||||
"imageSha256": "c33a7279165a4cec6fa6fadb6fd08cd967e082a17fe101ef442d27d36ae59b6b"
|
"imageSha256": "c33a7279165a4cec6fa6fadb6fd08cd967e082a17fe101ef442d27d36ae59b6b"
|
||||||
},
|
},
|
||||||
"room-contour-close": {
|
"room-contour-close": {
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
},
|
},
|
||||||
"theme": "dark",
|
"theme": "dark",
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"sourceSha256": "ef5431f42e2c08425388d4d14df10de29040ea755ac64f81b734430729faae4f",
|
"sourceSha256": "967bdb37e447ec29a88d82707f5ff957e576be72fbe801445c6cbdec970e43eb",
|
||||||
"imageSha256": "4d63670c7bcca33da21a6cf17275786bed12e8422a50d02d4dabe59645f2cd82"
|
"imageSha256": "4d63670c7bcca33da21a6cf17275786bed12e8422a50d02d4dabe59645f2cd82"
|
||||||
},
|
},
|
||||||
"plan-context-tray": {
|
"plan-context-tray": {
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
},
|
},
|
||||||
"theme": "dark",
|
"theme": "dark",
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"sourceSha256": "ef5431f42e2c08425388d4d14df10de29040ea755ac64f81b734430729faae4f",
|
"sourceSha256": "967bdb37e447ec29a88d82707f5ff957e576be72fbe801445c6cbdec970e43eb",
|
||||||
"imageSha256": "a6c526fede11bc3503fd2384bd4a3f6afa008f481c5c47578f06cae6c81c6f9b"
|
"imageSha256": "a6c526fede11bc3503fd2384bd4a3f6afa008f481c5c47578f06cae6c81c6f9b"
|
||||||
},
|
},
|
||||||
"device-editor": {
|
"device-editor": {
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
},
|
},
|
||||||
"theme": "dark",
|
"theme": "dark",
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"sourceSha256": "ef5431f42e2c08425388d4d14df10de29040ea755ac64f81b734430729faae4f",
|
"sourceSha256": "967bdb37e447ec29a88d82707f5ff957e576be72fbe801445c6cbdec970e43eb",
|
||||||
"imageSha256": "9585b59add4d35b5a6f028ce5b720ef1b77a3f8b19436d192cd1a0e6fc637264"
|
"imageSha256": "9585b59add4d35b5a6f028ce5b720ef1b77a3f8b19436d192cd1a0e6fc637264"
|
||||||
},
|
},
|
||||||
"device-display-preview": {
|
"device-display-preview": {
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
},
|
},
|
||||||
"theme": "dark",
|
"theme": "dark",
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"sourceSha256": "ef5431f42e2c08425388d4d14df10de29040ea755ac64f81b734430729faae4f",
|
"sourceSha256": "967bdb37e447ec29a88d82707f5ff957e576be72fbe801445c6cbdec970e43eb",
|
||||||
"imageSha256": "cfc317da4628d079a116ff71311fbf06b1eb3b181928a5ea7ba888ab936a5e8b"
|
"imageSha256": "cfc317da4628d079a116ff71311fbf06b1eb3b181928a5ea7ba888ab936a5e8b"
|
||||||
},
|
},
|
||||||
"background-editor": {
|
"background-editor": {
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
},
|
},
|
||||||
"theme": "dark",
|
"theme": "dark",
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"sourceSha256": "ef5431f42e2c08425388d4d14df10de29040ea755ac64f81b734430729faae4f",
|
"sourceSha256": "967bdb37e447ec29a88d82707f5ff957e576be72fbe801445c6cbdec970e43eb",
|
||||||
"imageSha256": "d7cfe70551d9260169df8efd832e32ed7df99c7f60b55d1fd4a8322bd4c47175"
|
"imageSha256": "d7cfe70551d9260169df8efd832e32ed7df99c7f60b55d1fd4a8322bd4c47175"
|
||||||
},
|
},
|
||||||
"room-card": {
|
"room-card": {
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
},
|
},
|
||||||
"theme": "dark",
|
"theme": "dark",
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"sourceSha256": "ef5431f42e2c08425388d4d14df10de29040ea755ac64f81b734430729faae4f",
|
"sourceSha256": "967bdb37e447ec29a88d82707f5ff957e576be72fbe801445c6cbdec970e43eb",
|
||||||
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
|
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
|
||||||
},
|
},
|
||||||
"device-info": {
|
"device-info": {
|
||||||
@@ -112,7 +112,7 @@
|
|||||||
},
|
},
|
||||||
"theme": "dark",
|
"theme": "dark",
|
||||||
"language": "en",
|
"language": "en",
|
||||||
"sourceSha256": "ef5431f42e2c08425388d4d14df10de29040ea755ac64f81b734430729faae4f",
|
"sourceSha256": "967bdb37e447ec29a88d82707f5ff957e576be72fbe801445c6cbdec970e43eb",
|
||||||
"imageSha256": "a06cbf83f09e2f67b3566d7c0b10e973db060c3d20786f26f74ada6a21937c3e"
|
"imageSha256": "a06cbf83f09e2f67b3566d7c0b10e973db060c3d20786f26f74ada6a21937c3e"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"benchmark:large-house-plan-snap": "node demo/benchmark_large_house.mjs --profile=large-house-plan-snap-v1",
|
"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:glow": "node demo/benchmark_glow.mjs",
|
||||||
"benchmark:compare": "node demo/performance/compare.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:capture": "node demo/golden/run.mjs --mode=capture",
|
||||||
"golden:verify": "node demo/golden/run.mjs --mode=verify",
|
"golden:verify": "node demo/golden/run.mjs --mode=verify",
|
||||||
"continuity:screencast": "node demo/screencast_visual_continuity.mjs",
|
"continuity:screencast": "node demo/screencast_visual_continuity.mjs",
|
||||||
|
|||||||
@@ -229,6 +229,54 @@ export const MUTANTS = [
|
|||||||
replace: ' config = _json_copy(payload.get("config"))',
|
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',
|
id: 'union-failure-kills-space',
|
||||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||||
|
|||||||
+57
-54
@@ -149,7 +149,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
hostedOpeningIntervalsOverlap, materializePartitionOpening,
|
hostedOpeningIntervalsOverlap, materializePartitionOpening,
|
||||||
partitionOpeningJambMargin, partitionOpeningNeedsStrictValidation,
|
partitionOpeningJambMargin, partitionOpeningNeedsStrictValidation,
|
||||||
partitionOpeningCut, partitionOpeningFace, partitionOpeningHasCompositeRoomWall,
|
partitionOpeningFace,
|
||||||
partitionPlacementIntervals, resolvePartitionOpeningCompat, resolvePartitionOpeningStrict,
|
partitionPlacementIntervals, resolvePartitionOpeningCompat, resolvePartitionOpeningStrict,
|
||||||
type PartitionOpeningOrphanReason, type ResolvedPartitionOpening,
|
type PartitionOpeningOrphanReason, type ResolvedPartitionOpening,
|
||||||
} from './partition-openings';
|
} from './partition-openings';
|
||||||
@@ -187,6 +187,16 @@ import {
|
|||||||
clampCanvasR, clampCanvasN, type ContentItem, type Rect,
|
clampCanvasR, clampCanvasN, type ContentItem, type Rect,
|
||||||
} from './space-geometry';
|
} from './space-geometry';
|
||||||
import { optimizePlans, type OptimizeReport } from './plan-optimizer';
|
import { optimizePlans, type OptimizeReport } from './plan-optimizer';
|
||||||
|
import {
|
||||||
|
checkOptimizeGeometry,
|
||||||
|
geometryOpenCuts,
|
||||||
|
geometryOpenings,
|
||||||
|
geometryOpenPairs,
|
||||||
|
geometryPartitionOpeningCuts,
|
||||||
|
geometryRoomOpeningInputs,
|
||||||
|
type GeometryOpeningProjection,
|
||||||
|
type OptimizeGeometryPreflightResult,
|
||||||
|
} from './plan-geometry-preflight';
|
||||||
import {
|
import {
|
||||||
canonicalizeConfigGeometry,
|
canonicalizeConfigGeometry,
|
||||||
canonicalizeLayoutGeometry,
|
canonicalizeLayoutGeometry,
|
||||||
@@ -1707,6 +1717,7 @@ class HouseplanCard extends LitElement {
|
|||||||
/** Optimization preview plus the exact pair, so commit cannot differ from it. */
|
/** Optimization preview plus the exact pair, so commit cannot differ from it. */
|
||||||
private _alignDialog: {
|
private _alignDialog: {
|
||||||
report: OptimizeReport; config: any; layout: Record<string, any>;
|
report: OptimizeReport; config: any; layout: Record<string, any>;
|
||||||
|
preflight: OptimizeGeometryPreflightResult | null;
|
||||||
/** the promised maximum, in centimetres, ALREADY rounded up (AUD-158B1-01) */
|
/** the promised maximum, in centimetres, ALREADY rounded up (AUD-158B1-01) */
|
||||||
cm: number;
|
cm: number;
|
||||||
/** the space that maximum belongs to, named only when there are several */
|
/** 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,
|
accept: (opening: OpeningCfg) => boolean = () => true,
|
||||||
): PartitionOpeningCut[] {
|
): PartitionOpeningCut[] {
|
||||||
if (!space) return [];
|
if (!space) return [];
|
||||||
const raw = this._curSpaceCfg?.id === space.id ? this._curSpaceCfg?.openings || [] : [];
|
const config = this._curSpaceCfg?.id === space.id ? this._curSpaceCfg : null;
|
||||||
const cuts: PartitionOpeningCut[] = [];
|
const openings = geometryOpenings(
|
||||||
for (const opening of raw) {
|
config, space, this._cellCm, this._gridPitch, NORM_W,
|
||||||
if (!opening.host || !accept(opening)) continue;
|
);
|
||||||
const resolution = resolvePartitionOpeningCompat(
|
return geometryPartitionOpeningCuts(openings, accept);
|
||||||
opening, space.partitions, NORM_W, this._cellCm, this._gridPitch,
|
|
||||||
);
|
|
||||||
if (resolution.resolved) cuts.push(partitionOpeningCut(resolution.resolved));
|
|
||||||
}
|
|
||||||
return cuts;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8610,22 +8616,11 @@ class HouseplanCard extends LitElement {
|
|||||||
): Array<{ x: number; y: number; angle: number; length: number }> {
|
): Array<{ x: number; y: number; angle: number; length: number }> {
|
||||||
if (!space) return [];
|
if (!space) return [];
|
||||||
const openCuts = this._openPairs().flatMap((pair) => pair.segs);
|
const openCuts = this._openPairs().flatMap((pair) => pair.segs);
|
||||||
const intervals = wallIntervals(
|
return geometryRoomOpeningInputs(
|
||||||
space.rooms, this._spaceWalls, openCuts,
|
openings as readonly GeometryOpeningProjection[],
|
||||||
|
space, this._spaceWalls, openCuts,
|
||||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
|
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(
|
private _openingFace(
|
||||||
@@ -10861,35 +10856,13 @@ class HouseplanCard extends LitElement {
|
|||||||
const sp = this._curSpaceCfg;
|
const sp = this._curSpaceCfg;
|
||||||
const space = this._spaceModel();
|
const space = this._spaceModel();
|
||||||
if (!space) return [];
|
if (!space) return [];
|
||||||
return resolveOpenCuts(
|
return geometryOpenCuts(sp, space, this._gridPitch, NORM_W);
|
||||||
space.rooms,
|
|
||||||
(sp as any)?.open_spans as OpenSpanEntry[] | undefined,
|
|
||||||
NORM_W,
|
|
||||||
this._gridPitch * 0.02,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open cuts grouped by room pair (for per-room outline trimming). */
|
/** Open cuts grouped by room pair (for per-room outline trimming). */
|
||||||
private _openPairs(): { a: RoomCfg; b: RoomCfg; segs: number[][] }[] {
|
private _openPairs(): { a: RoomCfg; b: RoomCfg; segs: number[][] }[] {
|
||||||
const cuts = this._openCuts();
|
const cuts = this._openCuts();
|
||||||
if (!cuts.length) return [];
|
return geometryOpenPairs(this._spaceModel()?.rooms || [], cuts, this._gridPitch);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14900,10 +14873,19 @@ class HouseplanCard extends LitElement {
|
|||||||
* Preview whole-plan maintenance. Nothing is written here: the pure run
|
* Preview whole-plan maintenance. Nothing is written here: the pure run
|
||||||
* produces both the report and the exact config/layout pair to commit.
|
* 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 => {
|
private _openAlignDialog = (): void => {
|
||||||
if (!this._norm || !this._serverCfg) return;
|
if (!this._norm || !this._serverCfg) return;
|
||||||
const spaces = this._serverCfg.spaces || [];
|
const spaces = this._serverCfg.spaces || [];
|
||||||
const r = optimizePlans(this._serverCfg, this._layout || {});
|
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
|
// The maximum geometry shift is an UPPER BOUND, not a sample. The run
|
||||||
// measured every element in the centimetres of ITS OWN space — converting
|
// measured every element in the centimetres of ITS OWN space — converting
|
||||||
// one normalised maximum through the first space's `cell_cm` understated
|
// 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) : '';
|
const where = spaces.length > 1 && sp ? String(sp.title || sp.id) : '';
|
||||||
this._alignDialog = {
|
this._alignDialog = {
|
||||||
report: r.report, config: r.config, layout: r.layout, cm, where,
|
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.
|
* one-deep snapshot that remains undoable until the next plan edit.
|
||||||
*/
|
*/
|
||||||
private async _runAlignToGrid(): Promise<void> {
|
private async _runAlignToGrid(): Promise<void> {
|
||||||
const d = this._alignDialog;
|
let d = this._alignDialog;
|
||||||
if (!d || d.busy || !this._serverCfg) return;
|
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._clearGeometryGesture();
|
||||||
this._alignDialog = { ...d, busy: true };
|
this._alignDialog = { ...d, busy: true };
|
||||||
try {
|
try {
|
||||||
@@ -15937,10 +15926,24 @@ class HouseplanCard extends LitElement {
|
|||||||
private _renderAlignDialog(): TemplateResult {
|
private _renderAlignDialog(): TemplateResult {
|
||||||
const d = this._alignDialog!;
|
const d = this._alignDialog!;
|
||||||
const r = d.report;
|
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"
|
return html`<hp-dialog .hass=${this.hass} .title=${this._t('gs.align_title')} icon="mdi:broom"
|
||||||
dismiss-on-scrim @hp-close=${() => (this._alignDialog = null)}>
|
dismiss-on-scrim @hp-close=${() => (this._alignDialog = null)}>
|
||||||
<div class="body">
|
<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`<p class="alignmsg">${this._t('gs.align_none')}</p>`
|
||||||
: html`
|
: html`
|
||||||
${r.moved ? html`<p class="alignmsg">${this._t('gs.align_count', {
|
${r.moved ? html`<p class="alignmsg">${this._t('gs.align_count', {
|
||||||
@@ -15973,7 +15976,7 @@ class HouseplanCard extends LitElement {
|
|||||||
<div class="row" slot="footer">
|
<div class="row" slot="footer">
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<button class="btn ghost" @click=${() => (this._alignDialog = null)}>${this._t('btn.cancel')}</button>
|
<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}>
|
<button class="btn on" @click=${this._runAlignToGrid} ?disabled=${d.busy}>
|
||||||
<ha-icon icon="mdi:check"></ha-icon>${d.busy ? '…' : this._t('gs.align_run')}
|
<ha-icon icon="mdi:check"></ha-icon>${d.busy ? '…' : this._t('gs.align_run')}
|
||||||
</button>`}
|
</button>`}
|
||||||
|
|||||||
@@ -794,6 +794,10 @@
|
|||||||
"gs.align_where": "The largest shift is in “{s}”.",
|
"gs.align_where": "The largest shift is in “{s}”.",
|
||||||
"gs.align_turned": "Openings whose angle is corrected: {n}.",
|
"gs.align_turned": "Openings whose angle is corrected: {n}.",
|
||||||
"gs.align_removed_drafts": "Invalid outlines collapsed by the grid and removed: {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_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.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.",
|
"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_where": "Наибольший сдвиг — в пространстве «{s}».",
|
||||||
"gs.align_turned": "Проёмов с исправлением угла: {n}.",
|
"gs.align_turned": "Проёмов с исправлением угла: {n}.",
|
||||||
"gs.align_removed_drafts": "Схлопнувшиеся на сетке некорректные контуры удалены: {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_changes": "Миграций модели: {m}; обновлено пространств: {c}; устранён шум координат: {p}; объединено отрезков реальных стен: {w}; виртуальных: {s}; независимых: {i}.",
|
||||||
"gs.optimize_glow_migration": "Старый Glow: пространств — {spaces} → без заливки данных + независимый Glow; комнат — {rooms} → наследуемая заливка + независимый Glow.",
|
"gs.optimize_glow_migration": "Старый Glow: пространств — {spaces} → без заливки данных + независимый Glow; комнат — {rooms} → наследуемая заливка + независимый Glow.",
|
||||||
"gs.align_warn": "Элементы, намеренно поставленные между узлами, будут сдвинуты. После операции доступна одна отмена — только до следующего изменения плана.",
|
"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);
|
assert.ok(scenario);
|
||||||
const fixture = prepareGoldenFixture(scenario);
|
const fixture = prepareGoldenFixture(scenario);
|
||||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
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(space.settings.sun_rays, true);
|
||||||
assert.equal(scenario.northDeg, 90,
|
assert.equal(scenario.northDeg, 90,
|
||||||
'the sign-sensitive golden must keep a non-zero north direction');
|
'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');
|
'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', () => {
|
test('golden harness neutralizes the shared pointer before every scenario', () => {
|
||||||
const harness = readFileSync(new URL('../demo/golden/harness.mjs', import.meta.url), 'utf8');
|
const harness = readFileSync(new URL('../demo/golden/harness.mjs', import.meta.url), 'utf8');
|
||||||
const reset = harness.indexOf('await page.mouse.move(0, 0);');
|
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/serialized-write-queue.ts",
|
||||||
"src/align-grid.ts",
|
"src/align-grid.ts",
|
||||||
"src/plan-optimizer.ts",
|
"src/plan-optimizer.ts",
|
||||||
|
"src/plan-geometry-preflight.ts",
|
||||||
"src/furniture.ts",
|
"src/furniture.ts",
|
||||||
"src/floating-surface.ts", "src/floating-surface-controller.ts", "src/help-behavior.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",
|
"src/hp-help.ts", "src/hp-dialog.ts", "src/hp-color-opacity.ts",
|
||||||
|
|||||||
Reference in New Issue
Block a user