mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
v1.59.0-beta.9: fix mixed-wall resize and virtual T-junctions
This commit is contained in:
@@ -45,7 +45,7 @@ PLAN_ORPHAN_TTL_S = 3600
|
||||
SCHEDULED_GRACE_S = 30 * 24 * 3600
|
||||
FILES_DIR = "houseplan/files"
|
||||
CONF_ADMIN_ONLY = "admin_only"
|
||||
VERSION = "1.59.0-beta.8"
|
||||
VERSION = "1.59.0-beta.9"
|
||||
|
||||
DEFAULT_CONFIG: dict = {
|
||||
"spaces": [],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,5 +16,5 @@
|
||||
"issue_tracker": "https://github.com/Matysh/houseplan-card/issues",
|
||||
"requirements": [],
|
||||
"single_config_entry": true,
|
||||
"version": "1.59.0-beta.8"
|
||||
"version": "1.59.0-beta.9"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Regression: resize a shared wall whose middle is virtual and whose solid
|
||||
* remainders carry thickness. The live overlay, commit and Undo must move one
|
||||
* geometry transaction; virtual drawing must paint above the real wall body.
|
||||
*/
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
const { page, browser } = await launch();
|
||||
|
||||
const res = await page.evaluate(async () => {
|
||||
const out = {};
|
||||
const c = window.__card;
|
||||
const sr = () => c.shadowRoot || c.renderRoot;
|
||||
const sp = () => c._serverCfg.spaces.find((s) => s.id === c._space);
|
||||
const upd = async () => { c._cfgEpoch++; c.requestUpdate(); await c.updateComplete; };
|
||||
|
||||
sp().settings = { ...(sp().settings || {}), show_borders: true };
|
||||
delete sp().walls;
|
||||
delete sp().open_spans;
|
||||
delete sp().openings;
|
||||
for (const r of sp().rooms || []) delete r.open_to;
|
||||
|
||||
c._setMode('plan');
|
||||
c._tool = 'wallthick';
|
||||
await upd();
|
||||
c._wallThickClick([550, 250]);
|
||||
await upd();
|
||||
if (c._wallDialog) {
|
||||
c._wallDialog = { ...c._wallDialog, value: '20' };
|
||||
c._wallThickApply(false);
|
||||
}
|
||||
await upd();
|
||||
out.realBodyReady = !!sr().querySelector('.wallbodies');
|
||||
|
||||
// The two-click rubber band used to be below .wallbodies and disappeared
|
||||
// exactly where it crossed the real T receiver.
|
||||
c._tool = 'openwall';
|
||||
c._openWallClick([550, 200]);
|
||||
c._cursorPt = [550, 300];
|
||||
await upd();
|
||||
const bodyGroup = sr().querySelector('.wallbodies');
|
||||
const virtualGroup = sr().querySelector('.openwalls');
|
||||
out.virtualPreviewDrawn = !!sr().querySelector('.openwall-preview');
|
||||
out.virtualPreviewAboveReal = !!bodyGroup && !!virtualGroup
|
||||
&& !!(bodyGroup.compareDocumentPosition(virtualGroup) & Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
|
||||
c._openWallClick([550, 300]);
|
||||
c._cursorPt = null;
|
||||
await upd();
|
||||
out.partialSpanCreated = (sp().open_spans || []).length === 1;
|
||||
out.atomicThicknessBefore = c._intervalCm([550, 145, 550, 195]) === 20
|
||||
&& c._intervalCm([550, 205, 550, 295]) === 0
|
||||
&& c._intervalCm([550, 305, 550, 455]) === 20;
|
||||
|
||||
const before = JSON.stringify({
|
||||
rooms: sp().rooms,
|
||||
walls: sp().walls,
|
||||
open_spans: sp().open_spans,
|
||||
});
|
||||
c._tool = 'resize';
|
||||
await upd();
|
||||
const stage = () => sr().querySelector('.stage');
|
||||
const toScreen = (x, y) => {
|
||||
const r = stage().getBoundingClientRect();
|
||||
const v = c._viewOr(c._baseVb());
|
||||
return {
|
||||
clientX: r.left + ((x - v.x) / v.w) * r.width,
|
||||
clientY: r.top + ((y - v.y) / v.h) * r.height,
|
||||
};
|
||||
};
|
||||
const pev = (type, target, x, y) => {
|
||||
const { clientX, clientY } = toScreen(x, y);
|
||||
target.dispatchEvent(new PointerEvent(type, {
|
||||
bubbles: true, composed: true, cancelable: true,
|
||||
pointerId: 77, clientX, clientY, button: 0, isPrimary: true,
|
||||
}));
|
||||
};
|
||||
const handle = [...sr().querySelectorAll('.rszhandle:not(.rszcorner)')].find((h) => {
|
||||
const x = +h.getAttribute('cx'), y = +h.getAttribute('cy');
|
||||
return Math.abs(x - 550) < c._gridPitch * 2 && y > 140 && y < 460;
|
||||
});
|
||||
out.resizeHandleFound = !!handle;
|
||||
if (handle) {
|
||||
const x = +handle.getAttribute('cx');
|
||||
const y = +handle.getAttribute('cy');
|
||||
const step = c._gridPitch;
|
||||
pev('pointerdown', handle, x, y);
|
||||
pev('pointermove', handle, x + step, y);
|
||||
await c.updateComplete;
|
||||
const liveCut = c._openCuts()[0];
|
||||
const liveX = liveCut?.[0];
|
||||
out.liveSpanMoves = Number.isFinite(liveX) && Math.abs(liveX - (550 + step)) < 0.1;
|
||||
out.liveThicknessStays = c._intervalCm([liveX, 145, liveX, 195]) === 20
|
||||
&& c._intervalCm([liveX, 205, liveX, 295]) === 0
|
||||
&& c._intervalCm([liveX, 305, liveX, 455]) === 20;
|
||||
out.liveBodyStays = !!sr().querySelector('.wallbodies');
|
||||
|
||||
pev('pointerup', handle, x + step, y);
|
||||
await upd();
|
||||
const finalCut = c._openCuts()[0];
|
||||
const finalX = finalCut?.[0];
|
||||
out.commitSpanMoves = Number.isFinite(finalX) && Math.abs(finalX - (550 + step)) < 0.1;
|
||||
out.commitThicknessStays = c._intervalCm([finalX, 145, finalX, 195]) === 20
|
||||
&& c._intervalCm([finalX, 205, finalX, 295]) === 0
|
||||
&& c._intervalCm([finalX, 305, finalX, 455]) === 20;
|
||||
out.commitKeepsAtomicKeys = (sp().walls || []).filter((w) => w.cm === 20).length >= 2;
|
||||
|
||||
c._rszUndoPop();
|
||||
await upd();
|
||||
out.undoRestoresAll = JSON.stringify({
|
||||
rooms: sp().rooms,
|
||||
walls: sp().walls,
|
||||
open_spans: sp().open_spans,
|
||||
}) === before;
|
||||
}
|
||||
|
||||
// The screenshot topology: the two real arms belong to different room
|
||||
// contours and only meet at the endpoint of B↔C's virtual boundary. Their
|
||||
// per-room butt caps must be completed into one clean outer mitre.
|
||||
sp().rooms = [
|
||||
{ id: 'ta', name: 'A', poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]] },
|
||||
{ id: 'tb', name: 'B', poly: [[0.5, 0.5], [0.9, 0.5], [0.9, 0.9], [0.5, 0.9]] },
|
||||
{ id: 'tc', name: 'C', poly: [[0.5, 0.1], [0.9, 0.1], [0.9, 0.5], [0.5, 0.5]] },
|
||||
];
|
||||
delete sp().walls;
|
||||
delete sp().open_spans;
|
||||
c._tool = 'wallthick';
|
||||
await upd();
|
||||
for (const p of [[300, 500], [500, 700]]) {
|
||||
c._wallThickClick(p);
|
||||
await upd();
|
||||
if (c._wallDialog) {
|
||||
c._wallDialog = { ...c._wallDialog, value: '20' };
|
||||
c._wallThickApply(false);
|
||||
await upd();
|
||||
}
|
||||
}
|
||||
sp().open_spans = [{ a: [0.5, 0.5], b: [0.9, 0.5] }];
|
||||
sp().rooms.find((r) => r.id === 'tb').open_to = ['tc'];
|
||||
sp().rooms.find((r) => r.id === 'tc').open_to = ['tb'];
|
||||
await upd();
|
||||
const tPath = sr().querySelector('.wallbody')?.getAttribute('d') || '';
|
||||
const values = (tPath.match(/-?\d+(?:\.\d+)?/g) || []).map(Number);
|
||||
const half = (20 / c._cellCm) * c._gridPitch / 2;
|
||||
out.virtualTJunctionMitred = values.some((x, i) => i % 2 === 0
|
||||
&& Math.abs(x - (500 + half)) < 1e-6
|
||||
&& Math.abs(values[i + 1] - (500 - half)) < 1e-6);
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
File diff suppressed because one or more lines are too long
Vendored
+34
-33
File diff suppressed because one or more lines are too long
@@ -2,6 +2,20 @@
|
||||
|
||||
## Unreleased (dev)
|
||||
|
||||
## v1.59.0-beta.9 — 2026-08-05
|
||||
|
||||
Ninth pre-release of the 1.59 line: mixed virtual/thick resize integrity and
|
||||
clean, visible virtual T-junctions.
|
||||
|
||||
- **Resize keeps mixed virtual/thick walls intact.** A live edge drag now moves
|
||||
`open_spans` and whole/atomic thickness keys together with the room polygons;
|
||||
commit no longer drops the thickness of the solid remainders, and Undo
|
||||
restores the complete transaction.
|
||||
- **Clean virtual T-junctions.** Real wall arms owned by different room
|
||||
contours receive the missing mitre at an open-span endpoint instead of a
|
||||
stepped corner. Virtual dashes and the two-click drawing preview paint above
|
||||
real wall bodies, so they stay visible right up to the junction centreline.
|
||||
|
||||
## v1.59.0-beta.8 — 2026-08-05
|
||||
|
||||
Eighth pre-release of the 1.59 line: beta.7 audit follow-ups, a fail-closed
|
||||
|
||||
@@ -8,6 +8,20 @@
|
||||
|
||||
## Не выпущено (dev)
|
||||
|
||||
## v1.59.0-beta.9 — 2026-08-05
|
||||
|
||||
Девятый pre-release линейки 1.59: целостный resize смешанных
|
||||
виртуальных/толстых стен и чистые, видимые виртуальные Т-стыки.
|
||||
|
||||
- **Resize сохраняет смешанную виртуальную/толстую стену.** Во время перетаскивания
|
||||
`open_spans` и целые/атомарные ключи толщины теперь двигаются вместе с polygon
|
||||
комнат; commit больше не удаляет толщину реальных остатков, а Undo
|
||||
восстанавливает всю транзакцию целиком.
|
||||
- **Чистые Т-стыки с виртуальной стеной.** Реальные плечи, принадлежащие разным
|
||||
контурам комнат, получают недостающий митровый угол у конца `open_span` вместо
|
||||
ступеньки. Пунктир и двухкликовое preview виртуальной стены рисуются поверх
|
||||
реального тела стены и видны до самой осевой линии стыка.
|
||||
|
||||
## v1.59.0-beta.8 — 2026-08-05
|
||||
|
||||
Восьмой pre-release линейки 1.59: правки после аудита beta.7, fail-closed
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.59.0-beta.8** everywhere (manifest, const.py, package.json, CARD_VERSION) — **pre-release**, tag `v1.59.0-beta.8` on **`dev`**, GitHub Release with `prerelease=true`; `main` is not touched and nothing is copied to the home instance by hand — HACS delivers it on the beta channel. On top of beta.7: Split preserves every room-pair piece of `open_spans`, the exact-tag release gate fails closed, smoke/inventory and full/static thin-wall parity are repaired, and sun rays start at the inner corners of thick window openings. Previous pre-release: v1.59.0-beta.7; previous stable: v1.58.0 |
|
||||
| Version | **v1.59.0-beta.9** everywhere (manifest, const.py, package.json, CARD_VERSION) — **pre-release**, tag `v1.59.0-beta.9` on **`dev`**, GitHub Release with `prerelease=true`; `main` is not touched and nothing is copied to the home instance by hand — HACS delivers it on the beta channel. On top of beta.8: resize keeps partial `open_spans` and the atomic thickness keys of their solid remainders together in live preview, commit and Undo; real arms at virtual T-junctions receive the missing mitre, and the virtual dash/preview renders above thick real walls. Previous pre-release: v1.59.0-beta.8; previous stable: v1.58.0 |
|
||||
|
||||
| Workflow | Since 2026-07-22: minor changes go to branch **`dev`** (build + smokes → deploy home → commit → push, NO release); releases are batched on the owner's command. **Since 2026-08-04 there is also a pre-release track:** bump to `X.Y.Z-beta.N`, tag the `dev` commit, publish a GitHub Release with `prerelease=true` — `main` is not touched and nothing is copied to the home instance by hand; HACS delivers it on the beta channel |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — `main` carries stable releases; pre-release tags may point directly at `dev`. Work lands on `dev` and is merged into `main` for a stable release, so `dev` is normally equal to or ahead of `main`, never behind. Push via SSH key `ha_jb` (remote git@github.com:…); API releases via the fine-grained PAT in `~/.git-credentials` (Contents R/W, issued 2026-07-23) |
|
||||
|
||||
+12
-1
@@ -957,6 +957,10 @@ require hands on real hardware — they remain for the human pass.
|
||||
exception to «shared walls together»), growing into one stops
|
||||
- [ ] Ctrl+Z / ⌘Z after releasing a handle restores the previous geometry —
|
||||
one release = one undo step (rooms AND openings)
|
||||
- [ ] A moved shared wall with a partial virtual middle and thick solid
|
||||
remainders keeps the dash and both thickness values during live drag and
|
||||
after release; Undo restores rooms, `open_spans` and `walls` together
|
||||
[auto: smoke_resize_virtual_thick]
|
||||
- [ ] Device markers do not move; the room settings gear re-centres itself
|
||||
- [ ] Smoke: `node demo/smoke_room_resize.mjs`
|
||||
|
||||
@@ -1460,7 +1464,14 @@ require hands on real hardware — they remain for the human pass.
|
||||
[auto: smoke_wall_thickness]
|
||||
- [ ] **Shared once / clear → line / resize re-keys**: one body for a shared
|
||||
wall; clearing thickness restores the centreline; resizing a thick wall
|
||||
keeps the thickness on the moved stretch [auto: smoke_wall_thickness]
|
||||
keeps the thickness on the moved stretch, including both atomic solid
|
||||
remainders around a partial virtual span
|
||||
[auto: smoke_wall_thickness + smoke_resize_virtual_thick]
|
||||
- [ ] **Virtual T-junction**: when two real thick arms from different room
|
||||
contours meet at an `open_span` endpoint, the outside corner is a clean
|
||||
mitre with no stair-step; the saved dash and the two-click rubber band
|
||||
paint above the real hatch right up to the centreline
|
||||
[auto: test/wall-thickness.test.mjs + smoke_resize_virtual_thick]
|
||||
- [ ] **Unit + backend**: inset/mitre/bevel, key from either end, degrade,
|
||||
rekey, cm↔inches; `walls` schema bounds
|
||||
[auto: test/wall-thickness.test.mjs + tests_backend/test_validation.py]
|
||||
|
||||
@@ -44,6 +44,9 @@ joins; bevel when the mitre spike exceeds `MITRE_LIMIT × thickness`.
|
||||
Openings cut the body full-depth; jambs cap the cut; window glass mid-tunnel;
|
||||
door swing from the **inner face**. Association uses wall direction ≈ opening
|
||||
angle (mod 180°), then nearest span — never a perpendicular neighbour at a T.
|
||||
At an `open_span` endpoint, real arms owned by different room contours receive
|
||||
the same bounded mitre patch as arms from one contour; a virtual T therefore
|
||||
has one clean outer corner rather than two butt caps forming a step.
|
||||
|
||||
## 4. Floor, fills, light, area
|
||||
|
||||
@@ -81,7 +84,9 @@ Decor-line thickness, per-side finish, auto-from-backdrop, plan-wide default.
|
||||
## 8. Testing
|
||||
|
||||
Unit: ring closed at corners; half-out; inner area; atomic partial shared;
|
||||
angle-aware opening; rekey after edge/scale. Browser: seamless frame; fill not
|
||||
in hatch; m² drops with thickness; real resize/undo keeps walls; sun starts at
|
||||
the room-side opening corners; nav mode restores after `can_write`; a 1 cm body
|
||||
uses solid-only in both full and static cards while a 20 cm body keeps its hatch.
|
||||
virtual-T mitre; angle-aware opening; whole and atomic rekey after edge/scale.
|
||||
Browser: seamless frame; fill not in hatch; m² drops with thickness; a partial
|
||||
virtual stretch, its solid thick remainders and Undo move as one real resize;
|
||||
the virtual rubber band paints above the real body; sun starts at the room-side
|
||||
opening corners; nav mode restores after `can_write`; a 1 cm body uses
|
||||
solid-only in both full and static cards while a 20 cm body keeps its hatch.
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.59.0-beta.8",
|
||||
"version": "1.59.0-beta.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "houseplan-card",
|
||||
"version": "1.59.0-beta.8",
|
||||
"version": "1.59.0-beta.9",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lit": "^3.1.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "houseplan-card",
|
||||
"version": "1.59.0-beta.8",
|
||||
"version": "1.59.0-beta.9",
|
||||
"description": "Interactive house plan Lovelace card for Home Assistant",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
+54
-27
@@ -85,7 +85,7 @@ import {
|
||||
import { alignAllToGrid, type AlignReport } from './align-grid';
|
||||
import { langOf, t, type I18nKey } from './i18n';
|
||||
|
||||
const CARD_VERSION = '1.59.0-beta.8';
|
||||
const CARD_VERSION = '1.59.0-beta.9';
|
||||
/** HP-1552 boot-veil timing (AUD-1552-02). The veil holds for at least
|
||||
* BOOT_MIN_MS; every stage-height change restarts a BOOT_QUIET_MS
|
||||
* trailing-quiescence requirement (chrome still settling near the cap
|
||||
@@ -3620,7 +3620,14 @@ class HouseplanCard extends LitElement {
|
||||
const real = this._serverCfg?.spaces.find((s: any) => s.id === this._space);
|
||||
if (!g || !real) return;
|
||||
const s = JSON.parse(g.snap); // fresh deep copies every move — free to mutate
|
||||
const sp = { ...real, rooms: s.rooms, openings: s.openings, walls: s.walls };
|
||||
const sp = {
|
||||
...real,
|
||||
rooms: s.rooms,
|
||||
openings: s.openings,
|
||||
walls: s.walls,
|
||||
open_spans: s.open_spans,
|
||||
};
|
||||
if (!Array.isArray(s.open_spans) || !s.open_spans.length) delete (sp as any).open_spans;
|
||||
const H = this._spaceH;
|
||||
for (const [id, poly] of Object.entries(polys)) {
|
||||
const r = sp.rooms.find((x: any) => x.id === id);
|
||||
@@ -3634,6 +3641,36 @@ class HouseplanCard extends LitElement {
|
||||
o.x = c[0] / NORM_W;
|
||||
o.y = c[1] / H;
|
||||
}
|
||||
// Geometry that belongs to a wall must ride in the SAME live overlay as
|
||||
// its room polygons. Map from the immutable snapshot on every move (never
|
||||
// from the previous preview), so partial virtual stretches and the atomic
|
||||
// thickness keys on their solid remainders cannot lag behind or accumulate
|
||||
// rounding error during a long drag.
|
||||
const oldSpans: [number[], number[]][] = [];
|
||||
const newSpans: [number[], number[]][] = [];
|
||||
for (const id of g.changed) {
|
||||
const oldR = g.rooms.find((r) => r.id === id);
|
||||
const nr = sp.rooms.find((x: any) => x.id === id);
|
||||
if (!oldR || !nr?.poly) continue;
|
||||
const newPoly = nr.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H] as number[]);
|
||||
if (oldR.poly.length !== newPoly.length) continue;
|
||||
for (let i = 0; i < oldR.poly.length; i++) {
|
||||
oldSpans.push([oldR.poly[i], oldR.poly[(i + 1) % oldR.poly.length]]);
|
||||
newSpans.push([newPoly[i], newPoly[(i + 1) % newPoly.length]]);
|
||||
}
|
||||
}
|
||||
if (oldSpans.length) {
|
||||
const movedOpen = rekeyOpenSpansAfterMove(
|
||||
sanitizeOpenSpans((sp as any).open_spans), oldSpans, newSpans, NORM_W,
|
||||
);
|
||||
if (movedOpen.length) (sp as any).open_spans = movedOpen;
|
||||
else delete (sp as any).open_spans;
|
||||
if (Array.isArray(sp.walls) && sp.walls.length) {
|
||||
sp.walls = rekeyWallsAfterMove(
|
||||
sp.walls, oldSpans, newSpans, this._wallKeyPitch, NORM_W,
|
||||
);
|
||||
}
|
||||
}
|
||||
this._rszPreview = { space: this._space, sp };
|
||||
this._cfgEpoch++;
|
||||
}
|
||||
@@ -3730,35 +3767,21 @@ class HouseplanCard extends LitElement {
|
||||
if (preview.sp.walls.length) sp.walls = preview.sp.walls;
|
||||
else delete sp.walls;
|
||||
}
|
||||
if (Array.isArray(preview.sp.open_spans) && preview.sp.open_spans.length) {
|
||||
(sp as any).open_spans = preview.sp.open_spans;
|
||||
} else {
|
||||
delete (sp as any).open_spans;
|
||||
}
|
||||
for (const id of g.changed) {
|
||||
const r = sp.rooms.find((x: any) => x.id === id);
|
||||
if (r?.poly) r.poly = simplifyPoly(r.poly, 1e-9);
|
||||
}
|
||||
// ONE geometry transaction (docs/WALL-THICKNESS.md, AUD-159B6-02):
|
||||
// old→new edge pairs are built once, then the open spans move (they
|
||||
// define where a wall is cut into atomic pieces) and only after that the
|
||||
// thickness keys are rewritten and the dead ones dropped.
|
||||
const H = this._spaceH;
|
||||
const oldSpans: [number[], number[]][] = [];
|
||||
const newSpans: [number[], number[]][] = [];
|
||||
for (const id of g.changed) {
|
||||
const oldR = g.rooms.find((r) => r.id === id);
|
||||
const nr = sp.rooms.find((x: any) => x.id === id);
|
||||
if (!oldR || !nr?.poly) continue;
|
||||
const newPoly = nr.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H] as number[]);
|
||||
if (oldR.poly.length !== newPoly.length) continue;
|
||||
for (let i = 0; i < oldR.poly.length; i++) {
|
||||
oldSpans.push([oldR.poly[i], oldR.poly[(i + 1) % oldR.poly.length]]);
|
||||
newSpans.push([newPoly[i], newPoly[(i + 1) % newPoly.length]]);
|
||||
}
|
||||
}
|
||||
this._commitOpenSpans({ old: oldSpans, next: newSpans });
|
||||
// The preview was derived from the immutable snapshot and already moved
|
||||
// both explicit spans and every whole/atomic thickness key. Commit only
|
||||
// clips that geometry to the simplified final rooms, rebuilds open_to,
|
||||
// and drops keys that genuinely no longer name a live interval.
|
||||
this._commitOpenSpans();
|
||||
if (Array.isArray(sp.walls) && sp.walls.length) {
|
||||
if (oldSpans.length) {
|
||||
sp.walls = rekeyWallsAfterMove(
|
||||
sp.walls, oldSpans, newSpans, this._wallKeyPitch, NORM_W,
|
||||
);
|
||||
}
|
||||
sp.walls = degradeWalls(sp.walls, sp.rooms || [], GRID_STEP_N, 1, this._cfgOpenCuts());
|
||||
if (!sp.walls.length) delete sp.walls;
|
||||
}
|
||||
@@ -8097,7 +8120,6 @@ class HouseplanCard extends LitElement {
|
||||
})()}
|
||||
${disp.fill === 'glow' && !this._markup ? this._renderGlowLayer(space) : nothing}
|
||||
${this._renderSunRays(space)}
|
||||
${this._renderOpenWalls(disp)}
|
||||
${this._editing ? this._renderAlignGuides() : nothing}
|
||||
${opMeasure?.guide ? this._renderOpeningCenterTick(opMeasure.guide) : nothing}
|
||||
${this._markup ? this._renderMarkupLayer(vb) : nothing}
|
||||
@@ -8108,6 +8130,11 @@ class HouseplanCard extends LitElement {
|
||||
it, the sun still comes in at its window, and the contact
|
||||
sensor still opens it. */}
|
||||
${this._renderWallBodies(disp)}
|
||||
${''/* Virtual boundaries (including the live two-click preview)
|
||||
deliberately paint AFTER real wall bodies. At a T-junction
|
||||
their first dash must remain visible all the way to the
|
||||
centreline instead of disappearing underneath the hatch. */}
|
||||
${this._renderOpenWalls(disp)}
|
||||
${disp.hideOpenings && !this._markup ? nothing : this._renderOpenings(disp)}
|
||||
${this._renderWallThickUi()}
|
||||
${this._markup && this._tool === 'resize' ? this._renderResizeLayer(view) : nothing}
|
||||
|
||||
+116
-2
@@ -255,6 +255,11 @@ export function wallAngleMatches(
|
||||
/**
|
||||
* After an edge drag: rewrite keys whose old span mid/dir map to a moved
|
||||
* stretch. `oldSpans` / `newSpans` are parallel lists of [a,b] endpoints.
|
||||
*
|
||||
* A stored key may name either the whole polygon edge or one atomic remainder
|
||||
* left by a partial shared/open stretch. The latter has a different midpoint,
|
||||
* so an exact whole-edge key map is insufficient: project every unmatched key
|
||||
* onto the old edge and carry that relative point onto the new one.
|
||||
*/
|
||||
export function rekeyWallsAfterMove(
|
||||
walls: WallEntry[] | null | undefined,
|
||||
@@ -273,11 +278,39 @@ export function rekeyWallsAfterMove(
|
||||
const nk = keyOf(na, nb, pitch, coordScale);
|
||||
if (ok !== nk) map.set(ok, nk);
|
||||
}
|
||||
if (!map.size) return walls.slice();
|
||||
const scale = coordScale > 0 ? coordScale : 1;
|
||||
const tol = Math.max(pitch * 0.5, 1e-9) * scale;
|
||||
const used = new Set<string>();
|
||||
const out: WallEntry[] = [];
|
||||
for (const w of walls) {
|
||||
const nk = map.get(w.key) || w.key;
|
||||
let nk = map.get(w.key) || '';
|
||||
if (!nk) {
|
||||
const parsed = parseKeys([w], scale)[0];
|
||||
if (parsed) {
|
||||
for (let i = 0; i < oldSpans.length; i++) {
|
||||
const [oa, ob] = oldSpans[i];
|
||||
const [na, nb] = newSpans[i];
|
||||
if (!angleClose(parsed.ang, segAngle(oa, ob))) continue;
|
||||
const dx = ob[0] - oa[0], dy = ob[1] - oa[1];
|
||||
const L2 = dx * dx + dy * dy;
|
||||
if (L2 < 1e-18) continue;
|
||||
const t = ((parsed.x - oa[0]) * dx + (parsed.y - oa[1]) * dy) / L2;
|
||||
if (t < -1e-6 || t > 1 + 1e-6) continue;
|
||||
if (distToSeg(parsed.x, parsed.y, oa[0], oa[1], ob[0], ob[1]) > tol) continue;
|
||||
const mx = na[0] + (nb[0] - na[0]) * Math.max(0, Math.min(1, t));
|
||||
const my = na[1] + (nb[1] - na[1]) * Math.max(0, Math.min(1, t));
|
||||
const [ux, uy] = wallDir(na, nb);
|
||||
const arm = Math.max(pitch * scale, 1e-6);
|
||||
nk = keyOf(
|
||||
[mx - ux * arm, my - uy * arm],
|
||||
[mx + ux * arm, my + uy * arm],
|
||||
pitch, scale,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!nk) nk = w.key;
|
||||
if (used.has(nk)) continue;
|
||||
used.add(nk);
|
||||
out.push({ key: nk, cm: w.cm });
|
||||
@@ -972,6 +1005,80 @@ function polyclipToPathD(geom: any): string {
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mitre patches at an endpoint where a virtual stretch meets real walls that
|
||||
* belong to different room contours.
|
||||
*
|
||||
* The normal per-room rings can only join adjacent thick edges of ONE room.
|
||||
* At a virtual T, the two real arms may be owned by two point-touching rooms;
|
||||
* each ring then ends with a butt cap and their union leaves a stair-step at
|
||||
* the outer corner. The patch is the missing offset-line parallelogram. It is
|
||||
* restricted to open-span endpoints, so ordinary corners keep the existing
|
||||
* contour/mitre/bevel implementation unchanged.
|
||||
*/
|
||||
function virtualJunctionPatches(
|
||||
rooms: any[],
|
||||
walls: WallEntry[] | null | undefined,
|
||||
openCuts: number[][],
|
||||
pitch: number,
|
||||
cellCm: number,
|
||||
gridPitch: number,
|
||||
coordScale: number,
|
||||
): number[][][] {
|
||||
if (!walls?.length || !openCuts?.length) return [];
|
||||
const eps = openEps(pitch, coordScale) * 4;
|
||||
const unique = new Map<string, WallInterval>();
|
||||
for (const iv of wallIntervals(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale)) {
|
||||
if (iv.open || !(iv.half > 0) || unique.has(iv.key)) continue;
|
||||
unique.set(iv.key, iv);
|
||||
}
|
||||
const intervals = [...unique.values()];
|
||||
if (intervals.length < 2) return [];
|
||||
|
||||
const nodes: number[][] = [];
|
||||
for (const cut of openCuts) {
|
||||
for (const p of [[cut[0], cut[1]], [cut[2], cut[3]]]) {
|
||||
if (!nodes.some((q) => Math.hypot(q[0] - p[0], q[1] - p[1]) <= eps)) nodes.push(p);
|
||||
}
|
||||
}
|
||||
const out: number[][][] = [];
|
||||
const awayFrom = (iv: WallInterval, v: number[]): number[] | null => {
|
||||
let dx = 0, dy = 0;
|
||||
if (Math.hypot(iv.a[0] - v[0], iv.a[1] - v[1]) <= eps) {
|
||||
dx = iv.b[0] - iv.a[0]; dy = iv.b[1] - iv.a[1];
|
||||
} else if (Math.hypot(iv.b[0] - v[0], iv.b[1] - v[1]) <= eps) {
|
||||
dx = iv.a[0] - iv.b[0]; dy = iv.a[1] - iv.b[1];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
const L = Math.hypot(dx, dy);
|
||||
return L > eps ? [dx / L, dy / L] : null;
|
||||
};
|
||||
|
||||
for (const v of nodes) {
|
||||
const touching = intervals
|
||||
.map((iv) => ({ iv, u: awayFrom(iv, v) }))
|
||||
.filter((x): x is { iv: WallInterval; u: number[] } => !!x.u);
|
||||
for (let i = 0; i < touching.length; i++) {
|
||||
for (let j = i + 1; j < touching.length; j++) {
|
||||
const a = touching[i], b = touching[j];
|
||||
const cross = a.u[0] * b.u[1] - a.u[1] * b.u[0];
|
||||
const sin = Math.abs(cross);
|
||||
if (sin < 1e-3) continue; // one straight wall, no corner to fill
|
||||
const da = b.iv.half / sin;
|
||||
const db = a.iv.half / sin;
|
||||
const pa = [v[0] - a.u[0] * da, v[1] - a.u[1] * da];
|
||||
const pb = [v[0] - b.u[0] * db, v[1] - b.u[1] * db];
|
||||
const far = [pa[0] + pb[0] - v[0], pa[1] + pb[1] - v[1]];
|
||||
const maxHalf = Math.max(a.iv.half, b.iv.half, 1e-9);
|
||||
if (Math.hypot(far[0] - v[0], far[1] - v[1]) > MITRE_LIMIT * maxHalf) continue;
|
||||
out.push(cross > 0 ? [v.slice(), pa, far, pb] : [v.slice(), pb, far, pa]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* One evenodd ring path per room: outset(half) − inset(half). Shared walls
|
||||
* meet as two half-rings; callers may union them via wallBodiesUnionPath.
|
||||
@@ -1042,6 +1149,9 @@ export function wallBodiesUnionPath(
|
||||
if (inC) insets.push(inC);
|
||||
}
|
||||
if (!outsets.length) return null;
|
||||
const junctions = virtualJunctionPatches(
|
||||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||||
);
|
||||
try {
|
||||
let Uout: any = closedRing(outsets[0]);
|
||||
for (let i = 1; i < outsets.length; i++) {
|
||||
@@ -1055,6 +1165,10 @@ export function wallBodiesUnionPath(
|
||||
}
|
||||
body = difference(Uout, Uin);
|
||||
}
|
||||
// The room-ring subtraction above cannot infer a mitre between real arms
|
||||
// owned by different contours at a virtual T. Add only those missing
|
||||
// junction pieces, then let physical openings cut through them as usual.
|
||||
for (const patch of junctions) body = union(body, closedRing(patch) as any);
|
||||
// cut opening tunnels (axis-aligned to opening angle)
|
||||
for (const o of openings) {
|
||||
if (!(o.length > 0)) continue;
|
||||
|
||||
@@ -93,6 +93,20 @@ test('rekeyWallsAfterMove rewrites the key when a span shifts by one cell', () =
|
||||
assert.equal(next[0].cm, 18);
|
||||
});
|
||||
|
||||
test('rekeyWallsAfterMove carries atomic remainders of a partially virtual wall', () => {
|
||||
const oldA = [0.5, 0.1], oldB = [0.5, 0.7];
|
||||
const newA = [0.6, 0.1], newB = [0.6, 0.7];
|
||||
const walls = [
|
||||
{ key: wallKey([0.5, 0.1], [0.5, 0.3], pitch), cm: 20 },
|
||||
{ key: wallKey([0.5, 0.5], [0.5, 0.7], pitch), cm: 25 },
|
||||
];
|
||||
const next = rekeyWallsAfterMove(walls, [[oldA, oldB]], [[newA, newB]], pitch);
|
||||
assert.deepEqual(next, [
|
||||
{ key: wallKey([0.6, 0.1], [0.6, 0.3], pitch), cm: 20 },
|
||||
{ key: wallKey([0.6, 0.5], [0.6, 0.7], pitch), cm: 25 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('setWallThickness upserts and removes', () => {
|
||||
const a = [0, 0], b = [1, 0];
|
||||
let walls = setWallThickness([], a, b, 12, pitch);
|
||||
@@ -278,6 +292,31 @@ test('wallBodyRings / union: outset − inset forms a closed ring', () => {
|
||||
assert.ok(polygonArea(inner) < polygonArea(rooms[0].poly));
|
||||
});
|
||||
|
||||
test('wallBodiesUnionPath mitres real arms owned by different rooms at a virtual T', () => {
|
||||
const scale = 1000;
|
||||
const rooms = [
|
||||
{ id: 'a', poly: [[100, 100], [500, 100], [500, 500], [100, 500]] },
|
||||
{ id: 'b', poly: [[500, 500], [900, 500], [900, 900], [500, 900]] },
|
||||
{ id: 'c', poly: [[500, 100], [900, 100], [900, 500], [500, 500]] },
|
||||
];
|
||||
const open = [[500, 500, 900, 500]];
|
||||
const walls = [
|
||||
{ key: wallKey([0.1, 0.5], [0.5, 0.5], pitch), cm: 20 },
|
||||
{ key: wallKey([0.5, 0.5], [0.5, 0.9], pitch), cm: 20 },
|
||||
];
|
||||
const united = wallBodiesUnionPath(rooms, walls, open, [], pitch, cellCm, GRID_PITCH, scale);
|
||||
assert.ok(united);
|
||||
const nums = (united.d.match(/-?\d+(?:\.\d+)?/g) || []).map(Number);
|
||||
const pts = [];
|
||||
for (let i = 0; i + 1 < nums.length; i += 2) pts.push([nums[i], nums[i + 1]]);
|
||||
const half = wallCmToUnits(20, cellCm, GRID_PITCH) / 2;
|
||||
assert.ok(
|
||||
pts.some((p) => Math.abs(p[0] - (500 + half)) < 1e-6
|
||||
&& Math.abs(p[1] - (500 - half)) < 1e-6),
|
||||
`missing outer mitre corner in ${united.d}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('wallBodiesUnionPath: single fully-thick room keeps a floor hole', () => {
|
||||
const room = { id: 'n', poly: [[100, 100], [300, 100], [300, 300], [100, 300]] };
|
||||
const walls = applyWallThicknessToNewRoom([], [room], 'n', 15, 0.01, [], 1000);
|
||||
|
||||
Reference in New Issue
Block a user