mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 23:19:14 +00:00
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
This commit is contained in:
+154
@@ -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',
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user