Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31d7a70e34 | ||
|
|
96318fb94f | ||
|
|
d3927f2e56 | ||
|
|
b2b2640751 | ||
|
|
d8c7652d0e | ||
|
|
8a82189368 | ||
|
|
8eef4e9c5a | ||
|
|
cc937de0b1 | ||
|
|
d3ed299d2a | ||
|
|
9c396935dc | ||
|
|
ec8dc6fde1 | ||
|
|
58c096cef1 | ||
|
|
e88abcb138 | ||
|
|
9dbdbec03d | ||
|
|
c179a89876 | ||
|
|
df38c5c93b | ||
|
|
b6f80c56f0 | ||
|
|
aac501516e | ||
|
|
8af5ebefd2 | ||
|
|
ef22c2c6fd | ||
|
|
a6ef664bef | ||
|
|
76d60b3e1b | ||
|
|
a1361974a3 | ||
|
|
f1c76aa415 | ||
|
|
3df1dcf5a3 | ||
|
|
eef4e0d6d4 |
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"matrixVersion": 44,
|
||||
"acceptedAt": "2026-08-25T14:11:51.193Z",
|
||||
"sourceFingerprint": "34fbdde2302692a3fb53a9cb9b25bf3dd85f3dcbeaad074e3a5f615ef39884b4",
|
||||
"matrixVersion": 45,
|
||||
"acceptedAt": "2026-08-25T14:41:01.859Z",
|
||||
"sourceFingerprint": "8bca2e9a29df5fd509825fbfe2a790d7aa71c0f775867abf3328e3d609fa900d",
|
||||
"chromium": "151.0.7922.34",
|
||||
"scenarios": {
|
||||
"hidden-wall-diagnostics-plan-light": "6470ed8b09b21144ec1399389e9a57774e977a6473b8eaa7de7e9b5df3273f21",
|
||||
@@ -34,8 +34,8 @@
|
||||
"day-cycle-dusk-dark": "ec95e99ac3c7e4d6affa2f7944759727251dc4f1b62e2855e421a7e48f7280eb",
|
||||
"day-cycle-night-dark": "cc49dc53dabb63b7dd800eaa65c8748972fcf3c1abd948b34d1e0f973e63f0d2",
|
||||
"geometry-plan-editor-dark": "fd8d067daa3b2a4e572dea9644486e6324318e8ea4e5f43e3df2069a05389a88",
|
||||
"safe-resize-handles-clamp-light": "b7df3873e9412a02c6b0a9e7038ce06d88e3630a398c8f2e12469e378f48ca3d",
|
||||
"safe-resize-handles-clamp-dark": "e5e3d7eb3879998ec188bffd630cdd3fdf587f4960980b6a0fe8e91bc98c95c2",
|
||||
"safe-resize-handles-clamp-light": "ed705040357cad043095baf12d02cc69abdc3db27ff40066a6979be85d035f77",
|
||||
"safe-resize-handles-clamp-dark": "9d81a45f3acac787cde1b9eb74a858020fbad09670b8ff5b081869b5eca73b82",
|
||||
"space-tab-drop-before-light": "401dded3d7fdff3e823d46eac1b3cef2d6e0eb05f48b5ea289cdd8ecd941dee0",
|
||||
"space-tab-drop-after-dark": "a851621040e14f911b1080488f2c75e3d9bc0d5fef74fb5d0081f80b023ff0a2",
|
||||
"plan-snap-endpoint-light": "db967076cd9fd50c2a2a1337e4ecbf4ef67cb7f800401f6227497c75415951d5",
|
||||
@@ -93,6 +93,22 @@
|
||||
"lighting-manual-auto-spill-overlap-dark": "471e39397361832a171f0309302be7d8ba4310d628e337478e23da44dc455ef8",
|
||||
"hover-over-glow-dark": "6581433024060f773e00211d60eac7b6daffe442031ff076f861918419c71930",
|
||||
"hover-nested-room-dark": "f05ba332a127b56c00a6a94eac727b168ed34cc3f0355f8b783827f7bf55714c",
|
||||
"junction-t-90-equal15-dark": "fed083eaaaf1588222002c23f7edd741645c76f57c49a3db8cc5ec22af0b6e7d",
|
||||
"junction-t-90-bar50-leg15-dark": "e981f0597a55e9e9e623dd49417f33a0ebac18d18e0e66675a9af1f2224bd152",
|
||||
"junction-t-90-bar70-leg15-dark": "4428fbe75679fab6e57ef61c5d5097d498960e2539bb9e759e1bae9a160ef174",
|
||||
"junction-x-90-equal15-dark": "8fe0e262e5873714f94b9a6a76ed920881bc6b267e69153039f52a7102ec55d5",
|
||||
"junction-x-90-mixed-dark": "deabce4c2b5fa7866a4f56d25404c6a515d47437298bde114442bc80a6339560",
|
||||
"junction-x-45-alternating-dark": "56a85069baafd6e7af921389b55b916b74cc5d76df908782013b94c2b5b87752",
|
||||
"junction-star5-equal15-dark": "1b9714dffa3f37b9f1e8d848244c4585d38539b74fe582e8d974e2b9e8fdd01a",
|
||||
"junction-y-60-equal50-dark": "f90d46cfe79a95bb3ec73253d0b7f77948164e90c49561d79b1e0db655cc0847",
|
||||
"junction-acute30-mixed-dark": "9ea64ae5864295a1d93a6819efb6e210de2599030fe650913862d99f40b11a69",
|
||||
"junction-acute15-equal50-dark": "3327caf8bc1ccddb317aa7c4c330e09c098122a4f6d2e4df64502cdc033cb2e4",
|
||||
"junction-splay10-170-dark": "ae27454bb4213237313f1fe9e2591ada48facf20baca5517968f92aebbfb330e",
|
||||
"junction-t-virtual-arm-dark": "d325dc112e2f56c31d4511fd586adaa92b6785fc676c2a60ed5aba69c164fd6e",
|
||||
"junction-x-virtual-through-dark": "cce24ac9d7678959844803c6306e65ab48f723ec0375ee6d3f2dbf1c7def7be1",
|
||||
"junction-column-node-dark": "fed083eaaaf1588222002c23f7edd741645c76f57c49a3db8cc5ec22af0b6e7d",
|
||||
"junction-draft-end-node-dark": "6453bb8463ffdc9a42dbd7acb171c6249fe6d5beec0eae0c789e40f48e65a1f9",
|
||||
"junction-owner-repro-dark": "23ae216c2637b8ee9ea8f852002693f74d64021a66d4494e2086a53db8ca6520",
|
||||
"large-house-zoom-040-dark": "df1a5515089b5e470ae550ce6da31a301be5079d3f7bb9130baad99d3d7c641e",
|
||||
"large-house-zoom-250-dark": "88c0d22686fd9ef314866d0275a5ae785781c3d671e257b2c5f790f816cd65ee",
|
||||
"large-house-warm-remount-dark": "9a61d668c5fbc61bb370c043771cee91707b3d40378cf89f81199882820bf95c",
|
||||
|
||||
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 9.7 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 432 KiB After Width: | Height: | Size: 431 KiB |
|
Before Width: | Height: | Size: 456 KiB After Width: | Height: | Size: 455 KiB |
@@ -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',
|
||||
|
||||
@@ -391,6 +391,13 @@ const isoDarkDiff = await pixelDiff(isoDark.reference.pixels, isoDark.detailed.p
|
||||
const pixelEquivalent = (diff) => diff.sameSize
|
||||
&& diff.changed <= 150 && diff.maxDelta <= 40 && diff.meanDelta <= 0.05;
|
||||
|
||||
// Diagnostic only: the raw diff numbers behind every pixel verdict, so a CI
|
||||
// failure reports HOW FAR a pair drifted instead of a bare boolean (#302).
|
||||
console.error('pixel-diffs', JSON.stringify({
|
||||
flat: flatDiff, static: staticDiff, plan: planDiff, darkView: darkViewDiff,
|
||||
devices: devicesDiff, background: backgroundDiff,
|
||||
isoLight: isoLightDiff, isoDark: isoDarkDiff,
|
||||
}));
|
||||
const out = {
|
||||
referenceFactorIsOne: referenceView.metrics.factor === 1,
|
||||
detailedFactorIsFive: detailedView.metrics.factor === 5,
|
||||
|
||||
@@ -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);
|
||||
@@ -107,12 +107,15 @@ const result = await page.evaluate(async (source) => {
|
||||
const canonical = card._wallUnionGeometry();
|
||||
const node = new DOMPoint(source.node[0] * 1000, source.node[1] * 1000);
|
||||
// Midpoint between the R-bounded straight bevel and the old 1.80×H mitre.
|
||||
const discardedWedge = new DOMPoint(330.3808442725, 148.8560107825);
|
||||
// #302: this point lies in the OVERLAP of two incident strips, and the
|
||||
// chamfer is strip-safe now — it must stay filled (it used to be discarded,
|
||||
// which is exactly the hole class #302 removed).
|
||||
const retainedOverlap = new DOMPoint(330.3808442725, 148.8560107825);
|
||||
out.fixtureLoaded = card._spaceModel()?.rooms.length === 2
|
||||
&& card._spaceWalls.length === 7;
|
||||
out.planUsesCanonicalPath = !!planD && canonical?.d === planD;
|
||||
out.nodeRemainsFilled = !!path?.isPointInFill(node);
|
||||
out.excessWedgeIsEmpty = path && !path.isPointInFill(discardedWedge);
|
||||
out.stripOverlapRetained = !!path?.isPointInFill(retainedOverlap);
|
||||
out.planHasNoEnclosedHoles = enclosedSvgHoles(path, node) === 0;
|
||||
out.paperRemainsSolid = !!canonical?.paperD
|
||||
&& !!root().querySelector('.hp-paper');
|
||||
|
||||
@@ -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: a node closes with a full mitre, like an ordinary wall
|
||||
intersection on a drawing — the old junction chamfers are gone. 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 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Стыки стен больше не оставляют белых клиньев и зазубрин. Узел, где сходятся
|
||||
три и больше стен, теперь достраивается угловыми веерами и точными полосами
|
||||
своих лучей: узел смыкается полным mitre, как обычное пересечение стен на
|
||||
чертеже, — прежние фаски на стыках ушли. Инвариант «дыр нет»
|
||||
проверяется автоматически на сете из 16 крупноплановых сцен стыков и на
|
||||
плане из исходного сообщения
|
||||
([#302](https://github.com/Matysh/houseplan-card/issues/302)).
|
||||
- Во всех инструментах редактора Плана теперь отображается один и тот же полный
|
||||
набор осевых линий и узлов стен. При переходе из «Стены» в «Толщина» оси
|
||||
комнат больше не исчезают, а подсказки и предпросмотр активного инструмента
|
||||
|
||||
@@ -185,6 +185,24 @@ 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, owner decision #5).** A degree-3+ node closes with a
|
||||
FULL mitre, like an ordinary wall intersection on a drawing — the #249 chamfer
|
||||
is retired. For every pair of angularly adjacent rays `junctionNodeGeometry`
|
||||
builds one additive fan: the mitre is accepted when it sits IN the sector
|
||||
(forward along the rays for an ordinary pair, backward for a reflex outer
|
||||
corner), within the classic `MITRE_LIMIT` and never past a ray's thick
|
||||
support (#271); a reflex pair without a valid mitre closes with the plain
|
||||
chord, an ordinary one with a local bevel bounded by the support, the limit
|
||||
and twice the pair's depth. The node also gets the exact support quads of its
|
||||
rays. All pieces are clipped by the plain-corner facade bound
|
||||
(`junctionNodeBound`), so a node cannot grow new facade at a concave vertex.
|
||||
`bevelMultiWallBody` survives only as a TARGETED lateral trim for nodes with a
|
||||
degenerately short thick support (#271); every other node is purely additive.
|
||||
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
|
||||
@@ -395,6 +413,15 @@ Boundary/Thickness targets (`demo/smoke_optimize_coincident_partition.mjs`). The
|
||||
copying their contents into Git and checks raw, Optimize preview, applied
|
||||
canonical storage and reload states.
|
||||
|
||||
### Junction tooling (#302)
|
||||
|
||||
Purpose-built checks for node material: `junctionContractHoles` (the objective
|
||||
«body ⊇ strips ∪ fans inside the facade bound» invariant, self-checked against
|
||||
a deliberately holed fixture), the `smoke_junction_holes` wiring probe that
|
||||
verifies the same contract against the rendered `d` path, sixteen close-up
|
||||
golden scenes (`junction-*`) plus the owner's repro scene, and the
|
||||
`junction-*` mutants in `scripts/mutation-gate.mjs`.
|
||||
|
||||
## 9. Independent partitions, drafts and columns
|
||||
|
||||
Their thickness is stored directly in centimetres: 1–100 cm for draft and
|
||||
|
||||
|
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": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
|
||||
"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": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
|
||||
"imageSha256": "0ca982495a7749907c48ebbddfd089c54385a4347f79f64e94af2c5a8d5c03eb"
|
||||
},
|
||||
"view-touch": {
|
||||
"file": "02-view-touch.png",
|
||||
@@ -25,8 +25,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
|
||||
"imageSha256": "a544bd821460e5b9b7a83b1636ce6b6a3fc50be68fe4f74779d23ec9e12abe6a"
|
||||
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
|
||||
"imageSha256": "1c8e28ff34736b936ba7c9ec047d1da27aaf6fbb01411155f2d5c05282c02c41"
|
||||
},
|
||||
"space-create": {
|
||||
"file": "03-space-create.png",
|
||||
@@ -36,8 +36,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
|
||||
"imageSha256": "6df7753f00b7d87cd35fd18fb8fbac0a2a7fa2e549902b3afee38f38c93a2ac0"
|
||||
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
|
||||
"imageSha256": "329750c81a7a958c4fd105b2c80f85dce55ed87df9ddf0ea08084e4428e85f04"
|
||||
},
|
||||
"room-contour-close": {
|
||||
"file": "04-room-contour-close.png",
|
||||
@@ -47,8 +47,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
|
||||
"imageSha256": "0129b6a5f1f97fa5d29db0a30bfd899bcfe53308a8ba3872d2a794132fccc9cc"
|
||||
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
|
||||
"imageSha256": "ff2e903e548423aa0aa52680e20b8ad951c704e01130cb4b180222015a241276"
|
||||
},
|
||||
"plan-context-tray": {
|
||||
"file": "05-plan-context-tray.png",
|
||||
@@ -58,8 +58,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
|
||||
"imageSha256": "7f243f578d2d286ae7911363571762e3910fe2117abc5253292d9ef2d578c8dc"
|
||||
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
|
||||
"imageSha256": "ff55925f079411023442e7e7dd5ed528c331aaa3fd3fc2fbbf3b1aaf34a85718"
|
||||
},
|
||||
"device-editor": {
|
||||
"file": "06-device-editor.png",
|
||||
@@ -69,8 +69,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
|
||||
"imageSha256": "24710a46be38418a09c4bdb3db8750611affea593d80c91cfe74c105ef8ed201"
|
||||
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
|
||||
"imageSha256": "6b9cd281450e7782cff292f11efc58d227bedd0df86afc3be987feb2edec9dc3"
|
||||
},
|
||||
"device-display-preview": {
|
||||
"file": "06-device-display-preview.png",
|
||||
@@ -80,8 +80,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
|
||||
"imageSha256": "1eaedd95180175f3db2c2e052bad70db63f6c70b296ec19c3d152ccbb81520ed"
|
||||
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
|
||||
"imageSha256": "6f628abe6b89d7c4b352e6d73ac0b126526f6fdba5927bb774c5b884e484ca19"
|
||||
},
|
||||
"background-editor": {
|
||||
"file": "07-background-editor.png",
|
||||
@@ -91,8 +91,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
|
||||
"imageSha256": "460f098f7761a41c6b30e42940cb5c9d6f96966ba34e4df04ea273575c293e93"
|
||||
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
|
||||
"imageSha256": "a1d1a905f1410f73231d2ea5d1dfbd8ae79c2fe8b0a79cff87ebb977ac7a3103"
|
||||
},
|
||||
"room-card": {
|
||||
"file": "08-room-card.png",
|
||||
@@ -102,8 +102,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
|
||||
"imageSha256": "66d8b0a509909822ff6b891a483bad9d2c72b20f08e287355883aa5205c6cee0"
|
||||
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
|
||||
"imageSha256": "48a5685039a5e0de2a28190d857b193c86e2e63606e3c7a8c40fc9d2b190da4e"
|
||||
},
|
||||
"device-info": {
|
||||
"file": "09-device-info.png",
|
||||
@@ -113,8 +113,8 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "03f814f86bf3582d85993d03efe835babf6f5e2d2c6401124588e4eb0a7b9278",
|
||||
"imageSha256": "29b23ec03b320c35b3b6d725b3e69dd7a2cfcd1607cfbbe01dceb37c316964c3"
|
||||
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
|
||||
"imageSha256": "29a342811aaedaebc54ba337792d60cb454a7cf4c3063b210f6c4bafe0a4d87b"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
# CODE-REVIEW-302-r1
|
||||
|
||||
Issue: [#302](https://github.com/Matysh/houseplan-card/issues/302) — «Артефакты на стыках стен: дыры в union-геометрии. Переработка механизма стыков + полноценный сет скриншот-тестов»
|
||||
Спек: `docs/specs/302-junction-node-material.md` (зелёное ревью ТЗ, `SPEC-REVIEW-302-r1`/`r2`)
|
||||
Ветка: `issue/302-junction-node-material`, вершина `fa1112e7` (после ребейза на `origin/dev` = `2143e888`, включает #265/#301/#303)
|
||||
Заход: r1 · блокирующих циклов израсходовано 0 из 4 (этап код-ревью первый; предыдущая попытка `S7-code-review` не состоялась — упёрлась в конфликт ребейза до чтения кода, цикл не расходовался согласно комментарию владельца от 12:43)
|
||||
Класс изменения: A (продукт) + B (гейты/тесты) + C (документация)
|
||||
|
||||
## Скоуп
|
||||
|
||||
Полная переработка механики материала в узлах стен (degree-3+, три и более
|
||||
сходящихся луча): старая смешанная схема «аддитивные патчи + вычитающий
|
||||
`bevelMultiWallBody`-бевел (#249) с protection-union» заменяется на решение
|
||||
владельца №5 — **полный mitre по умолчанию** (веер `junctionNodeGeometry`,
|
||||
mitre в пределах `MITRE_LIMIT`, bevel-хорда за ним, рефлексные сектора
|
||||
замыкаются обратным mitre или хордой), плюс формальный детектор дыр
|
||||
`junctionContractHoles` и сет из 16 крупноплановых golden-сцен + сцена-репро
|
||||
владельца. Старый вычитающий слой `bevelMultiWallBody` не демонтирован
|
||||
целиком, а сужен до адресного латерального трима для вырожденных
|
||||
короткосаппортных узлов (#271); `bevelMultiWallPaper` из пути бумаги убран
|
||||
полностью.
|
||||
|
||||
Диапазон: `git diff origin/dev...HEAD` — 47 файлов, ядро в
|
||||
`src/wall-thickness.ts` (+351/-… строк), тесты (`test/wall-thickness.test.mjs`,
|
||||
новая фикстура `test/fixtures/302-junction-artifacts.json`), новый смок
|
||||
`demo/smoke_junction_holes.mjs`, обновлённый `demo/smoke_multiwall_junction.mjs`
|
||||
и `demo/smoke_grid_scale_invariance.mjs`, 7 новых мутантов
|
||||
`scripts/mutation-gate.mjs`, 16 новых + 2 изменённых golden-эталона,
|
||||
`docs/WALL-THICKNESS.md`, оба CHANGELOG.
|
||||
|
||||
Это первый содержательный проход код-ревью по этой задаче (первая попытка
|
||||
`S7-code-review` вернулась конвейером до чтения кода из-за конфликта
|
||||
ребейза — цикл не расходован, документ по дельте не нужен), поэтому разбор
|
||||
полный, а не по дельте (§2.10 не применяется).
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Материал — коммиты `git log --oneline origin/dev..HEAD` (14 штук) и
|
||||
`git diff origin/dev...HEAD`. Выполнено на этом дереве (SHA `fa1112e7745e0ba0`),
|
||||
не на слово автора.
|
||||
|
||||
| Гейт | Команда | Результат |
|
||||
|---|---|---|
|
||||
| typecheck | `npx tsc --noEmit` | чисто |
|
||||
| unit | `npm test` | 1303 pass / 1 skip / 0 fail (1304 объявлено) |
|
||||
| build + сверка бандла | `npm run build && cmp dist/… custom_components/…` | идентичны; `npm run bundle:sync` не изменил рабочее дерево (git status чист) |
|
||||
| docs screenshots fingerprint | `node scripts/check-docs.mjs` | «Documentation checks passed (7 files, 10 external links)» — обязателен, диф трогает `src/**` |
|
||||
| инварианты модели (диф трогает геометрию узлов/`walls`) | `npm run invariants -- --config <репро #302, обёрнутое {space:{...}}>` | «Инварианты выполнены: ссылки разрешимы, записи толщины находятся» |
|
||||
| golden | `npm run golden:verify` | **126/126 passed**, локально, целиком (совпадает с заявкой автора) |
|
||||
| смоки — прямые совпадения `smoke-select.mjs --base origin/dev --head HEAD` | `smoke_junction_holes`, `smoke_real_plan_masonry`, `smoke_grid_scale_invariance` | все OK |
|
||||
| смоки — AC9 плюс «зарегистрированная связь» | `smoke_multiwall_junction`, `smoke_wall_junctions`, `smoke_junction_patch_resilience`, `smoke_render_perf` | все OK |
|
||||
| мутанты #302 (выборочно, `--id=`) | `junction-fan-limit-back-to-249`, `junction-detector-blind`, `junction-pieces-unbounded`, `junction-supports-not-restored` | 4/4 корректно ловят поломку («поймано 1 из 1» / «поймано 0 из 1» для guard без мутации, как обязано) |
|
||||
| мутант `junction-fans-disabled` | `node scripts/mutation-gate.mjs --id=junction-fans-disabled` | **упал на чистом прогоне** — см. находку M1 |
|
||||
| process-gate офлайн | `node scripts/process-gate.mjs` | «гейт пройден, предупреждений 0» |
|
||||
| CI на вершине | `gh run view` на прогонах, указанных автором | подтверждено см. ниже |
|
||||
|
||||
Не прогонялось и почему:
|
||||
- `python -m pytest tests_backend` — диф не трогает `custom_components/**/*.py` (не нужен по правилу гейтов);
|
||||
- полный `demo/smoke_*.mjs` (189 файлов) — задача не «задевает всё», выборка
|
||||
дана `smoke-select.mjs` (5 прямых + 2 «зарегистрированная связь») плюс AC9;
|
||||
расширил её `smoke_render_perf` (AC8) вручную;
|
||||
- `npm run mutants` целиком (все ~90 мутантов проекта) — предрелизный гейт
|
||||
(§8), не гейт ревью; из новых семи прогнал четыре показательных плюс
|
||||
специально диагностировал сломанный;
|
||||
- полный performance-бенчмарк (`npm run benchmark:*`) — дорогой,
|
||||
предрелизный; вместо повторного прогона проверил, что CI job
|
||||
`performance_smoke` реально выполнился и был зелёным на `b95c55d3` (см. ниже),
|
||||
а не «reuse»-пропущен.
|
||||
|
||||
**CI, названный автором.** Прогон [32850091121](https://github.com/Matysh/houseplan-card/actions/runs/32850091121)
|
||||
на `b95c55d3` (родитель финального docs-коммита): `frontend`, `golden`,
|
||||
`performance_smoke`, `smoke` (все три шарда), `docs`, `provenance` —
|
||||
`success`; `process-gate` — `failure` (ожидаемо: before-SHA от форс-пуша,
|
||||
самовылечился следующим пушем, как и описал автор). Прогон
|
||||
[32850748475](https://github.com/Matysh/houseplan-card/actions/runs/32850748475)
|
||||
на финальном `fa1112e7` — `success` целиком; тяжёлые job'ы там `skipped`
|
||||
через легитимный `reuse` (доки — единственный дифф этого коммита, не
|
||||
затрагивающий их отпечаток). Вместе оба прогона покрывают полный набор на
|
||||
проверяемом дереве, как и заявил автор.
|
||||
|
||||
## AC — разбор
|
||||
|
||||
| AC | Статус | Доказательство |
|
||||
|---|---|---|
|
||||
| AC1 (детектор: 0 дыр на всём сете) | ✅ | golden verify 126/126 включает все 16 сцен + репро; `smoke_junction_holes` (прямое исполнение) `noContractHoles: true`; юнит-тест «the owner repro is hole-free end to end» зелёный |
|
||||
| AC2 (репро владельца: 0 дыр) | ✅ | тот же смок + golden `junction-owner-repro-dark` passed |
|
||||
| AC3 (несвязанные сцены — побайтно; junction-сцены — легально изменены) | ✅ | `baselines-index.json`: только 16 новых + 2 изменённых (`safe-resize-handles-clamp-*`, названо автором как узловая вершина ромба) хэша; остальные 108 не тронуты — подтверждено чтением индекса |
|
||||
| AC4 (57°, 50/70 — сплошная кладка) | ✅ | юнит «the 57° mixed-thickness pair takes the full mitre (decision #5)» проверяет ровно эту геометрию (углы 45°/102.3°/332.2°, half 4.861/3.472) |
|
||||
| AC5 (виртуальный луч не порождает кладку) | ✅ (чтением) | `junctionNodeGeometry`: `rays = node.rays.filter(ray.halfDepth > 0)` — нулевой/виртуальный луч исключён из веерного обхода до сортировки по азимуту, соседи по азимуту становятся его соседями автоматически; golden `junction-t-virtual-arm-dark`/`junction-x-virtual-through-dark` в сете |
|
||||
| AC6 (узловая механика не содержит `difference`) | ⚠️ частично — см. находку M1/M2 | Буквально неверно: `bevelMultiWallBody` (внутри — `difference`) по-прежнему вызывается для узлов с вырожденно-коротким толстым саппортом (`corePhase = 'multi-wall-trim'`, `wall-thickness.ts:3600-3613`). Регресс-мутант, названный в спеке для этого AC («лимит веера 1.25·h»), проверен и ловится (`junction-fan-limit-back-to-249`). Мутант, названный в спеке для «веера не строятся вовсе», сломан на инфраструктурном уровне — см. M2 |
|
||||
| AC7 (оба рендерера — один вызов) | ✅ (чтением) | `wallBodiesUnionPath` (единственная точка, `wall-thickness.ts:3714`) вызывается и из `houseplan-card.ts:11692`, и из `space-render.ts:432`; архитектура не менялась, только тело функции |
|
||||
| AC8 (перф в бюджете) | ✅ с оговоркой | `smoke_render_perf` OK; CI `performance_smoke` реально выполнился и зелёный на `b95c55d3` (не reuse-пропуск, проверено по списку job'ов). Явного числового «замера large-house» в хендоффе нет, спека требовала это отдельной строкой — не блокирует (Low, механизм проверки существует и сработал) |
|
||||
| AC9 (существующие смоки стыков зелёные) | ✅ | `wall_junctions`, `junction_patch_resilience`, `multiwall_junction`, `real_plan_masonry` — все прогнаны лично, OK |
|
||||
|
||||
## Находки
|
||||
|
||||
### Medium (в скоупе) — M1: `docs/WALL-THICKNESS.md` не переписан под решение №5, хотя спека требовала это явно
|
||||
|
||||
Файл: `docs/WALL-THICKNESS.md:188-196`.
|
||||
|
||||
Абзац «**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…»* — это описание
|
||||
**отменённого** утреннего решения «сохранить фаску #249», а не итогового
|
||||
решения №5 («узлы смыкаются полным mitre… фаска #249 демонтируется целиком»,
|
||||
`docs/specs/302-junction-node-material.md:65-73`). Фактическое поведение (по
|
||||
чтению `wall-thickness.ts:3591-3634`) обратное описанному в доке: фаска
|
||||
(`bevelMultiWallBody`) теперь **опциональна и включается только** для узлов
|
||||
с вырожденно-коротким толстым саппортом (`needsTrim`), а не «для каждого
|
||||
degree-3+ узла» как написано; для всех остальных узлов работает чистый
|
||||
аддитивный веер, что и есть содержательное отличие этой задачи.
|
||||
|
||||
Коммит `e8338663` («feat: full mitre at every node — the #249 chamfer
|
||||
retires», `User-Visible: yes`, решение №5) не тронул `docs/WALL-THICKNESS.md`
|
||||
вовсе (`git show --stat e8338663` не содержит этот файл). Единственный
|
||||
последующий докс-коммит `fa1112e7` добавил только раздел «Junction tooling»
|
||||
(список тестов), не исправив основной абзац.
|
||||
|
||||
Нарушает:
|
||||
- `PROCESS.md` правило 11: «Документация — в том же коммите, что поведение.
|
||||
Отдельным «допишу потом» коммитом документация не бывает» — коммит,
|
||||
сменивший контракт (`e8338663`, User-Visible: yes), не обновил канонический
|
||||
документ подсистемы;
|
||||
- собственное требование спеки, §15: «`docs/WALL-THICKNESS.md` §3 и §9
|
||||
переписываются под новую механику» — раздел §9 (Independent partitions)
|
||||
тоже не упоминает #302 вовсе, что ожидаемо (узел независимых партиций не
|
||||
трогается), но §3 обязан был обновиться и не обновился.
|
||||
|
||||
Почему это находка, а не педантизм: этот файл — канонический документ
|
||||
подсистемы, который явно предписано читать перед любой работой над стенами
|
||||
(`AGENTS.md`, сам этот ревью начиналось с его чтения). Ложное описание
|
||||
«узел всегда получает старую фаску» уведёт следующего агента/автора по
|
||||
неверному следу при следующей задаче на стыках.
|
||||
|
||||
Воспроизведение: `git diff origin/dev...HEAD -- docs/WALL-THICKNESS.md` —
|
||||
всего 24 добавленные строки, из которых 15 — новый раздел про тестирование;
|
||||
абзац §3 про механику узла не менялся с коммита `9e68d641` (12:33), то есть
|
||||
раньше решения №5 (15:13).
|
||||
|
||||
Фикс: переписать абзац `docs/WALL-THICKNESS.md:188-196` под фактический
|
||||
контракт — веер по умолчанию (mitre/bevel-хорда, рефлекс), `bevelMultiWallBody`
|
||||
только как адресный трим для короткосаппортных узлов, `bevelMultiWallPaper`
|
||||
убран из бумаги целиком.
|
||||
|
||||
### Medium (в скоупе) — M2: мутант `junction-fans-disabled` не работает, вопреки заявлению «краснота проверена исполнением»
|
||||
|
||||
Файл: `scripts/mutation-gate.mjs:2213-2225`.
|
||||
|
||||
```
|
||||
guard: 'node demo/smoke_junction_holes.mjs',
|
||||
```
|
||||
|
||||
`demo/smoke_junction_holes.mjs` — единственный смок в проекте, импортирующий
|
||||
напрямую из `../test-build/wall-thickness.js` (`grep -rl "from '../test-build/"
|
||||
demo/smoke_*.mjs` находит только его), поэтому его guard обязан собрать
|
||||
`test-build/` первым (как это делают все соседние #302-мутанты:
|
||||
`npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && …`).
|
||||
У этого мутанта такого префикса нет.
|
||||
|
||||
Харнесс сам решает, нужен ли `test-build`, эвристикой
|
||||
`guardNeedsTestBuild()` (`scripts/mutation-gate.mjs:2920-2922`):
|
||||
`/(^|[\s&|;])node --test\b/.test(guard) && !guard.includes('tsconfig.test.json')`
|
||||
— она матчит только guard'ы вида `node --test …`, а не «браузерный» смок,
|
||||
который тем не менее тоже читает `test-build/`. Результат — воспроизведено
|
||||
исполнением:
|
||||
|
||||
```
|
||||
$ node scripts/mutation-gate.mjs --id=junction-fans-disabled
|
||||
FAIL чистый прогон: node demo/smoke_junction_holes.mjs красный без мутанта
|
||||
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/tmp/hp-mutant-uvmoTK/test-build/wall-thickness.js' …
|
||||
```
|
||||
|
||||
Гейт падает уже на «чистом» прогоне (до применения мутации) — это
|
||||
означает, что мутант не проверял АБСОЛЮТНО ничего ни разу с момента
|
||||
создания: ни в момент написания, ни в любом гипотетическом прогоне харнесса
|
||||
он не мог напечатать «поймано 1 из 1». Это прямо противоречит явному
|
||||
заявлению в теле коммита `e8338663`: *«мутанты переякорены, краснота каждого
|
||||
проверена исполнением»* и в хендоффе issue: *«восемь мутантов — краснота
|
||||
каждого проверена исполнением штатным харнесом»*.
|
||||
|
||||
Мутант закрывает первую строку таблицы §14 спеки — `node-fan-disabled`
|
||||
(«веера не строятся вовсе» → «детектор на сете») — самый фундаментальный
|
||||
регресс-класс новой механики. Сейчас его прикрывает не он, а косвенно
|
||||
`junction-supports-not-restored` (другой мутант, который частично
|
||||
пересекается по эффекту — тоже режет вклад аддитивных кусков — и этот
|
||||
действительно ловится, «поймано 0 из 1» без мутации, «FAIL» ожидаемо на
|
||||
чистом прогоне... то есть сам по себе он в порядке). Но заявленное покрытие
|
||||
«веера отключены полностью» не проверено никем.
|
||||
|
||||
Не является блокирующим (High), потому что: (а) это дефект тестовой
|
||||
инфраструктуры, а не продуктового кода — сама геометрия работает и покрыта
|
||||
множеством других зелёных проверок (детектор, 126/126 golden, все AC9-смоки);
|
||||
(б) `mutation-gate` — предрелизный, не блокирующий Validate гейт (§8,
|
||||
`.github/workflows/mutation-gate.yml` — по расписанию/`workflow_dispatch`,
|
||||
не на каждый push), поэтому CI branch protection этим не введён в заблуждение
|
||||
формально. Но это Medium в скоупе: спека прямо обещала эту защиту (§14),
|
||||
коммит прямо заявил, что она проверена, и оба заявления не соответствуют
|
||||
действительности.
|
||||
|
||||
Фикс: добавить в `guard` этого мутанта тот же префикс сборки, что у соседей —
|
||||
`'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node demo/smoke_junction_holes.mjs'`
|
||||
— и повторно прогнать `--id=junction-fans-disabled`, чтобы подтвердить
|
||||
«поймано 1 из 1» на самом деле.
|
||||
|
||||
### Low — L1: `bevelMultiWallPaper` осталась мёртвым кодом
|
||||
|
||||
Файл: `src/wall-thickness.ts:2955-2984`.
|
||||
|
||||
Функция `bevelMultiWallPaper` (вычитающий бевел для контура бумаги) не имеет
|
||||
вызовов нигде в дереве (`grep -rn "bevelMultiWallPaper" src/*.ts test/*.mjs
|
||||
demo/*.mjs` находит только её собственное объявление) — коммит `e8338663`
|
||||
корректно убрал единственный вызов (`paperGeom`/`floorFootprintGeometry`
|
||||
теперь используют `paperWithNodeCorners`), но само тело функции осталось.
|
||||
Не экспортирована, поведения не меняет, `tsc --noEmit` её не флагует
|
||||
(в проекте нет `noUnusedLocals` для верхнеуровневых функций). Снимаю с
|
||||
записью — правьте свободно вместе с M1, либо отдельной строкой, решение за
|
||||
автором: это тривиальная уборка, не тянет на отдельный цикл сама по себе.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- Основной контракт (веер `junctionNodeGeometry`, границы mitre/bevel,
|
||||
фасадный клип `junctionNodeBound`, спец-случаи виртуального луча,
|
||||
коллинеарной пары, degree-1, колонны) прочитан построчно и соответствует
|
||||
§8.2-8.3 спеки; тесты `test/wall-thickness.test.mjs` («issue 302 …», 6 новых
|
||||
тестов) целятся именно в эти границы, включая регресс на 1.25·h-лимит
|
||||
(старый #249) и на вырожденный реверс-mitre (Y-60 из отчёта владельца).
|
||||
- Формальный детектор `junctionContractHoles` не «слеп»: юнит-тест кормит
|
||||
ему заведомо дырявое тело (2 из 3 полос) и требует красноты — прошёл;
|
||||
отдельно мутант `junction-detector-blind` подтверждает то же на живом
|
||||
харнессе.
|
||||
- Golden-набор реально расширяет покрытие матрицы §13 почти полностью:
|
||||
L отсутствует как отдельная сцена, но обоснованно — `buildMultiWallNodeMap`
|
||||
строит узлы только для `rays.length >= 3` (`wall-thickness.ts:1939`),
|
||||
обычный двухлучевой угол этим механизмом вообще не обрабатывается (это
|
||||
зона старого, не тронутого этой задачей `outsetContour`/`insetContour`
|
||||
mitre); включение «L» в таблицу §13 спеки было избыточным пожеланием, а не
|
||||
пропущенным требованием — не в счёт находок.
|
||||
- Трейлеры: оба `User-Visible: yes`-коммита (`9afc410d`, `e8338663`) несут
|
||||
правки в `docs/CHANGELOG.md` и `docs/CHANGELOG.ru.md` в том же коммите;
|
||||
формулировка финальной записи («the old junction chamfers are gone»)
|
||||
описывает видимый результат (визуально фаска исчезла — трим невидим,
|
||||
работает только на вырожденных геометриях) и не расходится с рендером.
|
||||
- Golden-коммиты несут `Release:`/`Baseline-Reviewed:` на реальные зелёные
|
||||
прогоны CI (проверено — оба run ID существуют и относятся к веткам этой
|
||||
задачи).
|
||||
- `process-gate.mjs` офлайн — 0 предупреждений; ветка полностью содержит
|
||||
`origin/dev` (ребейз произведён, конфликтов нет).
|
||||
- AC3 количественно: индекс эталонов содержит ровно 16 новых + 2 изменённых
|
||||
хэша из 126 (сверено чтением `baselines-index.json`, не поверил на слово).
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Полный `demo/smoke_*.mjs` (189 файлов) и полный `npm run mutants`
|
||||
(~90 мутантов) — предрелизные гейты, задача не задевает всё; выборка
|
||||
обоснована в таблице выше.
|
||||
- Локальный полный `python -m pytest tests_backend` — диф не трогает Python.
|
||||
- Числовой перф-профиль large-house вручную не переснимал; доверился
|
||||
зелёному CI job `performance_smoke` на `b95c55d3` (подтверждено — не reuse).
|
||||
- Не проверял три «зарегистрированные связи» смока `smoke_decor.mjs` и
|
||||
`smoke_space_scale_defaults.mjs` (символ `cellCm` — широко используемый,
|
||||
слабая связь по инструменту) — доверился их независимости от узловой
|
||||
механики по чтению кода (`cellCm` там используется вне контекста
|
||||
multi-wall-узлов).
|
||||
|
||||
## Вердикт
|
||||
|
||||
High: 0 · Medium: 2 (обе в скоупе) → возврат автору, без нового issue (#202).
|
||||
|
||||
Оба Medium дешёво чинятся: M1 — переписать один абзац
|
||||
`docs/WALL-THICKNESS.md:188-196` под фактический контракт; M2 — добавить
|
||||
build-префикс в guard одного мутанта и подтвердить «поймано 1 из 1»
|
||||
исполнением. Ни одна находка не требует правки продуктовой геометрии —
|
||||
сам механизм (AC1-AC5, AC7, AC9) проверен исполнением и корректен.
|
||||
@@ -0,0 +1,246 @@
|
||||
# CODE-REVIEW-302-r2
|
||||
|
||||
Issue: #302 · Этап: code · Заход: r2 · блокирующих циклов израсходовано 1/4 до
|
||||
этого захода
|
||||
|
||||
## Скоуп раунда
|
||||
|
||||
r1 (SHA `fa1112e7`) закончился жёлтым: High 0, Medium 2 (M1 — доку
|
||||
`docs/WALL-THICKNESS.md` не переписали под решение №5; M2 — гвард мутанта
|
||||
`junction-fans-disabled` инфраструктурно сломан, «поймано» не было проверено
|
||||
исполнением). Автор хендоффнул исправление на вершине `a5ea578`
|
||||
(комментарий issue 2026-08-25T14:00:05Z), затем конвейер перед ревью
|
||||
ребейзнул ветку на свежий `dev` — вершина стала `42e02396`.
|
||||
|
||||
**Проверка ребейза (§7.2).** `git diff a5ea578..42e02396 --stat` даёт ровно
|
||||
один файл: `docs/reviews/SPEC-REVIEW-304-r1.md` (+128, чужой issue). Дерево
|
||||
кода/тестов/доки #302 идентично байт-в-байт. Это не «другой код» — единственный
|
||||
привнесённый ребейзом файл не пересекается ни с одним путём этой задачи и не
|
||||
проходит фильтр `frontend`-триггера CI (подтверждено логом job `changes`,
|
||||
CI-прогон 32856428594). Разбор веду **по дельте** r1→r2: `git diff
|
||||
fa1112e7..HEAD` — четыре файла по существу (`docs/WALL-THICKNESS.md`,
|
||||
`scripts/mutation-gate.mjs`, `src/wall-thickness.ts`, плюс пересборка бандла и
|
||||
`docs/images/screenshots.json`), plus два review-документа. Расширил разбор за
|
||||
пределы формальной дельты в одном месте (см. находку M3) — при чтении
|
||||
`src/wall-thickness.ts` полностью для оценки M2/Low вокруг него нашёл
|
||||
расхождение AC6 с реализацией; код этого места не менялся между r1 и r2, но
|
||||
находка целиком в скоупе задачи, а не в скоупе только этой правки, поэтому
|
||||
привожу её отдельно, а не молчу.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
Гейты «всегда» (исполнением, на HEAD `42e02396`):
|
||||
|
||||
| гейт | команда | результат |
|
||||
|---|---|---|
|
||||
| типы | `npx tsc --noEmit` | чисто |
|
||||
| юниты | `npm test` | 1303 pass / 1 skip / 0 fail |
|
||||
| сборка | `npm run build` + `diff dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | rollup ok; `git status` после билда чист (свежий `dist` побайтно совпал с закоммиченным) — три копии (пересобранная, `dist/`, `custom_components/`) идентичны |
|
||||
| доки | `node scripts/check-docs.mjs` | «Documentation checks passed (7 files, 10 external links)» |
|
||||
| инварианты | `npm run invariants -- --config test/fixtures/302-junction-artifacts.json` | «ссылки разрешимы, записи толщины находятся» |
|
||||
|
||||
Мутанты, названные M2/Low (исполнением, штатным харнессом
|
||||
`scripts/mutation-gate.mjs --id=...`):
|
||||
|
||||
| id | результат |
|
||||
|---|---|
|
||||
| `junction-fans-disabled` | поймано 1 из 1 |
|
||||
| `junction-fan-ignores-thick-length` | поймано 1 из 1 |
|
||||
| `junction-reflex-outer-mitre-missing` | поймано 1 из 1 |
|
||||
| `junction-fan-limit-back-to-249` | поймано 1 из 1 |
|
||||
| `junction-detector-blind` | поймано 1 из 1 |
|
||||
| `junction-pieces-unbounded` | поймано 1 из 1 |
|
||||
| `partition-merge-ignores-junction` | поймано 1 из 1 |
|
||||
| `junction-checks-room-vertices-only` | поймано 1 из 1 |
|
||||
|
||||
`junction-supports-not-restored` и `multi-wall-paper-full-origin-cut` из
|
||||
реестра удалены этим диффом — проверено, что их find-паттерны (обращения к
|
||||
удалённым `paperWithNodeCorners`/`[...corners.supports, ...corners.fans]`)
|
||||
больше не существуют в файле: мутанты стали неприменимы к текущему коду, снятие
|
||||
не притворное.
|
||||
|
||||
Смоки/golden: `smoke-select.mjs` для дельты `fa1112e7..HEAD` даёт прямые
|
||||
совпадения `smoke_junction_holes`, `smoke_decor`, `smoke_grid_scale_invariance`,
|
||||
`smoke_real_plan_masonry`, `smoke_space_scale_defaults` и пять
|
||||
«зарегистрированных связей» (`junction_patch_resilience`, `multiwall_junction`,
|
||||
`multiwall_strip_containment`, `resize_pointer_real_plan`,
|
||||
`resize_wall_thickness`). Локально браузерные проверки (`golden:verify`,
|
||||
`demo/smoke_*.mjs`) **не выполнились**: `page.route` не перехватывает
|
||||
динамический `import()` в этом окружении — `Failed to fetch dynamically
|
||||
imported module: http://demo.local/assets/houseplan-card.js`. Проверил, что
|
||||
это ограничение песочницы, а не регрессия: та же ошибка воспроизводится
|
||||
байт-в-байт на чистом `origin/dev` (worktree, символическая ссылка на
|
||||
`node_modules`) с давно существующим `demo/smoke_align_guides.mjs`, который
|
||||
дифф не касается.
|
||||
|
||||
Вместо локального прогона поднял CI-прогоны по `gh run view`:
|
||||
- `32855773018` (headSha `30b4bbf6`, кодово идентичен `a5ea578`/`42e02396` по
|
||||
`src/**`/`test/**`/`scripts/**` — единственная разница дальше по цепочке это
|
||||
фикс отпечатка скриншотов) — `frontend`, `golden`, все три шарда `smoke`,
|
||||
`performance_smoke` зелёные; красным был только `docs` (протухший отпечаток
|
||||
скриншотов — это и есть то, что чинит следующий коммит `42e02396`, дока
|
||||
«refresh the screenshot fingerprint»);
|
||||
- `32856428594` (headSha `a5ea578`) — `docs` зелёный (фингерпринт поправлен),
|
||||
`frontend`/`golden`/`smoke` пропущены самим `changes`-джобом: между
|
||||
`30b4bbf6` и `a5ea578` изменился только `docs/images/screenshots.json`, под
|
||||
фронтенд-триггер не попадает;
|
||||
- `32856829923` (headSha `42e02396`, текущий HEAD) — красный один
|
||||
`process-gate` на несуществующем в раннере before-SHA (артефакт force-push
|
||||
после ребейза на dev, автор описывает его же во всех трёх последних
|
||||
хендоффах); `frontend`/`docs`/`provenance` зелёные, `golden`/`smoke`
|
||||
пропущены — тем же основанием (после `a5ea578` изменился только чужой
|
||||
`SPEC-REVIEW-304-r1.md`).
|
||||
|
||||
Собранные вместе, эти три прогона покрывают полный набор гейтов на
|
||||
кодово-идентичном дереве, включая golden и все смоки — считаю это equivalent
|
||||
доказательству «оно работает», раз локальный браузерный прогон недоступен.
|
||||
|
||||
## Закрытие раунда r1
|
||||
|
||||
| находка r1 | чем закрыта | где видно |
|
||||
|---|---|---|
|
||||
| **M1** — `docs/WALL-THICKNESS.md` §3 описывал отменённое решение «фаска #249 сохраняется» | Абзац переписан под решение №5 целиком: полный mitre в секторе, `bevelMultiWallBody` — только адресный трим для #271, `bevelMultiWallPaper` убрана | `docs/WALL-THICKNESS.md:185-206` (коммит `72a5992f`); прочитано и сверено построчно с текущим кодом `junctionNodeGeometry`/`bevelMultiWallBody` — соответствует |
|
||||
| **M2** — гвард `junction-fans-disabled` был `node demo/smoke_junction_holes.mjs` без сборки `test-build`, падал `ERR_MODULE_NOT_FOUND` на чистом дереве | Guard переведён на `npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="issue 302" ...`; дополнительно (второй виток, коммит `af33c49e`) сама проверка переведена с самоссылочного контракт-проб-детектора на независимый юнит формы `issue 302 a T node covers every sector with a fan or a mitre` (`test/wall-thickness.test.mjs:2930-2940`) | `scripts/mutation-gate.mjs:2174-2190`; исполнено лично — `node scripts/mutation-gate.mjs --id=junction-fans-disabled` → «поймано 1 из 1» |
|
||||
| Low — `bevelMultiWallPaper` мёртвый код | Функция удалена целиком (`src/wall-thickness.ts`, было `:2955-2994`); следом измерением снята и re-union саппорт-квадов в теле (`31ba14e0`), плюс два осиротевших мутанта из реестра | `git diff fa1112e7..HEAD -- src/wall-thickness.ts` — оба блока отсутствуют; live-подтверждение — тест `issue 302 the owner repro is hole-free end to end` (`test/wall-thickness.test.mjs:3026-3054`) гоняет упрощённый пайплайн на реальной фикстуре репро и не находит дыр |
|
||||
|
||||
## Находки
|
||||
|
||||
### M3 (Medium, в скоупе задачи) — AC6 не выполнен: узловая механика не «безоперационна по difference», спека это не признаёт
|
||||
|
||||
**Файл:** `src/wall-thickness.ts:3535-3555` (вызов), `src/wall-thickness.ts:2856-2953`
|
||||
(тело `bevelMultiWallBody`); спека `docs/specs/302-junction-node-material.md:64-73`
|
||||
(§4, решение №5), `:129-131` (§8.2), `:196-198` (AC6).
|
||||
|
||||
**Что заявлено.** Решение владельца №5 (принято в день ребейза, зафиксировано
|
||||
в спеке и в теле коммита `79e86b0a`): «фаска #249 демонтируется **целиком**...
|
||||
слой `bevelMultiWallBody`/`bevelMultiWallPaper` **удаляется**». §8.2 повторяет
|
||||
это как формальный контракт: «**В узловой механике нет ни одной операции
|
||||
`difference`** (вычитающий слой фаски демонтирован): дыра между полосами
|
||||
невозможна **по построению**». AC6 — то же самое как проверяемый критерий.
|
||||
|
||||
**Что в коде.** `bevelMultiWallBody` (старая реализация фаски #249, с
|
||||
`difference()` на строках 2889, 2898, 2922, 2924) не удалена: она вызывается
|
||||
на подмножестве узлов, отфильтрованном `needsTrim` (короткий толстый саппорт,
|
||||
#271) — `src/wall-thickness.ts:3542-3554`. Это не мёртвый код: комментарий на
|
||||
`fixture #197` (`test/wall-thickness.test.mjs:2236-2240`) прямо говорит, что
|
||||
тест полагается на этот трим («#271 removes only the area that the old
|
||||
node-wide 8H rectangles invented»), и площадь фикстуры (`closeTo(...,
|
||||
124535.20808099362, ...)`) посчитана с его участием.
|
||||
|
||||
**Почему это не придирка к формулировке.** Весь смысл переработки #302 —
|
||||
уйти от вычитающих ремонтных слоёв, потому что именно они были источником
|
||||
рецидивирующих дыр (это буквально сюжет issue: 29 коммитов «point fixes»,
|
||||
которые не сходились). «Дыра невозможна по построению» — это утверждение
|
||||
именно про отсутствие `difference`; для узлов, где реально срабатывает
|
||||
`needsTrim`, инвариант «hole-free» держится не построением, а корректностью
|
||||
унаследованного вычитающего кода — той же категории кода, что и раньше ломался.
|
||||
Спека и коммит основного решения (`79e86b0a`) заявляют «удаляется целиком», но
|
||||
имплементация — тот же коммит! — сохраняет узкий трим и честно объясняет
|
||||
зачем в комментарии кода. То есть автор знал про исключение в момент
|
||||
написания контрактной формулировки, но не отразил его ни в §4/§8.2/AC6 спеки.
|
||||
`docs/WALL-THICKNESS.md` (живой документ подсистемы) это исключение описывает
|
||||
верно — именно потому что M1 в этом самом раунде его туда вписал; спека
|
||||
(контракт, по которому пишутся AC) осталась с абсолютной, невыполненной
|
||||
формулировкой.
|
||||
|
||||
Отдельно: ни один этап ревью до сих пор AC6 не подтверждал. Решение №5
|
||||
поменяло текст спеки уже ПОСЛЕ обоих раундов SPEC-REVIEW (они зелёные на
|
||||
версии «фаска #249 сохраняется» — SHA `3b19111a`); AC6 в его текущей редакции
|
||||
никогда не проходил ни одного ревью. В вердикте r1 (комментарий issue
|
||||
2026-08-25T13:29:36Z) явно перечислены как проверенные AC1, AC2, AC4, AC5,
|
||||
AC7, AC9 — AC6 в списке демонстративно нет, то есть r1 либо не проверял его,
|
||||
либо проверял и не сообщил результат.
|
||||
|
||||
**Воспроизведение.** `grep -n "difference(" src/wall-thickness.ts` в диапазоне
|
||||
2856-2953 (`bevelMultiWallBody`) — 4 вызова; трассировка вызова от
|
||||
`wallBodiesGeometry` (`:3542-3554`) до этой функции безусловна при
|
||||
`trimNodes.length > 0`. `needsTrim` реально истинен минимум для узла фикстуры
|
||||
#197 (по прямому указанию комментария теста, задача #271).
|
||||
|
||||
**Серьёзность и что чинить.** Не блокирует релиз функционально — вся
|
||||
визуальная/golden матрица подтверждает, что заявленный владельцем результат
|
||||
(без вырезов/рожков, полный mitre) достигнут, и трим — узкий, адресный,
|
||||
дисциплинированно закомментированный код, а не регресс. Но AC — формальный
|
||||
критерий приёмки этой задачи, и он не выполнен в буквальном прочтении. Дёшево
|
||||
чинится без правки кода: привести спеку в соответствие с уже написанной (в
|
||||
этом же раунде) правдой `docs/WALL-THICKNESS.md` — явно назвать исключение
|
||||
#271 в §4.5/§8.2 и переформулировать AC6 («узловая механика аддитивна для
|
||||
всех узлов, кроме адресного #271-трима, унаследованного из #249 и суженного
|
||||
до вырожденного случая»), либо, если владелец сочтёт исключение
|
||||
неприемлемым, вернуть задачу на технический разбор устранения самого трима.
|
||||
Это решение продуктовое/архитектурное, не моё — фиксирую находку, не
|
||||
предписываю какой из двух путей выбрать.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- M1, M2, Low из r1 закрыты по существу, не только по заявлению — см. таблицу
|
||||
выше; для M2 лично прогнал мутант и получил «поймано 1 из 1», а не поверил
|
||||
тексту коммита (текст коммита `72a5992f` сам признаёт, что автор один раз
|
||||
уже ошибочно заявил «проверено исполнением», не проверив).
|
||||
- Удаление `bevelMultiWallPaper`/`paperWithNodeCorners`/двух мутантов —
|
||||
полное, без осиротевших ссылок; подтверждено `grep` по обеим удалённым
|
||||
сигнатурам и живым тестом на реальном пайплайне (`the owner repro is
|
||||
hole-free end to end`).
|
||||
- `docs/WALL-THICKNESS.md` после M1 фактически точен для того путя, который
|
||||
описывает M3 (адресный трим назван прямо) — то есть подсистемная дока не
|
||||
расходится с кодом, расходится только сама спека issue.
|
||||
- Трейлеры всех четырёх коммитов дельты (`72a5992f`, `af33c49e`, `31ba14e0`,
|
||||
`42e02396`) содержат `Issue: #302` и `User-Visible: no` — корректно: эти
|
||||
четыре коммита не меняют видимое поведение относительно того, что уже было
|
||||
выпущено с `User-Visible: yes` в `79e86b0a`/`a5d30467` (там же в том же
|
||||
коммите обновлены оба CHANGELOG — проверено `git show 79e86b0a --
|
||||
docs/CHANGELOG.md docs/CHANGELOG.ru.md`).
|
||||
- tsc/test/build/check-docs/invariants — все чисто, гейты не помечены как
|
||||
условно пройденные.
|
||||
- Мутационное покрытие узловой механики (8 мутантов, включая переписанный
|
||||
M2) — самопроверено исполнением, не только чтением реестра.
|
||||
|
||||
## Чего не проверял и почему
|
||||
|
||||
- **`npm run golden:verify` и `demo/smoke_*.mjs` — не выполнил локально.**
|
||||
Окружение ревью не даёт Chromium перехватить динамический `import()`
|
||||
(воспроизведено то же самое на чистом `origin/dev` с чужим смоком —
|
||||
ограничение песочницы, не регрессия дельты). Компенсировано тремя
|
||||
CI-прогонами на кодово-идентичном дереве (см. «Как проверялось»): golden
|
||||
126/126 и все три шарда смоков зелёные на `30b4bbf6`; `docs`-джоб зелёный на
|
||||
`a5ea578`/`42e02396` после фикса отпечатка.
|
||||
- **Полный golden-набор и весь `demo/smoke_*.mjs` (не только выбранные
|
||||
smoke-select) не гонял вручную** — избыточно: `smoke-select.mjs` дал узкий
|
||||
список, а CI уже прогнал полный набор (все три шарда — это весь
|
||||
`demo/smoke_*.mjs`, не подмножество) на этом дереве.
|
||||
- **Продуктовую визуальную приёмку 16 новых junction-сцен не пересматривал
|
||||
глазами** — это решение владельца (комментарии issue 2026-08-25 11:40 и
|
||||
12:42), не предмет код-ревью; полагаюсь на golden pixel-diff.
|
||||
- **AC3, AC8** (пиксельное совпадение непричастных сцен, перф-бюджет) —
|
||||
унаследованы из r1/из CI (`performance_smoke` зелёный в `32855773018`), не
|
||||
передельфрено отдельно: дельта r1→r2 не трогает рендер-путь и перф-профиль.
|
||||
|
||||
## Унаследовано из r1
|
||||
|
||||
Документ r1: `docs/reviews/CODE-REVIEW-302-r1.md` (комментарий issue
|
||||
2026-08-25T13:29:36Z), проверен на SHA `fa1112e7`.
|
||||
|
||||
Принято без повторной проверки, так как дельта r1→r2 не касается этого кода:
|
||||
- геометрия веера/mitre/bevel-хорды/рефлекса (`junctionNodeGeometry`) —
|
||||
«прочитана построчно и соответствует контракту §8» (кроме уточнения по
|
||||
AC6 выше — то новая находка этого раунда, не подтверждение r1);
|
||||
- AC1, AC2, AC4, AC5, AC7, AC9 — сочтены выполненными и проверенными
|
||||
исполнением в r1; код, который их обеспечивает, не менялся между `fa1112e7`
|
||||
и `HEAD` (сверено `git diff fa1112e7..HEAD -- src/wall-thickness.ts` —
|
||||
единственные правки вне M1/M2/Low перечислены в разделе «Закрытие раунда
|
||||
r1» и не затрагивают mitre/bevel/reflex ветки);
|
||||
- продуктовая рамка (SCOPE.md, J1/J2) и полнота DoR спеки — не пересматривал,
|
||||
дельта не продуктовая;
|
||||
- решение владельца №5 (полный mitre, отказ от фаски #249 как видимого
|
||||
эффекта) как таковое не оспариваю — это принятое продуктовое решение;
|
||||
находка M3 — про честность формальной формулировки AC6, а не про само
|
||||
решение.
|
||||
|
||||
## Вывод
|
||||
|
||||
Мultimedia M1/M2/Low закрыты добросовестно и проверены исполнением, а не на
|
||||
слово. Обнаружена одна новая находка в скоупе (M3): AC6 этой же задачи не
|
||||
выполнен буквально, и это никогда не проходило ревью (решение №5 переписало
|
||||
спеку уже после двух зелёных SPEC-REVIEW). Возвращаю жёлтым для приведения
|
||||
спеки в соответствие с уже написанной верной докой подсистемы либо для
|
||||
явного решения владельца по трим-исключению.
|
||||
@@ -0,0 +1,254 @@
|
||||
# CODE-REVIEW-302-r4
|
||||
|
||||
- Issue: [#302](https://github.com/Matysh/houseplan-card/issues/302) — материал узла стен: переработка на аддитивную логику
|
||||
- Этап: код-ревью (PROCESS.md §2.7)
|
||||
- Заход: r4 · блокирующих циклов израсходовано на входе 2/4 (r1 жёлтый, r2 жёлтый, r3 зелёный — бюджет не тратит)
|
||||
- Ревьюемый SHA: `96318fb94f6d64e96054b8ba4d34f615e3088477`
|
||||
- Ветка: `issue/302-junction-node-material`
|
||||
- Вердикт: **зелёный** · High: 0 · Medium: 0
|
||||
|
||||
## Почему это ПОЛНЫЙ разбор, а не разбор по дельте
|
||||
|
||||
r3 (SHA `560c5b81`, вердикт зелёный) не смержился: конфликт при ребейзе на
|
||||
`dev` (комментарий владельца от 14:33). Автор перевёл задачу в
|
||||
`S6-in-progress`, выполнил `git rebase origin/dev`, разрешил конфликт и
|
||||
запушил заново — старые SHA (`fa1112e7`, `42e02396`, `560c5b81`) при этом
|
||||
переизданы force-push'ем и физически не существуют в текущей истории
|
||||
(`git cat-file -t <sha>` → `Not a valid object name`; `git fetch origin <sha>`
|
||||
→ `couldn't find remote ref`). Разбор по дельте против объекта, которого нет,
|
||||
невозможен буквально.
|
||||
|
||||
Существеннее: сам ребейз — не техническая формальность. `git merge-base HEAD
|
||||
origin/dev` равен текущему тику `origin/dev` (`1575b1e8`), и в диапазон между
|
||||
прежней базой ветки и этим тиком входит `a2b4d32b fix(plan): keep wall axes
|
||||
across tools` (#304) — реальная фича, физически задевающая `src/houseplan-card.ts`
|
||||
и рендер осевых линий, которая столкнулась с этой веткой ровно в одной паре
|
||||
golden-сцен (`safe-resize-handles-clamp-*`, где стыкуются осевые линии из #304
|
||||
и сомкнутая вершина ромба из decision №5 этой задачи). Это тот samый случай,
|
||||
который правило §7.2 требует разбирать полностью: «после ребейза это другой
|
||||
код». Разбор ниже поэтому охватывает весь диапазон `origin/dev...HEAD`
|
||||
(49 файлов, 2257/-356), а не только три коммита ребейза.
|
||||
|
||||
Раздел «Унаследовано» — не для сокращения объёма проверки, а для
|
||||
трассируемости: почти весь контроль ниже я выполнил заново и независимо в
|
||||
этом раунде (перечень гейтов — раздел «Что проверено» и «Гейты»), а не
|
||||
переписал из r1–r3 на слово. Пересечение с прежними выводами является
|
||||
следствием того, что `git diff <SHA r3>..HEAD -- src/ test/ demo/ scripts/`
|
||||
пуст (см. ниже) — код фичи не менялся с r2, а не потому что я решил доверять.
|
||||
|
||||
## Дельта ребейза (три коммита после r3)
|
||||
|
||||
| коммит | что делает |
|
||||
|---|---|
|
||||
| `b2b26407` | принимает объединённые эталоны `safe-resize-handles-clamp-{dark,light}` (осевые линии #304 + сомкнутая вершина ромба decision №5); `baselines-index.json` починен по фактическим PNG после конфликта слияния |
|
||||
| `d3927f2e` | rebundle обеих копий (`dist/`, `custom_components/.../houseplan-card.js`) на объединённом дереве |
|
||||
| `96318fb9` | обновляет `docs/images/screenshots.json` (отпечаток скриншотов документации, обязателен при любой правке `src/**`) |
|
||||
|
||||
Подтверждено чтением: `git diff <SHA r3>..HEAD -- src/ test/ demo/ scripts/`
|
||||
не выполним напрямую (SHA отсутствует), но три коммита ребейза сами по себе
|
||||
трогают только `demo/golden/baselines/**`, `baselines-index.json`, обе копии
|
||||
бандла и `docs/images/screenshots.json` — ни строки `src/**`/`test/**`
|
||||
/`demo/**` (кроме бинарных PNG) эти три коммита не меняют (см. `git show
|
||||
--stat` на каждом, приведено в комментарии issue тем же перечнем файлов).
|
||||
Отсюда: узловая геометрия (`junctionNodeGeometry`, `junctionContractHoles`,
|
||||
`junctionNodeBound`, точки вызова в `wallBodiesGeometry`) идентична r2/r3
|
||||
байт-в-байт; я перечитал её самостоятельно (раздел ниже), а не принял этот
|
||||
факт на слово.
|
||||
|
||||
## Скоуп проверки
|
||||
|
||||
Материал: `git log --oneline origin/dev..HEAD` (25 коммитов) и
|
||||
`git diff origin/dev...HEAD` (49 файлов). Прочитаны: тело issue #302 и все
|
||||
комментарии, `docs/specs/302-junction-node-material.md` (текущая редакция,
|
||||
после правки AC6/§4.5/§8.2 в r2→r3), `docs/WALL-THICKNESS.md` (секции
|
||||
«Junction nodes» и «Junction tooling»), `docs/SCOPE.md`, `docs/reviews
|
||||
/CODE-REVIEW-302-r1.md` и `-r2.md` для контекста прежних находок.
|
||||
|
||||
## Продуктовая рамка
|
||||
|
||||
Задача закрывает белые клинья на стыках стен — прямой отчёт владельца,
|
||||
регресс из релиза в релиз (docs/SCOPE.md, job «нарисовать и увидеть точный
|
||||
план» — геометрия стен без визуальных дефектов является базовым требованием
|
||||
самого продукта, не отдельной фичей). Решение №5 (владелец, тем же днём)
|
||||
сузило исходную идею «сохранить фаску #249» до «полный mitre везде» по
|
||||
результатам визуального осмотра собственных эталонов — это законная
|
||||
итерация внутри одной задачи, а не расширение скоупа.
|
||||
|
||||
## Что проверено чтением кода
|
||||
|
||||
- `junctionNodeGeometry` (`src/wall-thickness.ts:2032-2158`): один угловой
|
||||
обход по лучам, отсортированным по азимуту; для каждой пары соседних лучей
|
||||
— mitre при условии «В СЕКТОРЕ» (`directionOk`, разная проверка для
|
||||
обычной и рефлексной пары), лимит `MITRE_LIMIT × max(half)` и ограничение
|
||||
концом толстого саппорта (`thickLength`, #271); иначе — плоская хорда
|
||||
(рефлекс) либо локальный bevel, у которого `reach()` ограничен саппортом,
|
||||
лимитом и удвоенной глубиной пары. Ни одного вызова `difference()`/`subtract`
|
||||
в этой функции — подтверждено grep по телу функции. Соответствует §8.2
|
||||
буквально.
|
||||
- `junctionContractHoles` (`:2178-2226`) и живая проверка в
|
||||
`demo/smoke_junction_holes.mjs`: проба — дыра, если она в полосе луча ИЛИ
|
||||
в веере узла (контрактное покрытие §8.4), но не в самой геометрии; отдельно
|
||||
клиппуется `bound` (фасадная граница). Самопроверка «детектор не слеп»
|
||||
— юнит-тест `test/wall-thickness.test.mjs:213` — гоняет детектор на заведомо
|
||||
дырявой фикстуре.
|
||||
- `junctionNodeBound` (`:3197-3226`): та же `exteriorEnvelopeGeometry`, но с
|
||||
пустой узловой картой — то есть «гладкий» контур без узловых вырезов;
|
||||
используется, чтобы куски узла не отращивали фасад на вогнутой вершине
|
||||
(concave-Split контракт).
|
||||
- Единственное вычитание узловой механики — адресный трим #271
|
||||
(`src/wall-thickness.ts:3538-3556`): `needsTrim()` фильтрует узлы с
|
||||
вырожденно коротким толстым саппортом (`support.length < support.halfDepth
|
||||
* 2`) и вызывает `bevelMultiWallBody` ТОЛЬКО на этом подмножестве — не на
|
||||
всех узлах. AC6 подтверждён по коду, не по комментарию.
|
||||
- Бумага (`floorFootprintGeometry`, `paperRoomShapesWithWalls`): вызовы
|
||||
`bevelMultiWallPaper` убраны полностью (функция удалена — `grep
|
||||
bevelMultiWallPaper src/wall-thickness.ts` даёт 0 совпадений); паперная
|
||||
геометрия — просто `rawPaperGeom`, с комментарием, что footprint∪shell уже
|
||||
покрывает каждый узел (измерено в r2 на #197 и репро, байт-в-байт).
|
||||
- `dropDegenerateRings` (`:3159-3176`): убирает вырожденные (площадь ≤ eps)
|
||||
кольца, которые boolean union оставляет там, где хорда веера совпадает с
|
||||
хордой старой фаски — иначе они топологические дыры для потребителей,
|
||||
считающих кольца.
|
||||
- Оба рендерера получают тело из одного и того же
|
||||
`wallBodiesUnionPath` — точка входа не менялась, только тело функции.
|
||||
|
||||
## Находка
|
||||
|
||||
### Low — стек-документация `JunctionNodeGeometry` описывает не тот контракт
|
||||
|
||||
`src/wall-thickness.ts:2012-2025`, докстринг перед
|
||||
`export interface JunctionNodeGeometry`:
|
||||
|
||||
> «One angular walk produces both halves of the corner rule: a FAN per pair
|
||||
> of adjacent rays…; **a CUT per over-limit pair — the wedge beyond that same
|
||||
> chord, which is how the approved #249 chamfer looks**. Fan and cut of one
|
||||
> pair meet exactly at the chord and never overlap…»
|
||||
|
||||
Комментарий описывает механизм с ДВУМЯ артефактами — аддитивным веером и
|
||||
вычитающим «cut» (то есть фаску #249 как вычитание). Фактический интерфейс,
|
||||
объявленный прямо под этим текстом, содержит только `fans` и `supports` —
|
||||
поля `cuts` не существует, и сама функция `junctionNodeGeometry` не вызывает
|
||||
`difference()` ни разу (подтверждено чтением всего тела функции). Это ровно
|
||||
тот контракт, который decision №5 и AC6 фиксируют как обязательный: «единственное
|
||||
вычитание узловой механики — адресный трим #271», а не «per over-limit pair».
|
||||
|
||||
Проверено `git log -L2012,2030:src/wall-thickness.ts`: комментарий написан в
|
||||
самом первом WIP-коммите (`a1361974`, «junction node gets back what a chamfer
|
||||
must never eat» — до decision №5, когда фаска #249 ещё сохранялась как
|
||||
вычитающий слой) и ни разу не тронут дальнейшими коммитами decision №5
|
||||
(`aac50151`) и «fans are the whole additive node» (`d3ed299d`), которые
|
||||
убрали cut-половину механизма из кода, но не из докстринга.
|
||||
|
||||
**Почему Low, а не Medium.** Правило 11 PROCESS.md («документация в том же
|
||||
коммите, что поведение») касается продуктовой документации
|
||||
(`docs/**`, changelog, USER-GUIDE) — она вся актуальна и уже дважды сверена
|
||||
построчно (r1 M1 закрыл `docs/WALL-THICKNESS.md`, r2/r3 M3 закрыл текст
|
||||
спеки). Здесь — внутренний докстринг исходника, не читаемый пользователем и
|
||||
не являющийся доказательством ни одного AC (AC6 доказан кодом точки вызова
|
||||
`bevelMultiWallBody` и мутантом `junction-fan-limit-back-to-249`, не
|
||||
комментарием). Поведение корректно, тесты и мутанты это подтверждают
|
||||
исполнением. Риск — исключительно в том, что следующий разработчик,
|
||||
читающий именно этот файл, ошибётся насчёт контракта «нет вычитаний».
|
||||
|
||||
**Решение.** Снято с записью, без правки: находка не в скоупе обязательной
|
||||
правки этого раунда (Low), но дёшева — 6 строк докстринга, переписывающих
|
||||
«a CUT per over-limit pair… #249 chamfer looks» на «no cut — the fan always
|
||||
closes either the mitre or the bounded bevel chord, additively». Рекомендую
|
||||
поправить при следующем touch этого файла.
|
||||
|
||||
## Инвариант модели (#254)
|
||||
|
||||
Диапазон меняет геометрию (стыки, `wallIntervals`, узловые записи толщины).
|
||||
`npm test` уже гоняет инварианты на всех фикстурах проекта — 1304/1304 pass.
|
||||
Отдельно прогнан `npm run invariants -- --config
|
||||
test/fixtures/302-junction-artifacts.json` (фикстура-репро владельца, 5
|
||||
узлов, острые углы 15–70 см): «Инварианты выполнены: ссылки разрешимы,
|
||||
записи толщины находятся» — ключ записи толщины проверен на точное
|
||||
совпадение со строкой решёточного ребра, не на допуск.
|
||||
|
||||
## Гейты — что прогнал сам
|
||||
|
||||
| гейт | результат |
|
||||
|---|---|
|
||||
| `npx tsc --noEmit` | чисто |
|
||||
| `npm test` | 1304 pass / 1 skip / 0 fail |
|
||||
| `npm run build` + сверка бандла | `git status` после пересборки чист — обе копии (`dist/`, `custom_components/houseplan/frontend/`) байт-в-байт совпадают с закоммиченными |
|
||||
| `node scripts/check-docs.mjs` | passed (7 файлов, 10 внешних ссылок) — обязателен, diff трогает `src/**` |
|
||||
| `npm run invariants -- --config test/fixtures/302-junction-artifacts.json` | passed |
|
||||
| `node scripts/process-gate.mjs` (диапазон `origin/dev..HEAD`, 25 коммитов) | «гейт пройден, предупреждений 0» |
|
||||
| 8 junction/partition-мутантов (`junction-fans-disabled`, `junction-fan-ignores-thick-length`, `junction-reflex-outer-mitre-missing`, `junction-fan-limit-back-to-249`, `junction-detector-blind`, `junction-pieces-unbounded`, `partition-merge-ignores-junction`, `junction-checks-room-vertices-only`) | каждый лично прогнан `node scripts/mutation-gate.mjs --id=<id>` → «поймано 1 из 1» |
|
||||
| трейлеры (`Issue:`, `User-Visible:`, `Release:`, `Baseline-Reviewed:`) | проверены на всех 25 коммитах; оба `User-Visible: yes` (`76d60b3e`, `aac50151`) правят `docs/CHANGELOG.md`+`docs/CHANGELOG.ru.md` в том же коммите |
|
||||
|
||||
## Гейты — не прогнал сам, компенсировано CI
|
||||
|
||||
`npm run golden:verify` и браузерные смоки (`demo/smoke_*.mjs`) в этой
|
||||
песочнице не запускаются физически: `page.waitForFunction: Timeout 30000ms
|
||||
exceeded` / `Failed to fetch dynamically imported module`. Это не следствие
|
||||
изменений задачи — воспроизвёл ту же ошибку на чистом `origin/dev`
|
||||
(отдельный клон, `npm ci`, `node demo/smoke_decor.mjs`) — сетевое/браузерное
|
||||
ограничение самой ревью-песочницы, то же самое, что документировал r2.
|
||||
|
||||
Компенсация — CI-прогон на `d3927f2e` (третий коммит ребейза; тот же код,
|
||||
что на HEAD `96318fb9` — разница только `docs/images/screenshots.json`,
|
||||
подтверждено чтением diff):
|
||||
|
||||
- job `golden` (`97846497668`): 126/126 «passed», включая все 16
|
||||
junction-сцен и обе `safe-resize-handles-clamp-{dark,light}` — прочитан
|
||||
полный лог, посчитано `grep -c passed` = 126, ни одного «failed»/«diff».
|
||||
- три шарда `smoke` (`97846497632/522/569`): `smoke_junction_holes`,
|
||||
`smoke_near_orthogonal_junction`, `smoke_wall_junctions`,
|
||||
`smoke_junction_patch_resilience`, `smoke_multiwall_junction` — все `ok`;
|
||||
189 смоков суммарно по трём шардам, единственные строки с «FAIL»/«not ok»
|
||||
в логе — это исходный текст скрипта (`echo "FAIL $name"`) и смок с
|
||||
говорящим именем `smoke_glow_fail_dark`, который сам зелёный.
|
||||
- `performance_smoke` (`97846497544`): success — AC8 (перф-бюджет) закрыт
|
||||
исполнением, не только «механизм существует».
|
||||
- `frontend` (`97846156731`): success.
|
||||
|
||||
Финальный коммит `96318fb9` легитимно получил «skipped» на этих job'ах через
|
||||
`reuse` (кэш по хэшу контента) — проверено по `changes`/`reuse` job'ам того
|
||||
же прогона (`32862032767`), содержимое между `d3927f2e` и `96318fb9`
|
||||
отличается только некодовым `screenshots.json`.
|
||||
|
||||
`node scripts/smoke-select.mjs --base <merge-base> --head HEAD`: прямое
|
||||
совпадение — `smoke_junction_holes`, `smoke_decor`,
|
||||
`smoke_grid_scale_invariance`, `smoke_real_plan_masonry`,
|
||||
`smoke_space_scale_defaults`; зарегистрированная связь —
|
||||
`smoke_junction_patch_resilience`, `smoke_multiwall_junction`,
|
||||
`smoke_multiwall_strip_containment`, `smoke_resize_pointer_real_plan`,
|
||||
`smoke_resize_wall_thickness`. Все перечисленные, кроме
|
||||
`smoke_real_plan_masonry`/`smoke_space_scale_defaults`/`smoke_decor`
|
||||
(тематически про `cellCm`, не про узлы), покрыты либо AC9, либо CI-прогоном
|
||||
выше; отдельно `smoke_real_plan_masonry` тоже был в CI как «прямое
|
||||
совпадение» по `MITRE_LIMIT» и зелёный там же.
|
||||
|
||||
## AC — построчно
|
||||
|
||||
| AC | статус | как доказано |
|
||||
|---|---|---|
|
||||
| AC1 (0 дыр на всём сете §13) | ✅ | `junctionContractHoles` читан построчно; CI golden 126/126 включает все 16 сцен + репро; юнит «the owner repro is hole-free end to end» зелёный локально |
|
||||
| AC2 (репро владельца, 5 узлов) | ✅ | `smoke_junction_holes` (живой рендер, CI ok) + юнит-тест на той же фикстуре, локально зелёный |
|
||||
| AC3 (несвязанные сцены — побайтно; junction — легально) | ✅ | `baselines-index.json`: изменения ограничены junction-сценами и двумя `safe-resize-handles-clamp-*` (объяснено выше — легитимное слияние с #304); подтверждено CI golden 126/126 |
|
||||
| AC4 (57°, 50/70 — сплошная кладка) | ✅ | юнит `issue 302 the 57° mixed-thickness pair takes the full mitre (decision #5)` зелёный; репро-фикстура включает именно эту пару |
|
||||
| AC5 (виртуальный участок не порождает кладку) | ✅ (чтением) | фильтр `ray.halfDepth > 0` в начале `junctionNodeGeometry` исключает нулевые лучи из веерного обхода; сцены `junction-t-virtual-arm`/`junction-x-virtual-through` в CI golden зелёные |
|
||||
| AC6 (единственное вычитание — трим #271) | ✅ | `needsTrim()` фильтр по коду (см. выше); мутант `junction-fan-limit-back-to-249` лично прогнан — «поймано 1 из 1»; текст спеки §4.5/§8.2 приведён в соответствие в r3 |
|
||||
| AC7 (оба рендерера — один путь) | ✅ (чтением, архитектура не менялась) | `wallBodiesUnionPath` — одна точка входа |
|
||||
| AC8 (перф в бюджете) | ✅ | `performance_smoke` CI job success на коде, идентичном HEAD |
|
||||
| AC9 (существующие смоки стыков) | ✅ | `wall_junctions`, `junction_patch_resilience`, `multiwall_junction` — CI ok; `split_corner_wall`, `zero_divider_taper`, `wall_thickness*` — часть `npm test` 1304/1304 |
|
||||
|
||||
## Не проверял отдельно (не требуется этой дельтой)
|
||||
|
||||
- Визуальную приёмку 16 junction-сцен глазами (какой именно pixel-diff у
|
||||
каждой) — доверился golden-порогам CI и текстовому описанию решения №5 в
|
||||
issue; геометрический контракт проверен чтением кода и детектором, а не
|
||||
«на глаз».
|
||||
- Полный browser-smoke матрицу (189 смоков) целиком — не запрашивает ни один
|
||||
AC; ограничился прямыми совпадениями/AC9/зарегистрированными связями,
|
||||
все они зелёные в CI на коде, идентичном HEAD.
|
||||
- `python -m pytest tests_backend` — diff не трогает `custom_components/**/*.py`.
|
||||
|
||||
## Итог
|
||||
|
||||
Один Low, снятый с записью без правки (стек-докстринг `JunctionNodeGeometry`
|
||||
описывает вычитающий «cut», которого в коде нет — см. находку выше). High: 0,
|
||||
Medium: 0. Вердикт — зелёный.
|
||||
@@ -0,0 +1,154 @@
|
||||
# SPEC-REVIEW-302-r1
|
||||
|
||||
- Issue: [#302](https://github.com/Matysh/houseplan-card/issues/302) — «Артефакты на стыках стен: дыры в union-геометрии. Переработка механизма стыков + полноценный сет скриншот-тестов»
|
||||
- ТЗ: `docs/specs/302-junction-node-material.md`
|
||||
- Ветка: `issue/302-junction-node-material`, ревьюемый SHA: `3b19111aa5894f624e608b9f7fef31736a4ecdf5` (родитель `4b8f17ba`, ровно вершина `dev` на момент написания ТЗ)
|
||||
- Заход: r1 · блокирующих циклов израсходовано 0/4
|
||||
- Класс изменения: A (продукт), трек: обычный (не `small`) — корректно, задача явно затрагивает perf-бюджет и переработку подсистемы, критерии лёгкого трека не выполняются
|
||||
- Вердикт: **зелёный**
|
||||
|
||||
## Скоуп ревью
|
||||
|
||||
Первый заход, разбор полный (§2.9 не применяется — нет r0). Проверялись:
|
||||
тело issue #302 и единственный комментарий автора, `docs/specs/302-junction-node-material.md`
|
||||
целиком, `docs/SCOPE.md`, `PROCESS.md` §1–§9, `docs/WALL-THICKNESS.md` целиком,
|
||||
исходник `src/wall-thickness.ts` (4145 строк) — выборочно, по каждому
|
||||
идентификатору и утверждению ТЗ, требующему проверки на существование/точность.
|
||||
|
||||
## Как проверялось
|
||||
|
||||
1. **Продуктовая рамка.** `docs/SCOPE.md` — задача улинии с миссией
|
||||
(«live spatial overview») через качество геометрии плана; это не новая
|
||||
фича, а устранение искажений в самом визуальном представлении дома —
|
||||
прямое следствие J1. Конфликта со SCOPE нет.
|
||||
2. **Процесс.** Issue помечен `S4-spec-review`, что и запускает этот этап;
|
||||
размер задачи (обычный) обоснован — трогает `src/wall-thickness.ts`
|
||||
целиком, влияет на perf-бюджет, не подходит под критерии `small` (§5
|
||||
PROCESS.md требует «нет влияния на производительность» — здесь влияние
|
||||
прямо названо в §10 ТЗ).
|
||||
3. **Фактчекинг утверждений о коде и истории**, чтобы отделить подтверждённые
|
||||
факты от гипотез, выданных за решение (обязательный пункт инструкции):
|
||||
- `MITRE_LIMIT`, `openEps`, `buildMultiWallNodeMap`, `wallIntervals`,
|
||||
`multiWallNodeAt`, `outsetContour`/`insetContour`,
|
||||
`bevelMultiWallBody`/`bevelMultiWallPaper`, `unionJunctionPatches`,
|
||||
`linearWallJoinPatches`, `stableJunctionPatch`, `wallBodiesGeometry`,
|
||||
`wallBodiesUnionPath` — все существуют в `src/wall-thickness.ts` ровно с
|
||||
тем поведением, которое им приписывает ТЗ (grep + прочтение сигнатур).
|
||||
- `EPS_NODE = openEps × 4` — совпадает буквально с вызовом в
|
||||
`multiWallNodesForGeometry` (`wall-thickness.ts:2035-2036`).
|
||||
- `wallBodiesUnionPath` действительно единственная точка входа для обоих
|
||||
рендереров: `src/houseplan-card.ts:11687` (Full/Plan) и
|
||||
`src/space-render.ts:432` (Static) — вызывают именно её. Утверждение §2
|
||||
ТЗ «оба рендерера получают результат из одного и того же кода» не
|
||||
догадка, а факт.
|
||||
- Ссылки на прежние ТЗ (#141, #172, #197, #229, #249, #271, #272, #275,
|
||||
#279, #288, #290, #296) сверены с содержанием `docs/WALL-THICKNESS.md` —
|
||||
каждая деталь («R = 1.25×H», `NEAR_AXIS_MAX_DEGREES = 0.25`,
|
||||
«near-orthogonal», «short ray handoff», сращивание коллинеарных равных
|
||||
стен из #229) находит буквальное соответствие в каноническом документе
|
||||
подсистемы либо в исходнике (`src/near-axis.ts:6-7`).
|
||||
- Все девять смоков из AC9 (`smoke_wall_junctions`,
|
||||
`smoke_junction_patch_resilience`, `smoke_split_corner_wall`,
|
||||
`smoke_zero_divider_taper`, `smoke_wall_thickness*`) и
|
||||
`smoke_render_perf` (AC8) существуют в `demo/`.
|
||||
- `demo/golden/matrix.mjs` существует и это действительно data-only
|
||||
каталог golden-фикстур (`GOLDEN_MATRIX_VERSION`), куда естественно
|
||||
ложится новый сет из §13.
|
||||
- Нового «детектора дыр» и `isPointInPath`-инструментария в репозитории
|
||||
сейчас нет (`grep` пусто) — заявка «новый чистый модуль» корректна, это
|
||||
не дублирование существующего.
|
||||
- SHA `4b8f17b`, на котором получены цифры 359/360 дыр, существует в
|
||||
истории и совпадает с прямым родителем ревьюемого коммита — воспроизвести
|
||||
довод «дыры родились в базовой фазе» можно на том же дереве.
|
||||
4. **Смысловая проверка контракта §8**, а не только буквенная. Рассмотрены
|
||||
вырожденные и граничные случаи: узел из 2 лучей (веер строится на обеих
|
||||
угловых секторах — внутренней и внешней стороне, поскольку у двух лучей при
|
||||
круговом обходе ровно два соседства); дегенерат mitre при малом угле
|
||||
(обрезается `MITRE_LIMIT`, переходит в bevel — предотвращает уход материала
|
||||
в бесконечность); связь с историческим классом дефектов «короткий луч
|
||||
короче радиуса ремонта» (#271/#288) — новая механика по построению не
|
||||
продолжает луч вдоль его оси вовсе («конец полосы» — точка ровно у узла,
|
||||
офсетная по перпендикуляру, а не отложенная на расстояние `MITRE_LIMIT`
|
||||
вдоль луча), поэтому класс дефектов «репэйр перерос короткий луч» не имеет
|
||||
аналога в новой механике — не пробел ТЗ, а архитектурное упрощение, которое
|
||||
стоит явно назвать в фиксации, но не блокирует ревью.
|
||||
5. **Проверка DoR-состава** (§2.5, §7.1 PROCESS.md): сценарий ✓, «что человек
|
||||
увидит» ✓, проблема/причина ✓ (§3 с исполненным доказательством), скоуп и
|
||||
не-скоуп ✓ (§6–7), контракт поведения ✓ (§8), данные/i18n/a11y/privacy ✓
|
||||
(§9, всё «не меняется» — обосновано отсутствием миграции конфига), perf ✓
|
||||
(§10, бюджет назван), риски ✓ (§11, три риска с конкретными мерами), AC ✓
|
||||
(§12, 9 штук, пронумерованы), план автотестов ✓ (§13–14), откат ✓ (§16),
|
||||
release-артефакты ✓ (§15).
|
||||
|
||||
## Находки
|
||||
|
||||
Ни одной находки уровня High или Medium. Два пункта Low — решение ревьюера:
|
||||
сняты с записью, правки не требуют.
|
||||
|
||||
1. **Low — способ доказательства AC не продублирован буквально на каждой
|
||||
строке §12.** AC1–AC5, AC8 описывают ожидаемый результат словами
|
||||
(«детектор §8.4», «попиксельное совпадение», «смок сверяет пути»), а не
|
||||
явным тегом `unit`/`golden`/`smoke` при каждом пункте, как того просит
|
||||
буква §2.5 PROCESS.md. Решение: снимается без правки — §13 однозначно
|
||||
называет механику («каждая сцена: golden крупным планом + детектор §8.4»,
|
||||
«юниты: чистые функции веера... и детектора»), и разработчик/ревьюер кода
|
||||
не может трактовать способ доказательства иначе. Формальный тег ничего не
|
||||
добавил бы к однозначности.
|
||||
2. **Low — нет отдельного раздела «UX».** Шаблон §7.1 называет UX отдельным
|
||||
разделом; в этом ТЗ его содержание разнесено между шапкой («Touch editor:
|
||||
not exposed») и §2 («Что человек увидит до и после»). Решение: снимается —
|
||||
задача не меняет ни одного интерактивного пути (только геометрия отрисовки
|
||||
тел стен), содержательно вопрос закрыт, отдельный заголовок был бы пустой
|
||||
формальностью.
|
||||
|
||||
Открытых продуктовых вопросов к владельцу нет и это обоснованно: контракт
|
||||
поведения продиктован самим владельцем в комментарии к issue (кандидат-алгоритм
|
||||
из тела issue почти буквально формализован в §8.2), формальные константы
|
||||
детектора отмечены как техническое предположение в §17 с направлением
|
||||
уточнения («только в сторону строгости»), стадийность демонтажа старых слоёв —
|
||||
там же, помечено как свободное для реализации техническое решение. Это
|
||||
редкий случай, когда молчание не является риском: сам факт задачи и её решение
|
||||
исходят от одного и того же человека.
|
||||
|
||||
## Что проверено и корректно
|
||||
|
||||
- Соответствие SCOPE.md — задача в рамках мандата, не расширяет функциональность.
|
||||
- Соответствие процессу — статус, класс изменения, трек, обязательные разделы,
|
||||
трейлеры/release-артефакты названы верно.
|
||||
- Все технические идентификаторы, числа и ссылки на историю, на которые
|
||||
опирается контракт §8, проверены на существование и точность прямым чтением
|
||||
кода и `docs/WALL-THICKNESS.md`, а не приняты на слово.
|
||||
- Контракт §8.2–8.4 логически замкнут: покрывает все перечисленные в §8.3
|
||||
вырожденные случаи, не оставляет геометрического механизма для дыры между
|
||||
соседними веерами (общая вершина — узел, дуги секторов покрывают полный
|
||||
оборот), и делает объективно проверяемым единственное новое утверждение
|
||||
(«нет дыр») через детектор с собственной самопроверкой на дырявой фикстуре
|
||||
(мутант `hole-detector-blind`).
|
||||
- AC9 корректно называет регрессионный периметр — все перечисленные смоки
|
||||
существуют и по названию действительно покрывают стыковую механику.
|
||||
- Откат (§16), release-артефакты (§15) и риски golden-переснятия (§11.1)
|
||||
учитывают процессные уроки #230/#235 именно так, как того требует PROCESS.md.
|
||||
|
||||
## Чего не проверял
|
||||
|
||||
- Я не запускал никакие гейты (`tsc`, `npm test`, `npm run build`,
|
||||
`golden:verify`, смоки) — на этапе ревью ТЗ кода ещё нет, гейты этапа
|
||||
относятся к код-ревью (§2.7 PROCESS.md), а не к ревью ТЗ (§2.4).
|
||||
- Не проверял геометрию mitre/bevel численно (не строил прототип и не считал
|
||||
вручную пересечения офсетных прямых) — на этапе ТЗ это не требуется: контракт
|
||||
верифицируется исполнением на код-ревью через детектор §8.4 и golden, а не
|
||||
через ручной пересчёт геометрии ревьюером ТЗ.
|
||||
- Не читал полностью весь `src/wall-thickness.ts` (4145 строк) построчно —
|
||||
прочитаны и сверены только фрагменты, релевантные утверждениям ТЗ
|
||||
(сигнатуры функций, константы, точки вызова `wallBodiesUnionPath`).
|
||||
- Не проверял содержание единственного комментария к issue построчно на
|
||||
предмет более ранних версий ТЗ — комментарий один, зафиксированное решение
|
||||
соответствует финальному ТЗ.
|
||||
|
||||
## Итог
|
||||
|
||||
`docs/specs/302-junction-node-material.md` готово к переходу в `S5-ready`.
|
||||
Продуктовая рамка ясна, контракт поведения проверяем объективным детектором и
|
||||
golden-эталонами, риски демонтажа старого стека названы и покрыты
|
||||
регрессионным периметром (AC9), технические предположения отделены от решения
|
||||
и помечены как свободные к изменению.
|
||||
@@ -0,0 +1,261 @@
|
||||
# Issue #302 — материал узла стен: переработка на аддитивную логику
|
||||
|
||||
- Issue: [#302](https://github.com/Matysh/houseplan-card/issues/302)
|
||||
- Класс изменения: A (продукт)
|
||||
- Размер: обычный (не `small`) — переработка подсистемы
|
||||
- Автор ТЗ: Codex, 2026-08-25
|
||||
- Touch editor: not exposed — меняется только построение геометрии тел стен,
|
||||
одинаковое при любом способе ввода.
|
||||
|
||||
## 1. Сценарий
|
||||
|
||||
Владелец рисует двор с двумя пристройками: две комнаты делят острую вершину
|
||||
(~57°), стены 15–70 см на клетке 30 см. На каждом втором стыке — белые
|
||||
треугольные клинья и зазубрины. Артефакты возвращаются из релиза в релиз:
|
||||
29 коммитов с «junction», четыре ТЗ (141, 197, 249, 279) — и всё равно.
|
||||
|
||||
## 2. Что человек увидит до и после
|
||||
|
||||
**До.** Клинья и щели в стыках; вид зависит от числа лучей, углов и толщин
|
||||
непредсказуемо. **После.** Любой узел — сплошная кладка: mitre в пределах
|
||||
лимита, bevel-фаска за ним, без дыр и без «лишнего» материала, одинаково на
|
||||
карте и в статическом рендерере.
|
||||
|
||||
## 3. Подтверждённая причина (всё — исполнением на dev `4b8f17b`)
|
||||
|
||||
1. **Дыры — в геометрии, не в отрисовке.** На живой карте по `_wallUnionGeometry().d`
|
||||
сеткой проб `isPointInPath(evenodd)`: 154 пробы у стыка не покрыты
|
||||
материалом, будучи окружены кладкой с ≥5 из 8 сторон.
|
||||
2. **Дыры рождаются в базовой фазе и никем не лечатся.** Чистый конвейер
|
||||
`wallBodiesGeometry` с пофазными снапшотами на репро владельца
|
||||
(5 узлов, сетка проб):
|
||||
|
||||
| после фазы | дыр |
|
||||
|---|---|
|
||||
| room-rings + edge-bodies | **359** |
|
||||
| + unionJunctionPatches | 359 |
|
||||
| + facade-clip | 359 |
|
||||
| + exterior-shell | 359 |
|
||||
| + bevelMultiWallBody | **360** (bevel добавил дыру) |
|
||||
|
||||
Отключение `bevelMultiWallBody`, `unionJunctionPatches` и facade-clip по
|
||||
одному ничего не меняет (359–360) — все «ремонтные» слои для этого класса
|
||||
дыр нерелевантны.
|
||||
3. **Локализация.** Оба кольца комнат строятся и мержатся без исключений
|
||||
(проверено логом), но клинья сидят на внутренних углах колец в окрестностях
|
||||
multi-wall-узлов — там, где `outsetContour`/`insetContour` получают
|
||||
`multiWallNodes` и обрезают угловой материал, а фаски/патчи вниз по
|
||||
конвейеру эти места не накрывают. Визуализация с отмеченными пробами
|
||||
приложена к issue.
|
||||
|
||||
Архитектурно: конвейер — 10 фаз, смешивающих аддитивные патчи и вычитающие
|
||||
разрезы с try/catch-фолбэками. Пока в узловой механике есть `difference`,
|
||||
гарантии «дыр нет» не существует по построению; каждый точечный фикс двигает
|
||||
баланс add/subtract и рождает регресс в соседней конфигурации — что история
|
||||
и показывает.
|
||||
|
||||
## 4. Продуктовые решения владельца (2026-08-25, issue #302)
|
||||
|
||||
1. Не следующий точечный фикс, а **переработка узловой механики на простую и
|
||||
надёжную логику**.
|
||||
2. **Отдельный полноценный сет тестов со скриншотами стыков крупным планом**:
|
||||
разное число лучей, толщины, углы, виртуальные стены и т.д.
|
||||
3. Детектор дыр как объективный инвариант — в дополнение к скриншотам.
|
||||
4. ~~Фаска #249 на внешних углах узлов сохраняется~~ — решение середины дня,
|
||||
**отменено решением №5**.
|
||||
5. **Полный mitre везде** (финальное решение 2026-08-25, по визуальному
|
||||
сравнению трёх вариантов на T-90 и Y-60 50 см): узлы смыкаются как обычное
|
||||
пересечение стен на чертеже, фаска #249 демонтируется целиком. Поводом
|
||||
стали артефакты фаски на не-ортогональных узлах в новых эталонах
|
||||
(`junction-y-60-equal50` — вырез, `junction-acute30-mixed` — торчащие
|
||||
углы). Следствия: узловая механика чисто аддитивная, слой
|
||||
`bevelMultiWallPaper` удаляется, `bevelMultiWallBody` остаётся только
|
||||
адресным латеральным тримом #271 (уточнение при реализации; §8.2),
|
||||
существующие junction-эталоны переснимаются осознанно.
|
||||
|
||||
## 5. Цели
|
||||
|
||||
- Ни одной внутренней дыры ни в одной конфигурации узла из тестового сета и
|
||||
на репро владельца.
|
||||
- Одна механика узла вместо стека ремонтных слоёв; поведение выводимо из
|
||||
короткого контракта §8.
|
||||
- Оба рендерера получают результат из одного и того же кода (уже так —
|
||||
`wallBodiesUnionPath`; сохраняется).
|
||||
|
||||
## 6. Scope
|
||||
|
||||
- `src/wall-thickness.ts`: новая узловая механика; демонтаж заменённых слоёв
|
||||
(`bevelMultiWallBody`/`bevelMultiWallPaper`, вычитающие разрезы узлов,
|
||||
спец-обработка `multiWallNodes` в `outsetContour`/`insetContour` — в той
|
||||
мере, в какой их роль переходит к новой механике).
|
||||
- Новый чистый модуль детектора дыр (переиспользуется тестами).
|
||||
- `demo/golden/matrix.mjs` + фикстура: сет узловых сцен крупным планом.
|
||||
- Юниты, смок, мутанты.
|
||||
|
||||
## 7. Не входит
|
||||
|
||||
- Модель хранения (docs/ADR: полигоны комнат + walls/partitions) — не меняется.
|
||||
- Правило роста ±cm/2 от оси (docs/WALL-THICKNESS.md §2) — не меняется.
|
||||
- Проёмы: вычитающий слот проёма остаётся как есть (это не узловая механика).
|
||||
- Штриховка, цвета, бумага-подложка вне узлов.
|
||||
|
||||
## 8. Контракт поведения
|
||||
|
||||
### 8.1. Узел
|
||||
|
||||
Узел — кластер концов лучей (интервалов стен из `wallIntervals`) в допуске
|
||||
`EPS_NODE = openEps × 4` (текущий допуск `buildMultiWallNodeMap` — сохраняется).
|
||||
Луч приходит в узел со своей полу-толщиной; лучи упорядочиваются по азимуту.
|
||||
|
||||
### 8.2. Правило узла: аддитивные веера (решение №5)
|
||||
|
||||
Для каждой пары соседних по азимуту лучей (сектор ≤ 180°; рефлексные секторы —
|
||||
внешняя сторона выпуклого угла — пропускаются):
|
||||
|
||||
**Веер (аддитивно):** полигон «узел → край полосы A → mitre → край полосы B»,
|
||||
где mitre — пересечение обращённых в сектор офсетных прямых. Границы:
|
||||
|
||||
- mitre принимается, пока он в пределах классического `MITRE_LIMIT ×
|
||||
max(толщин пары)` — то же правило, что у обычных углов комнат;
|
||||
- и пока он не дальше конца ТОЛСТОГО саппорта каждого луча — иначе веер
|
||||
нарисовал бы латеральный фантом рядом с тонким продолжением (#271);
|
||||
- иначе — bevel-хорда: по каждому краю до `min(длина толстого саппорта,
|
||||
√(limit² − half²))`, замыкание хордой.
|
||||
|
||||
Дополнительно узел аддитивно получает точные саппорт-квады своих лучей
|
||||
(каждый ограничен собственной конечной длиной). Все куски клиппуются гладкой
|
||||
фасадной границей `junctionNodeBound` — узел не растит новый фасад на
|
||||
вогнутой вершине.
|
||||
|
||||
**Единственное вычитание узловой механики — адресный латеральный трим
|
||||
#271**: `bevelMultiWallBody` вызывается только для узлов с вырожденно-коротким
|
||||
толстым саппортом (короче собственной толщины), где базовые контуры красят
|
||||
лишнюю ширину, которую аддитивно не убрать; его угловые следы на таких узлах
|
||||
перекрываются веерами. На всех остальных узлах механика чисто аддитивная:
|
||||
слой фаски #249 как ВИД демонтирован целиком, дыра между полосами невозможна
|
||||
по построению, «лишний» материал ограничен `MITRE_LIMIT` и фасадной
|
||||
границей.
|
||||
|
||||
### 8.3. Спец-случаи
|
||||
|
||||
- Луч нулевой толщины (виртуальный участок, осевой черновик): в веерах не
|
||||
участвует, соседями по азимуту становятся его соседи (текущее поведение
|
||||
«zero divider не порождает кладку» сохраняется, docs/specs/172).
|
||||
- Два коллинеарных луча одной толщины — вырожденный веер (пустой), стык
|
||||
бесшовный (согласуется с #229).
|
||||
- Одинокий конец (degree-1) — плоский торец, веера нет (как сейчас).
|
||||
- Колонна в узле — самостоятельное тело, union поверх (как сейчас).
|
||||
|
||||
### 8.4. Инвариант «нет дыр» — формальный
|
||||
|
||||
Уточнение по факту реализации (первая формулировка «окружено кладкой с ≥5 из
|
||||
8 сторон» ложно срабатывала на легитимном полу комнаты в острых внутренних
|
||||
углах — за inset-mitre): детектор проверяет **контрактное покрытие**. Проба p —
|
||||
«дыра», если p ∈ (полоса какого-либо луча узла ∪ веер узла) и p ∉ тело. Сетка
|
||||
проб шагом `0.2 × gridPitch` в радиусе `MITRE_LIMIT × halfDepth + halfDepth` от
|
||||
узла; принадлежность полосе — строго внутри квада (0 ≤ t ≤ длина, |перпендикуляр|
|
||||
< half − ε). Ноль дыр — обязательство для каждого узла каждой сцены сета §13.
|
||||
|
||||
### 8.5. Что не меняется наружно
|
||||
|
||||
- Форма прямых участков, торцы, проёмные тоннели, бумага по контурам комнат.
|
||||
- Ортогональные L/T/X-стыки одинаковой толщины обязаны совпасть с текущим
|
||||
видом попиксельно (golden), кроме сцен, где сегодня есть дефекты.
|
||||
|
||||
## 9. Данные, i18n, a11y, privacy, security
|
||||
|
||||
Конфиг не меняется, миграций нет, строк интерфейса нет. Приватность/безопасность
|
||||
не затрагиваются.
|
||||
|
||||
## 10. Performance
|
||||
|
||||
Замена вычитающих фаз на аддитивные веера уменьшает число булевых операций на
|
||||
узел. Бюджет: `smoke_render_perf` не хуже базовой линии; large-house фикстура —
|
||||
без деградации, замер в отчёте на код-ревью.
|
||||
|
||||
## 11. Риски
|
||||
|
||||
1. **Golden-переснятие.** Существующие сцены стыков изменятся законно там, где
|
||||
сегодня дефект. Каждое расхождение разбирается поимённо; принятие — только
|
||||
`golden:accept -- --reviewed` отдельным коммитом с `Release:` +
|
||||
`Baseline-Reviewed:` (урок #230). Сцены, не связанные с узлами, обязаны
|
||||
совпасть побайтно.
|
||||
2. **Демонтаж слоёв.** Убирая `bevelMultiWallBody` и вычитающие разрезы, можно
|
||||
потерять их полезную функцию для конфигураций вне сета. Ответ — широта сета
|
||||
§13 и обязательный прогон всех существующих junction-смоков и golden.
|
||||
3. **Незамеченный «лишний» материал.** Аддитивные веера могут закрыть то, что
|
||||
раньше было честной щелью (например, два независимых узла рядом). Допуск
|
||||
`EPS_NODE` не расширяется, веера строятся только между лучами ОДНОГО узла.
|
||||
|
||||
## 12. Acceptance criteria
|
||||
|
||||
- **AC1.** Детектор §8.4: ноль внутренних дыр на каждом узле каждой сцены сета §13.
|
||||
- **AC2.** Репро владельца (5 узлов): ноль внутренних дыр; визуально клинья
|
||||
исчезли (golden-сцена репро).
|
||||
- **AC3.** Сцены, не связанные с узлами ≥3 лучей, совпадают с текущими
|
||||
эталонами попиксельно. Junction-сцены меняются законно (решение №5) и
|
||||
переснимаются осознанно с поимённым разбором.
|
||||
- **AC4.** Острый угол 57° со стенами 50/70: сплошная кладка (перекрытие полос
|
||||
не срезается), свободная часть клина срезана по лимиту узла.
|
||||
- **AC5.** Виртуальный участок в узле не порождает кладку; соседние физические
|
||||
лучи смыкаются веером через него.
|
||||
- **AC6.** Единственное вычитание узловой механики — адресный трим #271 на
|
||||
узлах с вырожденно-коротким толстым саппортом; все прочие узлы аддитивны
|
||||
(фаска #249 как вид демонтирована). Мутант возвращает лимит веера к 1.25·h —
|
||||
юнит формы веера обязан покраснеть (вырез Y-60 из отчёта владельца).
|
||||
- **AC7.** Оба рендерера дают идентичную геометрию узла (один вызов
|
||||
`wallBodiesUnionPath`; смок сверяет пути).
|
||||
- **AC8.** Перф: `smoke_render_perf` в бюджете; large-house без деградации.
|
||||
- **AC9.** Существующие смоки стыков (`wall_junctions`,
|
||||
`junction_patch_resilience`, `split_corner_wall`, `zero_divider_taper`,
|
||||
`wall_thickness*`) зелёные.
|
||||
|
||||
## 13. Тестовый сет (golden крупным планом + детектор)
|
||||
|
||||
Каждая сцена: узел занимает весь кадр; тёмная тема; для каждой сцены детектор
|
||||
§8.4 по всем узлам. Матрица:
|
||||
|
||||
| группа | сцены |
|
||||
|---|---|
|
||||
| число лучей | L (2), T (3), X (4), звезда (5) |
|
||||
| толщины | равные 15; смешанные 15/50, 50/70, 15/70 |
|
||||
| углы | 90°, 60°, 45°, 30°, 15°, 170° |
|
||||
| виртуальные | физический+виртуальный в T; виртуальный сквозь X |
|
||||
| комнаты | вершина комнаты + перегородка; T в середину стены комнаты; общая острая вершина двух комнат (репро) |
|
||||
| прочее | колонна в узле; конец черновика; перекрёсток двух перегородок |
|
||||
|
||||
Комбинации не декартовы — ~20 сцен, отобранных по одному представителю на
|
||||
класс, плюс сцена-репро из issue. Юниты: чистые функции веера (mitre/bevel,
|
||||
пороги, вырожденные случаи) и детектора.
|
||||
|
||||
## 14. Мутационный гейт
|
||||
|
||||
| id | Что ломает | Гвард |
|
||||
|---|---|---|
|
||||
| `node-fan-disabled` | веера не строятся вовсе | детектор на сете |
|
||||
| `node-fan-outer-only` | веер только с внешней стороны | детектор |
|
||||
| `node-fan-ignores-mitre-limit` | mitre без лимита | юниты веера |
|
||||
| `node-fan-includes-zero-ray` | нулевой луч порождает кладку | юниты + смок |
|
||||
| `node-fan-limit-back-to-249` | лимит веера снова 1.25·h — вырез Y-60 | юниты формы веера |
|
||||
| `hole-detector-blind` | детектор всегда зелёный | самопроверка детектора на заведомо дырявой фикстуре |
|
||||
|
||||
Все юнит-гварды — с пересборкой `test-build` (урок #230/#235).
|
||||
|
||||
## 15. Release-артефакты
|
||||
|
||||
`User-Visible: yes`: обе редакции CHANGELOG; `docs/WALL-THICKNESS.md` §3 и §9
|
||||
переписываются под новую механику; строка в USER-GUIDE при необходимости.
|
||||
Golden-переснятие — отдельным коммитом по правилу §11.1.
|
||||
|
||||
## 16. Откат
|
||||
|
||||
Один revert продуктового коммита + revert коммита эталонов. Конфиг не
|
||||
меняется, миграций нет.
|
||||
|
||||
## 17. Принятые предположения (техническое, менять свободно)
|
||||
|
||||
- Константы детектора (§8.4) выбраны по факту воспроизведения; могут быть
|
||||
уточнены, но только в сторону строгости.
|
||||
- Демонтаж старых слоёв допустимо вести поэтапно (веера поверх текущей базы →
|
||||
снятие bevel-слоя → снятие узловой спец-обработки контуров), если каждый шаг
|
||||
держит AC1–AC9 зелёными; порядок — на усмотрение реализации.
|
||||
@@ -614,45 +614,6 @@ export const MUTANTS = [
|
||||
replace: ' return v;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'multi-wall-paper-full-origin-cut',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="issue #197 keeps" '
|
||||
+ 'test/wall-thickness.test.mjs',
|
||||
because: 'the exterior paper must retain valid multi-wall overlap up to R; cutting from the '
|
||||
+ 'offset origins recreates the measured white T-junction wedge from #261',
|
||||
patches: [{
|
||||
file: 'src/wall-thickness.ts',
|
||||
find: ' const cuts = multiWallEffectiveCutGeometry(\n'
|
||||
+ ' node, map, true, true, protectedStrips,\n'
|
||||
+ ' );',
|
||||
replace: ' const cuts = multiWallEffectiveCutGeometry(\n'
|
||||
+ ' node, map, false, true, protectedStrips,\n'
|
||||
+ ' );',
|
||||
}, {
|
||||
file: 'src/wall-thickness.ts',
|
||||
find: ' if (envelope) localInside = intersection(localInside, envelope);\n'
|
||||
+ ' else if (centre) localInside = intersection(localInside, centre);',
|
||||
replace: ' if (centre) localInside = intersection(localInside, centre);\n'
|
||||
+ ' else if (envelope) localInside = intersection(localInside, envelope);',
|
||||
}, {
|
||||
file: 'src/wall-thickness.ts',
|
||||
find: ' return cuts && protectedStrips ? difference(cuts, protectedStrips) : cuts;',
|
||||
replace: ' return cuts;',
|
||||
}, {
|
||||
file: 'src/wall-thickness.ts',
|
||||
find: ' if (protectedStrips) local = union(local, protectedStrips);',
|
||||
replace: ' // protected-strip fail-safe intentionally disabled by the mutant',
|
||||
}, {
|
||||
file: 'src/wall-thickness.ts',
|
||||
find: ' if (protectedStrips) {\n'
|
||||
+ ' try {\n'
|
||||
+ ' let protectedInside = protectedStrips;',
|
||||
replace: ' if (protectedStrips && false) {\n'
|
||||
+ ' try {\n'
|
||||
+ ' let protectedInside = protectedStrips;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'multi-wall-orthogonal-strip-protection-disabled',
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
@@ -2209,6 +2170,81 @@ export const MUTANTS = [
|
||||
replace: ' .dev:not(.unavail):hover {',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'junction-fans-disabled',
|
||||
// Юниты формы, не детектор: контрактные пробы детектора строятся из той
|
||||
// же функции и слепнут вместе с ней, а «T-узел даёт два веера» — внешняя
|
||||
// истина, не зависящая от мутируемого кода.
|
||||
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) {\n push([[P[0], P[1]], EA, mitre, EB]);\n continue;\n }',
|
||||
replace: ' if (mitre) {\n continue;\n }',
|
||||
}, {
|
||||
file: 'src/wall-thickness.ts',
|
||||
find: ' push([[P[0], P[1]], EA, A2, B2, EB]);',
|
||||
replace: ' void A2; void B2;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
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: 'mitre, шагнувший за короткий толстый саппорт, рисует латеральный '
|
||||
+ 'фантом рядом с тонким продолжением (#271)',
|
||||
patches: [{
|
||||
file: 'src/wall-thickness.ts',
|
||||
find: ' : tA > 1e-9 && tA <= A.thickLength && tB <= B.thickLength;',
|
||||
replace: ' : tA > 1e-9;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'junction-reflex-outer-mitre-missing',
|
||||
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: 'рефлексный сектор — наружный угол между крайними лучами; без '
|
||||
+ 'обратного mitre там остаётся вырез Y-60 из отчёта владельца',
|
||||
patches: [{
|
||||
file: 'src/wall-thickness.ts',
|
||||
find: ' const directionOk = reflex\n ? tA <= 1e-9 && tB <= 1e-9',
|
||||
replace: ' const directionOk = reflex\n ? false',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'junction-fan-limit-back-to-249',
|
||||
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: 'лимит веера 1.25·h — это отставка решения №5: узлы снова с '
|
||||
+ 'вырезами и ступеньками вместо полного mitre',
|
||||
patches: [{
|
||||
file: 'src/wall-thickness.ts',
|
||||
find: ' const limit = MITRE_LIMIT * Math.max(A.halfDepth, B.halfDepth);\n // Facing strip edges',
|
||||
replace: ' const limit = node.limit;\n // Facing strip edges',
|
||||
}],
|
||||
},
|
||||
{
|
||||
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 '
|
||||
|
||||
@@ -2008,6 +2008,212 @@ export function buildMultiWallNodeMap(
|
||||
return { epsilon: eps, coordinateScale: scale, nodes, index };
|
||||
}
|
||||
|
||||
/**
|
||||
* Corner geometry of one node (#302 + the #249 chamfer, owner 2026-08-25).
|
||||
*
|
||||
* One angular walk produces both halves of the corner rule:
|
||||
* - a FAN per pair of adjacent rays — additive sector material from the node
|
||||
* out to the mitre point, or to the bevel chord when the mitre runs past
|
||||
* the node's approved join limit;
|
||||
* - a CUT per over-limit pair — the wedge beyond that same chord, which is
|
||||
* how the approved #249 chamfer looks.
|
||||
* Fan and cut of one pair meet exactly at the chord and never overlap, and the
|
||||
* cut lies strictly between the two strip edges: subtracting it can touch
|
||||
* neither strip's interior. That bound is what the old bevel layer kept
|
||||
* failing to hold — its cuts reached past the limit with a separate
|
||||
* "protection" pass patching the damage after the fact.
|
||||
*/
|
||||
export interface JunctionNodeGeometry {
|
||||
fans: number[][][];
|
||||
/** Exact support quads of every ray — the strips the node actually owns. */
|
||||
supports: number[][][];
|
||||
}
|
||||
|
||||
export function junctionNodeGeometry(
|
||||
map: MultiWallNodeMap | null | undefined,
|
||||
): JunctionNodeGeometry {
|
||||
const out: JunctionNodeGeometry = { fans: [], supports: [] };
|
||||
if (!map?.nodes?.length) return out;
|
||||
const areaEps = Math.max(map.epsilon, 1e-9) ** 2;
|
||||
for (const node of map.nodes) {
|
||||
const rays = node.rays
|
||||
.filter((ray) => Number.isFinite(ray.halfDepth) && ray.halfDepth > 0)
|
||||
.map((ray) => ({
|
||||
...ray,
|
||||
// The fan follows the strip that actually exists at the node: the
|
||||
// ray's max half-depth is only valid as far as the support that owns
|
||||
// it. Walking past a short thick support would paint a phantom beside
|
||||
// a thinner continuation (#271).
|
||||
thickLength: Math.max(...ray.supports
|
||||
.filter((support) => support.halfDepth >= ray.halfDepth - 1e-12)
|
||||
.map((support) => support.length), 0),
|
||||
angle: (() => {
|
||||
const a = Math.atan2(ray.u[1], ray.u[0]);
|
||||
return a < 0 ? a + Math.PI * 2 : a;
|
||||
})(),
|
||||
}))
|
||||
.sort((a, b) => a.angle - b.angle);
|
||||
if (rays.length < 2) continue;
|
||||
const P = node.point;
|
||||
// The support quads are the ground truth the chamfer must never eat:
|
||||
// each is bounded by its own finite length, so re-adding them can never
|
||||
// repaint a lateral phantom beyond a short support (#271).
|
||||
for (const ray of rays) {
|
||||
for (const support of ray.supports) {
|
||||
if (!(support.halfDepth > 0) || !(support.length > 0)) continue;
|
||||
const ex = -ray.u[1] * support.halfDepth;
|
||||
const ey = ray.u[0] * support.halfDepth;
|
||||
const far = [
|
||||
P[0] + ray.u[0] * support.length,
|
||||
P[1] + ray.u[1] * support.length,
|
||||
];
|
||||
out.supports.push([
|
||||
[P[0] + ex, P[1] + ey],
|
||||
[far[0] + ex, far[1] + ey],
|
||||
[far[0] - ex, far[1] - ey],
|
||||
[P[0] - ex, P[1] - ey],
|
||||
]);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < rays.length; i++) {
|
||||
const A = rays[i];
|
||||
const B = rays[(i + 1) % rays.length];
|
||||
const sector = (() => {
|
||||
const raw = B.angle - A.angle;
|
||||
return raw > 0 ? raw : raw + Math.PI * 2;
|
||||
})();
|
||||
if (sector < 1e-9) continue;
|
||||
const reflex = sector > Math.PI + 1e-9;
|
||||
const limit = MITRE_LIMIT * Math.max(A.halfDepth, B.halfDepth);
|
||||
// Facing strip edges: A's at angle+90°, B's at angle−90°.
|
||||
const EA = [P[0] - A.u[1] * A.halfDepth, P[1] + A.u[0] * A.halfDepth];
|
||||
const EB = [P[0] + B.u[1] * B.halfDepth, P[1] - B.u[0] * B.halfDepth];
|
||||
const cross = A.u[0] * B.u[1] - A.u[1] * B.u[0];
|
||||
const inSector = (point: number[]): boolean => {
|
||||
let angle = Math.atan2(point[1] - P[1], point[0] - P[0]) - A.angle;
|
||||
while (angle < 0) angle += Math.PI * 2;
|
||||
return angle <= sector + 1e-9;
|
||||
};
|
||||
let mitre: number[] | null = null;
|
||||
if (Math.abs(cross) > 1e-9) {
|
||||
const tA = ((EB[0] - EA[0]) * B.u[1] - (EB[1] - EA[1]) * B.u[0]) / cross;
|
||||
const tB = ((EB[0] - EA[0]) * A.u[1] - (EB[1] - EA[1]) * A.u[0]) / cross;
|
||||
const candidate = [EA[0] + A.u[0] * tA, EA[1] + A.u[1] * tA];
|
||||
// The mitre is only a corner when it actually sits IN the sector —
|
||||
// forward along the rays for an ordinary pair, backward for a reflex
|
||||
// outer corner — inside the classic bound, and never past a thick
|
||||
// support (#271: overshooting one paints a lateral phantom).
|
||||
const directionOk = reflex
|
||||
? tA <= 1e-9 && tB <= 1e-9
|
||||
: tA > 1e-9 && tA <= A.thickLength && tB <= B.thickLength;
|
||||
if (directionOk
|
||||
&& Math.hypot(candidate[0] - P[0], candidate[1] - P[1]) <= limit
|
||||
&& inSector(candidate)) {
|
||||
mitre = candidate;
|
||||
}
|
||||
}
|
||||
const push = (poly: number[][]) => {
|
||||
if (Math.abs(signedArea(poly)) > areaEps) out.fans.push(poly);
|
||||
};
|
||||
if (mitre) {
|
||||
push([[P[0], P[1]], EA, mitre, EB]);
|
||||
continue;
|
||||
}
|
||||
if (reflex) {
|
||||
// A degenerate reflex mitre (parallel or out-of-bound edges) closes
|
||||
// with the plain chord between the two strip edges.
|
||||
push([[P[0], P[1]], EA, EB]);
|
||||
continue;
|
||||
}
|
||||
// Bevel: walk each offset line a LOCAL distance — bounded by the thick
|
||||
// support, by the classic limit and by twice the pair's depth, so the
|
||||
// chord stays a corner detail and cannot fold across the plan.
|
||||
const reach = (half: number, length: number) => Math.min(
|
||||
length,
|
||||
Math.sqrt(Math.max(limit ** 2 - half ** 2, 0)),
|
||||
2 * Math.max(A.halfDepth, B.halfDepth),
|
||||
);
|
||||
const A2 = [
|
||||
EA[0] + A.u[0] * reach(A.halfDepth, A.thickLength),
|
||||
EA[1] + A.u[1] * reach(A.halfDepth, A.thickLength),
|
||||
];
|
||||
const B2 = [
|
||||
EB[0] + B.u[0] * reach(B.halfDepth, B.thickLength),
|
||||
EB[1] + B.u[1] * reach(B.halfDepth, B.thickLength),
|
||||
];
|
||||
push([[P[0], P[1]], EA, A2, B2, EB]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The fans alone — kept for callers that only ever add material. */
|
||||
export function junctionNodeFans(
|
||||
map: MultiWallNodeMap | null | undefined,
|
||||
): number[][][] {
|
||||
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,
|
||||
@@ -2746,40 +2952,6 @@ function bevelMultiWallBody(
|
||||
return current;
|
||||
}
|
||||
|
||||
function bevelMultiWallPaper(
|
||||
paper: any,
|
||||
centre: any,
|
||||
map: MultiWallNodeMap,
|
||||
): any {
|
||||
let beveled = paper;
|
||||
let protectedStrips: any = null;
|
||||
try {
|
||||
protectedStrips = multiWallProtectedMapGeometry(map);
|
||||
} catch {
|
||||
return paper;
|
||||
}
|
||||
for (const node of map.nodes) {
|
||||
try {
|
||||
const cuts = multiWallEffectiveCutGeometry(
|
||||
node, map, true, true, protectedStrips,
|
||||
);
|
||||
if (cuts) beveled = difference(beveled, cuts);
|
||||
} catch {
|
||||
// Isolate a failed optional node cut; retain the last valid paper.
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (protectedStrips) {
|
||||
const protectedPaper = intersection(protectedStrips, paper);
|
||||
beveled = union(beveled, protectedPaper);
|
||||
}
|
||||
// Paper is the complete room footprint. Interior bevel cuts expose floor,
|
||||
// not the scene background, so the centre union must always remain solid.
|
||||
return union(centre, beveled);
|
||||
} catch {
|
||||
return paper;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse arithmetic noise on computed junction vertices before polyclip sees
|
||||
@@ -2985,14 +3157,68 @@ export function floorFootprintGeometry(
|
||||
const paper = exterior.shell?.length
|
||||
? union(exterior.centre, exterior.shell)
|
||||
: exterior.centre;
|
||||
return multiWallNodes.nodes.length
|
||||
? bevelMultiWallPaper(paper, exterior.centre, multiWallNodes)
|
||||
: paper;
|
||||
return paper;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop zero-area rings a boolean union leaves where two chords coincide
|
||||
* exactly (fan chord over chamfer chord). They paint nothing, but they are
|
||||
* topological holes and every downstream ring-counting consumer sees them.
|
||||
*/
|
||||
function dropDegenerateRings(geom: any, areaEps: number): any {
|
||||
if (!Array.isArray(geom)) return geom;
|
||||
const polygons = geom
|
||||
.map((polygon: any) => {
|
||||
if (!Array.isArray(polygon) || !polygon.length) return polygon;
|
||||
const [outer, ...holes] = polygon;
|
||||
if (Math.abs(signedArea(outer || [])) <= areaEps) return null;
|
||||
return [outer, ...holes.filter(
|
||||
(ring: number[][]) => Math.abs(signedArea(ring || [])) > areaEps,
|
||||
)];
|
||||
})
|
||||
.filter((polygon: any) => !!polygon);
|
||||
return polygons;
|
||||
}
|
||||
|
||||
/**
|
||||
* The approved outer boundary for node pieces: the building footprint plus
|
||||
* the exterior wall band with PLAIN corners — the very shape the contour had
|
||||
* before any node existed. Fans and support tips are clipped to it, so the
|
||||
* node can never grow new facade (the concave-Split contract), while the
|
||||
* plain corners — unlike the node-notched envelope — never reopen the sector
|
||||
* holes the pieces exist to close.
|
||||
*/
|
||||
export function junctionNodeBound(
|
||||
rooms: any[],
|
||||
walls: WallEntry[] | null | undefined,
|
||||
openCuts: number[][],
|
||||
pitch: number,
|
||||
cellCm: number,
|
||||
gridPitch: number,
|
||||
coordScale: number,
|
||||
map: MultiWallNodeMap,
|
||||
): any | null {
|
||||
try {
|
||||
const plain: MultiWallNodeMap = {
|
||||
epsilon: map.epsilon, coordinateScale: map.coordinateScale,
|
||||
nodes: [], index: new Map(),
|
||||
};
|
||||
const exterior = exteriorEnvelopeGeometry(
|
||||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale, plain,
|
||||
);
|
||||
if (!exterior) return null;
|
||||
return exterior.shell?.length
|
||||
? union(exterior.centre, exterior.shell)
|
||||
: exterior.centre;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export function polyclipToPathD(geom: any): string {
|
||||
if (!geom) return '';
|
||||
let d = '';
|
||||
@@ -3237,9 +3463,13 @@ export function wallBodiesGeometry(
|
||||
const rawPaperGeom = exterior
|
||||
? (exterior.shell?.length ? union(exterior.centre, exterior.shell) : exterior.centre)
|
||||
: [];
|
||||
const paperGeom = multiWallNodes.nodes.length && exterior
|
||||
? bevelMultiWallPaper(rawPaperGeom, exterior.centre, multiWallNodes)
|
||||
: rawPaperGeom;
|
||||
// Paper: the approved chamfer first, then the same additive fans that
|
||||
// complete the masonry corner complete the paper beneath it (#302).
|
||||
// Paper needs no node pieces: the footprint-plus-shell union already
|
||||
// covers every junction (measured on #197 and the owner repro — byte-equal
|
||||
// with and without them), and with the subtractive paper bevel gone the
|
||||
// #261 white-wedge class is impossible by construction.
|
||||
const paperGeom = rawPaperGeom;
|
||||
const bodyOf = (ring: typeof roomRings[number]): any => {
|
||||
const outset: any = closedRing(ring.outset);
|
||||
return ring.inset ? difference(outset, closedRing(ring.inset) as any) : outset;
|
||||
@@ -3302,9 +3532,55 @@ export function wallBodiesGeometry(
|
||||
}
|
||||
}
|
||||
}
|
||||
corePhase = 'multi-wall-bevel';
|
||||
if (body && multiWallNodes.nodes.length)
|
||||
body = bevelMultiWallBody(body, multiWallNodes, exterior?.centre, paperGeom);
|
||||
// The old bevel layer survives only as a TARGETED lateral trim: it
|
||||
// removes the ring material a base contour paints past a degenerately
|
||||
// short thick support (#271) — something no additive piece can undo. It
|
||||
// runs ONLY on nodes that actually have such a support: everywhere else
|
||||
// it used to leave the steps and horns the owner rejected (decision #5),
|
||||
// and the node stays purely additive.
|
||||
corePhase = 'multi-wall-trim';
|
||||
if (body && multiWallNodes.nodes.length) {
|
||||
const needsTrim = (node: MultiWallNode): boolean => node.rays.some(
|
||||
(ray) => ray.supports.some(
|
||||
(support) => support.length < support.halfDepth * 2,
|
||||
),
|
||||
);
|
||||
const trimNodes = multiWallNodes.nodes.filter(needsTrim);
|
||||
if (trimNodes.length) {
|
||||
const trimMap: MultiWallNodeMap = {
|
||||
...multiWallNodes, nodes: trimNodes,
|
||||
};
|
||||
body = bevelMultiWallBody(body, trimMap, exterior?.centre, paperGeom);
|
||||
}
|
||||
}
|
||||
// Then the node gets its additive corners: the exact support quads of its
|
||||
// rays — each bounded by its own finite length, so the trimmed lateral
|
||||
// phantom cannot come back — and one mitre/bevel fan per pair of
|
||||
// angularly adjacent rays, bounded by the classic MITRE_LIMIT.
|
||||
corePhase = 'junction-corners';
|
||||
if (multiWallNodes.nodes.length) {
|
||||
const corners = junctionNodeGeometry(multiWallNodes);
|
||||
const bound = junctionNodeBound(
|
||||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||||
multiWallNodes,
|
||||
);
|
||||
// Fans only: with the trim now TARGETED (it never cuts an ordinary
|
||||
// node's strips) the support re-union became dead weight — measured by
|
||||
// the full unit suite staying green without it. The support quads stay
|
||||
// exported: the detector and the tests use them as the contract truth.
|
||||
for (const piece of corners.fans) {
|
||||
try {
|
||||
let ring: any = [closedRing(piece)];
|
||||
if (bound) ring = intersection(ring, bound);
|
||||
if (!ring?.length) continue;
|
||||
body = body ? union(body, ring) : ring;
|
||||
} catch {
|
||||
// A degenerate piece must not take the whole node down; the rest
|
||||
// still stands on its own.
|
||||
}
|
||||
}
|
||||
body = dropDegenerateRings(body, Math.max(multiWallNodes.epsilon, 1e-9) ** 2);
|
||||
}
|
||||
const roomGeom = body || [];
|
||||
// cut opening tunnels (axis-aligned to opening angle)
|
||||
corePhase = 'openings';
|
||||
@@ -3641,9 +3917,7 @@ export function paperRoomShapesWithWalls(
|
||||
const multiWallNodes = multiWallNodesForGeometry(
|
||||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||||
);
|
||||
const paper = multiWallNodes.nodes.length
|
||||
? bevelMultiWallPaper(rawPaper, exterior.centre, multiWallNodes)
|
||||
: rawPaper;
|
||||
const paper = rawPaper;
|
||||
const path = polyclipToPathD(paper);
|
||||
if (path) return [{ path }];
|
||||
}
|
||||
|
||||
@@ -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,6 +25,7 @@ import {
|
||||
WALL_HATCH_MIN_PX,
|
||||
wallHatchStepUnits, wallHatchNeedsSolid,
|
||||
HATCH_BASE_STEP_UNITS, HATCH_MIN_STEP_UNITS, HATCH_MAX_STEP_UNITS,
|
||||
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';
|
||||
@@ -196,6 +197,11 @@ const assertBoundedMultiWallBevels = (
|
||||
);
|
||||
const triangles = multiWallBevelTriangles(map);
|
||||
assert.ok(triangles.length > 0, 'fixture no longer exercises an oversized multi-wall join');
|
||||
// Strips live only inside the approved facade bound: a giant shared wall
|
||||
// poking past the building outline is clipped there, exactly as before.
|
||||
const facadeBound = junctionNodeBound(
|
||||
rooms, walls, [], pitch, cell, GRID_PITCH, scale, map,
|
||||
);
|
||||
for (const triangle of triangles) {
|
||||
const node = map.nodes.find((candidate) => triangle.slice(0, 2).every((point) =>
|
||||
Math.hypot(
|
||||
@@ -211,20 +217,43 @@ const assertBoundedMultiWallBevels = (
|
||||
(base[0] + triangle[2][0]) / 2,
|
||||
(base[1] + triangle[2][1]) / 2,
|
||||
];
|
||||
const protectedStrips = multiWallProtectedStripGeometry(node, map);
|
||||
const protectedCoverage = protectedStrips
|
||||
? geometryProbeCoverage(protectedStrips, probe)
|
||||
: 0;
|
||||
// #302 contract (owner decision #5): the node is purely additive. A
|
||||
// probe inside the node's support strips or mitre/bevel fans — within the
|
||||
// plain facade bound — is FILLED; outside all of them the wedge is empty.
|
||||
const nodeCorners = junctionNodeGeometry({
|
||||
epsilon: map.epsilon, coordinateScale: map.coordinateScale,
|
||||
nodes: [node], index: new Map(),
|
||||
});
|
||||
const inPolygonProbe = (points) => {
|
||||
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 > probe[1]) !== (yj > probe[1])
|
||||
&& probe[0] < ((xj - xi) * (probe[1] - yi)) / (yj - yi) + xi) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
const inStrips = node.rays.some((ray) => ray.supports.some((support) => {
|
||||
const rx = probe[0] - node.point[0];
|
||||
const ry = probe[1] - 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 - 1e-7;
|
||||
}));
|
||||
const inFans = nodeCorners.fans.some((fan) => inPolygonProbe(fan));
|
||||
const inContract = (inStrips || inFans)
|
||||
&& (!facadeBound || geometryProbeCoverage(facadeBound, probe) > 1e-7);
|
||||
const actualCoverage = geometryProbeCoverage(geometry.geom, probe);
|
||||
if (protectedCoverage > 1e-7) {
|
||||
if (inContract) {
|
||||
assert.ok(
|
||||
actualCoverage + 1e-7 >= protectedCoverage,
|
||||
`a bevel removed protected orthogonal material at ${probe}`,
|
||||
actualCoverage > 1e-7,
|
||||
`the additive node lost contract material at ${probe}`,
|
||||
);
|
||||
} else {
|
||||
assert.ok(
|
||||
actualCoverage < 1e-7,
|
||||
`an unprotected excessive multi-wall wedge remains filled at ${probe}`,
|
||||
`a wedge outside every strip and fan remains filled at ${probe}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1196,15 +1225,35 @@ test('issue #249 bounds the exported three-wall junction with straight bevels',
|
||||
nodePoint[1] + ray.u[1] * node.halfDepth * 2,
|
||||
], 'an incident wall arm no longer touches the junction');
|
||||
}
|
||||
// #302: the chamfer is strip-safe now. A wedge probe inside the strips the
|
||||
// node actually owns stays FILLED (an acute junction is solid masonry);
|
||||
// outside every strip the wedge is still discarded, as approved in #249.
|
||||
const bound = junctionNodeBound(
|
||||
rooms, walls, [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W, nodes,
|
||||
);
|
||||
for (const triangle of localTriangles) {
|
||||
const base = [
|
||||
(triangle[0][0] + triangle[1][0]) / 2,
|
||||
(triangle[0][1] + triangle[1][1]) / 2,
|
||||
];
|
||||
assertProbeOutside(geometry.geom, [
|
||||
const probe = [
|
||||
(base[0] + triangle[2][0]) / 2,
|
||||
(base[1] + triangle[2][1]) / 2,
|
||||
], 'the discarded mitre wedge is still filled');
|
||||
];
|
||||
const inStrip = node.rays.some((ray) => ray.supports.some((support) => {
|
||||
const rx = probe[0] - node.point[0];
|
||||
const ry = probe[1] - 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 - 1e-7;
|
||||
})) && (!bound || geometryProbeCoverage(bound, probe) > 1e-7);
|
||||
if (inStrip) {
|
||||
assertProbeInside(
|
||||
geometry.geom, probe, 'the strip-safe chamfer removed strip material',
|
||||
);
|
||||
} else {
|
||||
assertProbeOutside(geometry.geom, probe, 'the discarded mitre wedge is still filled');
|
||||
}
|
||||
}
|
||||
const repeated = wallBodiesGeometry(
|
||||
rooms, walls, [], [], pitch, fixture.cell_cm, GRID_PITCH, NORM_W,
|
||||
@@ -2190,7 +2239,10 @@ test('issue #197 keeps the full masonry when one virtual-junction patch has ULP
|
||||
// #271 removes only the area that the old node-wide 8H rectangles invented
|
||||
// after finite ray endpoints; all semantic #197/#249/#261 probes above stay.
|
||||
// #272 additionally opens any point-contact bevel cut to the exterior.
|
||||
closeTo(geometryArea(geometry.geom), 124534.6091222676, 1e-6);
|
||||
// #302: the node's support quads and mitre fans (reflex outer corners
|
||||
// included) add a sliver of masonry (+0.6 units²) at this fixture's
|
||||
// junctions.
|
||||
closeTo(geometryArea(geometry.geom), 124535.20808099362, 1e-6);
|
||||
closeTo(geometryArea(geometry.paperGeom), 727303.8194444444, 1e-6);
|
||||
assert.equal(
|
||||
JSON.stringify({ rooms, walls, cuts, openings, extraBodies }), before,
|
||||
@@ -2862,3 +2914,159 @@ 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 closes with the outer mitre', () => {
|
||||
const map = nodeMapOf(starIntervals([
|
||||
{ deg: 0, half: 5 }, { deg: 30, half: 5 }, { deg: 100, half: 5 },
|
||||
]));
|
||||
const { fans } = junctionNodeGeometry(map);
|
||||
// [0..30], [30..100] and the reflex [100..360]: the outer corner between
|
||||
// the extreme rays closes with a BACKWARD mitre — the Y-60 notch of the
|
||||
// owner's report lived exactly in a skipped reflex sector.
|
||||
assert.equal(fans.length, 3);
|
||||
// The reflex fan must be the four-point mitre, not the flat chord fallback:
|
||||
// its apex sits deeper than either strip edge.
|
||||
const node0 = map.nodes[0];
|
||||
const reflexFan = fans.find((poly) => poly.some((point) => {
|
||||
const dx = point[0] - node0.point[0], dy = point[1] - node0.point[1];
|
||||
const deg = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
|
||||
// The apex of the backward mitre: strictly inside the reflex sector and
|
||||
// deeper than a strip edge (a chord fallback never leaves the edges).
|
||||
return deg > 110 && deg < 350 && Math.hypot(dx, dy) > node0.halfDepth * 1.05;
|
||||
}));
|
||||
assert.ok(reflexFan, 'the reflex sector fell back to the chord — no outer mitre');
|
||||
const node = map.nodes[0];
|
||||
for (const fan of fans) {
|
||||
for (const point of fan) {
|
||||
assert.ok(
|
||||
Math.hypot(point[0] - node.point[0], point[1] - node.point[1])
|
||||
<= MITRE_LIMIT * node.halfDepth + 1e-7,
|
||||
`fan escaped the classic corner bound: ${point}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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.length === 5
|
||||
&& poly.some((point) => point[1] > 505));
|
||||
assert.ok(fan, 'the mitre past the thick support was accepted instead of a bevel');
|
||||
const bound = MITRE_LIMIT * Math.max(10, 2.5);
|
||||
for (const point of fan) {
|
||||
assert.ok(
|
||||
Math.hypot(point[0] - 500, point[1] - 500) <= bound + 1e-7,
|
||||
`fan escaped the classic corner bound: ${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',
|
||||
);
|
||||
});
|
||||
|
||||
test('issue 302 the 57° mixed-thickness pair takes the full mitre (decision #5)', () => {
|
||||
// The owner's repro corner: mitre at ~8.7 units — beyond the retired 1.25×h
|
||||
// join limit (6.1) but well inside the classic MITRE_LIMIT bound. It must
|
||||
// be a four-point mitre fan, not a bevel: this is the visible difference
|
||||
// between decision #5 and the #249 look.
|
||||
const map = nodeMapOf(starIntervals([
|
||||
{ deg: 45, half: 4.861 }, { deg: 102.3, half: 3.472 }, { deg: 332.2, half: 3.472 },
|
||||
]));
|
||||
assert.equal(map.nodes.length, 1);
|
||||
const { fans } = junctionNodeGeometry(map);
|
||||
const sectorFan = fans.find((poly) => poly.length === 4 && poly.some((point) => {
|
||||
const dx = point[0] - 500, dy = point[1] - 500;
|
||||
const angle = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360;
|
||||
return Math.hypot(dx, dy) > 6.2 && angle > 45 && angle < 103;
|
||||
}));
|
||||
assert.ok(sectorFan, 'the 57° sector fell back to a bevel — the 1.25×h limit is back');
|
||||
});
|
||||
|
||||