fix: isolate degenerate junction patches

Issue: #197
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-19 13:02:12 +03:00
parent bd9409b33d
commit db57180956
18 changed files with 1264 additions and 314 deletions
File diff suppressed because one or more lines are too long
+19
View File
@@ -1,5 +1,10 @@
import { makeLargeHouseFixture } from '../fixtures/large-house.mjs';
import { fixtureWallKey, makeVisualMatrixFixture } from '../fixtures/visual-matrix.mjs';
import { readFileSync } from 'node:fs';
const junctionPatchFixture = JSON.parse(readFileSync(
new URL('../../test/fixtures/197-junction-patch.json', import.meta.url), 'utf8',
));
const fixtureFor = (scenario) => scenario.fixture === 'large'
? makeLargeHouseFixture()
@@ -139,6 +144,20 @@ export function prepareGoldenFixture(scenario) {
wall_columns: [],
});
}
if (scenario.junctionPatchResilience) {
fixture.config.spaces.push({
...structuredClone(junctionPatchFixture),
id: scenario.space,
title: 'Junction patch resilience',
view_box: [0, 0, 1, 1],
settings: {
...(junctionPatchFixture.settings || {}),
fill_mode: 'none',
show_borders: true,
show_names: false,
},
});
}
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}`);
+7 -1
View File
@@ -1,7 +1,7 @@
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 = 29;
export const GOLDEN_MATRIX_VERSION = 30;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -93,6 +93,12 @@ export const GOLDEN_SCENARIOS = Object.freeze([
{ id: 'wall-junctions-view-dark', fixture: 'visual',
space: 'golden-wall-junctions', wallJunctions: true, mode: 'view',
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'junction-patch-resilience-plan-dark', fixture: 'visual',
space: 'golden-junction-patch-resilience', junctionPatchResilience: true, mode: 'plan',
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'junction-patch-resilience-view-dark', fixture: 'visual',
space: 'golden-junction-patch-resilience', junctionPatchResilience: true, mode: 'view',
theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'isometric-wall-junctions-dark', fixture: 'visual',
space: 'golden-wall-junctions', wallJunctions: true, mode: 'view',
labs: ['iso'], projection: 'iso', ...expiredIsoFixture,
+142
View File
@@ -0,0 +1,142 @@
/** Issue #197: one noisy virtual-junction patch never blanks canonical masonry. */
import { readFileSync } from 'node:fs';
import { launch, checkAll, finish } from './serve.mjs';
const fixture = JSON.parse(readFileSync(
new URL('../test/fixtures/197-junction-patch.json', import.meta.url), 'utf8',
));
const { page, browser } = await launch({ width: 1000, height: 860 }, 1);
const result = await page.evaluate(async (spaceFixture) => {
const out = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const settle = async () => {
await card.updateComplete;
while (card._modeTransitionBusy) await frame();
await frame();
};
const update = async (structural = false) => {
if (structural) {
card._cfgEpoch++;
card._modelCache = null;
card._frame = null;
card._wallUnionCache = null;
card._physicalBodiesCache = null;
card._lightBarrierCache = null;
card._isoGeometryCache.clear();
}
card.requestUpdate();
await settle();
};
const cfg = {
...structuredClone(card._serverCfg),
spaces: [{
...structuredClone(spaceFixture),
view_box: [0, 0, 1, 1],
settings: {
...(spaceFixture.settings || {}),
show_borders: true,
fill_mode: 'none',
},
}],
markers: [],
};
card._serverCfg = structuredClone(cfg);
card._layout = {};
card._space = spaceFixture.id;
card._setMode('plan');
card._tool = 'select';
await update(true);
const sourceBefore = JSON.stringify(card._serverCfg.spaces[0]);
out.fixtureSpaceSelected = card._spaceModel()?.id === spaceFixture.id;
out.fixtureShapeLoaded = card._spaceModel()?.rooms.length === 8
&& card._spaceWalls.length === 25
&& card._openCuts().length === 3;
out.planModeSettled = card._mode === 'plan';
const planPath = root().querySelector('[data-hp="wall"]');
const planD = planPath?.getAttribute('d') || '';
const canonical = card._wallUnionGeometry();
out.planKeepsMasonry = !!planD && !!planPath && canonical?.d === planD;
out.paperKeepsFootprint = !!canonical?.paperD && !!root().querySelector('.hp-paper');
const model = card._spaceModel();
const polys = model.rooms.map((room) => ({ r: room, poly: room.poly }));
const barriers = card._lightBarriers(model, polys, card._physicalBodiesR(model));
out.lightAndSunKeepCanonicalMasonry = barriers.masonryGeometry.flat(2).length > 0
&& barriers.occluders.length > 0
&& !!barriers.fingerprint;
const barrierFingerprint = barriers.fingerprint;
const cacheBeforeState = card._wallUnionCache;
card.hass = {
...card.hass,
themes: { ...(card.hass.themes || {}), darkMode: !card.hass.themes?.darkMode },
states: {
...card.hass.states,
'sensor.issue_197_tick': {
entity_id: 'sensor.issue_197_tick', state: '1', attributes: {},
},
},
};
await update(false);
out.themeAndStateKeepPath = root().querySelector('[data-hp="wall"]')?.getAttribute('d') === planD;
out.stateTickReusesGeometry = card._wallUnionCache === cacheBeforeState;
out.stateTickKeepsBarrierFingerprint = card._lightBarriers(
model, polys, card._physicalBodiesR(model),
).fingerprint === barrierFingerprint;
card._setMode('view');
await update(false);
out.viewMatchesPlan = root().querySelector('[data-hp="wall"]')?.getAttribute('d') === planD;
card._hoverRoom = { space: spaceFixture.id, room: model.rooms[0] };
const hoverFloor = card._roomHoverPaths(model);
out.cleanFloorConsumerStaysNonEmpty = !!hoverFloor?.fillD && !!hoverFloor.outlineD;
card._hoverRoom = null;
const kioskBefore = card._config.kiosk;
card._config.kiosk = true;
await update(false);
out.kioskMatchesPlan = root().querySelector('[data-hp="wall"]')?.getAttribute('d') === planD;
card._config.kiosk = kioskBefore;
await update(false);
await customElements.whenDefined('houseplan-space-card');
const staticCard = document.createElement('houseplan-space-card');
const baseCall = card.hass.callWS.bind(card.hass);
staticCard.setConfig({
type: 'custom:houseplan-space-card', space: spaceFixture.id, show_button: false,
});
staticCard.hass = { ...card.hass, callWS: async (message) => {
if (message.type === 'houseplan/config/get') return { config: structuredClone(cfg), rev: 1 };
if (message.type === 'houseplan/layout/get') return { layout: {}, rev: 1 };
return baseCall(message);
} };
document.body.appendChild(staticCard);
const started = Date.now();
while (!staticCard.renderRoot?.querySelector('[data-hp="wall"]')
&& Date.now() - started < 6000) {
await new Promise((done) => setTimeout(done, 60));
}
await staticCard.updateComplete;
out.staticMatchesPlan = staticCard.renderRoot
?.querySelector('[data-hp="wall"]')?.getAttribute('d') === planD;
staticCard.remove();
const labs = Object.freeze(['iso']);
card._onLabsSnapshot({ active: labs, space: '' });
window.__hpLabs = labs;
card._setProjection('iso');
await update(false);
out.hiddenIsoKeepsMasonry = !!root().querySelector('[data-hp="iso-walls"]')
&& card._isoSource().build().walls.flat(2).length > 0;
out.renderNeverWritesConfig = JSON.stringify(card._serverCfg.spaces[0]) === sourceBefore;
return out;
}, fixture);
checkAll(result);
await finish(browser, result);
File diff suppressed because one or more lines are too long
+101 -101
View File
File diff suppressed because one or more lines are too long
+9
View File
@@ -386,6 +386,15 @@ opening is subtracted from its explicit raw body before that union. It also
cuts a derived room wall only when the wall is exactly collinear and covers
the complete hosted interval; crossing or nearby bodies remain opaque and room
exterior authority remains intact.
Virtual-wall junction patches are computed, scale-relatively normalised below
the geometry epsilon, and unioned one at a time. Each such union is an optional
transaction: a malformed/degenerate patch retains the previous canonical body
and later patches still run. The surrounding structural pass is deliberately
outside that fallback boundary: an exception escaping the existing
exterior/body/opening/authoritative-extra passes still returns `null` and
activates the established fail-dark consumer behaviour. This distinction
prevents one cosmetic junction repair from blanking a whole plan without
masking a real structural failure (#197).
Before the exterior offset is built, each saved atomic endpoint splits its
containing collinear union edge. Offset changes are explicit butt steps at that
endpoint, including nonzero-to-zero transitions. The topology tolerance starts
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- A numerically degenerate virtual-wall junction can no longer erase every
thick wall after **Optimize plans**. Computed junction patches are stabilised
at sub-geometry precision and isolated individually, while the canonical
masonry, floor, paper and light barriers remain available in Plan, View,
kiosk, the static card and the hidden isometric experiment
([#197](https://github.com/Matysh/houseplan-card/issues/197)).
- Every remaining colour control now uses the same House Plan picker, including
the global light/temperature/LQI/Glow/wall palettes, plan and space
backgrounds, room colour and device activity ripple. Existing opacity appears
+6
View File
@@ -8,6 +8,12 @@
## Unreleased
- Численно вырожденный стык виртуальных стен больше не может скрыть всю
толстую кладку после **«Оптимизировать планы»**. Вычисляемые заплатки стыков
стабилизируются с точностью значительно меньше геометрического допуска и
обрабатываются независимо, а общая кладка, пол, подложка и световые барьеры
остаются доступны в Плане, Просмотре, киоске, статической карточке и скрытой
изометрии ([#197](https://github.com/Matysh/houseplan-card/issues/197)).
- Все оставшиеся настройки цвета теперь используют одну палитру House Plan:
общие цвета света, температуры, LQI, Glow и стен, фон плана и пространства,
цвет комнат и пульсации активности устройства. Существующая прозрачность
+1 -1
View File
@@ -22,7 +22,7 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
| Item | State |
|---|---|
| Version | **v1.65.0-beta.3** everywhere (manifest, const.py, package.json, CARD_VERSION) — prerelease candidate for the current `S8-merged` queue |
| Current local cycle | v1.65.0-beta.3 candidate carries #57, #103, #113, #117, #132, #184, #185, #187 and #189: color and opacity share one picker, toggle confirmations expose their exact result, independent Walls support openings with consistent snap/light geometry, and registry-less opening entities stay operable. Stale space/model references now fail safely. Publication waits for a green exact-SHA Validate and fail-closed prerelease preflight; stable v1.64.0 remains unchanged. |
| Current local cycle | v1.65.0-beta.3 candidate carries #57, #103, #113, #117, #132, #184, #185, #187 and #189: color and opacity share one picker, toggle confirmations expose their exact result, independent Walls support openings with consistent snap/light geometry, and registry-less opening entities stay operable. Stale space/model references now fail safely. Unreleased issue branches add the complete picker migration (#180) and isolate numerically degenerate optional junction patches so one virtual-wall join cannot erase the canonical masonry, paper/floor and light barriers (#197). Publication waits for a green exact-SHA Validate and fail-closed prerelease preflight; stable v1.64.0 remains unchanged. |
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1. #122 Stage 2 ships in v1.64.0 and evolves the same hidden, expiring `iso` experiment with matte walls, a low exterior floor edge, restrained shared shadows and live vertical door/window/gate panels. Flat remains default; editors and `houseplan-space-card` remain flat; live floor effects and HA actions remain unchanged. Public activation remains a separate task. |
| Workflow | Superseded 2026-08-12: the pre-1.62 rule of "local edits without tests or commits" is **dead** — since release 1.62 every product change follows `PROCESS.md` (issue in `S5-ready`+, branch `issue/<NN>-slug`, trailers on every commit, review pipeline; `AGENTS.md` is the summary). Release mechanics below remain current. 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; their labels carry priority and workflow status (`PROCESS.md` §9). GitHub Projects is no longer used. `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
@@ -1327,7 +1327,8 @@ whose hashes no longer match the reviewed manifest. `golden:accept --
error-free report captured from the current source fingerprint; the entire set
is validated before any reference is copied.
The matrix covers thick wall junctions, virtual/physical boundaries,
The matrix covers thick wall junctions, the full #197 multi-room
virtual-junction resilience fixture in Plan and View, virtual/physical boundaries,
partitions/columns, axis-aligned and 45° door/window/gate tunnels, hidden
opening symbols, Glow and sun, live/manual Glow overlap and light through a doorway,
light/temperature/LQI fill splits on a wall axis, hover over Glow and nested rooms, all three editors, dark/light themes,
@@ -2127,6 +2128,16 @@ require hands on real hardware — they remain for the human pass.
paper or light masonry continues along the zero side. Plan, View, static
and hidden Iso consume the same stepped geometry
[auto: smoke_wall_thickness_transition + test/wall-thickness.test.mjs]
- [ ] **One virtual-junction patch cannot blank the plan (#197)**: load the
complete 8-room, 25-wall, 3-cut regression fixture. Its ULP-noisy patch
is stabilised below geometry tolerance; a forced failure of one optional
patch retains the previous body and later patches still run. Plan, View,
kiosk, static and hidden Iso keep one non-empty canonical masonry path;
paper, clean-floor and light/sun consumers stay non-empty, and theme/HA
ticks neither rebuild topology nor write configuration
[auto: test/wall-thickness.test.mjs +
smoke_junction_patch_resilience + junction-patch-resilience golden
scenarios].
- [ ] **Openings cut the slab**: a door/window/gate on a thick wall leaves a gap in
the body; the door swing is offset toward the inner face and gate leaves
toward the exterior face; with
+1
View File
@@ -1348,6 +1348,7 @@ JSON хранит названия, идентификаторы HA и точн
| Появились мелкие отрезки стен | Закройте соседние виртуальные участки; оптимизация объединит одинаковые реальные/виртуальные фрагменты |
| Площадь отличается от внешнего контура | Для толстых стен это ожидаемо: показывается чистая внутренняя площадь |
| После оптимизации сдвинулся старый декор | Объект был сохранён старой версией или импортирован вне сетки; отмените оптимизацию до следующего edit и скорректируйте его на сетке |
| После оптимизации исчезла вся толстая кладка | Обновите House Plan до версии с исправлением #197 и перезагрузите ресурс карточки. Один вырожденный стык больше не скрывает остальные стены; если проблема остаётся, отмените оптимизацию до следующего изменения и приложите экспорт пространства к отчёту об ошибке |
| Лучей нет | Нужны `sun.sun`, север, включённые лучи, наружное окно, высота солнца ≥3° и подходящее направление |
### Пылесос не движется
+17 -2
View File
@@ -115,6 +115,15 @@ The virtual segment itself remains centreline-based. View paints it before the
wall body, which masks the part inside each adjoining thick jamb; editors paint
it after the body so its full stored extent and live preview stay visible.
Computed virtual-wall junction patches cross the polygon-boolean boundary only
after scale-relative sub-epsilon coordinate stabilisation. They are optional
local additions: each patch union is transactional, so an invalid, zero-area or
numerically rejected patch keeps the last valid body and does not prevent later
patches. This fallback never rounds persisted rooms, walls or open spans to the
grid and never turns a failure of the mandatory exterior/body/opening passes
into a successful result. One noisy junction therefore cannot remove otherwise
valid masonry, paper, floor faces or light barriers for the whole space (#197).
## 4. Floor, fills, light, area
- **Inner contour** = `inset(poly, half[])`.
@@ -174,7 +183,9 @@ clipping and room-side colour ownership; whole and
atomic rekey after edge/scale; corner Split exterior equality across
0/1/15/100 cm, unequal arms, both windings and convex/concave endpoints;
production-scale `0 ↔ 10`, `10 ↔ 20` and `1 ↔ 100` collinear transitions at
their exact endpoint.
their exact endpoint; full 8-room/25-wall/3-cut virtual-junction resilience,
ULP-equivalent patch vertices, per-patch failure isolation and record-order
invariance (#197).
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
@@ -185,7 +196,11 @@ door/window/gate tunnels repeat outer/shared room fills without an axis seam
Plan/View/kiosk/static/isometric surfaces and the light barrier
(`demo/smoke_split_corner_wall.mjs`); a Split followed by all-room thickness
keeps the neighbouring zero facade clear across Plan, View, static, hidden Iso
and light masonry (`demo/smoke_wall_thickness_transition.mjs`).
and light masonry (`demo/smoke_wall_thickness_transition.mjs`); the complete
#197 fixture keeps the same non-empty canonical path across Plan, View, kiosk,
static, hidden Iso, paper/clean-floor and light/sun consumers while theme and HA
state ticks reuse the structural geometry
(`demo/smoke_junction_patch_resilience.mjs`).
## 9. Independent partitions, drafts and columns
@@ -147,7 +147,6 @@ source rewriting; точная публичность helper не являетс
Дополнительные исполняемые проверки отделяют причину от корреляций:
- удаление 15-см микро-интервала оставляет результат `null`;
- слияние микро-интервала с соседним участком 22 см оставляет результат `null`;
- удаление open cuts, которое отключает virtual-junction pass, даёт валидную
geometry;
- округление только координат patch с технической точностью от `10⁻⁹` до
+57 -3
View File
@@ -1373,6 +1373,61 @@ function closedRing(poly: number[][]): number[][][] {
return [ring];
}
/**
* Collapse arithmetic noise on computed junction vertices before polyclip sees
* them. The quantum is relative to the caller coordinate scale and remains
* many orders of magnitude below the geometry epsilon: it must never snap a
* physical half-depth or mitre to the drawing grid.
*/
export function stableJunctionPatch(
patch: number[][],
coordScale = 1,
): number[][] | null {
if (!Array.isArray(patch) || patch.length < 3) return null;
const scale = Number.isFinite(coordScale) && coordScale > 0 ? coordScale : 1;
const quantum = Math.max(1, scale) * 1e-12;
const stable: number[][] = [];
for (const point of patch) {
if (!Array.isArray(point) || point.length < 2) return null;
const x = Number(point[0]), y = Number(point[1]);
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
const sx = Math.round(x / quantum) * quantum;
const sy = Math.round(y / quantum) * quantum;
if (!Number.isFinite(sx) || !Number.isFinite(sy)) return null;
stable.push([Object.is(sx, -0) ? 0 : sx, Object.is(sy, -0) ? 0 : sy]);
}
return Math.abs(signedArea(stable)) > quantum * quantum ? stable : null;
}
type JunctionUnion = (subject: any, clipping: any) => any;
/**
* Add optional virtual-junction patches transactionally. A single rejected
* patch may retain the local pre-patch contour, but it must not discard the
* already valid masonry for the entire space or prevent later patches.
*/
export function unionJunctionPatches(
body: any,
patches: number[][][],
coordScale = 1,
unionFn: JunctionUnion = union,
): any {
let current = body;
for (const raw of patches || []) {
const patch = stableJunctionPatch(raw, coordScale);
if (!patch) continue;
try {
const piece = closedRing(patch) as any;
const next = current ? unionFn(current, piece) : piece;
current = next;
} catch {
// Keep the last valid body and continue. Returning null here would erase
// every unrelated wall, floor/light barrier and successful later patch.
}
}
return current;
}
interface ExteriorEnvelopeGeometry {
/** Union of room centrelines. Shared Split edges disappear from this shape. */
centre: any;
@@ -1546,7 +1601,7 @@ function polyclipToPathD(geom: any): string {
* restricted to open-span endpoints, so ordinary corners keep the existing
* contour/mitre/bevel implementation unchanged.
*/
function virtualJunctionPatches(
export function virtualJunctionPatches(
rooms: any[],
walls: WallEntry[] | null | undefined,
openCuts: number[][],
@@ -1757,8 +1812,7 @@ export function wallBodiesGeometry(
// 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);
body = unionJunctionPatches(body, junctions, coordScale);
if (body && exterior) body = intersection(body, exterior.centre);
if (exterior?.shell?.length)
body = body ? union(body, exterior.shell) : exterior.shell;
+552
View File
@@ -0,0 +1,552 @@
{
"id": "space_repro",
"title": "repro",
"cell_cm": 5,
"rooms": [
{
"id": "room_1",
"name": "room_1",
"poly": [
[
0.07083333333333333,
0.4375
],
[
0.07083333333333333,
0.05
],
[
0.8875,
0.05
],
[
0.8875,
0.3458333333333333
],
[
0.6916666666666667,
0.3458333333333333
],
[
0.6916666666666667,
0.55
],
[
0.6208333333333333,
0.55
],
[
0.6208333333333333,
0.5333333333333333
],
[
0.42083333333333334,
0.5333333333333333
],
[
0.42083333333333334,
0.4375
]
],
"open_to": [
"room_5",
"room_8"
]
},
{
"id": "room_2",
"name": "room_2",
"poly": [
[
0.20416666666666666,
0.6458333333333334
],
[
0.20416666666666666,
0.4375
],
[
0.42083333333333334,
0.4375
],
[
0.42083333333333334,
0.6458333333333334
]
]
},
{
"id": "room_3",
"name": "room_3",
"poly": [
[
0.2375,
0.8666666666666667
],
[
0.2375,
0.6458333333333334
],
[
0.42083333333333334,
0.6458333333333334
],
[
0.42083333333333334,
0.8666666666666667
]
]
},
{
"id": "room_4",
"name": "room_4",
"poly": [
[
0.07083333333333333,
0.8666666666666667
],
[
0.07083333333333333,
0.6458333333333334
],
[
0.2375,
0.6458333333333334
],
[
0.2375,
0.8666666666666667
]
]
},
{
"id": "room_5",
"name": "room_5",
"poly": [
[
0.42083333333333334,
0.8666666666666667
],
[
0.42083333333333334,
0.5333333333333333
],
[
0.6208333333333333,
0.5333333333333333
],
[
0.6208333333333333,
0.8666666666666667
]
],
"open_to": [
"room_1"
]
},
{
"id": "room_6",
"name": "room_6",
"poly": [
[
0.6208333333333333,
0.8666666666666667
],
[
0.6208333333333333,
0.55
],
[
0.8875,
0.55
],
[
0.8875,
0.8666666666666667
]
]
},
{
"id": "room_7",
"name": "room_7",
"poly": [
[
0.07083333333333333,
0.6458333333333334
],
[
0.07083333333333333,
0.4375
],
[
0.20416666666666666,
0.4375
],
[
0.20416666666666666,
0.6458333333333334
]
]
},
{
"id": "room_8",
"name": "room_8",
"poly": [
[
0.6916666666666667,
0.3458333333333333
],
[
0.9791666666666666,
0.3458333333333333
],
[
0.9791666666666666,
0.55
],
[
0.6916666666666667,
0.55
]
],
"open_to": [
"room_1"
]
}
],
"walls": [
{
"key": "0.479167,0.050000@0.0000",
"cm": 29,
"a": [
0.07083333333333333,
0.05
],
"b": [
0.8875,
0.05
]
},
{
"key": "0.070833,0.245833@1.5706",
"cm": 29,
"a": [
0.07083333333333333,
0.4375
],
"b": [
0.07083333333333333,
0.05
]
},
{
"key": "0.245833,0.437500@0.0000",
"cm": 33,
"a": [
0.42083333333333334,
0.4375
],
"b": [
0.07083333333333333,
0.4375
]
},
{
"key": "0.420833,0.700000@1.5706",
"cm": 20,
"a": [
0.42083333333333334,
0.8666666666666667
],
"b": [
0.42083333333333334,
0.5333333333333333
]
},
{
"key": "0.620833,0.708333@1.5706",
"cm": 20,
"a": [
0.6208333333333333,
0.55
],
"b": [
0.6208333333333333,
0.8666666666666667
]
},
{
"key": "0.887500,0.708333@1.5706",
"cm": 29,
"a": [
0.8875,
0.55
],
"b": [
0.8875,
0.8666666666666667
]
},
{
"key": "0.887500,0.200000@1.5706",
"cm": 29,
"a": [
0.8875,
0.05
],
"b": [
0.8875,
0.3458333333333333
]
},
{
"key": "0.754167,0.550000@0.0000",
"cm": 20,
"a": [
0.6208333333333333,
0.55
],
"b": [
0.8875,
0.55
]
},
{
"key": "0.754167,0.866667@0.0000",
"cm": 29,
"a": [
0.8875,
0.8666666666666667
],
"b": [
0.6208333333333333,
0.8666666666666667
]
},
{
"key": "0.070833,0.758333@1.5706",
"cm": 29,
"a": [
0.07083333333333333,
0.8666666666666667
],
"b": [
0.07083333333333333,
0.6458333333333334
]
},
{
"key": "0.237500,0.758333@1.5706",
"cm": 20,
"a": [
0.2375,
0.8666666666666667
],
"b": [
0.2375,
0.6458333333333334
]
},
{
"key": "0.312500,0.645833@0.0000",
"cm": 20,
"a": [
0.42083333333333334,
0.6458333333333334
],
"b": [
0.20416666666666666,
0.6458333333333334
]
},
{
"key": "0.070833,0.541667@1.5706",
"cm": 29,
"a": [
0.07083333333333333,
0.6458333333333334
],
"b": [
0.07083333333333333,
0.4375
]
},
{
"key": "0.204167,0.541667@1.5706",
"cm": 20,
"a": [
0.20416666666666666,
0.6458333333333334
],
"b": [
0.20416666666666666,
0.4375
]
},
{
"key": "0.420833,0.541667@1.5706",
"cm": 20,
"a": [
0.42083333333333334,
0.4375
],
"b": [
0.42083333333333334,
0.6458333333333334
]
},
{
"key": "0.979167,0.450000@1.5706",
"cm": 28,
"a": [
0.9791666666666666,
0.3458333333333333
],
"b": [
0.9791666666666666,
0.55
]
},
{
"key": "0.520833,0.866667@0.0000",
"cm": 29,
"a": [
0.6208333333333333,
0.8666666666666667
],
"b": [
0.42083333333333334,
0.8666666666666667
]
},
{
"key": "0.791667,0.345833@0.0000",
"cm": 22,
"a": [
0.8875,
0.3458333333333333
],
"b": [
0.6916666666666667,
0.3458333333333333
]
},
{
"key": "0.329167,0.866667@0.0000",
"cm": 29,
"a": [
0.42083333333333334,
0.8666666666666667
],
"b": [
0.2375,
0.8666666666666667
]
},
{
"key": "0.154167,0.645833@0.0000",
"cm": 20,
"a": [
0.07083333333333333,
0.6458333333333334
],
"b": [
0.2375,
0.6458333333333334
]
},
{
"key": "0.154167,0.866667@0.0000",
"cm": 29,
"a": [
0.2375,
0.8666666666666667
],
"b": [
0.07083333333333333,
0.8666666666666667
]
},
{
"key": "0.691667,0.400000@1.5706",
"cm": 22,
"a": [
0.6916666666666667,
0.3458333333333333
],
"b": [
0.6916666666666667,
0.45416666666666666
]
},
{
"key": "0.933333,0.550000@0.0000",
"cm": 22,
"a": [
0.9791666666666666,
0.55
],
"b": [
0.8875,
0.55
]
},
{
"key": "0.933333,0.345833@0.0000",
"cm": 22,
"a": [
0.8888819039326658,
0.3458333333333333
],
"b": [
0.9791666666666666,
0.3458333333333333
]
},
{
"key": "0.887500,0.345833@0.0000",
"cm": 15,
"a": [
0.8875,
0.3458333333333333
],
"b": [
0.8888819039326658,
0.3458333333333333
]
}
],
"open_spans": [
{
"a": [
0.42083333333333334,
0.5333333333333333
],
"b": [
0.6208333333333333,
0.5333333333333333
]
},
{
"a": [
0.6208333333333333,
0.5333333333333333
],
"b": [
0.6208333333333333,
0.55
]
},
{
"a": [
0.6916666666666667,
0.45416666666666666
],
"b": [
0.6916666666666667,
0.55
]
}
]
}
+18 -1
View File
@@ -211,7 +211,7 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.ok(scenario);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.equal(GOLDEN_MATRIX_VERSION, 29);
assert.equal(GOLDEN_MATRIX_VERSION, 30);
assert.equal(space.settings.sun_rays, true);
assert.equal(scenario.northDeg, 90,
'the sign-sensitive golden must keep a non-zero north direction');
@@ -229,6 +229,23 @@ test('sun-ray golden requires browser-painted light from a state-only sun entity
assert.equal(scenario.sunRayPixels.minChannelDelta >= 4, true);
});
test('issue #197 golden keeps the complete junction fixture in Plan and View', () => {
const scenarios = GOLDEN_SCENARIOS.filter((item) =>
item.id.startsWith('junction-patch-resilience-'));
assert.deepEqual(scenarios.map((item) => item.mode).sort(), ['plan', 'view']);
for (const scenario of scenarios) {
assert.equal(scenario.theme, 'dark');
assert.equal(scenario.junctionPatchResilience, true);
const fixture = prepareGoldenFixture(scenario);
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
assert.ok(space);
assert.deepEqual(
[space.rooms.length, space.walls.length, space.open_spans.length],
[8, 25, 3],
);
}
});
test('the open color picker golden covers dark mobile and light desktop themes', () => {
const scenarios = GOLDEN_SCENARIOS.filter((item) => item.dialog === 'decor-color');
assert.deepEqual(scenarios.map(({ id, language, theme, viewport }) => ({
+114 -1
View File
@@ -1,6 +1,7 @@
// Wall thickness pure geometry (docs/WALL-THICKNESS.md §10).
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
wallKey, lookupWall, thicknessCmAt, degradeWalls, rekeyWallsAfterMove,
setWallThickness, setWallThicknessForRoom, applyWallThicknessToNewRoom,
@@ -8,6 +9,7 @@ import {
DRAW_WALL_DEFAULT_CM, clampWallCm, cmToField, fieldToCm,
wallCmToUnits, insetContour, outsetContour, inwardNormal, edgeKinds, wallEdgeBodies,
wallBodyRings, wallBodiesGeometry, wallBodiesUnionPath, floorFootprintGeometry,
virtualJunctionPatches, stableJunctionPatch, unionJunctionPatches,
innerContourForRoom,
paperRoomShapesWithWalls, WALL_MIN_CM, WALL_MAX_CM, MITRE_LIMIT,
atomicPolyForRoom, insetOffsetsForRoom, wallIntervals, materializeWallIntervals,
@@ -17,7 +19,8 @@ import {
WALL_HATCH_MIN_PX,
} from '../test-build/wall-thickness.js';
import { polygonArea, paperRoomShapes, splitRoomPath, sharedBoundary } from '../test-build/logic.js';
import { GRID_PITCH } from '../test-build/space-geometry.js';
import { resolveOpenCuts } from '../test-build/open-spans.js';
import { GRID_PITCH, NORM_W } from '../test-build/space-geometry.js';
import { geometryArea } from '../test-build/physical-geometry.js';
import { difference, intersection, union } from 'polyclip-ts';
@@ -788,6 +791,116 @@ test('wallBodiesUnionPath mitres real arms owned by different rooms at a virtual
);
});
test('issue #197 keeps the full masonry when one virtual-junction patch has ULP noise', () => {
const fixture = JSON.parse(readFileSync(
new URL('./fixtures/197-junction-patch.json', import.meta.url), 'utf8',
));
const rooms = fixture.rooms.map((room) => ({
...room,
poly: room.poly.map(([x, y]) => [x * NORM_W, y * NORM_W]),
}));
const walls = structuredClone(fixture.walls);
const cuts = resolveOpenCuts(rooms, fixture.open_spans, NORM_W, GRID_PITCH * 0.02);
const openings = [];
const extraBodies = [];
const before = JSON.stringify({ rooms, walls, cuts, openings, extraBodies });
assert.deepEqual([rooms.length, walls.length, cuts.length], [8, 25, 3]);
const intervals = wallIntervals(
rooms, walls, cuts, pitch, fixture.cell_cm, GRID_PITCH, NORM_W,
);
const nodeCms = intervals
.filter((iv) => Math.abs(iv.a[1] - 550) < 1e-6
&& Math.abs(iv.b[1] - 550) < 1e-6)
.map((iv) => iv.cm);
assert.ok(nodeCms.includes(20), `junction lost its 20 cm arm: ${nodeCms}`);
const patches = virtualJunctionPatches(
rooms, walls, cuts, pitch, fixture.cell_cm, GRID_PITCH, NORM_W,
);
assert.deepEqual(patches, [[
[620.8333333333334, 550],
[612.5, 550],
[612.5000000000001, 541.6666666666665],
[620.8333333333334, 541.6666666666666],
]]);
const stable = stableJunctionPatch(patches[0], NORM_W);
assert.ok(stable);
assert.equal(stable[1][0], stable[2][0], 'equivalent mitre x coordinates stay forked');
assert.equal(stable[2][1], stable[3][1], 'equivalent mitre y coordinates stay forked');
const normalizedStable = stableJunctionPatch(
patches[0].map(([x, y]) => [x / NORM_W, y / NORM_W]), 1,
);
assert.ok(normalizedStable);
assert.equal(normalizedStable[1][0], normalizedStable[2][0]);
assert.equal(normalizedStable[2][1], normalizedStable[3][1]);
const bounds = (poly) => [
Math.min(...poly.map((point) => point[0])), Math.min(...poly.map((point) => point[1])),
Math.max(...poly.map((point) => point[0])), Math.max(...poly.map((point) => point[1])),
];
bounds(stable).forEach((value, index) => closeTo(
value, bounds(patches[0])[index], 1e-9,
));
const geometry = wallBodiesGeometry(
rooms, walls, cuts, openings, pitch, fixture.cell_cm, GRID_PITCH, NORM_W, extraBodies,
);
assert.ok(geometry, 'one rejected junction patch must not erase the whole plan');
assert.ok(geometry.geom.length > 0);
assert.ok(geometry.paperGeom.length > 0);
closeTo(geometryArea(geometry.geom), 124991.31944444453, 1e-6);
closeTo(geometryArea(geometry.paperGeom), 727303.8194444444, 1e-6);
assert.equal(
JSON.stringify({ rooms, walls, cuts, openings, extraBodies }), before,
'rendering mutated persisted input',
);
const permuted = wallBodiesGeometry(
[...rooms].reverse(), [...walls].reverse(), cuts, openings, pitch,
fixture.cell_cm, GRID_PITCH, NORM_W, extraBodies,
);
assert.ok(permuted);
closeTo(geometryDifferenceArea(geometry.geom, permuted.geom), 0, 1e-7);
closeTo(geometryDifferenceArea(permuted.geom, geometry.geom), 0, 1e-7);
const reversedAndRepeated = wallBodiesGeometry(
rooms, walls.map((wall) => ({ ...wall, a: [...wall.b], b: [...wall.a] })),
cuts, openings, pitch, fixture.cell_cm, GRID_PITCH, NORM_W, extraBodies,
);
assert.ok(reversedAndRepeated);
closeTo(geometryDifferenceArea(geometry.geom, reversedAndRepeated.geom), 0, 1e-7);
closeTo(geometryDifferenceArea(reversedAndRepeated.geom, geometry.geom), 0, 1e-7);
const repeated = wallBodiesGeometry(
rooms, walls, cuts, openings, pitch, fixture.cell_cm, GRID_PITCH, NORM_W, extraBodies,
);
assert.ok(repeated);
closeTo(geometryDifferenceArea(geometry.geom, repeated.geom), 0, 1e-7);
closeTo(geometryDifferenceArea(repeated.geom, geometry.geom), 0, 1e-7);
});
test('junction patch union isolates one failure and continues with later patches', () => {
const patches = [
[[0, 0], [2, 0], [2, 2], [0, 2]],
[[3, 0], [5, 0], [5, 2], [3, 2]],
];
const calls = [];
const result = unionJunctionPatches('initial-body', patches, 1, (body, piece) => {
calls.push({ body, piece });
if (calls.length === 1) throw new Error('controlled first-patch failure');
return 'body-with-second-patch';
});
assert.equal(result, 'body-with-second-patch');
assert.equal(calls.length, 2, 'a failed patch suppressed the following patch');
assert.equal(calls[1].body, 'initial-body', 'failure replaced the last valid body');
let invalidCalls = 0;
assert.equal(unionJunctionPatches('opaque', [
[[0, 0], [Infinity, 0], [0, 1]],
[[0, 0], [1, 0], [2, 0]],
], 1, () => { invalidCalls++; }), 'opaque');
assert.equal(invalidCalls, 0, 'invalid or zero-area patches reached the boolean engine');
});
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);