feat: junction limits refuse the write in every editing surface (#329)

П3 measures the WALL, not the catalogue atom: a short filler segment that
compensates a thickness step (owner's fixture, 5 cm = (30-20)/2) is a legal
continuation of a long same-thickness wall, so the rule walks the maximal
collinear run through the shared nodes before judging the length.

Resize stops at the last allowed position and names the broken rule instead
of the generic "geometry cannot be saved"; the Thickness dialog refuses
through its own toast. Both channels are pinned by demo/smoke_junction_limits
plus three mutants (angle threshold, write barrier, degenerate apex bevel).

Issue: #329
User-Visible: yes
This commit is contained in:
Codex
2026-08-27 23:31:42 +03:00
parent 7d15bd06e9
commit f514fb27fe
28 changed files with 592 additions and 185 deletions
File diff suppressed because one or more lines are too long
+123
View File
@@ -0,0 +1,123 @@
/**
* Issue #329: the owner's junction limits refuse the WRITE, each surface
* through its own channel (spec §2), and a legacy plan that already violates
* them still accepts unrelated edits (spec §3).
*/
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 1000, height: 900 }, 1);
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
const update = async () => { card.requestUpdate(); await card.updateComplete; };
const toasts = [];
card._showToast = (text) => { toasts.push(String(text)); };
const CELL = 5;
// NORM_W units per centimetre for a 5 cm grid cell.
const cm = (value) => (value / CELL) * (1000 / 240);
const reset = async () => {
card._serverCfg = { spaces: [{
id: 'limits', title: 'Limits', cell_cm: CELL, view_box: [0, 0, 1, 1],
rooms: [], openings: [], room_drafts: [], partitions: [], wall_columns: [],
}], markers: [], settings: {} };
card._space = 'limits'; card._layout = {};
card._cfgEpoch++; card._modelCache = null; card._frame = null;
card._path = []; card._activeDraftId = null;
card._nameSel = ''; card._areaSel = '';
card._setMode('plan'); card._tool = 'draw'; card._drawWallField = '15';
await update();
toasts.length = 0;
};
const drawRoom = async (points, name) => {
card._path = [...points, points[0]];
card._nameSel = name; card._areaSel = '';
card._commitRoom();
await update();
return (card._spaceModel().rooms || []).some((room) => room.name === name);
};
const wedge = (degrees, originX = 400, originY = 600) => {
const radians = (degrees * Math.PI) / 180;
const reach = cm(400);
return [
[originX, originY],
[originX + reach, originY],
[originX + reach * Math.cos(radians), originY - reach * Math.sin(radians)],
];
};
// AC1: 14° отклоняется и называет правило; 16° проходит.
await reset();
result.angle14Refused = !(await drawRoom(wedge(14), 'narrow14'));
result.angle14NamesRule = toasts.some((text) => text.includes('15'));
await reset();
result.angle16Accepted = await drawRoom(wedge(16), 'wide16');
// AC3: сегмент 19 см отклоняется, 20 см проходит.
await reset();
result.short19Refused = !(await drawRoom([
[400, 600], [400 + cm(19), 600], [400 + cm(19), 600 - cm(300)],
[400, 600 - cm(300)],
], 'short19'));
result.short19NamesRule = toasts.some((text) => text.includes('20'));
await reset();
result.side20Accepted = await drawRoom([
[400, 600], [400 + cm(300), 600], [400 + cm(300), 600 - cm(300)],
[400, 600 - cm(300)],
], 'square300');
// AC5a: «шпиль» фикстуры issue (≈9.9°) не нарисовать заново.
await reset();
result.spikeRefused = !(await drawRoom(wedge(9.9), 'spike'));
result.spikeNamesRule = toasts.some((text) => text.includes('15'));
// AC7b: «Толщина» отказывает через тост с названием правила: 100 см на
// стенах комнаты 100x100 съедает весь просвет (П5).
await reset();
await drawRoom([
[400, 600], [400 + cm(100), 600], [400 + cm(100), 600 - cm(100)],
[400, 600 - cm(100)],
], 'forThickness');
toasts.length = 0;
const room = (card._curSpaceCfg.rooms || []).find((item) => item.name === 'forThickness');
// Диалог «Толщины» открывается по реальной записи стены — так же, как из UI.
const wall = (card._curSpaceCfg.walls || [])[0];
card._tool = 'wallthick';
card._wallDialog = {
a: wall.a, b: wall.b, value: '100', roomId: room.id,
source: { kind: 'room' }, sx: 50, sy: 50,
};
card._wallThickApply(true);
await update();
const catalogue = (card._curSpaceCfg.wall_segments || []).map((item) => item.cm);
const legacy = (card._curSpaceCfg.walls || []).map((item) => item.cm);
result.thicknessRefusedByLimit = ![...catalogue, ...legacy].includes(100);
result.thicknessToastShown = toasts.some((text) => text.includes('25'));
// §3: унаследованное нарушение не блокирует несвязанную запись.
card._serverCfg = { spaces: [{
id: 'limits', title: 'Limits', cell_cm: CELL, view_box: [0, 0, 1, 1],
rooms: [{ id: 'legacy', name: 'legacy', area: null, poly: [
[0.30, 0.70], [0.3167, 0.24], [0.36, 0.68],
] }],
walls: [
{ key: 'l0', a: [0.30, 0.70], b: [0.3167, 0.24], cm: 15 },
{ key: 'l1', a: [0.3167, 0.24], b: [0.36, 0.68], cm: 15 },
{ key: 'l2', a: [0.36, 0.68], b: [0.30, 0.70], cm: 15 },
],
openings: [], room_drafts: [], partitions: [], wall_columns: [],
}], markers: [], settings: {} };
card._cfgEpoch++; card._modelCache = null; card._frame = null;
card._path = []; card._tool = 'draw'; await update();
toasts.length = 0;
result.legacyKeepsAcceptingEdits = await drawRoom([
[700, 700], [700 + cm(300), 700], [700 + cm(300), 700 - cm(300)],
[700, 700 - cm(300)],
], 'unrelated');
return result;
});
checkAll(out);
await finish(browser);
+76 -76
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -6,7 +6,8 @@
«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
rooms with no interior left. Length is measured along the whole wall, so a
short filler segment compensating a thickness step stays legal
([#329](https://github.com/Matysh/houseplan-card/issues/329)).
## v1.68.1 — 2026-08-27
+3 -2
View File
@@ -11,8 +11,9 @@
- Острые углы комнат снова выглядят честно: очень острая вершина
распадалась на «трезубец» из пиков и вырезов, а рисование теперь не
разрешает саму причину — стены под углом меньше 15°, больше шести стен в
узле, участки короче 20 см или собственной толщины, узлы ближе 5 см и
комнаты без внутреннего просвета
узле, стены короче 20 см или собственной толщины, узлы ближе 5 см и
комнаты без внутреннего просвета. Длина меряется по всей стене, поэтому
короткий доборный участок, компенсирующий перепад толщин, остаётся законным
([#329](https://github.com/Matysh/houseplan-card/issues/329)).
## v1.68.1 — 2026-08-27
+25
View File
@@ -429,3 +429,28 @@ operational Store — for example after downgrade, an old writer or an interrupt
pair — all off bits are cleared rather than resurrected against unknown marker
lifecycle history. Older integrations safely ignore the separate Store on
downgrade.
## Wall junction limits (#329)
Junction limits (minimum 15° between neighbouring walls of one node, at most
six walls per node, a wall at least 20 cm long and never shorter than its own
thickness, 5 cm between non-incident nodes and node to foreign wall, at least
25 cm² of room interior left after the masonry) are a WRITE contract, not a
document contract. An existing plan that violates them stays valid and stays
readable: migration to model v9, JSON import, backup restore and full/space
transfer never run the check, and an edit that does not touch the offending
element still saves.
The gate compares the candidate against the pre-edit document **after both
have gone through the same `commitWallSegmentModel` migration**, and counts
violations **per rule**, not per subject id — a structural write re-keys
contour atoms, so subject identity is not stable across the barrier. Only a
rule whose violation count grows is a refusal.
Compatibility matrix:
| Frontend | Backend | Behaviour |
|---|---|---|
| old | new | No change: the limits live in the card's write barrier, the backend contract is untouched |
| new | old | No change: an inherited violation is never re-judged, so an old backend's documents keep loading and editing |
| new | new | A write that ADDS a violation is refused in the surface where it was made — a toast naming the rule for drawing and Thickness, a stopped wall for Resize |
+15
View File
@@ -34,6 +34,21 @@
## Stable wall-segment identity (#282)
- [ ] Wall junction limits (#329): drawing refuses an apex under 15°, a seventh
wall in one node, a wall shorter than 20 cm or than its own thickness,
nodes closer than 5 cm and a room with under 25 cm² of interior, each
through the surface's own channel — a toast naming the rule for drawing
and Thickness, a stopped wall for Resize. A T-joint stays legal, a short
filler atom compensating a thickness step stays legal (length is measured
along the collinear same-thickness wall run), and an inherited violation
never blocks an unrelated edit
[unit: junction-limits; auto: smoke_junction_limits, smoke_island_rooms;
mutants: junction-limit-angle-not-enforced,
junction-limit-write-gate-removed, degenerate-apex-bevelled-again].
- [ ] A degenerate sharp apex renders as ONE point on both faces — no flat
chamfer, no bow-tie fold, no jags between the inner and outer vertex; the
room ring of the #329 fixture triangle has exactly three distinct
vertices [unit: junction-limits §4].
- [ ] Shared fixture `test/fixtures/282-wall-identity-parity.json` produces the
same exact v8 catalog, room references, opening host and draft IDs in
TypeScript and Python.
+25
View File
@@ -382,6 +382,31 @@ wall shows one area badge; a shared wall shows two on opposite sides, each with
a short leader. In a narrow room the area stays visible and may extend outside
the room rather than overlap another area or the room-settings button.
### Wall junction limits
To keep a plan physically meaningful, the editor refuses a write that would
create an impossible junction. The thresholds are absolute — they do not scale
with `cell_cm`:
| Rule | Threshold |
|---|---|
| Angle between neighbouring walls of one node | at least 15° |
| Walls meeting in one node | at most 6 |
| Wall length | at least 20 cm and never below its own thickness |
| Distance between non-incident nodes, and node to foreign wall | at least 5 cm |
| Room interior left after subtracting the masonry | at least 25 cm² |
Length is measured along the WALL, not along a single contour piece: a short
filler segment that compensates a thickness step is legal as long as the whole
wall is longer than 20 cm. A T-joint (a wall end landing on the middle of
another wall) is not forbidden by the distance rule.
The check runs on writes only. An already saved plan is never re-judged:
migration, import and backup restore are never blocked, and an edit that does
not touch the offending element passes as usual. The refusal appears where you
work: drawing and Thickness raise a toast naming the rule, while Resize stops
the wall at the last allowed position.
### HA area binding
One HA area may be bound to one room. The binding drives automatic device
+50
View File
@@ -417,6 +417,56 @@ Undo оптимизации.
`pointercancel` ничего не завершают и не добавляют. После перезагрузки
сохранённый draft можно продолжить кликом по его концу.
### Ограничения стыков стен
Чтобы план оставался физически осмысленным, редактор отказывает в записи,
которая создала бы невозможный стык. Пороги абсолютные — они не зависят от
масштаба `cell_cm`:
| Правило | Порог |
|---|---|
| Угол между соседними стенами одного узла | не меньше 15° |
| Стен в одном узле | не больше 6 |
| Длина стены | не меньше 20 см и не меньше собственной толщины |
| Расстояние между несмежными узлами и от узла до чужой стены | не меньше 5 см |
| Внутренний просвет комнаты после вычета кладки | не меньше 25 см² |
Длина меряется по СТЕНЕ, а не по отдельному участку контура: короткий
доборный отрезок, компенсирующий перепад толщин соседних стен, законен, если
стена целиком длиннее 20 см. Т-стык (конец стены упирается в середину чужой)
правилом дистанции не запрещён.
Проверка работает только на записи. Уже сохранённый план не переоценивается:
миграция, импорт и восстановление из бэкапа не блокируются никогда, а правка,
не трогающая нарушающий элемент, проходит как обычно. Отказ виден там, где вы
работаете: рисование и «Толщина» показывают тост с названием правила, а
Resize останавливает стену в последней разрешённой позиции.
### Ограничения стыков стен
Чтобы план оставался физически осмысленным, редактор отказывает в записи,
которая создала бы невозможный стык. Пороги абсолютные — они не зависят от
масштаба `cell_cm`:
| Правило | Порог |
|---|---|
| Угол между соседними стенами одного узла | не меньше 15° |
| Стен в одном узле | не больше 6 |
| Длина стены | не меньше 20 см и не меньше собственной толщины |
| Расстояние между несмежными узлами и от узла до чужой стены | не меньше 5 см |
| Внутренний просвет комнаты после вычета кладки | не меньше 25 см² |
Длина меряется по СТЕНЕ, а не по отдельному участку контура: короткий
доборный отрезок, компенсирующий перепад толщин соседних стен, законен, если
стена целиком длиннее 20 см. Т-стык (конец стены упирается в середину чужой)
правилом дистанции не запрещён.
Проверка работает только на записи. Уже сохранённый план не переоценивается:
миграция, импорт и восстановление из бэкапа не блокируются никогда, а правка,
не трогающая нарушающий элемент, проходит как обычно. Отказ виден там, где вы
работаете: рисование и «Толщина» показывают тост с названием правила, а
Resize останавливает стену в последней разрешённой позиции.
### Связь с HA-зоной
Одна HA-зона может быть назначена только одной комнате. Связь нужна для автоматического размещения устройств, LQI, света и средних климатических показателей. Комната без HA-зоны всё равно может иметь вручную назначенные устройства и отдельные источники температуры/влажности.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 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: 42 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 327 KiB

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 289 KiB

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 291 KiB

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 141 KiB

+21 -21
View File
@@ -2,7 +2,7 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "41564f7e31f09f936503145b1d660462ee0bc4d757afaefc03d474766192c5e6",
"sourceFingerprint": "d7dbfe2be5a7f7d20a59f281ad796428717854c7fcf8972568826c79f206cb99",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,8 +14,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "41564f7e31f09f936503145b1d660462ee0bc4d757afaefc03d474766192c5e6",
"imageSha256": "36223106c073f07d8cc3ecf8eaab37192ebb2687daba65c5c21047d0b7890de0"
"sourceSha256": "d7dbfe2be5a7f7d20a59f281ad796428717854c7fcf8972568826c79f206cb99",
"imageSha256": "0ca982495a7749907c48ebbddfd089c54385a4347f79f64e94af2c5a8d5c03eb"
},
"view-touch": {
"file": "02-view-touch.png",
@@ -25,8 +25,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "41564f7e31f09f936503145b1d660462ee0bc4d757afaefc03d474766192c5e6",
"imageSha256": "41e3ba67f8db0e98f26f484293af83ef937c369ca5ca6a59a3350d8954c906f4"
"sourceSha256": "d7dbfe2be5a7f7d20a59f281ad796428717854c7fcf8972568826c79f206cb99",
"imageSha256": "1c8e28ff34736b936ba7c9ec047d1da27aaf6fbb01411155f2d5c05282c02c41"
},
"space-create": {
"file": "03-space-create.png",
@@ -36,8 +36,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "41564f7e31f09f936503145b1d660462ee0bc4d757afaefc03d474766192c5e6",
"imageSha256": "0dc024726327f60f4a9ceaf3044381691f81f1090af81d1812e870f22d9343ba"
"sourceSha256": "d7dbfe2be5a7f7d20a59f281ad796428717854c7fcf8972568826c79f206cb99",
"imageSha256": "a55b463055a565892ca139014e314a8f63fb5301c87fff2e48d7e69210fe9b6c"
},
"room-contour-close": {
"file": "04-room-contour-close.png",
@@ -47,8 +47,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "41564f7e31f09f936503145b1d660462ee0bc4d757afaefc03d474766192c5e6",
"imageSha256": "44cfaa95bd51e2cd628400e28db0ad8b2f0cd904385845bd402494f3f5c0d93c"
"sourceSha256": "d7dbfe2be5a7f7d20a59f281ad796428717854c7fcf8972568826c79f206cb99",
"imageSha256": "dfad9a27371cfa397a3fd8dce7005f42d24649743f5322105ee894067d3a9f16"
},
"plan-context-tray": {
"file": "05-plan-context-tray.png",
@@ -58,8 +58,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "41564f7e31f09f936503145b1d660462ee0bc4d757afaefc03d474766192c5e6",
"imageSha256": "e0662429b423fb74151b583dcc2c8635b001b637d03bbf7a6b16aec46399c3f8"
"sourceSha256": "d7dbfe2be5a7f7d20a59f281ad796428717854c7fcf8972568826c79f206cb99",
"imageSha256": "b75f348c632296da5c2fc142d53df613f757e1516acac315be50f5b4557729cc"
},
"device-editor": {
"file": "06-device-editor.png",
@@ -69,8 +69,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "41564f7e31f09f936503145b1d660462ee0bc4d757afaefc03d474766192c5e6",
"imageSha256": "d0ffd31ce80bfde21ab75da356a5fc1af38246f2b301030880320620c228d89d"
"sourceSha256": "d7dbfe2be5a7f7d20a59f281ad796428717854c7fcf8972568826c79f206cb99",
"imageSha256": "6b9cd281450e7782cff292f11efc58d227bedd0df86afc3be987feb2edec9dc3"
},
"device-display-preview": {
"file": "06-device-display-preview.png",
@@ -80,8 +80,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "41564f7e31f09f936503145b1d660462ee0bc4d757afaefc03d474766192c5e6",
"imageSha256": "2cdabae1f89c3286e4fac0ce30f757ee1690b707ab8a5488748b7cd420626160"
"sourceSha256": "d7dbfe2be5a7f7d20a59f281ad796428717854c7fcf8972568826c79f206cb99",
"imageSha256": "6f628abe6b89d7c4b352e6d73ac0b126526f6fdba5927bb774c5b884e484ca19"
},
"background-editor": {
"file": "07-background-editor.png",
@@ -91,8 +91,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "41564f7e31f09f936503145b1d660462ee0bc4d757afaefc03d474766192c5e6",
"imageSha256": "30147bb00a90eea7136b4cee30995f6e6a9217b5132f3e8d3ad7471413b1af8a"
"sourceSha256": "d7dbfe2be5a7f7d20a59f281ad796428717854c7fcf8972568826c79f206cb99",
"imageSha256": "a1d1a905f1410f73231d2ea5d1dfbd8ae79c2fe8b0a79cff87ebb977ac7a3103"
},
"room-card": {
"file": "08-room-card.png",
@@ -102,8 +102,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "41564f7e31f09f936503145b1d660462ee0bc4d757afaefc03d474766192c5e6",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
"sourceSha256": "d7dbfe2be5a7f7d20a59f281ad796428717854c7fcf8972568826c79f206cb99",
"imageSha256": "48a5685039a5e0de2a28190d857b193c86e2e63606e3c7a8c40fc9d2b190da4e"
},
"device-info": {
"file": "09-device-info.png",
@@ -113,8 +113,8 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "41564f7e31f09f936503145b1d660462ee0bc4d757afaefc03d474766192c5e6",
"imageSha256": "dd492f53150b7149085daada5cce9eeae9bde9e7ea1d86679a54b3041f72f517"
"sourceSha256": "d7dbfe2be5a7f7d20a59f281ad796428717854c7fcf8972568826c79f206cb99",
"imageSha256": "29a342811aaedaebc54ba337792d60cb454a7cf4c3063b210f6c4bafe0a4d87b"
}
}
}
+14 -3
View File
@@ -1,6 +1,6 @@
# Issue #329 — ограничения стыков стен и честная острая вершина
Статус: ревизия 5 (r1: M1/M2/L1; r2: M1 — AC7; поправки владельца 2026-08-27: §4 — острая вершина без фаски и без зазубрин на гранях). Решения владельца (чат, 2026-08-27, зафиксированы в issue):
Статус: ревизия 6 (r1: M1/M2/L1; r2: M1 — AC7; поправки владельца 2026-08-27: §4 — острая вершина без фаски и без зазубрин на гранях; П3 меряет СТЕНУ, а не атом — кейс компенсации перепада толщин). Решения владельца (чат, 2026-08-27, зафиксированы в issue):
пять ограничений приняты; проверки действуют на записи, легаси читается как
есть; для остаточных острых вершин легаси-планов рендер закрывает остриё
честной фаской вместо «трезубца».
@@ -41,8 +41,16 @@
узла ≥ **15°**. Считается по осям сегментов, для каждого узла кандидата.
- **П2. Валентность узла:** в одном узле сходятся ≤ **6** стен (контурные
атомы, независимые стены и перегородки считаются вместе).
- **П3. Минимальная длина сегмента:** длина ≥ **max(20 см, толщина
сегмента)**. Для нулевых стен (#306) — ≥ 20 см.
- **П3. Минимальная длина стены:** длина ≥ **max(20 см, толщина
сегмента)**. Для нулевых стен (#306) — ≥ 20 см. Меряется НЕ отдельный атом
каталога, а вся стена: максимальный коллинеарный (±1°) прогон соседних
атомов ОДНОЙ толщины, склеенных через общие узлы. Иначе правило запрещало
бы законный приём — короткий доборный участок, компенсирующий перепад
толщин соседних стен (владелец, 2026-08-27, фикстура
`houseplan-space-2-2026-08-27_18-24-23.json`: атомы 5 см = (30−20)/2 и
14 см, каждый — продолжение стены той же толщины в 349 см). Такой атом
короче 20 см законен ровно потому, что стена, частью которой он является,
длиннее 20 см.
- **П4. Минимальная дистанция:** несмежные узлы — не ближе **5 см**; узел —
не ближе 5 см к стене, которой он не принадлежит (касание конца о середину
чужой стены остаётся T-стыком и валидно — правило про «почти-касание» без
@@ -135,6 +143,9 @@ margin): правила П1–П5 применяются к элементам,
- **AC2 (П2).** Седьмая стена в узел — отказ; шесть — проходит.
- **AC3 (П3).** Сегмент 19 см — отказ; 20 см — проходит; сегмент 25 см при
толщине 30 см — отказ (длина < толщины).
- **AC3b (П3, компенсация перепада толщин).** Атом 5 см, коллинеарный
продолжению стены той же толщины, проходит: правило меряет прогон, а не
атом. Проверяется юнитом на фикстуре владельца — 0 нарушений П3.
- **AC4 (П4).** Узел в 4 см от несмежного узла или чужой стены — отказ; 5 см
— проходит; T-стык (конец на чужой стене) — проходит.
- **AC5a (сквозной сценарий фикстуры).** Повторение «шпиля» из фикстуры
+43 -1
View File
@@ -537,6 +537,43 @@ export const MUTANTS = [
replace: ' // mutant: pointer capture removed\n const plan = resolution.plan;',
}],
},
{
id: 'junction-limit-angle-not-enforced',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="П1" test/junction-limits.test.mjs',
because: 'the owner-approved 15° minimum between neighbouring walls is a hard limit; '
+ 'lowering it silently lets the trident apex of #329 be drawn again',
patches: [{
file: 'src/junction-limits.ts',
find: 'export const MIN_JUNCTION_ANGLE_DEG = 15;',
replace: 'export const MIN_JUNCTION_ANGLE_DEG = 0;',
}],
},
{
id: 'junction-limit-write-gate-removed',
guard: 'node demo/smoke_junction_limits.mjs',
because: 'the limits must refuse the WRITE, not merely report it — without the barrier '
+ 'an impossible junction reaches the saved document (#329 §2)',
patches: [{
file: 'src/houseplan-card.ts',
find: ' if (introduced.length) {\n this._clearGeometryGesture();',
replace: ' if (false && introduced.length) {\n this._clearGeometryGesture();',
}],
},
{
id: 'degenerate-apex-bevelled-again',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+ '&& node --test --test-name-pattern="§4" test/junction-limits.test.mjs',
because: 'a sharp apex must end in ONE point on both faces; the two-point bevel is the '
+ 'flat cut the owner rejected and the bow-tie fold that carved the jags (#329 §4)',
patches: [{
file: 'src/wall-thickness.ts',
find: ' if (isDegenerateApexCorner(poly, offsets, i)) {\n'
+ ' out.push([poly[i][0], poly[i][1]]);\n'
+ ' continue;\n }',
replace: ' // mutant: degenerate apex falls back to the bevel',
}],
},
{
id: 'resize-preview-reject-silent',
guard: 'node demo/smoke_room_resize.mjs',
@@ -544,7 +581,12 @@ export const MUTANTS = [
+ 'stopped instead of restoring the original silent no-op (#293)',
patches: [{
file: 'src/houseplan-card.ts',
find: " this._showToast(this._t('resize.preview_failed'));",
find: [
' this._showToast(this._rszLimitViolation',
' ? `${this._t(\'resize.limit_stopped\')} — `',
' + this._junctionLimitLabel(this._rszLimitViolation)',
" : this._t('resize.preview_failed'));",
].join('\n'),
replace: ' // mutant: reject remains silent',
}],
},
+28 -1
View File
@@ -1683,6 +1683,8 @@ class HouseplanCard extends LitElement {
private _resize = new ResizeController<
ResizePreview, ResizeLiveLabel[], SpaceGeometryState, ResizeWallUnion, ResizeWallArtifact
>();
/** #329 AC7a: the limit the LAST projection broke, or null if it broke none. */
private _rszLimitViolation: JunctionLimitViolation | null = null;
private _path: number[][] = []; // current outline (render units, vertices snapped to the grid)
private _cursorPt: number[] | null = null;
private _planSnapHover: {
@@ -8917,6 +8919,9 @@ class HouseplanCard extends LitElement {
changedRoomIds: readonly string[],
sourceRooms: readonly { id: string; poly: number[][]; wall_ids?: string[] }[],
): ResizeProjectionResult<ResizePreview, ResizeWallArtifact> {
// #329 AC7a: only the LAST projection may explain the refusal, so the
// previous verdict never leaks into a rejection of another kind.
this._rszLimitViolation = null;
const real = this._serverCfg?.spaces.find((s: any) => s.id === this._space);
if (!real || !this._serverCfg) return { ok: false, reason: 'missing-context' };
const s = JSON.parse(snapshot); // fresh deep copies every move — free to mutate
@@ -9024,6 +9029,22 @@ class HouseplanCard extends LitElement {
if (!preflight.ok) {
return { ok: false, reason: 'physical-geometry' };
}
// #329 AC7a: a step that would ADD a junction-limit violation is never
// projected, so the drag stops at the last allowed position instead of
// committing an impossible plan.
const limitCandidate = {
...this._serverCfg,
spaces: (this._serverCfg?.spaces || []).map(
(space: any) => (space?.id === this._space ? sp : space),
),
};
const limited = this._junctionLimitsIntroduced(
limitCandidate, this._serverCfg, this._space,
);
if (limited.length) {
this._rszLimitViolation = limited[0];
return { ok: false, reason: 'junction-limit' };
}
return {
ok: true,
value: {
@@ -9160,7 +9181,13 @@ class HouseplanCard extends LitElement {
// the pointer visibly stops there and pointerup can commit only that
// already-rendered safe candidate.
if (result.notify) {
this._showToast(this._t('resize.preview_failed'));
// #329 AC7a: a step stopped by a junction limit names THAT rule — the
// generic "geometry cannot be saved" wording would hide which limit
// the wall ran into.
this._showToast(this._rszLimitViolation
? `${this._t('resize.limit_stopped')} — `
+ this._junctionLimitLabel(this._rszLimitViolation)
: this._t('resize.preview_failed'));
}
this.requestUpdate();
return;
+1
View File
@@ -92,6 +92,7 @@
"resize.disabled.thickness-conflict": "The wall thickness cannot be preserved safely",
"resize.disabled.opening-conflict": "An opening on this wall cannot be moved safely",
"resize.disabled.invalid-geometry": "This wall cannot be resized without changing the plan structure",
"resize.limit_stopped": "Wall stopped: the next step would break a junction limit",
"resize.commit_failed": "Resize was cancelled because the final plan did not pass the geometry check",
"resize.preview_failed": "Resize stopped at the last safe position because the plan geometry could not be preserved",
"markup.opening": "Opening",
+1
View File
@@ -92,6 +92,7 @@
"resize.disabled.thickness-conflict": "Толщину стены нельзя безопасно сохранить",
"resize.disabled.opening-conflict": "Проём на этой стене нельзя безопасно переместить",
"resize.disabled.invalid-geometry": "Эту стену нельзя переместить без изменения структуры плана",
"resize.limit_stopped": "Стена остановлена: дальше нарушается ограничение стыков",
"resize.commit_failed": "Изменение размера отменено: итоговый план не прошёл проверку геометрии",
"resize.preview_failed": "Стена остановлена в последней безопасной позиции: геометрию плана нельзя сохранить без потерь",
"markup.opening": "Проём",
+54 -3
View File
@@ -96,7 +96,57 @@ export function checkNodes(
return violations;
}
/** П3: a segment is at least 20 cm and never shorter than its own thickness. */
/** Direction of a segment normalised to [0, 180). */
const axisDegrees = (segment: LimitSegment): number => {
const degrees = (Math.atan2(segment.b[1] - segment.a[1], segment.b[0] - segment.a[0])
* 180) / Math.PI;
return ((degrees % 180) + 180) % 180;
};
const collinear = (left: LimitSegment, right: LimitSegment, toleranceDeg = 1): boolean => {
const delta = Math.abs(axisDegrees(left) - axisDegrees(right));
return Math.min(delta, 180 - delta) <= toleranceDeg;
};
/**
* Length of the whole WALL a segment belongs to, not of the atom.
*
* The model splits a straight wall into atoms at every junction, so a plain
* run picks up short pieces that no one drew: where a 30 cm wall meets a
* 20 cm one, atomisation leaves a (30−20)/2 = 5 cm piece that compensates the
* thickness step (owner report 2026-08-27). Those pieces are collinear
* continuations of the same wall at the same thickness, so П3 measures the
* maximal collinear chain through the segment's nodes.
*/
export function collinearRunLengthUnits(
segment: LimitSegment, segments: readonly LimitSegment[],
): number {
const usable = usableSegments(segments);
const byNode = new Map<string, LimitSegment[]>();
for (const item of usable) {
for (const point of [item.a, item.b]) {
byNode.set(key(point), [...(byNode.get(key(point)) || []), item]);
}
}
const visited = new Set<LimitSegment>([segment]);
let total = length(segment.a, segment.b);
const walk = (from: LimitSegment, node: number[]): void => {
const next = (byNode.get(key(node)) || []).find((candidate) => (
!visited.has(candidate)
&& collinear(candidate, from)
&& Number(candidate.cm || 0) === Number(from.cm || 0)
));
if (!next) return;
visited.add(next);
total += length(next.a, next.b);
walk(next, key(next.a) === key(node) ? next.b : next.a);
};
walk(segment, segment.a);
walk(segment, segment.b);
return total;
}
/** П3: a wall is at least 20 cm and never shorter than its own thickness. */
export function checkSegmentLengths(
segments: readonly LimitSegment[],
cellCm: number,
@@ -104,8 +154,9 @@ export function checkSegmentLengths(
{ minLengthCm = MIN_SEGMENT_LENGTH_CM } = {},
): JunctionLimitViolation[] {
const violations: JunctionLimitViolation[] = [];
for (const segment of usableSegments(segments)) {
const units = length(segment.a, segment.b);
const usable = usableSegments(segments);
for (const segment of usable) {
const units = collinearRunLengthUnits(segment, usable);
const cm = (units / gridPitch) * (cellCm || 1);
const limit = Math.max(minLengthCm, Number(segment.cm) > 0 ? Number(segment.cm) : 0);
if (cm < limit - 1e-9) {
+4 -1
View File
@@ -24,7 +24,10 @@ export interface ResizeProjection<TPreview, TArtifact> {
export type ResizeProjectionResult<TPreview, TArtifact> =
| { ok: true; value: ResizeProjection<TPreview, TArtifact> }
| { ok: false; reason: 'missing-context' | 'wall-metadata' | 'physical-geometry' };
| { ok: false;
// 'junction-limit' is #329: the step would ADD a wall-junction violation.
reason: 'missing-context' | 'wall-metadata' | 'physical-geometry'
| 'junction-limit' };
export type ResizeMoveOutcome<TPreview, TLabels, TArtifact> =
| { kind: 'accepted'; preview: TPreview; labels: TLabels; artifact: TArtifact | null }
+31
View File
@@ -185,3 +185,34 @@ test('§4: у вырожденной вершины грани прямые —
assert.ok(side > cm(7.5), `сторона ${index} не вырождена: ${side}`);
}
});
test('П3 меряет стену, а не атом: компенсация перепада толщин законна', async () => {
const { collinearRunLengthUnits } = await import('../test-build/junction-limits.js');
// Репорт владельца 2026-08-27: на стыке 30 см и 20 см атомизация оставляет
// кусок (30−20)/2 = 5 см — коллинеарное продолжение той же стены.
const run = [
{ id: 'long', a: [0, 0], b: [0, cm(349)], cm: 30 },
{ id: 'step', a: [0, cm(349)], b: [0, cm(354)], cm: 30 },
{ id: 'cross', a: [0, cm(354)], b: [cm(482), cm(354)], cm: 20 },
];
assert.deepEqual(checkSegmentLengths(run, CELL, PITCH), [],
'короткий атом внутри прямого прогона не нарушение');
const runLength = collinearRunLengthUnits(run[1], run);
assert.ok(Math.abs((runLength / PITCH) * CELL - 354) < 1e-6, 'меряется весь прогон');
// Одинокая короткая стена по-прежнему отклоняется: коллинеарного
// продолжения нет.
const lonely = [
{ id: 'a', a: [0, 0], b: [cm(10), 0], cm: 15 },
{ id: 'b', a: [cm(10), 0], b: [cm(10), cm(10)], cm: 15 },
];
assert.equal(checkSegmentLengths(lonely, CELL, PITCH).length, 2);
// Продолжение ДРУГОЙ толщины прогоном не считается.
const mixed = [
{ id: 'thin', a: [0, 0], b: [0, cm(10)], cm: 15 },
{ id: 'thick', a: [0, cm(10)], b: [0, cm(400)], cm: 40 },
];
assert.ok(checkSegmentLengths(mixed, CELL, PITCH)
.some((item) => item.subject === 'thin'));
});