fix: no jags on the edges of a degenerate apex (#329 §4)

Owner report: small serrations remained on the outer edges between the inner
and the outer vertex. Measured on the fixture ring: two ~4 cm steps plus four
micro-vertices at the tip. Their source was the inset contour's two-point
bevel folding into a bow-tie, and the earlier half-plane clip of that fold,
which left a 0.2 cm sliver the boolean union turned into steps. The inset now
ends in ITS own mitre point at a degenerate apex — mirroring the sharp outer
tip — so there is no fold to clip and no sliver to smear: the room ring is
exactly three vertices, every side longer than the half depth. The clip
helper and its cap plumbing are gone. The user-visible wording of this work
already stands in both changelogs from the #329 entry.

Issue: #329
User-Visible: no
This commit is contained in:
Codex
2026-08-27 23:31:17 +03:00
parent 4758767e0c
commit 002795f7c8
6 changed files with 292 additions and 276 deletions
File diff suppressed because one or more lines are too long
+115 -115
View File
File diff suppressed because one or more lines are too long
+11 -11
View File
@@ -2,7 +2,7 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "ab24211e8f36804dc9f508e4bae2446801cb332b91b5a75a3649a50f2376edc2",
"sourceFingerprint": "41564f7e31f09f936503145b1d660462ee0bc4d757afaefc03d474766192c5e6",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,7 +14,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ab24211e8f36804dc9f508e4bae2446801cb332b91b5a75a3649a50f2376edc2",
"sourceSha256": "41564f7e31f09f936503145b1d660462ee0bc4d757afaefc03d474766192c5e6",
"imageSha256": "36223106c073f07d8cc3ecf8eaab37192ebb2687daba65c5c21047d0b7890de0"
},
"view-touch": {
@@ -25,7 +25,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ab24211e8f36804dc9f508e4bae2446801cb332b91b5a75a3649a50f2376edc2",
"sourceSha256": "41564f7e31f09f936503145b1d660462ee0bc4d757afaefc03d474766192c5e6",
"imageSha256": "41e3ba67f8db0e98f26f484293af83ef937c369ca5ca6a59a3350d8954c906f4"
},
"space-create": {
@@ -36,7 +36,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ab24211e8f36804dc9f508e4bae2446801cb332b91b5a75a3649a50f2376edc2",
"sourceSha256": "41564f7e31f09f936503145b1d660462ee0bc4d757afaefc03d474766192c5e6",
"imageSha256": "0dc024726327f60f4a9ceaf3044381691f81f1090af81d1812e870f22d9343ba"
},
"room-contour-close": {
@@ -47,7 +47,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ab24211e8f36804dc9f508e4bae2446801cb332b91b5a75a3649a50f2376edc2",
"sourceSha256": "41564f7e31f09f936503145b1d660462ee0bc4d757afaefc03d474766192c5e6",
"imageSha256": "44cfaa95bd51e2cd628400e28db0ad8b2f0cd904385845bd402494f3f5c0d93c"
},
"plan-context-tray": {
@@ -58,7 +58,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ab24211e8f36804dc9f508e4bae2446801cb332b91b5a75a3649a50f2376edc2",
"sourceSha256": "41564f7e31f09f936503145b1d660462ee0bc4d757afaefc03d474766192c5e6",
"imageSha256": "e0662429b423fb74151b583dcc2c8635b001b637d03bbf7a6b16aec46399c3f8"
},
"device-editor": {
@@ -69,7 +69,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ab24211e8f36804dc9f508e4bae2446801cb332b91b5a75a3649a50f2376edc2",
"sourceSha256": "41564f7e31f09f936503145b1d660462ee0bc4d757afaefc03d474766192c5e6",
"imageSha256": "d0ffd31ce80bfde21ab75da356a5fc1af38246f2b301030880320620c228d89d"
},
"device-display-preview": {
@@ -80,7 +80,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ab24211e8f36804dc9f508e4bae2446801cb332b91b5a75a3649a50f2376edc2",
"sourceSha256": "41564f7e31f09f936503145b1d660462ee0bc4d757afaefc03d474766192c5e6",
"imageSha256": "2cdabae1f89c3286e4fac0ce30f757ee1690b707ab8a5488748b7cd420626160"
},
"background-editor": {
@@ -91,7 +91,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ab24211e8f36804dc9f508e4bae2446801cb332b91b5a75a3649a50f2376edc2",
"sourceSha256": "41564f7e31f09f936503145b1d660462ee0bc4d757afaefc03d474766192c5e6",
"imageSha256": "30147bb00a90eea7136b4cee30995f6e6a9217b5132f3e8d3ad7471413b1af8a"
},
"room-card": {
@@ -102,7 +102,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ab24211e8f36804dc9f508e4bae2446801cb332b91b5a75a3649a50f2376edc2",
"sourceSha256": "41564f7e31f09f936503145b1d660462ee0bc4d757afaefc03d474766192c5e6",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
},
"device-info": {
@@ -113,7 +113,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "ab24211e8f36804dc9f508e4bae2446801cb332b91b5a75a3649a50f2376edc2",
"sourceSha256": "41564f7e31f09f936503145b1d660462ee0bc4d757afaefc03d474766192c5e6",
"imageSha256": "dd492f53150b7149085daada5cce9eeae9bde9e7ea1d86679a54b3041f72f517"
}
}
+11 -6
View File
@@ -1,6 +1,6 @@
# Issue #329 — ограничения стыков стен и честная острая вершина
Статус: ревизия 4 (r1: M1/M2/L1; r2: M1 — AC7; поправка владельца 2026-08-27: §4 — острая вершина, не фаска). Решения владельца (чат, 2026-08-27, зафиксированы в issue):
Статус: ревизия 5 (r1: M1/M2/L1; r2: M1 — AC7; поправки владельца 2026-08-27: §4 — острая вершина без фаски и без зазубрин на гранях). Решения владельца (чат, 2026-08-27, зафиксированы в issue):
пять ограничений приняты; проверки действуют на записи, легаси читается как
есть; для остаточных острых вершин легаси-планов рендер закрывает остриё
честной фаской вместо «трезубца».
@@ -77,9 +77,12 @@
обеих стен) даёт **одну точку внешнего контура ровно в вершине плана**:
ни двухточечной фаски, ни иглы mitre, уходящей на десятки сантиметров за
стены, которые её образуют;
- внутренний контур в этой зоне обрезается по линии смыкания внутренних
граней, поэтому складка не вычитается и V-вырезов не возникает; кладка
выше линии сплошная, штриховка непрерывна;
- внутренний контур сходится в СВОЮ вершину (одна точка mitre) вместо
двухточечной фаски, поэтому «бабочки» не возникает вовсе: вычитать нечего,
V-вырезов нет, кладка выше сплошная, штриховка непрерывна;
- на внешних гранях между внутренней и внешней вершиной не остаётся ступенек
(вторая поправка владельца): кольцо комнаты — ровно треугольник, каждая
сторона длиннее полутолщины, микро-вершин и «щепок» нет;
- обычные и просто острые пары (#310, вершина вне зоны перекрытия) не
меняются: их полный mitre сохраняется.
@@ -143,8 +146,10 @@ margin): правила П1–П5 применяются к элементам,
вычета тел < 25 см², запись отклоняется по П5. Тот же треугольник со
стороной 120 см (inradius ≈ 34.6 см) проходит. Юнит с этими числами.
- **AC6 (§4).** Легаси-фикстура issue рендерится без «трезубца»: у вершины
ровно одна точка внешнего контура, стоящая в вершине плана; ноль V-вырезов;
golden-сцена + юнит на контуре.
ровно одна точка внешнего контура, стоящая в вершине плана; внутренний
контур — треугольник (ровно 3 точки, не «бабочка»); внешнее кольцо тела —
ровно 3 различные вершины без ступенек и микро-сегментов; golden-сцена +
юниты на контурах и на кольце.
- **AC7a (Resize, канал §2).** Resize, приводящий к нарушению любого из
П1–П5, упирается в последнюю допустимую позицию; при отсутствии
безопасного шага ручка приглушена, и её объяснение (hover/focus/нажатие)
+12 -29
View File
@@ -1670,6 +1670,14 @@ export function insetContour(
out.push(hit);
continue;
}
// #329: at a degenerate apex the two-point bevel folds the contour over
// itself (a bow-tie), and subtracting that fold carved notches in the
// masonry. The interior simply ends in ITS own apex — the single mitre
// point — mirroring the sharp outer tip.
if (isDegenerateApexCorner(poly, offsets, i)) {
out.push(hit);
continue;
}
}
// bevel: two points, each edge's offset line stopped at the vertex offset
if (oA > 0) out.push([poly[i][0] + nAx * oA, poly[i][1] + nAy * oA]);
@@ -3768,11 +3776,7 @@ export function wallBodiesGeometry(
const outC = outsetContour(pr.poly, pr.offsets, multiWallNodes);
const inC = insetContour(pr.poly, pr.offsets, multiWallNodes);
if (!outC) continue;
// #329 §4: a legacy corner whose bodies overlap completely folds its
// inset contour over itself; carry the clip regions with the ring.
roomRings.push({
outset: outC, inset: inC, apexCaps: degenerateApexCaps(pr.poly, pr.offsets),
});
roomRings.push({ outset: outC, inset: inC });
}
for (const body of extraBodies) {
const xs = body.map((p) => p[0]), ys = body.map((p) => p[1]);
@@ -3816,30 +3820,9 @@ export function wallBodiesGeometry(
const bodyOf = (ring: typeof roomRings[number]): any => {
const outset: any = closedRing(ring.outset);
if (!ring.inset) return outset;
let insetPoly = ring.inset;
// #329 §4: at a degenerate apex the inset contour folds over itself
// (a bow-tie); differencing that fold carved the V-notches. Clip the
// fold away with the half-plane through the convergence point, so the
// interior simply ends there and the masonry above stays solid.
for (const cap of ring.apexCaps || []) {
const clipped = clipPolygonOutsideCap(insetPoly, cap);
if (clipped && clipped.length >= 3) insetPoly = clipped;
}
let hole: any = closedRing(insetPoly);
// #329 §4 (owner correction 2026-08-27): at a degenerate apex the two
// wall bodies overlap completely, and the inset contour folds over
// itself there — that fold is what carved the "trident" of pikes and
// V-notches. Clip the HOLE at the convergence point instead of cutting
// the masonry: above it the wall is solid and the apex stays a normal
// sharp point, exactly as a drawing would show it.
for (const cap of ring.apexCaps || []) {
try {
const clipped = difference(hole, closedRing(cap) as any);
if (clipped) hole = clipped;
} catch {
// Keep the hole we have; a failed clip must not lose the interior.
}
}
// #329: the interior now ends in its own apex point (insetContour),
// so the ring is a plain difference — no fold to clip, no slivers.
const hole: any = closedRing(ring.inset);
return difference(outset, hole);
};
corePhase = 'room-rings';
+28
View File
@@ -157,3 +157,31 @@ test('наследование считается по правилам, а не
[{ rule: 'angle', subject: 'n', actual: 9, limit: 15 }], [],
).length, 1);
});
test('§4: у вырожденной вершины грани прямые — ни зазубрин, ни щепок', async () => {
const { wallBodiesGeometry, insetContour, roomWallProfile } =
await import('../test-build/wall-thickness.js');
const room = {
id: 'spike', name: 'Spike', area: null,
poly: [[3.6417, 1.8333], [3.7167, 0.2417], [3.9083, 1.7667]],
};
const walls = room.poly.map((point, index) => ({
key: `w${index}`, a: point, b: room.poly[(index + 1) % room.poly.length], cm: 15,
}));
const profile = roomWallProfile([room], room.id, walls, [], PITCH, CELL, PITCH, 1);
// Внутренний контур сходится в СВОЮ вершину — четвёртой точки-складки нет.
const inset = insetContour(profile.poly, profile.offsets, null);
assert.equal(inset.length, 3, 'внутренний контур — треугольник, а не «бабочка»');
const geometry = wallBodiesGeometry([room], walls, [], [], PITCH, CELL, PITCH, 1);
assert.equal(geometry.status, 'ok');
const outer = geometry.roomGeom[0][0];
const distinct = outer.slice(0, -1);
assert.equal(distinct.length, 3, 'внешнее кольцо — треугольник без ступенек');
// Никаких микро-вершин: каждая сторона длиннее полутолщины.
for (let index = 0; index < distinct.length; index++) {
const next = distinct[(index + 1) % distinct.length];
const side = Math.hypot(next[0] - distinct[index][0], next[1] - distinct[index][1]);
assert.ok(side > cm(7.5), `сторона ${index} не вырождена: ${side}`);
}
});