Compare commits

...
Author SHA1 Message Date
claude[bot] 54d90efc80 docs: review document for #172
Issue: #172
User-Visible: no
2026-08-18 15:05:44 +00:00
claude[bot]andSergey Matyunin eeb9c34825 docs: review document for #172
Issue: #172
User-Visible: no
2026-08-18 17:53:14 +03:00
Sergey Matyunin c2112db5ab fix: cap zero-depth divider joins
Issue: #172
User-Visible: yes
2026-08-18 17:53:14 +03:00
claude[bot]andSergey Matyunin 56834c5d46 docs: review document for #172
Issue: #172
User-Visible: no
2026-08-18 17:52:16 +03:00
Sergey Matyunin 93f86f8321 docs(spec): define zero divider taper fix
Issue: #172
User-Visible: no
2026-08-18 17:52:15 +03:00
17 changed files with 1338 additions and 12 deletions
File diff suppressed because one or more lines are too long
+29 -1
View File
@@ -55,8 +55,36 @@ export function prepareGoldenFixture(scenario) {
const fixture = fixtureFor(scenario);
if (scenario.cornerSplitWall) {
const stage = scenario.cornerSplitWall;
if (!['before', 'thin', 'thick'].includes(stage))
if (!['before', 'thin', 'thick', 'zero-taper'].includes(stage))
throw new Error(`unknown cornerSplitWall stage: ${stage}`);
if (stage === 'zero-taper') {
const a = [0.10, 0.10], tr = [0.90, 0.10], split = [0.90, 0.405];
const br = [0.90, 0.80], notchBottom = [0.60, 0.80];
const notch = [0.60, 0.40], bl = [0.10, 0.40];
const entry = (from, to, cm) => ({
key: fixtureWallKey(from, to), a: [...from], b: [...to], cm,
});
fixture.config.spaces.push({
id: scenario.space,
name: 'Zero-depth angled Split',
rooms: [
{ id: 'zero-divider-main', name: 'Main room', area: null,
poly: [a, tr, split, notch, bl] },
{ id: 'zero-divider-child', name: 'New room', area: null,
poly: [split, br, notchBottom, notch] },
],
walls: [
entry(a, tr, 15), entry(tr, split, 15), entry(split, br, 15),
entry(br, notchBottom, 15), entry(notchBottom, notch, 15),
entry(notch, bl, 15), entry(bl, a, 15),
],
settings: {
show_borders: true, show_names: false,
fill_mode: 'custom', custom_fill: { c: '#536b82', a: 0.42 },
},
});
return fixture;
}
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) => ({
+3 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 24;
export const GOLDEN_MATRIX_VERSION = 25;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -18,6 +18,8 @@ export const GOLDEN_SCENARIOS = Object.freeze([
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: 'split-zero-divider-taper-dark', fixture: 'visual', space: 'golden-zero-divider',
cornerSplitWall: 'zero-taper', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-geometry-view-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
// Stage 2 material/floor-edge plus door, window, gate and nested-room coverage.
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
+167
View File
@@ -0,0 +1,167 @@
/** Issue #172: a slightly angled zero-depth Split stays free of masonry. */
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 samePoint = (a, b, epsilon = 0.02) => (
Math.hypot(a[0] - b[0], a[1] - b[1]) <= epsilon
);
const sharedEdge = (rooms) => {
for (let ai = 0; ai < rooms[0].poly.length; ai++) {
const a0 = rooms[0].poly[ai], a1 = rooms[0].poly[(ai + 1) % rooms[0].poly.length];
for (let bi = 0; bi < rooms[1].poly.length; bi++) {
const b0 = rooms[1].poly[bi], b1 = rooms[1].poly[(bi + 1) % rooms[1].poly.length];
if (samePoint(a0, b1) && samePoint(a1, b0)) return [a0[0], a0[1], a1[0], a1[1]];
}
}
return null;
};
const pointInRing = (point, ring) => {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
const a = ring[i], b = ring[j];
if ((a[1] > point[1]) !== (b[1] > point[1])
&& point[0] < ((b[0] - a[0]) * (point[1] - a[1])) / (b[1] - a[1]) + a[0])
inside = !inside;
}
return inside;
};
const pointInGeometry = (point, geometry) => (geometry || []).some((polygon) => (
polygon?.[0] && pointInRing(point, polygon[0])
&& !polygon.slice(1).some((hole) => pointInRing(point, hole))
));
const a = [0.10, 0.10], tr = [0.90, 0.10], br = [0.90, 0.80];
const notchBottom = [0.60, 0.80], notch = [0.60, 0.40], bl = [0.10, 0.40];
const outer = [
entry(a, tr, 15), entry(tr, br, 15), entry(br, notchBottom, 15),
entry(notchBottom, notch, 15), entry(notch, bl, 15), entry(bl, a, 15),
];
const space = sp();
space.settings = { ...(space.settings || {}), show_borders: true };
space.rooms = [{
id: 'zero-divider-source', name: 'Zero divider source', area: null,
poly: [a, tr, br, notchBottom, notch, bl],
}];
space.walls = outer;
delete space.open_spans;
delete space.openings;
delete space.partitions;
delete space.room_drafts;
delete space.wall_columns;
c._setMode('plan');
c._tool = 'split';
await update();
c._splitClick([700, 250]);
c._splitClick([600, 400]);
c._splitClick([900, 405]);
out.realSplitReachedDialog = !!c._pendingSplit && !!c._roomDialog;
c._nameSel = 'Zero divider child';
c._commitRoom();
await update();
const rooms = c._spaceModel().rooms;
const divider = rooms.length === 2 ? sharedEdge(rooms) : null;
out.realSplitSaved = rooms.length === 2 && !c._pendingSplit;
out.dividerFound = !!divider;
out.anglePreserved = !!divider && Math.abs(divider[3] - divider[1]) > 1;
out.dividerStaysZero = !!divider && c._intervalCm(divider) === 0;
const samples = [];
if (divider) {
const [x0, y0, x1, y1] = divider;
const dx = x1 - x0, dy = y1 - y0;
const length = Math.hypot(dx, dy);
const nx = -dy / length, ny = dx / length;
for (const t of [0.2, 0.35, 0.5, 0.65, 0.8]) {
for (const side of [-1, 1])
samples.push([x0 + dx * t + nx * side * 2, y0 + dy * t + ny * side * 2]);
}
}
const wall = sr().querySelector('.wallbody');
out.planHasNoTaper = !!wall && samples.length > 0
&& samples.every((point) => !wall.isPointInFill(new DOMPoint(...point)));
const planD = wall?.getAttribute('d') || '';
out.planUsesCanonicalBody = !!planD && c._wallUnionGeometry()?.d === planD;
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 masonry = c._lightBarriers(lightSpace, lightPolys, lightPhysical).masonryGeometry;
out.lightHasNoTaper = samples.every((point) => !pointInGeometry(point, masonry));
const persisted = JSON.stringify({ rooms: space.rooms, walls: space.walls });
c._setMode('view');
await update();
out.planViewParity = sr().querySelector('.wallbody')?.getAttribute('d') === 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'));
if (typeof c._onLabsSnapshot !== 'function') throw new Error('missing Labs fixture hook');
c._onLabsSnapshot({ active: Object.freeze(['iso']), space: '' });
await c.updateComplete;
c._setProjection('iso');
await update();
out.isoUsesCanonicalBody = !!sr().querySelector('.iso-walls .iso-wall-top')
&& c._isoSource().build().walls.flat(2).length > 0;
c._setProjection('flat');
await update();
await customElements.whenDefined('houseplan-space-card');
const cfg = structuredClone(c._serverCfg);
const baseCall = c.hass.callWS.bind(c.hass);
const staticCard = document.createElement('houseplan-space-card');
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: c._space, show_button: false });
staticCard.hass = { ...c.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
if (message.type === 'houseplan/layout/get') return { layout: c._layout || {}, rev: 1 };
return baseCall(message);
} };
document.body.appendChild(staticCard);
const started = Date.now();
while (!staticCard.renderRoot?.querySelector('.wallbody') && Date.now() - started < 6000)
await new Promise((resolve) => setTimeout(resolve, 60));
await staticCard.updateComplete;
out.staticParity = staticCard.renderRoot?.querySelector('.wallbody')?.getAttribute('d') === planD;
staticCard.remove();
out.renderDoesNotRewriteConfig = JSON.stringify({ rooms: space.rooms, walls: space.walls }) === persisted;
return out;
});
checkAll(res);
await finish(browser, res);
File diff suppressed because one or more lines are too long
+2 -2
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -7,6 +7,11 @@
depth up to the divider without leaking onto an adjacent zero-thickness
facade; Plan, View, static, hidden isometric and light geometry share the
corrected outline ([#150](https://github.com/Matysh/houseplan-card/issues/150)).
- A zero-thickness Split divider no longer borrows a triangular wedge from an
adjoining thick wall when the cut is slightly angled. The divider stays
visually open along its full length while the real wall ends in a local cap,
with the same geometry in Plan, View, Static, hidden isometric and lighting
([#172](https://github.com/Matysh/houseplan-card/issues/172)).
- Home Assistant devices and individual entities manually assigned to a room
without an HA Area now stay in that exact room instead of returning to their
registry Area. Existing saved assignments recover automatically, and
+6
View File
@@ -14,6 +14,12 @@
View, статическая и скрытая изометрическая геометрия, а также свет используют
один исправленный контур
([#150](https://github.com/Matysh/houseplan-card/issues/150)).
- Нулевой разделитель после «Разделить» больше не получает треугольный клин от
примыкающей толстой стены, если разрез нарисован под небольшим углом.
Разделитель остаётся визуально открытым по всей длине, а настоящая стена
заканчивается локальным торцом одинаково в Плане, View, Static, скрытой
изометрии и световой геометрии
([#172](https://github.com/Matysh/houseplan-card/issues/172)).
- Устройства и отдельные сущности Home Assistant, вручную назначенные комнате
без HA-зоны, теперь остаются именно в ней, а не возвращаются в исходную зону
из реестра. Уже сохранённые назначения восстанавливаются автоматически, а
+8
View File
@@ -74,6 +74,14 @@ thinner than 3 CSS px on screen, the shared full/static render policy suppresses
only the hatch so it does not collapse into noise; the solid fill remains. Mitre
joins; bevel when the mitre spike exceeds `MITRE_LIMIT × thickness`.
A variable-offset join where exactly one adjacent edge has zero depth is a
local flat cap, not a mitre. Both `inset` and `outset` retain the physical
edge's offset point followed by the untouched zero-edge vertex (or the reverse
order when entering the physical edge). This keeps a zero-depth Split free of
masonry even when it meets a thick wall at a slightly non-collinear angle;
the cap cannot stretch into a taper along the divider. Joins between two
positive depths keep the bounded mitre/bevel contract above.
Independent draft/partition segments keep flat raw quads for editor identity,
but exact endpoint↔endpoint and endpoint↔line nodes add computed join patches
before the presentation union. Each incident ray keeps its own half-depth;
+168
View File
@@ -0,0 +1,168 @@
# Code review #172 — r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/172
- **Spec:** `docs/specs/172-zero-divider-taper.md`, зелёное `SPEC-REVIEW-172-r1.md`
- **Reviewed branch:** `issue/172-zero-divider-taper`
- **Reviewed range:** `origin/dev..HEAD` = `4582628` (spec) → `c2c9c8b` (spec review doc)
→ `dfd56e8` (fix)
- **Base:** `origin/dev` at `c27185c`
- **Reviewer:** Claude, независимая сессия без контекста реализации
## Вердикт
**Зелёный · цикл r1/4 · High: 0 · Medium: 0.**
Дефект из #172 устранён в общей geometry-функции, подтверждён исполняемыми
юнит- и browser-smoke тестами, которые я лично прогнал и проверил на
способность падать (временный откат фикса красит именно новые проверки).
Все 11 AC закрыты — либо автотестом, либо чтением кода с явной пометкой. Две
находки Low сняты в этом документе без блокировки.
## Скоуп
Единственный продуктовый файл — `src/wall-thickness.ts`: `insetContour()` и
`outsetContour()` получили симметричную ветку «локальный cap», которая
перехватывает переход `положительный offset ↔ ровно нулевой offset` до общей
mitre/bevel-логики и явно сохраняет обе точки (offset-точку толстой грани и
нетронутую вершину нулевой грани), не позволяя bevel-ветке отбрасывать вершину
нулевой грани и растягивать клин вдоль всего разделителя.
Сопутствующие изменения: `test/wall-thickness.test.mjs` (два новых теста),
`demo/smoke_zero_divider_taper.mjs` (новый, реальный Split через UI),
`demo/golden/{harness,matrix}.mjs` + `test/golden-matrix.test.mjs` (новая
визуальная сцена, `GOLDEN_MATRIX_VERSION` 24→25, baseline сознательно не
принят), `docs/WALL-THICKNESS.md` §3 (задокументирован контракт cap),
`docs/CHANGELOG.md` / `docs/CHANGELOG.ru.md`, три синхронные копии бандла.
Ровно один продуктовый коммит `dfd56e8`, трейлеры `Issue: #172` /
`User-Visible: yes` на месте, оба changelog в том же коммите. Ветка
`issue/172-zero-divider-taper` соответствует правилу именования.
## Как проверялось
| Гейт | Результат |
|---|---|
| `npx tsc --noEmit` | pass |
| `npm test` | **830/830 pass** (`npm run inventory` подтверждает канонический счётчик) |
| `npm run build` + сверка трёх копий бандла | pass, все три `sha256` = `656c68df53108…34a181`, совпадает с закоммиченным |
| Откат двух новых веток в `insetContour`/`outsetContour` и повторный `npm test` | **2 новых теста красные** (`variable-offset contours keep a local cap…`, `near-collinear zero-depth Split divider never grows a masonry taper`), остальные 828 зелёные — дисциплина «тест умеет падать» подтверждена мной, а не только автором |
| `node demo/smoke_zero_divider_taper.mjs` (назван в AC6/AC7) | pass, все 13 полей `true`; повторно собрал бандл **без** фикса и прогнал тот же smoke — `planHasNoTaper` и `lightHasNoTaper` красные, с фиксом — зелёные |
| `node demo/smoke_split_corner_wall.mjs` (смежная поверхность: mitre/bevel для двух положительных толщин, AC3/AC9) | pass |
| `node demo/smoke_wall_thickness.mjs` (смежная поверхность: общий рендер стен/проёмов) | pass |
| `node demo/smoke_wall_junctions.mjs` (смежная поверхность: T/L-стыки через ту же join-логику) | pass |
| Ручная проверка AC4 (clean-floor invariant) на точном fixture AC1 через `innerContourForRoom` + `wallBodiesGeometry`, повторённая для `0°`, `0,477°`, `0,955°`, `-0,955°` | остаточная площадь ≈0,011% от общей (39 из ~340 527 единиц), одинаковая на точном `0°` и под углом — не фикс-специфичный дефект, а ранее существующий квантование-эпсилон atomic-геометрии |
### Не прогонялось, и почему
- **`npm run golden:verify`.** Диффа в `demo/golden/harness.mjs`/`matrix.mjs`
добавляет новую сцену `split-zero-divider-taper-dark` без baseline —
`verify` по контракту (`demo/golden/README.md`) обязан упасть на
отсутствующем эталоне независимо от корректности геометрии. AC8 сознательно
откладывает принятие baseline на предрелизный Linux-гейт
(`golden:accept -- --reviewed`), это прямо написано в ТЗ §12 и в хендоффе.
Локальный прогон дал бы только ожидаемый «missing baseline» без новой
информации; риск регрессии существующих сцен уже закрыт тремя целевыми
smoke-тестами и полным юнит-регрессом на той же общей geometry-функции.
- **`python -m pytest tests_backend`.** Ни один файл `custom_components/**/*.py`
не тронут.
- **Performance-профили.** Не названы в AC; спецификация явно фиксирует, что
прирост вершин ограничен одной точкой на переход и не меняет асимптотику;
диффа в hot-path выше единичного `if`-ветвления нет.
- **Полный набор из 136 browser-smoke.** Задача касается одной геометрической
функции с точечным изменением контракта; прогнаны названный в AC смок плюс
три смежных (corner-split, общая толщина стен, T/L-стыки) — поверхности,
которые используют ту же `insetContour`/`outsetContour`. Остальные 132 смока
не относятся к затронутой геометрии (проёмы без стен, Glow-специфика без
стен, UI-хром и т.д.).
## Проверка AC1–AC11
| AC | Метод по ТЗ | Статус | Как закрыт |
|---|---|---|---|
| AC1 | unit | ✅ | Новый тест на точном fixture §3 (`0,477°`/`0,955°`); я подтвердил, что он красный на исходном (без фикса) коде |
| AC2 | unit | ✅ | Тот же тест — матрица `h=1/15/100`, углы по обе стороны, winding-перестановка; изолированный тест `insetContour`/`outsetContour` покрывает обе последовательности `h→0`/`0→h` напрямую на примитиве |
| AC3 | unit | ✅ | Полный регресс (830/830) не покраснел; точный коллинеарный шаг и пары `1↔15`, `15↔100` — существующие тесты остались зелёными; `smoke_split_corner_wall.mjs` подтверждает facade для 0/15/100 см |
| AC4 | unit | ✅ (низкая находка, см. ниже) | Отдельного теста именно для AC1-fixture нет; я исполнил тот же helper-pipeline, что и существующий тест clean-floor (строка 936), на fixture с углом — расхождение ≈0,011%, идентичное точному `0°`, то есть не связано с фиксом |
| AC5 | unit | ✅ (чтением) | `splitRoomPath()` не тронута этим диффом; тест «rendering does not materialize or mutate saved geometry» плюс smoke-поля `anglePreserved`/`renderDoesNotRewriteConfig` эмпирически подтверждают отсутствие snap и мутации конфигурации |
| AC6 | smoke | ✅ | `node demo/smoke_zero_divider_taper.mjs` — реальный Split из вогнутого угла под ~1°, `dividerStaysZero`, `planHasNoTaper` = true |
| AC7 | smoke | ✅ | Тот же smoke: `planUsesCanonicalBody`, `planViewParity`, `kioskParity`, `isoUsesCanonicalBody`, `staticParity`, `lightHasNoTaper`, `renderDoesNotRewriteConfig` — все true |
| AC8 | golden | ✅ (отложено по контракту) | Сцена добавлена в матрицу (v25), `test/golden-matrix.test.mjs` проверяет её состав; baseline не принят — так и требуется до предрелизного Linux-гейта |
| AC9 | unit+smoke | ✅ | 830/830 + три смежных smoke зелёные, независимые тела и проёмы не меняются |
| AC10 | код-ревью | ✅ | Прочитан диф: правка только в общей variable-offset геометрии `wall-thickness.ts`, ни одного renderer-specific ветвления, схема данных не тронута |
| AC11 | код-ревью | ✅ | Прочитан диф: новых DOM-узлов/событий/таймеров/сетевых вызовов/HA-сервисов нет |
## Находки
### Low-1 — AC4 не имеет отдельного исполняемого теста на fixture из АК1
Спецификация назначила AC4 методом `unit`, но фактический diff теста
(`test/wall-thickness.test.mjs`) не содержит проверки clean-floor invariant
именно для near-collinear нулевого разделителя — только для
`dividerCm: 100` (существующий тест до этой задачи). Я закрыл разрыв
самостоятельно: исполнил `innerContourForRoom` + `wallBodiesGeometry` по тому
же fixture, что и AC1 (`[600,400]→[900,405]` и соседние углы), и получил
устойчивое расхождение ≈39 единиц из ~340 527 (≈0,011%) — идентичное значению
на точном `0°`. Поскольку расхождение не зависит от угла и воспроизводится
даже без него, это ранее существующий квантование-артефакт atomic-геометрии,
а не то, что фикс должен был закрыть и не закрыл.
**Вердикт:** снимается без правки. Инвариант AC4 подтверждён мной прямым
исполнением (не только чтением), отдельный тест на будущее не обязателен —
дальнейшее покрытие этой границы можно adресовать при следующей правке той же
области, если она понадобится.
### Low-2 — неверная ссылка на процесс в хендоффе при пропуске named-smoke
Хендофф реализации указывает, что `node demo/smoke_zero_divider_taper.mjs`,
golden и performance не запускались «по принятому implementation loop
(`PROCESS.md §11.4`)». §11.4 — это исключение для починки упавших
предрелизных гейтов после `S8-merged`, а не основание пропускать smoke,
названный в AC, перед выходом в `S7-code-review`. Правило, которое реально
требует прогона таких smoke локально, — это правка `AGENTS.md` (issue #151,
раздел «Гейты»): «перед переводом issue в `S7-code-review` — прогнать смоки,
названные в её AC, локально». Golden и performance действительно откладываются
на предрелиз (это верно и без §11.4), но smoke-тест из AC6/AC7 — нет.
**Вердикт:** снимается без правки для этого цикла. Я прогнал
`demo/smoke_zero_divider_taper.mjs` сам (см. таблицу гейтов) — тест зелёный и
подтверждённо умеет падать без фикса, так что риска для этого issue нет.
Отмечаю только неточность цитаты в хендоффе на будущее.
## Что проверено и корректно
- Правка симметрична для `insetContour()`/`outsetContour()`, порядок точек
(offset-точка физической грани → нетронутая вершина нулевой грани)
детерминирован для inset и outset независимо.
- Для точного коллинеарного перехода `h↔0` новая ветка производит тот же
результат, что и старая `collinearJoint`-ветка (при `oB=0` смещение `nB*0`
вырождается в исходную вершину) — регресс на этот случай логически
исключён и эмпирически подтверждён (830/830, включая существующий тест на
точную ступень).
- Переход двух положительных offset'ов не затронут: новая ветка
активируется только при строгом `(oA>0) !== (oB>0)`, что подтверждено и
чтением кода, и тремя зелёными смежными smoke.
- Локальный cap ограничен физической half-depth и не растёт пропорционально
длине разделителя — подтверждено тестом с полосой сэмплирования,
масштабируемой от `halfDepth`, для `outerCm ∈ {1,15,100}`.
- Симметрия относительно порядка комнат и winding подтверждена перестановочным
тестом (reversed room order + reversed polygon winding, area-diff = 0).
- Один канонический источник геометрии для Plan/View/kiosk/static/hidden-Iso и
light-барьеров подтверждён smoke-полями `planViewParity`, `kioskParity`,
`staticParity`, `isoUsesCanonicalBody`, `lightHasNoTaper`.
- Рендер не мутирует `rooms`/`walls` (`renderDoesNotRewriteConfig: true`,
плюс существующий unit-тест).
- Трейлеры, changelog RU/EN, `docs/specs/README.md`, `docs/WALL-THICKNESS.md`
— все в одном продуктовом коммите, соответствуют правилам PROCESS.md §7.1
и §10.1.
- Golden-матрица и её собственный тест (`test/golden-matrix.test.mjs`)
корректно описывают новую сцену без преждевременного принятия baseline.
## Чего не проверял
- Визуальный итог новой golden-сцены (baseline не существует по контракту до
предрелиза — см. «Не прогонялось» выше).
- Backend/HA harness — не затронут.
- Полный набор из 136 browser-smoke и `performance_smoke` — не относятся к
этому точечному изменению; остаются обязательными на предрелизном гейте.
- Мобильный/touch путь Split отдельно не тестировал: ТЗ фиксирует, что
сохранённая геометрия не зависит от типа указателя, а сам инструмент Split
desktop-first и не менялся этим диффом.
+201
View File
@@ -0,0 +1,201 @@
# Code review #172 — r2
- **Issue:** https://github.com/Matysh/houseplan-card/issues/172
- **Spec:** `docs/specs/172-zero-divider-taper.md`, зелёное `SPEC-REVIEW-172-r1.md`
- **Reviewed branch:** `issue/172-zero-divider-taper` (HEAD detached at
`origin/issue/172-zero-divider-taper`)
- **Reviewed range:** `origin/dev..HEAD` = `93f86f8` (spec) → `56834c5` (spec
review doc) → `c2112db` (fix, **User-Visible: yes**) → `eeb9c34` (code review
r1 doc)
- **Base:** `origin/dev` at `4d71f57` (уже включает #150 «preserve wall
thickness transitions»)
- **Reviewer:** Claude, независимая сессия без контекста реализации
- **Причина цикла r2:** r1 был зелёным (`High: 0 · Medium: 0`), но слияние в
`dev` конфликтовало; PROCESS.md §2.6/§10.4 требует повторного код-ревью
после ребейза на ушедший вперёд `dev`, потому что это другой код. Ветка
перебазирована автором на `origin/dev` `4d71f57` (включает #150), конфликт
разрешён, коммит реализации переименован в `c2112db`. Цикл считается по
этапу (§10.4): вердикт по ТЗ не расходует бюджет код-ревью, это первая
расходующая бюджет код-ревью правка → `r2/4`.
## Вердикт
**Зелёный · цикл r2/4 · High: 0 · Medium: 0.**
Продуктовый диф после ребейза **содержательно идентичен** дифу, уже
проверенному в `CODE-REVIEW-172-r1.md`: те же 25 строк в
`src/wall-thickness.ts` (симметричная ветка «локальный cap» в
`insetContour()`/`outsetContour()`), тот же набор тестов, тот же smoke, та же
golden-сцена (версия матрицы 25), та же документация и оба changelog в одном
коммите. Единственное отличие — коммит стал `c2112db` вместо `dfd56e8` (другой
SHA после ребейза на `dev`, содержащий #150) и второй код-ревью документ
(`eeb9c34`) добавлен как отдельный класс-C коммит.
Я не унаследовал вывод r1 не глядя: пересобрал бандл, независимо повторил
дисциплину «тест умеет падать» (временно откатил обе новые ветки в коде и
получил 2 красных unit-теста и 2 красных поля в named-smoke), прогнал полный
юнит-регресс и четыре смежных/зависимых browser-smoke, включая smoke #150
(`smoke_wall_thickness_transition.mjs`), которого не было в списке r1, потому
что на момент r1 #150 не был частью проверяемого дерева — теперь он есть, и
обе правки одной и той же общей geometry-функции сосуществуют без конфликта
поведения.
## Скоуп
Единственный продуктовый файл — `src/wall-thickness.ts`: `insetContour()` и
`outsetContour()` получили симметричную ветку `if ((oA > 0) !== (oB > 0))`,
которая перехватывает переход «положительный offset ↔ ровно нулевой offset» до
общей mitre/bevel- и collinear-логики, помещённую **перед** веткой
`collinearJoint()`. Для точного коллинеарного перехода обе ветки вычисляют
одну и ту же точку (`nA === nB` при совпадающем направлении), поэтому
перестановка порядка проверок не меняет поведение AC3 (существующая точная
ступень).
Сопутствующие изменения (не поменялись с r1): `test/wall-thickness.test.mjs`
(два новых теста), `demo/smoke_zero_divider_taper.mjs` (новый),
`demo/golden/{harness,matrix}.mjs` + `test/golden-matrix.test.mjs` (новая
сцена `split-zero-divider-taper-dark`, `GOLDEN_MATRIX_VERSION` 24→25, baseline
сознательно не принят), `docs/WALL-THICKNESS.md` §3 (контракт cap
задокументирован), `docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md`, три синхронные
копии бандла, `docs/specs/README.md`.
Ровно один продуктовый коммит `c2112db`, трейлеры `Issue: #172` /
`User-Visible: yes` на месте, оба changelog в том же коммите (проверено
`git show --stat c2112db`). Ветка называется по правилу, `process-gate.mjs`
проходит на всём диапазоне (4 коммита, 0 предупреждений).
## Как проверялось
| Гейт | Результат |
|---|---|
| `npx tsc --noEmit` | pass, без вывода |
| `npm test` | **833/833 pass** (было 830/830 в r1 — разница объясняется тремя тестами #150, которые вошли в базовый `dev` при ребейзе; сами тесты #172 те же два) |
| `npm run build` + сверка трёх копий бандла | pass; `sha256sum dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js demo/srv/assets/houseplan-card.js` → один и тот же хеш `21f8ffc…3233e`; `git status --short` после копирования — пусто, бандл в дереве уже актуален |
| Откат обеих новых веток в `insetContour`/`outsetContour`, повторный `npm test` | **831/833**, красные ровно `variable-offset contours keep a local cap at angled positive-to-zero joins` и `near-collinear zero-depth Split divider never grows a masonry taper` — дисциплина «тест умеет падать» подтверждена мной лично на пересобранном дереве, а не переиспользована из r1 |
| Пересборка **без** фикса + `node demo/smoke_zero_divider_taper.mjs` | `planHasNoTaper: false`, `lightHasNoTaper: false`, `FAILED (2)` — smoke тоже подтверждённо умеет падать |
| Восстановление фикса, пересборка, синхронизация трёх копий бандла | все три sha256 совпадают между собой и с закоммиченным деревом |
| `node demo/smoke_zero_divider_taper.mjs` (AC6/AC7) | pass, все 13 полей `true` |
| `node demo/smoke_split_corner_wall.mjs` (смежная поверхность, AC3/AC9) | pass |
| `node demo/smoke_wall_thickness.mjs` (смежная поверхность) | pass |
| `node demo/smoke_wall_junctions.mjs` (смежная поверхность, T/L-стыки) | pass |
| `node demo/smoke_wall_thickness_transition.mjs` (#150 — та же общая функция, слита при ребейзе) | pass — правка #172 не сломала соседнюю правку #150 в том же файле |
| `node scripts/process-gate.mjs --range origin/dev..HEAD --target-ref refs/heads/issue/172-zero-divider-taper` | pass, 4 коммита, 0 предупреждений (без `--issues`, офлайн-режим — токен GitHub здесь не нужен для проверки трейлеров/веток/changelog) |
| `git show --stat c2112db` | подтверждает оба changelog, документацию и три копии бандла в одном коммите с `User-Visible: yes` |
### Не прогонялось, и почему
- **`npm run golden:verify`.** Новая сцена `split-zero-divider-taper-dark`
(`GOLDEN_MATRIX_VERSION` 25) не имеет baseline — проверил напрямую:
`demo/golden/baselines/` не содержит записи `split-zero-divider-taper-dark`.
`verify` по контракту (`demo/golden/README.md`) обязан упасть на отсутствующем
эталоне независимо от корректности геометрии; AC8 сознательно откладывает
принятие baseline на предрелизный Linux-гейт (`golden:accept -- --reviewed`).
Локальный прогон дал бы только ожидаемый «missing baseline» без новой
информации.
- **`python -m pytest tests_backend`.** Ни один файл `custom_components/**/*.py`
не тронут этим диффом.
- **Performance-профили.** Не названы в AC; диф ограничен одним `if`-блоком на
переход, асимптотика не меняется — то же заключение, что и в r1, диф не
изменился.
- **Полный набор из 127+ browser-smoke.** Задача — точечное изменение одной
геометрической функции; прогнаны названный в AC смок плюс четыре смежных
(corner-split, общая толщина стен, T/L-стыки, и — дополнительно к списку r1 —
smoke #150, слитый той же общей функцией при ребейзе). Остальные смоки не
используют `insetContour`/`outsetContour` в зоне, задетой этим диффом.
## Проверка AC1–AC11
Продуктовый код и тесты идентичны r1; переисполнил или перепроверил каждую
строку самостоятельно, ссылки на r1 — только там, где вывод не может измениться
при неизменном диффе.
| AC | Метод по ТЗ | Статус | Как закрыт |
|---|---|---|---|
| AC1 | unit | ✅ | `variable-offset contours keep a local cap…` — прогнан лично, подтверждён красным без фикса |
| AC2 | unit | ✅ | `near-collinear zero-depth Split divider never grows a masonry taper` — матрица `outerCm ∈ {1,15,100}`, `deltaY ∈ {-5,-2.5,2.5,5}`, permutation room order/winding; прогнан лично, подтверждён красным без фикса |
| AC3 | unit | ✅ | Полный регресс 833/833 не покраснел; читал код (`src/wall-thickness.ts:810-817` до `collinearJoint`) — при точном коллинеарном стыке новая ветка вычисляет ту же точку, что и старая (`nA===nB`), логический регресс исключён; `smoke_split_corner_wall.mjs` зелёный |
| AC4 | unit | ✅ (см. Low-1 ниже, унаследована из r1) | Отдельного нового теста на точную AC1-fixture нет и не появилось при ребейзе (диф теста не изменился). Я предпринял независимую попытку пересчитать инвариант собственным скриптом (`innerContourForRoom` по каждой комнате в отдельности) и получил числа, не сопоставимые напрямую с методологией r1 (моя примитивная сумма per-room floor не воспроизводит точно то же сечение, что r1 мерил полосой вдоль разделителя) — не нашёл основания усомниться в выводе r1, но и не воспроизвёл его число независимо. См. «Чего не проверял» |
| AC5 | unit | ✅ (чтением) | `splitRoomPath()` не тронута диффом; тест «rendering does not materialize or mutate saved geometry» + smoke `anglePreserved`/`renderDoesNotRewriteConfig` |
| AC6 | smoke | ✅ | `node demo/smoke_zero_divider_taper.mjs`, лично прогнан, `dividerStaysZero`/`planHasNoTaper` true |
| AC7 | smoke | ✅ | Тот же smoke: `planUsesCanonicalBody`, `planViewParity`, `kioskParity`, `isoUsesCanonicalBody`, `staticParity`, `lightHasNoTaper`, `renderDoesNotRewriteConfig` — все true |
| AC8 | golden | ✅ (отложено по контракту) | Сцена в матрице v25, `test/golden-matrix.test.mjs` проверяет состав; baseline отсутствует — проверено напрямую по `demo/golden/baselines/` |
| AC9 | unit+smoke | ✅ | 833/833 + четыре смежных/зависимых smoke зелёные (включая #150) |
| AC10 | код-ревью | ✅ | Диф ограничен общей variable-offset геометрией `wall-thickness.ts`, ни одного renderer-specific ветвления |
| AC11 | код-ревью | ✅ | Новых DOM-узлов/событий/таймеров/сетевых вызовов/HA-сервисов нет |
## Находки
Новых находок в этом цикле нет — диф не изменился по существу с r1, только SHA
после ребейза. Обе находки Low из r1 остаются в силе с тем же решением
(«снимается без правки»); переношу их сюда без повторной эскалации, чтобы не
плодить фиктивный «новый» цикл вокруг уже закрытого вопроса.
### Low-1 (унаследована из r1) — AC4 не имеет отдельного исполняемого теста на fixture из АК1
Не изменилось с r1: отдельного unit-теста на clean-floor invariant именно для
near-collinear нулевого разделителя по-прежнему нет. r1 закрыл разрыв прямым
исполнением `innerContourForRoom` + `wallBodiesGeometry` и получил расхождение
≈0,011%, идентичное точному `0°` (то есть ранее существующий
квантование-артефакт, а не то, что фикс должен был закрыть). Моя собственная
попытка независимо пересчитать тот же инвариант (см. AC4 выше и «Чего не
проверял») использовала другую, более грубую методологию и не дала
сопоставимого числа — это ограничение моей проверки, а не найденное
расхождение с выводом r1. Диф, на котором сделан вывод r1, не изменился.
**Вердикт:** остаётся снятой без правки, как в r1. Не переоткрываю как новую
находку — методологическое расхождение в моей повторной проверке не
опровергает измерение r1 и не является само по себе дефектом кода.
### Low-2 (унаследована из r1) — неточная ссылка на процесс в хендоффе первого цикла
Касалась исходного implementation-хендоффа (цитата §11.4 не по адресу для
пропуска named-smoke). Автор сам прогнал smoke перед вторым хендоффом
(«Повторный хендофф после ребейза» явно перечисляет
`node demo/smoke_zero_divider_taper.mjs → pass, все 13 проверок true`), так что
для r2 вопрос уже неактуален практически, а не только формально.
**Вердикт:** снимается окончательно, без дальнейших действий.
## Что проверено и корректно
- Ребейз не изменил продуктовую логику: `git diff origin/dev...HEAD --
src/wall-thickness.ts` даёт тот же 25-строчный диф, что описан в r1, только с
другим базовым SHA.
- Правка сосуществует с #150 без конфликта поведения: обе используют одну и ту
же общую функцию `insetContour`/`outsetContour`, `smoke_wall_thickness_transition.mjs`
(#150) зелёный на дереве, содержащем обе правки.
- Дисциплина «тест умеет падать» подтверждена мной лично на пересобранном
дереве (не переиспользовано заявление r1): 2 unit-теста и named-smoke красные
без фикса, зелёные с фиксом.
- Три копии бандла побайтово идентичны друг другу и рабочему дереву (`git
status --short` пуст после пересборки).
- Трейлеры, оба changelog, `docs/WALL-THICKNESS.md`, `docs/specs/README.md` — в
одном продуктовом коммите `c2112db` (`git show --stat`).
- `process-gate.mjs` проходит на всём диапазоне `origin/dev..HEAD` (4 коммита,
0 предупреждений).
- Golden-сцена добавлена в матрицу без преждевременного baseline — проверено
прямым просмотром `demo/golden/baselines/`, а не только чтением ТЗ.
- Симметрия inset/outset, ограничение локального cap физической half-depth,
независимость от порядка комнат/winding — те же гарантии, что в r1, диф не
изменился, регресс логически исключён (см. AC3 выше).
## Чего не проверял
- Визуальный итог новой golden-сцены — baseline не существует по контракту до
предрелиза.
- Backend/HA harness — не затронут.
- Полный набор из 127+ browser-smoke и `performance_smoke` — не относятся к
этому точечному изменению; обязательны на предрелизном гейте.
- Мобильный/touch путь Split — ТЗ фиксирует независимость сохранённой
геометрии от типа указателя, инструмент desktop-first и не менялся этим
диффом.
- Независимое числовое воспроизведение AC4 (clean-floor invariant на точной
AC1-fixture) — моя попытка пересчитать инвариант собственным скриптом
использовала иную методологию, чем r1 (суммирование `innerContourForRoom` по
комнате вместо измерения полосы вдоль разделителя), не дала сопоставимого
числа и была отброшена как неубедительная, а не доведена до совпадения с
результатом r1. Полагаюсь на измерение r1 (Low-1), поскольку диф, на котором
оно сделано, не изменился.
- Правильность конкретной оценки владельца (8/10 · 6/10 · P2) и легитимность
полного трека по существу — уже подтверждены `SPEC-REVIEW-172-r1.md`, не
предмет код-ревью.
+272
View File
@@ -0,0 +1,272 @@
# SPEC-REVIEW-172-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/172
- **ТЗ под ревью:** `docs/specs/172-zero-divider-taper.md` (коммит `4582628`,
ветка `issue/172-zero-divider-taper`)
- **Роль:** ревьюер ТЗ (не автор), этап `S4-spec-review`
- **Трек:** обычный (не `small`/`trivial`) — сложность/риск 6/7 из 10, задача
задевает более одной поверхности (Plan, View/kiosk/static, hidden Iso,
clean-floor/room fills, Glow/sun) и физическую геометрию, потребляемую всем
рендером; критерии лёгкого трека (§5 PROCESS.md: одна поверхность, риск ≤3)
не выполняются ни по одному пункту — полный трек и файл ТЗ выбраны верно.
- **Цикл:** r1/4
## Скоуп ревью
Проверялось соответствие ТЗ:
- `docs/SCOPE.md` — попадание в Core user jobs (J4/J6), отсутствие расширения
скоупа за пределы описанного дефекта;
- `PROCESS.md` §2.4/§2.5 (DoR), §7.1 (обязательные разделы ТЗ), §5 (критерии
лёгкого трека), §3/§12 (запреты, включая «догадка вместо решения»);
- `AGENTS.md` — классы файлов, имя ветки, трейлеры коммита ТЗ;
- каноническому документу подсистемы `docs/WALL-THICKNESS.md` (модель
толщины, growth ±½, mitre/bevel-контракт, единый источник геометрии для
всех потребителей);
- `docs/USER-GUIDE.ru.md` — терминология инструмента «Split»;
- фактическому коду `src/wall-thickness.ts` (`insetContour()`,
`outsetContour()`, `MITRE_LIMIT`) — чтобы диагноз причины в ТЗ не оказался
непроверенной догадкой, выданной за факт;
- полному треду issue #172 — аналитика Codex, вопросы Q1–Q4 с default'ами,
решение владельца, финальный комментарий автора со ссылкой на ТЗ.
## Как проверялось
1. Прочитан весь тред issue #172: исходный баг-репорт пользователя (Г-образная
комната, Split из внутреннего угла с отклонением 0,5–1° от нормали, один
конец разделителя — толщина 0, другой — толщина примыкающей стены),
аналитика Codex (воспроизведение на `origin/dev` `a05aa5d` при углах `0°`,
`0,477°`, `0,955°`, `1,909°`, `9,462°`, `26,565°`; при точном `0°` дефекта
нет), явные вопросы Q1–Q4 с предложенными default'ами, ответ владельца
«принимаю все defaults» и финальная публикация ТЗ.
2. Сверены обязательные разделы ТЗ (§7.1 PROCESS.md) построчно — таблица ниже.
3. Прочитан код `src/wall-thickness.ts` и построчно сверен диагноз §3 ТЗ:
- `insetContour()` (:773-832): при `collinearJoint(uA, uB)` (:808) —
специальная ветка, которая кладёт обе точки `pa`/`pb` (offset-точка И
исходная вершина нулевой грани при коллинеарном стыке) — совпадает с
утверждением ТЗ §7.2 «строго коллинеарный переход сохраняет ступень»;
- при **не**-коллинеарном стыке (реальный fixture отклонён на доли/единицы
градуса) код идёт в mitre/bevel-ветку (:817-829); при удалённом mitre
(`dist > MITRE_LIMIT × maxO`, :821) срабатывает bevel (:827-829):
`if (oA > 0) out.push(...)`; `if (oB > 0) out.push(...)`. Если один из
offset'ов равен нулю (наш случай: положительная наружная стена ↔
нулевой разделитель), в вывод попадает **только одна** точка — офсетная
точка толстой грани; исходная вершина нулевой грани не добавляется
нигде. Это ровно механизм, который ТЗ §3/§4 (комментарий-анализ)
описывает как причину клина: «bevel-ветка сохраняет только смещённую
точку толстой грани и теряет исходную вершину нулевой грани». Диагноз
точен, не является догадкой.
- `outsetContour()` (:1912-1967) зеркально воспроизводит ту же структуру
(:1962-1963) — подтверждает утверждение ТЗ §8.2 о необходимости
симметричного исправления в inset и outset.
- Геометрически прослежен путь клина: в узле стыка (толстая стена → нулевой
разделитель) кольцо после bevel соединяет офсетную точку толстой грани
напрямую со следующей вершиной вдоль нулевого разделителя (у которой оба
соседних offset = 0, значит она остаётся исходной вершиной), образуя
прямую от «почти нулевого» смещения до нуля на другом конце — то самое
сечение «растёт от 0 до полной глубины стены», описанное в баг-репорте и
§3 ТЗ.
4. Прочитан `docs/WALL-THICKNESS.md` целиком: подтверждён контракт growth ±½,
union колец по комнатам, единый источник геометрии для
full/static/hidden-isometric и light occlusion (раздел 2–4) — ТЗ §7.4/§8.6
продолжает существующую модель, а не изобретает новую. Раздел 8 документа
(«Independent partitions… same joined set used by Glow, sun and source
placement») также согласуется с требованием ТЗ единого физического тела
для всех потребителей.
5. Проверено, что #172 не дублирует #123 (наружный фасад/выход толщины через
вершину при Split) и #150 (breakpoint между коллинеарными внешними
интервалами разной толщины): прочитан `docs/specs/123-corner-split-wall.md`
— там баг про экстерьерный bbox и наружный зуб от острого митра, здесь —
про внутреннюю нулевую границу и bevel, теряющий вершину. Разные механизмы,
разные условия срабатывания (там — вершина исходной комнаты, здесь —
стык offset>0 / offset=0 в bevel-ветке). Не дубликат.
6. Проверена терминология: «Split» в ТЗ совпадает с `docs/USER-GUIDE.ru.md:340`
(«Split | Делит комнату путём от одной стены до другой»); термины «masonry»,
«mitre», «bevel», «cap» — это уже принятая в `docs/WALL-THICKNESS.md`
английская терминология подсистемы, не изобретены автором ТЗ.
7. Проверен явно фактический фикстур §3: полигон
`[100,100]–[900,100]–[900,800]–[600,800]–[600,400]–[100,400]`, Split
`[600,400]→[900,402.5]` даёт `atan(2.5/300) ≈ 0,477°`, а `[900,405]` даёт
`atan(5/300) ≈ 0,955°` — числа в ТЗ внутренне согласованы, не выдуманы.
8. Проверено соответствие `docs/CONFIG-COMPATIBILITY.md`: задача не создаёт
нового compatibility-случая (не меняет persisted-представление `RoomCfg`/
`WallEntry`), что подтверждено и содержанием реестра (нет полей,
относящихся к разделителям/толщине, требующих отдельной миграции).
9. Проверен явный технический блок §16 «Принятые технические предположения»:
все пять пунктов — про место реализации, эпсилон в тестах, отсутствие
отдельной post-render маски, переиспользование fixture #123 и границу с
возможным отдельным багом boolean-библиотеки — технические, не продуктовые,
корректно не эскалированы владельцу (PROCESS.md §7.1: «владельцу — только
продуктовые вопросы»).
10. Проверена трассируемость: `docs/specs/README.md:94` обновлён тем же
коммитом `4582628`; ссылка issue → ТЗ и ТЗ → issue двусторонняя.
`git diff --stat origin/dev...HEAD` показывает только
`docs/specs/172-zero-divider-taper.md` и `docs/specs/README.md` (класс C,
ни одного файла класса A — правило №1 не нарушено на этапе ТЗ). Коммит
несёт `Issue: #172`, `User-Visible: no` — верно для документа ТЗ, который
сам не меняет поведение продукта.
11. Проверено существование файлов, которые ТЗ называет предполагаемыми:
`test/wall-thickness.test.mjs`, `demo/smoke_split_corner_wall.mjs`,
`demo/smoke_wall_junctions.mjs`, `demo/smoke_wall_thickness.mjs` — все
существуют; новый `demo/smoke_zero_divider_taper.mjs` не пересекается по
смыслу с существующим `demo/smoke_split_nonsnap.mjs` (тот проверяет
Split на не-grid-aligned полигоне, а не переход толщины на нулевой
границе) — не дублирует существующее покрытие.
## Обязательные разделы (§7.1 PROCESS.md)
| Раздел | Есть | Комментарий |
|---|---|---|
| Сценарий (персона/поверхность/момент) | ✅ | §1 — администратор дома, desktop Plan editor, момент завершения Split почти вдоль плеча угла |
| Что человек увидит до/после | ✅ | §2, «До:»/«После:» — см. Low-1 |
| Проблема (с подтверждённой причиной) | ✅ | §3, причина проверена построчно по коду (см. «Как проверялось» п.3) |
| Скоуп / не-скоуп | ✅ | §5 / §6, явные границы (без snap, без нового UX, без изменения `MITRE_LIMIT` для двух положительных толщин, без миграции) |
| Контракт поведения | ✅ | §7 (геометрия) + §8 (архитектурные ограничения реализации) |
| Модель данных и миграция | ✅ | §9 — явное «форматы не меняются», «читается исправленно, без записи» |
| UX, i18n, accessibility, touch | ✅ | §10, явно `Touch editor: best effort / intentionally degraded` — буквальная канон-метка `docs/TOUCH-SUPPORT.md` присутствует |
| AC1…ACn с доказательством | ✅ | §11, 11 штук, каждый помечен `unit`/`smoke`/`golden`/«ревью кода» |
| План автотестов | ✅ | §12, разбит на unit / browser smoke / golden и pre-release / implementation loop |
| Риски | ✅ | §13, таблица риск → мера, плюс performance/security |
| Откат | ✅ | §14 |
| Release-артефакты | ✅ | §15, конкретный список: оба changelog, `WALL-THICKNESS.md`, тесты, golden, три копии бандла |
Все обязательные разделы присутствуют и содержательны. Дополнительно есть
раздел решений владельца (§4, фиксирует принятые Q1–Q4) и явный блок принятых
технических предположений (§16) — соответствует требованию PROCESS.md §7.1
отделять продуктовое решение от технического и не выдавать догадку за факт.
## Находки
Находок уровня **High** и **Medium** нет — новых issue не требуется.
### Low-1 — «что человек увидит» длиннее одной фразы
**Файл:** `docs/specs/172-zero-divider-taper.md:26-34` (§2)
PROCESS.md §7.1 требует «одной фразой, без терминов реализации». Раздел
написан двумя-тремя предложениями на «До:»/«После:» (например, «После:»
содержит два предложения). По существу требование выполнено — язык
исключительно визуальный («клин», «толщина», «стык»), без имён функций или
внутренних терминов, — но формально это не «одна фраза». Тот же класс
находки уже фиксировался как Low и не блокировал приёмку в
`SPEC-REVIEW-141-r1` (Low-2) и `SPEC-REVIEW-137-r1`.
**Решение ревьюера:** Low, не блокирует. Косметическая правка на усмотрение
автора при следующей редакции.
### Low-2 — эпсилон/half-depth границы локального cap не формализованы числом
**Файл:** `docs/specs/172-zero-divider-taper.md:124-125, 338-339` (§7.1, §16.2)
Контракт требует, чтобы локальная область примыкания «ограничена физической
half-depth примыкающей стены и геометрическим epsilon» и «не может расти
пропорционально длине D», но конкретная формула эпсилон (как, например,
`max(4% × grid pitch, 1e-9)` для «effectively collinear» в
`docs/WALL-THICKNESS.md`) не приведена — §16.2 явно оставляет её тестовой
стратегии автора кода. В `src/wall-thickness.ts` уже есть несколько
устоявшихся эпсилон-констант (`openEps(pitch, coordScale)`,
`pitch * coordScale * 0.02`, `1e-9`), так что это не белое пятно, а
осознанно оставленная техническая свобода, корректно помеченная как
предположение, которое ревьюер вправе оспорить, но переносить в
продуктовый вопрос владельцу нет оснований — граница «не растёт
пропорционально длине» уже достаточна как проверяемый инвариант для AC2.
**Решение ревьюера:** Low, не блокирует. Код-ревью должно убедиться, что
выбранная константа действительно не масштабируется с длиной грани (что уже
явно требует AC2), а не что она равна конкретному числу.
## Что проверено и корректно
- **Соответствие `docs/SCOPE.md`:** задача закрывает **J4** («от нуля до
плана без Inkscape/YAML» — Split не должен молча создавать кладку, которую
пользователь не задавал) и **J6** («план остаётся правдивым по мере
развития» — сохранённые планы должны отображаться корректно без ручной
переработки). Обе строки Closed, это исправление дефекта внутри принятой
функциональности, а не расширение продукта. Общая физическая геометрия
также поддерживает согласованность J1/J2/J3 между View и Plan, как верно
указано в §1 ТЗ.
- **Легитимность полного трека:** сложность/риск 6/7 из 10, минимум пять
затронутых поверхностей (Plan, View/kiosk/static, hidden Iso, floor/room
fills, Glow/sun barriers) — критерии `small` (§5 PROCESS.md, одна
поверхность, риск ≤3) не выполняются; полный трек и отдельный файл ТЗ
выбраны верно, лёгкий трек владелец и автор корректно не применили.
- **Продуктовые вопросы закрыты по процессу:** Q1–Q4 заданы одним пакетным
комментарием, каждый с предлагаемым default, задача корректно ушла в
`blocked`+`S3-spec` до ответа и вышла из `blocked` сразу после решения
владельца (все четыре ответа — «да»). Ни один технический вопрос не был
ошибочно вынесен владельцу — §16 явно и полностью перечисляет технически
свободные решения.
- **Технический диагноз не голословен.** Причина клина (bevel-ветка
`insetContour()`/`outsetContour()` теряет исходную вершину нулевой грани
при близком, но не точном коллинеарном стыке) построчно проверена по
исходному коду `src/wall-thickness.ts:773-832, 1912-1967` и совпадает с
описанием в ТЗ — см. «Как проверялось» п.3. Числовые фикстуры (углы
`0,477°`/`0,955°`) внутренне согласованы с геометрией §3.
- **Не дубликат #123/#150:** механизм и условие срабатывания различны,
проверено по `docs/specs/123-corner-split-wall.md`; ТЗ корректно
разграничивает три задачи в одном абзаце §3.
- **Не-скоуп (§6) корректно отсекает соседние соблазны:** snap почти
перпендикулярного/коллинеарного Split, изменение допустимости или диалога
Split, автоназначение толщины, переработка модели данных, изменение
`MITRE_LIMIT` для пар двух положительных offset'ов, публикация изометрии
как отдельной фичи — все явно исключены с указанием причины.
- **Регрессионные гарантии сформулированы явно:** AC3/AC9 поимённо защищают
точный коллинеарный/ортогональный переход, пары `1↔15`/`15↔100`, corner
Split, wall junctions, wall thickness, opening tunnels — то есть именно те
сценарии, которые уже используют ту же `insetContour()`/`outsetContour()` и
могли бы негласно пострадать от исправления.
- **Модель данных и миграция (§9):** корректно заявлено «форматы `RoomCfg` и
`WallEntry` не меняются», «чтение и рендер не записывают конфигурацию», без
прямой/обратной миграции — сверено с `docs/CONFIG-COMPATIBILITY.md`, задача
не создаёт нового compatibility-случая.
- **UX/touch (§10):** буквально использует канон-формулировку
`docs/TOUCH-SUPPORT.md` («Touch editor: best effort / intentionally
degraded») и отдельно фиксирует safety floor: View/kiosk/static —
блокирующие поверхности, сохранённая геометрия не зависит от типа
указателя.
- **Release-артефакты (§15)** перечисляют оба changelog в одном
implementation-коммите, `docs/WALL-THICKNESS.md`, конкретные
unit/smoke/golden-файлы и три синхронные копии бандла — соответствует
§7.1/правилу 11 PROCESS.md. Golden корректно ограничен только
`npm run golden:accept -- --reviewed` по полному Linux-артефакту на
предрелизном этапе (§12, согласуется с PROCESS.md §8/§11.4).
- **Дисциплина «тест должен уметь падать»:** AC1 прямо требует, чтобы фикстура
§3 краснела на исходном `dev` из-за taper-клина, и явно передаёт эту
проверку на код-ревью («Ревьюер фиксирует эту проверку в code review») —
соответствует требованию PROCESS.md §2.7/§18.
- **Трассируемость:** `docs/specs/README.md:94` обновлён тем же коммитом
`4582628`; ветка `issue/172-zero-divider-taper` и трейлеры (`Issue: #172`,
`User-Visible: no`) корректны для документа класса C, который сам не меняет
поведение. `git diff --stat origin/dev...HEAD` не содержит ни одного файла
класса A — продуктовый код не тронут до `S5-ready` (правило №1).
## Чего не проверял
- Не проверял реализуемость конкретного алгоритма из §8.2 («сохранить обе
точки в детерминированном порядке») как единственно возможного решения —
это техническая свобода автора кода (§16), а не предмет ревью ТЗ; код-ревью
должно будет проверить сам факт отсутствия taper, а не конкретный порядок
вставки точек.
- Не запускал автотесты, `golden`, browser-смоки или performance-профили — на
этапе `spec` это не требуется; существование названных в ТЗ файлов
(`test/wall-thickness.test.mjs`, `demo/smoke_split_corner_wall.mjs`,
`demo/smoke_wall_junctions.mjs`, `demo/smoke_wall_thickness.mjs`) и
структура `insetContour()`/`outsetContour()` проверены чтением кода, а не
исполнением.
- Не проверял корректность конкретных числовых оценок аналитики (8/10 · 6/10 ·
7/10 · P2) по существу — это поле владельца (PROCESS.md §2.2), уже принято
явным решением владельца до написания ТЗ.
- Не проверял связанные issue #123/#150 по существу за пределами того, что
понадобилось для верификации отсутствия дублирования (различие механизма и
условий срабатывания) — они не входят в предмет этого ревью.
- Не проверял, обнаружится ли в ходе реализации отдельный дефект
boolean-библиотеки (упомянутый как риск в §16.5) — это явно вынесено в
будущий отдельный issue, если случится, и не влияет на приёмку ТЗ сейчас.
## Вердикт
Зелёный. High: 0, Medium: 0. Две находки Low (раздел «что человек увидит»
длиннее одной фразы; численная граница локального cap оставлена технической
свободой без явной формулы) — ни одна не блокирует приёмку, обе либо
правятся косметически при следующей редакции, либо снимаются этой записью без
нового цикла.
+345
View File
@@ -0,0 +1,345 @@
# Issue #172 — нулевой Split-разделитель не получает ложную толщину
- **Issue:** https://github.com/Matysh/houseplan-card/issues/172
- **Редакция:** первая редакция для независимого ревью; статус определяется только метками issue
- **Тип / приоритет:** bug / P2
- **Оценка:** пользовательская ценность 8/10; сложность 6/10; риск 7/10
- **Область:** редактор Плана, каноническая геометрия стен, View/static, hidden Iso, clean floor, Glow и солнце
- **Модель данных:** без изменений и миграции
- **Связано:** #123, #150, `docs/WALL-THICKNESS.md`, `docs/ARCHITECTURE.md`, `docs/TOUCH-SUPPORT.md`
## 1. Сценарий и персона
**Персона:** администратор дома, который строит и уточняет архитектуру плана в
desktop-редакторе.
**Поверхность и момент:** на плане есть Г-образная комната с заданной толщиной
наружных стен. Пользователь выбирает «Разделить», начинает линию во внутреннем
углу и заканчивает на другой стене. Линия почти продолжает одно из плеч угла,
но отклоняется от него на доли градуса или несколько градусов.
Задача поддерживает J4 и J6 из `docs/SCOPE.md`: редактор должен сохранять
правдивую геометрию, а штатное разделение комнаты не должно создавать стену,
которую пользователь не задавал. Общая физическая модель также сохраняет
согласованность J1/J2/J3 между Plan, View и световыми эффектами.
## 2. Что человек увидит до и после
**До:** разделитель сохранён без толщины, но визуально вдоль него появляется
треугольный клин: на одном конце он почти нулевой, на другом вырастает до
толщины примыкающей стены.
**После:** нулевой разделитель остаётся без кладки по всей длине; существующие
толстые стены заканчиваются локально в точках примыкания и не растягиваются
вдоль разделителя. Небольшой нарисованный угол сохраняется без автокоррекции.
## 3. Проблема и подтверждённая причина
Дефект воспроизведён на `origin/dev` SHA `a05aa5d` с production-параметрами
`pitch = 1/240`, `coordScale = 1000`, `cell_cm = 5`, `GRID_PITCH = 1000/240`.
Fixture — Г-образный полигон
`[100,100]–[900,100]–[900,800]–[600,800]–[600,400]–[100,400]`, наружные стены
`15 см`, Split от `[600,400]` к `[900,402.5]` или `[900,405]`.
Слой данных корректен:
- оба room-owned интервала общей границы классифицируются как `shared`;
- их эффективная толщина равна `0`;
- положительной `WallEntry` для разделителя нет;
- рендер не должен материализовать такую запись.
Ошибка возникает в `insetContour()` / `outsetContour()`. При стыке положительного
offset с нулевым и небольшом отклонении от коллинеарности удалённый mitre
отбрасывается по `MITRE_LIMIT`. Bevel-ветка сохраняет только смещённую точку
толстой грани и теряет исходную вершину нулевой грани. Следующий участок контура
соединяет эту точку с дальним концом разделителя и создаёт taper-клин.
На красном fixture поперечное сечение ложной кладки растёт вдоль нулевого
разделителя приблизительно от `0` до полной глубины стены `15 см`. При точном
`0°` специальная коллинеарная ветка уже сохраняет ступень, поэтому дефект легко
пропустить тестом только ортогональной геометрии.
Это не дубликат:
- #123 сохраняет наружный фасад при Split из вершины;
- #150 сохраняет breakpoint между коллинеарными внешними интервалами разной толщины;
- #172 убирает ложный taper вдоль нулевой внутренней общей границы.
## 4. Решения владельца
Владелец принял defaults Q1–Q4 18.08.2026:
https://github.com/Matysh/houseplan-card/issues/172#issuecomment-5329384353
1. Split-разделитель с `cm = 0` не содержит masonry по всей длине; толстая
примыкающая стена заканчивается локальным стыком без taper.
2. Инвариант применяется ко всем углам, толщинам, направлениям и winding.
3. Фактический угол Split сохраняется; snapping к нормали не добавляется.
4. Исправление едино для Plan, View/static, hidden Iso, clean floor/room fills и
препятствий Glow/солнца, без миграции конфигурации.
## 5. Скоуп
В задачу входят:
1. переход между положительной толщиной и точным нулевым offset в вершине
room wall profile;
2. обе последовательности `h → 0` и `0 → h`, прямой и обратный winding;
3. выпуклые и вогнутые вершины, острые, тупые, почти коллинеарные и
ортогональные допустимые углы;
4. все допустимые толщины `1…100 см`;
5. Split с одним или двумя endpoint на толстых стенах;
6. локальный cap/step без кладки в интерьере нулевого разделителя;
7. сохранение существующего mitre/bevel-контракта для пар `h1 ↔ h2`, где обе
толщины положительны;
8. единая каноническая геометрия всех перечисленных поверхностей;
9. корректное отображение уже сохранённых планов без записи конфигурации;
10. unit, targeted browser smoke, visual fixture, документация и RU/EN changelog.
## 6. Не входит в задачу
- snapping почти перпендикулярного или почти коллинеарного Split;
- изменение допустимости, маршрута кликов, диалога или сохранения Split;
- автоматическое назначение толщины новой общей границе;
- изменение инструмента «Толщина», `Перегородка` или открытых контуров;
- переработка модели `rooms`, `walls`, `partitions`, `open_spans` или `wall_columns`;
- новые настройки cap/join, пользовательские переключатели или i18n-тексты;
- изменение `MITRE_LIMIT` для стыков двух положительных толщин;
- миграция, schema version, backend или импорт/экспорт;
- публикация изометрии как публичной функции.
## 7. Контракт поведения
### 7.1. Нулевой разделитель
Пусть `D` — общая граница двух комнат после Split, а её эффективная толщина
равна `0`.
- Внутренняя часть `D`, за вычетом локальных областей примыкания существующих
толстых стен, не пересекается с канонической masonry geometry.
- Толщина вдоль `D` не интерполируется и не образует taper.
- В точке перехода `h ↔ 0` сохраняется вершина нулевой грани и формируется
локальный cap/step толстой стены.
- Локальная область примыкания ограничена физической half-depth примыкающей
стены и геометрическим epsilon; она не может расти пропорционально длине `D`.
- Правило симметрично относительно порядка комнат, направления границы и winding.
### 7.2. Существующие стены
- Толстая наружная или общая стена сохраняет заданную полную глубину до точки
примыкания.
- Наружный фасад до и после Split сохраняет контракт #123.
- Две положительные соседние толщины продолжают использовать действующие
mitre/bevel и `MITRE_LIMIT`; исправление нулевого перехода не превращает их
в принудительные плоские caps.
- Строго коллинеарный переход сохраняет существующую точную ступень.
- Проёмы и независимые физические тела не меняют свою ассоциацию или форму.
### 7.3. Геометрия Split и данные
- `splitRoomPath()` сохраняет фактически выбранные endpoints и промежуточные
точки; угол не округляется к нормали.
- Большая и новая комнаты сохраняются по действующему контракту.
- Нулевой разделитель не получает `WallEntry` и не материализуется при чтении.
- Рендер не мутирует `rooms`, `walls` и другие persisted-поля.
- Уже сохранённая конфигурация исправляется вычисляемо сразу после обновления.
### 7.4. Все потребители
Один результат wall-body geometry используется в:
- Plan editor;
- View, kiosk и `houseplan-space-card`;
- hidden Iso/floor footprint;
- paper, room fills и clean-floor contours;
- физических препятствиях Glow и солнечных лучей.
Renderer-specific SVG mask, отдельная геометрия только для Plan или подавление
штриховки без исправления физического тела не выполняют контракт.
## 8. Архитектурные ограничения реализации
1. Исправление живёт в общей variable-offset логике `src/wall-thickness.ts`, а
не в обработчике кликов Split.
2. Для стыка, где ровно один из соседних offsets положителен, контур обязан
сохранить как смещённую точку толстой грани, так и исходную вершину нулевой
грани в детерминированном порядке для inset и outset.
3. Обе половины кольца вместе формируют локальный cap; ни одна не соединяет
offset-точку с дальним узлом нулевой грани.
4. Пары двух положительных offsets проходят прежний bounded-mitre/bevel путь.
5. Результат остаётся валидным для boolean union/difference/intersection и не
полагается на порядок полигонов.
6. Canonical wall geometry остаётся единственным источником для drawing,
clean-floor и light/sun occlusion.
7. Новых persisted-полей, фоновых writes и renderer-specific caches нет.
Предполагаемые файлы:
- `src/wall-thickness.ts`;
- `test/wall-thickness.test.mjs`;
- новый `demo/smoke_zero_divider_taper.mjs` либо эквивалентное расширение
узкого Split-smoke;
- `demo/golden/matrix.mjs`, fixture/harness и `test/golden-matrix.test.mjs`,
если новая visual-сцена оформляется отдельно;
- `docs/WALL-THICKNESS.md`, при необходимости `docs/ARCHITECTURE.md`;
- `docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`;
- три синхронные поставляемые копии bundle.
## 9. Модель данных, compatibility и миграция
Форматы `RoomCfg` и `WallEntry` не меняются. Новых ключей, aliases и
compatibility-полей нет.
- legacy midpoint-only keys продолжают читаться;
- lossless `a`/`b` endpoints сохраняют текущий смысл;
- чтение и рендер не записывают конфигурацию;
- прямая и обратная миграции не нужны;
- откат на предыдущую версию не требует восстановления данных, но возвращает
визуальный дефект.
Это соответствует `docs/CONFIG-COMPATIBILITY.md`: меняется только вычисляемая
геометрия существующих данных.
## 10. UX, i18n, accessibility и touch
Новых controls, текстов, focus/keyboard semantics или ARIA нет. Новые RU/EN
i18n-ключи не требуются.
Split остаётся desktop-first. Touch editor: **best effort / intentionally
degraded**, но одна и та же сохранённая геометрия не может зависеть от типа
указателя. View, kiosk и static являются блокирующими поверхностями и обязаны
совпадать с desktop Plan.
Темы, `prefers-reduced-motion`, цвет и opacity стен не меняются.
## 11. Критерии приёмки
- **AC1 (`unit`):** production-scale fixture из §3 с углами `0,477°` и
`0,955°` сохраняет `cm = 0` на обоих room-owned интервалах разделителя, а
его внутренняя часть не пересекается с masonry geometry. Тест красный на
исходном `dev` из-за taper-клина.
- **AC2 (`unit`):** матрица `h = 1, 15, 100 см`, `h → 0` и `0 → h`, углы по
обе стороны от точной линии, оба направления и оба winding не создаёт
кладку вдоль нулевой грани; размер локального cap ограничен half-depth и
epsilon, а не длиной грани.
- **AC3 (`unit`):** точный коллинеарный и ортогональный переходы остаются
корректными; пары `1 ↔ 15`, `15 ↔ 100` и равные положительные offsets
сохраняют действующий mitre/bevel-контракт.
- **AC4 (`unit`):** наружная геометрия Г-образной комнаты до/после Split
сохраняется, а clean-floor union равен room union минус каноническая кладка;
нулевой разделитель не удаляет треугольник пола.
- **AC5 (`unit`):** `splitRoomPath()` сохраняет фактический угол fixture без
snap, render helpers не меняют сериализованные `rooms`/`walls`, миграция и
новые config keys отсутствуют.
- **AC6 (`smoke`):** targeted browser-smoke выполняет реальный Split из
вогнутого угла с отклонением около `1°`, подтверждает нулевые persisted
интервалы и отсутствие taper в SVG Plan.
- **AC7 (`smoke`):** тот же smoke доказывает побайтовое/геометрическое
совпадение канонической wall-body path в Plan, View/kiosk, static и hidden
Iso и совпадение masonry geometry световых препятствий; конфигурация после
рендера не меняется.
- **AC8 (`golden`):** детерминированная visual-сцена Г-образного Split явно
показывает нулевой разделитель без клина и без изменения фасада. Baseline
принимается только через `golden:accept -- --reviewed` по полному Linux CI
artifact на предрелизном этапе.
- **AC9 (`unit` + `smoke`):** существующие регрессии corner Split, wall
junctions, wall thickness и opening tunnels остаются зелёными; проёмы и
независимые тела не меняются.
- **AC10 (`ревью кода`):** исправление находится в общей variable-offset
геометрии; renderer-specific masks/branches и изменения persisted schema
отсутствуют.
- **AC11 (`ревью кода`):** новых DOM-узлов, событий, таймеров, сетевых запросов,
HA services и privacy/security surfaces нет.
## 12. План автотестов и гейтов
### Unit
В `test/wall-thickness.test.mjs` добавить:
1. точный красный fixture §3 с проверкой данных и площади пересечения masonry
с внутренней полосой разделителя;
2. параметрическую матрицу толщин, углов, направлений и winding;
3. negative-regression для двух положительных offsets и exact-collinear step;
4. clean-floor/exterior invariants и immutability.
Проверка должна уметь падать: временное возвращение старой bevel-ветки обязано
красить как минимум AC1/AC2. Ревьюер фиксирует эту проверку в code review.
### Browser smoke
`node demo/smoke_zero_divider_taper.mjs` (имя можно изменить при сохранении
однозначной связи с #172) выполняется локально перед `S7-code-review`. Smoke
использует `show_borders: true`, чтобы Plan/View/static/Iso сравнивали реально
отрисованные стены.
### Golden и pre-release
Новая visual-сцена добавляется в матрицу, но baseline не принимается ради
зелёной ветки. Capture/diff рассматриваются на предрелизном Linux-гейте; полный
golden, smoke и performance выполняются перед бетой по `PROCESS.md` §11.4.
### Обязательный implementation loop
```text
npm run typecheck
npm test
npm run build
node demo/smoke_zero_divider_taper.mjs
```
После build три bundle-копии должны совпадать побайтово. Backend/HA harness не
нужен: Python и Home Assistant integration не затрагиваются.
## 13. Риски, performance и security
| Риск | Мера |
|---|---|
| Исправление нулевого перехода меняет обычные положительные углы. | AC2/AC3 разделяют `h ↔ 0` и `h1 ↔ h2`; положительные пары сохраняют прежний путь. |
| Новый cap создаёт самопересекающийся contour при вогнутом угле. | Матрица convex/concave, оба winding и boolean-validity assertions. |
| SVG исправлен, но floor/light продолжают видеть клин. | AC4/AC7 проверяют общий geometry object и потребителей. |
| Тест исключает слишком большую endpoint-зону и пропускает taper. | Endpoint allowance ограничен физической half-depth + epsilon, дополнительно проверяются несколько внутренних сечений. |
| Golden принимается без review. | Только полный Linux artifact и `--reviewed`; до этого хранится candidate/diff. |
Performance-влияние практически отсутствует: число вершин variable-offset
контура увеличивается максимум на одну точку в каждом переходе `h ↔ 0`.
Асимптотика, cache keys и частота вычислений не меняются. Отдельный performance
budget не нужен; общий pre-release performance smoke остаётся обязательным.
Security/privacy влияние отсутствует: нет ввода HTML/CSS, сетевых запросов,
service calls, новых разрешений или данных HA.
## 14. Откат
Исправление откатывается одним frontend-коммитом вместе с тестами и
документацией. Persisted schema не меняется, поэтому обратная миграция и cleanup
не нужны. Откат возвращает ложный taper, но не повреждает сохранённые планы.
Feature flag не добавляется: это исправление общей физической геометрии, а не
экспериментальная функция.
## 15. Release-артефакты
- пользовательские записи в `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` в том
же implementation-коммите (`User-Visible: yes`);
- обновление `docs/WALL-THICKNESS.md`; `docs/ARCHITECTURE.md` — только если
уточняется общий variable-offset контракт;
- targeted unit и browser smoke;
- visual fixture/candidate и reviewed golden baseline по правилам §12;
- синхронные `dist/houseplan-card.js`, HA frontend и demo bundle;
- screenshots вне golden не требуются;
- backend, migration и отдельные security/performance artifacts не требуются;
- issue закрывается только после включения в опубликованную бету.
## 16. Принятые технические предположения
1. Точный нулевой offset — единственный триггер нового cap/step пути; малые
положительные толщины не приравниваются к нулю.
2. Endpoint allowance в тестах вычисляется из half-depth примыкающей стены и
общего geometry epsilon, а не задаётся долей длины разделителя.
3. Исправление выполняется симметрично в inset и outset; отдельная post-render
маска не допускается.
4. Новая visual-сцена может переиспользовать fixture #123, если остаётся
однозначно проверяемой и не скрывает L-shaped near-normal case.
5. Если во время реализации обнаружится отдельный дефект boolean-библиотеки,
он оформляется новой issue; #172 не расширяется до общей замены offset engine.
+1
View File
@@ -92,6 +92,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#141](https://github.com/Matysh/houseplan-card/issues/141) Бесшовные стыки перегородок и открытых контуров | [141-wall-junctions.md](141-wall-junctions.md) |
| [#157](https://github.com/Matysh/houseplan-card/issues/157) Тип проёма «Открытый проём» | [157-open-passage.md](157-open-passage.md) |
| [#150](https://github.com/Matysh/houseplan-card/issues/150) Точная геометрия коллинеарного перепада толщины | [150-wall-thickness-transition.md](150-wall-thickness-transition.md) |
| [#172](https://github.com/Matysh/houseplan-card/issues/172) Нулевой Split-разделитель не получает ложную толщину | [172-zero-divider-taper.md](172-zero-divider-taper.md) |
## Правило актуализации
+25
View File
@@ -801,6 +801,21 @@ export function insetContour(poly: number[][], offsets: number[]): number[][] |
continue;
}
// #172: a physical edge meeting a zero-depth divider owns a square cap,
// not a mitre into the divider. Keep both sides of that cap explicitly:
// the offset point of the physical edge and the untouched vertex of the
// zero edge. Letting the generic mitre/bevel path handle a near-collinear
// join drops the untouched vertex and stretches the cap along the complete
// divider as a triangular wall body.
if ((oA > 0) !== (oB > 0)) {
const v = poly[i];
const pa = oA > 0 ? [v[0] + nAx * oA, v[1] + nAy * oA] : [v[0], v[1]];
const pb = oB > 0 ? [v[0] + nBx * oB, v[1] + nBy * oB] : [v[0], v[1]];
out.push(pa);
if (Math.hypot(pb[0] - pa[0], pb[1] - pa[1]) > 1e-9) out.push(pb);
continue;
}
// AUD-159B6-01: atomic intervals put COLLINEAR neighbours in one outline.
// Two parallel offset lines never intersect, so the mitre branch below would
// fall through to a bevel that skips the zero side and slants the wall face.
@@ -1950,6 +1965,16 @@ export function outsetContour(poly: number[][], offsets: number[]): number[][] |
out.push([poly[i][0], poly[i][1]]);
continue;
}
// Mirror the inset contract above: one physical edge plus one zero-depth
// edge is a local cap with both endpoints present in traversal order.
if ((oA > 0) !== (oB > 0)) {
const v = poly[i];
const pa = oA > 0 ? [v[0] - nAx * oA, v[1] - nAy * oA] : [v[0], v[1]];
const pb = oB > 0 ? [v[0] - nBx * oB, v[1] - nBy * oB] : [v[0], v[1]];
out.push(pa);
if (Math.hypot(pb[0] - pa[0], pb[1] - pa[1]) > 1e-9) out.push(pb);
continue;
}
if (collinearJoint(uA, uB)) {
const v = poly[i];
const pa = [v[0] - nAx * oA, v[1] - nAy * oA];
+17 -2
View File
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
import test from 'node:test';
import { prepareGoldenFixture } from '../demo/golden/harness.mjs';
import { GOLDEN_MATRIX_VERSION, GOLDEN_SCENARIOS } from '../demo/golden/matrix.mjs';
import { fixtureWallKey } from '../demo/fixtures/visual-matrix.mjs';
test('golden matrix has stable unique ids and bounded comparison thresholds', () => {
assert.equal(Number.isInteger(GOLDEN_MATRIX_VERSION) && GOLDEN_MATRIX_VERSION > 0, true);
@@ -138,12 +139,26 @@ test('wall junction goldens cover live L/T previews plus saved flat and isometri
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']);
assert.deepEqual(
scenarios.map((scenario) => scenario.cornerSplitWall),
['before', 'thin', 'thick', 'zero-taper'],
);
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);
if (scenario.cornerSplitWall === 'zero-taper') {
assert.equal(space.rooms.length, 2);
assert.equal(space.rooms[0].poly.some((point) => point[1] === 0.405), true);
assert.equal(
space.walls.some((wall) => (
wall.key === fixtureWallKey([0.60, 0.40], [0.90, 0.405])
)),
false,
);
continue;
}
assert.equal(space.rooms.length, scenario.cornerSplitWall === 'before' ? 1 : 2);
if (scenario.cornerSplitWall !== 'before') {
const divider = space.walls.find((wall) => (
@@ -194,7 +209,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(GOLDEN_MATRIX_VERSION, 24);
assert.equal(GOLDEN_MATRIX_VERSION, 25);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
+85 -2
View File
@@ -6,7 +6,7 @@ import {
setWallThickness, setWallThicknessForRoom, applyWallThicknessToNewRoom,
drawWallPreviewD, linearWallBody, linearWallJoinPatches,
DRAW_WALL_DEFAULT_CM, clampWallCm, cmToField, fieldToCm,
wallCmToUnits, insetContour, inwardNormal, edgeKinds, wallEdgeBodies,
wallCmToUnits, insetContour, outsetContour, inwardNormal, edgeKinds, wallEdgeBodies,
wallBodyRings, wallBodiesGeometry, wallBodiesUnionPath, floorFootprintGeometry,
innerContourForRoom,
paperRoomShapesWithWalls, WALL_MIN_CM, WALL_MAX_CM, MITRE_LIMIT,
@@ -104,7 +104,7 @@ function cornerSplitFixture({
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 };
return { original, rooms, walls, divider, before, after };
}
function splitThicknessTransitionFixture() {
@@ -424,6 +424,36 @@ test('insetContour: acute corner falls back to a bevel (no infinite spike)', ()
}
});
test('variable-offset contours keep a local cap at angled positive-to-zero joins', () => {
const poly = [[0, 0], [10, 0], [20, 0.1], [20, 10], [0, 10]];
const vertex = poly[1];
const hasPoint = (contour, point) => contour.some((candidate) => (
Math.hypot(candidate[0] - point[0], candidate[1] - point[1]) <= 1e-9
));
for (const offsets of [[2, 0, 0, 0, 0], [0, 2, 0, 0, 0]]) {
const inset = insetContour(poly, offsets);
const outset = outsetContour(poly, offsets);
assert.ok(inset && outset);
assert.ok(hasPoint(inset, vertex), `inset lost the zero-edge vertex: ${JSON.stringify(offsets)}`);
assert.ok(hasPoint(outset, vertex), `outset lost the zero-edge vertex: ${JSON.stringify(offsets)}`);
assert.ok(
inset.some((point) => {
const distance = Math.hypot(point[0] - vertex[0], point[1] - vertex[1]);
return distance > 1 && distance < 3;
}),
'inset must also retain the physical edge offset point',
);
assert.ok(
outset.some((point) => {
const distance = Math.hypot(point[0] - vertex[0], point[1] - vertex[1]);
return distance > 1 && distance < 3;
}),
'outset must also retain the physical edge offset point',
);
}
});
test('inwardNormal points into the rectangle', () => {
const poly = [[0, 0], [10, 0], [10, 6], [0, 6]];
const [nx, ny] = inwardNormal(poly, 0); // bottom edge → should point +y
@@ -976,6 +1006,59 @@ test('corner Split preserves the facade for thin and thick outer/divider matrice
}
});
test('near-collinear zero-depth Split divider never grows a masonry taper', () => {
const poly = [[100, 100], [900, 100], [900, 800], [600, 800], [600, 400], [100, 400]];
const dividerStrip = (segment, halfWidth) => {
const [x0, y0, x1, y1] = segment;
const dx = x1 - x0, dy = y1 - y0;
const length = Math.hypot(dx, dy);
const nx = -dy / length, ny = dx / length;
const at = (t, side) => [
x0 + dx * t + nx * halfWidth * side,
y0 + dy * t + ny * halfWidth * side,
];
// Endpoint caps are physical. Inspect only the divider interior, far past
// the maximum 100 cm half-depth used by this matrix.
return [at(0.2, -1), at(0.8, -1), at(0.8, 1), at(0.2, 1)];
};
let reference = null;
for (const outerCm of [1, 15, 100]) {
for (const deltaY of [-5, -2.5, 2.5, 5]) {
const fixture = cornerSplitFixture({
poly,
path: [[600, 400], [900, 400 + deltaY]],
outerCm,
dividerCm: 0,
});
const shared = wallIntervals(
fixture.rooms, fixture.walls, [], pitch, cellCm, GRID_PITCH,
).filter((interval) => interval.kind === 'shared');
assert.equal(shared.length, 2, `shared interval count at ${outerCm} cm / ${deltaY}`);
assert.ok(shared.every((interval) => interval.cm === 0));
const segment = fixture.divider[0];
const halfDepth = wallCmToUnits(outerCm, cellCm, GRID_PITCH) / 2;
const strip = dividerStrip(segment, Math.max(0.25, halfDepth * 0.75));
const overlap = geometryArea(intersection(fixture.after.geom, closedGeometry(strip)));
closeTo(overlap, 0, 1e-7);
if (outerCm === 15 && deltaY === 2.5) reference = fixture;
}
}
assert.ok(reference);
const permutedRooms = reference.rooms
.map((room, index) => ({ id: `zero-divider-${index}`, poly: [...room.poly].reverse() }))
.reverse();
const permuted = wallBodiesGeometry(
permutedRooms, reference.walls, [], [], pitch, cellCm, GRID_PITCH,
);
assert.ok(permuted);
closeTo(geometryDifferenceArea(reference.after.geom, permuted.geom), 0, 1e-7);
closeTo(geometryDifferenceArea(permuted.geom, reference.after.geom), 0, 1e-7);
});
test('corner Split keeps unequal exterior arms and is order/id/winding independent', () => {
const fixture = cornerSplitFixture({
outerOverrides: [