mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
committed by
claude[bot]
parent
7af4146614
commit
955de3e69c
File diff suppressed because one or more lines are too long
+29
-1
@@ -1,5 +1,5 @@
|
||||
import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
|
||||
import { makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
|
||||
import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
|
||||
|
||||
const fixtureFor = (name) => name === 'large' ? makeLargeHouseFixture() : makeVisualMatrixFixture();
|
||||
|
||||
@@ -51,6 +51,34 @@ async function stableEnvironment(page, scenario) {
|
||||
/** Apply every data-only scenario override before the fixture crosses into the browser. */
|
||||
export function prepareGoldenFixture(scenario) {
|
||||
const fixture = fixtureFor(scenario.fixture);
|
||||
if (scenario.cornerSplitWall) {
|
||||
const stage = scenario.cornerSplitWall;
|
||||
if (!['before', 'thin', 'thick'].includes(stage))
|
||||
throw new Error(`unknown cornerSplitWall stage: ${stage}`);
|
||||
const a = [0.10, 0.10], tr = [0.90, 0.10], split = [0.90, 0.50];
|
||||
const br = [0.90, 0.90], bl = [0.10, 0.90];
|
||||
const entry = (from, to, cm) => ({
|
||||
key: fixtureWallKey(from, to), a: [...from], b: [...to], cm,
|
||||
});
|
||||
const before = stage === 'before';
|
||||
fixture.config.spaces.push({
|
||||
id: scenario.space,
|
||||
name: 'Corner Split',
|
||||
rooms: before
|
||||
? [{ id: 'corner-source', name: 'Before Split', area: null, poly: [a, tr, br, bl] }]
|
||||
: [
|
||||
{ id: 'corner-source', name: 'Main room', area: null, poly: [a, tr, split] },
|
||||
{ id: 'corner-fresh', name: 'New room', area: null, poly: [split, br, bl, a] },
|
||||
],
|
||||
walls: before
|
||||
? [entry(a, tr, 15), entry(tr, br, 15), entry(br, bl, 15), entry(bl, a, 15)]
|
||||
: [
|
||||
entry(a, tr, 15), entry(tr, split, 15), entry(split, br, 15),
|
||||
entry(br, bl, 15), entry(bl, a, 15), entry(a, split, stage === 'thin' ? 15 : 100),
|
||||
],
|
||||
settings: { show_borders: true, fill_mode: 'custom', custom_fill: { c: '#536b82', a: 0.42 } },
|
||||
});
|
||||
}
|
||||
const requireSpace = () => {
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
if (!space) throw new Error(`golden override references missing space: ${scenario.space}`);
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
|
||||
|
||||
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
|
||||
export const GOLDEN_MATRIX_VERSION = 17;
|
||||
export const GOLDEN_MATRIX_VERSION = 18;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
const sunWindow = { capture: 'sun-window', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.001 } };
|
||||
|
||||
export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
{ id: 'split-corner-wall-before-dark', fixture: 'visual', space: 'golden-corner-split',
|
||||
cornerSplitWall: 'before', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'split-corner-wall-thin-dark', fixture: 'visual', space: 'golden-corner-split',
|
||||
cornerSplitWall: 'thin', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'split-corner-wall-thick-dark', fixture: 'visual', space: 'golden-corner-split',
|
||||
cornerSplitWall: 'thick', mode: 'view', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-geometry-view-dark', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
labs: ['iso'], projection: 'iso', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'isometric-geometry-view-light', fixture: 'visual', space: 'golden-geometry', mode: 'view',
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/** Issue #123: a Split ending at a room vertex must not reshape the facade. */
|
||||
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((space) => space.id === c._space);
|
||||
const pitch = 1 / 240;
|
||||
const wallKey = (a, b) => {
|
||||
const q = (value) => Math.round(value / pitch) * pitch;
|
||||
let dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const length = Math.hypot(dx, dy) || 1;
|
||||
dx /= length; dy /= length;
|
||||
if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
|
||||
let angle = Math.atan2(dy, dx);
|
||||
if (angle < 0) angle += Math.PI;
|
||||
angle = Math.round(angle * 1800) / 1800;
|
||||
return `${q((a[0] + b[0]) / 2).toFixed(6)},${q((a[1] + b[1]) / 2).toFixed(6)}@${angle.toFixed(4)}`;
|
||||
};
|
||||
const entry = (a, b, cm) => ({ key: wallKey(a, b), a: [...a], b: [...b], cm });
|
||||
const update = async () => {
|
||||
c._cfgEpoch++;
|
||||
c._wallUnionCache = null;
|
||||
c._lightBarrierCache = null;
|
||||
c.requestUpdate();
|
||||
await c.updateComplete;
|
||||
};
|
||||
const bbox = (element) => {
|
||||
const box = element?.getBBox?.();
|
||||
return box ? [box.x, box.y, box.width, box.height] : null;
|
||||
};
|
||||
const sameBox = (a, b, epsilon = 0.01) => !!a && !!b
|
||||
&& a.every((value, index) => Math.abs(value - b[index]) <= epsilon);
|
||||
|
||||
const a = [0.10, 0.10], tr = [0.90, 0.10], b = [0.90, 0.50];
|
||||
const br = [0.90, 0.90], bl = [0.10, 0.90];
|
||||
const original = { id: 'corner-source', name: 'Corner source', area: null, poly: [a, tr, br, bl] };
|
||||
const outerBefore = [
|
||||
entry(a, tr, 15), entry(tr, br, 15), entry(br, bl, 15), entry(bl, a, 15),
|
||||
];
|
||||
const outerAfter = [
|
||||
entry(a, tr, 15), entry(tr, b, 15), entry(b, br, 15),
|
||||
entry(br, bl, 15), entry(bl, a, 15),
|
||||
];
|
||||
const splitRooms = [
|
||||
{ id: 'corner-source', name: 'Corner source', area: null, poly: [a, tr, b] },
|
||||
{ id: 'corner-fresh', name: 'Corner fresh', area: null, poly: [b, br, bl, a] },
|
||||
];
|
||||
|
||||
const space = sp();
|
||||
space.settings = { ...(space.settings || {}), show_borders: true };
|
||||
space.rooms = [structuredClone(original)];
|
||||
space.walls = structuredClone(outerBefore);
|
||||
delete space.open_spans;
|
||||
delete space.openings;
|
||||
delete space.partitions;
|
||||
delete space.room_drafts;
|
||||
delete space.wall_columns;
|
||||
c._setMode('plan');
|
||||
await update();
|
||||
const beforeWall = bbox(sr().querySelector('.wallbody'));
|
||||
const beforePaper = bbox(sr().querySelector('.hp-paperg'));
|
||||
out.beforeDrawn = !!beforeWall && !!beforePaper;
|
||||
|
||||
space.rooms = structuredClone(splitRooms);
|
||||
const pathByThickness = {};
|
||||
for (const cm of [0, 15, 100]) {
|
||||
space.walls = [
|
||||
...structuredClone(outerAfter),
|
||||
...(cm ? [entry(a, b, cm)] : []),
|
||||
];
|
||||
await update();
|
||||
const wall = sr().querySelector('.wallbody');
|
||||
const paper = sr().querySelector('.hp-paperg');
|
||||
out[`wall${cm}KeepsFacade`] = sameBox(bbox(wall), beforeWall);
|
||||
out[`paper${cm}KeepsFacade`] = sameBox(bbox(paper), beforePaper);
|
||||
pathByThickness[cm] = wall?.getAttribute('d') || '';
|
||||
}
|
||||
out.dividerChangesInterior = pathByThickness[15] !== pathByThickness[100]
|
||||
&& pathByThickness[0] !== pathByThickness[15];
|
||||
|
||||
const persisted = JSON.stringify({ rooms: space.rooms, walls: space.walls });
|
||||
const planD = sr().querySelector('.wallbody')?.getAttribute('d') || '';
|
||||
const lightGeom = c._lightBarriers(c._spaceModel()).masonryGeometry;
|
||||
const lightPoints = lightGeom.flat(2);
|
||||
const lightBox = lightPoints.length ? [
|
||||
Math.min(...lightPoints.map((point) => point[0])),
|
||||
Math.min(...lightPoints.map((point) => point[1])),
|
||||
Math.max(...lightPoints.map((point) => point[0])) - Math.min(...lightPoints.map((point) => point[0])),
|
||||
Math.max(...lightPoints.map((point) => point[1])) - Math.min(...lightPoints.map((point) => point[1])),
|
||||
] : null;
|
||||
out.lightUsesFacade = sameBox(lightBox, beforeWall);
|
||||
|
||||
c._setMode('view');
|
||||
await update();
|
||||
const viewD = sr().querySelector('.wallbody')?.getAttribute('d') || '';
|
||||
out.planViewParity = !!planD && viewD === planD;
|
||||
|
||||
const kioskBefore = c._config.kiosk;
|
||||
c._config.kiosk = true;
|
||||
await update();
|
||||
out.kioskParity = sr().querySelector('.wallbody')?.getAttribute('d') === planD;
|
||||
c._config.kiosk = kioskBefore;
|
||||
|
||||
history.replaceState(null, '', `?hp-labs=iso#space=${encodeURIComponent(c._space)}`);
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
await c.updateComplete;
|
||||
c._setProjection('iso');
|
||||
await update();
|
||||
out.isoUsesCanonicalBody = !!sr().querySelector('.iso-walls .iso-wall-top')
|
||||
&& c._isoSource().build().flat(2).length > 0;
|
||||
c._setProjection('flat');
|
||||
await update();
|
||||
|
||||
await customElements.whenDefined('houseplan-space-card');
|
||||
const cfg = structuredClone(c._serverCfg);
|
||||
const baseCall = c.hass.callWS.bind(c.hass);
|
||||
const staticCard = document.createElement('houseplan-space-card');
|
||||
staticCard.setConfig({ type: 'custom:houseplan-space-card', space: c._space, show_button: false });
|
||||
staticCard.hass = { ...c.hass, callWS: async (message) => {
|
||||
if (message.type === 'houseplan/config/get') return { config: cfg, rev: 1 };
|
||||
if (message.type === 'houseplan/layout/get') return { layout: c._layout || {}, rev: 1 };
|
||||
return baseCall(message);
|
||||
} };
|
||||
document.body.appendChild(staticCard);
|
||||
const started = Date.now();
|
||||
while (!staticCard.renderRoot?.querySelector('.wallbody') && Date.now() - started < 6000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
await staticCard.updateComplete;
|
||||
out.staticParity = staticCard.renderRoot?.querySelector('.wallbody')?.getAttribute('d') === planD;
|
||||
staticCard.remove();
|
||||
|
||||
out.renderDoesNotRewriteConfig = JSON.stringify({ rooms: space.rooms, walls: space.walls }) === persisted;
|
||||
return out;
|
||||
});
|
||||
|
||||
checkAll(res);
|
||||
await finish(browser, res);
|
||||
+186
-186
File diff suppressed because one or more lines are too long
Vendored
+186
-186
File diff suppressed because one or more lines are too long
@@ -326,6 +326,20 @@ its types declare named exports — breaking either tsc or the runtime): merge a
|
||||
when the union collapses into one hole-free outline; split cuts wall-to-wall with a chord, the
|
||||
bigger part keeps the room identity (name/area/devices).
|
||||
|
||||
`wallBodiesGeometry()` is the canonical physical masonry for flat full/static
|
||||
rendering, hidden isometric projection and Glow/sun occlusion. Its exterior
|
||||
shell is derived from the union of room centrelines plus the surviving `outer`
|
||||
atomic intervals; internal/shared interval bodies are clipped to that union
|
||||
before the shell is restored. Consequently a Split edge ending at an exterior
|
||||
vertex cannot contribute a child-room mitre to the facade. Per-room rings remain
|
||||
an interior join/nested-room representation, and atomic quads provide a safe
|
||||
physical interval when an acute child ring cannot be subtracted. Paper and
|
||||
masonry paths are emitted by that same geometry pass. The full card retains the
|
||||
pair in `_wallUnionCache`; static cards retain it in a weak server-snapshot
|
||||
cache guarded by a structural geometry fingerprint. This is computed render
|
||||
state only: it never rewrites rooms or wall entries, and an HA state tick does
|
||||
not rebuild topology.
|
||||
|
||||
## Markup editor (v1.4.0+)
|
||||
|
||||
State inside the card: `_markup` (mode), `_tool` (draw/partition/column/merge/split/resize/opening/
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Splitting a room from an existing corner no longer deforms the exterior wall
|
||||
or pulls a thick internal divider through the facade. Plan, View, kiosk,
|
||||
static cards, hidden isometric rendering and light obstacles now use the same
|
||||
preserved exterior geometry, including already saved plans
|
||||
([#123](https://github.com/Matysh/houseplan-card/issues/123)).
|
||||
|
||||
## v1.63.0-beta.1 — 2026-08-13
|
||||
|
||||
- Deleting a standalone sensor or lock marker no longer breaks its explicit
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Split из существующего угла комнаты больше не деформирует наружную стену и
|
||||
не вытягивает толстый внутренний разделитель сквозь фасад. Редактор плана,
|
||||
View, киоск, статичная карточка, скрытая изометрия и световые препятствия
|
||||
используют одну сохранённую наружную геометрию, в том числе для уже
|
||||
сохранённых планов ([#123](https://github.com/Matysh/houseplan-card/issues/123)).
|
||||
|
||||
## v1.63.0-beta.1 — 2026-08-13
|
||||
|
||||
- Удаление самостоятельного маркера датчика или замка больше не разрывает его
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.63.0-beta.1** everywhere (manifest, const.py, package.json, CARD_VERSION) — prerelease candidate |
|
||||
| Current local cycle | v1.63.0-beta.1 fixes the empty-plan crash (#111) and preserves explicit opening sensor/lock references after standalone marker deletion (#104). It also carries the reviewed process automation work #105 and #118–#121. |
|
||||
| Current local cycle | v1.63.0-beta.1 fixes the empty-plan crash (#111) and preserves explicit opening sensor/lock references after standalone marker deletion (#104). Development after that beta preserves the exterior facade when Split starts or ends at a room corner (#123), using one wall geometry for flat/static/isometric rendering and light. The line also carries the reviewed process automation work #105 and #118–#121. |
|
||||
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1 as a hidden, expiring `iso` Labs experiment: a fixed near-top orthographic volumetric View. Flat remains default; editors and `houseplan-space-card` remain flat; all existing floor live effects and HA actions are preserved. This is internal, not a public feature. |
|
||||
| Workflow | Owner's rule since 2026-08-07: ordinary fixes/features are made **locally, without tests and without commits**. A requested pre-release gets a production build plus the smallest targeted unit/smoke set covering the changed surfaces, one tested `dev` commit/tag and a GitHub Release with `prerelease=true`; `main` stays untouched. The complete local frontend/backend/smoke gate runs only before a stable release, after which `main` is fast-forwarded to the exact tested `dev` SHA and the GitHub Release uses `prerelease=false`. Release bodies are short and bilingual (Russian first): only significant user changes get individual bullets, while minor/code-only work is grouped as `Мелкие исправления и улучшения` / `Small fixes and improvements`; every body ends with separate links to the Russian and English changelogs. Detailed RU/EN changelog bullets may link the corresponding closed GitHub Issues; open or partially delivered issues are never presented as shipped. Telegram announcements are sent only for stable releases; beta and RC publication is silent. `docs/RELEASE-NOTES.md` is the current canonical body instance; `npm run release:prerelease -- <tag> --issues=… --yes` is the primary local publication path and the manual `Publish prerelease` workflow is its GitHub-only equivalent once present on `main`. Nothing is copied to the home instance by hand |
|
||||
| GitHub | https://github.com/Matysh/houseplan-card — [Issues](https://github.com/Matysh/houseplan-card/issues) are the canonical task records and the linked [Project v2](https://github.com/users/Matysh/projects/1) is the canonical priority/status view; both must stay current. `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) |
|
||||
|
||||
@@ -304,6 +304,9 @@ desktop: для точного рисования, Resize, модификато
|
||||
- Промежуточные точки должны идти внутри комнаты и не пересекать контур или сам разрез.
|
||||
- Большая по площади часть сохраняет исходную комнату и устройства.
|
||||
- Для меньшей части открывается диалог новой комнаты.
|
||||
- Разрез можно начать или закончить точно в существующем углу. Наружная форма
|
||||
дома при этом не меняется: общая стена двух новых комнат примыкает к фасаду
|
||||
только изнутри, даже если она толще наружной стены.
|
||||
|
||||
### Resize
|
||||
|
||||
|
||||
+16
-2
@@ -42,6 +42,17 @@ Every thick wall grows **half outward and half inward** from the polygon edge
|
||||
(outer and shared alike). Silhouette is wider than the polygon by `cm/2` on
|
||||
outer walls. Paper and the content frame grow under that outer half.
|
||||
|
||||
The exterior silhouette is generated from the boolean union of room
|
||||
centrelines and its surviving `outer` atomic intervals. A shared Split edge
|
||||
therefore disappears before exterior mitres are built. When Split ends at an
|
||||
existing corner, its divider is clipped to the interior side of this envelope:
|
||||
the real exterior mitre/bevel and unequal arm depths stay unchanged, while any
|
||||
divider thickness remains inside the facade. The same computed geometry is
|
||||
used for the full/static/hidden-isometric renderers and light occlusion. The
|
||||
paper and masonry paths come from one cached structural pass in flat renderers;
|
||||
live HA state ticks do not repeat the boolean topology. Saved room and wall data
|
||||
is not migrated or rewritten.
|
||||
|
||||
## 3. Body render
|
||||
|
||||
Production body is the **ring** `outset(poly, half) − inset(poly, half)` per
|
||||
@@ -132,14 +143,17 @@ virtual-T mitre; angle-aware opening; 45° wall; T-junction; detached parallel
|
||||
room; nested-room tie; partially out-of-span legacy opening; overlapping
|
||||
opening de-duplication; shared symbol/cut/tunnel rejection; thick-door tunnel
|
||||
clipping and room-side colour ownership; whole and
|
||||
atomic rekey after edge/scale.
|
||||
atomic rekey after edge/scale; corner Split exterior equality across
|
||||
0/1/15/100 cm, unequal arms, both windings and convex/concave endpoints.
|
||||
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;
|
||||
door/window/gate tunnels repeat outer/shared room fills without an axis seam
|
||||
(`demo/smoke_opening_tunnel_fill.mjs`).
|
||||
(`demo/smoke_opening_tunnel_fill.mjs`); corner Split keeps the same facade in
|
||||
Plan/View/kiosk/static/isometric surfaces and the light barrier
|
||||
(`demo/smoke_split_corner_wall.mjs`).
|
||||
|
||||
## 9. Independent partitions, drafts and columns
|
||||
|
||||
|
||||
@@ -290,9 +290,10 @@ View и киоск полностью поддерживаются: исправ
|
||||
- **AC10 (`unit`):** обычный Split от середины стены, wall materialisation,
|
||||
partial shared intervals, virtual-T mitre, openings и independent partitions
|
||||
сохраняют действующее поведение.
|
||||
- **AC11 (`performance` + ревью кода):** новый exterior/shared расчёт использует
|
||||
существующее geometry caching; HA state tick не пересчитывает topology, а
|
||||
общий pre-beta performance gate остаётся зелёным.
|
||||
- **AC11 (ревью кода):** новый exterior/shared расчёт использует существующее
|
||||
geometry caching; HA state tick не пересчитывает topology. Существующие
|
||||
`performance_smoke` и large-house benchmark остаются общим предрелизным
|
||||
подтверждением, а не отдельным типом доказательства AC.
|
||||
- **AC12 (`typecheck` + `unit` + `build`):** быстрые гейты зелёные; три bundle
|
||||
snapshot побайтно совпадают.
|
||||
- **AC13 (ревью документации):** RU/EN changelog и пользовательские документы
|
||||
|
||||
+36
-27
@@ -55,7 +55,7 @@ import {
|
||||
import {
|
||||
degradeWalls, rekeyWallsAfterMove,
|
||||
setWallThickness, setWallThicknessForRoom, cmToField, wallCmToUnits,
|
||||
wallEdgeBodies, wallBodiesGeometry, wallBodiesUnionPath, paperRoomShapesWithWalls,
|
||||
wallEdgeBodies, wallBodiesGeometry, wallBodiesUnionPath,
|
||||
innerContourForRoom, roomWallProfile, outsetContour,
|
||||
openingInnerFaceOffsetFromIndex, openingTunnelGeometriesFromIndex,
|
||||
openingWallIndex as buildOpeningWallIndex, applyWallThicknessToNewRoom,
|
||||
@@ -4432,7 +4432,7 @@ class HouseplanCard extends LitElement {
|
||||
rooms: space.rooms, walls, openCuts, openings,
|
||||
partitions: space.partitions, roomDrafts: space.room_drafts, columns: space.wall_columns,
|
||||
cellCm: this._cellCm, gridPitch: this._gridPitch, wallKeyPitch: this._wallKeyPitch,
|
||||
camera: ISO_CAMERA, wallHeight: ISO_WALL_HEIGHT, algorithm: 1,
|
||||
camera: ISO_CAMERA, wallHeight: ISO_WALL_HEIGHT, algorithm: 2,
|
||||
})}`;
|
||||
return {
|
||||
key,
|
||||
@@ -9646,13 +9646,39 @@ class HouseplanCard extends LitElement {
|
||||
}
|
||||
|
||||
/** Paper under rooms, grown by shared-wall half-thickness when set. */
|
||||
private _paperShapes(rooms: any[]): Array<{ poly: string } | { rect: { x: number; y: number; w: number; h: number; rx: number } }> {
|
||||
private _paperShapes(rooms: any[]): Array<
|
||||
| { path: string }
|
||||
| { poly: string }
|
||||
| { rect: { x: number; y: number; w: number; h: number; rx: number } }
|
||||
> {
|
||||
const walls = this._spaceWalls;
|
||||
if (!walls.length) return paperRoomShapes(rooms);
|
||||
const united = this._wallUnionGeometry();
|
||||
return united?.paperD ? [{ path: united.paperD }] : paperRoomShapes(rooms);
|
||||
}
|
||||
|
||||
/** Canonical paper + masonry geometry, cached by structural config epoch. */
|
||||
private _wallUnionGeometry(): ReturnType<typeof wallBodiesUnionPath> {
|
||||
const walls = this._spaceWalls;
|
||||
const extras = this._physicalBodiesR();
|
||||
if (!walls.length && !extras.length) return null;
|
||||
const openCuts = this._openPairs().flatMap((p) => p.segs);
|
||||
return paperRoomShapesWithWalls(
|
||||
rooms, walls, openCuts, this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
|
||||
);
|
||||
const openings = (this._curSpaceCfg?.openings || []).map((o: any) => ({
|
||||
x: Number(o.x) * NORM_W, y: Number(o.y) * NORM_W,
|
||||
angle: Number(o.angle) || 0,
|
||||
length: (Number(o.length) > 0 ? Number(o.length) : 0.9) * NORM_W,
|
||||
}));
|
||||
const unionKey = `${this._space}|${this._cfgEpoch}|${this._spaceModel().rooms.length}`;
|
||||
if (!this._wallUnionCache || this._wallUnionCache.key !== unionKey) {
|
||||
this._wallUnionCache = {
|
||||
key: unionKey,
|
||||
value: wallBodiesUnionPath(
|
||||
this._spaceModel().rooms, walls, openCuts, openings,
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, extras,
|
||||
),
|
||||
};
|
||||
}
|
||||
return this._wallUnionCache.value;
|
||||
}
|
||||
|
||||
/** Thick-wall spans in render units — suppress centreline stroke under bodies. */
|
||||
@@ -10009,26 +10035,7 @@ class HouseplanCard extends LitElement {
|
||||
if (this._renderProjection === 'iso') return svg`` as unknown as TemplateResult;
|
||||
if (disp && !disp.showBorders && (this._mode === 'view' || this._mode === 'devices'))
|
||||
return svg`` as unknown as TemplateResult;
|
||||
const walls = this._spaceWalls;
|
||||
const extras = this._physicalBodiesR();
|
||||
if (!walls.length && !extras.length) return svg`` as unknown as TemplateResult;
|
||||
const openCuts = this._openPairs().flatMap((p) => p.segs);
|
||||
const openings = (this._curSpaceCfg?.openings || []).map((o: any) => ({
|
||||
x: Number(o.x) * NORM_W, y: Number(o.y) * NORM_W,
|
||||
angle: Number(o.angle) || 0,
|
||||
length: (Number(o.length) > 0 ? Number(o.length) : 0.9) * NORM_W,
|
||||
}));
|
||||
const unionKey = `${this._space}|${this._cfgEpoch}|${this._spaceModel().rooms.length}`;
|
||||
if (!this._wallUnionCache || this._wallUnionCache.key !== unionKey) {
|
||||
this._wallUnionCache = {
|
||||
key: unionKey,
|
||||
value: wallBodiesUnionPath(
|
||||
this._spaceModel().rooms, walls, openCuts, openings,
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, extras,
|
||||
),
|
||||
};
|
||||
}
|
||||
const united = this._wallUnionCache.value;
|
||||
const united = this._wallUnionGeometry();
|
||||
if (!united) return svg`` as unknown as TemplateResult;
|
||||
const stage = this._stageEl;
|
||||
const v = this._viewOr(this._baseVb());
|
||||
@@ -14026,7 +14033,9 @@ class HouseplanCard extends LitElement {
|
||||
(styles.ts) is composited once for the whole sheet, so
|
||||
adjacent rooms never cast seams onto each other's paper. */}
|
||||
${this._wallHatchDefs(disp.color)}${svg`<g class="hp-paperg">${this._paperShapes(space.rooms).map((sh) =>
|
||||
'poly' in sh
|
||||
'path' in sh
|
||||
? svg`<path class="hp-paper" d="${sh.path}" fill-rule="evenodd" pointer-events="none"></path>`
|
||||
: 'poly' in sh
|
||||
? svg`<polygon class="hp-paper" points="${sh.poly}" pointer-events="none"></polygon>`
|
||||
: svg`<rect class="hp-paper" x="${sh.rect.x}" y="${sh.rect.y}" width="${sh.rect.w}" height="${sh.rect.h}" rx="${sh.rect.rx}" pointer-events="none"></rect>`,
|
||||
)}</g>`}
|
||||
|
||||
+40
-9
@@ -12,9 +12,7 @@ import {
|
||||
spaceDisplayOf, fillColorsOf, roomFillModeOf, roomGlowOf,
|
||||
roomCustomFillOf, resolveEffectiveRoomFill, stageBgOf, paperRoomShapes,
|
||||
} from './logic';
|
||||
import {
|
||||
wallBodiesUnionPath, paperRoomShapesWithWalls, wallBodyNeedsSolid, type WallEntry,
|
||||
} from './wall-thickness';
|
||||
import { wallBodiesUnionPath, wallBodyNeedsSolid, type WallEntry } from './wall-thickness';
|
||||
import { DEFAULT_ICON_RULES, compileIconRules, EXCLUDED_DOMAINS } from './rules';
|
||||
import { t, type Lang } from './i18n';
|
||||
import { bgModeOf, northDegOf, sunStateOf, dayPhase } from './sun';
|
||||
@@ -27,6 +25,7 @@ import {
|
||||
import { presentationSnapshotKey } from './render-device-snapshot';
|
||||
import { deviceFaceStyle, renderDeviceFace } from './device-face';
|
||||
import { valueBadgeTitle } from './device-value-badge';
|
||||
import { contentFingerprint } from './visual-continuity';
|
||||
import {
|
||||
spaceModels, roomCenter, defaultPositions, markerPos, labelPos, spaceFrame, iconCqw, NORM_W,
|
||||
GRID_STEP_N, GRID_PITCH,
|
||||
@@ -35,6 +34,29 @@ import {
|
||||
|
||||
export { spaceModels } from './space-geometry';
|
||||
|
||||
type StaticWallGeometry = ReturnType<typeof wallBodiesUnionPath>;
|
||||
type StaticWallGeometryEntry = { fingerprint: string; value: StaticWallGeometry };
|
||||
const staticWallGeometryCache = new WeakMap<object, Map<string, StaticWallGeometryEntry>>();
|
||||
|
||||
/** Static cards receive the same immutable server-config object on HA ticks. */
|
||||
function cachedStaticWallGeometry(
|
||||
cfg: ServerConfig,
|
||||
spaceId: string,
|
||||
fingerprint: string,
|
||||
build: () => StaticWallGeometry,
|
||||
): StaticWallGeometry {
|
||||
let spaces = staticWallGeometryCache.get(cfg as object);
|
||||
if (!spaces) {
|
||||
spaces = new Map<string, StaticWallGeometryEntry>();
|
||||
staticWallGeometryCache.set(cfg as object, spaces);
|
||||
}
|
||||
const cached = spaces.get(spaceId);
|
||||
if (cached?.fingerprint === fingerprint) return cached.value;
|
||||
const value = build();
|
||||
spaces.set(spaceId, { fingerprint, value });
|
||||
return value;
|
||||
}
|
||||
|
||||
export interface StaticRenderOpts {
|
||||
hass: any;
|
||||
registry?: HaRegistrySnapshot;
|
||||
@@ -306,12 +328,19 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
// gaps between detached buildings, and an empty space has no paper at all,
|
||||
// image or no image. The picture is drawn ON the paper, one layer above.
|
||||
|
||||
const paperShapes = walls.length
|
||||
? paperRoomShapesWithWalls(space.rooms, walls, [], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W)
|
||||
: paperRoomShapes(space.rooms);
|
||||
const wallUnion = (walls.length || extras.length) && disp.showBorders
|
||||
? wallBodiesUnionPath(space.rooms, walls, [], [], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W, extras)
|
||||
const needsCanonicalWallGeometry = !!(walls.length || (extras.length && disp.showBorders));
|
||||
const wallGeometryFingerprint = needsCanonicalWallGeometry
|
||||
? contentFingerprint({ rooms: space.rooms, walls, extras, cellCm })
|
||||
: '';
|
||||
const canonicalWallGeometry = needsCanonicalWallGeometry
|
||||
? cachedStaticWallGeometry(o.cfg, space.id, wallGeometryFingerprint, () => wallBodiesUnionPath(
|
||||
space.rooms, walls, [], [], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W, extras,
|
||||
))
|
||||
: null;
|
||||
const paperShapes = walls.length && canonicalWallGeometry?.paperD
|
||||
? [{ path: canonicalWallGeometry.paperD }]
|
||||
: paperRoomShapes(space.rooms);
|
||||
const wallUnion = disp.showBorders ? canonicalWallGeometry : null;
|
||||
const pxPerUnit = o.stageWidth && vb[2] ? o.stageWidth / vb[2] : 1;
|
||||
const solidWall = !!wallUnion && wallBodyNeedsSolid(wallUnion.depthUnits, pxPerUnit);
|
||||
const wallStroke = disp.color || '#607d8b';
|
||||
@@ -326,7 +355,9 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
</pattern>
|
||||
</defs>` : nothing}
|
||||
${paperShapes.map((sh) =>
|
||||
'poly' in sh
|
||||
'path' in sh
|
||||
? svg`<path class="hp-paper" d="${sh.path}" fill-rule="evenodd"></path>`
|
||||
: 'poly' in sh
|
||||
? svg`<polygon class="hp-paper" points="${sh.poly}"></polygon>`
|
||||
: svg`<rect class="hp-paper" x="${sh.rect.x}" y="${sh.rect.y}" width="${sh.rect.w}" height="${sh.rect.h}" rx="${sh.rect.rx}"></rect>`,
|
||||
)}
|
||||
|
||||
+177
-39
@@ -6,8 +6,8 @@
|
||||
* displayed m² use the inner (inset) contour. Wall-length rulers stay on the
|
||||
* centreline.
|
||||
*/
|
||||
import { union, difference } from 'polyclip-ts';
|
||||
import { polygonArea, roomPoly, roomEdges, sharedBoundary } from './logic';
|
||||
import { union, difference, intersection } from 'polyclip-ts';
|
||||
import { polygonArea, roomPoly, roomEdges, sharedBoundary, paperRoomShapes } from './logic';
|
||||
|
||||
export interface WallEntry {
|
||||
key: string;
|
||||
@@ -1206,6 +1206,113 @@ function closedRing(poly: number[][]): number[][][] {
|
||||
return [ring];
|
||||
}
|
||||
|
||||
interface ExteriorEnvelopeGeometry {
|
||||
/** Union of room centrelines. Shared Split edges disappear from this shape. */
|
||||
centre: any;
|
||||
/** Wall shell generated only from the surviving exterior boundary. */
|
||||
shell: any;
|
||||
}
|
||||
|
||||
/** Open every ring of a polyclip MultiPolygon and drop its closing duplicate. */
|
||||
function geometryRings(geom: any): number[][][] {
|
||||
const out: number[][][] = [];
|
||||
for (const polygon of Array.isArray(geom) ? geom : []) {
|
||||
if (!Array.isArray(polygon)) continue;
|
||||
for (const raw of polygon) {
|
||||
if (!Array.isArray(raw) || raw.length < 4) continue;
|
||||
const ring = raw.slice(0, -1).map((p: number[]) => [p[0], p[1]]);
|
||||
if (ring.length >= 3) out.push(ring);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pointOnSegment(p: number[], a: number[], b: number[], eps: number): boolean {
|
||||
if (distToSeg(p[0], p[1], a[0], a[1], b[0], b[1]) > eps) return false;
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const dot = (p[0] - a[0]) * dx + (p[1] - a[1]) * dy;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
return dot >= -eps && dot <= len2 + eps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a boolean-union boundary at every stored exterior interval endpoint.
|
||||
* Polyclip is allowed to collapse a collinear child-room vertex; retaining the
|
||||
* interval breakpoints is what preserves unequal wall depths on the two sides.
|
||||
*/
|
||||
function exteriorBoundaryProfile(
|
||||
ring: number[][],
|
||||
outer: WallInterval[],
|
||||
eps: number,
|
||||
): { poly: number[][]; offsets: number[] } | null {
|
||||
const poly: number[][] = [];
|
||||
const offsets: number[] = [];
|
||||
for (let i = 0; i < ring.length; i++) {
|
||||
const a = ring[i], b = ring[(i + 1) % ring.length];
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (!(len2 > eps * eps)) continue;
|
||||
const cuts = [0, 1];
|
||||
for (const iv of outer) {
|
||||
for (const p of [iv.a, iv.b]) {
|
||||
if (!pointOnSegment(p, a, b, eps)) continue;
|
||||
const t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / len2;
|
||||
if (t > eps && t < 1 - eps) cuts.push(t);
|
||||
}
|
||||
}
|
||||
cuts.sort((x, y) => x - y);
|
||||
const unique = cuts.filter((t, at) => at === 0 || Math.abs(t - cuts[at - 1]) > eps);
|
||||
for (let at = 0; at < unique.length - 1; at++) {
|
||||
const t0 = unique[at], t1 = unique[at + 1];
|
||||
const p = [a[0] + dx * t0, a[1] + dy * t0];
|
||||
const mid = [a[0] + dx * (t0 + t1) / 2, a[1] + dy * (t0 + t1) / 2];
|
||||
let half = 0;
|
||||
for (const iv of outer) {
|
||||
if (pointOnSegment(mid, iv.a, iv.b, eps)) half = Math.max(half, iv.half);
|
||||
}
|
||||
poly.push(p);
|
||||
offsets.push(half);
|
||||
}
|
||||
}
|
||||
return poly.length >= 3 && offsets.length === poly.length ? { poly, offsets } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exterior masonry is derived from the union of room centrelines, not from
|
||||
* each room independently. A Split edge therefore vanishes before mitres are
|
||||
* built and cannot turn its artificial child corner into part of the facade.
|
||||
*/
|
||||
function exteriorEnvelopeGeometry(
|
||||
rooms: any[],
|
||||
walls: WallEntry[] | null | undefined,
|
||||
openCuts: number[][],
|
||||
pitch: number,
|
||||
cellCm: number,
|
||||
gridPitch: number,
|
||||
coordScale: number,
|
||||
): ExteriorEnvelopeGeometry | null {
|
||||
const polys = (rooms || []).map(roomPoly).filter((p): p is number[][] => !!p && p.length >= 3);
|
||||
if (!polys.length) return null;
|
||||
let centre: any = union(closedRing(polys[0]) as any);
|
||||
for (let i = 1; i < polys.length; i++) centre = union(centre, closedRing(polys[i]) as any);
|
||||
|
||||
const outer = wallIntervals(
|
||||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||||
).filter((iv) => iv.kind === 'outer' && iv.half > 0);
|
||||
const eps = openEps(pitch, coordScale) * 4;
|
||||
let shell: any = null;
|
||||
for (const ring of geometryRings(centre)) {
|
||||
const profile = exteriorBoundaryProfile(ring, outer, eps);
|
||||
if (!profile || !profile.offsets.some((o) => o > 0)) continue;
|
||||
const outset = outsetContour(profile.poly, profile.offsets);
|
||||
const inset = insetContour(profile.poly, profile.offsets);
|
||||
if (!outset || !inset) continue;
|
||||
const piece = difference(closedRing(outset) as any, closedRing(inset) as any);
|
||||
shell = shell ? union(shell, piece) : piece;
|
||||
}
|
||||
return { centre, shell: shell || [] };
|
||||
}
|
||||
|
||||
function polyclipToPathD(geom: any): string {
|
||||
if (!geom) return '';
|
||||
let d = '';
|
||||
@@ -1368,7 +1475,7 @@ export function wallBodiesGeometry(
|
||||
gridPitch: number,
|
||||
coordScale = 1,
|
||||
extraBodies: number[][][] = [],
|
||||
): { geom: any; depthUnits: number } | null {
|
||||
): { geom: any; paperGeom: any; depthUnits: number } | null {
|
||||
if (!walls?.length && !extraBodies.length) return null;
|
||||
const roomRings: { outset: number[][]; inset: number[][] | null }[] = [];
|
||||
let maxDepth = 0;
|
||||
@@ -1404,17 +1511,54 @@ export function wallBodiesGeometry(
|
||||
? openingWallIndex(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale)
|
||||
: null;
|
||||
try {
|
||||
const exterior = exteriorEnvelopeGeometry(
|
||||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||||
);
|
||||
// Paper and masonry share this one structural pass. Renderers cache the
|
||||
// returned pair, so a live HA state update never repeats exterior topology.
|
||||
const paperGeom = exterior
|
||||
? (exterior.shell?.length ? union(exterior.centre, exterior.shell) : exterior.centre)
|
||||
: [];
|
||||
const bodyOf = (ring: typeof roomRings[number]): any => {
|
||||
const outset: any = closedRing(ring.outset);
|
||||
return ring.inset ? difference(outset, closedRing(ring.inset) as any) : outset;
|
||||
};
|
||||
let body: any = roomRings.length ? bodyOf(roomRings[0]) : null;
|
||||
for (let i = 1; i < roomRings.length; i++) body = union(body, bodyOf(roomRings[i]));
|
||||
let body: any = null;
|
||||
for (const ring of roomRings) {
|
||||
try {
|
||||
const piece = bodyOf(ring);
|
||||
body = body ? union(body, piece) : piece;
|
||||
} catch {
|
||||
// An acute child contour may be invalid for boolean subtraction. The
|
||||
// interval pass below still supplies its physical wall without letting
|
||||
// the artificial mitre back into the exterior envelope.
|
||||
}
|
||||
}
|
||||
// Per-room rings preserve established L/T/nested joins. Atomic quads are
|
||||
// also included so a rejected acute child ring cannot remove a divider or
|
||||
// an interior half-wall. Clipping them to the centre union gives a hard
|
||||
// facade boundary; the canonical exterior shell is added afterwards.
|
||||
if (exterior) {
|
||||
for (const edge of wallEdgeBodies(
|
||||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||||
)) {
|
||||
try {
|
||||
const piece = intersection(closedRing(edge.quad) as any, exterior.centre);
|
||||
body = body ? union(body, piece) : piece;
|
||||
} catch {
|
||||
// A valid per-room ring may already own this interval. If neither
|
||||
// representation is usable the final result fails closed below.
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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 = body ? union(body, closedRing(patch) as any) : closedRing(patch);
|
||||
if (body && exterior) body = intersection(body, exterior.centre);
|
||||
if (exterior?.shell?.length)
|
||||
body = body ? union(body, exterior.shell) : exterior.shell;
|
||||
// cut opening tunnels (axis-aligned to opening angle)
|
||||
for (const o of openings) {
|
||||
if (!(o.length > 0)) continue;
|
||||
@@ -1439,7 +1583,7 @@ export function wallBodiesGeometry(
|
||||
if (extra.length < 3) continue;
|
||||
body = body ? union(body, closedRing(extra) as any) : [closedRing(extra)];
|
||||
}
|
||||
return { geom: body || [], depthUnits: maxDepth };
|
||||
return { geom: body || [], paperGeom, depthUnits: maxDepth };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -1457,31 +1601,19 @@ export function wallBodiesUnionPath(
|
||||
/** Independent physical bodies are unioned only after room openings are cut,
|
||||
* so a door/window/gate can never punch a coincident partition or column. */
|
||||
extraBodies: number[][][] = [],
|
||||
): { d: string; depthUnits: number; fillRule: 'evenodd' | 'nonzero' } | null {
|
||||
): { d: string; paperD: string; depthUnits: number; fillRule: 'evenodd' | 'nonzero' } | null {
|
||||
if (!walls?.length && !extraBodies.length) return null;
|
||||
const united = wallBodiesGeometry(
|
||||
rooms, walls, openCuts, openings, pitch, cellCm, gridPitch, coordScale, extraBodies,
|
||||
);
|
||||
const d = united ? polyclipToPathD(united.geom) : '';
|
||||
if (united && d) return { d, depthUnits: united.depthUnits, fillRule: 'evenodd' };
|
||||
const paperD = united ? polyclipToPathD(united.paperGeom) : '';
|
||||
if (united && d) return { d, paperD, depthUnits: united.depthUnits, fillRule: 'evenodd' };
|
||||
if (united) return null; // successful empty result: do not resurrect raw rings
|
||||
// fall back to evenodd rings concatenated
|
||||
const rings = wallBodyRings(rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
|
||||
const extraD = extraBodies.map((poly) => polyToPath(poly)).join(' ');
|
||||
if (!rings.length && !extraD) return null;
|
||||
// `united` is null on this branch: a successful-but-empty union returned
|
||||
// above and must not be resurrected by the raw-ring fallback.
|
||||
let maxDepth = 0;
|
||||
for (const ring of rings) maxDepth = Math.max(maxDepth, ring.depthUnits);
|
||||
// Each room ring already reverses its inset. `nonzero` therefore keeps
|
||||
// floors as holes while overlapping independent rings add instead of
|
||||
// cancelling one another (the old even-odd fallback produced pinholes at
|
||||
// exactly the complex junctions for which a fallback is needed).
|
||||
return {
|
||||
d: [rings.map((r) => r.d).join(' '), extraD].filter(Boolean).join(' '),
|
||||
depthUnits: maxDepth,
|
||||
fillRule: 'nonzero',
|
||||
};
|
||||
// Fail closed. The old raw per-room-ring fallback is the exact algorithm
|
||||
// that creates an exterior tooth at a corner Split, so resurrecting it after
|
||||
// a boolean failure would make malformed input violate the facade invariant.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1666,22 +1798,28 @@ export function paperRoomShapesWithWalls(
|
||||
cellCm: number,
|
||||
gridPitch: number,
|
||||
coordScale = 1,
|
||||
): Array<{ poly: string } | { rect: { x: number; y: number; w: number; h: number; rx: number } }> {
|
||||
const out: Array<{ poly: string } | { rect: { x: number; y: number; w: number; h: number; rx: number } }> = [];
|
||||
for (const r of rooms || []) {
|
||||
const poly = roomPoly(r);
|
||||
if (poly && poly.length >= 3) {
|
||||
const pr = roomWallProfile(rooms, r.id, walls, openCuts, pitch, cellCm, gridPitch, coordScale);
|
||||
const grown = pr && pr.offsets.some((o) => o > 0)
|
||||
? outsetContour(pr.poly, pr.offsets)
|
||||
: null;
|
||||
const use = grown || poly;
|
||||
out.push({ poly: use.map((p) => p.join(',')).join(' ') });
|
||||
} else if (r && r.x != null && r.y != null && r.w != null && r.h != null) {
|
||||
out.push({ rect: { x: r.x, y: r.y, w: r.w, h: r.h, rx: Math.min(r.w, r.h) * 0.03 } });
|
||||
): Array<
|
||||
| { path: string }
|
||||
| { poly: string }
|
||||
| { rect: { x: number; y: number; w: number; h: number; rx: number } }
|
||||
> {
|
||||
if (!walls?.length) return paperRoomShapes(rooms);
|
||||
try {
|
||||
const exterior = exteriorEnvelopeGeometry(
|
||||
rooms, walls, openCuts, pitch, cellCm, gridPitch, coordScale,
|
||||
);
|
||||
if (exterior) {
|
||||
const paper = exterior.shell?.length
|
||||
? union(exterior.centre, exterior.shell)
|
||||
: exterior.centre;
|
||||
const path = polyclipToPathD(paper);
|
||||
if (path) return [{ path }];
|
||||
}
|
||||
} catch {
|
||||
// Safe fallback below: exact room centrelines never reproduce the known
|
||||
// exterior Split spike, even when boolean offsetting rejected bad input.
|
||||
}
|
||||
return out;
|
||||
return paperRoomShapes(rooms);
|
||||
}
|
||||
|
||||
interface OpeningWallEdge {
|
||||
|
||||
@@ -71,7 +71,7 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
|
||||
'tray-narrow', 'opaque-glow-two-doorways', 'filled-tunnel', 'opening-placement',
|
||||
'backup-full', 'backup-space', 'value-badge-positions', 'isometric-geometry',
|
||||
'isometric-live-layers', 'isometric-no-borders', 'isometric-touch-kiosk',
|
||||
'isometric-large-warm-remount'])
|
||||
'isometric-large-warm-remount', 'split-corner-wall'])
|
||||
assert.equal(ids.includes(token), true, token);
|
||||
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.mode)).has('plan'), true);
|
||||
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.mode)).has('devices'), true);
|
||||
@@ -80,6 +80,25 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
|
||||
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.theme)).has('dark'), true);
|
||||
});
|
||||
|
||||
test('corner Split golden captures before, thin and thick facade states', () => {
|
||||
const scenarios = GOLDEN_SCENARIOS.filter((scenario) => scenario.cornerSplitWall);
|
||||
assert.deepEqual(scenarios.map((scenario) => scenario.cornerSplitWall), ['before', 'thin', 'thick']);
|
||||
for (const scenario of scenarios) {
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
assert.ok(space);
|
||||
assert.equal(space.settings.show_borders, true);
|
||||
assert.equal(space.rooms.length, scenario.cornerSplitWall === 'before' ? 1 : 2);
|
||||
if (scenario.cornerSplitWall !== 'before') {
|
||||
const divider = space.walls.find((wall) => (
|
||||
wall.a?.[0] === 0.10 && wall.a?.[1] === 0.10
|
||||
&& wall.b?.[0] === 0.90 && wall.b?.[1] === 0.50
|
||||
));
|
||||
assert.equal(divider?.cm, scenario.cornerSplitWall === 'thin' ? 15 : 100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('filled opening golden has a pixel-level seam detector', () => {
|
||||
const scenario = GOLDEN_SCENARIOS.find((item) => item.id === 'openings-filled-tunnel-dark');
|
||||
assert.ok(scenario);
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
setWallThickness, setWallThicknessForRoom, applyWallThicknessToNewRoom,
|
||||
drawWallPreviewD, DRAW_WALL_DEFAULT_CM, clampWallCm, cmToField, fieldToCm,
|
||||
wallCmToUnits, insetContour, inwardNormal, edgeKinds, wallEdgeBodies,
|
||||
wallBodyRings, wallBodiesUnionPath, innerContourForRoom,
|
||||
wallBodyRings, wallBodiesGeometry, wallBodiesUnionPath, innerContourForRoom,
|
||||
paperRoomShapesWithWalls, WALL_MIN_CM, WALL_MAX_CM, MITRE_LIMIT,
|
||||
atomicPolyForRoom, insetOffsetsForRoom, wallIntervals, materializeWallIntervals,
|
||||
normalizeWallIntervals,
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
openingTunnelGeometries, tunnelFacePath,
|
||||
WALL_HATCH_MIN_PX,
|
||||
} from '../test-build/wall-thickness.js';
|
||||
import { polygonArea, paperRoomShapes } from '../test-build/logic.js';
|
||||
import { polygonArea, paperRoomShapes, splitRoomPath, sharedBoundary } from '../test-build/logic.js';
|
||||
import { GRID_PITCH } from '../test-build/space-geometry.js';
|
||||
import { geometryArea } from '../test-build/physical-geometry.js';
|
||||
import { difference, union } from 'polyclip-ts';
|
||||
|
||||
const closeTo = (got, want, tol = 1e-6) =>
|
||||
assert.ok(Math.abs(got - want) <= tol, `expected ${want}, got ${got}`);
|
||||
@@ -23,6 +25,59 @@ const closeTo = (got, want, tol = 1e-6) =>
|
||||
const pitch = 1 / 240; // normalised grid step
|
||||
const cellCm = 5;
|
||||
|
||||
const closedGeometry = (poly) => {
|
||||
const ring = [...poly, poly[0]].map((point) => [...point]);
|
||||
return [[ring]];
|
||||
};
|
||||
|
||||
const geometryBounds = (geom) => {
|
||||
const points = geom.flat(2);
|
||||
return [
|
||||
Math.min(...points.map((point) => point[0])),
|
||||
Math.min(...points.map((point) => point[1])),
|
||||
Math.max(...points.map((point) => point[0])),
|
||||
Math.max(...points.map((point) => point[1])),
|
||||
];
|
||||
};
|
||||
|
||||
const geometryDifferenceArea = (a, b) => geometryArea(difference(a, b));
|
||||
|
||||
function cornerSplitFixture({
|
||||
poly = [[100, 100], [900, 100], [900, 700], [100, 700]],
|
||||
path = [[100, 100], [900, 500]],
|
||||
outerCm = 15,
|
||||
dividerCm = 15,
|
||||
outerOverrides = [],
|
||||
} = {}) {
|
||||
const original = { id: 'source', poly: poly.map((point) => [...point]) };
|
||||
const split = splitRoomPath(original.poly, path);
|
||||
assert.ok(split, 'fixture must be a valid corner split');
|
||||
|
||||
let walls = outerCm > 0
|
||||
? applyWallThicknessToNewRoom([], [original], original.id, outerCm, pitch)
|
||||
: [];
|
||||
for (const [a, b, cm] of outerOverrides)
|
||||
walls = setWallThickness(walls, a, b, cm, pitch);
|
||||
const before = walls.length
|
||||
? wallBodiesGeometry([original], walls, [], [], pitch, cellCm, GRID_PITCH)
|
||||
: null;
|
||||
|
||||
walls = materializeWallIntervals([original], walls, [], pitch, cellCm, GRID_PITCH);
|
||||
const rooms = [
|
||||
{ id: 'source', poly: split[0] },
|
||||
{ id: 'fresh', poly: split[1] },
|
||||
];
|
||||
const divider = sharedBoundary(rooms[0].poly, rooms[1].poly);
|
||||
assert.equal(divider.length, 1);
|
||||
walls = setWallThickness(
|
||||
walls, divider[0].slice(0, 2), divider[0].slice(2), dividerCm, pitch,
|
||||
);
|
||||
walls = normalizeWallIntervals(rooms, walls, [], pitch, cellCm, GRID_PITCH);
|
||||
const after = wallBodiesGeometry(rooms, walls, [], [], pitch, cellCm, GRID_PITCH);
|
||||
assert.ok(after, `wall geometry missing for outer=${outerCm}, divider=${dividerCm}`);
|
||||
return { original, rooms, walls, before, after };
|
||||
}
|
||||
|
||||
// ------------------------------- key ----------------------------------------
|
||||
|
||||
test('wallKey is the same from either end of the wall', () => {
|
||||
@@ -687,6 +742,123 @@ test('wallBodiesUnionPath: a parent floor never erases a nested room wall', () =
|
||||
assert.ok((united.d.match(/M/g) || []).length >= 4, united.d);
|
||||
});
|
||||
|
||||
test('corner Split keeps the original exterior wall body and paper', () => {
|
||||
const { original, rooms, walls, before, after } = cornerSplitFixture();
|
||||
assert.ok(before);
|
||||
assert.deepEqual(geometryBounds(after.geom), geometryBounds(before.geom));
|
||||
|
||||
const centre = closedGeometry(original.poly);
|
||||
const beforeExterior = difference(before.geom, centre);
|
||||
const afterExterior = difference(after.geom, centre);
|
||||
closeTo(geometryDifferenceArea(beforeExterior, afterExterior), 0, 1e-7);
|
||||
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
|
||||
closeTo(geometryDifferenceArea(before.paperGeom, after.paperGeom), 0, 1e-7);
|
||||
closeTo(geometryDifferenceArea(after.paperGeom, before.paperGeom), 0, 1e-7);
|
||||
|
||||
const paper = paperRoomShapesWithWalls(
|
||||
rooms, walls, [], pitch, cellCm, GRID_PITCH,
|
||||
);
|
||||
assert.equal(paper.length, 1);
|
||||
assert.ok('path' in paper[0]);
|
||||
const nums = paper[0].path.match(/-?\d+(?:\.\d+)?/g).map(Number);
|
||||
const paperPoints = [];
|
||||
for (let i = 0; i < nums.length; i += 2) paperPoints.push([nums[i], nums[i + 1]]);
|
||||
assert.deepEqual(geometryBounds([[paperPoints]]), geometryBounds(before.geom));
|
||||
|
||||
const canonical = wallBodiesUnionPath(
|
||||
rooms, walls, [], [], pitch, cellCm, GRID_PITCH,
|
||||
);
|
||||
assert.ok(canonical?.paperD, 'canonical render pass must include its paper path');
|
||||
assert.equal(canonical.paperD, paper[0].path);
|
||||
});
|
||||
|
||||
test('corner Split clips every divider thickness when exterior walls are absent', () => {
|
||||
for (const dividerCm of [1, 15, 100]) {
|
||||
const { original, after } = cornerSplitFixture({ outerCm: 0, dividerCm });
|
||||
closeTo(geometryArea(difference(after.geom, closedGeometry(original.poly))), 0, 1e-7);
|
||||
}
|
||||
});
|
||||
|
||||
test('corner Split preserves the facade for thin and thick outer/divider matrices', () => {
|
||||
for (const outerCm of [1, 15, 100]) {
|
||||
for (const dividerCm of [0, 1, 15, 100]) {
|
||||
const { original, before, after } = cornerSplitFixture({ outerCm, dividerCm });
|
||||
assert.ok(before);
|
||||
const centre = closedGeometry(original.poly);
|
||||
const beforeExterior = difference(before.geom, centre);
|
||||
const afterExterior = difference(after.geom, centre);
|
||||
closeTo(geometryDifferenceArea(beforeExterior, afterExterior), 0, 1e-7);
|
||||
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('corner Split keeps unequal exterior arms and is order/id/winding independent', () => {
|
||||
const fixture = cornerSplitFixture({
|
||||
outerOverrides: [
|
||||
[[100, 100], [900, 100], 5],
|
||||
[[100, 700], [100, 100], 40],
|
||||
],
|
||||
dividerCm: 100,
|
||||
});
|
||||
const shuffled = fixture.rooms
|
||||
.map((room, at) => ({ id: `renamed-${at}`, poly: [...room.poly].reverse() }))
|
||||
.reverse();
|
||||
const permuted = wallBodiesGeometry(
|
||||
shuffled, fixture.walls, [], [], pitch, cellCm, GRID_PITCH,
|
||||
);
|
||||
assert.ok(permuted);
|
||||
closeTo(geometryDifferenceArea(fixture.after.geom, permuted.geom), 0, 1e-7);
|
||||
closeTo(geometryDifferenceArea(permuted.geom, fixture.after.geom), 0, 1e-7);
|
||||
|
||||
const centre = closedGeometry(fixture.original.poly);
|
||||
const beforeExterior = difference(fixture.before.geom, centre);
|
||||
const afterExterior = difference(fixture.after.geom, centre);
|
||||
closeTo(geometryDifferenceArea(beforeExterior, afterExterior), 0, 1e-7);
|
||||
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
|
||||
});
|
||||
|
||||
test('Split from a concave vertex does not turn the child mitre into facade', () => {
|
||||
const poly = [[100, 100], [900, 100], [900, 800], [600, 800], [600, 400], [100, 400]];
|
||||
const fixture = cornerSplitFixture({ poly, path: [[600, 400], [900, 250]], dividerCm: 100 });
|
||||
const centre = closedGeometry(poly);
|
||||
const beforeExterior = difference(fixture.before.geom, centre);
|
||||
const afterExterior = difference(fixture.after.geom, centre);
|
||||
closeTo(geometryDifferenceArea(beforeExterior, afterExterior), 0, 1e-7);
|
||||
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
|
||||
});
|
||||
|
||||
test('Split with both endpoints at exterior vertices preserves both corners', () => {
|
||||
const fixture = cornerSplitFixture({ path: [[100, 100], [900, 700]], dividerCm: 100 });
|
||||
const centre = closedGeometry(fixture.original.poly);
|
||||
const beforeExterior = difference(fixture.before.geom, centre);
|
||||
const afterExterior = difference(fixture.after.geom, centre);
|
||||
closeTo(geometryDifferenceArea(beforeExterior, afterExterior), 0, 1e-7);
|
||||
closeTo(geometryDifferenceArea(afterExterior, beforeExterior), 0, 1e-7);
|
||||
});
|
||||
|
||||
test('corner Split clean floors are exactly the room union minus canonical walls', () => {
|
||||
const fixture = cornerSplitFixture({ dividerCm: 100 });
|
||||
const floors = fixture.rooms.map((room) => innerContourForRoom(
|
||||
fixture.rooms, room.id, fixture.walls, [], pitch, cellCm, GRID_PITCH,
|
||||
));
|
||||
assert.ok(floors.every(Boolean));
|
||||
const actual = union(...floors.map((floor) => closedGeometry(floor)));
|
||||
const expected = difference(closedGeometry(fixture.original.poly), fixture.after.geom);
|
||||
closeTo(geometryDifferenceArea(actual, expected), 0, 1e-7);
|
||||
closeTo(geometryDifferenceArea(expected, actual), 0, 1e-7);
|
||||
});
|
||||
|
||||
test('corner Split rendering does not materialize or mutate saved geometry', () => {
|
||||
const fixture = cornerSplitFixture({ dividerCm: 100 });
|
||||
const rooms = structuredClone(fixture.rooms);
|
||||
const walls = structuredClone(fixture.walls);
|
||||
const before = JSON.stringify({ rooms, walls });
|
||||
assert.ok(wallBodiesGeometry(rooms, walls, [], [], pitch, cellCm, GRID_PITCH));
|
||||
assert.ok(paperRoomShapesWithWalls(rooms, walls, [], pitch, cellCm, GRID_PITCH).length);
|
||||
assert.equal(JSON.stringify({ rooms, walls }), before);
|
||||
});
|
||||
|
||||
test('paper with walls covers shared centreline; without walls matches paperRoomShapes', () => {
|
||||
const rooms = [
|
||||
{ id: 'a', poly: [[0, 0], [5, 0], [5, 4], [0, 4]] },
|
||||
@@ -698,9 +870,8 @@ test('paper with walls covers shared centreline; without walls matches paperRoom
|
||||
|
||||
const walls = [{ key: wallKey([5, 0], [5, 4], pitch), cm: 20 }];
|
||||
const grown = paperRoomShapesWithWalls(rooms, walls, [], pitch, cellCm, pitch);
|
||||
assert.equal(grown.length, 2);
|
||||
// grown polys are still present (strings)
|
||||
assert.ok('poly' in grown[0]);
|
||||
assert.equal(grown.length, 1);
|
||||
assert.ok('path' in grown[0], 'wall-aware paper is one canonical union path');
|
||||
});
|
||||
|
||||
test('area of the room polygon is unchanged by thickness helpers', () => {
|
||||
|
||||
Reference in New Issue
Block a user