v1.59.0-beta.9: fix mixed-wall resize and virtual T-junctions
Validate / hassfest (push) Failing after 8s
Validate / hacs (push) Failing after 10s
Validate / frontend (push) Successful in 3m1s
Validate / backend (push) Failing after 14m22s
Validate / smoke (push) Failing after 19m22s

This commit is contained in:
Matysh
2026-08-05 20:44:26 +03:00
parent e188f9d609
commit 4868cc0786
16 changed files with 517 additions and 139 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"
}
+151
View File
@@ -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
+34 -33
View File
File diff suppressed because one or more lines are too long
+14
View File
@@ -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
+14
View File
@@ -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
View File
@@ -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
View File
@@ -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]
+9 -4
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+39
View File
@@ -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);