mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 19:58:50 +00:00
fix: cap junction mitres at the visual limit and drop foreign-sector pair patches (#309)
Вылет mitre ограничен VISUAL_MITRE_LIMIT = 1.5·max(h): длиннее — плоская фаска перпендикулярно направлению вершины (chamferApex), в парных патчах и в веерах узлов. Узлы ≥3 канонических лучей закрываются веерами junctionNodeGeometry прямо в linearWallJoinPatches: парный патч живёт в секторе, противоположном своей паре, и красил ступень поверх тонких полос (крест 15/15/30/30 из отчёта владельца). Прямые углы (вылет 1.41h) байтово прежние. Механизм #249 (MULTI_WALL_JOIN_LIMIT, multiWallBevelCutsAt, mitre контуров комнат) не тронут. Юниты формы на фикстуре трёх узлов владельца + контрактный детектор дыр; 4 новых мутанта, краснота каждого проверена исполнением. Матрица golden 46: три новые сцены junction-309-{step,spike,hump}-dark. Issue: #309 User-Visible: yes
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -5,6 +5,9 @@ import { readFileSync } from 'node:fs';
|
||||
const junctionArtifactsFixture = JSON.parse(readFileSync(
|
||||
new URL('../../test/fixtures/302-junction-artifacts.json', import.meta.url), 'utf8',
|
||||
));
|
||||
const junctionTeethFixture = JSON.parse(readFileSync(
|
||||
new URL('../../test/fixtures/309-junction-teeth.json', import.meta.url), 'utf8',
|
||||
));
|
||||
const junctionPatchFixture = JSON.parse(readFileSync(
|
||||
new URL('../../test/fixtures/197-junction-patch.json', import.meta.url), 'utf8',
|
||||
));
|
||||
@@ -296,6 +299,17 @@ export function prepareGoldenFixture(scenario) {
|
||||
settings: { fill_mode: 'none', show_borders: true, show_names: false },
|
||||
});
|
||||
}
|
||||
if (scenario.junctionTeeth) {
|
||||
fixture.config.spaces.push({
|
||||
id: scenario.space,
|
||||
title: 'Junction teeth repro',
|
||||
cell_cm: junctionTeethFixture.cell_cm,
|
||||
partitions: structuredClone(junctionTeethFixture.partitions),
|
||||
rooms: [],
|
||||
view_box: [0.1, 0.65, 3.05, 1.75],
|
||||
settings: { fill_mode: 'none', show_borders: true, show_names: false },
|
||||
});
|
||||
}
|
||||
if (scenario.junctionPatchResilience) {
|
||||
if (!Array.isArray(scenario.retainedWedgeProbe)
|
||||
|| scenario.retainedWedgeProbe.length !== 2
|
||||
|
||||
@@ -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 = 45;
|
||||
export const GOLDEN_MATRIX_VERSION = 46;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
@@ -617,6 +617,14 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
id, fixture: 'visual', space: `golden-${id}`, mode: 'view', junctionNode,
|
||||
zoom: 4, theme: 'dark', viewport: { width: 900, height: 900 }, ...stage,
|
||||
})),
|
||||
...[
|
||||
['junction-309-step-dark', 8, [808.33, 1233.33]],
|
||||
['junction-309-spike-dark', 8, [2220.83, 1350]],
|
||||
['junction-309-hump-dark', 6, [1758.33, 1612.5]],
|
||||
].map(([id, zoom, zoomCenter]) => ({
|
||||
id, fixture: 'visual', space: `golden-${id}`, mode: 'view', junctionTeeth: true,
|
||||
zoom, zoomCenter, 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',
|
||||
|
||||
Vendored
+121
-121
File diff suppressed because one or more lines are too long
@@ -2,6 +2,13 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Wall junctions no longer sprout teeth: a mitre apex is limited to 1.5
|
||||
half-depths and anything longer is closed with a flat drafting chamfer, so
|
||||
acute pairs lose their tails, thick multi-wall nodes lose their peaks, and
|
||||
mixed-thickness crosses no longer show a step in a foreign quadrant. Square
|
||||
corners are untouched
|
||||
([#309](https://github.com/Matysh/houseplan-card/issues/309)).
|
||||
|
||||
- The active wall chain keeps its axis and node markers visible on already
|
||||
placed segments while drawing. The chain ink now paints above the wall
|
||||
masonry (and below the snap overlay), so each click no longer buries the
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Стыковочные узлы больше не выпускают зубцы: вылет mitre ограничен
|
||||
1.5 полутолщины, всё длиннее закрывается плоской чертёжной фаской — у
|
||||
острых пар исчезают хвосты, у толстых многолучевых узлов пики, а кресты
|
||||
смешанных толщин теряют ступеньку в чужом квадранте. Прямые углы не
|
||||
изменились ([#309](https://github.com/Matysh/houseplan-card/issues/309)).
|
||||
|
||||
- Во время рисования цепочки стен осевая линия и узлы остаются видимыми на уже
|
||||
поставленных сегментах. Разметка цепочки теперь рисуется поверх кладки стен
|
||||
(и под снап-оверлеем), поэтому каждый клик больше не прячет жёлтую ось под
|
||||
|
||||
@@ -203,6 +203,21 @@ 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.
|
||||
|
||||
**Visual mitre limit (#309, owner decision 2026-08-25).** A mitre apex may
|
||||
protrude at most `VISUAL_MITRE_LIMIT = 1.5` maximal half-depths from the node
|
||||
(a square corner of equal depths peaks at ~1.41·h, so right and obtuse
|
||||
corners are byte-identical); a longer apex is closed with a flat chamfer
|
||||
perpendicular to the apex direction at the limit (`chamferApex`). The rule
|
||||
applies to both the junction fans and the pair patches of
|
||||
`linearWallJoinPatches`; `MITRE_LIMIT = 4` survives only as the sanity bound
|
||||
for candidate construction. At a node of three or more canonical rays the
|
||||
pair patches are not built at all: a pair patch lives in the sector OPPOSITE
|
||||
its pair and painted a step over the thinner strips owning that sector (the
|
||||
15/15/30/30 cross of the owner report) — such nodes are closed with the same
|
||||
sector fans via a local multi-wall node map inside `linearWallJoinPatches`.
|
||||
The #249 machinery (`MULTI_WALL_JOIN_LIMIT = 1.25`, `multiWallBevelCutsAt`,
|
||||
room-contour mitres) is untouched.
|
||||
|
||||
**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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"version": 1,
|
||||
"fixture": "synthetic-only",
|
||||
"chromium": "151.0.7922.34",
|
||||
"sourceFingerprint": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
|
||||
"sourceFingerprint": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
|
||||
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
|
||||
"command": "npm run build && node demo/docs/capture.mjs",
|
||||
"scenarios": {
|
||||
@@ -14,7 +14,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
|
||||
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
|
||||
"imageSha256": "0ca982495a7749907c48ebbddfd089c54385a4347f79f64e94af2c5a8d5c03eb"
|
||||
},
|
||||
"view-touch": {
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
|
||||
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
|
||||
"imageSha256": "1c8e28ff34736b936ba7c9ec047d1da27aaf6fbb01411155f2d5c05282c02c41"
|
||||
},
|
||||
"space-create": {
|
||||
@@ -36,7 +36,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
|
||||
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
|
||||
"imageSha256": "329750c81a7a958c4fd105b2c80f85dce55ed87df9ddf0ea08084e4428e85f04"
|
||||
},
|
||||
"room-contour-close": {
|
||||
@@ -47,7 +47,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
|
||||
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
|
||||
"imageSha256": "ff2e903e548423aa0aa52680e20b8ad951c704e01130cb4b180222015a241276"
|
||||
},
|
||||
"plan-context-tray": {
|
||||
@@ -58,7 +58,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
|
||||
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
|
||||
"imageSha256": "ff55925f079411023442e7e7dd5ed528c331aaa3fd3fc2fbbf3b1aaf34a85718"
|
||||
},
|
||||
"device-editor": {
|
||||
@@ -69,7 +69,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
|
||||
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
|
||||
"imageSha256": "6b9cd281450e7782cff292f11efc58d227bedd0df86afc3be987feb2edec9dc3"
|
||||
},
|
||||
"device-display-preview": {
|
||||
@@ -80,7 +80,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
|
||||
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
|
||||
"imageSha256": "6f628abe6b89d7c4b352e6d73ac0b126526f6fdba5927bb774c5b884e484ca19"
|
||||
},
|
||||
"background-editor": {
|
||||
@@ -91,7 +91,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
|
||||
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
|
||||
"imageSha256": "a1d1a905f1410f73231d2ea5d1dfbd8ae79c2fe8b0a79cff87ebb977ac7a3103"
|
||||
},
|
||||
"room-card": {
|
||||
@@ -102,7 +102,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
|
||||
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
|
||||
"imageSha256": "48a5685039a5e0de2a28190d857b193c86e2e63606e3c7a8c40fc9d2b190da4e"
|
||||
},
|
||||
"device-info": {
|
||||
@@ -113,7 +113,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
|
||||
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
|
||||
"imageSha256": "29a342811aaedaebc54ba337792d60cb454a7cf4c3063b210f6c4bafe0a4d87b"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2170,6 +2170,57 @@ export const MUTANTS = [
|
||||
replace: ' .dev:not(.unavail):hover {',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'visual-mitre-limit-back-to-4',
|
||||
// #309: порог визуального среза возвращается к классическим 4·h — шип на
|
||||
// острой паре и горб над узлом 3×50 отрастают обратно.
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="issue 309" test/wall-thickness.test.mjs',
|
||||
because: 'без визуального лимита mitre снова торчит на 2–4 толщины за габарит узла',
|
||||
patches: [{
|
||||
file: 'src/wall-thickness.ts',
|
||||
find: 'export const VISUAL_MITRE_LIMIT = 1.5;',
|
||||
replace: 'export const VISUAL_MITRE_LIMIT = 4;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'chamfer-disabled-full-mitre',
|
||||
// #309: фаска отключена — сверх лимита рисуется сырой mitre.
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="issue 309" test/wall-thickness.test.mjs',
|
||||
because: 'сектор сверх визуального лимита обязан закрываться фаской, а не вершиной',
|
||||
patches: [{
|
||||
file: 'src/wall-thickness.ts',
|
||||
find: ' const ux = apex[0] - node[0], uy = apex[1] - node[1];\n const d = Math.hypot(ux, uy);\n if (!(d > 0) || d <= limit + 1e-9) return null;',
|
||||
replace: ' const ux = apex[0] - node[0], uy = apex[1] - node[1];\n const d = Math.hypot(ux, uy);\n if (d >= 0) return null;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'pair-patches-at-multiwall-nodes',
|
||||
// #309: скип узлов ≥3 лучей отключён — парный патч через чужой сектор
|
||||
// снова красит ступень на кресте смешанных толщин.
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="issue 309" test/wall-thickness.test.mjs',
|
||||
because: 'парный патч в узле ≥3 лучей живёт в чужом секторе и красит ступень поверх тонких полос',
|
||||
patches: [{
|
||||
file: 'src/wall-thickness.ts',
|
||||
find: ' for (const node of nodes) {\n if (coveredByFans(node)) continue;',
|
||||
replace: ' for (const node of nodes) {',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'chamfer-chord-instead-of-perpendicular',
|
||||
// #309: срез хордой pA–pB вместо перпендикуляра на пороге — это возврат
|
||||
// формы #249 (плоский бевел у самого узла), сектор худеет.
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="issue 309" test/wall-thickness.test.mjs',
|
||||
because: 'фаска обязана резать перпендикулярно направлению вершины на пороге, а не хордой между гранями',
|
||||
patches: [{
|
||||
file: 'src/wall-thickness.ts',
|
||||
find: ' const cA = cut(pA), cB = cut(pB);\n if (!cA || !cB) return null;\n return [[node[0], node[1]], pA, cA, cB, pB];',
|
||||
replace: ' return null;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'junction-fans-disabled',
|
||||
// Юниты формы, не детектор: контрактные пробы детектора строятся из той
|
||||
@@ -2181,8 +2232,8 @@ export const MUTANTS = [
|
||||
+ 'это и есть класс артефактов #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 }',
|
||||
find: ' push(chamferApex([P[0], P[1]], EA, mitre, EB, visual)\n ?? [[P[0], P[1]], EA, mitre, EB]);\n continue;',
|
||||
replace: ' continue;',
|
||||
}, {
|
||||
file: 'src/wall-thickness.ts',
|
||||
find: ' push([[P[0], P[1]], EA, A2, B2, EB]);',
|
||||
|
||||
+59
-4
@@ -79,6 +79,41 @@ export const HATCH_MIN_STEP_PX = 2;
|
||||
/** Mitre spikes longer than this × thickness fall back to a bevel. */
|
||||
export const MITRE_LIMIT = 4;
|
||||
|
||||
/**
|
||||
* Visual mitre limit (#309, owner decision 2026-08-25). A mitre apex may
|
||||
* protrude at most this many maximal half-depths from the node; anything
|
||||
* longer is closed with a flat chamfer perpendicular to the apex direction.
|
||||
* A square corner of equal depths peaks at ~1.41·h, so 1.5 keeps every
|
||||
* right and obtuse corner byte-identical and only trims acute spikes.
|
||||
* MITRE_LIMIT above stays as the sanity bound for candidate construction.
|
||||
*/
|
||||
export const VISUAL_MITRE_LIMIT = 1.5;
|
||||
|
||||
/**
|
||||
* Flat chamfer of an over-long mitre apex (#309). Returns the clipped
|
||||
* polygon [node, pA, cA, cB, pB] where cA/cB sit on the fan edges at the
|
||||
* visual limit along the apex direction, or null when the apex is within
|
||||
* the limit (keep the mitre) or the cut degenerates (fall back to the
|
||||
* caller's bevel/chord).
|
||||
*/
|
||||
function chamferApex(
|
||||
node: number[], pA: number[], apex: number[], pB: number[], limit: number,
|
||||
): number[][] | null {
|
||||
const ux = apex[0] - node[0], uy = apex[1] - node[1];
|
||||
const d = Math.hypot(ux, uy);
|
||||
if (!(d > 0) || d <= limit + 1e-9) return null;
|
||||
const nx = ux / d, ny = uy / d;
|
||||
const cut = (from: number[]): number[] | null => {
|
||||
const f = (from[0] - node[0]) * nx + (from[1] - node[1]) * ny;
|
||||
const t = (limit - f) / (d - f);
|
||||
if (!Number.isFinite(t) || t < -1e-9 || t > 1 + 1e-9) return null;
|
||||
return [from[0] + (apex[0] - from[0]) * t, from[1] + (apex[1] - from[1]) * t];
|
||||
};
|
||||
const cA = cut(pA), cB = cut(pB);
|
||||
if (!cA || !cB) return null;
|
||||
return [[node[0], node[1]], pA, cA, cB, pB];
|
||||
}
|
||||
|
||||
/** Multi-ray joins stay inside this × the largest incident half-depth (#249). */
|
||||
export const MULTI_WALL_JOIN_LIMIT = 1.25;
|
||||
|
||||
@@ -1099,7 +1134,22 @@ export function linearWallJoinPatches(
|
||||
}
|
||||
|
||||
const patches: number[][][] = [];
|
||||
// #309: a node of three or more canonical rays is closed with the sector
|
||||
// fans of the multi-wall junction machinery (visual mitre limit included)
|
||||
// instead of pair patches. A pair patch lives in the sector OPPOSITE its
|
||||
// pair and, at such a node, paints a step over the thinner strips that own
|
||||
// that sector (owner report: the 15/15/30/30 cross).
|
||||
const intervals: WallInterval[] = segments.map((segment, i) => ({
|
||||
roomId: '', a: [segment.a[0], segment.a[1]], b: [segment.b[0], segment.b[1]],
|
||||
key: `join-${i}`, kind: 'outer', cm: 0, open: false,
|
||||
half: segment.halfDepth,
|
||||
}));
|
||||
const multiWallNodes = buildMultiWallNodeMap(intervals, eps);
|
||||
for (const fan of junctionNodeGeometry(multiWallNodes).fans) patches.push(fan);
|
||||
const coveredByFans = (point: number[]): boolean =>
|
||||
!!multiWallNodeAt(multiWallNodes, point);
|
||||
for (const node of nodes) {
|
||||
if (coveredByFans(node)) continue;
|
||||
const rays: JunctionRay[] = [];
|
||||
for (const segment of segments) {
|
||||
if (closePoint(node, segment.a, eps)) {
|
||||
@@ -1141,10 +1191,10 @@ export function linearWallJoinPatches(
|
||||
node[1] - nB[1] * b.halfDepth * sign,
|
||||
];
|
||||
const hit = lineIntersect(pA, a.u, pB, b.u);
|
||||
const limit = MITRE_LIMIT * Math.max(a.halfDepth, b.halfDepth);
|
||||
const patch = hit && Math.hypot(hit[0] - node[0], hit[1] - node[1]) <= limit
|
||||
const visual = VISUAL_MITRE_LIMIT * Math.max(a.halfDepth, b.halfDepth);
|
||||
const patch = hit && Math.hypot(hit[0] - node[0], hit[1] - node[1]) <= visual
|
||||
? [node.slice(), pA, hit, pB]
|
||||
: [node.slice(), pA, pB];
|
||||
: (hit && chamferApex(node, pA, hit, pB, visual)) || [node.slice(), pA, pB];
|
||||
if (Math.abs(signedArea(patch)) > eps * eps) patches.push(patch);
|
||||
}
|
||||
}
|
||||
@@ -2116,7 +2166,12 @@ export function junctionNodeGeometry(
|
||||
if (Math.abs(signedArea(poly)) > areaEps) out.fans.push(poly);
|
||||
};
|
||||
if (mitre) {
|
||||
push([[P[0], P[1]], EA, mitre, EB]);
|
||||
// #309: the accepted apex may still be visually too long (the classic
|
||||
// bound admits 4·h). Past the visual limit the fan is closed with a
|
||||
// flat chamfer perpendicular to the apex direction.
|
||||
const visual = VISUAL_MITRE_LIMIT * Math.max(A.halfDepth, B.halfDepth);
|
||||
push(chamferApex([P[0], P[1]], EA, mitre, EB, visual)
|
||||
?? [[P[0], P[1]], EA, mitre, EB]);
|
||||
continue;
|
||||
}
|
||||
if (reflex) {
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"partitions": [
|
||||
{
|
||||
"id": "partition-mt8lgb4w-0",
|
||||
"a": [
|
||||
0.1625,
|
||||
1.2333333333333334
|
||||
],
|
||||
"b": [
|
||||
0.8083333333333333,
|
||||
1.2333333333333334
|
||||
],
|
||||
"cm": 15
|
||||
},
|
||||
{
|
||||
"id": "partition-mt8lgb4w-1",
|
||||
"a": [
|
||||
0.8083333333333333,
|
||||
1.2333333333333334
|
||||
],
|
||||
"b": [
|
||||
0.8083333333333333,
|
||||
1.6291666666666667
|
||||
],
|
||||
"cm": 15
|
||||
},
|
||||
{
|
||||
"id": "partition-mt8lgho0-0",
|
||||
"a": [
|
||||
0.8083333333333333,
|
||||
1.2333333333333334
|
||||
],
|
||||
"b": [
|
||||
1.3666666666666667,
|
||||
1.2333333333333334
|
||||
],
|
||||
"cm": 30
|
||||
},
|
||||
{
|
||||
"id": "partition-mt8lglu1-0",
|
||||
"a": [
|
||||
0.8083333333333333,
|
||||
1.2333333333333334
|
||||
],
|
||||
"b": [
|
||||
0.8083333333333333,
|
||||
0.7291666666666666
|
||||
],
|
||||
"cm": 30
|
||||
},
|
||||
{
|
||||
"id": "partition-mt8liuxi-0",
|
||||
"a": [
|
||||
1.9,
|
||||
1.0291666666666666
|
||||
],
|
||||
"b": [
|
||||
2.220833333333333,
|
||||
1.35
|
||||
],
|
||||
"cm": 10
|
||||
},
|
||||
{
|
||||
"id": "partition-mt8liuxi-1",
|
||||
"a": [
|
||||
2.220833333333333,
|
||||
1.35
|
||||
],
|
||||
"b": [
|
||||
2.220833333333333,
|
||||
0.8708333333333333
|
||||
],
|
||||
"cm": 20
|
||||
},
|
||||
{
|
||||
"id": "partition-mt8ljoqh-0",
|
||||
"a": [
|
||||
1.1291666666666667,
|
||||
2.066666666666667
|
||||
],
|
||||
"b": [
|
||||
1.7583333333333333,
|
||||
1.6125
|
||||
],
|
||||
"cm": 50
|
||||
},
|
||||
{
|
||||
"id": "partition-mt8ljoqh-1",
|
||||
"a": [
|
||||
1.7583333333333333,
|
||||
1.6125
|
||||
],
|
||||
"b": [
|
||||
3.0625,
|
||||
1.7
|
||||
],
|
||||
"cm": 50
|
||||
},
|
||||
{
|
||||
"id": "partition-mt8lju31-0",
|
||||
"a": [
|
||||
1.7583333333333333,
|
||||
1.6125
|
||||
],
|
||||
"b": [
|
||||
2.0833333333333335,
|
||||
2.3291666666666666
|
||||
],
|
||||
"cm": 50
|
||||
}
|
||||
],
|
||||
"cell_cm": 1
|
||||
}
|
||||
+139
-11
@@ -13,7 +13,7 @@ import {
|
||||
wallBodyRings, wallBodiesGeometry, wallBodiesUnionPath, floorFootprintGeometry,
|
||||
virtualJunctionPatches, stableJunctionPatch, unionJunctionPatches,
|
||||
innerContourForRoom, innerEdgeSpan, ownEdgeOffsets,
|
||||
paperRoomShapesWithWalls, WALL_MIN_CM, WALL_MAX_CM, MITRE_LIMIT,
|
||||
paperRoomShapesWithWalls, WALL_MIN_CM, WALL_MAX_CM, MITRE_LIMIT, VISUAL_MITRE_LIMIT,
|
||||
MULTI_WALL_JOIN_LIMIT, buildMultiWallNodeMap, multiWallBevelTriangles,
|
||||
MULTI_WALL_NEAR_ORTHOGONAL_MAX_DEGREES,
|
||||
MULTI_WALL_ORTHOGONAL_DOT_EPSILON, multiWallProtectedRayIndexes,
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
import { polygonArea, paperRoomShapes, splitRoomPath, sharedBoundary } from '../test-build/logic.js';
|
||||
import { resolveOpenCuts } from '../test-build/open-spans.js';
|
||||
import { GRID_PITCH, NORM_W } from '../test-build/space-geometry.js';
|
||||
import { geometryArea } from '../test-build/physical-geometry.js';
|
||||
import { geometryArea, physicalBodySet } from '../test-build/physical-geometry.js';
|
||||
import { checkMixedRoleRecords, checkWallRecordsPreserved } from '../scripts/model-invariants.mjs';
|
||||
import { difference, intersection, union } from 'polyclip-ts';
|
||||
|
||||
@@ -2754,8 +2754,12 @@ test('linear wall joins bevel an excessive mitre and ignore malformed or near-mi
|
||||
{ a: [0, 0], b: [10, 0.1], halfDepth: 1 },
|
||||
], 1e-6);
|
||||
assert.equal(acute.length, 1);
|
||||
assert.equal(acute[0].length, 3, 'a mitre beyond the limit becomes a bevel triangle');
|
||||
assert.ok(acute[0].every((point) => Math.hypot(point[0], point[1]) <= MITRE_LIMIT));
|
||||
assert.equal(acute[0].length, 5, 'a mitre beyond the visual limit becomes a flat chamfer (#309)');
|
||||
// The chamfer bounds the APEX projection at 1.5·h; a chamfer corner can sit
|
||||
// at most hypot(limit, h) from the node (limit along the axis, h across).
|
||||
assert.ok(acute[0].every((point) =>
|
||||
Math.hypot(point[0], point[1]) <= Math.hypot(VISUAL_MITRE_LIMIT, 1) + 1e-6),
|
||||
'chamfer keeps every vertex near the node instead of the far apex');
|
||||
|
||||
const separate = linearWallJoinPatches([
|
||||
{ a: [-2, 0], b: [0, 0], halfDepth: 1 },
|
||||
@@ -3053,20 +3057,144 @@ test('issue 302 the owner repro is hole-free end to end', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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.
|
||||
test('issue 309 the 57° mixed-thickness pair chamfers past the visual limit', () => {
|
||||
// The #302 owner's repro corner: the raw mitre lands at ~8.7 units — beyond
|
||||
// the visual limit 1.5·4.861 ≈ 7.29 (#309), so the sector closes with a
|
||||
// five-point flat chamfer. It must still reach past the retired 1.25×h join
|
||||
// limit (6.1): the chamfer is a trimmed mitre, not the #249 bevel.
|
||||
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 limit = VISUAL_MITRE_LIMIT * 4.861;
|
||||
const sectorFan = fans.find((poly) => poly.length === 5 && 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');
|
||||
assert.ok(sectorFan, 'the 57° sector lost its chamfered mitre — #249 bevel or raw apex is back');
|
||||
for (const poly of fans) {
|
||||
for (const point of poly) {
|
||||
assert.ok(Math.hypot(point[0] - 500, point[1] - 500) <= limit + map.epsilon + 0.1,
|
||||
`vertex ${point} escapes the visual limit`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// --- issue #309: visual mitre limit and node teeth ---------------------------
|
||||
|
||||
const teethFixture = () => JSON.parse(readFileSync(
|
||||
new URL('./fixtures/309-junction-teeth.json', import.meta.url), 'utf8',
|
||||
));
|
||||
const teethSegments = () => teethFixture().partitions.map((p) => ({
|
||||
a: [p.a[0] * NORM_W, p.a[1] * NORM_W],
|
||||
b: [p.b[0] * NORM_W, p.b[1] * NORM_W],
|
||||
halfDepth: wallCmToUnits(p.cm, 1, GRID_PITCH) / 2,
|
||||
}));
|
||||
const pointInPoly = (point, body) => {
|
||||
let inside = false;
|
||||
for (let i = 0, j = body.length - 1; i < body.length; j = i++) {
|
||||
const a = body[i], b = body[j];
|
||||
if (((a[1] > point[1]) !== (b[1] > point[1]))
|
||||
&& point[0] < ((b[0] - a[0]) * (point[1] - a[1]))
|
||||
/ ((b[1] - a[1]) || 1e-12) + a[0]) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
|
||||
test('issue 309 the acute 10/20 pair keeps its tail within the visual limit', () => {
|
||||
const node = [2220.833333333333, 1350];
|
||||
const segments = teethSegments().filter((segment) =>
|
||||
[segment.a, segment.b].some((end) =>
|
||||
Math.hypot(end[0] - node[0], end[1] - node[1]) < 0.5));
|
||||
assert.equal(segments.length, 2);
|
||||
const hMax = Math.max(...segments.map((segment) => segment.halfDepth));
|
||||
const patches = linearWallJoinPatches(segments, 0.2);
|
||||
assert.ok(patches.length >= 1, 'the acute pair still closes its outer sector');
|
||||
for (const patch of patches) {
|
||||
assert.equal(patch.length, 5, 'the over-long apex is a flat chamfer, not a spike');
|
||||
for (const point of patch) {
|
||||
assert.ok(Math.hypot(point[0] - node[0], point[1] - node[1])
|
||||
<= Math.hypot(VISUAL_MITRE_LIMIT * hMax, hMax) + 0.5,
|
||||
`spike vertex ${point} survived past the visual limit`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('issue 309 the 3×50 node fans stay within the visual limit', () => {
|
||||
const node = [1758.3333333333333, 1612.5];
|
||||
const segments = teethSegments();
|
||||
const intervals = segments.map((segment, index) => ({
|
||||
roomId: '', a: segment.a, b: segment.b, key: `iv-${index}`,
|
||||
kind: 'outer', cm: 0, open: false, half: segment.halfDepth,
|
||||
}));
|
||||
const map = buildMultiWallNodeMap(intervals, 0.2);
|
||||
const target = map.nodes.find((candidate) =>
|
||||
Math.hypot(candidate.point[0] - node[0], candidate.point[1] - node[1]) < 0.5);
|
||||
assert.ok(target, 'the 3×50 node is a multi-wall node');
|
||||
const h = Math.max(...target.rays.map((ray) => ray.halfDepth));
|
||||
const { fans } = junctionNodeGeometry(map);
|
||||
let sawChamfer = false;
|
||||
for (const fan of fans) {
|
||||
if (!fan.some((p) => Math.hypot(p[0] - node[0], p[1] - node[1]) < 0.5)) continue;
|
||||
if (fan.length === 5) sawChamfer = true;
|
||||
for (const point of fan) {
|
||||
const along = Math.hypot(point[0] - node[0], point[1] - node[1]);
|
||||
assert.ok(along <= Math.hypot(VISUAL_MITRE_LIMIT * h, h) + 0.5,
|
||||
`hump vertex ${point} escapes the node envelope`);
|
||||
}
|
||||
}
|
||||
assert.ok(sawChamfer, 'the 1.95·h apex must be chamfered into a five-point fan');
|
||||
});
|
||||
|
||||
test('issue 309 the mixed-thickness cross has no step in a foreign quadrant', () => {
|
||||
const node = [808.3333333333334, 1233.3333333333333];
|
||||
const segments = teethSegments();
|
||||
const patches = linearWallJoinPatches(segments, 0.2);
|
||||
// The parasite pair patch used to cover this probe: thick-half deep into the
|
||||
// quadrant owned by the two thin rays (owner report, the 15/15/30/30 cross).
|
||||
const probe = [node[0] - 47, node[1] + 47];
|
||||
assert.equal(patches.some((patch) => pointInPoly(probe, patch)), false,
|
||||
'a pair patch across a foreign sector paints the step again');
|
||||
// The thin-owned corner itself stays closed by its sector fan.
|
||||
const corner = [node[0] - 25, node[1] + 25];
|
||||
assert.equal(patches.some((patch) => pointInPoly(corner, patch)), true,
|
||||
'the thin corner lost its fan coverage');
|
||||
});
|
||||
|
||||
test('issue 309 a square corner of equal depths keeps its byte-identical mitre', () => {
|
||||
const patches = linearWallJoinPatches([
|
||||
{ a: [0, 0], b: [100, 0], halfDepth: 10 },
|
||||
{ a: [0, 0], b: [0, 100], halfDepth: 10 },
|
||||
], 1e-6);
|
||||
assert.equal(patches.length, 1);
|
||||
assert.equal(patches[0].length, 4, 'a 1.41·h apex stays a full mitre');
|
||||
const apex = patches[0][2];
|
||||
assert.ok(Math.abs(Math.hypot(apex[0], apex[1]) - 10 * Math.SQRT2) < 1e-9,
|
||||
'the square mitre apex is exactly √2·h from the node');
|
||||
});
|
||||
|
||||
test('issue 309 the full teeth fixture leaves no junction holes', () => {
|
||||
const segments = teethSegments();
|
||||
const intervals = segments.map((segment, index) => ({
|
||||
roomId: '', a: segment.a, b: segment.b, key: `iv-${index}`,
|
||||
kind: 'outer', cm: 0, open: false, half: segment.halfDepth,
|
||||
}));
|
||||
const map = buildMultiWallNodeMap(intervals, 0.2);
|
||||
assert.ok(map.nodes.length >= 2, 'fixture keeps its multi-wall nodes');
|
||||
const space = {
|
||||
partitions: teethFixture().partitions.map((partition) => ({
|
||||
...partition,
|
||||
a: [partition.a[0] * NORM_W, partition.a[1] * NORM_W],
|
||||
b: [partition.b[0] * NORM_W, partition.b[1] * NORM_W],
|
||||
})),
|
||||
room_drafts: [], wall_columns: [],
|
||||
};
|
||||
const frame = physicalBodySet(space, 1, GRID_PITCH, 0.2);
|
||||
const reports = junctionContractHoles(frame.geometry, map, { step: 2 });
|
||||
assert.deepEqual(
|
||||
reports.map((report) => ({ node: report.node, holes: report.holes.length })), [],
|
||||
'the teeth fixture has junction holes',
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user