mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54d90efc80 | ||
|
|
eeb9c34825 | ||
|
|
c2112db5ab | ||
|
|
56834c5d46 | ||
|
|
93f86f8321 |
File diff suppressed because one or more lines are too long
+29
-1
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Vendored
+2
-2
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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-зоны, теперь остаются именно в ней, а не возвращаются в исходную зону
|
||||
из реестра. Уже сохранённые назначения восстанавливаются автоматически, а
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 и не менялся этим диффом.
|
||||
@@ -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`, не
|
||||
предмет код-ревью.
|
||||
@@ -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 оставлена технической
|
||||
свободой без явной формулы) — ни одна не блокирует приёмку, обе либо
|
||||
правятся косметически при следующей редакции, либо снимаются этой записью без
|
||||
нового цикла.
|
||||
@@ -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.
|
||||
@@ -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) |
|
||||
|
||||
## Правило актуализации
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user