fix: a mixed-thickness legacy apex converges honestly (#339)

isDegenerateApexCorner measured the inner-face convergence as
max(h1,h2)/tan(theta/2) — for a 10-degree apex between a 15 cm and a 30 cm
wall that overstates the distance (171.5 cm against the true 128.3/128.9 on
160 cm edges), the corner failed the "inside both edges" test and rendered
as the #329 trident again. Worse, the verdict depended on which neighbouring
edge carried the thicker wall.

The check now intersects the two actual face lines: the meeting point lands
at (hOther + hOwn*cos(theta))/sin(theta) along each edge, degenerate only
when inside both. With equal halves this reduces algebraically to the old
h/tan(theta/2), so equal-thickness verdicts are unchanged by construction —
pinned by the untouched section-4 units and the full golden matrix (136
scenes verified). New units cover both traversal orders of the mixed apex,
the one-point outset tip, the 30-degree ordinary pair and the zero-thickness
guard.

The write path is untouched: P1 forbids new sub-15-degree corners since
issue 329, this is purely how a legacy document renders.

Issue: #339
User-Visible: yes
This commit is contained in:
Codex
2026-08-28 13:36:07 +03:00
parent ccf685e0f8
commit 66bd3d6922
31 changed files with 212 additions and 163 deletions
+29
View File
@@ -578,3 +578,32 @@ test('#331 AC1: квантование .5-тика одинаково в обо
assert.ok(checkNodes(rays).some((item) => item.rule === 'valence'),
'2.5e-7 квантуется вверх и сливается с 3e-7 — семь лучей в одном узле');
});
// --- #339: смешанные толщины у вырожденной вершины ---
test('#339: вершина 10° со смежными рёбрами 15+30 см вырождена в оба обхода', async () => {
const { isDegenerateApexCorner, outsetContour } =
await import('../test-build/wall-thickness.js');
const th = (10 * Math.PI) / 180;
const reach = cm(160);
const tri = [[0, 0], [reach, 0], [reach * Math.cos(th), reach * Math.sin(th)]];
// AC1: оба порядка толщин на смежных рёбрах вершины 0 — вырождена.
for (const offsets of [[cm(7.5), cm(7.5), cm(15)], [cm(15), cm(7.5), cm(7.5)]]) {
// offsets[2] и offsets[0] — рёбра, смежные с вершиной 0.
assert.equal(isDegenerateApexCorner(tri, offsets, 0), true,
`смешанные толщины [prev=${offsets[2]}, next=${offsets[0]}] обязаны быть вырождены`);
}
// Рендер: внешний контур несёт РОВНО одну точку у вершины — остриё в
// вершине плана, как в §4 #329 для равных толщин.
const offsets = [cm(15), cm(7.5), cm(7.5)];
const outset = outsetContour(tri, offsets, null);
assert.ok(outset, 'контур строится');
const near = outset.filter((p) => Math.hypot(p[0], p[1]) < cm(30));
assert.equal(near.length, 1, 'у вершины ровно одна точка внешнего контура');
assert.ok(Math.hypot(near[0][0], near[0][1]) < 1e-9, 'остриё в вершине плана');
// AC3: 30° со смешанными — обычная пара #310; худая геометрия — false.
const th30 = (30 * Math.PI) / 180;
const tri30 = [[0, 0], [reach, 0], [reach * Math.cos(th30), reach * Math.sin(th30)]];
assert.equal(isDegenerateApexCorner(tri30, offsets, 0), false);
assert.equal(isDegenerateApexCorner(tri, [0, 0, 0], 0), false, 'нулевые толщины — нет тел');
});