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
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -11,8 +11,9 @@
|
||||
- Острые углы комнат снова выглядят честно: очень острая вершина
|
||||
распадалась на «трезубец» из пиков и вырезов, а рисование теперь не
|
||||
разрешает саму причину — стены под углом меньше 15°, больше шести стен в
|
||||
узле, участки короче 20 см или собственной толщины, узлы ближе 5 см и
|
||||
комнаты без внутреннего просвета
|
||||
узле, стены короче 20 см или собственной толщины, узлы ближе 5 см и
|
||||
комнаты без внутреннего просвета. Длина меряется по всей стене, поэтому
|
||||
короткий доборный участок, компенсирующий перепад толщин, остаётся законным
|
||||
([#329](https://github.com/Matysh/houseplan-card/issues/329)).
|
||||
|
||||
## v1.68.1 — 2026-08-27
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-зоны всё равно может иметь вручную назначенные устройства и отдельные источники температуры/влажности.
|
||||
|
||||
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 336 KiB After Width: | Height: | Size: 343 KiB |
|
Before Width: | Height: | Size: 327 KiB After Width: | Height: | Size: 334 KiB |
|
Before Width: | Height: | Size: 289 KiB After Width: | Height: | Size: 319 KiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 317 KiB |
|
Before Width: | Height: | Size: 290 KiB After Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 141 KiB |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (сквозной сценарий фикстуры).** Повторение «шпиля» из фикстуры
|
||||
|
||||
@@ -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',
|
||||
}],
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Проём",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
|
||||