mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc17109249 | ||
|
|
e79f8f5aa1 | ||
|
|
024a1accd8 | ||
|
|
47c6f10a9d | ||
|
|
52ec0fb54f | ||
|
|
bcd280afb9 | ||
|
|
ba56d4f768 |
File diff suppressed because one or more lines are too long
+29
-1
@@ -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}`);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
+186
-186
File diff suppressed because one or more lines are too long
Vendored
+186
-186
File diff suppressed because one or more lines are too long
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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) |
|
||||
|
||||
@@ -304,6 +304,9 @@ desktop: для точного рисования, Resize, модификато
|
||||
- Промежуточные точки должны идти внутри комнаты и не пересекать контур или сам разрез.
|
||||
- Большая по площади часть сохраняет исходную комнату и устройства.
|
||||
- Для меньшей части открывается диалог новой комнаты.
|
||||
- Разрез можно начать или закончить точно в существующем углу. Наружная форма
|
||||
дома при этом не меняется: общая стена двух новых комнат примыкает к фасаду
|
||||
только изнутри, даже если она толще наружной стены.
|
||||
|
||||
### Resize
|
||||
|
||||
|
||||
+16
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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, а этот
|
||||
дефект найден на этапе код-ревью, не пре-релиза).
|
||||
@@ -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-снимка побайтно
|
||||
идентичны, трейлеры и процесс-гейт зелёные.
|
||||
@@ -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», а фиксированное решение ревьюера: можно поправить или
|
||||
отклонить без нового цикла).
|
||||
@@ -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 не относятся к изменяемым предположениям.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user