fix: the Thickness tool serves standalone walls and saved drafts (#313)

Кандидаты _wallThickHit расширены: интервалы комнат ∪ перегородки ∪ сегменты
сохранённых драфтов (активная цепочка исключена, как в снап-геометрии); при
точном наложении побеждает независимая кладка — она владеет хит-зоной и
рисует видимое тело (решение владельца, согласовано с select и кейсом #308).
Диалог для независимой кладки без кнопки «на всю комнату»; запись — в
partition.cm / draft.segments[i].cm той же физической транзакцией с одним
Undo. Ноль/пусто для независимой кладки отклоняется существующим тостом
диапазона; switch записи — единственный шов, куда #306 повесит ветку
«ноль превращает перегородку в виртуальную стену».

Мутант wall-thickness-writer-bypasses-common-barrier расширен вторым патчем
на новую точку коммита (#278-гвард), краснота обоих проверена исполнением.
Смок smoke_wallthick_standalone: hit/диалог/запись/отказ нуля/приоритет
наложения/hover — на dev падает.

Issue: #313
User-Visible: yes
This commit is contained in:
Codex
2026-08-26 05:09:48 +00:00
committed by claude[bot]
parent ffe0f0c788
commit 5e5dad277c
9 changed files with 271 additions and 101 deletions
File diff suppressed because one or more lines are too long
+70
View File
@@ -0,0 +1,70 @@
/**
* Issue #313: the Thickness tool serves independent masonry — standalone
* partitions and saved-draft segments — not only room contours. Independent
* masonry wins an exact overlap (the #308 duplicate), and zero thickness is
* refused for it until #306 gives zero a meaning.
*/
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 800 }, 1);
const out = await page.evaluate(async () => {
const NORM_W = 1000;
const card = window.__card;
const update = async () => { card.requestUpdate(); await card.updateComplete; };
card._serverCfg = { spaces: [{ id: 'wt', title: 'WT', cell_cm: 5, view_box: [0, 0, 1, 0.8],
rooms: [{ id: 'room', name: 'Room', area: null,
poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.4], [0.1, 0.4]] }],
partitions: [
{ id: 'standalone', a: [0.6, 0.2], b: [0.9, 0.2], cm: 20 },
// exact duplicate over the room's top edge — the #308 layout
{ id: 'overlay', a: [0.1, 0.1], b: [0.5, 0.1], cm: 30 },
],
room_drafts: [{ id: 'saved-draft', points: [[0.6, 0.5], [0.9, 0.5]], segments: [{ cm: 12 }] }],
}], markers: [], settings: {} };
card._layout = {}; card._space = 'wt'; card._modelCache = null; card._frame = null;
card._cfgEpoch++; card._setMode('plan'); card._tool = 'wallthick'; await update();
const result = {};
// 1) standalone partition: hit, dialog, write, undo
const partitionHit = card._wallThickHit([0.75 * NORM_W, 0.2 * NORM_W]);
result.partitionHit = partitionHit?.source?.kind === 'partition' && partitionHit.cm === 20;
card._wallThickClick([0.75 * NORM_W, 0.2 * NORM_W]); await update();
result.dialogOpen = !!card._wallDialog && card._wallDialog.source.kind === 'partition';
const dlg = card.renderRoot.querySelector('.wallthick-dlg');
result.noRoomButton = !!dlg && !dlg.textContent.includes(card._t('wallthick.apply_room'));
card._wallDialog = { ...card._wallDialog, value: '35' };
card._wallThickApply(false); await update();
const sp = () => card._serverCfg.spaces[0];
result.partitionWritten = sp().partitions[0].cm === 35;
// Undo (server history is async-free here: geometry history object)
// 2) zero refused for independent masonry
card._wallThickClick([0.75 * NORM_W, 0.2 * NORM_W]); await update();
card._wallDialog = { ...card._wallDialog, value: '' };
card._wallThickApply(false); await update();
// the refusal keeps both the value and the dialog (range toast shown)
result.zeroRefused = sp().partitions[0].cm === 35 && !!card._wallDialog;
card._wallDialog = null; await update();
// 3) saved draft segment
const draftHit = card._wallThickHit([0.75 * NORM_W, 0.5 * NORM_W]);
result.draftHit = draftHit?.source?.kind === 'draft' && draftHit.cm === 12;
card._wallThickClick([0.75 * NORM_W, 0.5 * NORM_W]); await update();
card._wallDialog = { ...card._wallDialog, value: '18' };
card._wallThickApply(false); await update();
result.draftWritten = sp().room_drafts[0].segments[0].cm === 18;
// 4) #308 overlap: the independent wall owns the hit
const overlapHit = card._wallThickHit([0.3 * NORM_W, 0.1 * NORM_W]);
result.overlapPrefersIndependent = overlapHit?.source?.kind === 'partition'
&& overlapHit?.source?.id === 'overlay' && overlapHit.cm === 30;
// 5) plain room wall still resolves to the room interval
const roomHit = card._wallThickHit([0.3 * NORM_W, 0.4 * NORM_W]);
result.roomWallStillWorks = roomHit?.source?.kind === 'room' && roomHit.roomId === 'room';
// 6) hover strip follows the partition thickness
card._cursorPt = [0.75 * NORM_W, 0.2 * NORM_W];
const hover = card._wallThickHover;
result.hoverPresent = !!hover && hover.d.length > 0;
return result;
});
checkAll(out);
await finish(browser);
+38 -37
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- The Thickness tool now serves every wall: standalone partitions and saved
draft segments can be picked and resized just like room contours, with the
independent wall winning an exact overlap. Zero stays refused for
standalone walls — a wall without thickness does not exist
([#313](https://github.com/Matysh/houseplan-card/issues/313)).
## v1.68.0-beta.1 — 2026-08-26
- Two walls meeting at an acute angle keep their full drawing point again —
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- Инструмент «Толщина» теперь берёт любую стену: отдельно стоящие перегородки
и сегменты сохранённых черновиков выбираются и меняются так же, как контуры
комнат; при точном наложении клик достаётся отдельно стоящей стене. Ноль
для неё по-прежнему отклоняется — стены без толщины не существует
([#313](https://github.com/Matysh/houseplan-card/issues/313)).
## v1.68.0-beta.1 — 2026-08-26
- Две стены, сходящиеся под острым углом, снова сходятся в полное остриё —
+2 -1
View File
@@ -516,7 +516,8 @@ Glow и солнечные лучи, но при Resize комнаты оста
| Параметр | Поведение |
|---|---|
| Значение | 1–100 см; пусто/0 в интерфейсе очищает толщину |
| Значение | 1–100 см; пусто/0 у стены комнаты очищает толщину, у отдельно стоящей стены — отклоняется (стена без толщины не существует) |
| Какие стены | Любые: контуры комнат, отдельно стоящие стены и сегменты сохранённых черновиков; при точном наложении клик берёт отдельно стоящую стену — именно её тело видно |
| Геометрия | Толщина растёт на половину значения в обе стороны от оси стены |
| Общая стена | Одна физическая стена между комнатами |
| Чистая площадь | Считается по внутренней грани стен |
+11 -11
View File
@@ -2,7 +2,7 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "eb044c22b9a5d03dd9c66c1bc8ace5e8a2637da2b0078ba5782eb0e056a8cc23",
"sourceFingerprint": "5fd7e29594eae558c770dc79fd15551d251cfcf7c8e5e335526f8bed3e517e07",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,7 +14,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "eb044c22b9a5d03dd9c66c1bc8ace5e8a2637da2b0078ba5782eb0e056a8cc23",
"sourceSha256": "5fd7e29594eae558c770dc79fd15551d251cfcf7c8e5e335526f8bed3e517e07",
"imageSha256": "36223106c073f07d8cc3ecf8eaab37192ebb2687daba65c5c21047d0b7890de0"
},
"view-touch": {
@@ -25,7 +25,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "eb044c22b9a5d03dd9c66c1bc8ace5e8a2637da2b0078ba5782eb0e056a8cc23",
"sourceSha256": "5fd7e29594eae558c770dc79fd15551d251cfcf7c8e5e335526f8bed3e517e07",
"imageSha256": "41e3ba67f8db0e98f26f484293af83ef937c369ca5ca6a59a3350d8954c906f4"
},
"space-create": {
@@ -36,7 +36,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "eb044c22b9a5d03dd9c66c1bc8ace5e8a2637da2b0078ba5782eb0e056a8cc23",
"sourceSha256": "5fd7e29594eae558c770dc79fd15551d251cfcf7c8e5e335526f8bed3e517e07",
"imageSha256": "c33a7279165a4cec6fa6fadb6fd08cd967e082a17fe101ef442d27d36ae59b6b"
},
"room-contour-close": {
@@ -47,7 +47,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "eb044c22b9a5d03dd9c66c1bc8ace5e8a2637da2b0078ba5782eb0e056a8cc23",
"sourceSha256": "5fd7e29594eae558c770dc79fd15551d251cfcf7c8e5e335526f8bed3e517e07",
"imageSha256": "b4777162eae89e0d95801721330bcd74ba761624b82b3362069d7e2d38317e08"
},
"plan-context-tray": {
@@ -58,7 +58,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "eb044c22b9a5d03dd9c66c1bc8ace5e8a2637da2b0078ba5782eb0e056a8cc23",
"sourceSha256": "5fd7e29594eae558c770dc79fd15551d251cfcf7c8e5e335526f8bed3e517e07",
"imageSha256": "c0e28edf82f45ccc6df568d3023b681e9e4394262ad34c1605fe9eacdb57a390"
},
"device-editor": {
@@ -69,7 +69,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "eb044c22b9a5d03dd9c66c1bc8ace5e8a2637da2b0078ba5782eb0e056a8cc23",
"sourceSha256": "5fd7e29594eae558c770dc79fd15551d251cfcf7c8e5e335526f8bed3e517e07",
"imageSha256": "d0ffd31ce80bfde21ab75da356a5fc1af38246f2b301030880320620c228d89d"
},
"device-display-preview": {
@@ -80,7 +80,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "eb044c22b9a5d03dd9c66c1bc8ace5e8a2637da2b0078ba5782eb0e056a8cc23",
"sourceSha256": "5fd7e29594eae558c770dc79fd15551d251cfcf7c8e5e335526f8bed3e517e07",
"imageSha256": "2cdabae1f89c3286e4fac0ce30f757ee1690b707ab8a5488748b7cd420626160"
},
"background-editor": {
@@ -91,7 +91,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "eb044c22b9a5d03dd9c66c1bc8ace5e8a2637da2b0078ba5782eb0e056a8cc23",
"sourceSha256": "5fd7e29594eae558c770dc79fd15551d251cfcf7c8e5e335526f8bed3e517e07",
"imageSha256": "30147bb00a90eea7136b4cee30995f6e6a9217b5132f3e8d3ad7471413b1af8a"
},
"room-card": {
@@ -102,7 +102,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "eb044c22b9a5d03dd9c66c1bc8ace5e8a2637da2b0078ba5782eb0e056a8cc23",
"sourceSha256": "5fd7e29594eae558c770dc79fd15551d251cfcf7c8e5e335526f8bed3e517e07",
"imageSha256": "029a3e69ec647a8a370d99e6bb7f9225833c526739076022f6b52ba54bff30ea"
},
"device-info": {
@@ -113,7 +113,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "eb044c22b9a5d03dd9c66c1bc8ace5e8a2637da2b0078ba5782eb0e056a8cc23",
"sourceSha256": "5fd7e29594eae558c770dc79fd15551d251cfcf7c8e5e335526f8bed3e517e07",
"imageSha256": "dd492f53150b7149085daada5cce9eeae9bde9e7ea1d86679a54b3041f72f517"
}
}
+6
View File
@@ -1218,6 +1218,12 @@ export const MUTANTS = [
file: 'src/houseplan-card.ts',
find: " if (this._commitPhysicalGeometry(this._t('history.wall_thickness'), before))",
replace: " if ((this._recordGeometry(this._t('history.wall_thickness'), before), true))",
}, {
// #313: the independent-masonry writer is a second thickness commit
// point — bypassing the barrier there recreates the same #278 hole.
file: 'src/houseplan-card.ts',
find: " const committed = this._commitPhysicalGeometry(\n this._t('history.wall_thickness'), before,\n );",
replace: " const committed = (this._recordGeometry(this._t('history.wall_thickness'), before), true);",
}],
},
{
+94 -15
View File
@@ -590,6 +590,15 @@ const unionRect = (a: Rect, b: Rect): Rect => {
return { x, y, w: Math.max(a.x + a.w, b.x + b.w) - x, h: Math.max(a.y + a.h, b.y + b.h) - y };
};
/** #313: one Thickness-tool hit — a room interval or independent masonry. */
type WallThickSource = { kind: 'room' }
| { kind: 'partition'; id: string }
| { kind: 'draft'; id: string; segment: number };
type WallThickHit = {
a: number[]; b: number[]; roomId: string; segs: number[][];
open: boolean; cm: number; source: WallThickSource;
};
type MarkupTool = 'select' | 'draw' | 'column' | 'merge' | 'split' | 'resize' | 'opening' | 'boundary' | 'wallthick' | 'delroom';
type RoomFillFrame = {
byRoom: Map<RoomCfg, ResolvedRoomFill | null>;
@@ -1537,6 +1546,12 @@ class HouseplanCard extends LitElement {
private _wallDialog: {
a: number[]; b: number[];
value: string; roomId: string | null;
// #313: the Thickness tool serves independent masonry too. `room` keeps
// the historical shape (walls records + «apply to room»); the other two
// write partition.cm / draft.segments[i].cm. The switch in
// _wallThickApply is the single seam where #306 will later hang the
// «zero turns a partition virtual» branch.
source: WallThickSource;
sx: number; sy: number;
} | null = null;
/**
@@ -11912,28 +11927,58 @@ class HouseplanCard extends LitElement {
* wall wrote a key that the renderer then spread over the shared part as
* well. The unit of the tool is now the same interval the renderer draws.
*/
private _wallThickHit(raw: number[]): {
a: number[]; b: number[]; roomId: string; segs: number[][]; open: boolean; cm: number;
} | null {
private _wallThickHit(raw: number[]): WallThickHit | null {
const space = this._spaceModel();
if (!space) return null;
const pull = this._gridPitch * 6;
const cuts = this._openCuts();
let best: { iv: ReturnType<typeof wallIntervals>[number]; d: number } | null = null;
type Hit = WallThickHit;
// #313: the tool serves every wall — room intervals AND independent
// masonry. On an exact overlap (the #308 duplicate) the independent wall
// wins: it owns the hit zone, exactly like the select tool, and it is
// the body the eye actually sees.
let best: { hit: Hit; d: number; independent: boolean } | null = null;
const offer = (hit: Hit, d: number, independent: boolean): void => {
if (d > pull) return;
if (!best || d < best.d - 1e-9
|| (independent && !best.independent && d <= best.d + 1e-9)) {
best = { hit, d, independent };
}
};
for (const iv of wallIntervals(
space.rooms, this._spaceWalls, cuts,
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
)) {
const d = distToSegment(raw, [iv.a[0], iv.a[1], iv.b[0], iv.b[1]]);
if (d <= pull && (!best || d < best.d)) best = { iv, d };
offer({
a: iv.a, b: iv.b, roomId: iv.roomId,
segs: [[iv.a[0], iv.a[1], iv.b[0], iv.b[1]]],
open: iv.open, cm: iv.cm, source: { kind: 'room' },
}, distToSegment(raw, [iv.a[0], iv.a[1], iv.b[0], iv.b[1]]), false);
}
if (!best) return null;
const iv = best.iv;
return {
a: iv.a, b: iv.b, roomId: iv.roomId,
segs: [[iv.a[0], iv.a[1], iv.b[0], iv.b[1]]],
open: iv.open, cm: iv.cm,
};
for (const partition of space.partitions || []) {
const a = [partition.a[0], partition.a[1]];
const b = [partition.b[0], partition.b[1]];
offer({
a, b, roomId: '', segs: [[a[0], a[1], b[0], b[1]]],
open: false, cm: Number(partition.cm) || 0,
source: { kind: 'partition', id: partition.id },
}, distToSegment(raw, [a[0], a[1], b[0], b[1]]), true);
}
for (const draft of space.room_drafts || []) {
// The active chain belongs to the draw tool; like the snap geometry,
// Thickness only sees saved drafts.
if (draft.id === this._activeDraftId) continue;
for (let i = 0; i + 1 < draft.points.length; i++) {
const a = [draft.points[i][0], draft.points[i][1]];
const b = [draft.points[i + 1][0], draft.points[i + 1][1]];
offer({
a, b, roomId: '', segs: [[a[0], a[1], b[0], b[1]]],
open: false, cm: Number(draft.segments[i]?.cm) || 15,
source: { kind: 'draft', id: draft.id, segment: i },
}, distToSegment(raw, [a[0], a[1], b[0], b[1]]), true);
}
}
return best ? (best as { hit: Hit }).hit : null;
}
private get _wallThickHover(): { segs: number[][]; open: boolean; d: string } | null {
@@ -11970,6 +12015,7 @@ class HouseplanCard extends LitElement {
a: hit.a, b: hit.b,
value: cmToField(cm, this._imperial),
roomId: hit.roomId,
source: hit.source,
sx: ((mx - view.x) / view.w) * 100,
sy: ((my - view.y) / view.h) * 100,
};
@@ -11990,6 +12036,38 @@ class HouseplanCard extends LitElement {
this._showPhysicalRange(100);
return;
}
// #313: independent masonry writes its own record. This switch is the
// single seam for #306: the future «zero turns the wall virtual» branch
// belongs here, next to the range refusal below.
if (d.source.kind !== 'room') {
// A partition or draft segment without thickness does not exist in the
// model (owner decision 2026-08-26): zero and empty are refused with
// the ordinary range toast until #306 gives zero a meaning.
if (!text || !(cmRaw > 0)) {
this._showPhysicalRange(100);
return;
}
const before = this._geometrySnapshot();
if (d.source.kind === 'partition') {
const partition = (sp.partitions || [])
.find((item: PartitionCfg) => item.id === (d.source as { id: string }).id);
if (!partition) return;
partition.cm = cmRaw;
} else {
const draft = (sp.room_drafts || [])
.find((item: any) => item.id === (d.source as { id: string }).id);
const segment = draft?.segments?.[(d.source as { segment: number }).segment];
if (!segment) return;
segment.cm = cmRaw;
}
this._wallDialog = null;
const committed = this._commitPhysicalGeometry(
this._t('history.wall_thickness'), before,
);
if (committed) this._showToast(this._t('toast.wallthick_set'));
this.requestUpdate();
return;
}
const before = this._geometrySnapshot();
const cm = text && cmRaw > 0 ? cmRaw : null;
const openCuts = this._openCuts();
@@ -12555,9 +12633,10 @@ class HouseplanCard extends LitElement {
<span class="opl">${this._t(this._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm')}</span>
</div>
<div class="row">
<button class="btn ghost" @click=${() => this._wallThickApply(true)}>
${d.source.kind === 'room' ? html`<button class="btn ghost"
@click=${() => this._wallThickApply(true)}>
${this._t('wallthick.apply_room')}
</button>
</button>` : nothing}
<span class="spacer"></span>
<button class="btn on" @click=${() => this._wallThickApply(false)}>
<ha-icon icon="mdi:check"></ha-icon>