feat: join independent wall junctions

Issue: #141
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-14 12:11:08 +00:00
committed by claude[bot]
parent b860ef4c43
commit 087f7cf381
22 changed files with 1131 additions and 433 deletions
File diff suppressed because one or more lines are too long
+51
View File
@@ -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
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 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
+153
View File
@@ -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));
File diff suppressed because one or more lines are too long
+125 -123
View File
File diff suppressed because one or more lines are too long
+14 -1
View File
@@ -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
+6
View File
@@ -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
+6
View File
@@ -8,6 +8,12 @@
## Unreleased
- Соединённые отрезки «Контура комнаты» и «Перегородки» теперь сразу, включая
активный rubber-band, образуют одну бесшовную толстую стену. Прямые и косые
углы получают ограниченный mitre/bevel, T-соединение не показывает торец,
каждый отрезок сохраняет свою толщину, а свободные концы остаются плоскими в
Plan, View, статичной карточке, скрытой изометрии, чистой площади и световой
геометрии ([#141](https://github.com/Matysh/houseplan-card/issues/141)).
- При рисовании инструментами «Контур комнаты» и «Перегородка» поверх
существующих стен теперь видны их осевые линии и точные конечные узлы.
Увеличенная точка заранее показывает соединение с концом стены или создание
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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) |
+10
View File
@@ -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
+8
View File
@@ -275,6 +275,13 @@ desktop: для точного рисования, Resize, модификато
предпросмотра рекомендуется редактор на компьютере; tap на сенсорном экране
тоже выполняет привязку, но отдельный hover до касания не показывается.
Толстые отрезки, соединённые в такой точке, сразу показываются как одна стена:
прямые и косые углы получают ограниченный mitre/bevel без зуба или щели, а
T-соединение входит в проходящую стену без видимого торца. Это действует уже у
активного rubber-band до клика и сохраняется после клика, в View и на статичной
карточке. Толщина каждого ранее поставленного отрезка остаётся своей; свободный
конец незамкнутой стены остаётся плоским.
Каждый законченный отрезок незамкнутого контура сохраняется сразу. В той же
сессии инструмент продолжит его автоматически; после перезагрузки выберите
кликом один из концов. Замкнутый контур можно сохранить как комнату или выбрать
@@ -345,6 +352,7 @@ Glow и солнечные лучи, но при Resize комнаты оста
| Общая стена | Одна физическая стена между комнатами |
| Чистая площадь | Считается по внутренней грани стен |
| Углы | Соседние тела стен соединяются; на малом масштабе штриховка скрывается, тело остаётся |
| Независимые стыки | Контуры и перегородки с точным общим узлом образуют один ограниченный mitre/bevel; T-стык не дробит исходную стену |
| Соседние одинаковые участки | Нормализуются в один участок при редактировании/оптимизации |
| Виртуальный участок | Не имеет тела и толщины; реальные остатки сохраняют свою толщину |
+19
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(' ');
}
/**
+24 -1
View File
@@ -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']);
+46 -1
View File
@@ -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,
+30 -1
View File
@@ -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');
});