Compare commits

...
Author SHA1 Message Date
claude[bot] cc17109249 docs: review document for #123
Issue: #123
User-Visible: no
2026-08-13 18:55:48 +00:00
Sergey Matyunin e79f8f5aa1 Fix corner split smoke geometry input
Issue: #123
User-Visible: no
2026-08-13 21:48:28 +03:00
claude[bot] 024a1accd8 docs: code review document for #123
Issue: #123
User-Visible: no
2026-08-13 18:45:40 +00:00
Sergey Matyunin 47c6f10a9d Fix corner split exterior walls
Issue: #123
User-Visible: yes
2026-08-13 21:29:34 +03:00
Sergey Matyunin 52ec0fb54f Merge dev into issue/123 branch
Issue: #123
User-Visible: no
2026-08-13 20:57:00 +03:00
claude[bot] bcd280afb9 docs: review document for #123
Issue: #123
User-Visible: no
2026-08-13 17:55:48 +00:00
Sergey Matyunin ba56d4f768 Specify corner split wall geometry
Issue: #123
User-Visible: no
2026-08-13 20:18:35 +03:00
22 changed files with 2228 additions and 644 deletions
File diff suppressed because one or more lines are too long
+29 -1
View File
@@ -1,5 +1,5 @@
import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
import { makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
const fixtureFor = (name) => name === 'large' ? makeLargeHouseFixture() : makeVisualMatrixFixture();
@@ -51,6 +51,34 @@ async function stableEnvironment(page, scenario) {
/** Apply every data-only scenario override before the fixture crosses into the browser. */
export function prepareGoldenFixture(scenario) {
const fixture = fixtureFor(scenario.fixture);
if (scenario.cornerSplitWall) {
const stage = scenario.cornerSplitWall;
if (!['before', 'thin', 'thick'].includes(stage))
throw new Error(`unknown cornerSplitWall stage: ${stage}`);
const a = [0.10, 0.10], tr = [0.90, 0.10], split = [0.90, 0.50];
const br = [0.90, 0.90], bl = [0.10, 0.90];
const entry = (from, to, cm) => ({
key: fixtureWallKey(from, to), a: [...from], b: [...to], cm,
});
const before = stage === 'before';
fixture.config.spaces.push({
id: scenario.space,
name: 'Corner Split',
rooms: before
? [{ id: 'corner-source', name: 'Before Split', area: null, poly: [a, tr, br, bl] }]
: [
{ id: 'corner-source', name: 'Main room', area: null, poly: [a, tr, split] },
{ id: 'corner-fresh', name: 'New room', area: null, poly: [split, br, bl, a] },
],
walls: before
? [entry(a, tr, 15), entry(tr, br, 15), entry(br, bl, 15), entry(bl, a, 15)]
: [
entry(a, tr, 15), entry(tr, split, 15), entry(split, br, 15),
entry(br, bl, 15), entry(bl, a, 15), entry(a, split, stage === 'thin' ? 15 : 100),
],
settings: { show_borders: true, fill_mode: 'custom', custom_fill: { c: '#536b82', a: 0.42 } },
});
}
const requireSpace = () => {
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
if (!space) throw new Error(`golden override references missing space: ${scenario.space}`);
+7 -1
View File
@@ -1,13 +1,19 @@
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 = 17;
export const GOLDEN_MATRIX_VERSION = 18;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
const sunWindow = { capture: 'sun-window', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.001 } };
export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'split-corner-wall-before-dark', fixture: 'visual', space: 'golden-corner-split',
cornerSplitWall: 'before', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'split-corner-wall-thin-dark', fixture: 'visual', space: 'golden-corner-split',
cornerSplitWall: 'thin', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'split-corner-wall-thick-dark', fixture: 'visual', space: 'golden-corner-split',
cornerSplitWall: 'thick', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-geometry-view-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
labs: ['iso'], projection: 'iso', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-geometry-view-light', fixture: 'visual', space: 'golden-geometry', mode: 'view',
+146
View File
@@ -0,0 +1,146 @@
/** Issue #123: a Split ending at a room vertex must not reshape the facade. */
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch();
const res = await page.evaluate(async () => {
const out = {};
const c = window.__card;
const sr = () => c.shadowRoot || c.renderRoot;
const sp = () => c._serverCfg.spaces.find((space) => space.id === c._space);
const pitch = 1 / 240;
const wallKey = (a, b) => {
const q = (value) => Math.round(value / pitch) * pitch;
let dx = b[0] - a[0], dy = b[1] - a[1];
const length = Math.hypot(dx, dy) || 1;
dx /= length; dy /= length;
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
let angle = Math.atan2(dy, dx);
if (angle < 0) angle += Math.PI;
angle = Math.round(angle * 1800) / 1800;
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${angle.toFixed(4)}`;
};
const entry = (a, b, cm) => ({ key: wallKey(a, b), a: [...a], b: [...b], cm });
const update = async () => {
c._cfgEpoch++;
c._wallUnionCache = null;
c._lightBarrierCache = null;
c.requestUpdate();
await c.updateComplete;
};
const bbox = (element) => {
const box = element?.getBBox?.();
return box ? [box.x, box.y, box.width, box.height] : null;
};
const sameBox = (a, b, epsilon = 0.01) => !!a && !!b
&& a.every((value, index) => Math.abs(value - b[index]) <= epsilon);
const a = [0.10, 0.10], tr = [0.90, 0.10], b = [0.90, 0.50];
const br = [0.90, 0.90], bl = [0.10, 0.90];
const original = { id: 'corner-source', name: 'Corner source', area: null, poly: [a, tr, br, bl] };
const outerBefore = [
entry(a, tr, 15), entry(tr, br, 15), entry(br, bl, 15), entry(bl, a, 15),
];
const outerAfter = [
entry(a, tr, 15), entry(tr, b, 15), entry(b, br, 15),
entry(br, bl, 15), entry(bl, a, 15),
];
const splitRooms = [
{ id: 'corner-source', name: 'Corner source', area: null, poly: [a, tr, b] },
{ id: 'corner-fresh', name: 'Corner fresh', area: null, poly: [b, br, bl, a] },
];
const space = sp();
space.settings = { ...(space.settings || {}), show_borders: true };
space.rooms = [structuredClone(original)];
space.walls = structuredClone(outerBefore);
delete space.open_spans;
delete space.openings;
delete space.partitions;
delete space.room_drafts;
delete space.wall_columns;
c._setMode('plan');
await update();
const beforeWall = bbox(sr().querySelector('.wallbody'));
const beforePaper = bbox(sr().querySelector('.hp-paperg'));
out.beforeDrawn = !!beforeWall && !!beforePaper;
space.rooms = structuredClone(splitRooms);
const pathByThickness = {};
for (const cm of [0, 15, 100]) {
space.walls = [
...structuredClone(outerAfter),
...(cm ? [entry(a, b, cm)] : []),
];
await update();
const wall = sr().querySelector('.wallbody');
const paper = sr().querySelector('.hp-paperg');
out[`wall${cm}KeepsFacade`] = sameBox(bbox(wall), beforeWall);
out[`paper${cm}KeepsFacade`] = sameBox(bbox(paper), beforePaper);
pathByThickness[cm] = wall?.getAttribute('d') || '';
}
out.dividerChangesInterior = pathByThickness[15] !== pathByThickness[100]
&& pathByThickness[0] !== pathByThickness[15];
const persisted = JSON.stringify({ rooms: space.rooms, walls: space.walls });
const planD = sr().querySelector('.wallbody')?.getAttribute('d') || '';
const lightSpace = c._spaceModel();
const lightPolys = lightSpace.rooms
.filter((room) => Array.isArray(room.poly))
.map((room) => ({ r: room, poly: room.poly }));
const lightPhysical = c._physicalBodiesR(lightSpace);
const lightGeom = c._lightBarriers(lightSpace, lightPolys, lightPhysical).masonryGeometry;
const lightPoints = lightGeom.flat(2);
const lightBox = lightPoints.length ? [
Math.min(...lightPoints.map((point) => point[0])),
Math.min(...lightPoints.map((point) => point[1])),
Math.max(...lightPoints.map((point) => point[0])) - Math.min(...lightPoints.map((point) => point[0])),
Math.max(...lightPoints.map((point) => point[1])) - Math.min(...lightPoints.map((point) => point[1])),
] : null;
out.lightUsesFacade = sameBox(lightBox, beforeWall);
c._setMode('view');
await update();
const viewD = sr().querySelector('.wallbody')?.getAttribute('d') || '';
out.planViewParity = !!planD && viewD === planD;
const kioskBefore = c._config.kiosk;
c._config.kiosk = true;
await update();
out.kioskParity = sr().querySelector('.wallbody')?.getAttribute('d') === planD;
c._config.kiosk = kioskBefore;
history.replaceState(null, '', `?hp-labs=iso#space=${encodeURIComponent(c._space)}`);
dispatchEvent(new HashChangeEvent('hashchange'));
await c.updateComplete;
c._setProjection('iso');
await update();
out.isoUsesCanonicalBody = !!sr().querySelector('.iso-walls .iso-wall-top')
&& c._isoSource().build().flat(2).length > 0;
c._setProjection('flat');
await update();
await customElements.whenDefined('houseplan-space-card');
const cfg = structuredClone(c._serverCfg);
const baseCall = c.hass.callWS.bind(c.hass);
const staticCard = document.createElement('houseplan-space-card');
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: c._space, show_button: false });
staticCard.hass = { ...c.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
if (message.type === 'houseplan/layout/get') return { layout: c._layout || {}, rev: 1 };
return baseCall(message);
} };
document.body.appendChild(staticCard);
const started = Date.now();
while (!staticCard.renderRoot?.querySelector('.wallbody') && Date.now() - started < 6000)
await new Promise((resolve) => setTimeout(resolve, 60));
await staticCard.updateComplete;
out.staticParity = staticCard.renderRoot?.querySelector('.wallbody')?.getAttribute('d') === planD;
staticCard.remove();
out.renderDoesNotRewriteConfig = JSON.stringify({ rooms: space.rooms, walls: space.walls }) === persisted;
return out;
});
checkAll(res);
await finish(browser, res);
File diff suppressed because one or more lines are too long
+186 -186
View File
File diff suppressed because one or more lines are too long
+14
View File
@@ -326,6 +326,20 @@ its types declare named exports — breaking either tsc or the runtime): merge a
when the union collapses into one hole-free outline; split cuts wall-to-wall with a chord, the
bigger part keeps the room identity (name/area/devices).
`wallBodiesGeometry()` is the canonical physical masonry for flat full/static
rendering, hidden isometric projection and Glow/sun occlusion. Its exterior
shell is derived from the union of room centrelines plus the surviving `outer`
atomic intervals; internal/shared interval bodies are clipped to that union
before the shell is restored. Consequently a Split edge ending at an exterior
vertex cannot contribute a child-room mitre to the facade. Per-room rings remain
an interior join/nested-room representation, and atomic quads provide a safe
physical interval when an acute child ring cannot be subtracted. Paper and
masonry paths are emitted by that same geometry pass. The full card retains the
pair in `_wallUnionCache`; static cards retain it in a weak server-snapshot
cache guarded by a structural geometry fingerprint. This is computed render
state only: it never rewrites rooms or wall entries, and an HA state tick does
not rebuild topology.
## Markup editor (v1.4.0+)
State inside the card: `_markup` (mode), `_tool` (draw/partition/column/merge/split/resize/opening/
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- Splitting a room from an existing corner no longer deforms the exterior wall
or pulls a thick internal divider through the facade. Plan, View, kiosk,
static cards, hidden isometric rendering and light obstacles now use the same
preserved exterior geometry, including already saved plans
([#123](https://github.com/Matysh/houseplan-card/issues/123)).
## v1.63.0-beta.1 — 2026-08-13
- Deleting a standalone sensor or lock marker no longer breaks its explicit
+6
View File
@@ -8,6 +8,12 @@
## Unreleased
- Split из существующего угла комнаты больше не деформирует наружную стену и
не вытягивает толстый внутренний разделитель сквозь фасад. Редактор плана,
View, киоск, статичная карточка, скрытая изометрия и световые препятствия
используют одну сохранённую наружную геометрию, в том числе для уже
сохранённых планов ([#123](https://github.com/Matysh/houseplan-card/issues/123)).
## v1.63.0-beta.1 — 2026-08-13
- Удаление самостоятельного маркера датчика или замка больше не разрывает его
+1 -1
View File
@@ -22,7 +22,7 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
| Item | State |
|---|---|
| Version | **v1.63.0-beta.1** everywhere (manifest, const.py, package.json, CARD_VERSION) — prerelease candidate |
| Current local cycle | v1.63.0-beta.1 fixes the empty-plan crash (#111) and preserves explicit opening sensor/lock references after standalone marker deletion (#104). It also carries the reviewed process automation work #105 and #118–#121. |
| Current local cycle | v1.63.0-beta.1 fixes the empty-plan crash (#111) and preserves explicit opening sensor/lock references after standalone marker deletion (#104). Development after that beta preserves the exterior facade when Split starts or ends at a room corner (#123), using one wall geometry for flat/static/isometric rendering and light. The line also carries the reviewed process automation work #105 and #118–#121. |
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1 as a hidden, expiring `iso` Labs experiment: a fixed near-top orthographic volumetric View. Flat remains default; editors and `houseplan-space-card` remain flat; all existing floor live effects and HA actions are preserved. This is internal, not a public feature. |
| Workflow | Owner's rule since 2026-08-07: ordinary fixes/features are made **locally, without tests and without commits**. 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 and the linked [Project v2](https://github.com/users/Matysh/projects/1) is the canonical priority/status view; both must stay current. `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) |
+3
View File
@@ -304,6 +304,9 @@ desktop: для точного рисования, Resize, модификато
- Промежуточные точки должны идти внутри комнаты и не пересекать контур или сам разрез.
- Большая по площади часть сохраняет исходную комнату и устройства.
- Для меньшей части открывается диалог новой комнаты.
- Разрез можно начать или закончить точно в существующем углу. Наружная форма
дома при этом не меняется: общая стена двух новых комнат примыкает к фасаду
только изнутри, даже если она толще наружной стены.
### Resize
+16 -2
View File
@@ -42,6 +42,17 @@ Every thick wall grows **half outward and half inward** from the polygon edge
(outer and shared alike). Silhouette is wider than the polygon by `cm/2` on
outer walls. Paper and the content frame grow under that outer half.
The exterior silhouette is generated from the boolean union of room
centrelines and its surviving `outer` atomic intervals. A shared Split edge
therefore disappears before exterior mitres are built. When Split ends at an
existing corner, its divider is clipped to the interior side of this envelope:
the real exterior mitre/bevel and unequal arm depths stay unchanged, while any
divider thickness remains inside the facade. The same computed geometry is
used for the full/static/hidden-isometric renderers and light occlusion. The
paper and masonry paths come from one cached structural pass in flat renderers;
live HA state ticks do not repeat the boolean topology. Saved room and wall data
is not migrated or rewritten.
## 3. Body render
Production body is the **ring** `outset(poly, half) − inset(poly, half)` per
@@ -132,14 +143,17 @@ virtual-T mitre; angle-aware opening; 45° wall; T-junction; detached parallel
room; nested-room tie; partially out-of-span legacy opening; overlapping
opening de-duplication; shared symbol/cut/tunnel rejection; thick-door tunnel
clipping and room-side colour ownership; whole and
atomic rekey after edge/scale.
atomic rekey after edge/scale; corner Split exterior equality across
0/1/15/100 cm, unequal arms, both windings and convex/concave endpoints.
Browser: seamless frame; fill not in hatch; m² drops with thickness; a partial
virtual stretch, its solid thick remainders and Undo move as one real resize;
the virtual rubber band paints above the real body; sun starts at the room-side
opening corners; nav mode restores after `can_write`; a 1 cm body uses
solid-only in both full and static cards while a 20 cm body keeps its hatch;
door/window/gate tunnels repeat outer/shared room fills without an axis seam
(`demo/smoke_opening_tunnel_fill.mjs`).
(`demo/smoke_opening_tunnel_fill.mjs`); corner Split keeps the same facade in
Plan/View/kiosk/static/isometric surfaces and the light barrier
(`demo/smoke_split_corner_wall.mjs`).
## 9. Independent partitions, drafts and columns
+250
View File
@@ -0,0 +1,250 @@
# CODE-REVIEW-123-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/123
- **ТЗ:** `docs/specs/123-corner-split-wall.md` (зелёное ревью
`docs/reviews/SPEC-REVIEW-123-r1.md`)
- **Диапазон:** `git log --oneline origin/dev..HEAD` — 4 коммита, релевантный для
продукта `47c6f10 Fix corner split exterior walls` (`Issue: #123`,
`User-Visible: yes`); `git diff origin/dev...HEAD` — 20 файлов
(`src/wall-thickness.ts`, `src/houseplan-card.ts`, `src/space-render.ts`,
`test/wall-thickness.test.mjs`, `test/golden-matrix.test.mjs`,
`demo/golden/matrix.mjs`, `demo/golden/harness.mjs`,
`demo/smoke_split_corner_wall.mjs`, три bundle snapshot, документация)
- **Роль:** ревьюер кода (не исполнитель), этап `S7-code-review`
- **Цикл:** r1/4
## Скоуп ревью
По каждому AC1–AC13 (`docs/specs/123-corner-split-wall.md` §10) — доказан ли он
автотестом, который умеет падать, либо чтением кода. Дополнительно: гейты §8
PROCESS.md, трейлеры и changelog §10.1/§2.6, соответствие `docs/SCOPE.md` (J4/J6,
регрессия внутри уже принятой функциональности — расширения скоупа нет),
терминология `docs/USER-GUIDE.ru.md`, каноника `docs/WALL-THICKNESS.md` /
`docs/ARCHITECTURE.md`.
## Как проверялось
Окружение подготовлено `npm ci` (зависимости отсутствовали). Для нескольких
пунктов ниже дополнительно установлен `npx playwright install chromium` —
браузерные бинарники тоже отсутствовали, а без них нельзя было исполнить
браузерный смок и самостоятельно проверить AC7/AC8/AC9.
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
| Unit | `npm test` | `752/752` (см. `npm run inventory`), 0 fail |
| Build | `npm run build` | зелёный, `dist/houseplan-card.js` собран |
| Bundle sync | `cmp dist/… custom_components/houseplan/frontend/…` и `cmp dist/… demo/srv/assets/…` | оба совпадают побайтно; sha256 всех трёх = `182fb55a…483ff`, совпадает со значением из хендоффа |
| Process gate | `node scripts/process-gate.mjs` и `node scripts/process-gate.mjs --issues` | `гейт пройден, предупреждений 0` в обоих запусках (второй проверил и метку `S7-code-review` на #123 через `gh`) |
| Regression-can-fail | новый `test/wall-thickness.test.mjs` (тесты `corner Split …`, см. ниже) скопирован в чистый `git worktree` на `origin/dev` (SHA `8a3f6ef`, т.е. без фикса #123) и прогнан отдельно | `57/63 pass, 6 fail` — новые тесты `keeps the original exterior wall body and paper`, `preserves the facade for thin and thick outer/divider matrices`, `keeps unequal exterior arms…`, `Split with both endpoints at exterior vertices…`, а также существующий `paper with walls covers shared centreline…` реально падают на добаговом коде. Тест краснеет не тавтологически (см. AC2 в §10 ТЗ) |
| Browser smoke (новый) | `node demo/smoke_split_corner_wall.mjs` (после сборки и синхронизации `demo/srv/assets/houseplan-card.js`) | **crash**, см. находку High-1 |
| Browser smoke (независимая проверка того же сценария с исправленным вызовом) | автономный скрипт вне репозитория, тот же фикстур/сценарий из `demo/smoke_split_corner_wall.mjs`, но с исправленным вызовом `_lightBarriers(model, polys, physical)` вместо `_lightBarriers(model)` | все 14 проверок `true`, включая `wall0/15/100KeepsFacade`, `paper0/15/100KeepsFacade`, `lightUsesFacade`, `planViewParity`, `kioskParity`, `isoUsesCanonicalBody`, `staticParity`, `renderDoesNotRewriteConfig` — см. находку High-1 для интерпретации |
| Golden/performance/backend | не запускались | по `PROCESS.md` §8/§11.4 это пре-релизные гейты, не гейт код-ревью; `custom_components/houseplan/**/*.py` в диапазоне не менялся, backend вне скоупа |
## Находки
### High-1 — новый браузерный смок падает на первой же строке и не подтверждает ни один AC
**Файл:** `demo/smoke_split_corner_wall.mjs:87`, метод `src/houseplan-card.ts:13154-13155`
```js
// demo/smoke_split_corner_wall.mjs:87
const lightGeom = c._lightBarriers(c._spaceModel()).masonryGeometry;
```
```ts
// src/houseplan-card.ts:13154-13155
private _lightBarriers(
space: SpaceModel, polys: { r: RoomCfg; poly: number[][] }[], physical: number[][][],
): { … }
```
`_lightBarriers` принимает три обязательных параметра; смок передаёт один. Все
корректные вызовы в самом продукте (`_renderGlowLayer`,
`src/houseplan-card.ts:13258-13261`) собирают `polys`/`physical` перед вызовом.
**Воспроизведение:** `npm run build`, синхронизировать
`demo/srv/assets/houseplan-card.js`, затем `node demo/smoke_split_corner_wall.mjs`.
Результат — необработанное исключение внутри `page.evaluate`:
```
browserType.launch: … (после `npx playwright install chromium`, если бинарник отсутствует)
page.evaluate: TypeError: e is not iterable
at wu._lightBarriers (…/houseplan-card.js:4710:3112)
at eval (…, <anonymous>:82:23)
```
Поскольку исключение бросается **внутри** `await page.evaluate(...)`, весь вызов
рушится целиком — `return out;` в конце скрипта никогда не выполняется. Ни одно
из 14 полей (`wall0/15/100KeepsFacade`, `paper0/15/100KeepsFacade`,
`dividerChangesInterior`, `lightUsesFacade`, `planViewParity`, `kioskParity`,
`isoUsesCanonicalBody`, `staticParity`, `renderDoesNotRewriteConfig`) не
попадает в `checkAll`/`finish` — процесс падает необработанным исключением
Node (`triggerUncaughtException`), минуя даже отчёт `FAILED (n)`.
Это единственный новый браузерный тест, который ТЗ называет доказательством
для **AC7** (`unit + smoke`, Glow/солнце видят тот же фасад) и **AC8**
(`smoke + golden`, паритет Plan/View/kiosk/`houseplan-space-card`/изометрии), а
`docs/WALL-THICKNESS.md` и оба changelog ссылаются на этот файл как на
подтверждение кросс-поверхностного паритета. В текущем виде файл не
подтверждает вообще ничего — не является «тестом, который умеет падать
содержательно», а падает по причине, не связанной с проверяемым инвариантом.
**Важно — это не признак ошибки в продуктовом коде.** Я independently
воспроизвёл тот же сценарий (тот же фикстур, тот же путь Split из вершины,
0/15/100 см) отдельным скриптом с исправленным вызовом
`_lightBarriers(model, polys, physical)`, зеркалящим построение `polys`/
`physical` из `_renderGlowLayer`. С исправленным вызовом все 14 проверок,
которые должен был выполнить смок, включая AC7 (свет использует тот же
фасад) и AC8 (Plan/View/kiosk/статичная карточка/изометрия рисуют идентичный
`d`-путь), возвращают `true`. Это подтверждает: сам фикс
(`src/wall-thickness.ts`, `src/houseplan-card.ts`, `src/space-render.ts`)
работает корректно; дефект локализован в одной строке доставленного смока.
**Почему High, а не Low/Medium.** Дефект не является хрупкостью окружения
(в отличие от известного `smoke_opening_measure.mjs` из `AGENTS.md`) — это
неверная сигнатура вызова, которая на 100% воспроизводима и рушит весь файл
без остатка, включая проверки, не связанные со светом. Не пропустить в этот
раз дешевле, чем чинить по исключению §11.4 после пре-релизного гейта: доводы
исключения («часть проблем физически не может быть найдена раньше») здесь не
применимы — проблема обнаруживается прямо сейчас, при код-ревью, инструментами,
которые у ревьюера есть.
**Что нужно для зелёного вердикта:** починить вызов на строке 87 (по образцу
`_renderGlowLayer`), пересобрать бандл и приложить содержательный (не просто
безошибочный) вывод `node demo/smoke_split_corner_wall.mjs` — `OK` со всеми 14
полями `true`, не только отсутствие исключения.
## Что проверено и корректно
- **AC1–AC6, AC9, AC10 (`unit`):** новые тесты в `test/wall-thickness.test.mjs`
(`corner Split keeps the original exterior wall body and paper`,
`…clips every divider thickness when exterior walls are absent`,
`…preserves the facade for thin and thick outer/divider matrices`,
`…keeps unequal exterior arms and is order/id/winding independent`,
`Split from a concave vertex does not turn the child mitre into facade`,
`Split with both endpoints at exterior vertices preserves both corners`,
`…clean floors are exactly the room union minus canonical walls`,
`…rendering does not materialize or mutate saved geometry`) буквально
покрывают матрицу AC3/AC4 (толщины разделителя 0/1/15/100, наружные 1/15/100,
выпуклая/вогнутая вершина, оба endpoint, reverse winding/permutation) и
сравнивают не bbox, а boolean-разность геометрии до/после — именно то, что
требует §11.1 ТЗ. Существующий регрессионный набор (partial shared wall,
virtual-T, nested room, split materialisation и т.д., AC10) остался зелёным:
`752/752`.
- **Тест умеет падать содержательно, не тавтологически** (§18 PROCESS.md):
прогон тех же новых тестов на добаговом `origin/dev` (`8a3f6ef`) даёт
`6 fail / 57 pass` с содержательными сообщениями (`expected 0, got
7240.3…`, `wall geometry missing for outer=15, divider=100`), а не падение
из-за отсутствующего экспорта — тесты действительно проверяют устранённый
дефект, а не самосогласованность нового кода.
- **AC7/AC8/AC9 по существу** (не по доставленному смоку, см. High-1):
подтверждено самостоятельным прогоном исправленной копии сценария — общая
каноническая геометрия (`wallBodiesGeometry`/`wallBodiesUnionPath`)
действительно используется светом (`_lightBarriers`), Plan, View, kiosk,
`houseplan-space-card` (статичный рендер через `renderSpaceStatic` в
`src/space-render.ts`) и скрытой изометрией (`_isoSource` в
`src/houseplan-card.ts:4421-4451` строит геометрию тем же
`wallBodiesGeometry(...).geom`, `src/iso-walls.ts` не менялся и не завёл
вторую модель) — все дают идентичный путь/bbox для фикстуры из issue.
- **AC11 (ревью кода):** кэширование на месте и не задевает hot path. Полный
рендер кэширует пару `{d, paperD}` в `this._wallUnionCache`, ключ —
`${space}|${cfgEpoch}|rooms.length}` (`src/houseplan-card.ts:9660-9679`), т.е.
HA state tick без структурных изменений не пересчитывает topology.
Статичная карточка (`src/space-render.ts:38-56`) добавляет `WeakMap`-кэш по
тому же `cfg`-объекту с ключом `contentFingerprint({rooms, walls, extras,
cellCm})` — сервер отдаёт тот же неизменяемый объект конфигурации на
каждый tick, так что фингерпринт не пересчитывает boolean-топологию заново.
Проверено чтением, не исполнением — `performance_smoke`/large-house
benchmark — пре-релизный гейт (см. таблицу выше), а не гейт код-ревью.
- **AC12:** typecheck/test/build зелёные, три bundle snapshot побайтно
идентичны (см. таблицу гейтов), sha256 совпадает со значением из хендоффа.
- **AC13 и документация:** `docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md` описывают
исправление как сохранение фасада при Split, без переименования инструмента
или нового 3D-контракта; правки в **обоих** changelog находятся в **том же**
коммите `47c6f10`, что и поведение (`git show --stat 47c6f10`). Терминология
`docs/USER-GUIDE.ru.md` («Split», «общая стена», «фасад») совпадает с
таблицей инструментов в этом же документе. `docs/WALL-THICKNESS.md` и
`docs/ARCHITECTURE.md` описывают именно тот механизм (`exteriorEnvelopeGeometry`
+ surviving `outer` intervals), который реализован в
`src/wall-thickness.ts:1209-1318` — не изобретённая задним числом
формулировка.
- **Трейлеры и процесс:** `node scripts/process-gate.mjs` и
`node scripts/process-gate.mjs --issues` оба зелёные без предупреждений —
ветка `issue/123-corner-split-wall`, трейлеры `Issue: #123` на всех 4
коммитах, `User-Visible: yes` только на коммите, меняющем поведение, и
ровно в нём правки обоих changelog. Метка `S7-code-review` — единственная
статусная на issue.
- **Реакция на Low-1 ревью ТЗ:** формулировка доказательства AC11 в
`docs/specs/123-corner-split-wall.md` изменена с `(performance + ревью
кода)` на `(ревью кода)` с явной ссылкой на существующий
`performance_smoke`/benchmark — ровно та правка, которую предложил
`SPEC-REVIEW-123-r1.md`, без нового цикла ревью ТЗ.
- **Golden:** три новых сценария (`split-corner-wall-before-dark`, `…-thin-dark`,
`…-thick-dark`, `demo/golden/matrix.mjs`) построены на отдельном
`space: 'golden-corner-split'`, не пересекающемся ни с одним существующим id
в `demo/fixtures/visual-matrix.mjs`; `GOLDEN_MATRIX_VERSION` корректно
увеличен (17 → 18); `test/golden-matrix.test.mjs` проверяет форму фикстуры
(`show_borders`, число комнат, толщину разделителя по стадии) без
исполнения браузера. Эталоны не принимались — консистентно с §11.3/§13 ТЗ,
golden — пре-релизный гейт.
- **Данные и compatibility:** формат `RoomCfg`/`WallEntry` не изменён; поиск по
диапазону не нашёл новых `config key`, миграций или изменения schema
version — соответствует §8 ТЗ и `docs/CONFIG-COMPATIBILITY.md`.
- **Скоуп:** правки укладываются в заявленную поверхность
(`src/wall-thickness.ts`, `src/houseplan-card.ts`, `src/space-render.ts`,
тесты, демо, документация); инструмент «Перегородка», модель `rooms`/`walls`/
`partitions`, снаппинг и диалог новой комнаты не тронуты — совпадает с §5
ТЗ (не-скоуп).
- **Дохлый код, замеченный по пути, но не блокирующий (Low-2, на решение
автора без нового цикла):** `paperRoomShapesWithWalls()`
(`src/wall-thickness.ts:1793`) остаётся экспортируемой и покрыта
`test/wall-thickness.test.mjs`, но после этого изменения её больше не
вызывает ни `_paperShapes` (`src/houseplan-card.ts`), ни `renderSpaceStatic`
(`src/space-render.ts`) — оба перешли на `wallBodiesUnionPath(...).paperD`.
Она не создаёт вторую копию геометрии (внутри вызывает тот же
`exteriorEnvelopeGeometry()`), поэтому архитектурный контракт §7.4 ТЗ не
нарушен — это просто более не используемый продуктом публичный экспорт.
- **Low-3 (на решение автора без нового цикла):** отказ от старого
nonzero-fill fallback в `wallBodiesUnionPath` (при отказе boolean-операции
функция теперь возвращает `null`, то есть стены/бумага не рисуются вовсе,
вместо прежнего искажённого, но видимого рендера) — намеренное решение,
прямо предписанное §7.7 ТЗ и названное риском в хендоффе автора. Не нашёл
отдельного unit-теста, который бы гонял именно этот путь (`malformed input →
null`) — вся текущая матрица тестов проходит через успешные boolean-операции.
Не блокирует: поведение явно специфицировано и осознанно, но следующей
правкой стоит закрыть тестом.
## Чего не проверял
- Golden capture/verify (`npm run golden:verify`) и `performance_smoke` —
осознанно, это пре-релизные гейты по `PROCESS.md` §8/§11.4, не гейт
код-ревью; их будущий провал (если случится) чинится по §11.4 без нового
код-ревью.
- Backend (`tests_backend`) — `custom_components/houseplan/**/*.py` не входит в
диапазон, backend вне скоупа ТЗ.
- Точность самого boolean-алгоритма (`polyclip-ts`) на произвольных
реально-сложных этажах за пределами матрицы фикстур ТЗ — проверены ровно те
конфигурации, что перечислены в §11.1 ТЗ и AC3/AC4; более широкий fuzz по
случайным полигонам не проводился и не был частью ТЗ.
- Полный автоматический паритет Plan/View/kiosk/static/iso для реального
large-house fixture (`demo/fixtures/large-house.mjs`) — проверено только на
фикстуре issue (прямоугольник 900×800 с наружными стенами 15 см); эта же
фикстура используется во всех новых unit- и golden-сценариях, так что это
совпадает с заявленным скоупом ТЗ, а не с недосмотром ревью.
## Вердикт
Красный. High: 1, Medium: 0. Единственная блокирующая находка — сломанный
`demo/smoke_split_corner_wall.mjs` (неверная сигнатура вызова
`_lightBarriers`), который не подтверждает ни один из AC7/AC8/AC9, хотя ТЗ и
документация ссылаются на него как на доказательство. Сам продуктовый фикс
(`src/wall-thickness.ts`, `src/houseplan-card.ts`, `src/space-render.ts`)
подтверждён: 752/752 unit, новые регрессионные тесты содержательно падают на
добаговом коде, а независимый прогон исправленной копии смок-сценария
подтверждает AC7–AC9 напрямую. Возврат в `S6-in-progress` нужен только для
починки одной строки смока и приложения его содержательного (не просто
безошибочного) вывода — повторный код-ревью после этого обязателен
(`PROCESS.md` §2.6: ребейз/правка гейта не освобождает от повторного ревью,
если правка не является чисто «упавший пре-релизный гейт» по §11.4, а этот
дефект найден на этапе код-ревью, не пре-релиза).
+155
View File
@@ -0,0 +1,155 @@
# CODE-REVIEW-123-r2
- **Issue:** https://github.com/Matysh/houseplan-card/issues/123
- **ТЗ:** `docs/specs/123-corner-split-wall.md` (зелёное ревью
`docs/reviews/SPEC-REVIEW-123-r1.md`)
- **Диапазон:** `git log --oneline origin/dev..HEAD` — 6 коммитов; относительно
предыдущего цикла (`docs/reviews/CODE-REVIEW-123-r1.md`, снят на коммите
`024a1ac`) диапазон вырос ровно на один коммит:
`e79f8f5 Fix corner split smoke geometry input` (`Issue: #123`,
`User-Visible: no`). `git diff 024a1ac..HEAD --stat` подтверждает: изменён
только `demo/smoke_split_corner_wall.mjs` (+6/−1 строк), продуктовый код
(`src/wall-thickness.ts`, `src/houseplan-card.ts`, `src/space-render.ts`) не
тронут ни байтом.
- **Роль:** ревьюер кода (не исполнитель), этап `S7-code-review`
- **Цикл:** r2/4
## Скоуп ревью
Единственная блокирующая находка r1 (`High-1`) — сломанная сигнатура вызова
`_lightBarriers(c._spaceModel())` в `demo/smoke_split_corner_wall.mjs:87`,
из-за которой смок падал необработанным исключением до выполнения хотя бы
одной проверки, и AC7 (`unit + smoke`)/AC8 (`smoke + golden`)/AC9
(`unit + smoke`) не были подтверждены доставленным доказательством. Скоуп
этого цикла: (1) убедиться, что фикс `e79f8f5` действительно чинит вызов, а не
маскирует падение; (2) прогнать смок и убедиться, что все 14 полей — `true`;
(3) убедиться, что тест по-прежнему умеет **содержательно** падать, а не
превратился в тавтологию; (4) поскольку продуктовый код не менялся с r1,
повторно прогнать быстрые гейты и точечные смоки по затронутым поверхностям
для очистки от сомнений, не переделывая заново детальное чтение
`src/wall-thickness.ts`, уже выполненное в r1 (не изменилось — см. diff-статы
выше).
## Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный, без вывода |
| Unit | `npm test` | `752/752` (`npm run inventory` подтверждает то же число), 0 fail |
| Build | `npm run build` | зелёный |
| Bundle sync | `cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` и `cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` | оба совпадают побайтно; sha256 всех трёх `182fb55a…483ff` — идентичен значению из r1 (ожидаемо: продуктовый код не менялся) |
| Process gate | `node scripts/process-gate.mjs` | `диапазон origin/dev..HEAD, коммитов 6`, `гейт пройден, предупреждений 0` |
| Process gate + issues | `node scripts/process-gate.mjs --issues` | `гейт пройден, предупреждений 0` (метка issue #123 подтверждена через `gh`: ровно одна `S*` — `S7-code-review`) |
| Целевой browser smoke | `node demo/smoke_split_corner_wall.mjs` (после `npm run build`, синхронизация `demo/srv/assets/houseplan-card.js`) | **`OK`**, все 14 полей `true`: `beforeDrawn`, `wall0/15/100KeepsFacade`, `paper0/15/100KeepsFacade`, `dividerChangesInterior`, `lightUsesFacade`, `planViewParity`, `kioskParity`, `isoUsesCanonicalBody`, `staticParity`, `renderDoesNotRewriteConfig` |
| Regression-can-fail (сам доставленный смок, не независимая копия) | доставленный `demo/smoke_split_corner_wall.mjs` (версия из `e79f8f5`) скопирован в чистый `git worktree` на `52ec0fb` (коммит непосредственно перед продуктовым фиксом `47c6f10`, т.е. добаговый `wallBodiesGeometry`), пересобран и прогнан там | `FAILED (7)`: `wall0/15/100KeepsFacade`, `paper0/15/100KeepsFacade`, `lightUsesFacade` — все `expected true, got false`, `planViewParity`/`kioskParity`/`isoUsesCanonicalBody`/`staticParity`/`renderDoesNotRewriteConfig` остаются `true` (паритет между поверхностями держится даже на баге — расходится именно ожидаемый факт «фасад сохранён»). Падение содержательное (конкретные `false`, не исключение), т.е. смок доказывает именно то, что называет AC, а не тавтологию |
| Точечные browser smokes по затронутым поверхностям (split/wall-thickness/glow/iso/static-card) | `node demo/smoke_wall_thickness.mjs`, `smoke_merge_split.mjs`, `smoke_split_nonsnap.mjs`, `smoke_split_polyline.mjs`, `smoke_glow.mjs`, `smoke_isometric_contract.mjs`, `smoke_space_card.mjs` | все `OK`, регрессий на смежных поверхностях нет |
| Golden/performance/backend | не запускались | пре-релизные гейты по `PROCESS.md` §8/§11.4; визуальный/перф/backend-код не менялся с r1 (см. diff-статы), решение о непрогоне уже обосновано в r1 и остаётся в силе |
Полный набор из 128 browser-смоков не прогонялся — правка этого цикла точечная
(один файл демо-гарнеса), затронутые поверхности перечислены выше и покрыты.
## Находки
Блокирующих (High/Medium) находок нет. High-1 из r1 закрыт.
### Low-1 — смок строит `lightPolys` не буквально через хелпер `roomPoly(r)`
**Файл:** `demo/smoke_split_corner_wall.mjs:88-90`
```js
const lightPolys = lightSpace.rooms
.filter((room) => Array.isArray(room.poly))
.map((room) => ({ r: room, poly: room.poly }));
```
Продуктовый `_renderGlowLayer` (`src/houseplan-card.ts:13250-13252`) строит тот
же список через `roomPoly(r)` (`src/logic.ts:103-108`), которая (а) достаёт
`r.poly`, только если в нём **не менее 3** точек, и (б) для комнаты без
явного `poly` вычисляет прямоугольник из `x/y/w/h`. Смок вместо этого
фильтрует `Array.isArray(room.poly)` без проверки длины и не имеет пути для
`x/y/w/h`-комнат.
Для фикстуры issue (все комнаты заданы явным `poly` длиной 3 или 4)
результат совпадает с продуктовым один в один — расхождение не проявляется,
и AC7 доказан корректно для того сценария, который называет ТЗ. Но если этот
файл когда-нибудь расширят на комнату без явного `poly` (`x/y/w/h`), копия
молча исключит такую комнату из `lightPolys` там, где продукт бы её включил
— тихое расхождение, а не падение с сообщением.
**Решение ревьюера:** Low, не блокирует зелёный вердикт — фактическое
поведение для покрываемого сценария корректно, откладываю на усмотрение
автора при следующей правке этого файла (например, заменить построение на
прямой вызов `roomPoly` из продукта, если он становится доступен смоку).
## Что проверено и корректно
- **High-1 (r1) закрыт:** `_lightBarriers(lightSpace, lightPolys, lightPhysical)`
теперь вызывается с тем же числом и порядком аргументов, что и
`_renderGlowLayer` (`polys`, `physical` строятся явно, `physical` — через
тот же `c._physicalBodiesR(lightSpace)`, что и в продукте). Смок выполняется
до конца, `checkAll`/`finish` печатают `OK`, все 14 полей — `true`.
- **AC7 (`unit + smoke`):** `lightUsesFacade: true` — Glow использует то же
исправленное препятствие (`masonryGeometry` из `_lightBarriers`), что и
рендер стен; подтверждено смоком и независимо не расходится с unit-уровнем
r1 (`src/wall-thickness.ts` не менялся).
- **AC8 (`smoke + golden`):** `planViewParity`, `kioskParity`,
`isoUsesCanonicalBody`, `staticParity` — все `true`; Plan, View/kiosk,
скрытая изометрия и `houseplan-space-card` рисуют идентичный путь `d` для
сценария из issue. Golden-эталоны (второй тип доказательства AC8) —
пре-релизный гейт, не запускался, консистентно с r1/§11.4 PROCESS.md.
- **AC9 (`unit + smoke`):** `renderDoesNotRewriteConfig: true` — рендер не
мутирует сохранённые `rooms`/`walls`; сравнение JSON до/после рендера
совпадает.
- **Дисциплина «тест умеет падать» — усилена относительно r1.** В r1 AC7–AC9
были подтверждены независимой копией сценария вне репозитория (сам
доставленный файл падал необработанным исключением). В этом цикле
содержательное падение показано на **самом доставленном** файле — прогон в
чистом worktree на добаговом коде (`52ec0fb`, до `47c6f10`) даёт `FAILED (7)`
с конкретными `expected/got`, не крах. Это закрывает главное сомнение r1:
теперь именно тот файл, что лежит в репозитории, доказывает регресс, а не
только рассуждение ревьюера о нём.
- **Продуктовый код не менялся с r1:** `git diff 024a1ac..HEAD --stat`
показывает изменения только в `demo/smoke_split_corner_wall.mjs`. Всё, что
r1 проверил чтением и тестами по AC1–AC6, AC9 (unit-часть), AC10, AC11
(кэширование), AC12, AC13 (документация/changelog), остаётся в силе без
повторного разбора — предмет разбора не менялся, и разбор `r1` уже прошёл
свой цикл ревью.
- **Трейлеры и процесс:** `node scripts/process-gate.mjs` /
`--issues` — зелёные без предупреждений; коммит `e79f8f5` несёт
`Issue: #123` и `User-Visible: no` — корректно, это правка тестового
гарнеса (`demo/**`, класс B), поведение продукта не меняет, изменений в
changelog не требует и их нет. Метка issue — ровно одна, `S7-code-review`.
`origin/dev` не сдвинулся с момента слияния в ветку задачи (`merge-base`
совпадает с текущим `origin/dev`), ребейз перед мержем не потребуется.
- **Точечные смоки по затронутым поверхностям** (`smoke_wall_thickness`,
`smoke_merge_split`, `smoke_split_nonsnap`, `smoke_split_polyline`,
`smoke_glow`, `smoke_isometric_contract`, `smoke_space_card`) — все `OK`,
регрессий не найдено.
## Чего не проверял
- Полный набор из 128 browser-смоков — правка точечная (один файл демо-
гарнеса), полный прогон не пропорционален объёму изменения; прогнаны
целевой смок AC7–AC9 плюс смоки по затронутым поверхностям (см. таблицу).
- `npm run golden:verify` и `performance_smoke` — пре-релизные гейты
(`PROCESS.md` §8/§11.4), визуальный рендер и перф-чувствительные пути не
менялись с r1; будущий провал чинится по §11.4 без нового код-ревью.
- `tests_backend` — `custom_components/houseplan/**/*.py` не входит в
диапазон.
- Повторное детальное чтение `src/wall-thickness.ts`/`src/houseplan-card.ts`/
`src/space-render.ts` построчно — не требовалось: файлы не изменились со
времени r1, где это чтение уже выполнено и задокументировано.
- Low-1 не проверялся на альтернативной фикстуре (комната без явного `poly`)
— вне сценария, который называет ТЗ; см. решение ревьюера в находке.
## Вердикт
Зелёный. High: 0, Medium: 0 (Low: 1, не блокирует, решение зафиксировано в
находке Low-1 выше — оставлено на усмотрение автора без нового цикла).
Единственная блокирующая находка r1 устранена: доставленный
`demo/smoke_split_corner_wall.mjs` теперь вызывает `_lightBarriers` с полной
сигнатурой, проходит до конца с `OK` по всем 14 полям и содержательно падает
на добаговом коде того же файла (не независимой копии) — AC7, AC8, AC9
подтверждены доказательством, которое называет ТЗ. Продуктовый код не менялся
с r1 и остаётся подтверждённым: 752/752 unit, три bundle-снимка побайтно
идентичны, трейлеры и процесс-гейт зелёные.
+165
View File
@@ -0,0 +1,165 @@
# SPEC-REVIEW-123-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/123
- **ТЗ под ревью:** `docs/specs/123-corner-split-wall.md` (коммит `ba56d4f`)
- **Роль:** ревьюер ТЗ (не автор), этап `S4-spec-review`
- **Трек:** обычный (не `small`) — оценка сложности 7/10, больше одной поверхности,
визуальная и световая геометрия; лёгкий трек корректно не применён
- **Цикл:** r1/4
## Скоуп ревью
Проверялось соответствие ТЗ:
- `docs/SCOPE.md` — попадание в Core user jobs, отсутствие расширения скоупа;
- `PROCESS.md` §2.4, §2.5 (DoR), §7.1 (обязательные разделы) и §12 (запреты);
- `AGENTS.md` — классы файлов, ветка, легитимность приёма чужого issue в процесс;
- каноническим документам затронутой подсистемы: `docs/WALL-THICKNESS.md`,
`docs/SUN.md`, `docs/LIGHT.md`, `docs/ISOMETRIC.md`, `docs/TOUCH-SUPPORT.md`,
`docs/CONFIG-COMPATIBILITY.md`;
- `docs/USER-GUIDE.ru.md` — терминология «Split» / «Перегородка»;
- фактическому состоянию кода (`src/wall-thickness.ts`, `src/iso-walls.ts`,
`src/space-render.ts`, `test/wall-thickness.test.mjs`) — на предмет того, что
технические утверждения ТЗ не являются непроверенной догадкой.
## Как проверялось
1. Прочитан весь тред issue #123, включая решение владельца о приёме чужого
issue в процесс (после правки конвейера, коммит `024cdc0`, issue #114) и
протокол аналитики с defaults Q1–Q3, принятыми владельцем 2026-08-13
(комментарий https://github.com/Matysh/houseplan-card/issues/123#issuecomment-5283843252).
2. Сверены обязательные разделы ТЗ (§7.1 PROCESS.md) построчно — см. таблицу ниже.
3. Прочитан код `wallBodiesGeometry()` (`src/wall-thickness.ts:1361-1412`):
подтверждено, что тело стены строится как per-room `outset(poly, half) −
inset(poly, half)`, затем `union` по комнатам — именно механизм, который ТЗ
называет причиной дефекта (диагональный Split из вершины вносит острые митры
дочерних комнат в наружный union).
4. Прочитан `src/iso-walls.ts` и `docs/ISOMETRIC.md` — подтверждено, что скрытая
изометрия уже потребляет тот же `wallBodiesGeometry()` MultiPolygon, а не
отдельную модель; утверждение ТЗ §6.4 о единой геометрии для Plan/View/
`houseplan-space-card`/изометрии не является новым архитектурным изобретением
автора, а фиксирует уже существующий контракт.
5. Прочитан `docs/LIGHT.md` («Opaque: the wall bodies exactly as the plan draws
them (`wallBodiesGeometry`)») — подтверждает AC7 (Glow/солнце используют то
же исправленное preграждение) технически достижимым без отдельной правки
light-барьеров.
6. Прочитан `docs/TOUCH-SUPPORT.md` — формулировка «safety floor» и «pointer
cancellation» в ТЗ §9 дословно соответствует канону, а не придумана.
7. Прочитан `docs/USER-GUIDE.ru.md` (таблица инструментов, разделы «Split» и
«Перегородка») — терминология ТЗ совпадает с пользовательским словарём,
различие Split/Перегородка воспроизведено верно и явно вынесено в не-скоуп
(п.5.4).
8. Прочитан `test/wall-thickness.test.mjs` — регрессионные сценарии, которые ТЗ
в §11.1 п.7 требует не сломать (partial shared wall, virtual-T mitre, nested
room, 45° wall, split materialisation), реально существуют в файле, то есть
план автотестов не ссылается на несуществующее покрытие.
9. Проверено, что `docs/ARCHITECTURE.md`, `docs/STATUS.md`,
`docs/CHANGELOG(.ru).md` существуют — release-артефакты в §13 указывают на
реальные файлы.
10. Проверена запись в `docs/specs/README.md` — строка на #123 добавлена в том
же коммите, ссылка issue ↔ ТЗ двусторонняя.
## Обязательные разделы (§7.1 PROCESS.md)
| Раздел | Есть | Комментарий |
|---|---|---|
| Сценарий (персона/поверхность/момент) | ✅ | §1 |
| Что человек увидит до/после (без терминов реализации) | ✅ | §1, одна фраза |
| Проблема | ✅ | §2, с воспроизведёнными числами bbox на `948f284` |
| Скоуп / не-скоуп | ✅ | §4 / §5 |
| Контракт поведения | ✅ | §6 |
| UX | ✅ | §9 |
| Модель данных и миграция | ✅ | §8 |
| i18n | ✅ | §9 (пусто, обосновано) |
| AC1…ACn с доказательством | ✅ | §10, 13 штук, каждый с типом |
| План автотестов | ✅ | §11 |
| Риски | ✅ | §14 |
| Откат | ✅ | §15 |
| Release-артефакты | ✅ | §13 |
Все обязательные разделы присутствуют и содержательны, не формальные заглушки.
## Находки
Находок уровня **High** и **Medium** нет.
### Low-1 — тип доказательства AC11 не входит буквально в перечень §2.5
**Файл:** `docs/specs/123-corner-split-wall.md:293-295`
AC11 помечен `(performance + ревью кода)`. DoR (`PROCESS.md` §2.5) перечисляет
допустимые типы доказательства как `unit` / `backend` / `smoke` / `golden` /
«ревью кода»; литерала `performance` в этом перечне нет. По существу критерий
всё равно доказуем: в ТЗ явно указано «ревью кода» вторым типом, а
`performance_smoke`/large-house benchmark — существующие release-blocking гейты
(§11.4 этого же ТЗ, `PROCESS.md` §8), а не новый вид проверки. Блокирующим не
является, но для чистоты трассируемости стоит переформулировать доказательство
AC11 как «ревью кода» с явной ссылкой на существующий `performance_smoke`/
large-house benchmark, не вводя пятый тип доказательства.
**Решение ревьюера:** Low, не блокирует. Можно поправить формулировку при
следующей правке ТЗ или снять с этой записью — оставляю на усмотрение автора,
т.к. критерий по сути проверяем и не создаёт риска для DoR.
## Что проверено и корректно
- Легитимность приёма issue в процесс (чужой автор, но явно допущен владельцем
после правки конвейера #114) — не относится к дефектам ТЗ, отдельно
зафиксировано в треде issue самим владельцем.
- Соответствие `docs/SCOPE.md`: задача закрывает J6 («Keep the plan true as the
home evolves») и частично J4 (встроенный редактор без искажений архитектуры),
обе строки в статусе «Closed» — это регрессионный баг внутри уже принятой
функциональности, а не новая фича и не расширение скоупа.
- Владелец лично принял defaults Q1–Q3 и приоритет P2 (комментарии
2026-08-13T16:52 и 17:00) — открытых продуктовых вопросов в финальной
редакции ТЗ нет, и это корректно: вопросы были заданы и закрыты на этапе
аналитики, а не додуманы автором.
- Технический диагноз причины (per-room `outset−inset` union, острые митры
дочерних комнат Split входят в наружный силуэт) подтверждён чтением
`src/wall-thickness.ts` — не голословное утверждение автора.
- Раздел 16 «Принятые технические предположения» корректно отделяет свободно
изменяемые технические решения (имена helper'ов, конкретная boolean-
декомпозиция, имя golden/smoke сценария) от решений владельца Q1–Q3,
которые пересмотру не подлежат — ни одна догадка не выдана за факт без
пометки.
- Не найдено ни одного утверждения о поведении, которое не следует ни из
канонических документов, ни из принятых владельцем defaults, ни из чтения
существующего кода, и при этом не помечено как предположение.
- AC1–AC13 однозначны, у каждого указан тип доказательства и он входит (кроме
Low-1) в допустимый по DoR список; план автотестов (§11) даёт конкретный,
проверяемый маршрут для каждого, включая явное требование «тест из п.3
обязан краснеть на `948f284`» — критерий, защищающий от неспособного падать
теста.
- Не-скоуп (§5) корректно отсекает смежные соблазны (не превращать Split в
Перегородку, не трогать инструмент «Перегородка», не менять модель данных,
не вводить новый UX для cap/join) — типичные места, где скоуп мог бы незаметно
расшириться.
- Release-артефакты (§13) перечисляют реальные файлы, включая
`docs/WALL-THICKNESS.md` (exterior/shared junction invariant) и
`docs/USER-GUIDE.ru.md` — корректная точка правки терминологии для
пользователя.
- Реестр `docs/specs/README.md` обновлён тем же коммитом, ссылка issue ↔ ТЗ
двусторонняя (`PROCESS.md` §7.1).
## Чего не проверял
- Не проверял, что предложенная в §7 архитектурная декомпозиция (exterior
envelope vs shared divider body) реализуема без регрессии в
`polyclip-ts`-based boolean операциях — это по правилам ТЗ (§16 п.2)
свободно изменяемое техническое предположение автора кода, не предмет
ревью ТЗ.
- Не проверял производительность реального large-house benchmark — AC11
предполагает существующий гейт, а не новый, и это станет предметом ревью
кода/пре-релизного гейта, не ревью ТЗ.
- Не запускал никаких автотестов — на этапе `spec` это не требуется; проверка
существования регрессионных сценариев (см. «Как проверялось», п.8) сделана
чтением файла, не исполнением.
- Не проверял корректность конкретных числовых bbox-диагностик из §2 —
доверяю записи владельца/автора в треде issue как источнику числа, поскольку
оно уже независимо зафиксировано в комментарии аналитики до написания ТЗ.
## Вердикт
Зелёный. High: 0, Medium: 0. Одна находка Low (AC11 формулировка типа
доказательства) — не блокирует, оставлена автору на усмотрение с записью в этом
документе (не «TODO», а фиксированное решение ревьюера: можно поправить или
отклонить без нового цикла).
+422
View File
@@ -0,0 +1,422 @@
# Issue #123 — Split из вершины не меняет наружную геометрию стен
- **Issue:** https://github.com/Matysh/houseplan-card/issues/123
- **Редакция:** первая редакция для независимого ревью; статус задачи определяется
только метками issue
- **Тип / приоритет:** bug / P2
- **Оценка:** пользовательская ценность 7/10; ценность для разработки 8/10;
сложность и риск 7/10
- **Область:** Split в «Редакторе плана», наружные и общие толстые стены,
полный и статический рендеры, скрытая изометрия, чистая площадь, Glow и солнце
- **Модель данных:** без изменений и миграции
- **Связано:** `docs/WALL-THICKNESS.md`, `docs/CANVAS.md`,
`docs/UX-MODES.md`, `docs/TOUCH-SUPPORT.md`, `docs/ARCHITECTURE.md`
## 1. Сценарий и продуктовый контекст
**Персона:** администратор дома, который поддерживает архитектуру плана в
desktop-браузере.
**Поверхность и момент:** в «Редакторе плана» пользователь выбирает Split,
указывает комнату и начинает разрез точно из существующего угла комнаты. После
создания второй комнаты он при необходимости задаёт новой общей стене толщину.
**До → после, без терминов реализации:** сейчас разделение комнаты из угла
деформирует наружную стену и может вытянуть из фасада большой зуб; после
исправления фасад выглядит ровно как до разделения, а новая внутренняя стена
аккуратно примыкает к нему изнутри.
Задача поддерживает:
- **J4:** встроенный редактор должен позволять построить правдивый план без
внешних инструментов;
- **J6:** последующее изменение комнат не должно искажать уже созданную
архитектуру;
- **J1/J2/J3:** View, состояния и действия остаются на той же пространственной
модели, а не на отдельной исправленной только для редактора картинке.
## 2. Проблема и подтверждённая причина
`splitRoomPath()` правильно делит исходный полигон: площади двух частей дают
площадь исходной комнаты. Сохранение исходных интервалов толщины вокруг новых
дочерних рёбер также уже покрыто тестами.
Ошибка появляется позже, при построении физического тела стены.
`wallBodiesGeometry()` создаёт для каждой комнаты отдельное кольцо
`outset(room) − inset(room)`, затем объединяет кольца. Split из вершины заменяет
один исходный угол двумя углами дочерних комнат. Общая диагональная стена входит
в оба новых контура, поэтому их митры ошибочно становятся частью наружного
силуэта дома:
- даже при нулевой толщине разделителя bbox кладки отличается от исходного;
- при ненулевой толщине острый митр вытягивается наружу заметным зубом;
- нарисованная кладка одновременно используется как препятствие, поэтому это
не только косметический дефект редактора.
Диагностика на `dev` SHA `948f284` для прямоугольной комнаты с наружными
стенами 15 см:
| Состояние | bbox кладки |
|---|---|
| До Split | `[93.75, 93.75, 906.25, 706.25]` |
| Split из вершины, разделитель 0 см | `[92.9167, 93.75, 906.25, 706.25]` |
| Тот же разделитель 15 см | `[83.4702, 86.1502, 906.25, 706.25]` |
CSS-скругление обычного room border не исправляет физическое тело стены,
чистую площадь или световые препятствия. Превращение Split в независимую
`Перегородку` тоже неверно: Split обязан сохранить две комнаты, две возможные
HA-зоны и настоящую общую границу.
## 3. Решения владельца
Владелец принял defaults Q1–Q3 и приоритет P2 13.08.2026. Каноническая запись:
https://github.com/Matysh/houseplan-card/issues/123#issuecomment-5283843252
1. Наружный силуэт и наружная грань остаются такими же, как до Split, при любой
допустимой толщине внутренней стены. Общая стена заканчивается у внутренней
грани наружной кладки и не выступает за фасад.
2. Исправленная геометрия едина для Plan, View/киоска,
`houseplan-space-card`, скрытой изометрии, чистой площади, Glow и солнца.
3. Существующие планы исправляются вычисляемо сразу после обновления, без
миграции и перезаписи конфигурации.
## 4. Скоуп
В задачу входят:
1. любой валидный Split, у которого хотя бы один endpoint после действующего
wall-snap совпадает с вершиной исходной комнаты;
2. случаи, где endpoint-вершина выпуклая или вогнутая и где вершинами являются
один либо оба конца разреза;
3. нулевая и любая допустимая толщина новой общей стены, включая толщину больше
толщины примыкающей наружной стены;
4. нулевая, одинаковая и различная толщина двух наружных рёбер у вершины;
5. сохранение исходного внешнего силуэта, наружной грани и настоящего внешнего
угла;
6. чистое внутреннее примыкание без щели пола, наружного зуба или лишнего
митра дочерней комнаты;
7. единая физическая геометрия полного и статического рендеров, скрытой
изометрии, чистого пола и препятствий Glow/солнца;
8. автоматическое исправление уже сохранённых планов на чтении/рендере;
9. unit, browser smoke, visual golden, документация и RU/EN changelog.
## 5. Не входит в задачу
- запрет или предупреждение для Split из угла;
- изменение выбора комнаты, snapping, маршрута кликов, диалога новой комнаты,
правил имени/HA-зоны или выбора большей части;
- изменение модели `rooms`, `walls`, `open_spans`, `partitions` или
`wall_columns`;
- превращение общей стены Split в независимую `Перегородку`;
- изменение поведения инструмента `Перегородка`;
- новый тип стыка, пользовательская настройка cap/join или новые i18n-тексты;
- общая переработка всех пересечений стен, которые не воспроизводят дефект
endpoint-вершины;
- изменение дверей, окон, ворот, виртуальных границ или их конфигурации;
- миграция, schema version, backend и import/export;
- свободная 3D-геометрия или отдельная модель для изометрии.
## 6. Контракт поведения
### 6.1. Split остаётся Split
После подтверждения диалога:
- создаются две комнаты по действующим правилам `splitRoomPath()`;
- их площади по центровым контурам дают площадь исходной комнаты в пределах
действующего epsilon;
- большая часть сохраняет id, имя, HA-зону и устройства исходной комнаты;
- меньшая часть получает новый id и данные из диалога;
- линия разреза является общей производной границей комнат, а не записью в
`partitions[]`;
- Undo/Redo и сохранение работают как сейчас.
### 6.2. Наружная кладка
Пусть `before` — физический внешний контур комнаты непосредственно перед
Split, а `after` — внешний контур объединения получившихся комнат при тех же
наружных интервалах толщины.
Для endpoint в вершине:
1. `after` не содержит кладки снаружи `before`;
2. существующая наружная кладка не исчезает и не получает щель;
3. настоящий внешний угол и его bevel/mitre остаются такими же, как до Split;
4. искусственный угол дочерней комнаты между внешним ребром и линией Split не
участвует в формировании фасада;
5. правило действует и при нулевой наружной толщине: внутренняя стена
обрезается по границе пола и не выступает наружу;
6. разная толщина двух наружных плеч сохраняется без усреднения или
выравнивания.
Численное сравнение использует единый геометрический epsilon; визуально
различимый зуб, щель или ступень не может быть оправдан погрешностью.
### 6.3. Внутренняя общая стена
- При `cm > 0` тело общей стены заканчивается у внутренней грани наружной
кладки. Внутри пола оно сохраняет полную заданную толщину.
- При `cm = 0` Split остаётся общей осевой границей, но не меняет тело
примыкающих наружных стен.
- При толщине разделителя больше наружной лишняя ширина остаётся внутри дома;
она не расширяет фасад.
- Примыкание не оставляет между стенами участок чистого пола и не создаёт
двойную непрозрачность/штриховку.
- Обычный endpoint в середине стены и существующие L/T/virtual junctions не
меняют нынешний контракт.
### 6.4. Все поверхности видят одну геометрию
Исправленный результат обязан быть общим для:
- Plan editor;
- View и киоска;
- `houseplan-space-card`;
- скрытого изометрического Labs-режима;
- paper/room fill и чистой площади;
- физических препятствий Glow и солнечных лучей.
Запрещено исправить только SVG полного card renderer отдельной маской: нарисованная
и физическая кладка снова разойдутся.
### 6.5. Существующие планы
План с уже сохранёнными дочерними полигонами и интервалами толщины:
- отображается правильно после обновления без открытия редактора;
- не получает новый config key;
- не вызывает скрытое сохранение или оптимизацию;
- при простом открытии/рендере сохраняет конфигурацию побайтно;
- остаётся совместимым с предыдущей версией при откате.
## 7. Архитектурный контракт реализации
Конкретные helper names и разбиение файлов являются техническим выбором автора,
но должны соблюдаться следующие границы:
1. Фасадная кладка определяется exterior envelope объединения комнат и
`outer` atomic intervals, а не острыми углами каждого дочернего room ring.
2. `shared` interval Split строится как внутренняя физическая стена и
ограничивается внутренней стороной exterior envelope до объединения тел.
3. Настоящие внешние углы продолжают использовать действующий
mitre/bevel-контракт и `MITRE_LIMIT`; искусственный child corner на endpoint
общей стены не считается внешним углом.
4. Один канонический результат wall-body geometry потребляют drawing,
clean-floor projection и light/sun occlusion. Отдельные исправленные копии
геометрии по render surface запрещены.
5. Расчёт детерминирован относительно порядка комнат, их id и winding.
6. Исправление не мутирует `rooms`, `walls`, `open_spans` и не материализует
config при чтении.
7. Boolean/fallback path также соблюдает exterior invariant; при ошибке
операции нельзя молча вернуться к известной геометрии с наружным зубом.
8. Новые вычисления входят в существующий geometry fingerprint/cache и не
выполняются заново на каждом HA state tick.
Предполагаемые файлы реализации:
- `src/wall-thickness.ts`;
- при необходимости `src/iso-walls.ts`, только для потребления общей
исправленной геометрии без второй модели;
- `test/wall-thickness.test.mjs` и при необходимости `test/logic.test.mjs`;
- новый узкий `demo/smoke_split_corner_wall.mjs` либо эквивалент;
- golden scenario/baseline по правилам review;
- документы из раздела 13.
## 8. Модель данных, compatibility и миграция
Форматы не меняются:
```ts
interface RoomCfg {
poly?: number[][];
}
interface WallEntry {
key: string;
cm: number;
a?: number[];
b?: number[];
}
```
- новых полей и compatibility aliases нет;
- legacy midpoint-only wall keys остаются читаемыми;
- materialisation/normalisation при явном редактировании сохраняет текущий
контракт;
- schema version и backend validation не меняются;
- прямой и обратной миграции нет.
## 9. UX, i18n, accessibility и touch
Новых controls, диалогов, текстов, фокуса или keyboard semantics нет. Поэтому
новые i18n-ключи не требуются.
Plan editor остаётся desktop-first. Touch editor — **best effort**, но safety
floor обязателен: Split на touch не может сохранить другую геометрию из-за
pointer cancellation или второго касания.
View и киоск полностью поддерживаются: исправленный фасад, room fills и
световые препятствия должны совпадать с desktop. `prefers-reduced-motion` не
затрагивается.
## 10. Критерии приёмки
- **AC1 (`unit`):** Split из вершины по-прежнему создаёт точное разбиение:
площади частей суммируются в исходную, большая часть сохраняет identity,
линия разреза не появляется в `partitions[]`.
- **AC2 (`unit`):** для прямоугольника из воспроизведения внешний wall-body
после Split при разделителе 0 см геометрически совпадает с исходным фасадом;
текущая bbox-регрессия отсутствует.
- **AC3 (`unit`):** тот же инвариант выполняется при толщине разделителя 1, 15
и 100 см и при наружной толщине 0, 15 и 100 см; никакая точка кладки не
выступает за допустимый исходный exterior envelope.
- **AC4 (`unit`):** матрица включает острый и тупой угол разреза, один и два
endpoint-угла, выпуклую и вогнутую вершину, а также разные толщины двух
наружных плеч. Результат не зависит от room order, id и winding.
- **AC5 (`unit` + `golden`):** внутренний разделитель примыкает к внутренней
грани наружной стены без щели, зуба, ступени и двойной штриховки; настоящая
форма exterior corner до/после визуально идентична.
- **AC6 (`unit`):** clean-floor geometry двух комнат учитывает внутреннюю стену
только внутри дома; суммарная потеря пола соответствует внутреннему телу
разделителя и не включает наружный spike.
- **AC7 (`unit` + `smoke`):** Glow и солнце используют то же исправленное
препятствие: свет не проходит через примыкание, но и не блокируется
несуществующей кладкой за фасадом.
- **AC8 (`smoke` + `golden`):** Plan, View/киоск и `houseplan-space-card`
показывают один фасад для fixture #123; скрытая изометрия не возвращает зуб и
не вводит вторую геометрию.
- **AC9 (`unit` + `smoke`):** сохранённые ранее room polygons и wall entries
исправляются без записи, миграции или изменения сериализованного config.
- **AC10 (`unit`):** обычный Split от середины стены, wall materialisation,
partial shared intervals, virtual-T mitre, openings и independent partitions
сохраняют действующее поведение.
- **AC11 (ревью кода):** новый exterior/shared расчёт использует существующее
geometry caching; HA state tick не пересчитывает topology. Существующие
`performance_smoke` и large-house benchmark остаются общим предрелизным
подтверждением, а не отдельным типом доказательства AC.
- **AC12 (`typecheck` + `unit` + `build`):** быстрые гейты зелёные; три bundle
snapshot побайтно совпадают.
- **AC13 (ревью документации):** RU/EN changelog и пользовательские документы
описывают исправление как сохранение фасада при Split, не как изменение
инструмента `Перегородка` или новый 3D-контракт.
## 11. План автотестов
### 11.1. Unit
1. Зафиксировать исходную комнату и её exterior wall-body geometry.
2. Выполнить `splitRoomPath()` из точной вершины к середине другого ребра,
materialise/normalise текущие wall intervals и применить толщину общей
стене.
3. Сравнивать не только bbox, а boolean difference exterior geometry до/после:
лишняя и потерянная фасадная площадь должны быть меньше epsilon.
4. Отдельно проверить полное покрытие внутреннего примыкания и отсутствие тела
разделителя снаружи исходного exterior envelope.
5. Повторить матрицу AC3/AC4, включая reverse winding и перестановку rooms.
6. Проверить clean-floor area и барьеры `wallBodiesGeometry()`.
7. Regression suite: partial shared wall, virtual T, nested room, opening cut,
materialisation после Split и independent body union.
8. Проверить отсутствие мутации входных rooms/walls и сериализованного fixture.
Тест из пункта 3 обязан краснеть на `948f284`, а не только подтверждать новое
вспомогательное вычисление.
### 11.2. Browser smoke
Один узкий сценарий на production bundle:
1. создать прямоугольную комнату с наружными стенами 15 см;
2. сохранить внешний SVG bbox/path signature;
3. выполнить пользовательский путь Split из угла и подтвердить новую комнату;
4. проверить 0 см, затем 15 см и 100 см общей стены;
5. переключить Plan → View, киоск, static card и Labs iso;
6. доказать одинаковый exterior bbox, отсутствие наружного зуба и наличие
внутренней стены;
7. проверить Undo/Redo и отсутствие скрытой config write;
8. поставить источник света/солнце у примыкания и подтвердить общий occluder.
По текущему процессу smoke добавляется при реализации, но запускается перед
бетой; в цикле реализации выполняются только typecheck, unit и build.
### 11.3. Golden
Добавить deterministic scenario `split-corner-wall` либо эквивалент с тремя
кадрами:
1. исходный внешний угол;
2. Split из угла с тонкой общей границей;
3. тот же Split с толстой общей стеной.
Кадр должен включать Plan и View либо паритетные full/static поверхности.
Baseline принимается только через `npm run golden:accept -- --reviewed` по
полному Linux CI artifact с обязательными trailers `Release:` и
`Baseline-Reviewed:`. Принятие ради зелёного CI запрещено.
### 11.4. Performance и backend
Backend не меняется. Отдельный backend gate не нужен.
Перед бетой выполняются общий `performance_smoke` и целевой large-house render
benchmark, если реализация меняет асимптотику wall topology. Отдельного нового
численного бюджета нет: действующие бюджеты и exact-SHA CI остаются
release-blocking.
## 12. Производительность и безопасность
**Производительность:** wall topology — cached structural input. Исправление не
должно переносить boolean union/difference в HA state hot path, создавать
отдельный расчёт для каждой render surface или обходить geometry fingerprint.
**Безопасность:** HA service calls, locks, permissions и destructive actions не
затрагиваются. Главный safety-риск здесь — расхождение нарисованного пола и
физического препятствия; единая canonical geometry обязательна.
## 13. Документация и release-артефакты
В том же user-visible implementation commit обновить:
- `docs/CHANGELOG.md`;
- `docs/CHANGELOG.ru.md`;
- `docs/USER-GUIDE.ru.md` — раздел Split/толстые стены: Split из вершины не
меняет фасад, внутренняя общая стена примыкает изнутри;
- `docs/WALL-THICKNESS.md` — exterior/shared junction invariant;
- `docs/ARCHITECTURE.md` — каноническое разделение exterior shell и shared
divider body без второй модели;
- `docs/STATUS.md` — текущая релизная линия после фактической реализации.
Visual change требует targeted golden из §11.3 и review полного Linux artifact.
Отдельного security artifact нет. Performance подтверждается §11.4. Issue
должна пройти опубликованную beta до stable release.
## 14. Риски и снижение
| Риск | Вероятность / ущерб | Снижение |
|---|---|---|
| Новый exterior shell сотрёт shared/nested wall | средняя / высокий | строить outer/shared отдельно; nested и partial regression unit |
| Разная толщина наружных плеч усреднится | средняя / средний | atomic interval matrix и точное сравнение фасада |
| Появится щель между divider и наружной стеной | средняя / высокий | coverage/difference unit плюс golden крупного угла |
| Drawing исправится, Glow/солнце останутся старыми | средняя / высокий | один canonical body и occlusion smoke |
| Room order/winding изменит boolean result | средняя / высокий | permutation/reverse-winding unit |
| Existing config перепишется при чтении | низкая / высокий | immutable fixture и browser no-write assertion |
| Geometry hot path станет дороже | средняя / средний | существующий fingerprint/cache и pre-beta performance |
| Исправление сломает обычные T/virtual/opening joins | средняя / высокий | полный целевой regression unit set |
## 15. Откат
Откат — revert implementation commit. Данные и schema не мигрируются, поэтому
планы остаются читаемыми. После отката вернётся прежний визуальный дефект, но
никакого восстановления конфигурации не потребуется. Feature flag и обратная
миграция не нужны.
## 16. Принятые технические предположения — можно менять без пересмотра продукта
1. Endpoint считается вершиной по уже существующему wall-snap/geometry epsilon;
отдельный пользовательский tolerance не вводится.
2. Предпочтительная реализация разделяет exterior shell и shared wall bodies,
но конкретная boolean decomposition может быть другой, если AC доказываются.
3. `wallBodiesGeometry()` остаётся canonical entry point; имя и внутренние
helpers можно менять.
4. Имя smoke/golden scenario не является частью продукта.
5. Новая настройка cap/join не нужна: поведение однозначно следует решениям
владельца.
6. Решения Q1–Q3 из раздела 3 не относятся к изменяемым предположениям.
+1
View File
@@ -77,6 +77,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#90](https://github.com/Matysh/houseplan-card/issues/90) Управляемый бейдж со значением | [090-device-value-badge.md](090-device-value-badge.md) |
| [#94](https://github.com/Matysh/houseplan-card/issues/94) Универсальное действие «Переключить состояние» | [094-universal-state-toggle.md](094-universal-state-toggle.md) |
| [#101](https://github.com/Matysh/houseplan-card/issues/101) Плавный переход View ↔ редакторы | [101-view-editor-transition.md](101-view-editor-transition.md) |
| [#123](https://github.com/Matysh/houseplan-card/issues/123) Split из вершины не меняет наружную геометрию стен | [123-corner-split-wall.md](123-corner-split-wall.md) |
## Правило актуализации
+36 -27
View File
@@ -55,7 +55,7 @@ import {
import {
degradeWalls, rekeyWallsAfterMove,
setWallThickness, setWallThicknessForRoom, cmToField, wallCmToUnits,
wallEdgeBodies, wallBodiesGeometry, wallBodiesUnionPath, paperRoomShapesWithWalls,
wallEdgeBodies, wallBodiesGeometry, wallBodiesUnionPath,
innerContourForRoom, roomWallProfile, outsetContour,
openingInnerFaceOffsetFromIndex, openingTunnelGeometriesFromIndex,
openingWallIndex as buildOpeningWallIndex, applyWallThicknessToNewRoom,
@@ -4432,7 +4432,7 @@ class HouseplanCard extends LitElement {
rooms: space.rooms, walls, openCuts, openings,
partitions: space.partitions, roomDrafts: space.room_drafts, columns: space.wall_columns,
cellCm: this._cellCm, gridPitch: this._gridPitch, wallKeyPitch: this._wallKeyPitch,
camera: ISO_CAMERA, wallHeight: ISO_WALL_HEIGHT, algorithm: 1,
camera: ISO_CAMERA, wallHeight: ISO_WALL_HEIGHT, algorithm: 2,
})}`;
return {
key,
@@ -9646,13 +9646,39 @@ class HouseplanCard extends LitElement {
}
/** Paper under rooms, grown by shared-wall half-thickness when set. */
private _paperShapes(rooms: any[]): Array<{ poly: string } | { rect: { x: number; y: number; w: number; h: number; rx: number } }> {
private _paperShapes(rooms: any[]): Array<
| { path: string }
| { poly: string }
| { rect: { x: number; y: number; w: number; h: number; rx: number } }
> {
const walls = this._spaceWalls;
if (!walls.length) return paperRoomShapes(rooms);
const united = this._wallUnionGeometry();
return united?.paperD ? [{ path: united.paperD }] : paperRoomShapes(rooms);
}
/** Canonical paper + masonry geometry, cached by structural config epoch. */
private _wallUnionGeometry(): ReturnType<typeof wallBodiesUnionPath> {
const walls = this._spaceWalls;
const extras = this._physicalBodiesR();
if (!walls.length && !extras.length) return null;
const openCuts = this._openPairs().flatMap((p) => p.segs);
return paperRoomShapesWithWalls(
rooms, walls, openCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
);
const openings = (this._curSpaceCfg?.openings || []).map((o: any) => ({
x: Number(o.x) * NORM_W, y: Number(o.y) * NORM_W,
angle: Number(o.angle) || 0,
length: (Number(o.length) > 0 ? Number(o.length) : 0.9) * NORM_W,
}));
const unionKey = `${this._space}|${this._cfgEpoch}|${this._spaceModel().rooms.length}`;
if (!this._wallUnionCache || this._wallUnionCache.key !== unionKey) {
this._wallUnionCache = {
key: unionKey,
value: wallBodiesUnionPath(
this._spaceModel().rooms, walls, openCuts, openings,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, extras,
),
};
}
return this._wallUnionCache.value;
}
/** Thick-wall spans in render units — suppress centreline stroke under bodies. */
@@ -10009,26 +10035,7 @@ class HouseplanCard extends LitElement {
if (this._renderProjection === 'iso') return svg`` as unknown as TemplateResult;
if (disp && !disp.showBorders && (this._mode === 'view' || this._mode === 'devices'))
return svg`` as unknown as TemplateResult;
const walls = this._spaceWalls;
const extras = this._physicalBodiesR();
if (!walls.length && !extras.length) return svg`` as unknown as TemplateResult;
const openCuts = this._openPairs().flatMap((p) => p.segs);
const openings = (this._curSpaceCfg?.openings || []).map((o: any) => ({
x: Number(o.x) * NORM_W, y: Number(o.y) * NORM_W,
angle: Number(o.angle) || 0,
length: (Number(o.length) > 0 ? Number(o.length) : 0.9) * NORM_W,
}));
const unionKey = `${this._space}|${this._cfgEpoch}|${this._spaceModel().rooms.length}`;
if (!this._wallUnionCache || this._wallUnionCache.key !== unionKey) {
this._wallUnionCache = {
key: unionKey,
value: wallBodiesUnionPath(
this._spaceModel().rooms, walls, openCuts, openings,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, extras,
),
};
}
const united = this._wallUnionCache.value;
const united = this._wallUnionGeometry();
if (!united) return svg`` as unknown as TemplateResult;
const stage = this._stageEl;
const v = this._viewOr(this._baseVb());
@@ -14026,7 +14033,9 @@ class HouseplanCard extends LitElement {
(styles.ts) is composited once for the whole sheet, so
adjacent rooms never cast seams onto each other's paper. */}
${this._wallHatchDefs(disp.color)}${svg`<g class="hp-paperg">${this._paperShapes(space.rooms).map((sh) =>
'poly' in sh
'path' in sh
? svg`<path class="hp-paper" d="${sh.path}" fill-rule="evenodd" pointer-events="none"></path>`
: 'poly' in sh
? svg`<polygon class="hp-paper" points="${sh.poly}" pointer-events="none"></polygon>`
: svg`<rect class="hp-paper" x="${sh.rect.x}" y="${sh.rect.y}" width="${sh.rect.w}" height="${sh.rect.h}" rx="${sh.rect.rx}" pointer-events="none"></rect>`,
)}</g>`}
+40 -9
View File
@@ -12,9 +12,7 @@ import {
spaceDisplayOf, fillColorsOf, roomFillModeOf, roomGlowOf,
roomCustomFillOf, resolveEffectiveRoomFill, stageBgOf, paperRoomShapes,
} from './logic';
import {
wallBodiesUnionPath, paperRoomShapesWithWalls, wallBodyNeedsSolid, type WallEntry,
} from './wall-thickness';
import { wallBodiesUnionPath, wallBodyNeedsSolid, type WallEntry } from './wall-thickness';
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
import { t, type Lang } from './i18n';
import { bgModeOf, northDegOf, sunStateOf, dayPhase } from './sun';
@@ -27,6 +25,7 @@ import {
import { presentationSnapshotKey } from './render-device-snapshot';
import { deviceFaceStyle, renderDeviceFace } from './device-face';
import { valueBadgeTitle } from './device-value-badge';
import { contentFingerprint } from './visual-continuity';
import {
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, spaceFrame, iconCqw, NORM_W,
GRID_STEP_N, GRID_PITCH,
@@ -35,6 +34,29 @@ import {
export { spaceModels } from './space-geometry';
type StaticWallGeometry = ReturnType<typeof wallBodiesUnionPath>;
type StaticWallGeometryEntry = { fingerprint: string; value: StaticWallGeometry };
const staticWallGeometryCache = new WeakMap<object, Map<string, StaticWallGeometryEntry>>();
/** Static cards receive the same immutable server-config object on HA ticks. */
function cachedStaticWallGeometry(
cfg: ServerConfig,
spaceId: string,
fingerprint: string,
build: () => StaticWallGeometry,
): StaticWallGeometry {
let spaces = staticWallGeometryCache.get(cfg as object);
if (!spaces) {
spaces = new Map<string, StaticWallGeometryEntry>();
staticWallGeometryCache.set(cfg as object, spaces);
}
const cached = spaces.get(spaceId);
if (cached?.fingerprint === fingerprint) return cached.value;
const value = build();
spaces.set(spaceId, { fingerprint, value });
return value;
}
export interface StaticRenderOpts {
hass: any;
registry?: HaRegistrySnapshot;
@@ -306,12 +328,19 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
// gaps between detached buildings, and an empty space has no paper at all,
// image or no image. The picture is drawn ON the paper, one layer above.
const paperShapes = walls.length
? paperRoomShapesWithWalls(space.rooms, walls, [], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W)
: paperRoomShapes(space.rooms);
const wallUnion = (walls.length || extras.length) && disp.showBorders
? wallBodiesUnionPath(space.rooms, walls, [], [], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W, extras)
const needsCanonicalWallGeometry = !!(walls.length || (extras.length && disp.showBorders));
const wallGeometryFingerprint = needsCanonicalWallGeometry
? contentFingerprint({ rooms: space.rooms, walls, extras, cellCm })
: '';
const canonicalWallGeometry = needsCanonicalWallGeometry
? cachedStaticWallGeometry(o.cfg, space.id, wallGeometryFingerprint, () => wallBodiesUnionPath(
space.rooms, walls, [], [], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W, extras,
))
: null;
const paperShapes = walls.length && canonicalWallGeometry?.paperD
? [{ path: canonicalWallGeometry.paperD }]
: paperRoomShapes(space.rooms);
const wallUnion = disp.showBorders ? canonicalWallGeometry : null;
const pxPerUnit = o.stageWidth && vb[2] ? o.stageWidth / vb[2] : 1;
const solidWall = !!wallUnion && wallBodyNeedsSolid(wallUnion.depthUnits, pxPerUnit);
const wallStroke = disp.color || '#607d8b';
@@ -326,7 +355,9 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
</pattern>
</defs>` : nothing}
${paperShapes.map((sh) =>
'poly' in sh
'path' in sh
? svg`<path class="hp-paper" d="${sh.path}" fill-rule="evenodd"></path>`
: 'poly' in sh
? svg`<polygon class="hp-paper" points="${sh.poly}"></polygon>`
: svg`<rect class="hp-paper" x="${sh.rect.x}" y="${sh.rect.y}" width="${sh.rect.w}" height="${sh.rect.h}" rx="${sh.rect.rx}"></rect>`,
)}
+177 -39
View File
@@ -6,8 +6,8 @@
* displayed m² use the inner (inset) contour. Wall-length rulers stay on the
* centreline.
*/
import { union, difference } from 'polyclip-ts';
import { polygonArea, roomPoly, roomEdges, sharedBoundary } from './logic';
import { union, difference, intersection } from 'polyclip-ts';
import { polygonArea, roomPoly, roomEdges, sharedBoundary, paperRoomShapes } from './logic';
export interface WallEntry {
key: string;
@@ -1206,6 +1206,113 @@ function closedRing(poly: number[][]): number[][][] {
return [ring];
}
interface ExteriorEnvelopeGeometry {
/** Union of room centrelines. Shared Split edges disappear from this shape. */
centre: any;
/** Wall shell generated only from the surviving exterior boundary. */
shell: any;
}
/** Open every ring of a polyclip MultiPolygon and drop its closing duplicate. */
function geometryRings(geom: any): number[][][] {
const out: number[][][] = [];
for (const polygon of Array.isArray(geom) ? geom : []) {
if (!Array.isArray(polygon)) continue;
for (const raw of polygon) {
if (!Array.isArray(raw) || raw.length < 4) continue;
const ring = raw.slice(0, -1).map((p: number[]) => [p[0], p[1]]);
if (ring.length >= 3) out.push(ring);
}
}
return out;
}
function pointOnSegment(p: number[], a: number[], b: number[], eps: number): boolean {
if (distToSeg(p[0], p[1], a[0], a[1], b[0], b[1]) > eps) return false;
const dx = b[0] - a[0], dy = b[1] - a[1];
const dot = (p[0] - a[0]) * dx + (p[1] - a[1]) * dy;
const len2 = dx * dx + dy * dy;
return dot >= -eps && dot <= len2 + eps;
}
/**
* Split a boolean-union boundary at every stored exterior interval endpoint.
* Polyclip is allowed to collapse a collinear child-room vertex; retaining the
* interval breakpoints is what preserves unequal wall depths on the two sides.
*/
function exteriorBoundaryProfile(
ring: number[][],
outer: WallInterval[],
eps: number,
): { poly: number[][]; offsets: number[] } | null {
const poly: number[][] = [];
const offsets: number[] = [];
for (let i = 0; i < ring.length; i++) {
const a = ring[i], b = ring[(i + 1) % ring.length];
const dx = b[0] - a[0], dy = b[1] - a[1];
const len2 = dx * dx + dy * dy;
if (!(len2 > eps * eps)) continue;
const cuts = [0, 1];
for (const iv of outer) {
for (const p of [iv.a, iv.b]) {
if (!pointOnSegment(p, a, b, eps)) continue;
const t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2;
if (t > eps && t < 1 - eps) cuts.push(t);
}
}
cuts.sort((x, y) => x - y);
const unique = cuts.filter((t, at) => at === 0 || Math.abs(t - cuts[at - 1]) > eps);
for (let at = 0; at < unique.length - 1; at++) {
const t0 = unique[at], t1 = unique[at + 1];
const p = [a[0] + dx * t0, a[1] + dy * t0];
const mid = [a[0] + dx * (t0 + t1) / 2, a[1] + dy * (t0 + t1) / 2];
let half = 0;
for (const iv of outer) {
if (pointOnSegment(mid, iv.a, iv.b, eps)) half = Math.max(half, iv.half);
}
poly.push(p);
offsets.push(half);
}
}
return poly.length >= 3 && offsets.length === poly.length ? { poly, offsets } : null;
}
/**
* Exterior masonry is derived from the union of room centrelines, not from
* each room independently. A Split edge therefore vanishes before mitres are
* built and cannot turn its artificial child corner into part of the facade.
*/
function exteriorEnvelopeGeometry(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
pitch: number,
cellCm: number,
gridPitch: number,
coordScale: number,
): ExteriorEnvelopeGeometry | null {
const polys = (rooms || []).map(roomPoly).filter((p): p is number[][] => !!p && p.length >= 3);
if (!polys.length) return null;
let centre: any = union(closedRing(polys[0]) as any);
for (let i = 1; i < polys.length; i++) centre = union(centre, closedRing(polys[i]) as any);
const outer = wallIntervals(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
).filter((iv) => iv.kind === 'outer' && iv.half > 0);
const eps = openEps(pitch, coordScale) * 4;
let shell: any = null;
for (const ring of geometryRings(centre)) {
const profile = exteriorBoundaryProfile(ring, outer, eps);
if (!profile || !profile.offsets.some((o) => o > 0)) continue;
const outset = outsetContour(profile.poly, profile.offsets);
const inset = insetContour(profile.poly, profile.offsets);
if (!outset || !inset) continue;
const piece = difference(closedRing(outset) as any, closedRing(inset) as any);
shell = shell ? union(shell, piece) : piece;
}
return { centre, shell: shell || [] };
}
function polyclipToPathD(geom: any): string {
if (!geom) return '';
let d = '';
@@ -1368,7 +1475,7 @@ export function wallBodiesGeometry(
gridPitch: number,
coordScale = 1,
extraBodies: number[][][] = [],
): { geom: any; depthUnits: number } | null {
): { geom: any; paperGeom: any; depthUnits: number } | null {
if (!walls?.length && !extraBodies.length) return null;
const roomRings: { outset: number[][]; inset: number[][] | null }[] = [];
let maxDepth = 0;
@@ -1404,17 +1511,54 @@ export function wallBodiesGeometry(
? openingWallIndex(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale)
: null;
try {
const exterior = exteriorEnvelopeGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
// Paper and masonry share this one structural pass. Renderers cache the
// returned pair, so a live HA state update never repeats exterior topology.
const paperGeom = exterior
? (exterior.shell?.length ? union(exterior.centre, exterior.shell) : exterior.centre)
: [];
const bodyOf = (ring: typeof roomRings[number]): any => {
const outset: any = closedRing(ring.outset);
return ring.inset ? difference(outset, closedRing(ring.inset) as any) : outset;
};
let body: any = roomRings.length ? bodyOf(roomRings[0]) : null;
for (let i = 1; i < roomRings.length; i++) body = union(body, bodyOf(roomRings[i]));
let body: any = null;
for (const ring of roomRings) {
try {
const piece = bodyOf(ring);
body = body ? union(body, piece) : piece;
} catch {
// An acute child contour may be invalid for boolean subtraction. The
// interval pass below still supplies its physical wall without letting
// the artificial mitre back into the exterior envelope.
}
}
// Per-room rings preserve established L/T/nested joins. Atomic quads are
// also included so a rejected acute child ring cannot remove a divider or
// an interior half-wall. Clipping them to the centre union gives a hard
// facade boundary; the canonical exterior shell is added afterwards.
if (exterior) {
for (const edge of wallEdgeBodies(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
)) {
try {
const piece = intersection(closedRing(edge.quad) as any, exterior.centre);
body = body ? union(body, piece) : piece;
} catch {
// A valid per-room ring may already own this interval. If neither
// representation is usable the final result fails closed below.
}
}
}
// The room-ring subtraction above cannot infer a mitre between real arms
// owned by different contours at a virtual T. Add only those missing
// junction pieces, then let physical openings cut through them as usual.
for (const patch of junctions)
body = body ? union(body, closedRing(patch) as any) : closedRing(patch);
if (body && exterior) body = intersection(body, exterior.centre);
if (exterior?.shell?.length)
body = body ? union(body, exterior.shell) : exterior.shell;
// cut opening tunnels (axis-aligned to opening angle)
for (const o of openings) {
if (!(o.length > 0)) continue;
@@ -1439,7 +1583,7 @@ export function wallBodiesGeometry(
if (extra.length < 3) continue;
body = body ? union(body, closedRing(extra) as any) : [closedRing(extra)];
}
return { geom: body || [], depthUnits: maxDepth };
return { geom: body || [], paperGeom, depthUnits: maxDepth };
} catch {
return null;
}
@@ -1457,31 +1601,19 @@ export function wallBodiesUnionPath(
/** Independent physical bodies are unioned only after room openings are cut,
* so a door/window/gate can never punch a coincident partition or column. */
extraBodies: number[][][] = [],
): { d: string; depthUnits: number; fillRule: 'evenodd' | 'nonzero' } | null {
): { d: string; paperD: string; depthUnits: number; fillRule: 'evenodd' | 'nonzero' } | null {
if (!walls?.length && !extraBodies.length) return null;
const united = wallBodiesGeometry(
rooms, walls, openCuts, openings, pitch, cellCm, gridPitch, coordScale, extraBodies,
);
const d = united ? polyclipToPathD(united.geom) : '';
if (united && d) return { d, depthUnits: united.depthUnits, fillRule: 'evenodd' };
const paperD = united ? polyclipToPathD(united.paperGeom) : '';
if (united && d) return { d, paperD, depthUnits: united.depthUnits, fillRule: 'evenodd' };
if (united) return null; // successful empty result: do not resurrect raw rings
// fall back to evenodd rings concatenated
const rings = wallBodyRings(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
const extraD = extraBodies.map((poly) => polyToPath(poly)).join(' ');
if (!rings.length && !extraD) return null;
// `united` is null on this branch: a successful-but-empty union returned
// above and must not be resurrected by the raw-ring fallback.
let maxDepth = 0;
for (const ring of rings) maxDepth = Math.max(maxDepth, ring.depthUnits);
// Each room ring already reverses its inset. `nonzero` therefore keeps
// floors as holes while overlapping independent rings add instead of
// cancelling one another (the old even-odd fallback produced pinholes at
// exactly the complex junctions for which a fallback is needed).
return {
d: [rings.map((r) => r.d).join(' '), extraD].filter(Boolean).join(' '),
depthUnits: maxDepth,
fillRule: 'nonzero',
};
// Fail closed. The old raw per-room-ring fallback is the exact algorithm
// that creates an exterior tooth at a corner Split, so resurrecting it after
// a boolean failure would make malformed input violate the facade invariant.
return null;
}
/**
@@ -1666,22 +1798,28 @@ export function paperRoomShapesWithWalls(
cellCm: number,
gridPitch: number,
coordScale = 1,
): Array<{ poly: string } | { rect: { x: number; y: number; w: number; h: number; rx: number } }> {
const out: Array<{ poly: string } | { rect: { x: number; y: number; w: number; h: number; rx: number } }> = [];
for (const r of rooms || []) {
const poly = roomPoly(r);
if (poly && poly.length >= 3) {
const pr = roomWallProfile(rooms, r.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
const grown = pr && pr.offsets.some((o) => o > 0)
? outsetContour(pr.poly, pr.offsets)
: null;
const use = grown || poly;
out.push({ poly: use.map((p) => p.join(',')).join(' ') });
} else if (r && r.x != null && r.y != null && r.w != null && r.h != null) {
out.push({ rect: { x: r.x, y: r.y, w: r.w, h: r.h, rx: Math.min(r.w, r.h) * 0.03 } });
): Array<
| { path: string }
| { poly: string }
| { rect: { x: number; y: number; w: number; h: number; rx: number } }
> {
if (!walls?.length) return paperRoomShapes(rooms);
try {
const exterior = exteriorEnvelopeGeometry(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
);
if (exterior) {
const paper = exterior.shell?.length
? union(exterior.centre, exterior.shell)
: exterior.centre;
const path = polyclipToPathD(paper);
if (path) return [{ path }];
}
} catch {
// Safe fallback below: exact room centrelines never reproduce the known
// exterior Split spike, even when boolean offsetting rejected bad input.
}
return out;
return paperRoomShapes(rooms);
}
interface OpeningWallEdge {
+20 -1
View File
@@ -71,7 +71,7 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
'tray-narrow', 'opaque-glow-two-doorways', 'filled-tunnel', 'opening-placement',
'backup-full', 'backup-space', 'value-badge-positions', 'isometric-geometry',
'isometric-live-layers', 'isometric-no-borders', 'isometric-touch-kiosk',
'isometric-large-warm-remount'])
'isometric-large-warm-remount', 'split-corner-wall'])
assert.equal(ids.includes(token), true, token);
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.mode)).has('plan'), true);
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.mode)).has('devices'), true);
@@ -80,6 +80,25 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.theme)).has('dark'), true);
});
test('corner Split golden captures before, thin and thick facade states', () => {
const scenarios = GOLDEN_SCENARIOS.filter((scenario) => scenario.cornerSplitWall);
assert.deepEqual(scenarios.map((scenario) => scenario.cornerSplitWall), ['before', 'thin', 'thick']);
for (const scenario of scenarios) {
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.ok(space);
assert.equal(space.settings.show_borders, true);
assert.equal(space.rooms.length, scenario.cornerSplitWall === 'before' ? 1 : 2);
if (scenario.cornerSplitWall !== 'before') {
const divider = space.walls.find((wall) => (
wall.a?.[0] === 0.10 && wall.a?.[1] === 0.10
&& wall.b?.[0] === 0.90 && wall.b?.[1] === 0.50
));
assert.equal(divider?.cm, scenario.cornerSplitWall === 'thin' ? 15 : 100);
}
}
});
test('filled opening golden has a pixel-level seam detector', () => {
const scenario = GOLDEN_SCENARIOS.find((item) => item.id === 'openings-filled-tunnel-dark');
assert.ok(scenario);
+176 -5
View File
@@ -6,7 +6,7 @@ import {
setWallThickness, setWallThicknessForRoom, applyWallThicknessToNewRoom,
drawWallPreviewD, DRAW_WALL_DEFAULT_CM, clampWallCm, cmToField, fieldToCm,
wallCmToUnits, insetContour, inwardNormal, edgeKinds, wallEdgeBodies,
wallBodyRings, wallBodiesUnionPath, innerContourForRoom,
wallBodyRings, wallBodiesGeometry, wallBodiesUnionPath, innerContourForRoom,
paperRoomShapesWithWalls, WALL_MIN_CM, WALL_MAX_CM, MITRE_LIMIT,
atomicPolyForRoom, insetOffsetsForRoom, wallIntervals, materializeWallIntervals,
normalizeWallIntervals,
@@ -14,8 +14,10 @@ import {
openingTunnelGeometries, tunnelFacePath,
WALL_HATCH_MIN_PX,
} from '../test-build/wall-thickness.js';
import { polygonArea, paperRoomShapes } from '../test-build/logic.js';
import { polygonArea, paperRoomShapes, splitRoomPath, sharedBoundary } from '../test-build/logic.js';
import { GRID_PITCH } from '../test-build/space-geometry.js';
import { geometryArea } from '../test-build/physical-geometry.js';
import { difference, union } from 'polyclip-ts';
const closeTo = (got, want, tol = 1e-6) =>
assert.ok(Math.abs(got - want) <= tol, `expected ${want}, got ${got}`);
@@ -23,6 +25,59 @@ const closeTo = (got, want, tol = 1e-6) =>
const pitch = 1 / 240; // normalised grid step
const cellCm = 5;
const closedGeometry = (poly) => {
const ring = [...poly, poly[0]].map((point) => [...point]);
return [[ring]];
};
const geometryBounds = (geom) => {
const points = geom.flat(2);
return [
Math.min(...points.map((point) => point[0])),
Math.min(...points.map((point) => point[1])),
Math.max(...points.map((point) => point[0])),
Math.max(...points.map((point) => point[1])),
];
};
const geometryDifferenceArea = (a, b) => geometryArea(difference(a, b));
function cornerSplitFixture({
poly = [[100, 100], [900, 100], [900, 700], [100, 700]],
path = [[100, 100], [900, 500]],
outerCm = 15,
dividerCm = 15,
outerOverrides = [],
} = {}) {
const original = { id: 'source', poly: poly.map((point) => [...point]) };
const split = splitRoomPath(original.poly, path);
assert.ok(split, 'fixture must be a valid corner split');
let walls = outerCm > 0
? applyWallThicknessToNewRoom([], [original], original.id, outerCm, pitch)
: [];
for (const [a, b, cm] of outerOverrides)
walls = setWallThickness(walls, a, b, cm, pitch);
const before = walls.length
? wallBodiesGeometry([original], walls, [], [], pitch, cellCm, GRID_PITCH)
: null;
walls = materializeWallIntervals([original], walls, [], pitch, cellCm, GRID_PITCH);
const rooms = [
{ id: 'source', poly: split[0] },
{ id: 'fresh', poly: split[1] },
];
const divider = sharedBoundary(rooms[0].poly, rooms[1].poly);
assert.equal(divider.length, 1);
walls = setWallThickness(
walls, divider[0].slice(0, 2), divider[0].slice(2), dividerCm, pitch,
);
walls = normalizeWallIntervals(rooms, walls, [], pitch, cellCm, GRID_PITCH);
const after = wallBodiesGeometry(rooms, walls, [], [], pitch, cellCm, GRID_PITCH);
assert.ok(after, `wall geometry missing for outer=${outerCm}, divider=${dividerCm}`);
return { original, rooms, walls, before, after };
}
// ------------------------------- key ----------------------------------------
test('wallKey is the same from either end of the wall', () => {
@@ -687,6 +742,123 @@ test('wallBodiesUnionPath: a parent floor never erases a nested room wall', () =
assert.ok((united.d.match(/M/g) || []).length >= 4, united.d);
});
test('corner Split keeps the original exterior wall body and paper', () => {
const { original, rooms, walls, before, after } = cornerSplitFixture();
assert.ok(before);
assert.deepEqual(geometryBounds(after.geom), geometryBounds(before.geom));
const centre = closedGeometry(original.poly);
const beforeExterior = difference(before.geom, centre);
const afterExterior = difference(after.geom, centre);
closeTo(geometryDifferenceArea(beforeExterior, afterExterior), 0, 1e-7);
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
closeTo(geometryDifferenceArea(before.paperGeom, after.paperGeom), 0, 1e-7);
closeTo(geometryDifferenceArea(after.paperGeom, before.paperGeom), 0, 1e-7);
const paper = paperRoomShapesWithWalls(
rooms, walls, [], pitch, cellCm, GRID_PITCH,
);
assert.equal(paper.length, 1);
assert.ok('path' in paper[0]);
const nums = paper[0].path.match(/-?\d+(?:\.\d+)?/g).map(Number);
const paperPoints = [];
for (let i = 0; i < nums.length; i += 2) paperPoints.push([nums[i], nums[i + 1]]);
assert.deepEqual(geometryBounds([[paperPoints]]), geometryBounds(before.geom));
const canonical = wallBodiesUnionPath(
rooms, walls, [], [], pitch, cellCm, GRID_PITCH,
);
assert.ok(canonical?.paperD, 'canonical render pass must include its paper path');
assert.equal(canonical.paperD, paper[0].path);
});
test('corner Split clips every divider thickness when exterior walls are absent', () => {
for (const dividerCm of [1, 15, 100]) {
const { original, after } = cornerSplitFixture({ outerCm: 0, dividerCm });
closeTo(geometryArea(difference(after.geom, closedGeometry(original.poly))), 0, 1e-7);
}
});
test('corner Split preserves the facade for thin and thick outer/divider matrices', () => {
for (const outerCm of [1, 15, 100]) {
for (const dividerCm of [0, 1, 15, 100]) {
const { original, before, after } = cornerSplitFixture({ outerCm, dividerCm });
assert.ok(before);
const centre = closedGeometry(original.poly);
const beforeExterior = difference(before.geom, centre);
const afterExterior = difference(after.geom, centre);
closeTo(geometryDifferenceArea(beforeExterior, afterExterior), 0, 1e-7);
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
}
}
});
test('corner Split keeps unequal exterior arms and is order/id/winding independent', () => {
const fixture = cornerSplitFixture({
outerOverrides: [
[[100, 100], [900, 100], 5],
[[100, 700], [100, 100], 40],
],
dividerCm: 100,
});
const shuffled = fixture.rooms
.map((room, at) => ({ id: `renamed-${at}`, poly: [...room.poly].reverse() }))
.reverse();
const permuted = wallBodiesGeometry(
shuffled, fixture.walls, [], [], pitch, cellCm, GRID_PITCH,
);
assert.ok(permuted);
closeTo(geometryDifferenceArea(fixture.after.geom, permuted.geom), 0, 1e-7);
closeTo(geometryDifferenceArea(permuted.geom, fixture.after.geom), 0, 1e-7);
const centre = closedGeometry(fixture.original.poly);
const beforeExterior = difference(fixture.before.geom, centre);
const afterExterior = difference(fixture.after.geom, centre);
closeTo(geometryDifferenceArea(beforeExterior, afterExterior), 0, 1e-7);
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
});
test('Split from a concave vertex does not turn the child mitre into facade', () => {
const poly = [[100, 100], [900, 100], [900, 800], [600, 800], [600, 400], [100, 400]];
const fixture = cornerSplitFixture({ poly, path: [[600, 400], [900, 250]], dividerCm: 100 });
const centre = closedGeometry(poly);
const beforeExterior = difference(fixture.before.geom, centre);
const afterExterior = difference(fixture.after.geom, centre);
closeTo(geometryDifferenceArea(beforeExterior, afterExterior), 0, 1e-7);
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
});
test('Split with both endpoints at exterior vertices preserves both corners', () => {
const fixture = cornerSplitFixture({ path: [[100, 100], [900, 700]], dividerCm: 100 });
const centre = closedGeometry(fixture.original.poly);
const beforeExterior = difference(fixture.before.geom, centre);
const afterExterior = difference(fixture.after.geom, centre);
closeTo(geometryDifferenceArea(beforeExterior, afterExterior), 0, 1e-7);
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
});
test('corner Split clean floors are exactly the room union minus canonical walls', () => {
const fixture = cornerSplitFixture({ dividerCm: 100 });
const floors = fixture.rooms.map((room) => innerContourForRoom(
fixture.rooms, room.id, fixture.walls, [], pitch, cellCm, GRID_PITCH,
));
assert.ok(floors.every(Boolean));
const actual = union(...floors.map((floor) => closedGeometry(floor)));
const expected = difference(closedGeometry(fixture.original.poly), fixture.after.geom);
closeTo(geometryDifferenceArea(actual, expected), 0, 1e-7);
closeTo(geometryDifferenceArea(expected, actual), 0, 1e-7);
});
test('corner Split rendering does not materialize or mutate saved geometry', () => {
const fixture = cornerSplitFixture({ dividerCm: 100 });
const rooms = structuredClone(fixture.rooms);
const walls = structuredClone(fixture.walls);
const before = JSON.stringify({ rooms, walls });
assert.ok(wallBodiesGeometry(rooms, walls, [], [], pitch, cellCm, GRID_PITCH));
assert.ok(paperRoomShapesWithWalls(rooms, walls, [], pitch, cellCm, GRID_PITCH).length);
assert.equal(JSON.stringify({ rooms, walls }), before);
});
test('paper with walls covers shared centreline; without walls matches paperRoomShapes', () => {
const rooms = [
{ id: 'a', poly: [[0, 0], [5, 0], [5, 4], [0, 4]] },
@@ -698,9 +870,8 @@ test('paper with walls covers shared centreline; without walls matches paperRoom
const walls = [{ key: wallKey([5, 0], [5, 4], pitch), cm: 20 }];
const grown = paperRoomShapesWithWalls(rooms, walls, [], pitch, cellCm, pitch);
assert.equal(grown.length, 2);
// grown polys are still present (strings)
assert.ok('poly' in grown[0]);
assert.equal(grown.length, 1);
assert.ok('path' in grown[0], 'wall-aware paper is one canonical union path');
});
test('area of the room polygon is unchanged by thickness helpers', () => {