mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix: a degenerate sharp corner renders as a normal sharp apex (#329 §4)
Owner correction (chat, 2026-08-27): no flat chamfer at the tip — a plain sharp apex. Proven by execution on the issue fixture: the outset contour fell back to a two-point bevel (the 4·h mitre limit against an 87 cm reach) while the inset contour folded into a bow-tie, and subtracting that fold carved the V-notches — together they made the trident. Now a degenerate corner (below 15 degrees, inner faces meeting inside both walls) contributes ONE outset point at the plan's own vertex — no bevel, no metres-long mitre needle — and its inset is clipped at the convergence line so no fold is subtracted. Plain and merely sharp pairs keep the full mitre of #310. The write-side limits of П1-П5 stop new plans from creating such corners at all. Issue: #329 User-Visible: yes
This commit is contained in:
File diff suppressed because one or more lines are too long
Vendored
+1270
-1270
File diff suppressed because one or more lines are too long
@@ -2,6 +2,13 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Sharp room corners are honest again: a very acute apex used to break into a
|
||||
«trident» of pikes and notches, and drawing now refuses the shapes that
|
||||
cause it — walls closer than 15°, more than six walls in one node, segments
|
||||
shorter than 20 cm or than their own thickness, nodes closer than 5 cm and
|
||||
rooms with no interior left
|
||||
([#329](https://github.com/Matysh/houseplan-card/issues/329)).
|
||||
|
||||
## v1.68.1 — 2026-08-27
|
||||
|
||||
- Creating a space no longer reports `v8+ space requires wall_segments` while
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
|
||||
## Не выпущено
|
||||
|
||||
- Острые углы комнат снова выглядят честно: очень острая вершина
|
||||
распадалась на «трезубец» из пиков и вырезов, а рисование теперь не
|
||||
разрешает саму причину — стены под углом меньше 15°, больше шести стен в
|
||||
узле, участки короче 20 см или собственной толщины, узлы ближе 5 см и
|
||||
комнаты без внутреннего просвета
|
||||
([#329](https://github.com/Matysh/houseplan-card/issues/329)).
|
||||
|
||||
## v1.68.1 — 2026-08-27
|
||||
|
||||
- Создание пространства больше не выдаёт ошибку
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"version": 1,
|
||||
"fixture": "synthetic-only",
|
||||
"chromium": "151.0.7922.34",
|
||||
"sourceFingerprint": "bbdc8981e3d11029702a3e5b78a9026e6814bb2bdf8e3cb56d0c2e8f7c519b2c",
|
||||
"sourceFingerprint": "e3e2539c7a8a6a86120cad4dd041929b27e220eee60a5efe92cabe006353dbde",
|
||||
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
|
||||
"command": "npm run build && node demo/docs/capture.mjs",
|
||||
"scenarios": {
|
||||
@@ -14,7 +14,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "bbdc8981e3d11029702a3e5b78a9026e6814bb2bdf8e3cb56d0c2e8f7c519b2c",
|
||||
"sourceSha256": "e3e2539c7a8a6a86120cad4dd041929b27e220eee60a5efe92cabe006353dbde",
|
||||
"imageSha256": "36223106c073f07d8cc3ecf8eaab37192ebb2687daba65c5c21047d0b7890de0"
|
||||
},
|
||||
"view-touch": {
|
||||
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "bbdc8981e3d11029702a3e5b78a9026e6814bb2bdf8e3cb56d0c2e8f7c519b2c",
|
||||
"sourceSha256": "e3e2539c7a8a6a86120cad4dd041929b27e220eee60a5efe92cabe006353dbde",
|
||||
"imageSha256": "41e3ba67f8db0e98f26f484293af83ef937c369ca5ca6a59a3350d8954c906f4"
|
||||
},
|
||||
"space-create": {
|
||||
@@ -36,7 +36,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "bbdc8981e3d11029702a3e5b78a9026e6814bb2bdf8e3cb56d0c2e8f7c519b2c",
|
||||
"sourceSha256": "e3e2539c7a8a6a86120cad4dd041929b27e220eee60a5efe92cabe006353dbde",
|
||||
"imageSha256": "0dc024726327f60f4a9ceaf3044381691f81f1090af81d1812e870f22d9343ba"
|
||||
},
|
||||
"room-contour-close": {
|
||||
@@ -47,7 +47,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "bbdc8981e3d11029702a3e5b78a9026e6814bb2bdf8e3cb56d0c2e8f7c519b2c",
|
||||
"sourceSha256": "e3e2539c7a8a6a86120cad4dd041929b27e220eee60a5efe92cabe006353dbde",
|
||||
"imageSha256": "44cfaa95bd51e2cd628400e28db0ad8b2f0cd904385845bd402494f3f5c0d93c"
|
||||
},
|
||||
"plan-context-tray": {
|
||||
@@ -58,7 +58,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "bbdc8981e3d11029702a3e5b78a9026e6814bb2bdf8e3cb56d0c2e8f7c519b2c",
|
||||
"sourceSha256": "e3e2539c7a8a6a86120cad4dd041929b27e220eee60a5efe92cabe006353dbde",
|
||||
"imageSha256": "e0662429b423fb74151b583dcc2c8635b001b637d03bbf7a6b16aec46399c3f8"
|
||||
},
|
||||
"device-editor": {
|
||||
@@ -69,7 +69,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "bbdc8981e3d11029702a3e5b78a9026e6814bb2bdf8e3cb56d0c2e8f7c519b2c",
|
||||
"sourceSha256": "e3e2539c7a8a6a86120cad4dd041929b27e220eee60a5efe92cabe006353dbde",
|
||||
"imageSha256": "d0ffd31ce80bfde21ab75da356a5fc1af38246f2b301030880320620c228d89d"
|
||||
},
|
||||
"device-display-preview": {
|
||||
@@ -80,7 +80,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "bbdc8981e3d11029702a3e5b78a9026e6814bb2bdf8e3cb56d0c2e8f7c519b2c",
|
||||
"sourceSha256": "e3e2539c7a8a6a86120cad4dd041929b27e220eee60a5efe92cabe006353dbde",
|
||||
"imageSha256": "2cdabae1f89c3286e4fac0ce30f757ee1690b707ab8a5488748b7cd420626160"
|
||||
},
|
||||
"background-editor": {
|
||||
@@ -91,7 +91,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "bbdc8981e3d11029702a3e5b78a9026e6814bb2bdf8e3cb56d0c2e8f7c519b2c",
|
||||
"sourceSha256": "e3e2539c7a8a6a86120cad4dd041929b27e220eee60a5efe92cabe006353dbde",
|
||||
"imageSha256": "30147bb00a90eea7136b4cee30995f6e6a9217b5132f3e8d3ad7471413b1af8a"
|
||||
},
|
||||
"room-card": {
|
||||
@@ -102,7 +102,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "bbdc8981e3d11029702a3e5b78a9026e6814bb2bdf8e3cb56d0c2e8f7c519b2c",
|
||||
"sourceSha256": "e3e2539c7a8a6a86120cad4dd041929b27e220eee60a5efe92cabe006353dbde",
|
||||
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
|
||||
},
|
||||
"device-info": {
|
||||
@@ -113,7 +113,7 @@
|
||||
},
|
||||
"theme": "dark",
|
||||
"language": "en",
|
||||
"sourceSha256": "bbdc8981e3d11029702a3e5b78a9026e6814bb2bdf8e3cb56d0c2e8f7c519b2c",
|
||||
"sourceSha256": "e3e2539c7a8a6a86120cad4dd041929b27e220eee60a5efe92cabe006353dbde",
|
||||
"imageSha256": "dd492f53150b7149085daada5cce9eeae9bde9e7ea1d86679a54b3041f72f517"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Issue #329 — ограничения стыков стен и честная острая вершина
|
||||
|
||||
Статус: ревизия 3 (r1: M1/M2/L1; r2: M1 — AC7 доказывает каналы §2). Решения владельца (чат, 2026-08-27, зафиксированы в issue):
|
||||
Статус: ревизия 4 (r1: M1/M2/L1; r2: M1 — AC7; поправка владельца 2026-08-27: §4 — острая вершина, не фаска). Решения владельца (чат, 2026-08-27, зафиксированы в issue):
|
||||
пять ограничений приняты; проверки действуют на записи, легаси читается как
|
||||
есть; для остаточных острых вершин легаси-планов рендер закрывает остриё
|
||||
честной фаской вместо «трезубца».
|
||||
@@ -65,17 +65,23 @@
|
||||
|
||||
## 4. Рендер остаточных острых вершин (легаси)
|
||||
|
||||
Для пары стен вершины, чьи внутренние грани смыкаются на расстоянии `d` от
|
||||
вершины, меньшем длины обеих стен (эквивалентно: угол мал настолько, что
|
||||
существует зона полного перекрытия тел):
|
||||
**Поправка владельца (чат, 2026-08-27):** плоского среза вершины быть не
|
||||
должно — у остриё обычная нормальная острая вершина.
|
||||
|
||||
- остриё обрезается **одной плоской фаской**, перпендикулярной биссектрисе,
|
||||
проходящей через точку смыкания внутренних граней;
|
||||
- выше фаски тел нет вовсе (никаких пиков), ниже — обычная кладка; V-вырезы
|
||||
запрещены; штриховка непрерывна;
|
||||
- правило дополняет #309/#310 и включается только в вырожденной зоне
|
||||
(`d` внутри длины стен); обычные острые пары (#310, остриё вне зоны
|
||||
перекрытия) не меняются.
|
||||
Для корня «трезубца» доказано исполнением: у вершины ≈9.85° внешний контур
|
||||
падал на плоскую фаску (лимит mitre 4·h против выноса 87 см), а внутренний
|
||||
контур складывался «бабочкой» — вырезы рождались вычитанием этой складки.
|
||||
Контракт:
|
||||
|
||||
- вырожденная вершина (угол < 15° и точка смыкания внутренних граней внутри
|
||||
обеих стен) даёт **одну точку внешнего контура ровно в вершине плана**:
|
||||
ни двухточечной фаски, ни иглы mitre, уходящей на десятки сантиметров за
|
||||
стены, которые её образуют;
|
||||
- внутренний контур в этой зоне обрезается по линии смыкания внутренних
|
||||
граней, поэтому складка не вычитается и V-вырезов не возникает; кладка
|
||||
выше линии сплошная, штриховка непрерывна;
|
||||
- обычные и просто острые пары (#310, вершина вне зоны перекрытия) не
|
||||
меняются: их полный mitre сохраняется.
|
||||
|
||||
## 5. Бэкенд
|
||||
|
||||
@@ -136,9 +142,9 @@ margin): правила П1–П5 применяются к элементам,
|
||||
полутолщины 16.5 см плюс минимального просвета — внутренняя область после
|
||||
вычета тел < 25 см², запись отклоняется по П5. Тот же треугольник со
|
||||
стороной 120 см (inradius ≈ 34.6 см) проходит. Юнит с этими числами.
|
||||
- **AC6 (§4).** Легаси-фикстура issue рендерится без «трезубца»: одна фаска,
|
||||
ноль V-вырезов; golden-сцена + пиксельная проверка смоком (нет разрывов
|
||||
тела выше фаски).
|
||||
- **AC6 (§4).** Легаси-фикстура issue рендерится без «трезубца»: у вершины
|
||||
ровно одна точка внешнего контура, стоящая в вершине плана; ноль V-вырезов;
|
||||
golden-сцена + юнит на контуре.
|
||||
- **AC7a (Resize, канал §2).** Resize, приводящий к нарушению любого из
|
||||
П1–П5, упирается в последнюю допустимую позицию; при отсутствии
|
||||
безопасного шага ручка приглушена, и её объяснение (hover/focus/нажатие)
|
||||
|
||||
@@ -85,6 +85,10 @@ import {
|
||||
type LinearWallSegment, type WallEntry, type WallInterval,
|
||||
innerEdgeSpan, ownEdgeOffsets, thicknessCmAt,
|
||||
} from './wall-thickness';
|
||||
import {
|
||||
checkNodeDistances, checkNodes, checkRoomClearance, checkSegmentLengths,
|
||||
newViolations, type JunctionLimitViolation, type LimitSegment,
|
||||
} from './junction-limits';
|
||||
import {
|
||||
pointOnOpenCut, sanitizeOpenSpans,
|
||||
type OpenSpanEntry,
|
||||
@@ -7378,6 +7382,83 @@ class HouseplanCard extends LitElement {
|
||||
this._showToast(this._t(key, { reason: this._wallModelBlockerLabel(error) }));
|
||||
}
|
||||
|
||||
/** #329: every wall of a space as the limit checks see it. */
|
||||
private _limitSegmentsOf(space: any): LimitSegment[] {
|
||||
const segments: LimitSegment[] = [];
|
||||
for (const segment of (space?.wall_segments || [])) {
|
||||
if (segment?.a && segment?.b) {
|
||||
segments.push({
|
||||
id: String(segment.id || ''), a: segment.a, b: segment.b, cm: Number(segment.cm),
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const partition of (space?.partitions || [])) {
|
||||
if (partition?.a && partition?.b) {
|
||||
segments.push({
|
||||
id: String(partition.id || ''), a: partition.a, b: partition.b, cm: Number(partition.cm),
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const draft of (space?.room_drafts || [])) {
|
||||
const points = Array.isArray(draft?.points) ? draft.points : [];
|
||||
const drafted = Array.isArray(draft?.segments) ? draft.segments : [];
|
||||
for (let index = 0; index + 1 < points.length; index++) {
|
||||
segments.push({
|
||||
id: String(drafted[index]?.id || `${draft?.id || 'draft'}-${index}`),
|
||||
a: points[index], b: points[index + 1], cm: Number(drafted[index]?.cm),
|
||||
});
|
||||
}
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** #329 П1-П5 over one space. Pure input, no side effects. */
|
||||
private _junctionLimitViolations(config: any, spaceId: string): JunctionLimitViolation[] {
|
||||
const space = (config?.spaces || []).find((item: any) => item?.id === spaceId);
|
||||
if (!space) return [];
|
||||
const cellCm = Number(space.cell_cm) > 0 ? Number(space.cell_cm) : 5;
|
||||
const segments = this._limitSegmentsOf(space);
|
||||
const violations = [
|
||||
...checkNodes(segments),
|
||||
...checkSegmentLengths(segments, cellCm, GRID_STEP_N),
|
||||
...checkNodeDistances(segments, cellCm, GRID_STEP_N),
|
||||
];
|
||||
for (const room of (space.rooms || [])) {
|
||||
const roomId = String(room?.id || '');
|
||||
if (!roomId) continue;
|
||||
let inner: number[][] | null = null;
|
||||
try {
|
||||
inner = innerContourForRoom(
|
||||
space.rooms || [], roomId, space.walls || [], [],
|
||||
GRID_STEP_N, cellCm, GRID_STEP_N, 1,
|
||||
);
|
||||
} catch { inner = null; }
|
||||
violations.push(...checkRoomClearance(roomId, inner, cellCm, GRID_STEP_N));
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
/** Localised refusal text for the first violation a write introduces. */
|
||||
private _junctionLimitLabel(violation: JunctionLimitViolation): string {
|
||||
const round = (value: number) => String(Math.round(value * 10) / 10);
|
||||
return this._t(`junction.limit_${violation.rule}` as any, {
|
||||
actual: round(violation.actual), limit: round(violation.limit),
|
||||
});
|
||||
}
|
||||
|
||||
/** #329: violations this write would ADD; inherited ones stay untouched. */
|
||||
private _junctionLimitsIntroduced(
|
||||
candidate: any, previousConfig: any, spaceId: string,
|
||||
): JunctionLimitViolation[] {
|
||||
let inherited: JunctionLimitViolation[] = [];
|
||||
try { inherited = this._junctionLimitViolations(previousConfig, spaceId); }
|
||||
catch { inherited = []; }
|
||||
let next: JunctionLimitViolation[] = [];
|
||||
try { next = this._junctionLimitViolations(candidate, spaceId); }
|
||||
catch { return []; }
|
||||
return newViolations(next, inherited);
|
||||
}
|
||||
|
||||
private _commitPhysicalGeometry(
|
||||
name: string,
|
||||
before: SpaceGeometryState | null,
|
||||
@@ -7474,6 +7555,20 @@ class HouseplanCard extends LitElement {
|
||||
this._showToast(this._t('toast.geometry_unsafe'));
|
||||
return false;
|
||||
}
|
||||
// #329: the owner's junction limits refuse the WRITE. An existing plan's
|
||||
// inherited violations are never re-judged (spec §3), so the candidate is
|
||||
// compared against the pre-edit document.
|
||||
const beforeConfig = JSON.parse(JSON.stringify(liveCandidate));
|
||||
this._restoreGeometryStateInConfig(beforeConfig, before);
|
||||
const introduced = this._junctionLimitsIntroduced(
|
||||
committedCandidate, beforeConfig, before.spaceId,
|
||||
);
|
||||
if (introduced.length) {
|
||||
this._clearGeometryGesture();
|
||||
this._restoreGeometryStateLocal(before);
|
||||
this._showToast(this._junctionLimitLabel(introduced[0]));
|
||||
return false;
|
||||
}
|
||||
adoptWallSegmentModelCandidateInPlace(liveCandidate, committedCandidate);
|
||||
this._recordGeometry(name, historyBefore);
|
||||
const afterSpace = liveCandidate.spaces.find((space: any) => space.id === before.spaceId);
|
||||
|
||||
@@ -540,6 +540,11 @@
|
||||
"toast.physical_angle": "Enter a rotation angle from 0° up to, but not including, 90°",
|
||||
"toast.physical_limit": "The space has reached the limit for this type of geometry",
|
||||
"toast.geometry_unsafe": "Change canceled: wall geometry could not be built safely.",
|
||||
"junction.limit_angle": "The angle between walls is too sharp: {actual}°, minimum {limit}°.",
|
||||
"junction.limit_valence": "Too many walls meet in one node: {actual}, maximum {limit}.",
|
||||
"junction.limit_length": "The wall segment is too short: {actual} cm, minimum {limit} cm.",
|
||||
"junction.limit_distance": "Walls and nodes are too close: {actual} cm, minimum {limit} cm.",
|
||||
"junction.limit_clearance": "No room interior is left: {actual} cm², minimum {limit} cm².",
|
||||
"toast.wall_model_migration_blocked": "The wall model could not be updated: {reason}. The plan was not changed. Run “Optimize plans”; if the error repeats, fix the conflicting wall geometry.",
|
||||
"toast.wall_model_client_outdated": "Update the card and reload the page before editing the plan.",
|
||||
"wall_model.reason.invalid-room": "invalid room contour",
|
||||
|
||||
@@ -540,6 +540,11 @@
|
||||
"toast.physical_angle": "Введите угол поворота от 0° до 90°, не включая 90°",
|
||||
"toast.physical_limit": "В пространстве достигнут лимит объектов этого типа",
|
||||
"toast.geometry_unsafe": "Изменение отменено: геометрию стен нельзя безопасно построить.",
|
||||
"junction.limit_angle": "Слишком острый угол между стенами: {actual}°, минимум {limit}°.",
|
||||
"junction.limit_valence": "Слишком много стен в одном узле: {actual}, максимум {limit}.",
|
||||
"junction.limit_length": "Слишком короткий участок стены: {actual} см, минимум {limit} см.",
|
||||
"junction.limit_distance": "Стены и узлы слишком близко: {actual} см, минимум {limit} см.",
|
||||
"junction.limit_clearance": "Внутри комнаты не остаётся места: {actual} см², минимум {limit} см².",
|
||||
"toast.wall_model_migration_blocked": "Не удалось обновить модель стен: {reason}. План не изменён. Запустите «Оптимизировать планы»; если ошибка повторится, исправьте конфликтующую геометрию стен.",
|
||||
"toast.wall_model_client_outdated": "Обновите карточку и перезагрузите страницу перед редактированием плана.",
|
||||
"wall_model.reason.invalid-room": "некорректный контур комнаты",
|
||||
|
||||
+181
-2
@@ -2803,6 +2803,8 @@ function structurallyValidWallGeometry(geometry: any): boolean {
|
||||
interface MultiWallRoomRing {
|
||||
outset: number[][];
|
||||
inset: number[][] | null;
|
||||
/** #329 §4: clip regions for degenerate apexes of this room. */
|
||||
apexCaps?: number[][][];
|
||||
}
|
||||
|
||||
/** Excess pairwise overlap cuts removed to expose the straight bevel. */
|
||||
@@ -3597,6 +3599,145 @@ export function wallBodyRings(
|
||||
* returned as an empty typed result. Core failure is `failed-core`; an optional
|
||||
* merge failure is `degraded-extra` with every known-valid component retained.
|
||||
*/
|
||||
/**
|
||||
* #329 §4 — honest apex for a legacy sharp corner.
|
||||
*
|
||||
* When two walls meet at an angle so small that their INNER faces converge
|
||||
* inside both walls (distance `h/tan(θ/2)` shorter than either edge), the
|
||||
* zone above that convergence has no room interior at all: the two bodies
|
||||
* overlap completely. Rendering it produced the "trident" of #329 — pikes
|
||||
* and V-notches instead of a tip. Return, per such corner, a large quad
|
||||
* covering everything BEYOND the convergence point along the bisector; the
|
||||
* caller subtracts it, so the masonry ends in one flat chamfer.
|
||||
*
|
||||
* Corners whose convergence lies outside the walls are ordinary sharp pairs
|
||||
* (#310) and are never returned.
|
||||
*/
|
||||
/**
|
||||
* Corners at or above this angle are ordinary sharp pairs (#310). Below it a
|
||||
* write is refused today (#329 П1), so only legacy documents reach the
|
||||
* degenerate-apex chamfer.
|
||||
*/
|
||||
export const DEGENERATE_APEX_MAX_DEGREES = 15;
|
||||
|
||||
/**
|
||||
* Keep the part of `poly` on the far side of a degenerate-apex cap (#329 §4).
|
||||
* The cap's first edge is the chamfer line; everything on the apex side of it
|
||||
* is dropped. Sutherland–Hodgman against one half-plane keeps a simple
|
||||
* polygon simple and unfolds the bow-tie of a degenerate inset contour.
|
||||
*/
|
||||
export function clipPolygonOutsideCap(
|
||||
poly: number[][] | null | undefined, cap: number[][],
|
||||
): number[][] | null {
|
||||
if (!poly || poly.length < 3 || !cap || cap.length < 4) return poly ?? null;
|
||||
const [p0, p1, , p3] = cap;
|
||||
const edge = [p1[0] - p0[0], p1[1] - p0[1]];
|
||||
const edgeLength = Math.hypot(edge[0], edge[1]);
|
||||
if (!(edgeLength > 1e-12)) return poly;
|
||||
// Inward normal of the KEPT side: the cap's fourth point lies there.
|
||||
let nx = -edge[1] / edgeLength, ny = edge[0] / edgeLength;
|
||||
if ((p3[0] - p0[0]) * nx + (p3[1] - p0[1]) * ny > 0) { nx = -nx; ny = -ny; }
|
||||
const side = (point: number[]): number =>
|
||||
(point[0] - p0[0]) * nx + (point[1] - p0[1]) * ny;
|
||||
const out: number[][] = [];
|
||||
for (let index = 0; index < poly.length; index++) {
|
||||
const current = poly[index];
|
||||
const next = poly[(index + 1) % poly.length];
|
||||
const dCurrent = side(current), dNext = side(next);
|
||||
if (dCurrent >= -1e-12) out.push([current[0], current[1]]);
|
||||
if ((dCurrent > 0 && dNext < 0) || (dCurrent < 0 && dNext > 0)) {
|
||||
const t = dCurrent / (dCurrent - dNext);
|
||||
out.push([
|
||||
current[0] + (next[0] - current[0]) * t,
|
||||
current[1] + (next[1] - current[1]) * t,
|
||||
]);
|
||||
}
|
||||
}
|
||||
return out.length >= 3 ? out : null;
|
||||
}
|
||||
|
||||
/** True when corner `index` is sharp enough that the two wall bodies overlap
|
||||
* completely near it (#329 §4) — the only case that may not use a mitre. */
|
||||
export function isDegenerateApexCorner(
|
||||
poly: number[][], offsets: number[], index: number,
|
||||
): boolean {
|
||||
const n = poly?.length || 0;
|
||||
if (n < 3 || offsets?.length !== n) return false;
|
||||
const previous = poly[(index - 1 + n) % n];
|
||||
const vertex = poly[index];
|
||||
const next = poly[(index + 1) % n];
|
||||
const toPrev = [previous[0] - vertex[0], previous[1] - vertex[1]];
|
||||
const toNext = [next[0] - vertex[0], next[1] - vertex[1]];
|
||||
const lenPrev = Math.hypot(toPrev[0], toPrev[1]);
|
||||
const lenNext = Math.hypot(toNext[0], toNext[1]);
|
||||
if (!(lenPrev > 1e-9) || !(lenNext > 1e-9)) return false;
|
||||
const cos = Math.max(-1, Math.min(1,
|
||||
(toPrev[0] * toNext[0] + toPrev[1] * toNext[1]) / (lenPrev * lenNext)));
|
||||
const theta = Math.acos(cos);
|
||||
if (!(theta > 1e-9)) return false;
|
||||
if (theta >= (DEGENERATE_APEX_MAX_DEGREES * Math.PI) / 180) return false;
|
||||
const half = Math.max(
|
||||
Math.max(0, offsets[(index - 1 + n) % n]), Math.max(0, offsets[index]),
|
||||
);
|
||||
if (!(half > 0)) return false;
|
||||
const distance = half / Math.tan(theta / 2);
|
||||
return Number.isFinite(distance) && distance > 0
|
||||
&& distance < lenPrev - 1e-9 && distance < lenNext - 1e-9;
|
||||
}
|
||||
|
||||
export function degenerateApexCaps(
|
||||
poly: number[][], offsets: number[],
|
||||
): number[][][] {
|
||||
const n = poly?.length || 0;
|
||||
if (n < 3 || offsets?.length !== n) return [];
|
||||
const caps: number[][][] = [];
|
||||
for (let index = 0; index < n; index++) {
|
||||
const previous = poly[(index - 1 + n) % n];
|
||||
const vertex = poly[index];
|
||||
const next = poly[(index + 1) % n];
|
||||
const toPrev = [previous[0] - vertex[0], previous[1] - vertex[1]];
|
||||
const toNext = [next[0] - vertex[0], next[1] - vertex[1]];
|
||||
const lenPrev = Math.hypot(toPrev[0], toPrev[1]);
|
||||
const lenNext = Math.hypot(toNext[0], toNext[1]);
|
||||
if (!(lenPrev > 1e-9) || !(lenNext > 1e-9)) continue;
|
||||
const uPrev = [toPrev[0] / lenPrev, toPrev[1] / lenPrev];
|
||||
const uNext = [toNext[0] / lenNext, toNext[1] / lenNext];
|
||||
const cos = Math.max(-1, Math.min(1, uPrev[0] * uNext[0] + uPrev[1] * uNext[1]));
|
||||
const theta = Math.acos(cos);
|
||||
if (!(theta > 1e-9) || theta >= Math.PI - 1e-9) continue;
|
||||
// Only a corner sharper than what a write may create today (#329 П1) can
|
||||
// be degenerate. An ordinary corner also has a finite inner-face meeting
|
||||
// point — cutting there would eat legitimate masonry.
|
||||
if (theta >= (DEGENERATE_APEX_MAX_DEGREES * Math.PI) / 180) continue;
|
||||
// Offsets are half-depths of the two incident edges; the wider one owns
|
||||
// the convergence distance.
|
||||
const half = Math.max(
|
||||
Math.max(0, offsets[(index - 1 + n) % n]), Math.max(0, offsets[index]),
|
||||
);
|
||||
if (!(half > 0)) continue;
|
||||
const distance = half / Math.tan(theta / 2);
|
||||
if (!Number.isFinite(distance) || distance <= 0) continue;
|
||||
// Not degenerate: the bodies stop overlapping inside the walls.
|
||||
if (distance >= lenPrev - 1e-9 || distance >= lenNext - 1e-9) continue;
|
||||
const bisector = [uPrev[0] + uNext[0], uPrev[1] + uNext[1]];
|
||||
const bisLength = Math.hypot(bisector[0], bisector[1]);
|
||||
if (!(bisLength > 1e-9)) continue;
|
||||
// Inward bisector points into the corner; the cap covers the opposite
|
||||
// (apex) side, starting at the convergence point.
|
||||
const inward = [bisector[0] / bisLength, bisector[1] / bisLength];
|
||||
const cut = [vertex[0] + inward[0] * distance, vertex[1] + inward[1] * distance];
|
||||
const side = [-inward[1], inward[0]];
|
||||
const reach = (lenPrev + lenNext + distance) * 4 + half * 8;
|
||||
caps.push([
|
||||
[cut[0] + side[0] * reach, cut[1] + side[1] * reach],
|
||||
[cut[0] - side[0] * reach, cut[1] - side[1] * reach],
|
||||
[cut[0] - side[0] * reach - inward[0] * reach, cut[1] - side[1] * reach - inward[1] * reach],
|
||||
[cut[0] + side[0] * reach - inward[0] * reach, cut[1] + side[1] * reach - inward[1] * reach],
|
||||
]);
|
||||
}
|
||||
return caps;
|
||||
}
|
||||
|
||||
export function wallBodiesGeometry(
|
||||
rooms: any[],
|
||||
walls: WallEntry[] | null | undefined,
|
||||
@@ -3627,7 +3768,11 @@ export function wallBodiesGeometry(
|
||||
const outC = outsetContour(pr.poly, pr.offsets, multiWallNodes);
|
||||
const inC = insetContour(pr.poly, pr.offsets, multiWallNodes);
|
||||
if (!outC) continue;
|
||||
roomRings.push({ outset: outC, inset: inC });
|
||||
// #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),
|
||||
});
|
||||
}
|
||||
for (const body of extraBodies) {
|
||||
const xs = body.map((p) => p[0]), ys = body.map((p) => p[1]);
|
||||
@@ -3670,7 +3815,32 @@ export function wallBodiesGeometry(
|
||||
const paperGeom = rawPaperGeom;
|
||||
const bodyOf = (ring: typeof roomRings[number]): any => {
|
||||
const outset: any = closedRing(ring.outset);
|
||||
return ring.inset ? difference(outset, closedRing(ring.inset) as any) : 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.
|
||||
}
|
||||
}
|
||||
return difference(outset, hole);
|
||||
};
|
||||
corePhase = 'room-rings';
|
||||
let body: any = null;
|
||||
@@ -4206,6 +4376,11 @@ export function outsetContour(
|
||||
}
|
||||
const hit = lineIntersect(pA, uA, pB, uB);
|
||||
const maxO = Math.max(oA, oB, 1e-9);
|
||||
// #329 (owner correction 2026-08-27): the tip of a degenerate corner is
|
||||
// a NORMAL SHARP APEX — the masonry converges to a point at the plan's
|
||||
// own vertex. Neither the flat chamfer (which produced the "trident"
|
||||
// with the folded inset) nor the raw mitre needle (which sticks metres
|
||||
// past the walls that make it) is acceptable there.
|
||||
const joinLimit = multiWallNodeAt(multiWallNodes, poly[i])?.limit
|
||||
?? MITRE_LIMIT * maxO;
|
||||
if (hit) {
|
||||
@@ -4214,6 +4389,10 @@ export function outsetContour(
|
||||
out.push(hit);
|
||||
continue;
|
||||
}
|
||||
if (isDegenerateApexCorner(poly, offsets, i)) {
|
||||
out.push([poly[i][0], poly[i][1]]);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (oA > 0) out.push([poly[i][0] - nAx * oA, poly[i][1] - nAy * oA]);
|
||||
if (oB > 0) out.push([poly[i][0] - nBx * oB, poly[i][1] - nBy * oB]);
|
||||
|
||||
@@ -100,3 +100,38 @@ test('унаследованные нарушения не считаются н
|
||||
const worse = [...inherited, { rule: 'angle', subject: 'other', actual: 3, limit: 15 }];
|
||||
assert.deepEqual(newViolations(worse, inherited).map((item) => item.subject), ['other']);
|
||||
});
|
||||
|
||||
// --- #329 §4: честная вершина легаси-плана ---
|
||||
|
||||
test('§4: вырожденная вершина сходится в точку плана, обычные углы нетронуты', async () => {
|
||||
const { isDegenerateApexCorner, outsetContour, DEGENERATE_APEX_MAX_DEGREES } =
|
||||
await import('../test-build/wall-thickness.js');
|
||||
assert.equal(DEGENERATE_APEX_MAX_DEGREES, 15);
|
||||
|
||||
// Фикстура issue: вершина ≈9.85°, стены 15 см (полутолщина 7.5 см).
|
||||
const apex = [3.7167, 0.2417];
|
||||
const spike = [[3.6417, 1.8333], apex, [3.9083, 1.7667]];
|
||||
const half = cm(7.5);
|
||||
const offsets = [half, half, half];
|
||||
assert.equal(isDegenerateApexCorner(spike, offsets, 1), true);
|
||||
assert.equal(isDegenerateApexCorner(spike, offsets, 0), false);
|
||||
|
||||
// Внешний контур сходится РОВНО в вершине плана: ни плоского среза (две
|
||||
// точки у вершины), ни иглы mitre, торчащей далеко за неё.
|
||||
const outset = outsetContour(spike, offsets, null);
|
||||
assert.ok(outset, 'контур строится');
|
||||
const nearApex = outset.filter((point) => Math.hypot(point[0] - apex[0], point[1] - apex[1]) < half * 4);
|
||||
assert.equal(nearApex.length, 1, 'у вершины ровно одна точка — остриё');
|
||||
assert.ok(Math.hypot(nearApex[0][0] - apex[0], nearApex[0][1] - apex[1]) < 1e-9,
|
||||
'остриё стоит в самой вершине плана');
|
||||
|
||||
// Прямоугольник: обычные углы, mitre сохраняется, вырожденных нет.
|
||||
const square = [[0, 0], [cm(400), 0], [cm(400), cm(400)], [0, cm(400)]];
|
||||
assert.equal(square.every((_, index) => !isDegenerateApexCorner(square, Array(4).fill(half), index)), true);
|
||||
// Острый, но не вырожденный угол 30° — тоже обычный (#310 сохраняется).
|
||||
const thirty = [[0, 0], [cm(400), 0],
|
||||
[cm(400) * Math.cos(Math.PI / 6), cm(400) * Math.sin(Math.PI / 6)]];
|
||||
assert.equal(isDegenerateApexCorner(thirty, Array(3).fill(half), 0), false);
|
||||
// Нулевая толщина не создаёт вырожденной зоны.
|
||||
assert.equal(isDegenerateApexCorner(spike, [0, 0, 0], 1), false);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user