mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
Вторая половина переработки узлов поверх ядра из прошлого коммита: - `junctionContractHoles` — объективный инвариант «тело ⊇ полосы ∪ веера в фасадной границе» как экспортная чистая функция; самопроверка на заведомо дырявой фикстуре входит в юниты. Первая формулировка детектора из ТЗ («окружено кладкой с ≥5 из 8 сторон») уточнена в §8.4 по факту измерения: она ложно флагует легитимный пол комнаты в острых внутренних углах. - `junctionNodeBound` — «гладкая» фасадная граница (конверт с обычными углами) экспортирована: ею клиппуются куски узла и ею же пользуются тесты. - юниты #302: веера/рефлекс/короткий толстый саппорт/детектор/репро end-to-end; хелпер тестов старого контракта переведён на strip-safe семантику по саппортам и фасадной границе. - смок `smoke_junction_holes`: контрактные пробы считаются в node из той же фикстуры и проверяются в браузере по реальному `d`-пути карточки. - сет из 16 golden-сцен стыков крупным планом (звёзды лучей: T/X/Y/острые/ почти коллинеарные/виртуальные/колонна/черновик) + сцена-репро владельца; билдер сцен и `zoomCenter` в harness. Контракт сцены `multiwall-junction-bevel-view-dark` инвертирован по решению владельца: проба в перекрытии полос обязана быть ЗАПОЛНЕНА (strip-safe), а не пустой. - семь мутантов §14, включая слепоту детектора и невозврат саппортов. - фикстура #197: площадь кладки выросла на 0.2 юнита² — слайвер вееров вдоль хорд фаски; константа обновлена с комментарием. Эталоны новых сцен идут отдельным коммитом с положенными трейлерами. Issue: #302 User-Visible: yes
105 lines
4.5 KiB
JavaScript
105 lines
4.5 KiB
JavaScript
// Issue #302: the rendered wall body of the owner's repro covers the junction
|
|
// contract — every support strip and sector fan, clipped to the facade bound.
|
|
//
|
|
// The units own the geometry rules; this smoke owns the wiring: the same
|
|
// probes that the pure pipeline must cover are checked against the actual
|
|
// `d` attribute the card renders, so a regression anywhere between the
|
|
// geometry pass and the DOM turns it red.
|
|
import { readFileSync } from 'node:fs';
|
|
import { launch, checkAll, finish } from './serve.mjs';
|
|
import {
|
|
wallBodiesGeometry, wallIntervals, buildMultiWallNodeMap,
|
|
junctionNodeGeometry, junctionNodeBound, MITRE_LIMIT,
|
|
} from '../test-build/wall-thickness.js';
|
|
import { GRID_PITCH, GRID_STEP_N, NORM_W } from '../test-build/space-geometry.js';
|
|
|
|
const fixture = JSON.parse(readFileSync(
|
|
new URL('../test/fixtures/302-junction-artifacts.json', import.meta.url), 'utf8',
|
|
));
|
|
const rooms = fixture.rooms.map((room) => ({
|
|
...room, poly: room.poly.map(([x, y]) => [x * NORM_W, y * NORM_W]),
|
|
}));
|
|
const map = buildMultiWallNodeMap(
|
|
wallIntervals(rooms, fixture.walls, [], GRID_STEP_N, fixture.cell_cm, GRID_PITCH, NORM_W)
|
|
.filter((iv) => !iv.open && iv.half > 0),
|
|
GRID_STEP_N * NORM_W * 0.04 * 4, NORM_W,
|
|
);
|
|
const bound = junctionNodeBound(
|
|
rooms, fixture.walls, [], GRID_STEP_N, fixture.cell_cm, GRID_PITCH, NORM_W, map,
|
|
);
|
|
const corners = junctionNodeGeometry(map);
|
|
const inPolygon = (points, x, y) => {
|
|
let inside = false;
|
|
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
|
const [xi, yi] = points[i], [xj, yj] = points[j];
|
|
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
|
|
}
|
|
return inside;
|
|
};
|
|
const inGeometry = (geom, x, y) => {
|
|
let inside = false;
|
|
for (const poly of geom || []) for (const ring of poly || []) {
|
|
if (inPolygon(ring, x, y)) inside = !inside;
|
|
}
|
|
return inside;
|
|
};
|
|
// Contract probes around every node: strip or fan, inside the facade bound.
|
|
const probes = [];
|
|
for (const node of map.nodes) {
|
|
const radius = MITRE_LIMIT * node.halfDepth + node.halfDepth;
|
|
const step = GRID_PITCH * 0.25;
|
|
for (let dx = -radius; dx <= radius; dx += step) {
|
|
for (let dy = -radius; dy <= radius; dy += step) {
|
|
const x = node.point[0] + dx, y = node.point[1] + dy;
|
|
const inStrip = node.rays.some((ray) => ray.supports.some((support) => {
|
|
const rx = x - node.point[0], ry = y - node.point[1];
|
|
const along = rx * ray.u[0] + ry * ray.u[1];
|
|
if (along < 0 || along > support.length) return false;
|
|
return Math.abs(rx * ray.u[1] - ry * ray.u[0]) <= support.halfDepth - step * 0.25;
|
|
}));
|
|
const inFan = !inStrip && corners.fans.some((fan) => inPolygon(fan, x, y));
|
|
if (!inStrip && !inFan) continue;
|
|
if (bound && !inGeometry(bound, x, y)) continue;
|
|
probes.push([x, y]);
|
|
}
|
|
}
|
|
}
|
|
if (probes.length < 500) {
|
|
console.error(`FAIL: contract probe set degenerated to ${probes.length} points`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const { page, browser } = await launch({ width: 1200, height: 1000 }, 1);
|
|
const res = await page.evaluate(async ({ sp, probes: probeList }) => {
|
|
const out = {};
|
|
const c = window.__card;
|
|
const settle = async () => {
|
|
for (let i = 0; i < 4; i++) await new Promise((r) => requestAnimationFrame(r));
|
|
await c.updateComplete;
|
|
};
|
|
sp.id = c._space;
|
|
c._serverCfg.spaces[c._serverCfg.spaces.findIndex((s) => s.id === c._space)] = sp;
|
|
c._cfgEpoch++; c.requestUpdate(); await settle();
|
|
await new Promise((r) => setTimeout(r, 600));
|
|
const united = c._wallUnionGeometry();
|
|
out.geometryProduced = !!united?.d;
|
|
const path = new Path2D(united.d);
|
|
const g = document.createElement('canvas').getContext('2d');
|
|
const holes = probeList.filter(([x, y]) =>
|
|
!g.isPointInPath(path, x, y, united.fillRule || 'evenodd'));
|
|
out.probes = probeList.length;
|
|
out.holes = holes.length;
|
|
out.holeSample = holes.slice(0, 5).map((p) => p.map((v) => +v.toFixed(1)));
|
|
out.rendered = !!c.shadowRoot.querySelector('.wallbody');
|
|
return out;
|
|
}, { sp: { ...JSON.parse(readFileSync(new URL('../test/fixtures/302-junction-artifacts.json', import.meta.url), 'utf8')), rooms: fixture.rooms, title: 'Repro', view_box: [0, 0, 1, 1], settings: { show_borders: true } }, probes });
|
|
const verdict = {
|
|
geometryProduced: res.geometryProduced,
|
|
rendered: res.rendered,
|
|
enoughProbes: res.probes >= 500,
|
|
noContractHoles: res.holes === 0,
|
|
};
|
|
if (res.holes) console.error('дыры:', res.holeSample, 'из', res.holes);
|
|
checkAll(verdict);
|
|
await finish(browser, verdict);
|