feat: junction nodes — detector, fans over the approved chamfer, scene set (#302)
Вторая половина переработки узлов поверх ядра из прошлого коммита: - `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
@@ -2,6 +2,9 @@ import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
|
||||
import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const junctionArtifactsFixture = JSON.parse(readFileSync(
|
||||
new URL('../../test/fixtures/302-junction-artifacts.json', import.meta.url), 'utf8',
|
||||
));
|
||||
const junctionPatchFixture = JSON.parse(readFileSync(
|
||||
new URL('../../test/fixtures/197-junction-patch.json', import.meta.url), 'utf8',
|
||||
));
|
||||
@@ -224,6 +227,75 @@ export function prepareGoldenFixture(scenario) {
|
||||
partitions: [], room_drafts: [], wall_columns: [], decor: [],
|
||||
});
|
||||
}
|
||||
if (scenario.junctionNode) {
|
||||
// #302: a close-up star node. Pie-slice rooms around the centre; every
|
||||
// arm is a shared edge carrying its own thickness; a virtual arm is the
|
||||
// same edge released by an open span.
|
||||
const spec = scenario.junctionNode;
|
||||
const C = [0.5, 0.5];
|
||||
const R = 0.3;
|
||||
const arms = spec.arms
|
||||
.map((arm) => ({ ...arm, rad: (arm.deg * Math.PI) / 180 }))
|
||||
.sort((a, b) => a.deg - b.deg);
|
||||
const endOf = (arm) => [
|
||||
C[0] + Math.cos(arm.rad) * R, C[1] + Math.sin(arm.rad) * R,
|
||||
];
|
||||
const rooms = [];
|
||||
for (let index = 0; index < arms.length; index++) {
|
||||
const a = arms[index];
|
||||
const b = arms[(index + 1) % arms.length];
|
||||
const sweep = ((b.deg - a.deg + 360) % 360) || 360;
|
||||
const arc = [];
|
||||
const steps = Math.max(1, Math.ceil(sweep / 60));
|
||||
for (let step = 0; step <= steps; step++) {
|
||||
const rad = ((a.deg + (sweep * step) / steps) * Math.PI) / 180;
|
||||
arc.push([C[0] + Math.cos(rad) * R, C[1] + Math.sin(rad) * R]);
|
||||
}
|
||||
rooms.push({
|
||||
id: `junction-slice-${index}`, name: `S${index}`, area: null,
|
||||
poly: [C.map((v) => v), ...arc],
|
||||
});
|
||||
}
|
||||
const walls = arms.map((arm) => {
|
||||
const end = endOf(arm);
|
||||
return { key: fixtureWallKey(C, end), a: [...C], b: [...end], cm: arm.cm };
|
||||
});
|
||||
const space = {
|
||||
id: scenario.space, title: 'Junction node', view_box: [0, 0, 1, 1],
|
||||
cell_cm: 5,
|
||||
rooms, walls,
|
||||
settings: { fill_mode: 'none', show_borders: true, show_names: false },
|
||||
};
|
||||
const virtual = arms.filter((arm) => arm.virtual);
|
||||
if (virtual.length) {
|
||||
space.open_spans = virtual.map((arm) => {
|
||||
const end = endOf(arm);
|
||||
return { a: [...C], b: [...end] };
|
||||
});
|
||||
}
|
||||
if (spec.column) {
|
||||
space.wall_columns = [{
|
||||
id: 'junction-column', center: [...C], shape: 'circle', size_cm: 40,
|
||||
}];
|
||||
}
|
||||
if (spec.draft) {
|
||||
space.room_drafts = [{
|
||||
id: 'junction-draft',
|
||||
points: [[0.5, 0.5], [0.75, 0.62]],
|
||||
segments: [{ cm: 15 }],
|
||||
}];
|
||||
}
|
||||
fixture.config.spaces.push(space);
|
||||
}
|
||||
if (scenario.junctionArtifacts) {
|
||||
fixture.config.spaces.push({
|
||||
...structuredClone(junctionArtifactsFixture),
|
||||
id: scenario.space,
|
||||
title: 'Junction artifacts repro',
|
||||
view_box: [0, 0, 1, 1],
|
||||
settings: { fill_mode: 'none', show_borders: true, show_names: false },
|
||||
});
|
||||
}
|
||||
if (scenario.junctionPatchResilience) {
|
||||
if (!Array.isArray(scenario.retainedWedgeProbe)
|
||||
|| scenario.retainedWedgeProbe.length !== 2
|
||||
@@ -262,7 +334,7 @@ export function prepareGoldenFixture(scenario) {
|
||||
const contract = scenario.multiWallJunction;
|
||||
const validPoint = (point) => Array.isArray(point) && point.length === 2
|
||||
&& point.every(Number.isFinite);
|
||||
if (!validPoint(contract.node) || !validPoint(contract.discardedWedgeProbe)
|
||||
if (!validPoint(contract.node) || !validPoint(contract.retainedOverlapProbe)
|
||||
|| !Number.isInteger(contract.rays) || contract.rays < 3
|
||||
|| !Number.isInteger(contract.enclosedHoles) || contract.enclosedHoles < 0) {
|
||||
throw new Error(`invalid golden multiWallJunction: ${scenario.id}`);
|
||||
@@ -728,11 +800,11 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
}
|
||||
}
|
||||
if (scenario.multiWallJunction) {
|
||||
const { node, discardedWedgeProbe, enclosedHoles } = scenario.multiWallJunction;
|
||||
const { node, retainedOverlapProbe, enclosedHoles } = scenario.multiWallJunction;
|
||||
const wall = card.renderRoot.querySelector('[data-hp="wall"]');
|
||||
const at = (point) => new DOMPoint(point[0] * 1000, point[1] * card._spaceH);
|
||||
if (!wall?.isPointInFill?.(at(node))
|
||||
|| wall.isPointInFill(at(discardedWedgeProbe))) {
|
||||
|| !wall.isPointInFill(at(retainedOverlapProbe))) {
|
||||
throw new Error(`golden multi-wall bevel contract failed: ${scenario.id}`);
|
||||
}
|
||||
// A pixel threshold missed the reported triangles because they occupy a
|
||||
@@ -885,7 +957,8 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
await until(() => card._renderProjection === 'iso');
|
||||
}
|
||||
if (Number.isFinite(scenario.zoom)) {
|
||||
card._applyView(scenario.zoom, 500, 500);
|
||||
const [zx, zy] = Array.isArray(scenario.zoomCenter) ? scenario.zoomCenter : [500, 500];
|
||||
card._applyView(scenario.zoom, zx, zy);
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
}
|
||||
|
||||
@@ -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 = 44;
|
||||
export const GOLDEN_MATRIX_VERSION = 45;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
@@ -306,7 +306,10 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
space: 'golden-multiwall-junction', mode: 'view',
|
||||
multiWallJunction: {
|
||||
node: [0.329166667, 0.141666667], rays: 3,
|
||||
discardedWedgeProbe: [0.3303808442725, 0.1488560107825],
|
||||
// #302: the chamfer is strip-safe — this point sits in the overlap of
|
||||
// two incident strips and must stay FILLED (it used to be discarded by
|
||||
// the old subtractive bevel, which is exactly the hole class fixed).
|
||||
retainedOverlapProbe: [0.3303808442725, 0.1488560107825],
|
||||
// Product contract (#272): a bounded exterior bevel is allowed, but no
|
||||
// empty component may remain trapped inside the continuous masonry.
|
||||
enclosedHoles: 0,
|
||||
@@ -571,6 +574,53 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
hoverRoom: 'light-right', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'hover-nested-room-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
hoverRoom: 'geo-nested', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
// --- #302: junction close-ups. One node per scene, the node fills the
|
||||
// frame; the same scenes carry the objective no-holes detector in tests.
|
||||
...[
|
||||
['junction-t-90-equal15-dark', { arms: [
|
||||
{ deg: 0, cm: 15 }, { deg: 90, cm: 15 }, { deg: 180, cm: 15 }] }],
|
||||
['junction-t-90-bar50-leg15-dark', { arms: [
|
||||
{ deg: 0, cm: 50 }, { deg: 90, cm: 15 }, { deg: 180, cm: 50 }] }],
|
||||
['junction-t-90-bar70-leg15-dark', { arms: [
|
||||
{ deg: 0, cm: 70 }, { deg: 90, cm: 15 }, { deg: 180, cm: 70 }] }],
|
||||
['junction-x-90-equal15-dark', { arms: [
|
||||
{ deg: 0, cm: 15 }, { deg: 90, cm: 15 }, { deg: 180, cm: 15 },
|
||||
{ deg: 270, cm: 15 }] }],
|
||||
['junction-x-90-mixed-dark', { arms: [
|
||||
{ deg: 0, cm: 15 }, { deg: 90, cm: 50 }, { deg: 180, cm: 15 },
|
||||
{ deg: 270, cm: 70 }] }],
|
||||
['junction-x-45-alternating-dark', { arms: [
|
||||
{ deg: 45, cm: 15 }, { deg: 135, cm: 50 }, { deg: 225, cm: 15 },
|
||||
{ deg: 315, cm: 50 }] }],
|
||||
['junction-star5-equal15-dark', { arms: [
|
||||
{ deg: 0, cm: 15 }, { deg: 72, cm: 15 }, { deg: 144, cm: 15 },
|
||||
{ deg: 216, cm: 15 }, { deg: 288, cm: 15 }] }],
|
||||
['junction-y-60-equal50-dark', { arms: [
|
||||
{ deg: 0, cm: 50 }, { deg: 60, cm: 50 }, { deg: 120, cm: 50 }] }],
|
||||
['junction-acute30-mixed-dark', { arms: [
|
||||
{ deg: 0, cm: 50 }, { deg: 30, cm: 70 }, { deg: 180, cm: 15 }] }],
|
||||
['junction-acute15-equal50-dark', { arms: [
|
||||
{ deg: 0, cm: 50 }, { deg: 15, cm: 50 }, { deg: 180, cm: 50 }] }],
|
||||
['junction-splay10-170-dark', { arms: [
|
||||
{ deg: 0, cm: 15 }, { deg: 10, cm: 15 }, { deg: 180, cm: 50 }] }],
|
||||
['junction-t-virtual-arm-dark', { arms: [
|
||||
{ deg: 0, cm: 15 }, { deg: 90, cm: 15, virtual: true },
|
||||
{ deg: 180, cm: 15 }] }],
|
||||
['junction-x-virtual-through-dark', { arms: [
|
||||
{ deg: 0, cm: 15 }, { deg: 90, cm: 15, virtual: true },
|
||||
{ deg: 180, cm: 15 }, { deg: 270, cm: 15, virtual: true }] }],
|
||||
['junction-column-node-dark', { column: true, arms: [
|
||||
{ deg: 0, cm: 15 }, { deg: 90, cm: 15 }, { deg: 180, cm: 15 }] }],
|
||||
['junction-draft-end-node-dark', { draft: true, arms: [
|
||||
{ deg: 0, cm: 15 }, { deg: 90, cm: 15 }, { deg: 180, cm: 15 }] }],
|
||||
].map(([id, junctionNode]) => ({
|
||||
id, fixture: 'visual', space: `golden-${id}`, mode: 'view', junctionNode,
|
||||
zoom: 4, theme: 'dark', viewport: { width: 900, height: 900 }, ...stage,
|
||||
})),
|
||||
{ id: 'junction-owner-repro-dark', fixture: 'visual',
|
||||
space: 'golden-junction-owner-repro', mode: 'view', junctionArtifacts: true,
|
||||
zoom: 6, zoomCenter: [329.17, 141.67], theme: 'dark',
|
||||
viewport: { width: 900, height: 900 }, ...stage },
|
||||
{ id: 'large-house-zoom-040-dark', fixture: 'large', space: 'perf-floor-1', mode: 'view',
|
||||
zoom: 0.4, theme: 'dark', viewport: { width: 1180, height: 900 }, ...stage },
|
||||
{ id: 'large-house-zoom-250-dark', fixture: 'large', space: 'perf-floor-1', mode: 'view',
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// 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);
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Wall junctions no longer leave white wedges and notches. A node where three
|
||||
or more walls meet is now completed with sector fans and the exact strips of
|
||||
its rays on top of the existing chamfer: an acute junction is solid masonry,
|
||||
and the chamfer cut survives only where it does not touch the walls
|
||||
themselves. The no-holes invariant is checked automatically on a set of 16
|
||||
close-up junction scenes and on the plan from the original report
|
||||
([#302](https://github.com/Matysh/houseplan-card/issues/302)).
|
||||
- Every Plan editor tool now shows the same complete set of wall axes and
|
||||
structural endpoints. Switching from Walls to Thickness no longer hides room
|
||||
axes, while each tool keeps its own hints and live preview
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Стыки стен больше не оставляют белых клиньев и зазубрин. Узел, где сходятся
|
||||
три и больше стен, теперь достраивается угловыми веерами и точными полосами
|
||||
своих лучей поверх прежней фаски: острый стык — сплошная кладка, срез фаски
|
||||
остаётся только там, где он не задевает сами стены. Инвариант «дыр нет»
|
||||
проверяется автоматически на сете из 16 крупноплановых сцен стыков и на
|
||||
плане из исходного сообщения
|
||||
([#302](https://github.com/Matysh/houseplan-card/issues/302)).
|
||||
- Во всех инструментах редактора Плана теперь отображается один и тот же полный
|
||||
набор осевых линий и узлов стен. При переходе из «Стены» в «Толщина» оси
|
||||
комнат больше не исчезают, а подсказки и предпросмотр активного инструмента
|
||||
|
||||
@@ -185,6 +185,21 @@ excluded from this shared `roomGeom`, so a door does not change the room fill
|
||||
and a detached body cannot punch it. Full and Static render paths reuse the same
|
||||
structural cache instead of rebuilding wall booleans once per room.
|
||||
|
||||
**Junction nodes (#302).** A degree-3+ node keeps the approved #249 chamfer
|
||||
(`bevelMultiWallBody`, bounded by the node's join limit
|
||||
`MULTI_WALL_JOIN_LIMIT × halfDepth`), and AFTER it the node additively gets
|
||||
back what a chamfer must never eat: the exact support quads of its rays (each
|
||||
bounded by its own finite length, so a trimmed lateral phantom — #271 — cannot
|
||||
come back) and one sector fan per pair of angularly adjacent rays
|
||||
(`junctionNodeGeometry`): mitre up to the node limit, bevel chord past it,
|
||||
reflex sectors skipped. The pieces are clipped by the plain-corner facade
|
||||
bound (`junctionNodeBound`), so a node can never grow new facade at a concave
|
||||
vertex. The result is strip-safe: an acute junction is solid masonry, and the
|
||||
objective invariant «body ⊇ support strips ∪ fans, inside the facade bound» is
|
||||
machine-checked by `junctionContractHoles` in tests and the
|
||||
`smoke_junction_holes` wiring probe. Degenerate zero-area rings left by
|
||||
coincident chords are dropped.
|
||||
|
||||
**Hatch density is physical (#230).** The pattern step is a distance on the
|
||||
plan, not a count of coordinate units: `wallHatchStepUnits(cellCm)` returns
|
||||
`8 × (5 / cell_cm)`, which is 9.6 cm at every grid scale and exactly the
|
||||
|
||||
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 350 KiB After Width: | Height: | Size: 349 KiB |
|
Before Width: | Height: | Size: 338 KiB After Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 320 KiB After Width: | Height: | Size: 319 KiB |
|
Before Width: | Height: | Size: 319 KiB After Width: | Height: | Size: 317 KiB |
|
Before Width: | Height: | Size: 297 KiB After Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 141 KiB |
@@ -2,7 +2,7 @@
|
||||
"version": 1,
|
||||
"fixture": "synthetic-only",
|
||||
"chromium": "151.0.7922.34",
|
||||
"sourceFingerprint": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
|
||||
"sourceFingerprint": "4869370390a8aaa4066ea720457ccafc421b87da1879eda2e6273935a3a0f4d9",
|
||||
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
|
||||
"command": "npm run build && node demo/docs/capture.mjs",
|
||||
"scenarios": {
|
||||
@@ -14,8 +14,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
|
||||
"imageSha256": "c9e2203fe2df5c2627c861f92e9c4dfad585efb101df19e4b6388a6fbc27d01a"
|
||||
"sourceSha256": "4869370390a8aaa4066ea720457ccafc421b87da1879eda2e6273935a3a0f4d9",
|
||||
"imageSha256": "51551aa5237090b67ff4c48802b0f2518e884b48cb1b0319e782fced239953ea"
|
||||
},
|
||||
"view-touch": {
|
||||
"file": "02-view-touch.png",
|
||||
@@ -25,8 +25,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
|
||||
"imageSha256": "a544bd821460e5b9b7a83b1636ce6b6a3fc50be68fe4f74779d23ec9e12abe6a"
|
||||
"sourceSha256": "4869370390a8aaa4066ea720457ccafc421b87da1879eda2e6273935a3a0f4d9",
|
||||
"imageSha256": "e52f419772f86480b336a0ab63d0c781207101b262fdf73f5fd581527e47dce4"
|
||||
},
|
||||
"space-create": {
|
||||
"file": "03-space-create.png",
|
||||
@@ -36,8 +36,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
|
||||
"imageSha256": "6df7753f00b7d87cd35fd18fb8fbac0a2a7fa2e549902b3afee38f38c93a2ac0"
|
||||
"sourceSha256": "4869370390a8aaa4066ea720457ccafc421b87da1879eda2e6273935a3a0f4d9",
|
||||
"imageSha256": "329750c81a7a958c4fd105b2c80f85dce55ed87df9ddf0ea08084e4428e85f04"
|
||||
},
|
||||
"room-contour-close": {
|
||||
"file": "04-room-contour-close.png",
|
||||
@@ -47,8 +47,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
|
||||
"imageSha256": "0129b6a5f1f97fa5d29db0a30bfd899bcfe53308a8ba3872d2a794132fccc9cc"
|
||||
"sourceSha256": "4869370390a8aaa4066ea720457ccafc421b87da1879eda2e6273935a3a0f4d9",
|
||||
"imageSha256": "e9a847d17b9bd02ad9fa39a6e7194f980144e90538eb94413f770f9539ceb7bc"
|
||||
},
|
||||
"plan-context-tray": {
|
||||
"file": "05-plan-context-tray.png",
|
||||
@@ -58,8 +58,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
|
||||
"imageSha256": "7f243f578d2d286ae7911363571762e3910fe2117abc5253292d9ef2d578c8dc"
|
||||
"sourceSha256": "4869370390a8aaa4066ea720457ccafc421b87da1879eda2e6273935a3a0f4d9",
|
||||
"imageSha256": "7fd57bfc022211c5d37bd7c4a1a72b34ed7651fd577ada1d2314b64ae5061c21"
|
||||
},
|
||||
"device-editor": {
|
||||
"file": "06-device-editor.png",
|
||||
@@ -69,8 +69,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
|
||||
"imageSha256": "24710a46be38418a09c4bdb3db8750611affea593d80c91cfe74c105ef8ed201"
|
||||
"sourceSha256": "4869370390a8aaa4066ea720457ccafc421b87da1879eda2e6273935a3a0f4d9",
|
||||
"imageSha256": "0ef6798b79ff0b78e1947b1dba547b92cbdf11b79b6335cffb8a415158c09b5d"
|
||||
},
|
||||
"device-display-preview": {
|
||||
"file": "06-device-display-preview.png",
|
||||
@@ -80,8 +80,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
|
||||
"imageSha256": "1eaedd95180175f3db2c2e052bad70db63f6c70b296ec19c3d152ccbb81520ed"
|
||||
"sourceSha256": "4869370390a8aaa4066ea720457ccafc421b87da1879eda2e6273935a3a0f4d9",
|
||||
"imageSha256": "3842b3306d4e73a5b1d3475a96b4c1fd433c5c4334054a34d52f7cc0a20ae931"
|
||||
},
|
||||
"background-editor": {
|
||||
"file": "07-background-editor.png",
|
||||
@@ -91,8 +91,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
|
||||
"imageSha256": "460f098f7761a41c6b30e42940cb5c9d6f96966ba34e4df04ea273575c293e93"
|
||||
"sourceSha256": "4869370390a8aaa4066ea720457ccafc421b87da1879eda2e6273935a3a0f4d9",
|
||||
"imageSha256": "f91c9575404abc64c5f03bb7dc9283b80231ada6bfd88774cfdf7d53fb46e0b2"
|
||||
},
|
||||
"room-card": {
|
||||
"file": "08-room-card.png",
|
||||
@@ -102,8 +102,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
|
||||
"imageSha256": "66d8b0a509909822ff6b891a483bad9d2c72b20f08e287355883aa5205c6cee0"
|
||||
"sourceSha256": "4869370390a8aaa4066ea720457ccafc421b87da1879eda2e6273935a3a0f4d9",
|
||||
"imageSha256": "48a5685039a5e0de2a28190d857b193c86e2e63606e3c7a8c40fc9d2b190da4e"
|
||||
},
|
||||
"device-info": {
|
||||
"file": "09-device-info.png",
|
||||
@@ -113,8 +113,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
|
||||
"imageSha256": "29b23ec03b320c35b3b6d725b3e69dd7a2cfcd1607cfbbe01dceb37c316964c3"
|
||||
"sourceSha256": "4869370390a8aaa4066ea720457ccafc421b87da1879eda2e6273935a3a0f4d9",
|
||||
"imageSha256": "83debe3fc2853406f8bc37df55d162a0b0dadc8c4e997dda4db83c07ca25caab"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2209,6 +2209,77 @@ export const MUTANTS = [
|
||||
replace: ' .dev:not(.unavail):hover {',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'junction-fans-disabled',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="issue 302" test/wall-thickness.test.mjs',
|
||||
because: 'без вееров сектор между соседними полосами узла остаётся дырой — '
|
||||
+ 'это и есть класс артефактов #302',
|
||||
patches: [{
|
||||
file: 'src/wall-thickness.ts',
|
||||
find: ' if (mitre && mitreWithinStrips',
|
||||
replace: ' if (false && mitre && mitreWithinStrips',
|
||||
}, {
|
||||
file: 'src/wall-thickness.ts',
|
||||
find: ' push(out.fans, [[P[0], P[1]], EA, A2, B2, EB]);',
|
||||
replace: ' void A2; void B2;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'junction-supports-not-restored',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="hole-free end to end" test/wall-thickness.test.mjs',
|
||||
because: 'фаска старого слоя срезает материал полос на острых стыках; '
|
||||
+ 'без возврата саппорт-квадов узла репро владельца снова дырявое',
|
||||
patches: [{
|
||||
file: 'src/wall-thickness.ts',
|
||||
find: ' for (const piece of [...corners.supports, ...corners.fans]) {',
|
||||
replace: ' for (const piece of corners.fans) {',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'junction-fan-ignores-thick-length',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="issue 302" test/wall-thickness.test.mjs',
|
||||
because: 'веер, шагающий за короткий толстый саппорт, рисует латеральный '
|
||||
+ 'фантом рядом с тонким продолжением (#271)',
|
||||
patches: [{
|
||||
file: 'src/wall-thickness.ts',
|
||||
find: ' mitreWithinStrips = tA <= A.thickLength && tB <= B.thickLength;',
|
||||
replace: ' mitreWithinStrips = true;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'junction-reflex-sector-fanned',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="issue 302" test/wall-thickness.test.mjs',
|
||||
because: 'рефлексный сектор — внешность выпуклого угла; веер там — лишний '
|
||||
+ 'фасадный материал (контракт вогнутого Split)',
|
||||
patches: [{
|
||||
file: 'src/wall-thickness.ts',
|
||||
find: ' if (sector > Math.PI + 1e-9) continue;',
|
||||
replace: ' void sector;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'junction-detector-blind',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="issue 302" test/wall-thickness.test.mjs',
|
||||
because: 'слепой детектор превращает инвариант «нет дыр» в декорацию — '
|
||||
+ 'самопроверка на заведомо дырявой фикстуре обязана краснеть',
|
||||
patches: [{
|
||||
file: 'src/wall-thickness.ts',
|
||||
find: ' if (!inGeometry(geometry, x, y)) holes.push([x, y]);',
|
||||
replace: ' void x; void y;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'junction-pieces-unbounded',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="concave vertex" test/wall-thickness.test.mjs',
|
||||
because: 'куски узла без клипа гладкой фасадной границей отращивают новый '
|
||||
+ 'фасад на вогнутой вершине',
|
||||
patches: [{
|
||||
file: 'src/wall-thickness.ts',
|
||||
find: ' if (bound) ring = intersection(ring, bound);',
|
||||
replace: ' void bound;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'hatch-step-ignores-cell-cm',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
|
||||
@@ -2138,6 +2138,66 @@ export function junctionNodeFans(
|
||||
return junctionNodeGeometry(map).fans;
|
||||
}
|
||||
|
||||
/**
|
||||
* The objective no-holes invariant of #302 (spec §8.4).
|
||||
*
|
||||
* A probe is a HOLE when the contract says the point is masonry — inside one
|
||||
* of the node's support strips or sector fans, and inside the approved facade
|
||||
* bound — yet the produced geometry does not cover it. Legitimate floor of an
|
||||
* acute room corner never trips this (it is outside both strips and fans),
|
||||
* which is what the first "surrounded by masonry" formulation got wrong.
|
||||
*/
|
||||
export function junctionContractHoles(
|
||||
geometry: any,
|
||||
map: MultiWallNodeMap | null | undefined,
|
||||
options: { step: number; bound?: any },
|
||||
): { node: [number, number]; holes: number[][] }[] {
|
||||
if (!map?.nodes?.length || !(options.step > 0)) return [];
|
||||
const corners = junctionNodeGeometry(map);
|
||||
const inPolygon = (points: number[][], x: number, y: number): boolean => {
|
||||
let inside = false;
|
||||
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
|
||||
const [xi, yi] = points[i];
|
||||
const [xj, yj] = points[j];
|
||||
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi)
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
const inGeometry = (geom: any, x: number, y: number): boolean => {
|
||||
let inside = false;
|
||||
for (const polygon of geom || []) for (const ring of polygon || []) {
|
||||
if (inPolygon(ring, x, y)) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
const reports: { node: [number, number]; holes: number[][] }[] = [];
|
||||
for (const node of map.nodes) {
|
||||
const radius = MITRE_LIMIT * node.halfDepth + node.halfDepth;
|
||||
const holes: number[][] = [];
|
||||
for (let dx = -radius; dx <= radius; dx += options.step) {
|
||||
for (let dy = -radius; dy <= radius; dy += options.step) {
|
||||
const x = node.point[0] + dx;
|
||||
const y = node.point[1] + dy;
|
||||
const inStrip = node.rays.some((ray) => ray.supports.some((support) => {
|
||||
const rx = x - node.point[0];
|
||||
const 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 - options.step * 0.25;
|
||||
}));
|
||||
const inFan = !inStrip && corners.fans.some((fan) => inPolygon(fan, x, y));
|
||||
if (!inStrip && !inFan) continue;
|
||||
if (options.bound && !inGeometry(options.bound, x, y)) continue;
|
||||
if (!inGeometry(geometry, x, y)) holes.push([x, y]);
|
||||
}
|
||||
}
|
||||
if (holes.length) reports.push({ node: [...node.point], holes });
|
||||
}
|
||||
return reports;
|
||||
}
|
||||
|
||||
/** Find the canonical degree-3+ node matching a contour vertex. */
|
||||
export function multiWallNodeAt(
|
||||
map: MultiWallNodeMap | null | undefined,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"cell_cm": 30,
|
||||
"rooms": [
|
||||
{
|
||||
"id": "rmrwhxasn",
|
||||
"poly": [
|
||||
[
|
||||
0.23333333333333334,
|
||||
0.029166666666666667
|
||||
],
|
||||
[
|
||||
0.525,
|
||||
0.029166666666666667
|
||||
],
|
||||
[
|
||||
0.525,
|
||||
0.7666666666666667
|
||||
],
|
||||
[
|
||||
0.23333333333333334,
|
||||
0.7666666666666667
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rmt4w8xzu-0",
|
||||
"poly": [
|
||||
[
|
||||
0.30833333333333335,
|
||||
0.2375
|
||||
],
|
||||
[
|
||||
0.32916666666666666,
|
||||
0.14166666666666666
|
||||
],
|
||||
[
|
||||
0.37916666666666665,
|
||||
0.19166666666666668
|
||||
],
|
||||
[
|
||||
0.37916666666666665,
|
||||
0.2375
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "rmt4w9f2r-0",
|
||||
"poly": [
|
||||
[
|
||||
0.37916666666666665,
|
||||
0.19166666666666668
|
||||
],
|
||||
[
|
||||
0.32916666666666666,
|
||||
0.14166666666666666
|
||||
],
|
||||
[
|
||||
0.4083333333333333,
|
||||
0.1
|
||||
],
|
||||
[
|
||||
0.43333333333333335,
|
||||
0.15833333333333333
|
||||
]
|
||||
]
|
||||
}
|
||||
],
|
||||
"walls": [
|
||||
{
|
||||
"key": "0.320833,0.191667@1.7850",
|
||||
"cm": 50,
|
||||
"a": [
|
||||
0.30833333333333335,
|
||||
0.2375
|
||||
],
|
||||
"b": [
|
||||
0.32916666666666666,
|
||||
0.14166666666666666
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.370833,0.120833@2.6572",
|
||||
"cm": 50,
|
||||
"a": [
|
||||
0.32916666666666666,
|
||||
0.14166666666666666
|
||||
],
|
||||
"b": [
|
||||
0.4083333333333333,
|
||||
0.1
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.345833,0.237500@0.0000",
|
||||
"cm": 15,
|
||||
"a": [
|
||||
0.37916666666666665,
|
||||
0.2375
|
||||
],
|
||||
"b": [
|
||||
0.30833333333333335,
|
||||
0.2375
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.354167,0.166667@0.7856",
|
||||
"cm": 70,
|
||||
"a": [
|
||||
0.32916666666666666,
|
||||
0.14166666666666666
|
||||
],
|
||||
"b": [
|
||||
0.37916666666666665,
|
||||
0.19166666666666668
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.408333,0.175000@2.5900",
|
||||
"cm": 50,
|
||||
"a": [
|
||||
0.43333333333333335,
|
||||
0.15833333333333333
|
||||
],
|
||||
"b": [
|
||||
0.37916666666666665,
|
||||
0.19166666666666668
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.420833,0.129167@1.1661",
|
||||
"cm": 50,
|
||||
"a": [
|
||||
0.4083333333333333,
|
||||
0.1
|
||||
],
|
||||
"b": [
|
||||
0.43333333333333335,
|
||||
0.15833333333333333
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "0.379167,0.216667@1.5706",
|
||||
"cm": 25,
|
||||
"a": [
|
||||
0.37916666666666665,
|
||||
0.19166666666666668
|
||||
],
|
||||
"b": [
|
||||
0.37916666666666665,
|
||||
0.2375
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -367,7 +367,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, 44);
|
||||
assert.equal(GOLDEN_MATRIX_VERSION, 45);
|
||||
assert.equal(space.settings.sun_rays, true);
|
||||
assert.equal(scenario.northDeg, 90,
|
||||
'the sign-sensitive golden must keep a non-zero north direction');
|
||||
@@ -466,7 +466,7 @@ test('issue #249 golden isolates a bounded physical three-ray bevel', () => {
|
||||
assert.equal(scenario.theme, 'dark');
|
||||
assert.equal(scenario.multiWallJunction.rays, 3);
|
||||
assert.equal(scenario.multiWallJunction.node.length, 2);
|
||||
assert.equal(scenario.multiWallJunction.discardedWedgeProbe.length, 2);
|
||||
assert.equal(scenario.multiWallJunction.retainedOverlapProbe.length, 2);
|
||||
assert.equal(scenario.multiWallJunction.enclosedHoles, 0,
|
||||
'the product contract must not preserve the temporary two-hole inventory');
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
WALL_HATCH_MIN_PX,
|
||||
wallHatchStepUnits, wallHatchNeedsSolid,
|
||||
HATCH_BASE_STEP_UNITS, HATCH_MIN_STEP_UNITS, HATCH_MAX_STEP_UNITS,
|
||||
junctionNodeBound,
|
||||
junctionNodeBound, junctionNodeGeometry, junctionContractHoles,
|
||||
} from '../test-build/wall-thickness.js';
|
||||
import { polygonArea, paperRoomShapes, splitRoomPath, sharedBoundary } from '../test-build/logic.js';
|
||||
import { resolveOpenCuts } from '../test-build/open-spans.js';
|
||||
@@ -2897,3 +2897,118 @@ test('ownEdgeOffsets reads the atomic profile, not a whole-edge lookup (#233)',
|
||||
assert.deepEqual(ownEdgeOffsets(rooms, 'r', [], [], pitch, 5, GRID_PITCH, 1), [0, 0, 0, 0]);
|
||||
assert.equal(ownEdgeOffsets(rooms, 'missing', whole, [], pitch, 5, GRID_PITCH, 1), null);
|
||||
});
|
||||
|
||||
// --- issue #302: junction node corners --------------------------------------
|
||||
|
||||
const nodeMapOf = (intervals, eps = 1e-6) => buildMultiWallNodeMap(intervals, eps, 1);
|
||||
const starIntervals = (arms) => arms.flatMap(({ deg, half, len = 300 }, index) => {
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
const b = [500 + Math.cos(rad) * len, 500 + Math.sin(rad) * len];
|
||||
return [{
|
||||
key: `arm-${index}`, a: [500, 500], b, half, cm: half * 2,
|
||||
kind: 'outer', open: false, roomId: `r${index}`,
|
||||
}];
|
||||
});
|
||||
|
||||
test('issue 302 a T node covers every sector with a fan or a mitre', () => {
|
||||
const map = nodeMapOf(starIntervals([
|
||||
{ deg: 0, half: 5 }, { deg: 90, half: 5 }, { deg: 180, half: 5 },
|
||||
]));
|
||||
assert.equal(map.nodes.length, 1);
|
||||
const { fans, supports } = junctionNodeGeometry(map);
|
||||
assert.equal(supports.length, 3, 'one quad per support');
|
||||
// Sectors: [0..90], [90..180] (two quarter fans) and [180..360] reflex —
|
||||
// skipped: the outside of the bar corner is legitimately empty.
|
||||
assert.equal(fans.length, 2);
|
||||
});
|
||||
|
||||
test('issue 302 a reflex sector adds nothing', () => {
|
||||
const map = nodeMapOf(starIntervals([
|
||||
{ deg: 0, half: 5 }, { deg: 30, half: 5 }, { deg: 100, half: 5 },
|
||||
]));
|
||||
const { fans } = junctionNodeGeometry(map);
|
||||
// [0..30] and [30..100] are real sectors; [100..360] (260°) is the outside
|
||||
// of the convex corner — legitimately empty, no fan.
|
||||
assert.equal(fans.length, 2);
|
||||
});
|
||||
|
||||
test('issue 302 a mitre past the short thick support degrades to a bevel', () => {
|
||||
// The mitre point lies inside the node limit but PAST the 2-unit thick
|
||||
// support of the 0° ray: accepting it would paint a lateral phantom beside
|
||||
// the thin continuation (#271), so the pair must fall back to a bevel.
|
||||
const map = nodeMapOf([
|
||||
...starIntervals([{ deg: 90, half: 2.5 }, { deg: 210, half: 2.5 }]),
|
||||
{ key: 'short-thick', a: [500, 500], b: [502, 500], half: 10, cm: 20,
|
||||
kind: 'outer', open: false, roomId: 'rt' },
|
||||
]);
|
||||
assert.equal(map.nodes.length, 1);
|
||||
const { fans } = junctionNodeGeometry(map);
|
||||
// The [0°..90°] fan: mitre would sit at (502.5, 510) — inside the limit
|
||||
// (10.3 < 12.5) but past the thick support (t = 2.5 > 2) → five-point bevel.
|
||||
const fan = fans.find((poly) => poly.some((point) => point[1] > 505));
|
||||
assert.ok(fan, 'the sector fan between the thick and the vertical ray is gone');
|
||||
assert.equal(fan.length, 5, 'the mitre past the thick support was accepted');
|
||||
for (const point of fan) {
|
||||
assert.ok(
|
||||
Math.hypot(point[0] - 500, point[1] - 500) <= map.nodes[0].limit + 1e-7,
|
||||
`fan escaped the node limit: ${point}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('issue 302 the hole detector is not blind: a deliberately holed body is red', () => {
|
||||
const map = nodeMapOf(starIntervals([
|
||||
{ deg: 0, half: 5 }, { deg: 120, half: 5 }, { deg: 240, half: 5 },
|
||||
]));
|
||||
// A body that covers only two of the three strips: the third is a hole.
|
||||
const quad = (deg, half, len) => {
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
const u = [Math.cos(rad), Math.sin(rad)];
|
||||
const e = [-u[1] * half, u[0] * half];
|
||||
return [[
|
||||
[500 + e[0], 500 + e[1]],
|
||||
[500 + u[0] * len + e[0], 500 + u[1] * len + e[1]],
|
||||
[500 + u[0] * len - e[0], 500 + u[1] * len - e[1]],
|
||||
[500 - e[0], 500 - e[1]],
|
||||
[500 + e[0], 500 + e[1]],
|
||||
]];
|
||||
};
|
||||
const holed = [quad(0, 5, 300), quad(120, 5, 300)];
|
||||
const reports = junctionContractHoles(holed, map, { step: 2 });
|
||||
assert.equal(reports.length, 1, 'the detector missed the missing strip');
|
||||
assert.ok(reports[0].holes.length > 10);
|
||||
const full = [quad(0, 5, 300), quad(120, 5, 300), quad(240, 5, 300),
|
||||
...junctionNodeGeometry(map).fans.map((fan) => [[...fan, fan[0]]])];
|
||||
assert.equal(junctionContractHoles(full, map, { step: 2 }).length, 0,
|
||||
'the detector flags a body that covers the whole contract');
|
||||
});
|
||||
|
||||
test('issue 302 the owner repro is hole-free end to end', () => {
|
||||
const fixture = JSON.parse(readFileSync(
|
||||
new URL('./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 geometry = wallBodiesGeometry(
|
||||
rooms, fixture.walls, [], [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W,
|
||||
);
|
||||
assert.equal(geometry.status, 'ok');
|
||||
const map = buildMultiWallNodeMap(
|
||||
wallIntervals(rooms, fixture.walls, [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W)
|
||||
.filter((iv) => !iv.open && iv.half > 0),
|
||||
pitch * NORM_W * 0.04 * 4, NORM_W,
|
||||
);
|
||||
assert.ok(map.nodes.length >= 2, 'the repro lost its multi-wall nodes');
|
||||
const bound = junctionNodeBound(
|
||||
rooms, fixture.walls, [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W, map,
|
||||
);
|
||||
const reports = junctionContractHoles(geometry.geom, map, {
|
||||
step: GRID_PITCH * 0.2, bound,
|
||||
});
|
||||
assert.deepEqual(
|
||||
reports.map((report) => ({ node: report.node, holes: report.holes.length })),
|
||||
[],
|
||||
'the владелец repro still has junction holes',
|
||||
);
|
||||
});
|
||||
|
||||