mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 20:29:00 +00:00
committed by
claude[bot]
parent
b860ef4c43
commit
087f7cf381
File diff suppressed because one or more lines are too long
@@ -79,6 +79,36 @@ export function prepareGoldenFixture(scenario) {
|
||||
settings: { show_borders: true, fill_mode: 'custom', custom_fill: { c: '#536b82', a: 0.42 } },
|
||||
});
|
||||
}
|
||||
if (scenario.wallJunctions) {
|
||||
const a = [0.06, 0.06], tr = [0.94, 0.06], br = [0.94, 0.94], bl = [0.06, 0.94];
|
||||
const entry = (from, to, cm) => ({
|
||||
key: fixtureWallKey(from, to), a: [...from], b: [...to], cm,
|
||||
});
|
||||
fixture.config.spaces.push({
|
||||
id: scenario.space,
|
||||
title: 'Wall junctions',
|
||||
plan_url: null,
|
||||
view_box: [0, 0, 1, 1],
|
||||
cell_cm: 5,
|
||||
settings: { fill_mode: 'none', show_borders: true, show_names: false },
|
||||
rooms: [{ id: 'junction-room', name: 'Room', area: null, poly: [a, tr, br, bl] }],
|
||||
walls: [entry(a, tr, 10), entry(tr, br, 10), entry(br, bl, 10), entry(bl, a, 10)],
|
||||
partitions: [
|
||||
{ id: 'junction-l-a', a: [0.16, 0.25], b: [0.38, 0.25], cm: 18 },
|
||||
{ id: 'junction-l-b', a: [0.38, 0.25], b: [0.38, 0.46], cm: 30 },
|
||||
{ id: 'junction-oblique-a', a: [0.58, 0.22], b: [0.78, 0.38], cm: 22 },
|
||||
{ id: 'junction-oblique-b', a: [0.78, 0.38], b: [0.62, 0.52], cm: 14 },
|
||||
{ id: 'junction-t-through', a: [0.18, 0.70], b: [0.78, 0.70], cm: 24 },
|
||||
{ id: 'junction-t-branch', a: [0.50, 0.54], b: [0.50, 0.70], cm: 16 },
|
||||
{ id: 'junction-room-branch', a: [0.30, 0.82], b: [0.30, 0.94], cm: 18 },
|
||||
],
|
||||
room_drafts: [{
|
||||
id: 'junction-draft', points: [[0.16, 0.54], [0.30, 0.54], [0.30, 0.64]],
|
||||
segments: [{ cm: 12 }, { cm: 20 }],
|
||||
}],
|
||||
wall_columns: [],
|
||||
});
|
||||
}
|
||||
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}`);
|
||||
@@ -318,6 +348,27 @@ export async function prepareGoldenScenario(page, scenario) {
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
}
|
||||
if (scenario.wallJunctionPreview) {
|
||||
const { path, pointer, cms, cm } = scenario.wallJunctionPreview;
|
||||
const validPoint = (point) => Array.isArray(point) && point.length === 2
|
||||
&& point.every(Number.isFinite);
|
||||
if (!Array.isArray(path) || !path.length || !path.every(validPoint)
|
||||
|| !validPoint(pointer) || !Array.isArray(cms) || !cms.every(Number.isFinite)
|
||||
|| !(Number(cm) > 0)) {
|
||||
throw new Error(`invalid golden wallJunctionPreview contract: ${scenario.id}`);
|
||||
}
|
||||
card._tool = 'draw';
|
||||
card._activeDraftId = null;
|
||||
card._path = path.map((point) => [point[0] * 1000, point[1] * card._spaceH]);
|
||||
card._draftSegmentCms = [...cms];
|
||||
card._drawWallField = String(cm);
|
||||
card._cursorPt = [pointer[0] * 1000, pointer[1] * card._spaceH];
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
await frame();
|
||||
if (!card.renderRoot.querySelector('.drawwall-preview'))
|
||||
throw new Error(`golden wall junction preview did not render: ${scenario.id}`);
|
||||
}
|
||||
if (scenario.planSnap) {
|
||||
const { tool, anchor, pointer, expectedKind } = scenario.planSnap;
|
||||
const validPoint = (point) => Array.isArray(point) && point.length === 2
|
||||
|
||||
+19
-1
@@ -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 = 20;
|
||||
export const GOLDEN_MATRIX_VERSION = 21;
|
||||
|
||||
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
|
||||
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
|
||||
@@ -45,6 +45,24 @@ export const GOLDEN_SCENARIOS = Object.freeze([
|
||||
tool: 'draw', anchor: [0.70, 0.455], pointer: [0.482, 0.455], expectedKind: 'line',
|
||||
},
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'wall-junctions-plan-preview-light', fixture: 'visual',
|
||||
space: 'golden-wall-junctions', wallJunctions: true, mode: 'plan',
|
||||
wallJunctionPreview: {
|
||||
path: [[0.16, 0.15], [0.38, 0.15]], pointer: [0.38, 0.25], cms: [12], cm: 24,
|
||||
},
|
||||
theme: 'light', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'wall-junctions-plan-t-dark', fixture: 'visual',
|
||||
space: 'golden-wall-junctions', wallJunctions: true, mode: 'plan',
|
||||
wallJunctionPreview: {
|
||||
path: [[0.50, 0.54]], pointer: [0.50, 0.70], cms: [], cm: 24,
|
||||
},
|
||||
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
|
||||
{ id: 'wall-junctions-view-dark', fixture: 'visual',
|
||||
space: 'golden-wall-junctions', wallJunctions: 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', theme: 'dark', viewport: { width: 1000, height: 900 }, ...stage },
|
||||
{ id: 'opening-placement-door-thick-wall-dark', fixture: 'visual', space: 'golden-geometry',
|
||||
// The shared centre edge is a long 25 cm physical wall. It can contain the
|
||||
// complete 90 cm door preset while still proving rotation, inner-face
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/** Issue #141: joined independent walls share one production geometry surface. */
|
||||
import { launch, checkAll, finish } from './serve.mjs';
|
||||
|
||||
const { page, browser } = await launch({ width: 1000, height: 860 }, 1);
|
||||
|
||||
const out = await page.evaluate(async () => {
|
||||
const result = {};
|
||||
const card = window.__card;
|
||||
const root = () => card.shadowRoot || card.renderRoot;
|
||||
const update = async () => {
|
||||
card._cfgEpoch++;
|
||||
card._modelCache = null;
|
||||
card._frame = null;
|
||||
card._wallUnionCache = null;
|
||||
card._lightBarrierCache = null;
|
||||
card.requestUpdate();
|
||||
await card.updateComplete;
|
||||
};
|
||||
const entry = (a, b, cm) => ({
|
||||
// The smoke needs an exact persisted span; the renderer resolves the key
|
||||
// from these endpoints before it falls back to the legacy bucket.
|
||||
key: `smoke-${a.join('-')}-${b.join('-')}`, a: [...a], b: [...b], cm,
|
||||
});
|
||||
const a = [0.05, 0.05], tr = [0.95, 0.05], br = [0.95, 0.95], bl = [0.05, 0.95];
|
||||
const cfg = {
|
||||
spaces: [{
|
||||
id: 'junctions', title: 'Junctions', cell_cm: 5, view_box: [0, 0, 1, 1],
|
||||
rooms: [{ id: 'room', name: 'Room', area: null, poly: [a, tr, br, bl] }],
|
||||
walls: [entry(a, tr, 0), entry(tr, br, 0), entry(br, bl, 0), entry(bl, a, 0)],
|
||||
partitions: [
|
||||
{ id: 'l-horizontal', a: [0.10, 0.30], b: [0.40, 0.30], cm: 10 },
|
||||
{ id: 'l-vertical', a: [0.40, 0.30], b: [0.40, 0.60], cm: 20 },
|
||||
{ id: 'oblique-a', a: [0.58, 0.22], b: [0.76, 0.38], cm: 22 },
|
||||
{ id: 'oblique-b', a: [0.76, 0.38], b: [0.62, 0.53], cm: 12 },
|
||||
{ id: 't-through', a: [0.18, 0.70], b: [0.78, 0.70], cm: 24 },
|
||||
{ id: 't-branch', a: [0.50, 0.54], b: [0.50, 0.70], cm: 16 },
|
||||
{ id: 'room-branch', a: [0.30, 0.82], b: [0.30, 0.95], cm: 18 },
|
||||
],
|
||||
room_drafts: [{
|
||||
id: 'saved-draft', points: [[0.12, 0.56], [0.28, 0.56], [0.28, 0.64]],
|
||||
segments: [{ cm: 12 }, { cm: 18 }],
|
||||
}],
|
||||
wall_columns: [],
|
||||
}],
|
||||
markers: [], settings: {},
|
||||
};
|
||||
card._serverCfg = structuredClone(cfg);
|
||||
card._layout = {};
|
||||
card._space = 'junctions';
|
||||
card._setMode('plan');
|
||||
card._tool = 'select';
|
||||
await update();
|
||||
|
||||
const sourceBefore = JSON.stringify(card._serverCfg.spaces[0]);
|
||||
const space = card._spaceModel();
|
||||
const bodies = card._physicalBodiesR(space);
|
||||
const raw = card._rawPhysicalBodiesR(space);
|
||||
const frame = card._physicalBodiesCache;
|
||||
result.computedPatchesExist = frame?.patches.length >= 4 && bodies.length > raw.length;
|
||||
result.rawIdentityCountStaysPerSegment = raw.length === 9
|
||||
&& card._curSpaceCfg.partitions.length === 7
|
||||
&& card._curSpaceCfg.room_drafts[0].segments.length === 2;
|
||||
|
||||
const wallBody = root().querySelector('.wallbody');
|
||||
const missingCorner = new DOMPoint(404, 298);
|
||||
result.savedRightAngleToothIsFilled = !!wallBody?.isPointInFill(missingCorner);
|
||||
const planD = wallBody?.getAttribute('d') || '';
|
||||
|
||||
const insideRing = (point, ring) => {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const a0 = ring[i], b0 = ring[j];
|
||||
const crosses = ((a0[1] > point[1]) !== (b0[1] > point[1]))
|
||||
&& point[0] < ((b0[0] - a0[0]) * (point[1] - a0[1]))
|
||||
/ ((b0[1] - a0[1]) || 1e-12) + a0[0];
|
||||
if (crosses) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
};
|
||||
const insideGeometry = (point, geometry) => (geometry || []).some((polygon) =>
|
||||
polygon?.length && insideRing(point, polygon[0])
|
||||
&& !polygon.slice(1).some((hole) => insideRing(point, hole)));
|
||||
const room = space.rooms[0];
|
||||
const clean = card._cleanFloor(room, room.poly, space);
|
||||
result.cleanFloorUsesJoinedCorner = !insideGeometry([404, 298], clean.geom);
|
||||
const light = card._lightBarriers(
|
||||
space, [{ r: room, poly: room.poly }], card._physicalBodiesR(space),
|
||||
);
|
||||
result.lightUsesJoinedCorner = insideGeometry([404, 298], light.masonryGeometry);
|
||||
|
||||
card._tool = 'draw';
|
||||
card._activeDraftId = null;
|
||||
card._path = [[120, 780], [380, 780]];
|
||||
card._draftSegmentCms = [12];
|
||||
card._drawWallField = '24';
|
||||
card._cursorPt = [380, 600];
|
||||
await update();
|
||||
const liveD = root().querySelector('.drawwall-preview')?.getAttribute('d') || '';
|
||||
card._path = [[120, 780], [380, 780], [380, 600]];
|
||||
card._draftSegmentCms = [12, 24];
|
||||
card._cursorPt = null;
|
||||
await update();
|
||||
const committedPreviewD = root().querySelector('.drawwall-preview')?.getAttribute('d') || '';
|
||||
result.rubberBandAndCommittedPreviewMatch = !!liveD && committedPreviewD === liveD;
|
||||
|
||||
card._path = [[500, 540]];
|
||||
card._draftSegmentCms = [];
|
||||
card._drawWallField = '20';
|
||||
card._cursorPt = [500, 700];
|
||||
await update();
|
||||
result.lineTargetGetsLocalJoinPatch = root().querySelectorAll('.drawwall-preview').length === 2;
|
||||
result.previewDoesNotWriteOrSplitTarget = JSON.stringify(card._serverCfg.spaces[0]) === sourceBefore;
|
||||
|
||||
card._cursorPt = null;
|
||||
card._path = [];
|
||||
card._tool = 'select';
|
||||
card._setMode('view');
|
||||
await update();
|
||||
result.planViewParity = !!planD && root().querySelector('.wallbody')?.getAttribute('d') === planD;
|
||||
|
||||
history.replaceState(null, '', '?hp-labs=iso#space=junctions');
|
||||
dispatchEvent(new HashChangeEvent('hashchange'));
|
||||
await card.updateComplete;
|
||||
card._setProjection('iso');
|
||||
await update();
|
||||
result.isoUsesJoinedFootprint = !!root().querySelector('.iso-walls .iso-wall-top')
|
||||
&& card._isoSource().build().walls.flat(2).length > 0;
|
||||
card._setProjection('flat');
|
||||
await update();
|
||||
|
||||
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: 'junctions', 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('.wallbody') && Date.now() - started < 6000)
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
await staticCard.updateComplete;
|
||||
result.staticUsesSameJoinedPath = staticCard.renderRoot
|
||||
?.querySelector('.wallbody')?.getAttribute('d') === planD;
|
||||
staticCard.remove();
|
||||
|
||||
result.renderNeverRewritesConfig = JSON.stringify(card._serverCfg.spaces[0]) === sourceBefore;
|
||||
return result;
|
||||
});
|
||||
|
||||
await finish(browser, checkAll(out));
|
||||
+125
-123
File diff suppressed because one or more lines are too long
Vendored
+125
-123
File diff suppressed because one or more lines are too long
+14
-1
@@ -318,6 +318,16 @@ physical bodies are unioned with room walls for rendering and light occlusion,
|
||||
and subtracted from clean room floor area. Openings (doors, windows and gates)
|
||||
still belong only to derived room walls and never cut an independent object.
|
||||
|
||||
Independent linear objects have two deliberate projections. Raw flat-capped
|
||||
quads preserve source identity for hit/selection/drag/properties/delete/history
|
||||
and furniture magnet behaviour. `physicalBodySet()` also derives exact
|
||||
endpoint↔endpoint and endpoint↔line topology, adds bounded mitre/bevel patches
|
||||
without persisted nodes or segment splits, and exposes the joined geometry to
|
||||
presentation and physics. Degree-one caps remain flat; an interior X crossing
|
||||
is only a boolean overlap. The full card caches this structural frame by
|
||||
space/config geometry, while static cards use a weak server-snapshot cache;
|
||||
cursor and HA state updates do not repeat the saved O(N²) node search.
|
||||
|
||||
Rooms may not overlap
|
||||
(`pointStrictlyInside` + `roomsOverlap`; being ON a shared wall is legal — real neighbouring
|
||||
walls overlap collinearly rather than match exactly). **Merge/Split** use boolean geometry from
|
||||
@@ -334,7 +344,10 @@ 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
|
||||
masonry paths are emitted by that same geometry pass. Computed independent
|
||||
junction patches enter as extras only after room opening cuts, so an opening
|
||||
cannot cut a coincident partition and room exterior authority remains intact.
|
||||
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
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Connected Room outline and Partition segments now form one seamless thick
|
||||
wall immediately, including the live rubber-band. Right and oblique corners
|
||||
use bounded mitre/bevel joins, T-connections have no visible butt face, each
|
||||
segment keeps its own thickness, and free ends remain flat consistently in
|
||||
Plan, View, static cards, hidden isometric, clean-floor and light geometry
|
||||
([#141](https://github.com/Matysh/houseplan-card/issues/141)).
|
||||
- Room outline and Partition drawing now shows wall centre lines and exact
|
||||
endpoints above existing walls. The enlarged target previews whether the
|
||||
next click will join an endpoint or create a wall-bound T-connection, while
|
||||
|
||||
@@ -8,6 +8,12 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Соединённые отрезки «Контура комнаты» и «Перегородки» теперь сразу, включая
|
||||
активный rubber-band, образуют одну бесшовную толстую стену. Прямые и косые
|
||||
углы получают ограниченный mitre/bevel, T-соединение не показывает торец,
|
||||
каждый отрезок сохраняет свою толщину, а свободные концы остаются плоскими в
|
||||
Plan, View, статичной карточке, скрытой изометрии, чистой площади и световой
|
||||
геометрии ([#141](https://github.com/Matysh/houseplan-card/issues/141)).
|
||||
- При рисовании инструментами «Контур комнаты» и «Перегородка» поверх
|
||||
существующих стен теперь видны их осевые линии и точные конечные узлы.
|
||||
Увеличенная точка заранее показывает соединение с концом стены или создание
|
||||
|
||||
@@ -53,6 +53,11 @@ physical bodies have been resolved. It normalizes outer/hole winding, builds one
|
||||
evenodd top path and at most one visible side per ring edge, then uses a stable
|
||||
depth/order tie-break. Complexity is O(E) in canonical ring edges.
|
||||
|
||||
For connected drafts and partitions those extras already contain computed
|
||||
bounded junction patches. Isometric wall tops/sides therefore use the same
|
||||
seamless L/T footprint as flat full/static cards; raw per-record rectangles are
|
||||
reserved for editor identity and never projected as competing wall faces.
|
||||
|
||||
The content fingerprint includes rooms, wall geometry/thickness, open cuts,
|
||||
openings, partitions, drafts, columns, scale/grid inputs, camera, wall height and
|
||||
algorithm version. It deliberately excludes `_cfgEpoch`, HA state, hover and
|
||||
|
||||
+9
-1
@@ -26,7 +26,9 @@ plan geometry and shares it between every lamp in the space.
|
||||
|
||||
- the wall bodies exactly as the plan draws them (`wallBodiesGeometry`), with
|
||||
their real thickness and mitred junctions;
|
||||
- independent bodies: partitions, columns, room drafts;
|
||||
- independent bodies: partitions, columns and room drafts. Exact connected
|
||||
draft/partition segments enter as one joined volume, not as raw rectangles
|
||||
whose former butt faces could become false barriers;
|
||||
- the bare outline of any room edge that carries no thickness — a wall is still
|
||||
a wall when it is drawn as a line.
|
||||
|
||||
@@ -107,6 +109,12 @@ set is invisible — the plan simply keeps lighting through a wall that now
|
||||
exists. The same fingerprint, plus position and radius, keys the per-source
|
||||
region cache (`_glowClipCache`).
|
||||
|
||||
The masonry boolean receives room walls after passage cuts plus the cached
|
||||
joined independent body set. Its outer/hole rings are the authoritative
|
||||
barriers for both visibility and the fail-dark source guard. A boolean failure
|
||||
falls back to the raw independent bodies as opaque obstacles; it never turns a
|
||||
malformed wall transparent.
|
||||
|
||||
## Source, state and service identity
|
||||
|
||||
The geometry above consumes `resolvedLightSources()`; it never discovers light
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ metadata). Only an explicit owner-approved emergency hotfix may skip this gate.
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| Version | **v1.64.0-beta.1** everywhere (manifest, const.py, package.json, CARD_VERSION) — prerelease candidate on the v1.63.0 stable base |
|
||||
| Current local cycle | v1.64.0-beta.1 packages the merged #122 hidden isometric Stage 2, #131 read-only cold-start fix and #107 persistent virtual-light toggles. The independent #124 exact-SHA view-toggle performance debt remains open under the owner's accepted exception. |
|
||||
| Current local cycle | v1.64.0-beta.1 packages the merged #122 hidden isometric Stage 2, #131 read-only cold-start fix and #107 persistent virtual-light toggles. Issue #141 is in implementation on its issue branch: connected draft/partition L and T nodes now use one computed joined wall volume across flat/static/hidden-iso, clean-floor and light consumers while raw objects retain editor identity; Linux smoke/golden/performance remain pre-release gates. The independent #124 exact-SHA view-toggle performance debt remains open under the owner's accepted exception. |
|
||||
| Hidden Labs Stage | #89 Stage 1 ships in v1.63.0-beta.1. #122 Stage 2 is included in the v1.64.0-beta.1 candidate 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 is explicitly a separate task. |
|
||||
| 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; 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) |
|
||||
|
||||
@@ -2089,6 +2089,16 @@ require hands on real hardware — they remain for the human pass.
|
||||
source inside masonry lights nothing. Window rays are blocked by the same
|
||||
bodies. `show_borders: false` changes paint only [auto:
|
||||
physical-geometry.test; manual visual].
|
||||
- [ ] **Seamless junctions**: saved draft/partition L corners (right, acute and
|
||||
obtuse), unequal thickness, endpoint-on-line T and a branch touching a
|
||||
room wall use one bounded joined body; near-miss, X crossing, malformed
|
||||
segments and flat free caps keep their documented semantics. The active
|
||||
rubber-band has the same contour before and after commit, target records
|
||||
are not split, Plan/View/static/hidden-iso paths agree, clean floor and
|
||||
light use the joined corner, and preview/render never writes config
|
||||
[auto: wall-thickness.test, physical-geometry.test,
|
||||
smoke_wall_junctions, wall-junctions golden scenarios; manual golden
|
||||
artifact review].
|
||||
- [ ] **Lifecycle/performance**: an external config revision cancels live
|
||||
move/rotate state before replacing geometry. Drag preview performs no
|
||||
polygon boolean work; clean floor and Glow clips are reused until the
|
||||
|
||||
@@ -275,6 +275,13 @@ desktop: для точного рисования, Resize, модификато
|
||||
предпросмотра рекомендуется редактор на компьютере; tap на сенсорном экране
|
||||
тоже выполняет привязку, но отдельный hover до касания не показывается.
|
||||
|
||||
Толстые отрезки, соединённые в такой точке, сразу показываются как одна стена:
|
||||
прямые и косые углы получают ограниченный mitre/bevel без зуба или щели, а
|
||||
T-соединение входит в проходящую стену без видимого торца. Это действует уже у
|
||||
активного rubber-band до клика и сохраняется после клика, в View и на статичной
|
||||
карточке. Толщина каждого ранее поставленного отрезка остаётся своей; свободный
|
||||
конец незамкнутой стены остаётся плоским.
|
||||
|
||||
Каждый законченный отрезок незамкнутого контура сохраняется сразу. В той же
|
||||
сессии инструмент продолжит его автоматически; после перезагрузки выберите
|
||||
кликом один из концов. Замкнутый контур можно сохранить как комнату или выбрать
|
||||
@@ -345,6 +352,7 @@ Glow и солнечные лучи, но при Resize комнаты оста
|
||||
| Общая стена | Одна физическая стена между комнатами |
|
||||
| Чистая площадь | Считается по внутренней грани стен |
|
||||
| Углы | Соседние тела стен соединяются; на малом масштабе штриховка скрывается, тело остаётся |
|
||||
| Независимые стыки | Контуры и перегородки с точным общим узлом образуют один ограниченный mitre/bevel; T-стык не дробит исходную стену |
|
||||
| Соседние одинаковые участки | Нормализуются в один участок при редактировании/оптимизации |
|
||||
| Виртуальный участок | Не имеет тела и толщины; реальные остатки сохраняют свою толщину |
|
||||
|
||||
|
||||
@@ -65,6 +65,15 @@ thinner than 3 CSS px on screen, the shared full/static render policy suppresses
|
||||
only the hatch so it does not collapse into noise; the solid fill remains. Mitre
|
||||
joins; bevel when the mitre spike exceeds `MITRE_LIMIT × thickness`.
|
||||
|
||||
Independent draft/partition segments keep flat raw quads for editor identity,
|
||||
but exact endpoint↔endpoint and endpoint↔line nodes add computed join patches
|
||||
before the presentation union. Each incident ray keeps its own half-depth;
|
||||
ordinary corners use the same bounded `MITRE_LIMIT = 4` rule and excessive
|
||||
spikes become bevels. A degree-one endpoint receives no patch and therefore
|
||||
keeps its flat cap. This topology is render-only: a T does not split or rewrite
|
||||
the saved target segment. The live open-outline/rubber-band preview calls the
|
||||
same primitive with saved per-segment thicknesses plus the current field value.
|
||||
|
||||
Openings cut the body full-depth; jambs cap the cut; window glass mid-tunnel;
|
||||
door swing from the **inner face**. Association uses wall direction ≈ opening
|
||||
angle (mod 180°), then nearest span — never a perpendicular neighbour at a T.
|
||||
@@ -167,3 +176,13 @@ occlusion even when borders are hidden. A source inside/on a physical body is
|
||||
fully occluded instead of leaking around its own masonry. The same fail-dark
|
||||
placement rule applies to window tunnels and exterior door/gate openings;
|
||||
interior passages remain valid source positions (#92).
|
||||
|
||||
`physicalBodySet()` separates raw draft/partition/column bodies from computed
|
||||
junction patches and their joined geometry. Raw bodies remain authoritative for
|
||||
hit testing, selection, drag, properties, deletion, history and furniture
|
||||
magnet semantics. Flat full/static render, hidden isometric, clean floor, Glow,
|
||||
sun and source placement consume the joined set through the canonical masonry
|
||||
pass, so an old butt face cannot become a visible seam or a false light barrier.
|
||||
Exact near-misses remain separate, an interior↔interior X crossing keeps normal
|
||||
boolean-union semantics, and malformed legacy segments fall back opaque without
|
||||
writing configuration.
|
||||
|
||||
+99
-22
@@ -61,10 +61,11 @@ import {
|
||||
openingInnerFaceOffsetFromIndex, openingTunnelGeometriesFromIndex,
|
||||
openingWallIndex as buildOpeningWallIndex, resolveOpeningWallAssociation,
|
||||
applyWallThicknessToNewRoom,
|
||||
drawWallPreviewD, DRAW_WALL_DEFAULT_CM, wallIntervals, materializeWallIntervals,
|
||||
drawWallPreviewD, linearWallJoinPatches, DRAW_WALL_DEFAULT_CM,
|
||||
wallIntervals, materializeWallIntervals,
|
||||
normalizeWallIntervals,
|
||||
intervalCmAt, wallBodyNeedsSolid, type OpeningTunnelGeometry, type OpeningWallIndex,
|
||||
type WallEntry, type WallInterval,
|
||||
type LinearWallSegment, type WallEntry, type WallInterval,
|
||||
} from './wall-thickness';
|
||||
import {
|
||||
resolveOpenCuts, resolveBoundaryTarget, snapOpenPoint,
|
||||
@@ -125,13 +126,14 @@ import type {
|
||||
} from './types';
|
||||
import {
|
||||
COLUMN_MAX_CM, canonicalColumnAngle, clampColumnCm, columnBody,
|
||||
directionalOccluders, draftBodies, floorMinusBodies, geometryArea, geometryOuterRings,
|
||||
directionalOccluders, floorMinusBodies, geometryArea, geometryOuterRings,
|
||||
geometryAllRings, intersectionPaths, partitionBody, polyclipPathD,
|
||||
pointInOpaquePlanBody, pointInPhysicalBody, sameColumnPlacement, physicalBodies,
|
||||
pointInOpaquePlanBody, pointInPhysicalBody, sameColumnPlacement,
|
||||
physicalBodies, physicalBodySet,
|
||||
} from './physical-geometry';
|
||||
import {
|
||||
buildPlanSnapGeometry, resolvePlanSnap,
|
||||
type PlanSnapCandidate, type PlanSnapGeometry,
|
||||
type PlanSnapCandidate, type PlanSnapGeometry, type PlanSnapSegment,
|
||||
} from './plan-snap-overlay';
|
||||
import {
|
||||
LightSegment, polygonSegments, splitAtIntersections, visibilityPolygon,
|
||||
@@ -1200,7 +1202,7 @@ class HouseplanCard extends LitElement {
|
||||
private _planSnapGeometryCache: { key: string; value: PlanSnapGeometry } | null = null;
|
||||
private _physicalBodiesCache: {
|
||||
key: string; drafts: number[][][]; partitions: number[][][];
|
||||
columns: number[][][]; all: number[][][];
|
||||
columns: number[][][]; patches: number[][][]; all: number[][][]; geometry: any | null;
|
||||
} | null = null;
|
||||
private _cleanFloorCache = new Map<string, {
|
||||
floor: number[][]; geom: any; path: string; area: number;
|
||||
@@ -8228,7 +8230,7 @@ class HouseplanCard extends LitElement {
|
||||
* partitions/drafts/columns. Openings intentionally still use room walls
|
||||
* only; furniture is allowed to lean against every real obstacle. */
|
||||
private get _furnWalls(): number[][] {
|
||||
const faces = this._physicalBodiesR().flatMap((body) =>
|
||||
const faces = this._rawPhysicalBodiesR().flatMap((body) =>
|
||||
body.map((a, i) => {
|
||||
const b = body[(i + 1) % body.length];
|
||||
return [a[0], a[1], b[0], b[1]];
|
||||
@@ -11621,17 +11623,18 @@ class HouseplanCard extends LitElement {
|
||||
private _physicalBodiesR(space = this._spaceModel()): number[][][] {
|
||||
const key = `${space.id}|${this._cfgEpoch}|${this._cellCm}|${this._gridPitch}`;
|
||||
if (this._physicalBodiesCache?.key === key) return this._physicalBodiesCache.all;
|
||||
const drafts = (space.room_drafts || []).flatMap((d) =>
|
||||
draftBodies(d, this._cellCm, this._gridPitch));
|
||||
const partitions = (space.partitions || []).flatMap((p) => {
|
||||
const body = partitionBody(p.a, p.b, p.cm, this._cellCm, this._gridPitch);
|
||||
return body ? [body] : [];
|
||||
});
|
||||
const columns = (space.wall_columns || []).map((c) =>
|
||||
columnBody(c, this._cellCm, this._gridPitch));
|
||||
const all = [...drafts, ...partitions, ...columns];
|
||||
this._physicalBodiesCache = { key, drafts, partitions, columns, all };
|
||||
return all;
|
||||
const frame = physicalBodySet(
|
||||
space, this._cellCm, this._gridPitch, this._gridPitch * 0.0002,
|
||||
);
|
||||
this._physicalBodiesCache = { key, ...frame };
|
||||
return frame.all;
|
||||
}
|
||||
|
||||
/** Per-record bodies remain the editor/furniture identity surface. */
|
||||
private _rawPhysicalBodiesR(space = this._spaceModel()): number[][][] {
|
||||
this._physicalBodiesR(space);
|
||||
const frame = this._physicalBodiesCache;
|
||||
return frame ? [...frame.drafts, ...frame.partitions, ...frame.columns] : [];
|
||||
}
|
||||
|
||||
/** Cached clean floor. A cheap bbox pass is the spatial index needed for
|
||||
@@ -13619,14 +13622,20 @@ class HouseplanCard extends LitElement {
|
||||
// jamb faces. Treating a wall as its centreline let light bleed half a wall
|
||||
// deep (a bright bar at every opening) and started every shadow half a wall
|
||||
// away from the corner that casts it.
|
||||
const masonry = walls.length
|
||||
const masonry = walls.length || physical.length
|
||||
? wallBodiesGeometry(
|
||||
space.rooms, walls, openCuts,
|
||||
passages.map((o) => ({ x: o.rx, y: o.ry, angle: o.angle, length: o.rlen })),
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W, physical,
|
||||
)
|
||||
: null;
|
||||
for (const ring of geometryAllRings(masonry?.geom)) occluders.push(...polygonSegments(ring));
|
||||
if (masonry) {
|
||||
for (const ring of geometryAllRings(masonry.geom)) occluders.push(...polygonSegments(ring));
|
||||
} else {
|
||||
// Malformed legacy geometry must remain opaque even when the canonical
|
||||
// boolean pass cannot produce a joined result.
|
||||
for (const body of physical) occluders.push(...polygonSegments(body));
|
||||
}
|
||||
// Edges without any thickness are still walls; so is a room outline when
|
||||
// the boolean pass above could not run at all.
|
||||
for (const { poly } of polys) {
|
||||
@@ -13634,7 +13643,6 @@ class HouseplanCard extends LitElement {
|
||||
occluders.push(seg as LightSegment);
|
||||
}
|
||||
}
|
||||
for (const body of physical) occluders.push(...polygonSegments(body));
|
||||
const value = {
|
||||
occluders: splitAtIntersections(occluders),
|
||||
floor: polys.map((x) => x.poly),
|
||||
@@ -16676,6 +16684,57 @@ class HouseplanCard extends LitElement {
|
||||
</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
/** Physical depth for one immutable architectural snap segment. */
|
||||
private _planSnapPhysicalSegment(segment: PlanSnapSegment): LinearWallSegment | null {
|
||||
let cm = 0;
|
||||
const space = this._spaceModel();
|
||||
if (segment.sourceKind === 'partition') {
|
||||
cm = Number(space.partitions.find((item) => item.id === segment.sourceId)?.cm) || 0;
|
||||
} else if (segment.sourceKind === 'draft') {
|
||||
const match = /^(.*):(\d+)$/.exec(segment.sourceId);
|
||||
const draft = match ? space.room_drafts.find((item) => item.id === match[1]) : null;
|
||||
cm = draft && match ? Number(draft.segments[Number(match[2])]?.cm) || 0 : 0;
|
||||
} else {
|
||||
cm = intervalCmAt(
|
||||
space.rooms, this._spaceWalls, this._openCuts(),
|
||||
[segment.a[0], segment.a[1], segment.b[0], segment.b[1]],
|
||||
this._wallKeyPitch, this._cellCm, this._gridPitch, NORM_W,
|
||||
);
|
||||
}
|
||||
if (!(cm > 0)) return null;
|
||||
return {
|
||||
a: [...segment.a], b: [...segment.b],
|
||||
halfDepth: wallCmToUnits(cm, this._cellCm, this._gridPitch) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Local target patches make a snapped rubber-band meet saved masonry before
|
||||
* the click. The expensive saved union stays cached; pointermove examines
|
||||
* only immutable snap axes touching a preview vertex.
|
||||
*/
|
||||
private _drawPreviewJoinPatchD(
|
||||
points: number[][], halfDepths: number[],
|
||||
): string {
|
||||
if (points.length < 2) return '';
|
||||
const preview: LinearWallSegment[] = [];
|
||||
for (let i = 0; i + 1 < points.length; i++) {
|
||||
if (!(halfDepths[i] > 0)) continue;
|
||||
preview.push({ a: points[i], b: points[i + 1], halfDepth: halfDepths[i] });
|
||||
}
|
||||
if (!preview.length) return '';
|
||||
const eps = this._gridPitch * 0.0002;
|
||||
const touching = this._planSnapGeometrySnapshot().value.segments
|
||||
.filter((segment) => points.some((point) => distToSegment(point, [
|
||||
segment.a[0], segment.a[1], segment.b[0], segment.b[1],
|
||||
]) <= eps))
|
||||
.map((segment) => this._planSnapPhysicalSegment(segment))
|
||||
.filter((segment): segment is LinearWallSegment => !!segment);
|
||||
const patches = linearWallJoinPatches([...preview, ...touching], eps);
|
||||
return patches.map((patch) =>
|
||||
`M ${patch.map((point) => `${point[0]} ${point[1]}`).join(' L ')} Z`).join(' ');
|
||||
}
|
||||
|
||||
private _renderMarkupLayer(vb: number[]): TemplateResult {
|
||||
// derived walls minus the open stretches — those are drawn dashed on top
|
||||
const openCuts = this._openPairs().flatMap((p) => p.segs);
|
||||
@@ -16694,13 +16753,27 @@ class HouseplanCard extends LitElement {
|
||||
if (this._cursorPt) return [...path, this._cursorPt];
|
||||
return path.length >= 2 ? path : null;
|
||||
})();
|
||||
const previewHalfDepths = previewPts
|
||||
? previewPts.slice(0, -1).map((_, i) => {
|
||||
const cm = Number(this._draftSegmentCms[i]) > 0
|
||||
? Number(this._draftSegmentCms[i])
|
||||
: this._contourClosed && i === previewPts.length - 2
|
||||
? (this._closingWallCm || drawCm || DRAW_WALL_DEFAULT_CM)
|
||||
: (drawCm || DRAW_WALL_DEFAULT_CM);
|
||||
return wallCmToUnits(cm, this._cellCm, this._gridPitch) / 2;
|
||||
})
|
||||
: [];
|
||||
const previewD = previewPts
|
||||
? drawWallPreviewD(
|
||||
previewPts,
|
||||
wallCmToUnits(drawCm!, this._cellCm, this._gridPitch) / 2,
|
||||
this._contourClosed,
|
||||
previewHalfDepths,
|
||||
)
|
||||
: '';
|
||||
const previewJoinPatchD = previewPts
|
||||
? this._drawPreviewJoinPatchD(previewPts, previewHalfDepths)
|
||||
: '';
|
||||
return svg`
|
||||
${this._gridLevels()
|
||||
? svg`<rect x="${view.x}" y="${view.y}" width="${view.w}" height="${view.h}" fill="url(#hp-grid-major)" pointer-events="none"></rect>`
|
||||
@@ -16718,6 +16791,10 @@ class HouseplanCard extends LitElement {
|
||||
? svg`<path class="drawwall-preview-fill" d="${previewD}"></path>
|
||||
<path class="drawwall-preview" d="${previewD}"></path>`
|
||||
: nothing}
|
||||
${previewJoinPatchD
|
||||
? svg`<path class="drawwall-preview-fill" d="${previewJoinPatchD}"></path>
|
||||
<path class="drawwall-preview" style="stroke:none" d="${previewJoinPatchD}"></path>`
|
||||
: nothing}
|
||||
${path.length > 1
|
||||
? svg`<polyline class="pathline" points="${path.map((p) => p.join(',')).join(' ')}"></polyline>`
|
||||
: nothing}
|
||||
|
||||
+61
-17
@@ -1,7 +1,10 @@
|
||||
/** Geometry shared by independent partitions, saved room drafts and columns. */
|
||||
import { difference, intersection, union } from 'polyclip-ts';
|
||||
import { polygonArea } from './logic';
|
||||
import { wallCmToUnits } from './wall-thickness';
|
||||
import {
|
||||
linearWallBody, linearWallJoinPatches, wallCmToUnits,
|
||||
type LinearWallSegment,
|
||||
} from './wall-thickness';
|
||||
import type {
|
||||
PartitionCfg, RoomDraftCfg, SpaceModel, WallColumnCfg,
|
||||
} from './types';
|
||||
@@ -37,19 +40,12 @@ export function polyclipPathD(geom: any): string {
|
||||
return out.join(' ');
|
||||
}
|
||||
|
||||
/** A wall segment has flat ends. Joining is delegated to polygon union. */
|
||||
/** A wall segment has flat ends. Canonical node joins are added by `physicalBodySet`. */
|
||||
export function partitionBody(
|
||||
a: number[], b: number[], cm: number, cellCm: number, gridPitch: number,
|
||||
): number[][] | null {
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (!(len > 1e-9)) return null;
|
||||
const half = wallCmToUnits(cm, cellCm, gridPitch) / 2;
|
||||
const nx = (-dy / len) * half, ny = (dx / len) * half;
|
||||
return [
|
||||
[a[0] + nx, a[1] + ny], [b[0] + nx, b[1] + ny],
|
||||
[b[0] - nx, b[1] - ny], [a[0] - nx, a[1] - ny],
|
||||
];
|
||||
return linearWallBody({ a, b, halfDepth: half });
|
||||
}
|
||||
|
||||
export function columnBody(
|
||||
@@ -91,14 +87,62 @@ export function physicalBodies(
|
||||
cellCm: number,
|
||||
gridPitch: number,
|
||||
): number[][][] {
|
||||
const out: number[][][] = [];
|
||||
for (const p of space.partitions || []) {
|
||||
const body = partitionBody(p.a, p.b, p.cm, cellCm, gridPitch);
|
||||
if (body) out.push(body);
|
||||
return physicalBodySet(space, cellCm, gridPitch).all;
|
||||
}
|
||||
|
||||
export interface PhysicalBodySet {
|
||||
drafts: number[][][];
|
||||
partitions: number[][][];
|
||||
columns: number[][][];
|
||||
/** Bounded mitre/bevel volumes; never persisted or independently editable. */
|
||||
patches: number[][][];
|
||||
/** Canonical independent volume inputs for boolean render/floor/light consumers. */
|
||||
all: number[][][];
|
||||
/** Unioned geometry without raw overlap/butt-face boundaries. */
|
||||
geometry: any | null;
|
||||
}
|
||||
|
||||
/** Raw editable bodies plus their computed, order-independent junction volumes. */
|
||||
export function physicalBodySet(
|
||||
space: Pick<SpaceModel, 'partitions' | 'room_drafts' | 'wall_columns'>,
|
||||
cellCm: number,
|
||||
gridPitch: number,
|
||||
epsilon = Math.max(gridPitch * 0.0002, 1e-9),
|
||||
): PhysicalBodySet {
|
||||
const draftSegments: LinearWallSegment[] = [];
|
||||
const partitionSegments: LinearWallSegment[] = [];
|
||||
const drafts: number[][][] = [];
|
||||
const partitions: number[][][] = [];
|
||||
for (const draft of space.room_drafts || []) {
|
||||
for (let i = 0; i + 1 < draft.points.length; i++) {
|
||||
const halfDepth = wallCmToUnits(
|
||||
draft.segments[i]?.cm || 15, cellCm, gridPitch,
|
||||
) / 2;
|
||||
const segment = { a: draft.points[i], b: draft.points[i + 1], halfDepth };
|
||||
const body = linearWallBody(segment);
|
||||
if (!body) continue;
|
||||
draftSegments.push(segment);
|
||||
drafts.push(body);
|
||||
}
|
||||
}
|
||||
for (const d of space.room_drafts || []) out.push(...draftBodies(d, cellCm, gridPitch));
|
||||
for (const c of space.wall_columns || []) out.push(columnBody(c, cellCm, gridPitch));
|
||||
return out;
|
||||
for (const partition of space.partitions || []) {
|
||||
const segment = {
|
||||
a: partition.a,
|
||||
b: partition.b,
|
||||
halfDepth: wallCmToUnits(partition.cm, cellCm, gridPitch) / 2,
|
||||
};
|
||||
const body = linearWallBody(segment);
|
||||
if (!body) continue;
|
||||
partitionSegments.push(segment);
|
||||
partitions.push(body);
|
||||
}
|
||||
const columns = (space.wall_columns || []).map((column) =>
|
||||
columnBody(column, cellCm, gridPitch));
|
||||
const patches = linearWallJoinPatches(
|
||||
[...draftSegments, ...partitionSegments], epsilon,
|
||||
);
|
||||
const all = [...drafts, ...partitions, ...patches, ...columns];
|
||||
return { drafts, partitions, columns, patches, all, geometry: unionBodies(all) };
|
||||
}
|
||||
|
||||
export function unionBodies(bodies: number[][][]): any | null {
|
||||
|
||||
+30
-1
@@ -38,6 +38,8 @@ export { spaceModels } from './space-geometry';
|
||||
type StaticWallGeometry = ReturnType<typeof wallBodiesUnionPath>;
|
||||
type StaticWallGeometryEntry = { fingerprint: string; value: StaticWallGeometry };
|
||||
const staticWallGeometryCache = new WeakMap<object, Map<string, StaticWallGeometryEntry>>();
|
||||
type StaticPhysicalBodiesEntry = { fingerprint: string; value: number[][][] };
|
||||
const staticPhysicalBodiesCache = new WeakMap<object, Map<string, StaticPhysicalBodiesEntry>>();
|
||||
|
||||
/** Static cards receive the same immutable server-config object on HA ticks. */
|
||||
function cachedStaticWallGeometry(
|
||||
@@ -58,6 +60,24 @@ function cachedStaticWallGeometry(
|
||||
return value;
|
||||
}
|
||||
|
||||
function cachedStaticPhysicalBodies(
|
||||
cfg: ServerConfig,
|
||||
spaceId: string,
|
||||
fingerprint: string,
|
||||
build: () => number[][][],
|
||||
): number[][][] {
|
||||
let spaces = staticPhysicalBodiesCache.get(cfg as object);
|
||||
if (!spaces) {
|
||||
spaces = new Map<string, StaticPhysicalBodiesEntry>();
|
||||
staticPhysicalBodiesCache.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;
|
||||
@@ -173,7 +193,16 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
const spCfg: any = o.cfg.spaces.find((s: any) => s.id === o.spaceId) || {};
|
||||
const walls: WallEntry[] = Array.isArray(spCfg.walls) ? spCfg.walls : [];
|
||||
const cellCm = Number(spCfg.cell_cm) > 0 ? Number(spCfg.cell_cm) : 5;
|
||||
const extras = physicalBodies(space, cellCm, GRID_PITCH);
|
||||
const physicalFingerprint = contentFingerprint({
|
||||
partitions: space.partitions,
|
||||
roomDrafts: space.room_drafts,
|
||||
columns: space.wall_columns,
|
||||
cellCm,
|
||||
});
|
||||
const extras = cachedStaticPhysicalBodies(
|
||||
o.cfg, space.id, physicalFingerprint,
|
||||
() => physicalBodies(space, cellCm, GRID_PITCH),
|
||||
);
|
||||
for (const body of extras) {
|
||||
const xs = body.map((p) => p[0]), ys = body.map((p) => p[1]);
|
||||
if (xs.length) placed.push({
|
||||
|
||||
+165
-17
@@ -27,6 +27,13 @@ export const WALL_HATCH_MIN_PX = 3;
|
||||
/** Mitre spikes longer than this × thickness fall back to a bevel. */
|
||||
export const MITRE_LIMIT = 4;
|
||||
|
||||
/** One finite physical wall centreline with its already-converted half depth. */
|
||||
export interface LinearWallSegment {
|
||||
a: number[];
|
||||
b: number[];
|
||||
halfDepth: number;
|
||||
}
|
||||
|
||||
// ------------------------------- units --------------------------------------
|
||||
|
||||
/** Shared full/static render policy for the thin-on-screen fallback. */
|
||||
@@ -483,14 +490,161 @@ export function applyWallThicknessToNewRoom(
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A flat-capped body for one already-scaled centreline segment. */
|
||||
export function linearWallBody(segment: LinearWallSegment): number[][] | null {
|
||||
const { a, b, halfDepth } = segment;
|
||||
if (!Array.isArray(a) || !Array.isArray(b) || a.length < 2 || b.length < 2
|
||||
|| ![a[0], a[1], b[0], b[1]].every(Number.isFinite)) return null;
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (!(len > 1e-9) || !(halfDepth > 0) || !Number.isFinite(halfDepth)) return null;
|
||||
const nx = (-dy / len) * halfDepth, ny = (dx / len) * halfDepth;
|
||||
return [
|
||||
[a[0] + nx, a[1] + ny], [b[0] + nx, b[1] + ny],
|
||||
[b[0] - nx, b[1] - ny], [a[0] - nx, a[1] - ny],
|
||||
];
|
||||
}
|
||||
|
||||
interface JunctionRay {
|
||||
u: [number, number];
|
||||
halfDepth: number;
|
||||
}
|
||||
|
||||
function closePoint(a: number[], b: number[], epsilon: number): boolean {
|
||||
return Math.hypot(a[0] - b[0], a[1] - b[1]) <= epsilon;
|
||||
}
|
||||
|
||||
function pointOnSegmentInterior(
|
||||
point: number[], segment: LinearWallSegment, epsilon: number,
|
||||
): boolean {
|
||||
const dx = segment.b[0] - segment.a[0], dy = segment.b[1] - segment.a[1];
|
||||
const len2 = dx * dx + dy * dy;
|
||||
if (!(len2 > epsilon * epsilon)) return false;
|
||||
const t = ((point[0] - segment.a[0]) * dx + (point[1] - segment.a[1]) * dy) / len2;
|
||||
if (!(t > 0 && t < 1)) return false;
|
||||
const q = [segment.a[0] + dx * t, segment.a[1] + dy * t];
|
||||
return Math.hypot(point[0] - q[0], point[1] - q[1]) <= epsilon;
|
||||
}
|
||||
|
||||
function addJunctionRay(rays: JunctionRay[], dx: number, dy: number, halfDepth: number): void {
|
||||
const len = Math.hypot(dx, dy);
|
||||
if (!(len > 1e-9) || !(halfDepth > 0)) return;
|
||||
const u: [number, number] = [dx / len, dy / len];
|
||||
const same = rays.find((ray) =>
|
||||
Math.abs(ray.u[0] * u[1] - ray.u[1] * u[0]) < 1e-9
|
||||
&& ray.u[0] * u[0] + ray.u[1] * u[1] > 1 - 1e-9);
|
||||
if (same) same.halfDepth = Math.max(same.halfDepth, halfDepth);
|
||||
else rays.push({ u, halfDepth });
|
||||
}
|
||||
|
||||
/**
|
||||
* Missing node volumes for flat-capped linear wall segments.
|
||||
*
|
||||
* Endpoints are the only nodes. An endpoint may also land in another segment's
|
||||
* interior (the non-persisted T produced by #137); that through segment then
|
||||
* contributes two incident rays. Each non-collinear ray pair receives the
|
||||
* same bounded mitre/bevel used by room contours. Unioning these patches with
|
||||
* the raw bodies removes the tooth without changing caps at degree-one nodes.
|
||||
*/
|
||||
export function linearWallJoinPatches(
|
||||
input: LinearWallSegment[], epsilon = 1e-6,
|
||||
): number[][][] {
|
||||
const segments = (input || []).filter((segment) =>
|
||||
segment && Array.isArray(segment.a) && Array.isArray(segment.b)
|
||||
&& segment.a.length >= 2 && segment.b.length >= 2
|
||||
&& segment.a.every(Number.isFinite) && segment.b.every(Number.isFinite)
|
||||
&& Number.isFinite(segment.halfDepth) && segment.halfDepth > 0
|
||||
&& Math.hypot(segment.b[0] - segment.a[0], segment.b[1] - segment.a[1]) > 1e-9);
|
||||
if (segments.length < 2) return [];
|
||||
const eps = Math.max(Number.isFinite(epsilon) ? epsilon : 0, 1e-9);
|
||||
const endpoints = segments.flatMap((segment) => [segment.a, segment.b])
|
||||
.map((point) => [point[0], point[1]])
|
||||
.sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
const nodes: number[][] = [];
|
||||
for (const point of endpoints) {
|
||||
if (!nodes.some((node) => closePoint(node, point, eps))) nodes.push(point);
|
||||
}
|
||||
|
||||
const patches: number[][][] = [];
|
||||
for (const node of nodes) {
|
||||
const rays: JunctionRay[] = [];
|
||||
for (const segment of segments) {
|
||||
if (closePoint(node, segment.a, eps)) {
|
||||
addJunctionRay(
|
||||
rays, segment.b[0] - segment.a[0], segment.b[1] - segment.a[1],
|
||||
segment.halfDepth,
|
||||
);
|
||||
} else if (closePoint(node, segment.b, eps)) {
|
||||
addJunctionRay(
|
||||
rays, segment.a[0] - segment.b[0], segment.a[1] - segment.b[1],
|
||||
segment.halfDepth,
|
||||
);
|
||||
} else if (pointOnSegmentInterior(node, segment, eps)) {
|
||||
addJunctionRay(
|
||||
rays, segment.a[0] - node[0], segment.a[1] - node[1], segment.halfDepth,
|
||||
);
|
||||
addJunctionRay(
|
||||
rays, segment.b[0] - node[0], segment.b[1] - node[1], segment.halfDepth,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (rays.length < 2) continue;
|
||||
rays.sort((a, b) => Math.atan2(a.u[1], a.u[0]) - Math.atan2(b.u[1], b.u[0])
|
||||
|| a.halfDepth - b.halfDepth);
|
||||
for (let i = 0; i < rays.length; i++) {
|
||||
for (let j = i + 1; j < rays.length; j++) {
|
||||
const a = rays[i], b = rays[j];
|
||||
const cross = a.u[0] * b.u[1] - a.u[1] * b.u[0];
|
||||
if (Math.abs(cross) < 1e-9) continue;
|
||||
const nA = [-a.u[1], a.u[0]];
|
||||
const nB = [-b.u[1], b.u[0]];
|
||||
const sign = cross < 0 ? 1 : -1;
|
||||
const pA = [
|
||||
node[0] + nA[0] * a.halfDepth * sign,
|
||||
node[1] + nA[1] * a.halfDepth * sign,
|
||||
];
|
||||
const pB = [
|
||||
node[0] - nB[0] * b.halfDepth * sign,
|
||||
node[1] - nB[1] * b.halfDepth * sign,
|
||||
];
|
||||
const hit = lineIntersect(pA, a.u, pB, b.u);
|
||||
const limit = MITRE_LIMIT * Math.max(a.halfDepth, b.halfDepth);
|
||||
const patch = hit && Math.hypot(hit[0] - node[0], hit[1] - node[1]) <= limit
|
||||
? [node.slice(), pA, hit, pB]
|
||||
: [node.slice(), pA, pB];
|
||||
if (Math.abs(signedArea(patch)) > eps * eps) patches.push(patch);
|
||||
}
|
||||
}
|
||||
}
|
||||
return patches;
|
||||
}
|
||||
|
||||
function unionSimpleBodies(bodies: number[][][]): any | null {
|
||||
let geom: any = null;
|
||||
try {
|
||||
for (const body of bodies) {
|
||||
if (body.length < 3) continue;
|
||||
const piece: any = closedRing(body);
|
||||
geom = geom ? union(geom, piece) : piece;
|
||||
}
|
||||
return geom;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SVG path for the thick-wall preview while drawing a room outline.
|
||||
* Closed contours use outset−inset; open polylines use per-segment quads.
|
||||
* Closed contours use outset−inset; open polylines use the same bounded joins
|
||||
* as persisted independent walls. `segmentHalfDepths` preserves the thickness
|
||||
* already committed for each draft segment while the last rubber-band uses the
|
||||
* current session value.
|
||||
*/
|
||||
export function drawWallPreviewD(
|
||||
pts: number[][],
|
||||
halfDepth: number,
|
||||
closed: boolean,
|
||||
segmentHalfDepths?: number[],
|
||||
): string {
|
||||
if (!(halfDepth > 0) || !pts || pts.length < 2) return '';
|
||||
if (closed && pts.length >= 3) {
|
||||
@@ -501,7 +655,7 @@ export function drawWallPreviewD(
|
||||
poly = pts.slice(0, -1);
|
||||
}
|
||||
if (poly.length >= 3) {
|
||||
const offs = poly.map(() => halfDepth);
|
||||
const offs = poly.map((_, i) => segmentHalfDepths?.[i] || halfDepth);
|
||||
const outset = outsetContour(poly, offs);
|
||||
const inset = insetContour(poly, offs);
|
||||
if (outset && inset) {
|
||||
@@ -509,24 +663,18 @@ export function drawWallPreviewD(
|
||||
}
|
||||
}
|
||||
}
|
||||
let d = '';
|
||||
const segments: LinearWallSegment[] = [];
|
||||
for (let i = 0; i < pts.length - 1; i++) {
|
||||
const a = pts[i], b = pts[i + 1];
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const L = Math.hypot(dx, dy);
|
||||
if (L < 1e-9) continue;
|
||||
const ux = dx / L, uy = dy / L;
|
||||
const nx = -uy, ny = ux;
|
||||
const h = halfDepth;
|
||||
const quad = [
|
||||
[a[0] + nx * h, a[1] + ny * h],
|
||||
[b[0] + nx * h, b[1] + ny * h],
|
||||
[b[0] - nx * h, b[1] - ny * h],
|
||||
[a[0] - nx * h, a[1] - ny * h],
|
||||
];
|
||||
d += (d ? ' ' : '') + polyToPath(quad);
|
||||
const h = segmentHalfDepths?.[i] || halfDepth;
|
||||
if (Math.hypot(b[0] - a[0], b[1] - a[1]) >= 1e-9 && h > 0)
|
||||
segments.push({ a, b, halfDepth: h });
|
||||
}
|
||||
return d;
|
||||
const bodies = segments.map(linearWallBody).filter((body): body is number[][] => !!body);
|
||||
const joined = [...bodies, ...linearWallJoinPatches(segments)];
|
||||
const geom = unionSimpleBodies(joined);
|
||||
if (geom) return polyclipToPathD(geom);
|
||||
return joined.map((body) => polyToPath(body)).join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -66,6 +66,12 @@ test('golden matrix has stable unique ids and bounded comparison thresholds', ()
|
||||
assert.equal(scenario.mode, 'plan', scenario.id);
|
||||
assert.equal(scenario.capture, 'page', scenario.id);
|
||||
}
|
||||
if (scenario.wallJunctionPreview) {
|
||||
assert.equal(scenario.wallJunctions, true, scenario.id);
|
||||
assert.equal(scenario.mode, 'plan', scenario.id);
|
||||
assert.equal(scenario.capture, 'page', scenario.id);
|
||||
assert.ok(scenario.wallJunctionPreview.path.length >= 1, scenario.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -78,7 +84,7 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
|
||||
'backup-full', 'backup-space', 'value-badge-positions', 'isometric-geometry',
|
||||
'isometric-live-layers', 'isometric-no-borders', 'isometric-touch-kiosk',
|
||||
'isometric-large-warm-remount', 'split-corner-wall', 'plan-snap-endpoint',
|
||||
'plan-snap-line-gaps'])
|
||||
'plan-snap-line-gaps', 'wall-junctions', 'isometric-wall-junctions'])
|
||||
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);
|
||||
@@ -87,6 +93,23 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
|
||||
assert.equal(new Set(GOLDEN_SCENARIOS.map((scenario) => scenario.theme)).has('dark'), true);
|
||||
});
|
||||
|
||||
test('wall junction goldens cover live L/T previews plus saved flat and isometric bodies', () => {
|
||||
const scenarios = GOLDEN_SCENARIOS.filter((scenario) => scenario.wallJunctions);
|
||||
assert.deepEqual(scenarios.map((scenario) => scenario.id), [
|
||||
'wall-junctions-plan-preview-light',
|
||||
'wall-junctions-plan-t-dark',
|
||||
'wall-junctions-view-dark',
|
||||
'isometric-wall-junctions-dark',
|
||||
]);
|
||||
for (const scenario of scenarios) {
|
||||
const fixture = prepareGoldenFixture(scenario);
|
||||
const space = fixture.config.spaces.find((item) => item.id === scenario.space);
|
||||
assert.equal(space.partitions.length, 7);
|
||||
assert.equal(space.room_drafts[0].segments.length, 2);
|
||||
assert.ok(space.partitions.some((item) => item.b[1] === 0.94), 'room-wall T fixture');
|
||||
}
|
||||
});
|
||||
|
||||
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']);
|
||||
|
||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
canonicalColumnAngle, columnBody, floorMinusBodies, geometryArea,
|
||||
directionalOccluders, intersectionPaths, partitionBody, pointInPhysicalBody,
|
||||
pointInOpaquePlanBody, pointInPhysicalGeometry,
|
||||
physicalBodySet, pointInOpaquePlanBody, pointInPhysicalGeometry,
|
||||
sameColumnPlacement,
|
||||
} from '../test-build/physical-geometry.js';
|
||||
import {
|
||||
@@ -21,6 +21,51 @@ test('partition body keeps the centreline and requested physical width', () => {
|
||||
closeTo(geometryArea([[[...body, body[0]]]]), 0.5);
|
||||
});
|
||||
|
||||
test('joined partitions fill straight and oblique endpoint teeth without changing flat free caps', () => {
|
||||
const base = {
|
||||
room_drafts: [], wall_columns: [],
|
||||
partitions: [
|
||||
{ id: 'horizontal', a: [-2, 0], b: [0, 0], cm: 10 },
|
||||
{ id: 'vertical', a: [0, 0], b: [0, 2], cm: 10 },
|
||||
{ id: 'oblique', a: [4, 2], b: [3, 0], cm: 20 },
|
||||
{ id: 'oblique-arm', a: [3, 0], b: [5, -1], cm: 10 },
|
||||
],
|
||||
};
|
||||
const frame = physicalBodySet(base, 5, 0.25);
|
||||
assert.ok(frame.patches.length >= 2, 'each non-collinear endpoint node gains a bounded patch');
|
||||
assert.equal(pointInPhysicalGeometry([0.2, -0.2], frame.geometry), true,
|
||||
'the missing outer quadrant at the right angle is solid');
|
||||
assert.equal(pointInPhysicalGeometry([5.3, -1.15], frame.geometry), false,
|
||||
'an unrelated flat free cap is not extended');
|
||||
|
||||
const reversed = physicalBodySet({
|
||||
...base,
|
||||
partitions: [...base.partitions].reverse().map((segment) => ({
|
||||
...segment, a: segment.b, b: segment.a,
|
||||
})),
|
||||
}, 5, 0.25);
|
||||
closeTo(geometryArea(frame.geometry), geometryArea(reversed.geometry), 1e-8);
|
||||
});
|
||||
|
||||
test('endpoint-on-line T join is computed without splitting or mutating source records', () => {
|
||||
const space = {
|
||||
room_drafts: [{
|
||||
id: 'draft-branch', points: [[1, -2], [1, 0]], segments: [{ cm: 15 }],
|
||||
}],
|
||||
wall_columns: [],
|
||||
partitions: [
|
||||
{ id: 'through', a: [-2, 0], b: [2, 0], cm: 20 },
|
||||
{ id: 'branch', a: [0, -2], b: [0, 0], cm: 10 },
|
||||
],
|
||||
};
|
||||
const before = JSON.stringify(space);
|
||||
const frame = physicalBodySet(space, 5, 0.25);
|
||||
assert.ok(frame.patches.length >= 2, 'partition and saved-draft branches share the T primitive');
|
||||
assert.equal(pointInPhysicalGeometry([0.2, -0.1], frame.geometry), true);
|
||||
assert.equal(pointInPhysicalGeometry([1.2, -0.1], frame.geometry), true);
|
||||
assert.equal(JSON.stringify(space), before, 'computed node topology is render-only');
|
||||
});
|
||||
|
||||
test('column size means square side or circle diameter', () => {
|
||||
const square = columnBody(
|
||||
{ id: 'sq', shape: 'square', center: [1, 1], cm: 20, angle: 45 }, 5, 0.25,
|
||||
|
||||
@@ -4,7 +4,8 @@ import assert from 'node:assert/strict';
|
||||
import {
|
||||
wallKey, lookupWall, thicknessCmAt, degradeWalls, rekeyWallsAfterMove,
|
||||
setWallThickness, setWallThicknessForRoom, applyWallThicknessToNewRoom,
|
||||
drawWallPreviewD, DRAW_WALL_DEFAULT_CM, clampWallCm, cmToField, fieldToCm,
|
||||
drawWallPreviewD, linearWallBody, linearWallJoinPatches,
|
||||
DRAW_WALL_DEFAULT_CM, clampWallCm, cmToField, fieldToCm,
|
||||
wallCmToUnits, insetContour, inwardNormal, edgeKinds, wallEdgeBodies,
|
||||
wallBodyRings, wallBodiesGeometry, wallBodiesUnionPath, floorFootprintGeometry,
|
||||
innerContourForRoom,
|
||||
@@ -970,7 +971,35 @@ test('split materialisation cuts a partial shared interval at the new divider',
|
||||
test('drawWallPreviewD returns a path for open and closed outlines', () => {
|
||||
const open = drawWallPreviewD([[0, 0], [10, 0], [10, 6]], 1, false);
|
||||
assert.ok(open.includes('M'));
|
||||
assert.match(open, /11 -1(?:\D|$)/, 'open preview already contains the 90-degree mitre');
|
||||
const stepped = drawWallPreviewD(
|
||||
[[0, 0], [10, 0], [10, 6]], 1, false, [1, 2],
|
||||
);
|
||||
assert.match(stepped, /12 -1(?:\D|$)/,
|
||||
'the joined preview respects the second segment own half-depth');
|
||||
const closed = drawWallPreviewD([[0, 0], [10, 0], [10, 6], [0, 6]], 1, true);
|
||||
assert.ok(closed.includes('M'));
|
||||
assert.equal(drawWallPreviewD([[0, 0]], 1, false), '');
|
||||
});
|
||||
|
||||
test('linear wall joins bevel an excessive mitre and ignore malformed or near-miss inputs', () => {
|
||||
const acute = linearWallJoinPatches([
|
||||
{ a: [0, 0], b: [10, 0], halfDepth: 1 },
|
||||
{ a: [0, 0], b: [10, 0.1], halfDepth: 1 },
|
||||
], 1e-6);
|
||||
assert.equal(acute.length, 1);
|
||||
assert.equal(acute[0].length, 3, 'a mitre beyond the limit becomes a bevel triangle');
|
||||
assert.ok(acute[0].every((point) => Math.hypot(point[0], point[1]) <= MITRE_LIMIT));
|
||||
|
||||
const separate = linearWallJoinPatches([
|
||||
{ a: [-2, 0], b: [0, 0], halfDepth: 1 },
|
||||
{ a: [0.001, 0], b: [0.001, 2], halfDepth: 1 },
|
||||
], 1e-6);
|
||||
assert.deepEqual(separate, [], 'a point outside geometry epsilon remains disconnected');
|
||||
assert.equal(linearWallBody({ a: [0, 0], b: [Infinity, 1], halfDepth: 1 }), null);
|
||||
assert.deepEqual(linearWallJoinPatches([
|
||||
{ a: [-2, 0], b: [0, 0], halfDepth: 1 },
|
||||
{ a: [0, 0], b: [0, 0], halfDepth: 1 },
|
||||
{ a: [0, 0], b: [Infinity, 1], halfDepth: 1 },
|
||||
]), [], 'invalid neighbours do not alter a valid flat-capped segment');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user