mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-28 19:01:34 +00:00
File diff suppressed because one or more lines are too long
@@ -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}`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
+101
-101
File diff suppressed because one or more lines are too long
Vendored
+101
-101
File diff suppressed because one or more lines are too long
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Численно вырожденный стык виртуальных стен больше не может скрыть всю
|
||||
толстую кладку после **«Оптимизировать планы»**. Вычисляемые заплатки стыков
|
||||
стабилизируются с точностью значительно меньше геометрического допуска и
|
||||
обрабатываются независимо, а общая кладка, пол, подложка и световые барьеры
|
||||
остаются доступны в Плане, Просмотре, киоске, статической карточке и скрытой
|
||||
изометрии ([#197](https://github.com/Matysh/houseplan-card/issues/197)).
|
||||
- Все оставшиеся настройки цвета теперь используют одну палитру House Plan:
|
||||
общие цвета света, температуры, LQI, Glow и стен, фон плана и пространства,
|
||||
цвет комнат и пульсации активности устройства. Существующая прозрачность
|
||||
|
||||
+1
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -1348,6 +1348,7 @@ JSON хранит названия, идентификаторы HA и точн
|
||||
| Появились мелкие отрезки стен | Закройте соседние виртуальные участки; оптимизация объединит одинаковые реальные/виртуальные фрагменты |
|
||||
| Площадь отличается от внешнего контура | Для толстых стен это ожидаемо: показывается чистая внутренняя площадь |
|
||||
| После оптимизации сдвинулся старый декор | Объект был сохранён старой версией или импортирован вне сетки; отмените оптимизацию до следующего edit и скорректируйте его на сетке |
|
||||
| После оптимизации исчезла вся толстая кладка | Обновите House Plan до версии с исправлением #197 и перезагрузите ресурс карточки. Один вырожденный стык больше не скрывает остальные стены; если проблема остаётся, отмените оптимизацию до следующего изменения и приложите экспорт пространства к отчёту об ошибке |
|
||||
| Лучей нет | Нужны `sun.sun`, север, включённые лучи, наружное окно, высота солнца ≥3° и подходящее направление |
|
||||
|
||||
### Пылесос не движется
|
||||
|
||||
+17
-2
@@ -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
@@ -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
@@ -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
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 }) => ({
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user