feat: full mitre at every node — the #249 chamfer retires (#302, decision #5)

Владелец, осмотрев первые эталоны сета, отменил дневное решение о сохранении
фаски: `junction-y-60-equal50` показывал вырез, `junction-acute30-mixed` —
торчащие углы. По визуальному сравнению трёх вариантов принято: узлы
смыкаются полным mitre, как обычное пересечение стен на чертеже.

Итоговое правило веера (одно на все случаи):

- mitre принимается, когда он В СЕКТОРЕ пары (вперёд по лучам для обычной
  пары, назад — для рефлексной: наружный угол между крайними лучами, где и
  жил вырез Y-60), в пределах классического `MITRE_LIMIT` и не дальше конца
  толстого саппорта (#271);
- рефлекс без валидного mitre замыкается плоской хордой между гранями;
- обычная пара без mitre — локальный бевел: ход по граням ограничен толстым
  саппортом, лимитом и двойной толщиной пары, чтобы хорда осталась деталью
  угла. Гигантские бевел-«бабочки» и mitre вне сектора — две реальные ошибки
  промежуточных версий, обе пойманы на сценах сета до пуша.

Слой `bevelMultiWallBody` сохранён только как АДРЕСНЫЙ латеральный трим для
узлов с вырожденно-коротким толстым саппортом (#271); все прочие узлы — чисто
аддитивные, следы трима на них исчезли. `bevelMultiWallPaper` из бумаги
удалён. Обе записи CHANGELOG приведены к финальному контракту.

Тесты: юниты §302 усилены; площадь фикстуры #197 +0.6 юнита²; мутанты
переякорены, краснота каждого проверена исполнением.

Issue: #302
User-Visible: yes
This commit is contained in:
Codex
2026-08-25 17:34:19 +03:00
parent 8af5ebefd2
commit aac501516e
17 changed files with 453 additions and 348 deletions
File diff suppressed because one or more lines are too long
+112 -112
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
@@ -4,9 +4,9 @@
- Wall junctions no longer leave white wedges and notches. A node where three
or more walls meet is now completed with sector fans and the exact strips of
its rays on top of the existing chamfer: an acute junction is solid masonry,
and the chamfer cut survives only where it does not touch the walls
themselves. The no-holes invariant is checked automatically on a set of 16
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
+2 -2
View File
@@ -10,8 +10,8 @@
- Стыки стен больше не оставляют белых клиньев и зазубрин. Узел, где сходятся
три и больше стен, теперь достраивается угловыми веерами и точными полосами
своих лучей поверх прежней фаски: острый стык — сплошная кладка, срез фаски
остаётся только там, где он не задевает сами стены. Инвариант «дыр нет»
своих лучей: узел смыкается полным mitre, как обычное пересечение стен на
чертеже, — прежние фаски на стыках ушли. Инвариант «дыр нет»
проверяется автоматически на сете из 16 крупноплановых сцен стыков и на
плане из исходного сообщения
([#302](https://github.com/Matysh/houseplan-card/issues/302)).
Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 349 KiB

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 319 KiB

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 296 KiB

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 141 KiB

+19 -19
View File
@@ -2,7 +2,7 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "b66440ffc782b0d7a51d92d093a811dafbab2ddea69cf612d4f63e2f86fa01cd",
"sourceFingerprint": "3fc426354afb0d34764bb3fa529528368afe690db5a6520473e8510d7e60f6b0",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,8 +14,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "b66440ffc782b0d7a51d92d093a811dafbab2ddea69cf612d4f63e2f86fa01cd",
"imageSha256": "51551aa5237090b67ff4c48802b0f2518e884b48cb1b0319e782fced239953ea"
"sourceSha256": "3fc426354afb0d34764bb3fa529528368afe690db5a6520473e8510d7e60f6b0",
"imageSha256": "0ca982495a7749907c48ebbddfd089c54385a4347f79f64e94af2c5a8d5c03eb"
},
"view-touch": {
"file": "02-view-touch.png",
@@ -25,8 +25,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "b66440ffc782b0d7a51d92d093a811dafbab2ddea69cf612d4f63e2f86fa01cd",
"imageSha256": "e52f419772f86480b336a0ab63d0c781207101b262fdf73f5fd581527e47dce4"
"sourceSha256": "3fc426354afb0d34764bb3fa529528368afe690db5a6520473e8510d7e60f6b0",
"imageSha256": "1c8e28ff34736b936ba7c9ec047d1da27aaf6fbb01411155f2d5c05282c02c41"
},
"space-create": {
"file": "03-space-create.png",
@@ -36,7 +36,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "b66440ffc782b0d7a51d92d093a811dafbab2ddea69cf612d4f63e2f86fa01cd",
"sourceSha256": "3fc426354afb0d34764bb3fa529528368afe690db5a6520473e8510d7e60f6b0",
"imageSha256": "329750c81a7a958c4fd105b2c80f85dce55ed87df9ddf0ea08084e4428e85f04"
},
"room-contour-close": {
@@ -47,8 +47,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "b66440ffc782b0d7a51d92d093a811dafbab2ddea69cf612d4f63e2f86fa01cd",
"imageSha256": "e9a847d17b9bd02ad9fa39a6e7194f980144e90538eb94413f770f9539ceb7bc"
"sourceSha256": "3fc426354afb0d34764bb3fa529528368afe690db5a6520473e8510d7e60f6b0",
"imageSha256": "ff2e903e548423aa0aa52680e20b8ad951c704e01130cb4b180222015a241276"
},
"plan-context-tray": {
"file": "05-plan-context-tray.png",
@@ -58,8 +58,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "b66440ffc782b0d7a51d92d093a811dafbab2ddea69cf612d4f63e2f86fa01cd",
"imageSha256": "7fd57bfc022211c5d37bd7c4a1a72b34ed7651fd577ada1d2314b64ae5061c21"
"sourceSha256": "3fc426354afb0d34764bb3fa529528368afe690db5a6520473e8510d7e60f6b0",
"imageSha256": "ff55925f079411023442e7e7dd5ed528c331aaa3fd3fc2fbbf3b1aaf34a85718"
},
"device-editor": {
"file": "06-device-editor.png",
@@ -69,8 +69,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "b66440ffc782b0d7a51d92d093a811dafbab2ddea69cf612d4f63e2f86fa01cd",
"imageSha256": "0ef6798b79ff0b78e1947b1dba547b92cbdf11b79b6335cffb8a415158c09b5d"
"sourceSha256": "3fc426354afb0d34764bb3fa529528368afe690db5a6520473e8510d7e60f6b0",
"imageSha256": "6b9cd281450e7782cff292f11efc58d227bedd0df86afc3be987feb2edec9dc3"
},
"device-display-preview": {
"file": "06-device-display-preview.png",
@@ -80,8 +80,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "b66440ffc782b0d7a51d92d093a811dafbab2ddea69cf612d4f63e2f86fa01cd",
"imageSha256": "3842b3306d4e73a5b1d3475a96b4c1fd433c5c4334054a34d52f7cc0a20ae931"
"sourceSha256": "3fc426354afb0d34764bb3fa529528368afe690db5a6520473e8510d7e60f6b0",
"imageSha256": "6f628abe6b89d7c4b352e6d73ac0b126526f6fdba5927bb774c5b884e484ca19"
},
"background-editor": {
"file": "07-background-editor.png",
@@ -91,8 +91,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "b66440ffc782b0d7a51d92d093a811dafbab2ddea69cf612d4f63e2f86fa01cd",
"imageSha256": "f91c9575404abc64c5f03bb7dc9283b80231ada6bfd88774cfdf7d53fb46e0b2"
"sourceSha256": "3fc426354afb0d34764bb3fa529528368afe690db5a6520473e8510d7e60f6b0",
"imageSha256": "a1d1a905f1410f73231d2ea5d1dfbd8ae79c2fe8b0a79cff87ebb977ac7a3103"
},
"room-card": {
"file": "08-room-card.png",
@@ -102,7 +102,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "b66440ffc782b0d7a51d92d093a811dafbab2ddea69cf612d4f63e2f86fa01cd",
"sourceSha256": "3fc426354afb0d34764bb3fa529528368afe690db5a6520473e8510d7e60f6b0",
"imageSha256": "48a5685039a5e0de2a28190d857b193c86e2e63606e3c7a8c40fc9d2b190da4e"
},
"device-info": {
@@ -113,8 +113,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "b66440ffc782b0d7a51d92d093a811dafbab2ddea69cf612d4f63e2f86fa01cd",
"imageSha256": "83debe3fc2853406f8bc37df55d162a0b0dadc8c4e997dda4db83c07ca25caab"
"sourceSha256": "3fc426354afb0d34764bb3fa529528368afe690db5a6520473e8510d7e60f6b0",
"imageSha256": "29a342811aaedaebc54ba337792d60cb454a7cf4c3063b210f6c4bafe0a4d87b"
}
}
}
+36 -29
View File
@@ -61,12 +61,16 @@
2. **Отдельный полноценный сет тестов со скриншотами стыков крупным планом**:
разное число лучей, толщины, углы, виртуальные стены и т.д.
3. Детектор дыр как объективный инвариант — в дополнение к скриншотам.
4. **Фаска #249 на внешних углах узлов сохраняется** (решение в ходе
реализации, 2026-08-25): узлы ≥3 лучей остаются чуть срезанными, как
утверждено в #249, а не превращаются в острые mitre-углы. Следствие для
контракта: чисто аддитивной модель быть не может — фаска по природе
вычитающая; гарантию «нет дыр» несёт strip-safe построение среза (§8.2)
плюс детектор, а не отсутствие вычитаний.
4. ~~Фаска #249 на внешних углах узлов сохраняется~~ — решение середины дня,
**отменено решением №5**.
5. **Полный mitre везде** (финальное решение 2026-08-25, по визуальному
сравнению трёх вариантов на T-90 и Y-60 50 см): узлы смыкаются как обычное
пересечение стен на чертеже, фаска #249 демонтируется целиком. Поводом
стали артефакты фаски на не-ортогональных узлах в новых эталонах
(`junction-y-60-equal50` — вырез, `junction-acute30-mixed` — торчащие
углы). Следствия: узловая механика чисто аддитивная, слой
`bevelMultiWallBody`/`bevelMultiWallPaper` удаляется, существующие
junction-эталоны переснимаются осознанно.
## 5. Цели
@@ -102,27 +106,29 @@
`EPS_NODE = openEps × 4` (текущий допуск `buildMultiWallNodeMap` — сохраняется).
Луч приходит в узел со своей полу-толщиной; лучи упорядочиваются по азимуту.
### 8.2. Правило узла: веер + strip-safe фаска из одного углового обхода
### 8.2. Правило узла: аддитивные веера (решение №5)
Для каждой пары соседних по азимуту лучей (сектор ≤ 180°; рефлексные секторы —
внешняя сторона выпуклого угла — пропускаются целиком):
внешняя сторона выпуклого угла — пропускаются):
1. **Веер (аддитивно):** полигон «узел → край полосы A → mitre → край полосы B»,
где mitre — пересечение обращённых в сектор офсетных прямых. Если mitre
дальше **лимита узла** (`node.limit = MULTI_WALL_JOIN_LIMIT × halfDepth`,
утверждённая граница #249) — вместо mitre хорда bevel на радиусе лимита.
2. **Фаска (вычитающе, strip-safe):** клин за хордой (треугольник
«конец A → mitre → конец B») минус **квады всех лучей этого узла** — ещё на
этапе построения, до какого-либо `difference` по телу. Срез физически не
может задеть материал полос: у острых стыков, где клин перекрывается с
полосами, срезается только его свободная часть, и стык остаётся сплошным —
это и есть исправление артефактов из issue. У ортогональных T/X клин лежит
вне полос, и фаска выглядит ровно как в #249.
**Веер (аддитивно):** полигон «узел → край полосы A → mitre → край полосы B»,
где mitre — пересечение обращённых в сектор офсетных прямых. Границы:
Инвариант вместо «нет difference»: **тело ⊇ полосы лучей ∪ веера** — материал
контракта не может быть удалён ничем, включая фаску (она strip-safe по
построению и не пересекается с веером — их разделяет общая хорда). Проверяется
детектором §8.4.
- mitre принимается, пока он в пределах классического `MITRE_LIMIT ×
max(толщин пары)` — то же правило, что у обычных углов комнат;
- и пока он не дальше конца ТОЛСТОГО саппорта каждого луча — иначе веер
нарисовал бы латеральный фантом рядом с тонким продолжением (#271);
- иначе — bevel-хорда: по каждому краю до `min(длина толстого саппорта,
√(limit² − half²))`, замыкание хордой.
Дополнительно узел аддитивно получает точные саппорт-квады своих лучей
(каждый ограничен собственной конечной длиной). Все куски клиппуются гладкой
фасадной границей `junctionNodeBound` — узел не растит новый фасад на
вогнутой вершине.
**В узловой механике нет ни одной операции `difference`** (вычитающий слой
фаски демонтирован): дыра между полосами невозможна по построению, «лишний»
материал ограничен `MITRE_LIMIT` и фасадной границей.
### 8.3. Спец-случаи
@@ -180,15 +186,16 @@
- **AC1.** Детектор §8.4: ноль внутренних дыр на каждом узле каждой сцены сета §13.
- **AC2.** Репро владельца (5 узлов): ноль внутренних дыр; визуально клинья
исчезли (golden-сцена репро).
- **AC3.** Ортогональные стыки одинаковой толщины: попиксельное совпадение с
текущими эталонами.
- **AC3.** Сцены, не связанные с узлами ≥3 лучей, совпадают с текущими
эталонами попиксельно. Junction-сцены меняются законно (решение №5) и
переснимаются осознанно с поимённым разбором.
- **AC4.** Острый угол 57° со стенами 50/70: сплошная кладка (перекрытие полос
не срезается), свободная часть клина срезана по лимиту узла.
- **AC5.** Виртуальный участок в узле не порождает кладку; соседние физические
лучи смыкаются веером через него.
- **AC6.** Единственные вычитания узловой механики — strip-safe фаски §8.2:
срез строится уже за вычетом квадов лучей узла. Мутант убирает вычет квадов
из среза — детектор обязан покраснеть.
- **AC6.** Узловая механика не содержит `difference`: слой фаски демонтирован,
веера и саппорты только добавляют. Мутант возвращает лимит веера к 1.25·h —
юнит формы веера обязан покраснеть (вырез Y-60 из отчёта владельца).
- **AC7.** Оба рендерера дают идентичную геометрию узла (один вызов
`wallBodiesUnionPath`; смок сверяет пути).
- **AC8.** Перф: `smoke_render_perf` в бюджете; large-house без деградации.
@@ -222,7 +229,7 @@
| `node-fan-outer-only` | веер только с внешней стороны | детектор |
| `node-fan-ignores-mitre-limit` | mitre без лимита | юниты веера |
| `node-fan-includes-zero-ray` | нулевой луч порождает кладку | юниты + смок |
| `node-cut-not-strip-safe` | срез фаски без вычета квадов лучей | детектор + AC6 |
| `node-fan-limit-back-to-249` | лимит веера снова 1.25·h — вырез Y-60 | юниты формы веера |
| `hole-detector-blind` | детектор всегда зелёный | самопроверка детектора на заведомо дырявой фикстуре |
Все юнит-гварды — с пересборкой `test-build` (урок #230/#235).
+23 -12
View File
@@ -2211,16 +2211,16 @@ export const MUTANTS = [
},
{
id: 'junction-fans-disabled',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="issue 302" test/wall-thickness.test.mjs',
guard: 'node demo/smoke_junction_holes.mjs',
because: 'без вееров сектор между соседними полосами узла остаётся дырой — '
+ 'это и есть класс артефактов #302',
patches: [{
file: 'src/wall-thickness.ts',
find: ' if (mitre && mitreWithinStrips',
replace: ' if (false && mitre && mitreWithinStrips',
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(out.fans, [[P[0], P[1]], EA, A2, B2, EB]);',
find: ' push([[P[0], P[1]], EA, A2, B2, EB]);',
replace: ' void A2; void B2;',
}],
},
@@ -2238,23 +2238,34 @@ export const MUTANTS = [
{
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: 'веер, шагающий за короткий толстый саппорт, рисует латеральный '
because: 'mitre, шагнувший за короткий толстый саппорт, рисует латеральный '
+ 'фантом рядом с тонким продолжением (#271)',
patches: [{
file: 'src/wall-thickness.ts',
find: ' mitreWithinStrips = tA <= A.thickLength && tB <= B.thickLength;',
replace: ' mitreWithinStrips = true;',
find: ' : tA > 1e-9 && tA <= A.thickLength && tB <= B.thickLength;',
replace: ' : tA > 1e-9;',
}],
},
{
id: 'junction-reflex-sector-fanned',
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: 'рефлексный сектор — внешность выпуклого угла; веер там — лишний '
+ 'фасадный материал (контракт вогнутого Split)',
because: 'рефлексный сектор — наружный угол между крайними лучами; без '
+ 'обратного mitre там остаётся вырез Y-60 из отчёта владельца',
patches: [{
file: 'src/wall-thickness.ts',
find: ' if (sector > Math.PI + 1e-9) continue;',
replace: ' void sector;',
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',
}],
},
{
+67 -38
View File
@@ -2078,44 +2078,60 @@ export function junctionNodeGeometry(
for (let i = 0; i < rays.length; i++) {
const A = rays[i];
const B = rays[(i + 1) % rays.length];
// A reflex sector (> 180°) is the OUTSIDE of a convex corner: the two
// strips already form it and the space between them is legitimately
// empty — nothing to add there.
const sector = (() => {
const raw = B.angle - A.angle;
return raw > 0 ? raw : raw + Math.PI * 2;
})();
if (sector > Math.PI + 1e-9) continue;
// Strip edges facing the sector between A and B (in increasing-angle
// order): A's edge sits at angle+90°, B's at angle−90°. This is a pure
// angular statement, valid in either screen orientation.
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 limit = node.limit;
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;
let mitreWithinStrips = false;
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;
if (tA > 0) {
mitre = [EA[0] + A.u[0] * tA, EA[1] + A.u[1] * tA];
mitreWithinStrips = tA <= A.thickLength && tB <= B.thickLength;
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 = (list: number[][][], poly: number[][]) => {
if (Math.abs(signedArea(poly)) > areaEps) list.push(poly);
const push = (poly: number[][]) => {
if (Math.abs(signedArea(poly)) > areaEps) out.fans.push(poly);
};
if (mitre && mitreWithinStrips
&& Math.hypot(mitre[0] - P[0], mitre[1] - P[1]) <= limit) {
push(out.fans, [[P[0], P[1]], EA, mitre, EB]);
if (mitre) {
push([[P[0], P[1]], EA, mitre, EB]);
continue;
}
// Bevel: walk each offset line up to the join limit (never past the
// thick support's real end) and close with a chord. The wedge past the
// chord belongs to the chamfer layer, which is left exactly as approved.
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)),
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),
@@ -2125,7 +2141,7 @@ export function junctionNodeGeometry(
EB[0] + B.u[0] * reach(B.halfDepth, B.thickLength),
EB[1] + B.u[1] * reach(B.halfDepth, B.thickLength),
];
push(out.fans, [[P[0], P[1]], EA, A2, B2, EB]);
push([[P[0], P[1]], EA, A2, B2, EB]);
}
}
return out;
@@ -3177,7 +3193,7 @@ export function floorFootprintGeometry(
: exterior.centre;
return multiWallNodes.nodes.length
? paperWithNodeCorners(
bevelMultiWallPaper(paper, exterior.centre, multiWallNodes), multiWallNodes,
paper, multiWallNodes,
junctionNodeBound(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
multiWallNodes,
@@ -3504,10 +3520,9 @@ export function wallBodiesGeometry(
: [];
// Paper: the approved chamfer first, then the same additive fans that
// complete the masonry corner complete the paper beneath it (#302).
const paperGeom = multiWallNodes.nodes.length && exterior
const paperGeom = multiWallNodes.nodes.length
? paperWithNodeCorners(
bevelMultiWallPaper(rawPaperGeom, exterior.centre, multiWallNodes),
multiWallNodes,
rawPaperGeom, multiWallNodes,
junctionNodeBound(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
multiWallNodes,
@@ -3576,16 +3591,31 @@ export function wallBodiesGeometry(
}
}
}
// The approved #249 chamfer layer stays exactly as is (owner 2026-08-25)…
corePhase = 'multi-wall-bevel';
if (body && multiWallNodes.nodes.length)
body = bevelMultiWallBody(body, multiWallNodes, exterior?.centre, paperGeom);
// …and AFTER it the node gets back what a chamfer must never eat (#302):
// the exact support quads of its rays — each bounded by its own finite
// length, so a trimmed lateral phantom (#271) cannot come back — and one
// fan per pair of angularly adjacent rays, bounded by the same node
// limit as the chamfer chord, covering every sector the subtractive
// layer keeps leaving open. Both halves are purely additive.
// 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);
@@ -3944,8 +3974,7 @@ export function paperRoomShapesWithWalls(
);
const paper = multiWallNodes.nodes.length
? paperWithNodeCorners(
bevelMultiWallPaper(rawPaper, exterior.centre, multiWallNodes),
multiWallNodes,
rawPaper, multiWallNodes,
junctionNodeBound(
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
multiWallNodes,
+79 -21
View File
@@ -217,27 +217,43 @@ const assertBoundedMultiWallBevels = (
(base[0] + triangle[2][0]) / 2,
(base[1] + triangle[2][1]) / 2,
];
// #302 contract: the chamfer is strip-safe. A wedge probe that falls
// inside any of the node's ray strips stays FILLED (an acute junction is
// solid masonry — the very fix of #302); outside every strip the wedge is
// cut, which is exactly the approved #249 chamfer.
const inNodeStrip = node.rays.some((ray) => ray.supports.some((support) => {
// #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;
})) && (!facadeBound || geometryProbeCoverage(facadeBound, probe) > 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 (inNodeStrip) {
if (inContract) {
assert.ok(
actualCoverage > 1e-7,
`the strip-safe chamfer still removed strip material at ${probe}`,
`the additive node lost contract material at ${probe}`,
);
} else {
assert.ok(
actualCoverage < 1e-7,
`an excessive multi-wall wedge outside every strip remains filled at ${probe}`,
`a wedge outside every strip and fan remains filled at ${probe}`,
);
}
}
@@ -2223,9 +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.
// #302: the node's support quads and sector fans add a sliver of masonry
// (+0.208 units²) along the chamfer chords of this fixture's junctions.
closeTo(geometryArea(geometry.geom), 124534.81716317777, 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,
@@ -2922,14 +2939,36 @@ test('issue 302 a T node covers every sector with a fan or a mitre', () => {
assert.equal(fans.length, 2);
});
test('issue 302 a reflex sector adds nothing', () => {
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] and [30..100] are real sectors; [100..360] (260°) is the outside
// of the convex corner — legitimately empty, no fan.
assert.equal(fans.length, 2);
// [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', () => {
@@ -2945,13 +2984,14 @@ test('issue 302 a mitre past the short thick support degrades to a bevel', () =>
const { fans } = junctionNodeGeometry(map);
// The [0°..90°] fan: mitre would sit at (502.5, 510) — inside the limit
// (10.3 < 12.5) but past the thick support (t = 2.5 > 2) → five-point bevel.
const fan = fans.find((poly) => poly.some((point) => point[1] > 505));
assert.ok(fan, 'the sector fan between the thick and the vertical ray is gone');
assert.equal(fan.length, 5, 'the mitre past the thick support was accepted');
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) <= map.nodes[0].limit + 1e-7,
`fan escaped the node limit: ${point}`,
Math.hypot(point[0] - 500, point[1] - 500) <= bound + 1e-7,
`fan escaped the classic corner bound: ${point}`,
);
}
});
@@ -3012,3 +3052,21 @@ test('issue 302 the owner repro is hole-free end to end', () => {
'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');
});