mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 06:59:46 +00:00
fix: restore the full pair apex and trim the poking butt end (#310)
Узел ровно двух лучей снова закрывается полным mitre — стены сходятся в точку, фаска #309 остаётся только веерам узлов ≥3 лучей. Настоящий зубец убран: pairButtEndTrimWedges возвращает адресный клин — часть тела стены снаружи наружной грани соседа и не дальше 2·halfDepth от узла — который physicalBodyParts и превью вычитают из тела до разрезов проёмов. Это второе адресное вычитание конвейера узлов рядом с латеральным тримом #271. Узлы-двойки невидимы детектору #302 (карта требует ≥3 лучей): контракт «без дыр» для них закрыт парным сеточным юнитом (кладка = полосы ∪ патч − клинья) на spike-узле фикстуры владельца и синтетике. 3 новых мутанта, краснота каждого проверена исполнением; парный юнит #309 переписан под полное остриё. Issue: #310 User-Visible: yes
This commit is contained in:
File diff suppressed because one or more lines are too long
Vendored
+112
-112
File diff suppressed because one or more lines are too long
@@ -2,6 +2,12 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Two walls meeting at an acute angle keep their full drawing point again —
|
||||
the #309 chamfer now applies only to nodes of three and more walls. The
|
||||
real tooth is gone instead: the deeper wall's butt end no longer pokes
|
||||
sideways past the face of its thinner partner
|
||||
([#310](https://github.com/Matysh/houseplan-card/issues/310)).
|
||||
|
||||
- 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
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Две стены, сходящиеся под острым углом, снова сходятся в полное остриё —
|
||||
фаска #309 действует только в узлах из трёх и более стен. Вместо этого
|
||||
убран настоящий зубец: торец толстой стены больше не торчит вбок из грани
|
||||
тонкой ([#310](https://github.com/Matysh/houseplan-card/issues/310)).
|
||||
|
||||
- Стыковочные узлы больше не выпускают зубцы: вылет mitre ограничен
|
||||
1.5 полутолщины, всё длиннее закрывается плоской чертёжной фаской — у
|
||||
острых пар исчезают хвосты, у толстых многолучевых узлов пики, а кресты
|
||||
|
||||
+16
-3
@@ -208,9 +208,8 @@ 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
|
||||
applies to the junction fans; `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
|
||||
@@ -218,6 +217,20 @@ 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.
|
||||
|
||||
**Pair apex and butt-end trim (#310, owner decision 2026-08-25).** A node of
|
||||
exactly two rays keeps the FULL mitre at any length: two walls meet in a
|
||||
drawing point, the #309 chamfer does not apply there. What does get removed is
|
||||
the butt-end tooth: the deeper wall's rectangular end may poke sideways past
|
||||
the outer face of its thinner partner right at the node —
|
||||
`pairButtEndTrimWedges` returns, per wall, the addressed wedge (outside the
|
||||
partner's apex-side outer face, within 2·halfDepth of the node along the
|
||||
axis) which consumers subtract from the owning body before opening cuts. This
|
||||
is the SECOND addressed subtraction of the junction pipeline, next to the
|
||||
lateral trim of #271; both are strictly local to their node. Two-ray nodes are
|
||||
invisible to `junctionContractHoles` (the node map requires 3+ canonical
|
||||
rays), so their no-holes contract is a grid probe in the unit suite: masonry
|
||||
inside the node neighbourhood equals (strips ∪ patch) − wedges.
|
||||
|
||||
**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": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
|
||||
"sourceFingerprint": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
|
||||
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
|
||||
"command": "npm run build && node demo/docs/capture.mjs",
|
||||
"scenarios": {
|
||||
@@ -14,7 +14,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
|
||||
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
|
||||
"imageSha256": "0ca982495a7749907c48ebbddfd089c54385a4347f79f64e94af2c5a8d5c03eb"
|
||||
},
|
||||
"view-touch": {
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
|
||||
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
|
||||
"imageSha256": "1c8e28ff34736b936ba7c9ec047d1da27aaf6fbb01411155f2d5c05282c02c41"
|
||||
},
|
||||
"space-create": {
|
||||
@@ -36,7 +36,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
|
||||
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
|
||||
"imageSha256": "329750c81a7a958c4fd105b2c80f85dce55ed87df9ddf0ea08084e4428e85f04"
|
||||
},
|
||||
"room-contour-close": {
|
||||
@@ -47,7 +47,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
|
||||
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
|
||||
"imageSha256": "ff2e903e548423aa0aa52680e20b8ad951c704e01130cb4b180222015a241276"
|
||||
},
|
||||
"plan-context-tray": {
|
||||
@@ -58,7 +58,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
|
||||
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
|
||||
"imageSha256": "ff55925f079411023442e7e7dd5ed528c331aaa3fd3fc2fbbf3b1aaf34a85718"
|
||||
},
|
||||
"device-editor": {
|
||||
@@ -69,7 +69,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
|
||||
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
|
||||
"imageSha256": "6b9cd281450e7782cff292f11efc58d227bedd0df86afc3be987feb2edec9dc3"
|
||||
},
|
||||
"device-display-preview": {
|
||||
@@ -80,7 +80,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
|
||||
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
|
||||
"imageSha256": "6f628abe6b89d7c4b352e6d73ac0b126526f6fdba5927bb774c5b884e484ca19"
|
||||
},
|
||||
"background-editor": {
|
||||
@@ -91,7 +91,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
|
||||
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
|
||||
"imageSha256": "a1d1a905f1410f73231d2ea5d1dfbd8ae79c2fe8b0a79cff87ebb977ac7a3103"
|
||||
},
|
||||
"room-card": {
|
||||
@@ -102,7 +102,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
|
||||
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
|
||||
"imageSha256": "48a5685039a5e0de2a28190d857b193c86e2e63606e3c7a8c40fc9d2b190da4e"
|
||||
},
|
||||
"device-info": {
|
||||
@@ -113,7 +113,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "81255e1bea5fc85e11741baf7a296d033ec81cce05dede922d60924597ea605a",
|
||||
"sourceSha256": "dc22dc9e6166e8b09eee216838fb26e2b48623185162d4654ec7f949e3bcdf15",
|
||||
"imageSha256": "29a342811aaedaebc54ba337792d60cb454a7cf4c3063b210f6c4bafe0a4d87b"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2170,6 +2170,44 @@ export const MUTANTS = [
|
||||
replace: ' .dev:not(.unavail):hover {',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'pair-chamfer-returns',
|
||||
// #310: фаска возвращается в узел-двойку — остриё пары снова срезано
|
||||
// вопреки решению владельца.
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="issue 310" test/wall-thickness.test.mjs',
|
||||
because: 'узел ровно двух лучей обязан закрываться полным mitre — стены сходятся в точку',
|
||||
patches: [{
|
||||
file: 'src/wall-thickness.ts',
|
||||
find: ' const patch = hit\n ? [node.slice(), pA, hit, pB]\n : [node.slice(), pA, pB];',
|
||||
replace: ' const visual = VISUAL_MITRE_LIMIT * Math.max(a.halfDepth, b.halfDepth);\n const patch = hit && Math.hypot(hit[0] - node[0], hit[1] - node[1]) <= visual\n ? [node.slice(), pA, hit, pB]\n : (hit && chamferApex(node, pA, hit, pB, visual)) || [node.slice(), pA, pB];',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'butt-end-trim-disabled',
|
||||
// #310: торцевой трим отключён — зубец торца толстой стены снова торчит
|
||||
// из тонкой.
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="issue 310" test/wall-thickness.test.mjs',
|
||||
because: 'выступ прямоугольного торца за грань тонкой стены — это и есть зубец из отчёта владельца',
|
||||
patches: [{
|
||||
file: 'src/physical-geometry.ts',
|
||||
find: ' for (const { segmentIndex, wedge } of pairButtEndTrimWedges(allSegments, epsilon)) {',
|
||||
replace: ' for (const { segmentIndex, wedge } of pairButtEndTrimWedges([], epsilon)) {',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'butt-end-trim-unbounded',
|
||||
// #310: трим без ограничения окрестностью узла режет всё тело стены.
|
||||
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
|
||||
+ '&& node --test --test-name-pattern="issue 310" test/wall-thickness.test.mjs',
|
||||
because: 'торцевой трим обязан быть адресным — не дальше 2·halfDepth от узла вдоль оси',
|
||||
patches: [{
|
||||
file: 'src/wall-thickness.ts',
|
||||
find: ' const reach = Math.min(2 * self.halfDepth, self.length);',
|
||||
replace: ' const reach = self.length;',
|
||||
}],
|
||||
},
|
||||
{
|
||||
id: 'visual-mitre-limit-back-to-4',
|
||||
// #309: порог визуального среза возвращается к классическим 4·h — шип на
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { difference, intersection, union } from 'polyclip-ts';
|
||||
import { polygonArea } from './logic';
|
||||
import {
|
||||
linearWallBody, linearWallJoinPatches, wallCmToUnits,
|
||||
linearWallBody, linearWallJoinPatches, pairButtEndTrimWedges, wallCmToUnits,
|
||||
type LinearWallSegment,
|
||||
} from './wall-thickness';
|
||||
import type {
|
||||
@@ -189,6 +189,35 @@ export function cutPartitionBody(
|
||||
* Most runtime consumers need these polygons directly and must not pay for an
|
||||
* additional polygon union which they never read.
|
||||
*/
|
||||
/**
|
||||
* Subtract one #310 butt-end wedge from a simple wall body. The wedge sits at
|
||||
* a body corner, so the difference is expected to stay one simple ring; on
|
||||
* any degenerate polygon-clipping outcome the body is left untouched.
|
||||
*/
|
||||
function subtractWedgeFromBody(
|
||||
body: number[][], wedge: number[][],
|
||||
): number[][] | null {
|
||||
try {
|
||||
const result: any = difference(
|
||||
[[...body.map((point) => [point[0], point[1]]), [body[0][0], body[0][1]]]] as any,
|
||||
[[...wedge.map((point) => [point[0], point[1]]), [wedge[0][0], wedge[0][1]]]] as any,
|
||||
);
|
||||
let best: number[][] | null = null;
|
||||
let bestArea = 0;
|
||||
for (const polygon of result || []) {
|
||||
const ring = (polygon?.[0] || []) as number[][];
|
||||
const area = Math.abs(polygonArea(ring));
|
||||
if (ring.length >= 4 && area > bestArea) {
|
||||
bestArea = area;
|
||||
best = ring.slice(0, -1).map((point) => [point[0], point[1]]);
|
||||
}
|
||||
}
|
||||
return best;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function physicalBodyParts(
|
||||
space: Pick<SpaceModel, 'partitions' | 'room_drafts' | 'wall_columns'>,
|
||||
cellCm: number,
|
||||
@@ -219,6 +248,7 @@ export function physicalBodyParts(
|
||||
drafts.push(body);
|
||||
}
|
||||
}
|
||||
const partitionMeta: { id: string; body: number[][] }[] = [];
|
||||
for (const partition of space.partitions || []) {
|
||||
const segment = {
|
||||
a: partition.a,
|
||||
@@ -229,8 +259,27 @@ export function physicalBodyParts(
|
||||
if (!body) continue;
|
||||
partitionSegments.push(segment);
|
||||
partitions.push(body);
|
||||
partitionMeta.push({ id: partition.id, body });
|
||||
}
|
||||
// #310: at a two-ray node the deeper wall's rectangular butt end may poke
|
||||
// past its thin partner's outer face; subtract the addressed wedge from the
|
||||
// owning body BEFORE opening cuts, so jambs inherit the clean silhouette.
|
||||
const allSegments = [...draftSegments, ...partitionSegments];
|
||||
for (const { segmentIndex, wedge } of pairButtEndTrimWedges(allSegments, epsilon)) {
|
||||
const target = segmentIndex < draftSegments.length
|
||||
? { list: drafts, at: segmentIndex }
|
||||
: { list: partitions, at: segmentIndex - draftSegments.length };
|
||||
const trimmed = subtractWedgeFromBody(target.list[target.at], wedge);
|
||||
if (trimmed) {
|
||||
target.list[target.at] = trimmed;
|
||||
if (segmentIndex >= draftSegments.length) {
|
||||
partitionMeta[target.at].body = trimmed;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const meta of partitionMeta) {
|
||||
presentedPartitions.push(...cutPartitionBody(
|
||||
body, cutsByPartition.get(partition.id) || [], epsilon,
|
||||
meta.body, cutsByPartition.get(meta.id) || [], epsilon,
|
||||
));
|
||||
}
|
||||
const columns = (space.wall_columns || []).map((column) =>
|
||||
|
||||
+137
-5
@@ -1114,6 +1114,123 @@ function addJunctionRay(rays: JunctionRay[], dx: number, dy: number, halfDepth:
|
||||
* same bounded mitre/bevel used by room contours. Unioning these patches with
|
||||
* the raw bodies removes the tooth without changing caps at degree-one nodes.
|
||||
*/
|
||||
/** #310: subtract one butt-end wedge from a simple body (largest ring wins). */
|
||||
function clipBodyByWedge(body: number[][], wedge: number[][]): number[][] | null {
|
||||
try {
|
||||
const result: any = difference(
|
||||
closedRing(body) as any, closedRing(wedge) as any,
|
||||
);
|
||||
let best: number[][] | null = null;
|
||||
let bestArea = 0;
|
||||
for (const polygon of result || []) {
|
||||
const ring = (polygon?.[0] || []) as number[][];
|
||||
const area = Math.abs(signedArea(ring));
|
||||
if (ring.length >= 4 && area > bestArea) {
|
||||
bestArea = area;
|
||||
best = ring.slice(0, -1).map((point) => [point[0], point[1]]);
|
||||
}
|
||||
}
|
||||
return best;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Butt-end trim of a two-ray node (#310, owner decision). With the full pair
|
||||
* mitre restored, the rectangular butt end of the deeper wall can still poke
|
||||
* sideways past the outer face of its thinner partner right at the node — the
|
||||
* «tooth sticking out of the thin wall» of the owner report. For every
|
||||
* two-ray node with an accepted mitre this returns, per input segment, the
|
||||
* wedges to subtract: the part of that segment's body OUTSIDE the partner's
|
||||
* outer face and within 2·halfDepth of the node along the segment's axis.
|
||||
* The rule is symmetric; for the thinner wall the wedge is empty. This is the
|
||||
* SECOND addressed subtraction of the junction pipeline, next to the lateral
|
||||
* trim of #271 — both strictly local to their node.
|
||||
*/
|
||||
export function pairButtEndTrimWedges(
|
||||
input: LinearWallSegment[], epsilon = 1e-6,
|
||||
): { segmentIndex: number; wedge: number[][] }[] {
|
||||
const segments = (input || []).map((segment, index) => ({ segment, index }))
|
||||
.filter(({ segment }) =>
|
||||
segment && Array.isArray(segment.a) && Array.isArray(segment.b)
|
||||
&& segment.a.length >= 2 && segment.b.length >= 2
|
||||
&& segment.a.every(Number.isFinite) && segment.b.every(Number.isFinite)
|
||||
&& Number.isFinite(segment.halfDepth) && segment.halfDepth > 0
|
||||
&& Math.hypot(segment.b[0] - segment.a[0], segment.b[1] - segment.a[1]) > 1e-9);
|
||||
if (segments.length < 2) return [];
|
||||
const eps = Math.max(Number.isFinite(epsilon) ? epsilon : 0, 1e-9);
|
||||
const endpoints = segments.flatMap(({ segment }) => [segment.a, segment.b])
|
||||
.map((point) => [point[0], point[1]])
|
||||
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
const nodes: number[][] = [];
|
||||
for (const point of endpoints) {
|
||||
if (!nodes.some((node) => closePoint(node, point, eps))) nodes.push(point);
|
||||
}
|
||||
const out: { segmentIndex: number; wedge: number[][] }[] = [];
|
||||
for (const node of nodes) {
|
||||
// Endpoint rays only: an interior (T) hit makes the node degree-3+ and
|
||||
// the fans of the multi-wall machinery own it, not the pair mitre.
|
||||
const rays: { u: number[]; halfDepth: number; length: number; index: number }[] = [];
|
||||
let interior = false;
|
||||
for (const { segment, index } of segments) {
|
||||
const length = Math.hypot(segment.b[0] - segment.a[0], segment.b[1] - segment.a[1]);
|
||||
if (closePoint(node, segment.a, eps)) {
|
||||
rays.push({ u: [(segment.b[0] - segment.a[0]) / length,
|
||||
(segment.b[1] - segment.a[1]) / length], halfDepth: segment.halfDepth, length, index });
|
||||
} else if (closePoint(node, segment.b, eps)) {
|
||||
rays.push({ u: [(segment.a[0] - segment.b[0]) / length,
|
||||
(segment.a[1] - segment.b[1]) / length], halfDepth: segment.halfDepth, length, index });
|
||||
} else if (pointOnSegmentInterior(node, segment, eps)) {
|
||||
interior = true;
|
||||
}
|
||||
}
|
||||
if (interior || rays.length !== 2) continue;
|
||||
const [a, b] = rays;
|
||||
const cross = a.u[0] * b.u[1] - a.u[1] * b.u[0];
|
||||
if (Math.abs(cross) < 1e-9) continue;
|
||||
const sign = cross < 0 ? 1 : -1;
|
||||
const nA = [-a.u[1], a.u[0]];
|
||||
const nB = [-b.u[1], b.u[0]];
|
||||
const pA = [node[0] + nA[0] * a.halfDepth * sign, node[1] + nA[1] * a.halfDepth * sign];
|
||||
const pB = [node[0] - nB[0] * b.halfDepth * sign, node[1] - nB[1] * b.halfDepth * sign];
|
||||
if (!lineIntersect(pA, a.u, pB, b.u)) continue; // no mitre — nothing pokes
|
||||
// For each wall: clip its near-node body rectangle by the OUTSIDE
|
||||
// half-plane of the partner's outer face (the face owning the apex side).
|
||||
const pairs: [typeof a, typeof b, number[], number[]][] = [
|
||||
[a, b, pB, [nB[0] * -sign, nB[1] * -sign]],
|
||||
[b, a, pA, [nA[0] * sign, nA[1] * sign]],
|
||||
];
|
||||
for (const [self, , faceP, faceOut] of pairs) {
|
||||
const reach = Math.min(2 * self.halfDepth, self.length);
|
||||
const ex = [-self.u[1] * self.halfDepth, self.u[0] * self.halfDepth];
|
||||
const rect = [
|
||||
[node[0] + ex[0], node[1] + ex[1]],
|
||||
[node[0] + self.u[0] * reach + ex[0], node[1] + self.u[1] * reach + ex[1]],
|
||||
[node[0] + self.u[0] * reach - ex[0], node[1] + self.u[1] * reach - ex[1]],
|
||||
[node[0] - ex[0], node[1] - ex[1]],
|
||||
];
|
||||
// Sutherland–Hodgman clip of the rectangle by dot(x - faceP, faceOut) >= 0.
|
||||
const side = (point: number[]): number =>
|
||||
(point[0] - faceP[0]) * faceOut[0] + (point[1] - faceP[1]) * faceOut[1];
|
||||
const clipped: number[][] = [];
|
||||
for (let i = 0; i < rect.length; i++) {
|
||||
const cur = rect[i], nxt = rect[(i + 1) % rect.length];
|
||||
const sc = side(cur), sn = side(nxt);
|
||||
if (sc >= -1e-12) clipped.push(cur);
|
||||
if ((sc > 1e-12 && sn < -1e-12) || (sc < -1e-12 && sn > 1e-12)) {
|
||||
const t = sc / (sc - sn);
|
||||
clipped.push([cur[0] + (nxt[0] - cur[0]) * t, cur[1] + (nxt[1] - cur[1]) * t]);
|
||||
}
|
||||
}
|
||||
if (clipped.length >= 3 && Math.abs(signedArea(clipped)) > eps * eps) {
|
||||
out.push({ segmentIndex: self.index, wedge: clipped });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function linearWallJoinPatches(
|
||||
input: LinearWallSegment[], epsilon = 1e-6,
|
||||
): number[][][] {
|
||||
@@ -1191,10 +1308,12 @@ export function linearWallJoinPatches(
|
||||
node[1] - nB[1] * b.halfDepth * sign,
|
||||
];
|
||||
const hit = lineIntersect(pA, a.u, pB, b.u);
|
||||
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
|
||||
// #310 (owner decision): a node of exactly two rays keeps the FULL
|
||||
// mitre — two walls meet in a point like on a drawing. The #309
|
||||
// chamfer applies only to the fans of >=3-ray nodes above.
|
||||
const patch = hit
|
||||
? [node.slice(), pA, hit, pB]
|
||||
: (hit && chamferApex(node, pA, hit, pB, visual)) || [node.slice(), pA, pB];
|
||||
: [node.slice(), pA, pB];
|
||||
if (Math.abs(signedArea(patch)) > eps * eps) patches.push(patch);
|
||||
}
|
||||
}
|
||||
@@ -1257,8 +1376,21 @@ export function drawWallPreviewD(
|
||||
if (Math.hypot(b[0] - a[0], b[1] - a[1]) >= 1e-9 && h > 0)
|
||||
segments.push({ a, b, halfDepth: h });
|
||||
}
|
||||
const bodies = segments.map(linearWallBody).filter((body): body is number[][] => !!body);
|
||||
const joined = [...bodies, ...linearWallJoinPatches(segments)];
|
||||
// Index-aligned with `segments`: the #310 wedge below addresses its owner
|
||||
// body by segment index, so the null filter happens only at the join.
|
||||
const bodies = segments.map(linearWallBody);
|
||||
// #310: the preview shares the butt-end trim with persisted masonry, so the
|
||||
// rubber-band silhouette matches what the click will save.
|
||||
for (const { segmentIndex, wedge } of pairButtEndTrimWedges(segments)) {
|
||||
const body = bodies[segmentIndex];
|
||||
if (!body) continue;
|
||||
const trimmed = clipBodyByWedge(body, wedge);
|
||||
if (trimmed) bodies[segmentIndex] = trimmed;
|
||||
}
|
||||
const joined = [
|
||||
...bodies.filter((body): body is number[][] => !!body),
|
||||
...linearWallJoinPatches(segments),
|
||||
];
|
||||
const geom = unionSimpleBodies(joined);
|
||||
if (geom) return polyclipToPathD(geom);
|
||||
return joined.map((body) => polyToPath(body)).join(' ');
|
||||
|
||||
+163
-17
@@ -14,6 +14,7 @@ import {
|
||||
virtualJunctionPatches, stableJunctionPatch, unionJunctionPatches,
|
||||
innerContourForRoom, innerEdgeSpan, ownEdgeOffsets,
|
||||
paperRoomShapesWithWalls, WALL_MIN_CM, WALL_MAX_CM, MITRE_LIMIT, VISUAL_MITRE_LIMIT,
|
||||
pairButtEndTrimWedges,
|
||||
MULTI_WALL_JOIN_LIMIT, buildMultiWallNodeMap, multiWallBevelTriangles,
|
||||
MULTI_WALL_NEAR_ORTHOGONAL_MAX_DEGREES,
|
||||
MULTI_WALL_ORTHOGONAL_DOT_EPSILON, multiWallProtectedRayIndexes,
|
||||
@@ -2754,12 +2755,9 @@ 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, 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');
|
||||
// #310 (owner decision): a node of exactly two rays keeps the FULL mitre —
|
||||
// the drawing point of two converging walls is legitimate at any length.
|
||||
assert.equal(acute[0].length, 4, 'a two-ray node keeps its full mitre apex (#310)');
|
||||
|
||||
const separate = linearWallJoinPatches([
|
||||
{ a: [-2, 0], b: [0, 0], halfDepth: 1 },
|
||||
@@ -3103,23 +3101,31 @@ const pointInPoly = (point, body) => {
|
||||
return inside;
|
||||
};
|
||||
|
||||
test('issue 309 the acute 10/20 pair keeps its tail within the visual limit', () => {
|
||||
test('issue 310 the acute 10/20 pair keeps its full apex and loses the butt-end tooth', () => {
|
||||
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`);
|
||||
}
|
||||
}
|
||||
assert.equal(patches.length, 1, 'the pair closes its outer sector with one patch');
|
||||
// Full apex (#310): four points, the apex at the intersection of the outer
|
||||
// faces — farther than the #309 visual limit, and that is the point.
|
||||
assert.equal(patches[0].length, 4, 'the pair keeps its full mitre, not a chamfer');
|
||||
const hMax = Math.max(...segments.map((segment) => segment.halfDepth));
|
||||
const apex = patches[0][2];
|
||||
assert.ok(Math.hypot(apex[0] - node[0], apex[1] - node[1]) > 1.5 * hMax,
|
||||
'the restored apex must reach past the retired pair chamfer limit');
|
||||
// Butt-end tooth (#310): the deeper wall's wedge exists and removes the
|
||||
// probe that used to sit inside the poking corner of its butt end.
|
||||
const wedges = pairButtEndTrimWedges(segments, 0.2);
|
||||
assert.equal(wedges.length, 1, 'exactly one non-empty butt-end wedge (the deeper wall)');
|
||||
const deeper = segments.reduce((a, b) => (a.halfDepth >= b.halfDepth ? a : b));
|
||||
assert.equal(segments.indexOf(deeper), wedges[0].segmentIndex,
|
||||
'the wedge belongs to the deeper wall');
|
||||
const probe = [node[0] - deeper.halfDepth * 0.9, node[1] - 2];
|
||||
assert.equal(pointInPoly(probe, wedges[0].wedge), true,
|
||||
'the wedge covers the old tooth corner probe');
|
||||
});
|
||||
|
||||
test('issue 309 the 3×50 node fans stay within the visual limit', () => {
|
||||
@@ -3198,3 +3204,143 @@ test('issue 309 the full teeth fixture leaves no junction holes', () => {
|
||||
'the teeth fixture has junction holes',
|
||||
);
|
||||
});
|
||||
|
||||
test('issue 310 the butt-end trim is addressed and the node body is a clean wedge', () => {
|
||||
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));
|
||||
const fixture = teethFixture();
|
||||
const space = {
|
||||
partitions: fixture.partitions
|
||||
.filter((partition) => ['partition-mt8liuxi-0', 'partition-mt8liuxi-1']
|
||||
.includes(partition.id))
|
||||
.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 inGeometry = (point) => (frame.geometry || []).some((polygon) =>
|
||||
(polygon || []).some((ring, index) => index === 0 && pointInPoly(point, ring)));
|
||||
const deeper = segments.reduce((a, b) => (a.halfDepth >= b.halfDepth ? a : b));
|
||||
// The old tooth: just outside the thin wall's outer face, inside the deep
|
||||
// wall's rectangular butt end. Must be empty now.
|
||||
assert.equal(inGeometry([node[0] - deeper.halfDepth * 0.9, node[1] - 2]), false,
|
||||
'the butt-end tooth survived the trim');
|
||||
// The apex direction stays filled: probe halfway from the node to the
|
||||
// actual mitre apex of the restored full pair patch.
|
||||
const apex = linearWallJoinPatches(segments, 0.2)[0][2];
|
||||
assert.equal(inGeometry([(node[0] + apex[0]) / 2, (node[1] + apex[1]) / 2]), true,
|
||||
'the full apex region must stay masonry');
|
||||
// Addressed: far from the node the deep wall body is intact at full width.
|
||||
assert.equal(inGeometry([node[0] - deeper.halfDepth * 0.9, node[1] - 200]), true,
|
||||
'the trim may not eat the wall far from the node');
|
||||
});
|
||||
|
||||
test('issue 310 a square pair of equal depths has no butt-end wedge', () => {
|
||||
const wedges = pairButtEndTrimWedges([
|
||||
{ a: [0, 0], b: [100, 0], halfDepth: 10 },
|
||||
{ a: [0, 0], b: [0, 100], halfDepth: 10 },
|
||||
], 1e-6);
|
||||
assert.deepEqual(wedges, [], 'equal square corners have nothing poking out');
|
||||
});
|
||||
|
||||
test('issue 310 the butt-end trim reach is bounded by the node neighbourhood', () => {
|
||||
// A near-parallel co-directed pair: the thin wall's outer face undercuts the
|
||||
// deep wall's strip for ~100 units, but the ADDRESSED wedge may only reach
|
||||
// 2·halfDepth from the node along the axis — the rest of the wall is not
|
||||
// this node's business.
|
||||
const segments = [
|
||||
{ a: [0, 0], b: [300, 0], halfDepth: 10 },
|
||||
{ a: [0, 0], b: [300, 30], halfDepth: 20 },
|
||||
];
|
||||
const wedges = pairButtEndTrimWedges(segments, 1e-6);
|
||||
assert.ok(wedges.length >= 1, 'the near-parallel pair must produce a wedge');
|
||||
for (const { segmentIndex, wedge } of wedges) {
|
||||
const segment = segments[segmentIndex];
|
||||
const length = Math.hypot(segment.b[0] - segment.a[0], segment.b[1] - segment.a[1]);
|
||||
const u = [(segment.b[0] - segment.a[0]) / length, (segment.b[1] - segment.a[1]) / length];
|
||||
for (const point of wedge) {
|
||||
const along = point[0] * u[0] + point[1] * u[1];
|
||||
assert.ok(along <= 2 * segment.halfDepth + 1e-6,
|
||||
`wedge vertex ${point} reaches past 2·halfDepth along the wall`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('issue 310 pair grid contract: masonry equals strips plus patch minus wedges', () => {
|
||||
// AC5 for two-ray nodes: buildMultiWallNodeMap drops nodes of <3 rays, so
|
||||
// the #302 detector never sees a pair. The pair contract is checked on a
|
||||
// grid instead: inside the node neighbourhood a point is masonry IFF it
|
||||
// lies in (strip A ∪ strip B ∪ mitre patch) − (butt-end wedges).
|
||||
const cases = [
|
||||
{ name: 'owner spike 10/20', segments: (() => {
|
||||
const node = [2220.833333333333, 1350];
|
||||
return teethSegments().filter((segment) =>
|
||||
[segment.a, segment.b].some((end) =>
|
||||
Math.hypot(end[0] - node[0], end[1] - node[1]) < 0.5));
|
||||
})(), node: [2220.833333333333, 1350] },
|
||||
{ name: 'square 90', segments: [
|
||||
{ a: [0, 0], b: [300, 0], halfDepth: 10 },
|
||||
{ a: [0, 0], b: [0, 300], halfDepth: 10 },
|
||||
], node: [0, 0] },
|
||||
{ name: 'near-parallel', segments: [
|
||||
{ a: [0, 0], b: [300, 0], halfDepth: 10 },
|
||||
{ a: [0, 0], b: [300, 30], halfDepth: 20 },
|
||||
], node: [0, 0] },
|
||||
];
|
||||
for (const { name, segments, node } of cases) {
|
||||
const bodies = segments.map((segment) => linearWallBody(segment));
|
||||
const patches = linearWallJoinPatches(segments, 1e-6);
|
||||
const wedges = pairButtEndTrimWedges(segments, 1e-6);
|
||||
const space = {
|
||||
partitions: segments.map((segment, index) => ({
|
||||
id: `pair-${index}`, a: segment.a, b: segment.b, cm: 15,
|
||||
})),
|
||||
room_drafts: [], wall_columns: [],
|
||||
};
|
||||
// physicalBodyParts converts cm itself; drive it with exact halfDepths by
|
||||
// reusing wall bodies via the pure pipeline instead: geometry from parts.
|
||||
const parts = [
|
||||
...bodies.filter(Boolean).map((body, index) => {
|
||||
const wedge = wedges.filter((item) => item.segmentIndex === index);
|
||||
return { body, wedge };
|
||||
}),
|
||||
];
|
||||
const h = Math.max(...segments.map((segment) => segment.halfDepth));
|
||||
const radius = 3 * h;
|
||||
const step = Math.max(h / 4, 2);
|
||||
const inside = (point, poly) => pointInPoly(point, poly);
|
||||
for (let dx = -radius; dx <= radius; dx += step) {
|
||||
for (let dy = -radius; dy <= radius; dy += step) {
|
||||
const point = [node[0] + dx, node[1] + dy];
|
||||
const inStrip = bodies.some((body) => body && inside(point, body));
|
||||
const inPatch = patches.some((patch) => inside(point, patch));
|
||||
const inWedge = wedges.some(({ wedge }) => inside(point, wedge));
|
||||
const expected = (inStrip || inPatch) && !inWedge;
|
||||
// actual masonry: trimmed strips ∪ patches
|
||||
const actual = parts.some(({ body, wedge }) =>
|
||||
inside(point, body) && !wedge.some(({ wedge: w }) => inside(point, w)))
|
||||
|| inPatch;
|
||||
// Skip probes within one epsilon band of any edge: point-in-polygon
|
||||
// on shared borders is not a stable oracle.
|
||||
const nearEdge = [...bodies.filter(Boolean), ...patches,
|
||||
...wedges.map(({ wedge }) => wedge)].some((poly) =>
|
||||
poly.some((a, i) => {
|
||||
const b = poly[(i + 1) % poly.length];
|
||||
const t = Math.max(0, Math.min(1,
|
||||
((point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1]))
|
||||
/ (((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2) || 1e-12)));
|
||||
return Math.hypot(point[0] - (a[0] + (b[0] - a[0]) * t),
|
||||
point[1] - (a[1] + (b[1] - a[1]) * t)) < step / 4;
|
||||
}));
|
||||
if (nearEdge) continue;
|
||||
assert.equal(actual, expected,
|
||||
`${name}: contract mismatch at [${dx.toFixed(1)}, ${dy.toFixed(1)}]`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user