Compare commits

...
Author SHA1 Message Date
claude[bot] b268dd2683 docs: review document for #141
Issue: #141
User-Visible: no
2026-08-14 12:11:07 +00:00
claude[bot] 44ba55d559 docs: review document for #141
Issue: #141
User-Visible: no
2026-08-14 09:31:11 +00:00
Sergey Matyunin f9476c3f78 fix: preserve single wall previews
Issue: #141
User-Visible: yes
2026-08-14 12:29:20 +03:00
Sergey Matyunin 3e33f4a584 feat: join independent wall junctions
Issue: #141
User-Visible: yes
2026-08-14 12:19:12 +03:00
Sergey Matyunin c7f9651c89 merge: sync dev into issue 141
Issue: #141
User-Visible: no
2026-08-14 11:56:46 +03:00
claude[bot] 28581754a7 docs: spec review for #141
Issue: #141
User-Visible: no
2026-08-14 08:54:30 +00:00
Sergey Matyunin 59c66ddfb9 docs: specify seamless wall junctions
Issue: #141
User-Visible: no
2026-08-14 11:47:10 +03:00
27 changed files with 2341 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
+7
View File
@@ -2,6 +2,13 @@
## 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 single-segment thickness hover and free
ends remain visible and 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
+7
View File
@@ -8,6 +8,13 @@
## Unreleased
- Соединённые отрезки «Контура комнаты» и «Перегородки» теперь сразу, включая
активный rubber-band, образуют одну бесшовную толстую стену. Прямые и косые
углы получают ограниченный mitre/bevel, T-соединение не показывает торец,
каждый отрезок сохраняет свою толщину, а hover толщины одиночного сегмента и
свободные концы остаются видимыми и плоскими в
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.
+233
View File
@@ -0,0 +1,233 @@
# Code review — issue #141, cycle r1
Вердикт: **красный** · цикл r1/4 · High: 1 · Medium: 0
Ветка: `issue/141-wall-joints` · implementation-коммит
[`3e33f4a`](https://github.com/Matysh/houseplan-card/commit/3e33f4a5845a29694473697bea916bb3e2490ac2)
· ТЗ: [`docs/specs/141-wall-junctions.md`](../specs/141-wall-junctions.md)
(reviewed `2858175`, зелёный SPEC-REVIEW-141-r1).
## Скоуп проверки
Диапазон `git diff origin/dev...HEAD` — 25 файлов, ядро изменений:
`src/wall-thickness.ts`, `src/physical-geometry.ts`, `src/space-render.ts`,
`src/houseplan-card.ts`, плюс тесты (`test/*.test.mjs`), новый
`demo/smoke_wall_junctions.mjs`, `demo/golden/matrix.mjs`/`harness.mjs`,
документация (`ARCHITECTURE.md`, `WALL-THICKNESS.md`, `LIGHT.md`,
`ISOMETRIC.md`, `USER-GUIDE.ru.md`, `TESTING.md`, `STATUS.md`) и оба
changelog. Три копии bundle идентичны между собой.
Трейлеры единственного коммита `3e33f4a`: `Issue: #141` ·
`User-Visible: yes`; оба changelog обновлены в этом же коммите — требование
выполнено.
## Как проверялось
Дешёвые гейты (всегда):
- `npx tsc --noEmit` → **зелёный**, без вывода.
- `npm test` → **793/793 green** (на Linux упомянутый автором Windows-only
сбой `process-gate.test.mjs` не воспроизводится — ожидаемо).
- `npm run build` → зелёный; `cmp dist/houseplan-card.js
custom_components/houseplan/frontend/houseplan-card.js` и `cmp
dist/houseplan-card.js demo/srv/assets/houseplan-card.js` — обе команды
молча завершились успехом, `git status` после билда пуст (комитнутые копии
побайтно совпадают со свежей сборкой).
Гейты по необходимости (diff меняет геометрию рендера/света/iso, и под задачу
заведён именной smoke plus golden-сценарии):
- `node demo/smoke_wall_junctions.mjs` (назван в ТЗ §13.2 и в AC2/AC5/AC7-9) —
**упал**: `lineTargetGetsLocalJoinPatch: expected true, got false`. Разбор
ниже, в разделе «Находки».
- `npm run golden:verify` — **не прогонялся** полным набором. Инструмент
отказывается верифицировать по одному сценарию
(`golden verify must run the complete matrix; use capture for a diagnostic
--scenario run`), а полный набор (67 сценариев) — предрелизный гейт;
прогонять его сейчас нецелесообразно: ниже уже есть блокирующий High,
найденный дешевле и точнее через smoke и прямое чтение/воспроизведение кода,
а после исправления понадобится новый прогон в любом случае.
- `python -m pytest tests_backend` — не прогонялся: Python не тронут (AC12
подтверждён и diff'ом, и по ТЗ backend/schema не меняются).
- performance-профили — не прогонялись: это предрелизный гейт (AC11 говорит
про кеш/bounded pass, что проверено чтением кода, а не про изменение
бюджета); диф не даёт повода подозревать причину именно в перформансе.
## Находки
### [High] Живой rubber-band превью первого сегмента контура/перегородки не рисуется вовсе
**Файл:** `src/wall-thickness.ts:622-676` (`unionSimpleBodies`,
`drawWallPreviewD`).
**Сценарий отказа:** пользователь в Plan editor выбирает инструмент
«Контур» или «Перегородка», ставит первую точку и двигает курсор — то есть
самое частое, стартовое состояние рисования любой независимой стены/контура.
До клика на второй точке предпросмотр состоит ровно из одного сегмента.
`drawWallPreviewD()` в этом случае строит один `linearWallBody`, вызывает
`linearWallJoinPatches(segments)` (которая гарантированно возвращает `[]` при
`segments.length < 2`), получает `joined.length === 1` и передаёт его в
`unionSimpleBodies`:
```ts
function unionSimpleBodies(bodies: number[][][]): any | null {
let geom: any = null;
for (const body of bodies) {
...
const piece: any = closedRing(body);
geom = geom ? union(geom, piece) : piece; // <-- первое тело НЕ проходит union()
}
return geom;
}
```
Для одного тела `geom` становится «голым» `Polygon` (`Ring[]`, то есть
`[ring]`), а не ожидаемым `MultiPolygon` (`Polygon[]`, то есть `[[ring]]`),
который во всех остальных местах кодовой базы гарантированно возвращает
`union(...)` (см. `physical-geometry.ts: unionBodies`, где даже единственное
тело всегда идёт через `union(polygons[0])` и получает корректную обёртку —
проверено отдельно, ниже). `drawWallPreviewD` передаёт этот неверно
сформированный `geom` в `polyclipToPathD`, которая ожидает `MultiPolygon` и
итерирует `for (const poly of geom) for (const ring of poly)`; получив вместо
`poly` голое кольцо (массив точек), она видит на месте «колец» отдельные точки
`[x, y]` (`ring.length < 4` всегда) и молча ничего не добавляет в `d`. Функция
возвращает **пустую строку**, хотя выше стоит `if (geom) return
polyclipToPathD(geom);` — то есть путь фиксируется как «успешный», и
единственный fallback (`joined.map(polyToPath)...`) не выполняется.
**Как воспроизведено (не только чтением):**
1. Прямой вызов скомпилированного `test-build/wall-thickness.js`:
```js
import { drawWallPreviewD } from './test-build/wall-thickness.js';
drawWallPreviewD([[0,0],[100,0]], 8, false); // -> "" (было бы '"M ... Z"' до #141)
drawWallPreviewD([[0,0],[100,0],[100,100]], 8, false); // -> корректный путь (2 сегмента, union() вызывается)
```
2. Собственный smoke задачи (`demo/smoke_wall_junctions.mjs`), написанный тем
же автором для AC2/AC5, воспроизводит это в реальном рендере card: при
одном сегменте рубер-бэнда, коснувшемся T-цели, DOM должен получить два
элемента `.drawwall-preview` (основной штрих + join-patch), но получает
один — потому что основной штрих пуст, а виден только join-patch.
Отладочный прогон (см. приложенный вывод) подтверждает: атрибут `d`
единственного найденного `.drawwall-preview` — это на самом деле patch
(`"M 500 700 L 491.66... Z M 500 700 L 508.33... Z"`), а не полоса от
(500,540) до (500,700), которую должен рисовать основной сегмент.
**Почему это не только про «T-цель» из имени упавшей проверки.** Причина не в
логике поиска target-сегментов (`_drawPreviewJoinPatchD`), а в
`unionSimpleBodies`: пустая строка возвращается для **любого** одиночного
сегмента без соединений — то есть при рисовании одиночной «Перегородки» (два
клика: старт+конец) весь live-preview во время наведения между этими кликами
не рисуется совсем, не только в стыке. Это прямой регресс базового поведения:
до этой задачи `drawWallPreviewD` строила `d` конкатенацией
`polyToPath(quad)` без union и работала для любого числа сегментов, включая
один.
**Нарушенный контракт:** ТЗ §7.5.1/7.5.2 и AC2 требуют, чтобы «после каждого
законченного segment открытый контур показывает joined body немедленно» и
чтобы rubber-band «использует тот же bounded mitre/bevel к предыдущему
segment». Ни то, ни другое не выполняется, когда сегмент один — вместо
недостающего угла (как было `до`, по формулировке ТЗ §2) пользователь не видит
вообще никакой заливки/штриха. Это хуже исходного дефекта issue, а не лучше, и
напрямую бьёт по J4/J6 (`docs/SCOPE.md`): администратор перестаёт видеть, что
рисует, в самый частый момент рисования.
Также замечу: ни один существующий unit-тест не покрывает именно
однократный (двухточечный) вызов `drawWallPreviewD` без закрытия — новый тест
«`drawWallPreviewD returns a path for open and closed outlines`» и его
дополнение используют 3-точечные (двухсегментные) входы, поэтому регресс не
пойман `npm test` и обнаружился только через специально написанный smoke.
Это не отдельная находка, а причина, по которой AC3/AC13 «зелёный unit»
не является доказательством для этого случая.
**Требуется:** доработка автора (например, всегда вызывать `union()`, а не
присваивать первое тело напрямую — по аналогии с уже корректным
`unionBodies()` в `physical-geometry.ts`), новый unit-тест на ровно один
сегмент без соединений, и повторный прогон
`demo/smoke_wall_junctions.mjs` до зелёного.
## Что проверено и корректно
- **AC1/AC3/AC4/AC6 (сохранённая геометрия узлов)** — юнит-тесты
`test/physical-geometry.test.mjs` («joined partitions fill straight and
oblique endpoint teeth…», «endpoint-on-line T join…») и
`test/wall-thickness.test.mjs` («linear wall joins bevel an excessive
mitre…») проходят и содержательны: проверяют bounded mitre/bevel,
инвариантность к порядку/направлению записей, near-miss вне epsilon,
malformed/zero-length входы. Не вакуальны — на `origin/dev` физически не
существует `physicalBodySet`/`linearWallJoinPatches`, так что тест не мог
бы даже собраться.
- **AC7/AC8 (единая физическая семантика для presentation/occlusion)** —
прочитан `houseplan-card.ts:13625-13637`: Glow/sun/light теперь получают
`physical` (join-patches включены) как `extraBodies` в
`wallBodiesGeometry()`, а старый отдельный обход `for (const body of
physical) occluders.push(...)` убран из «счастливого» пути и остался только
как fail-opaque fallback, когда `wallBodiesGeometry` вернула `null` — именно
то, что требует ТЗ §7.7.3. Подтверждено также прогоном smoke:
`cleanFloorUsesJoinedCorner`, `lightUsesJoinedCorner`,
`savedRightAngleToothIsFilled` — все `true`.
- **AC7 (Plan/View/static/iso общий footprint)** — smoke:
`planViewParity`, `isoUsesJoinedFootprint`, `staticUsesSameJoinedPath` —
все `true`; `space-render.ts` получил собственный `staticPhysicalBodiesCache`
с тем же fingerprint-паттерном, что уже был у `staticWallGeometryCache`.
- **AC9 (identity редактируемых записей)** — smoke:
`rawIdentityCountStaysPerSegment: true` (raw-тел ровно 9 = 7 partitions + 2
сегмента drafts, конфиг не меняет число записей); `_furnWalls` теперь
явно использует `_rawPhysicalBodiesR()` (raw, не joined) — корректно для
мебельного магнита, как и требует ТЗ.
- **AC12 (схема/бэкенд/i18n не меняются)** — подтверждено diff'ом: ни один
файл `custom_components/**/*.py`, `src/types.ts` (схема), i18n JSON не
затронут.
- **AC13 (гейты, бандлы, документация в одном коммите)** — единственный
implementation-коммит `3e33f4a` несёт `Issue: #141` / `User-Visible: yes`,
оба changelog, `ARCHITECTURE.md`/`WALL-THICKNESS.md`/`LIGHT.md`/
`ISOMETRIC.md`/`USER-GUIDE.ru.md`/`TESTING.md`/`STATUS.md` и все три копии
bundle — свежая локальная сборка побайтно совпала с закоммиченными файлами.
- **previewDoesNotWriteOrSplitTarget / renderNeverRewritesConfig** (smoke) —
`true`: вычисляемые узлы не пишут конфиг ни при рендере, ни при hover —
соответствует ТЗ §9.
- Мёртвый `draftBodies()` (`physical-geometry.ts:71`) остался экспортирован,
но больше нигде не используется продуктовым кодом (только собственное имя
дублирует уже инлайненную в `physicalBodySet` логику для drafts) — **Low**,
не блокирует; безопасно удалить в этой же задаче при исправлении High, но
можно оставить, если автор считает риск правки того же файла в цикле r2
выше пользы. Записано, не заводится отдельным issue — правится или
сознательно снимается автором вместе с основным фиксом.
## Чего не проверял
- **`npm run golden:verify` (полный набор)** — не прогонялся. Причина
проста и явная: инструмент запрещает частичный прогон в режиме verify, а
полный прогон (67 сценариев) — предрелизный гейт, непропорциональный
ревью, когда блокирующий High уже найден дешевле. Новые golden-сценарии
`wall-junctions-*`/`isometric-wall-junctions-dark` не просмотрены визуально
— это стоит сделать в r2 вместе с починкой (эти сценарии, скорее всего,
как раз показывали бы пустой rubber-band, если бы захватывали превью-момент;
`wall-junctions-plan-preview-light` — по имени похоже, что именно этот
сценарий должен был поймать баг, но без прогона это предположение, не факт).
- **Полный browser smoke-suite (127 файлов)** — не прогонялся, вне
относящихся к задаче поверхностей; прогнан только целевой
`smoke_wall_junctions.mjs`, который и нашёл проблему.
- **`python -m pytest tests_backend`** — не прогонялся, Python не тронут.
- **Performance smoke / Full Performance** — не прогонялись; это
предрелизный гейт, и diff не даёт повода подозревать регресс
производительности отдельно от найденного High.
- **Ручное визуальное сравнение в браузере** (не headless) — не выполнялось;
вывод основан на smoke/golden harness и прямом воспроизведении через
скомпилированный `test-build`.
- **Drag/Undo/Redo отдельных partitions после join** (часть AC9) — smoke
проверяет только сохранение количества и id raw-тел, не сам факт
перетаскивания/истории в интерактивном сценарии; отдельный ручной проход по
этому пункту не делался.
## Итог
High: 1 (описан выше, блокирует). Medium: 0. Low: 1 (мёртвый экспорт
`draftBodies`, не блокирует, правится по усмотрению автора).
Вердикт красный: цикл возвращается автору на исправление
`unionSimpleBodies`/`drawWallPreviewD` для одиночного сегмента, с последующим
зелёным прогоном `npm test` (включая новый регресс-тест на один сегмент) и
`node demo/smoke_wall_junctions.mjs`.
+218
View File
@@ -0,0 +1,218 @@
# Code review — issue #141, cycle r2
Вердикт: **зелёный** · цикл r2/4 · High: 0 · Medium: 0
Ветка: `issue/141-wall-joints` · head-коммит
[`f9476c3`](https://github.com/Matysh/houseplan-card/commit/f9476c3f78c242b25344fa190c801c27b839c71c)
(`fix: preserve single wall previews`) поверх implementation-коммита
[`3e33f4a`](https://github.com/Matysh/houseplan-card/commit/3e33f4a5845a29694473697bea916bb3e2490ac2),
проверенного в [`CODE-REVIEW-141-r1.md`](CODE-REVIEW-141-r1.md) (красный,
High: 1). ТЗ: [`docs/specs/141-wall-junctions.md`](../specs/141-wall-junctions.md)
(reviewed `2858175`, зелёный `SPEC-REVIEW-141-r1.md`).
## Скоуп проверки
r1 нашёл один блокирующий High и остановился на нём (правило «дороже искать
второй дефект, чем дешевле починить первый»). r2 проверяет: (а) правку этого
конкретного High по существу, а не только «тест прошёл»; (б) что фикс не
сломал ничего из уже подтверждённого в r1 по остальным 12 пунктам; (в) что
изменённый диапазон (`git diff origin/dev...HEAD`, теперь 26 файлов) не
содержит новых незамеченных изменений сверх точечного коммита `f9476c3`.
`git show f9476c3 --stat` — 7 файлов: `src/wall-thickness.ts` (+6/-1),
`test/wall-thickness.test.mjs` (+2), оба changelog (+3/-1 каждый), три копии
бандла. Это ровно тот минимальный набор, который требуется для точечного
фикса: не расширяет диф `3e33f4a`, не трогает `physical-geometry.ts`,
`space-render.ts`, `houseplan-card.ts` — весь остальной код, уже проверенный в
r1, не менялся между r1 и r2.
Трейлеры `f9476c3`: `Issue: #141` · `User-Visible: yes`; оба changelog правят
ту же строку записи `3e33f4a` (не добавляют новую) в этом же коммите —
требование выполнено. Финальный коммит диапазона `44ba55d` — только
`docs/reviews/CODE-REVIEW-141-r1.md`, класс C, не влияет на проверяемое
поведение.
## Как проверялось
### Разбор фикса по коду (не только «тест зелёный»)
`src/wall-thickness.ts:622-636` (`unionSimpleBodies`):
```ts
geom = geom ? union(geom, piece) : [piece]; // было: ... : piece;
```
`closedRing()` (`src/wall-thickness.ts:1355-1359`) возвращает `[ring]` —
одно кольцо, то есть значение типа `Polygon` (`Ring[]`) в терминах
`polyclip-ts`. `union(a, b)` всегда возвращает `MultiPolygon` (`Polygon[]`).
До фикса единственное тело присваивалось в `geom` как голый `piece`
(`Polygon`), а не как `MultiPolygon` — ровно расхождение форм, которое r1
нашёл: `polyclipToPathD()` итерирует `for (const poly of geom) for (const
ring of poly)`, и на голом `Polygon` внешний цикл видел не полигоны, а сами
точки кольца. После фикса `[piece]` — это `Polygon[]` с одним элементом, то
есть корректный `MultiPolygon` формы, которую в норме возвращает `union(...)`.
Дальнейшие итерации (`geom ? union(geom, piece) : ...`) не менялись и уже были
верны — правка точечная и минимальна, не переписывает остальную функцию.
Проверено также, что это не механическая правка вслепую: `piece` не проходит
через `union()` на этом шаге (для одного простого, не самопересекающегося
quad/patch это не нужно — обёртка формы эквивалентна нормализации через
`union()` одного полигона), что соответствует уже принятой в кодовой базе
семантике `unionBodies()` (`src/physical-geometry.ts:148-155`, где единственное
тело тоже уходит через `union(polygons[0])`, гарантированно возвращающий
`MultiPolygon`) — именно то соответствие, которое r1 требовал восстановить.
Проверено прямым вызовом собранного `test-build`, что регресс правда снят:
```
node -e "
const { drawWallPreviewD } = await import('./test-build/wall-thickness.js');
console.log(drawWallPreviewD([[0,0],[100,0]], 8, false));
"
```
— возвращает непустой путь `M ...` (до фикса возвращал `''`, как задокументировано
в `CODE-REVIEW-141-r1.md`).
Новый unit-тест (`test/wall-thickness.test.mjs:972-973`) закрывает именно
пропущенный в r1 случай — ровно один сегмент без соединений — и по формату
идентичен уже существующим утверждениям в том же тесте (не декоративный
`assert.ok(true)`; проверяет `includes('M')`, то есть непустой путь).
### Дешёвые гейты (всегда)
- `npx tsc --noEmit` → **зелёный**, без вывода.
- `npm test` → **793/793 green** (Linux; Windows-only `process-gate.test.mjs`
здесь не воспроизводится — ожидаемо, как и в r1).
- `npm run build` → зелёный; после билда `cmp dist/houseplan-card.js
custom_components/houseplan/frontend/houseplan-card.js` и `cmp
dist/houseplan-card.js demo/srv/assets/houseplan-card.js` — обе пары
идентичны побайтно, `git status --short` после билда пуст (закоммиченные
копии соответствуют исходникам, дрифта нет).
### Гейты по необходимости
- `node demo/smoke_wall_junctions.mjs` (назван в ТЗ §13.2, покрывает
AC1/AC2/AC4/AC6/AC7/AC8/AC9) — **12/12 green**, включая
`lineTargetGetsLocalJoinPatch: true` — именно ту проверку, которая упала в
r1.
- `node demo/smoke_wall_thickness.mjs` — прогнан дополнительно, потому что
автор в комментарии к `f9476c3` сообщил о втором падении того же коммита
`3e33f4a` (пропавший hover одного участка стены) и точечно исправил его тем
же изменением `unionSimpleBodies`. Регресс-риск общий с основным фиксом, а
тест не назван в ТЗ #141 явно — стоило перепроверить отдельно. **28/28
green**, включая `hover: true`.
- `node demo/smoke_glow.mjs`, `node demo/smoke_sun.mjs`,
`node demo/smoke_isometric_contract.mjs` — прогнаны точечно, потому что
`unionSimpleBodies` используется в `drawWallPreviewD`, который делит код с
путём, питающим `wallBodiesGeometry` (общая joined-геометрия для Glow/sun/iso
по AC7/AC8); фикс расположен в общем helper'е, а не в изолированном месте.
Все три **green** (27/27, 27/27, 16/16) — фикс не задел эти поверхности за
пределами уже проверенного.
- `npm run golden:verify` (полный набор 67 сценариев) — **не прогонялся**, по
той же причине, что и в r1: инструмент запрещает частичный прогон, полный
прогон — предрелизный Chromium-гейт (`AGENTS.md`: «smoke, golden и
performance_smoke... belong to the pre-release run»), а диф r2 — шесть
добавленных строк в одной функции плюс один unit-тест. Риск того, что именно
эта точечная правка сдвинула какой-то из 67 golden-baseline, не
подтверждается ни диффом (никакая геометрия узла/mitre/bevel не менялась,
правка только про форму возвращаемого значения для случая с одним телом,
который в закрытых/многосегментных golden-сценариях не возникает), ни
прогнанными smoke.
- `python -m pytest tests_backend` — не прогонялся: Python не тронут ни в
`3e33f4a`, ни в `f9476c3` (AC12).
- Performance-профили — не прогонялись: правка не меняет алгоритмическую
форму (не добавляет проходов, не трогает кеш/fingerprint), только форму
возвращаемого значения; AC11 в r1 подтверждён чтением кода и не затронут
этим коммитом.
- Полный browser smoke-suite (127 файлов) — не прогонялся целиком; прогнаны
именной smoke задачи плюс три смежные по трогаемому общему коду поверхности
(glow/sun/iso) — расширять дальше нет диффового повода.
- Полный локальный визуальный просмотр в браузере (не headless) — не
выполнялся; как и в r1, вывод строится на смоках/юнитах и прямом
воспроизведении на `test-build`.
## Находки
Находок уровня High и Medium нет.
**Low, унаследованный из r1, не переоценивается заново:** экспортированная
`draftBodies()` (`src/physical-geometry.ts:71`) остаётся мёртвым кодом —
не изменилась между r1 и r2, автор явно отметил в комментарии к `f9476c3`,
что не стал удалять публично экспортируемый helper в рамках точечного
review-фикса. Решение разумно: `f9476c3` — узкий фикс с минимальным дифом
специально для быстрой повторной проверки; удаление отдельного мёртвого
экспорта в этом же коммите увеличило бы диф без необходимости. Остаётся Low,
не блокирует, правится по усмотрению автора в отдельной задаче или следующей
правке этого файла.
**Побочное наблюдение вне скоупа #141 (не находка, не для этой задачи):** тот
же паттерн формы (`body = body ? union(body, piece) : piece;` без обёртки в
`[piece]`) присутствует в непотронутой этим диффом `exteriorEnvelopeGeometry()`
(`src/wall-thickness.ts:~1690-1745`, уже существовала на `origin/dev` до
#141). Не проверялось, воспроизводим ли там аналогичный дефект — код не входит
в диапазон `git diff origin/dev...HEAD`, никакая строка там не менялась ни в
`3e33f4a`, ни в `f9476c3`, и AC #141 не покрывают эту функцию. Упоминаю только
как наблюдение для владельца/следующего аналитика, не как Medium/High этого
ревью — заводить отдельный issue на непроверенное предположение о коде, не
относящемся к диффу, было бы самому выдавать догадку за факт.
## Что проверено и корректно
- **Исправление High из r1** — `unionSimpleBodies` теперь возвращает
корректный `MultiPolygon` и для одного, и для нескольких тел; подтверждено
чтением кода, прямым вызовом на `test-build` и зелёным
`smoke_wall_junctions.mjs` (`lineTargetGetsLocalJoinPatch: true`,
`rubberBandAndCommittedPreviewMatch: true`).
- **Побочный регресс, о котором сообщил автор** (`smoke_wall_thickness`,
пропавший hover одного участка) — исправлен тем же изменением; подтверждено
отдельным прогоном smoke (`hover: true`, 28/28).
- **Все 12 пунктов, подтверждённых в r1 по коду и smoke** (единая joined-
геометрия Plan/View/static/iso, clean-floor/Glow/sun через `physical` как
`extraBodies`, identity raw-тел, отсутствие записи конфига при
preview/hover, схема/бэкенд/i18n не тронуты, документация и бандлы в одном
коммите) — код между r1 и r2 в этих местах не менялся; повторно
подтверждено тем же `smoke_wall_junctions.mjs` (12/12) и точечными
`smoke_glow`/`smoke_sun`/`smoke_isometric_contract` (все green), чтобы
убедиться, что общий helper (`unionSimpleBodies`) не задел эти поверхности
при исправлении.
- **Регрессионный unit на ровно один сегмент** — новый ассерт в
`test/wall-thickness.test.mjs` содержателен (проверяет непустой путь для
двухточечного вызова), не тавтологичен, действительно ловит регресс:
временный откат правки к `: piece` (без `[...]`) заставляет этот ассерт
упасть (проверено локально откатом одной строки и повторным `npm test`).
- **Трейлеры и changelog `f9476c3`** — `Issue: #141` / `User-Visible: yes`,
правка той же строки в обоих changelog в этом же коммите; терминология
(«hover толщины одиночного сегмента») согласуется с инструментом «Толщина»
из `docs/USER-GUIDE.ru.md` (:306, :346), не изобретает новый термин.
- **Три копии бандла** — идентичны друг другу и свежей локальной сборке;
`git status` после билда чист.
## Чего не проверял
- **`npm run golden:verify` (полный набор)** — не прогонялся; см. обоснование
в разделе «Как проверялось». Новые golden-сценарии `wall-junctions-*` /
`isometric-wall-junctions-dark` из r1 так и не просмотрены визуально ни в
r1, ни здесь — это остаётся открытым пунктом предрелизного гейта, а не
code-review, но фиксирую явно, чтобы решение не потерялось.
- **Полный browser smoke-suite (127 файлов)** — не прогонялся; прогнаны
целевой + три смежные по общему коду.
- **`python -m pytest tests_backend`** — не прогонялся, Python не тронут.
- **Performance smoke / Full Performance** — не прогонялись; диф не меняет
алгоритмическую форму горячего пути.
- **Ручное визуальное сравнение в браузере (не headless)** — не выполнялось.
- **Drag/Undo/Redo отдельных partitions после join** (часть AC9) — как и в
r1, отдельный интерактивный проход не делался; smoke проверяет только
сохранение количества/id raw-тел.
- **Побочное наблюдение про `exteriorEnvelopeGeometry()`** (см. «Находки») —
не исполнялось и не воспроизводилось; это код вне диапазона диффа #141.
## Итог
High: 0. Medium: 0. Low: 1 (унаследованный `draftBodies()`, не блокирует).
Вердикт зелёный: High из r1 исправлен по существу (не только тест переписан —
разобрана причина, форма данных проверена по контракту `polyclip-ts`,
регресс-тест содержателен и способен падать), сопутствующий регресс
(`smoke_wall_thickness`) закрыт тем же изменением и отдельно перепроверен,
диф между r1 и r2 минимален и не выходит за рамки точечного фикса. Задача
может переходить в `S8-merged`.
+269
View File
@@ -0,0 +1,269 @@
# SPEC-REVIEW-141-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/141
- **ТЗ под ревью:** `docs/specs/141-wall-junctions.md` (коммит `59c66dd`,
ветка `issue/141-wall-joints`)
- **Роль:** ревьюер ТЗ (не автор), этап `S4-spec-review`
- **Трек:** обычный (не `small`/`trivial`) — сложность/риск 7/10, две+
поверхности (перегородки/drafts и рисование комнатных стен), влияние на
golden, свет и производительность; лёгкий/короткий трек владелец и автор
корректно не применили.
- **Цикл:** r1/4
## Скоуп ревью
Проверялось соответствие ТЗ:
- `docs/SCOPE.md` — попадание в Core user jobs, отсутствие расширения скоупа;
- `PROCESS.md` §2.4, §2.5 (DoR), §7.1 (обязательные разделы), §3/§12 (запреты);
- `AGENTS.md` — классы файлов, ветка, трейлеры коммита ТЗ;
- каноническим документам: `docs/WALL-THICKNESS.md` (модель толщины,
mitre/bevel, union тел), `docs/LIGHT.md` (барьеры/`_lightBarriers`),
`docs/SUN.md` (occluders), `docs/ISOMETRIC.md` (`wallBodiesGeometry` +
extra physical bodies), `docs/CANVAS.md` (координаты/масштаб),
`docs/TOUCH-SUPPORT.md` (best-effort editors), `docs/CONFIG-COMPATIBILITY.md`
(миграция — в задаче её нет, но проверено, что это верно);
- `docs/USER-GUIDE.ru.md` — терминология «Контур комнаты» / «Перегородка»;
- фактическому коду (`src/wall-thickness.ts`, `src/physical-geometry.ts`,
`src/houseplan-card.ts`) — на предмет того, что технический диагноз ТЗ не
является непроверенной догадкой, выданной за факт.
## Как проверялось
1. Прочитан весь тред issue #141: исходное описание с уточнением владельца
(«проявляется и на прямых, и на непрямых углах»), полная аналитика
(ценность 8/10, сложность/риск 7/10, P2, тип `bug`, полный трек) с Q1–Q3 и
явными defaults, решение владельца «принимаю все defaults», финальный
комментарий автора со ссылкой на коммит ТЗ.
2. Сверены обязательные разделы ТЗ (§7.1 PROCESS.md) построчно — таблица ниже.
3. Прочитан код и подтверждены построчно все технические утверждения §3 ТЗ:
- `drawWallPreviewD()` (`src/wall-thickness.ts:490`) действительно строит
`outset−inset` только для `closed`-контура (:496); открытый путь —
независимый quad на каждый сегмент (:512-529). Диагноз ТЗ п.1 точен.
- `partitionBody()` (`src/physical-geometry.ts:41`) строит прямоугольник с
плоскими торцами без топологии узла; `physicalBodies()` (:89) просто
собирает такие прямоугольники в список. Диагноз ТЗ п.2 точен.
- `MITRE_LIMIT = 4` (`src/wall-thickness.ts:28`) и его использование в трёх
местах (:669, :1429, :1805) существуют — ссылка ТЗ §7.2.3/AC3 не
изобретена.
- Изометрия (`src/houseplan-card.ts:4606-4611`) уже вызывает
`wallBodiesGeometry(..., extras)` с `extras = physicalBodies(...)` —
значит full/static/iso уже объединяют независимые тела через
`wallBodiesUnionPath`/`wallBodiesGeometry` (тот же приём подтверждён в
`src/space-render.ts:338` для статической карточки). Ровно то, что ТЗ
утверждает в §3.5 и §8.2 («full/static/isometric render уже объединяют…»).
- `_lightBarriers()` (`src/houseplan-card.ts:13556`) вызывает
`wallBodiesGeometry()` **без** `extras` (:13623) и затем отдельно кладёт
сырые ребра каждого `physical`-тела в occluders (:13637:
`for (const body of physical) occluders.push(...polygonSegments(body));`).
Солнце (`src/houseplan-card.ts:12705-12716`) вызывает
`directionalOccluders(physical, ...)` напрямую на тех же сырых
прямоугольниках. Это подтверждает диагноз ТЗ §3.5/§8.2: свет и солнце
сегодня действительно обходят raw-прямоугольники отдельно от joined-пути
рендера, а не голословное утверждение.
- `floorMinusBodies()` вызывается с тем же `_physicalBodiesR()` (сырые
тела) и для чистой площади (:7366, :7388), и для проверки source-inside
(:11655) — совпадает с AC8.
4. Прочитан `docs/WALL-THICKNESS.md` целиком: подтверждён существующий
контракт mitre/bevel для комнатных стен (§3) и отдельный раздел «9.
Independent partitions, drafts and columns» — независимые тела уже
юнионятся с комнатными только после вырезов проёмов и участвуют в
clean-floor/Glow/sun occlusion. ТЗ §7.3.2, §9 и AC6/AC10 корректно
продолжают именно эту, а не новую, модель.
5. Прочитан `docs/LIGHT.md` и `docs/SUN.md`: подтверждено, что `_lightBarriers`
держит один общий барьерный набор на пространство и кэшируется по
геометрическому fingerprint, а не по `_cfgEpoch` — ТЗ §8.3.1-2 корректно
продолжает существующий кэш-контракт, не изобретая новый механизм
инвалидации.
6. Прочитан `docs/ISOMETRIC.md`: `src/iso-walls.ts` явно описан как
потребитель «канонического `wallBodiesGeometry()` MultiPolygon after
openings and extra physical bodies have been resolved» — буквально
подтверждает ТЗ §8.2 («full/static/isometric не создают разные join
алгоритмы»), т.е. это не домысел автора, а прямая цитата канона.
7. Прочитан `docs/TOUCH-SUPPORT.md`: политика требует, чтобы «новые
спецификации editor-фич и код-ревью» явно указывали одно из трёх значений
`Touch editor: supported / best effort / not exposed`. ТЗ §10 описывает
ровно best-effort-поведение словами, но не использует эту точную метку —
см. Low-1.
8. Прочитан `docs/USER-GUIDE.ru.md` (раздел «Создание комнаты», таблица
«Инструменты плана», раздел «Виртуальные стены»): термины «Контур комнаты»
и «Перегородка» в ТЗ (§4.1, §5.3 и др.) совпадают с интерфейсным словарём
ровно там, где употребляются полностью; там же нашёл существующую строку
«Углы | Соседние тела стен соединяются…» (:347) — актуальное описание,
которое release-артефакты ТЗ (§15) обязывают дополнить для partitions —
корректно учтено.
9. Прочитан `src/types.ts:45-72` — `RoomDraftCfg`, `PartitionCfg`,
`space.room_drafts`, `space.partitions` существуют как реальные поля
схемы, а не придуманы для ТЗ; AC4/AC5 про кросс-типовые соединения
(partition↔partition, draft↔partition, partition/draft↔room wall)
технически имеют смысл на этой модели.
10. Проверена запись `docs/specs/README.md:85` — строка на #141 добавлена в
том же коммите; ссылка issue ↔ ТЗ двусторонняя (ТЗ ссылается на issue в
заголовке, issue-комментарий ссылается на файл ТЗ и коммит).
11. Проверены трейлеры и class-принадлежность: `git diff --stat
origin/dev...HEAD` показывает только `docs/specs/141-wall-junctions.md` и
`docs/specs/README.md` (класс C, ни одного файла класса A); коммит
`59c66dd` несёт `Issue: #141` и `User-Visible: no` — корректно для
документа ТЗ, который сам не меняет поведение.
## Обязательные разделы (§7.1 PROCESS.md)
| Раздел | Есть | Комментарий |
|---|---|---|
| Сценарий (персона/поверхность/момент) | ✅ | §1 — домашний администратор, desktop Plan editor, момент второго клика |
| Что человек увидит до/после | ✅ | §2, «До:»/«После:» — см. Low-2 |
| Проблема (с подтверждённой причиной) | ✅ | §3, шесть пунктов, все проверены по коду (см. выше) |
| Скоуп / не-скоуп | ✅ | §5 / §6, явные границы (без snap #137, без нового cap, без #138) |
| Контракт поведения | ✅ | §7 (геометрия) + §8 (архитектура) |
| Модель данных и миграция | ✅ | §9 — явное «schema не меняется», «читается исправленно, без записи» |
| UX, i18n, accessibility, touch | ✅ | §10 — см. Low-1 |
| AC1…ACn с доказательством | ✅ | §12, 13 штук, каждый с типом доказательства и наблюдаемым результатом |
| План автотестов | ✅ | §13, по подпунктам на unit/smoke/golden/performance |
| Риски | ✅ | §16, таблица с вероятностью/ущербом/снижением |
| Откат | ✅ | §17 |
| Release-артефакты | ✅ | §15, конкретный список файлов документации и обоих changelog |
Все обязательные разделы присутствуют и содержательны. Дополнительно есть
раздел «Решения владельца» (§4, явно фиксирует принятые Q1–Q3), архитектурный
контракт (§8), план реализации (§14, помечен как техническая свобода) и явный
блок «принятые технические предположения» (§18) — соответствует духу
PROCESS.md §7.1 об отделении продуктового решения от технического.
## Находки
Находок уровня **High** и **Medium** нет — новых issue не требуется.
### Low-1 — нет явной метки touch-контракта по `docs/TOUCH-SUPPORT.md`
**Файл:** `docs/specs/141-wall-junctions.md:252-261` (§10)
`docs/TOUCH-SUPPORT.md` требует: «New editor feature specifications and code
reviews must state one of: `Touch editor: supported`; `Touch editor: best
effort / intentionally degraded`; `Touch editor: not exposed`.» §10 ТЗ по
существу описывает best-effort-контракт словами («Plan editor остаётся
desktop-first… новый hover parity не обещается») и корректно ссылается на
safety floor (сохранённая geometry тапнутого сегмента должна совпадать), но не
использует ни одну из трёх канонических формулировок буквально.
**Решение ревьюера:** Low, не блокирует. При следующей правке ТЗ или в
хендоффе код-ревью достаточно добавить одну строку `Touch editor: best effort
/ intentionally degraded` — по содержанию это именно то, что §10 уже
описывает.
### Low-2 — «что человек увидит» длиннее одной фразы
**Файл:** `docs/specs/141-wall-junctions.md:28-37` (§2)
PROCESS.md §7.1 требует «одной фразой, без терминов реализации». Раздел
написан двумя короткими абзацами («До:» / «После:»), по одному предложению
каждый; по существу требование выполнено (без терминов реализации, конкретно
и однозначно), но формально это не «одна фраза». Тот же класс находки уже
фиксировался как Low и не блокировал приёмку в `SPEC-REVIEW-137-r1`.
**Решение ревьюера:** Low, не блокирует. Косметическая правка на усмотрение
автора.
### Low-3 — доказательство AC10 («code review») до кода не проверяемо, но это ожидаемо для code-review-этапа
**Файл:** `docs/specs/141-wall-junctions.md:315-321` (AC10, AC11)
AC10 и AC11 указывают доказательство «`unit` + code review» / «`performance` +
code review» — это не входит буквально в список §2.5 PROCESS.md
(`unit`/`backend`/`smoke`/`golden`/«ревью кода»), но «code review» — это то же
самое «ревью кода» иначе сформулированное, и AC11 корректно называет
`performance`, обязывая код-ревьюера прогнать performance-профиль по правилу
«гейты соразмерны AC» (`PROCESS.md` §8). Не дефект по существу, тот же класс
находки не блокировал `SPEC-REVIEW-137-r1` (Low-3 там).
**Решение ревьюера:** Low, не блокирует. Оставить как есть.
## Что проверено и корректно
- **Соответствие `docs/SCOPE.md`:** задача закрывает **J4** («от нуля до
плана без внешнего редактора» — администратор должен доверять форме стены в
момент рисования, не после замыкания) и **J6** («план остаётся правдивым по
мере развития» — независимые перегородки не деградируют в кривую геометрию
навсегда). Обе строки Closed — это исправление дефекта внутри принятой
функциональности, не расширение продукта.
- **Легитимность полного трека:** сложность/риск 7/10, минимум две
поверхности (перегородки и рисование стен комнаты), влияние на golden, свет
и производительность — критерии `small`/`trivial` (§5/§5.1 PROCESS.md) не
выполняются ни по одному пункту; полный трек выбран верно.
- **Продуктовые вопросы закрыты по процессу:** Q1 (какие соединения входят),
Q2 (нормализуется ли rubber-band до клика) и Q3 (что делать со свободными
торцами) заданы одним пакетным комментарием, каждый с предлагаемым default,
issue корректно ушёл в `blocked`+`S3-spec` до ответа и вышел из `blocked`
сразу после решения владельца. Ни одна догадка не выдана за факт без
пометки — §18 отдельно и явно перечисляет технически свободные решения и
прямо фиксирует «нет открытых продуктовых вопросов» (§18.10).
- **Технический диагноз не голословен.** Все шесть пунктов §3 (независимый
путь `drawWallPreviewD`, плоские торцы `partitionBody`, отсутствие топологии
узла в `physicalBodies`, отдельный путь mitre для закрытого контура,
частичное объединение в full/static/iso против необъединённых barriers у
Glow/sun, неподтверждённость отдельного дефекта свободного торца по
скриншоту) построчно проверены по исходному коду и совпадают с ним — см.
«Как проверялось» п.3.
- **Не-скоуп (§6) корректно отсекает соседние соблазны:** snap-tolerance/#137,
автоматическое дробление persisted-геометрии, превращение partition в
границу комнаты/HA-зону, новый persisted junction-тип и миграция, новый
cap/join selector, #138 (замыкание контура по углам существующей комнаты) —
все явно исключены с указанием, почему это не эта задача.
- **Регрессионные гарантии сформулированы явно:** AC10 поимённо защищает
проёмы комнатных стен, virtual-T, nested/partial стены, экстерьер #123 и
колонны — то есть ровно те механизмы, которые уже используют
`MITRE_LIMIT`/union и могли бы негласно пострадать от новой топологии узлов.
- **Производительность:** §11/AC11 корректно ссылаются на существующую
60-partition фикстуру `large-house-v1` и явно запрещают full-house boolean
union на каждый pointermove/HA-tick (§8.3.3) — согласуется с
`docs/LIGHT.md` («barriers keyed by geometry fingerprint, never `_cfgEpoch`»)
и не вводит новый бюджет без решения процесса.
- **Модель данных и миграция (§9):** корректно заявлено «schema не меняется»,
«читается исправленно без записи», без прямой/обратной миграции — сверено с
`docs/CONFIG-COMPATIBILITY.md`: задача не создаёт нового
compatibility-случая, потому что не меняет persisted-представление.
- **Release-артефакты (§15)** перечисляют конкретные существующие документы
(`WALL-THICKNESS.md`, `LIGHT.md`, `SUN.md`, `ISOMETRIC.md`, `ARCHITECTURE.md`,
`TESTING.md`, `STATUS.md`, `USER-GUIDE.ru.md`) и оба changelog в одном
implementation-коммите — соответствует §7.1/правилу 11 PROCESS.md. Golden
корректно ограничен принятием только через `npm run golden:accept --
--reviewed` по полному Linux-артефакту (§13.3), perf/golden/browser-suite
верно отнесены к пре-релизному, а не implementation-гейту (§8, §11.4
PROCESS.md).
- **Откат (§17)** корректно опирается на отсутствие миграции данных: revert
implementation-коммита восстанавливает прежний (дефектный) визуал без
риска для сохранённых данных.
- **Трассируемость:** `docs/specs/README.md:85` обновлён тем же коммитом
(`59c66dd`); ветка `issue/141-wall-joints` и трейлеры (`Issue: #141`,
`User-Visible: no`) корректны для документа класса C, который сам не меняет
поведение. `git diff --stat origin/dev...HEAD` не содержит ни одного файла
класса A — продуктовый код не тронут до `S5-ready` (правило №1).
## Чего не проверял
- Не проверял реализуемость «одного чистого вычисляемого frame» (§8.1) как
конкретной структуры данных/API — по правилам ТЗ это явно свободное
техническое решение автора кода (§18.2), не предмет ревью ТЗ.
- Не запускал автотесты, `golden`, `performance` или browser-смоки — на этапе
`spec` это не требуется; существование фикстур (`large-house-v1`, реальные
поля схемы, конкретные функции и их поведение) проверено чтением кода, а не
исполнением.
- Не проверял, что boolean-decomposition конкретной реализации (mitre patch
для узла степени 3+, T-разрез сквозного сегмента) технически осуществима
средствами `polyclip-ts` в разумное время — это открытое для автора кода
техническое решение, отмеченное в ТЗ как свободно изменяемое (§18.3), и
фактическая проверка бюджета (AC11) относится к код-ревью и пре-релизному
гейту.
- Не проверял корректность конкретных числовых оценок аналитики (8/10 · 7/10 ·
P2) по существу — это поле владельца (PROCESS.md §2.2), уже принято явным
решением владельца до написания ТЗ.
- Не проверял связанные issue #123/#137/#138 по существу за пределами того,
что понадобилось для верификации ссылок ТЗ (регрессия #123, снэп #137,
разграничение с #138) — они не входят в предмет этого ревью.
## Вердикт
Зелёный. High: 0, Medium: 0. Три находки Low (отсутствие буквальной метки
touch-контракта по `docs/TOUCH-SUPPORT.md`; «что человек увидит» длиннее
одной фразы; типы доказательства AC10/AC11 вне буквального перечня §2.5) — ни
одна не блокирует приёмку, все либо правятся косметически при следующей
редакции, либо снимаются этой записью без нового цикла.
+481
View File
@@ -0,0 +1,481 @@
# Issue #141 — бесшовные стыки перегородок и открытых контуров
Статус: **ТЗ на ревью**
Дата: 2026-08-14
Тип: `bug` · приоритет: `P2` · оценка ценности: 8/10 · сложность/риск: 7/10
Issue: [#141](https://github.com/Matysh/houseplan-card/issues/141)
Ветка: `issue/141-wall-joints`
Канонические документы: [SCOPE](../SCOPE.md),
[WALL-THICKNESS](../WALL-THICKNESS.md), [CANVAS](../CANVAS.md),
[UX-MODES](../UX-MODES.md), [LIGHT](../LIGHT.md), [SUN](../SUN.md),
[ISOMETRIC](../ISOMETRIC.md), [TOUCH-SUPPORT](../TOUCH-SUPPORT.md).
Решения владельца Q1–Q3 и defaults аналитики приняты 2026-08-14 в issue #141.
## 1. Сценарий и продуктовый контекст
Основная персона — домашний администратор, который в desktop Plan editor с нуля
рисует план либо позднее добавляет к нему независимые перегородки. После второго
клика он должен оценивать уже финальную форму стены, а не ждать замыкания комнаты
или перехода в View, чтобы обнаружить клин, щель либо зуб в стыке.
Это часть J4/J6 из `docs/SCOPE.md`: GUI должен доводить администратора от пустого
холста до правдивого плана без внешнего редактора и сохранять этот план правдивым
при дальнейших изменениях. View и киоск не получают новых действий, но обязаны
показывать ту же физическую кладку и те же световые препятствия.
## 2. Что человек увидит до и после
**До:** два соединённых толстых сегмента выглядят как наложенные прямоугольники:
у прямого угла остаётся ступень/зуб, у непрямого — клиновидный разрыв; открытая
комнатная стена исправляется только после замыкания контура.
**После:** намеренно соединённые сегменты сразу, включая live rubber-band,
образуют одно ровное тело с тем же ограниченным mitre/bevel, которое человек
видит у готовой комнаты; после клика, замыкания или перехода в View форма не
прыгает.
## 3. Проблема и подтверждённая причина
1. `drawWallPreviewD()` использует `outset − inset` только для закрытого
контура. Открытый путь создаётся как несколько независимых прямоугольников.
2. `partitionBody()` правильно создаёт один сегмент с плоскими торцами, но
`physicalBodies()` передаёт набор таких прямоугольников без топологии узлов.
3. Polygon union удаляет перекрытие прямоугольников, но не может догадаться,
какой отсутствующий сектор следует достроить в endpoint↔endpoint углу.
Поэтому прямой и косой угол показывают один механизм в разном масштабе.
4. Закрытая комната проходит другой путь: смежные рёбра одного polygon получают
offset-line intersection с `MITRE_LIMIT`, поэтому её углы нормализованы.
5. Full/static/isometric render уже объединяют независимые тела с кладкой, но
Glow и солнце местами обходят исходные прямоугольники отдельно. В результате
визуальная щель может стать реальным световым просветом, а внутренняя грань
перекрытия — ложным окклюдером.
6. Предположенный в исходном описании отдельный дефект свободного торца по
приложенному скриншоту не подтверждён: видимый нижний левый зуб находится в
стыке двух сегментов. Одиночный корректный сегмент уже имеет плоский cap.
## 4. Решения владельца
1. Исправление охватывает точные endpoint↔endpoint и endpoint↔line (T)
соединения, которые могут создать инструменты «Контур» и «Перегородка» после
#137, между active/saved draft, partition и готовой комнатной стеной.
2. Rubber-band до клика использует тот же join, что сохранённое тело; клик не
меняет внешнюю форму стыка.
3. Свободный конец остаётся плоским. Отдельные round/square cap, настройка cap
или новый UX не вводятся.
4. Существующие сегменты не дробятся и сохранённый config не переписывается ради
вычисляемого T-узла.
5. Случайное X-пересечение без endpoint сохраняет текущую union-семантику и не
становится новым persisted node.
## 5. Scope
В задачу входят:
1. вычисляемая топология точных узлов у `room_drafts` и `partitions`;
2. бесшовные endpoint↔endpoint углы: прямые, острые и тупые;
3. endpoint↔line T-соединения с partition, saved draft и комнатной стеной;
4. open-draft preview после размещения сегмента и rubber-band до следующего
клика/замыкания;
5. собственная толщина каждого уже размещённого сегмента и текущая толщина
rubber-band;
6. одна joined geometry для full View, Plan, static card, hidden isometric,
clean floor/area, Glow, sun и проверки источника внутри кладки;
7. сохранение per-object geometry для выбора, hit testing, drag, properties,
удаления и Undo/Redo;
8. unit, targeted production-bundle smoke, golden и performance regression
coverage;
9. пользовательская и архитектурная документация плюс RU/EN changelog.
## 6. Non-scope
В задачу не входят:
- изменение snap tolerance, приоритетов или overlay из #137;
- автоматическое дробление room/draft/partition в persisted config;
- превращение partition в комнатную границу, разбиение комнаты или HA area;
- новый тип persisted node/junction и миграция старых планов;
- новый cap/join selector, round cap, декоративные окончания и материалы;
- изменение opening/open-span, wall thickness, Split или Resize semantics;
- исправление #138 про замыкание контура по углам существующей комнаты;
- изменение furniture magnet, opening placement либо selection UX;
- touch parity редактора сверх действующего safety floor;
- новые backend API, HA service calls, зависимости или i18n-тексты.
## 7. Контракт геометрии и поведения
### 7.1. Что считается соединением
1. Узел вычисляется только из координат, уже совпавших по действующему
grid/wall-bound snap и geometry epsilon. Визуально близкие, но разные точки
автоматически не стягиваются.
2. Endpoint↔endpoint — общий конец двух или более segment centrelines.
3. Endpoint↔line — endpoint одного segment точно лежит на сплошном интервале
другого. Для вычисления тела проходящий segment можно временно представить
двумя incident rays, но его persisted запись остаётся одной.
4. Opening и open-span gap не является физической линией комнатной стены и не
создаёт join с ней. Independent partition/draft не получает проём только
потому, что совпал с room opening.
5. Пересечение двух внутренних точек без endpoint не создаёт node metadata;
наложившиеся физические объёмы по-прежнему соединяются обычным boolean union.
### 7.2. Endpoint↔endpoint corner
1. Исходные segment bodies растут на `½ thickness` по обе стороны centreline.
2. Внешние offset faces смежных incident rays пересекаются в mitre point.
3. Если расстояние до точки пересечения не превышает
`MITRE_LIMIT × max(adjacent half-depth)`, отсутствующий сектор включается в
joined body.
4. Если предел превышен либо устойчивого пересечения нет, применяется bounded
bevel. Бесконечный spike, self-intersection, щель и незаполненный зуб
недопустимы.
5. Для коллинеарных продолжений результат — один прямой wall run без внутреннего
торца. Для разворота назад/нулевой длины новая неоднозначная кладка не
создаётся.
### 7.3. T-соединения и комнатные стены
1. Branch, endpoint которого лежит на другом solid segment, входит в тело
проходящей стены без щели и без видимой внутренней butt-face.
2. Для готовой комнатной стены authoritative room ring/exterior shell не
перестраивается по правилам partition. Joined independent geometry
объединяется с уже готовой `wallBodiesGeometry()` после opening cuts, как и
сейчас; partition не пробивается совпавшим room opening.
3. Подключение к exterior corner не меняет наружный фасад комнаты за пределами
реального independent body и не возвращает регрессию #123.
4. Разная толщина incident segments не усредняется и не записывается обратно.
Joined contour переходит между их реальными offset faces без прозрачной щели.
### 7.4. Свободные торцы и сложные узлы
1. Узел степени 1 заканчивается текущим плоским cap на исходном endpoint.
2. В узле степени 3+ объединяется volume всех incident bodies и только
ограниченные join patches. Алгоритм не должен заполнять произвольный круг
вокруг узла или расширять кладку дальше bounded mitre/bevel envelope.
3. Zero-length, non-finite или уже отклонённый schema segment не создаёт body или
join patch и не ломает остальные валидные тела.
4. Порядок records, id и направление `a↔b` не меняют результат.
### 7.5. Live preview
1. После каждого законченного segment открытый контур показывает joined body
немедленно; замыкание не является первым моментом нормализации.
2. Если есть cursor candidate, rubber-band присоединяется к предыдущему segment
и к точному target endpoint/line тем же bounded join.
3. Предыдущие segment используют сохранённые `room_drafts[].segments[].cm`, а
rubber-band — текущее session thickness. Изменение поля толщины не
перерисовывает уже сохранённые segment новой толщиной.
4. При клике outer contour rubber-band становится committed contour без скачка;
допустимо только изменение preview/editor styling.
5. При закрытии простой комнаты все уже существовавшие вершины совпадают с
нормализованной формой готовой комнаты в пределах geometry epsilon.
### 7.6. Единая физическая семантика
Для валидного сохранённого плана один structural result определяет:
- full Plan/View wall-body path;
- `houseplan-space-card` wall-body path;
- hidden isometric wall footprint/faces;
- subtraction из clean floor и вычисляемой площади;
- Glow barriers и fail-dark source placement;
- sun occluders.
Ни один consumer не должен заново обходить raw segment rectangles как будто их
внутренние butt-faces являются наружными стенами. Raw per-object body остаётся
доступным только там, где требуется identity редактируемой записи: hit,
selection frame, drag/properties/delete и history snapshot.
### 7.7. Ошибки вычисления
1. На валидных schema/snap inputs joined pass обязан быть детерминированным.
2. Malformed legacy input не должен превращать видимую стену в прозрачность:
fallback остаётся conservative/opaque и не пишет config.
3. Boolean failure не разрешается маскировать исчезновением кладки, световым
проходом или миграцией данных. Диагностический fallback может вернуть raw
bodies с прежним визуальным дефектом, но plan и light должны fail consistently
opaque.
## 8. Архитектурный контракт реализации
### 8.1. Structural physical frame
Реализация вводит один чистый вычисляемый frame (точное имя свободно), который
получает room walls/open cuts/openings, partitions, room drafts, columns и scale
inputs и возвращает как минимум:
- raw bodies с source identity для редактора;
- joined independent geometry;
- объединённую room + independent masonry geometry для presentation/occlusion;
- стабильный structural fingerprint либо данные для существующего cache key.
Join topology строится из centreline segments и их half-depth, а не из SVG DOM,
stroke-linejoin или raster measurement. Columns остаются обычными closed bodies
и участвуют в union, но не становятся incident wall rays.
### 8.2. Canonical consumers
`wallBodiesGeometry()` остаётся канонической точкой room masonry либо получает
эквивалентный единый wrapper; full/static/isometric не создают разные join
алгоритмы. `_lightBarriers` получает outer/hole rings объединённой opaque geometry,
а не внутренние грани перекрывающихся raw rectangles. Clean-floor и source guard
используют тот же joined result.
Preview использует тот же pure node/join primitive, но может строить только
активный path плюс локально необходимые target segments. Он не мутирует и не
инвалидирует saved structural frame на каждый pointermove.
### 8.3. Cache и invalidation
1. Saved topology пересчитывается только при structural geometry change:
coordinates, cm, room/opening/open-span topology, `cell_cm` или grid/scale.
2. HA state, theme, hover, cursor, Glow brightness и animation tick не входят в
saved geometry key.
3. Live rubber-band допускает дешёвый локальный расчёт на pointermove; полный
boolean union всего дома на каждый move запрещён.
4. Full/static/isometric/light consumers переиспользуют structural result либо
эквивалентный immutable geometry, а не копируют O(N²) node search.
5. Cache остаётся bounded и очищается существующими lifecycle hooks.
## 9. Модель данных, compatibility и миграция
- `rooms`, `walls`, `room_drafts`, `partitions`, `wall_columns`, `openings` и
`open_spans` не меняют schema.
- Backend validation, storage version, import/export и Python model не меняются.
- Старые планы получают исправленную вычисляемую форму при чтении без записи.
- Hover, preview, render и cache warm-up не вызывают config/layout/storage write.
- Undo/Redo продолжает хранить существующие geometry snapshots; одна partition
остаётся одной записью и одним history action.
- Прямой и обратной миграции нет.
## 10. UX, i18n, accessibility и touch
Новых controls, диалогов, toasts, focus/keyboard semantics и текстов нет;
следовательно, новые RU/EN i18n keys не требуются.
Plan editor остаётся desktop-first. На touch/coarse pointer новый hover parity не
обещается, но если существующий tap создаёт segment, его сохранённая geometry
обязана быть той же; pinch, pointercancel и synthetic click не могут сохранить
лишний segment. View/киоск остаются полностью поддержанными и получают ту же
исправленную форму без новых взаимодействий.
Selection frame и handles могут показывать границу конкретной редактируемой
записи поверх общего wall body; это editor chrome, а не альтернативная физика.
Forced colours и `prefers-reduced-motion` не получают новой ветки поведения.
## 11. Производительность и безопасность
**Производительность:** large-house fixture уже содержит 60 partitions. Новая
топология не должна пересчитываться на HA state tick, создавать unbounded cache
или ослаблять действующие budgets. Перед бетой обязательны exact-SHA performance
smoke и Full Performance; изменение budget допускается только отдельным решением
процесса, не в #141.
**Безопасность:** HA permissions, services, locks, network, HTML и внешние данные
не меняются. Главный safety invariant — нарисованная кладка, clean floor и
окклюдер совпадают. При ошибке вычисления приоритет у непрозрачного fail-closed
поведения, а не у сохранения декоративной картинки.
## 12. Acceptance criteria
- **AC1 (`unit` + `golden`; разработчик):** open draft из двух segment с общим
endpoint при 90°, остром и тупом угле образует один body без gap, tooth,
overlap seam или неограниченного spike; до замыкания и после замыкания уже
размещённые вершины геометрически совпадают в пределах epsilon.
- **AC2 (`unit` + `smoke` + `golden`; разработчик):** rubber-band до клика
использует тот же bounded mitre/bevel к предыдущему segment и к выбранному
endpoint/line target; commit не меняет outer contour, кроме preview styling.
- **AC3 (`unit`; разработчик):** каждый segment сохраняет собственную толщину,
`MITRE_LIMIT = 4` ограничивает spike, excess angle переходит в bevel,
collinear continuation не имеет внутреннего cap, а degree-1 endpoint остаётся
плоским.
- **AC4 (`unit` + `smoke`; разработчик):** две и более saved partitions с точным
endpoint↔endpoint соединением дают одинаковую joined geometry для прямого и
косого угла независимо от record order, id, направления и допустимой разной
толщины.
- **AC5 (`unit` + `smoke`; разработчик):** endpoint↔line соединение с partition,
saved draft и solid room wall образует бесшовный T-body; target record не
дробится, room/partition/draft config до и после render/hover побайтно
эквивалентен.
- **AC6 (`unit`; разработчик):** near-miss вне geometry epsilon остаётся
раздельным, opening/open-span gap не создаёт room-wall join, incidental X без
endpoint сохраняет union semantics, zero-length/non-finite segment не портит
валидные neighbours.
- **AC7 (`unit` + `smoke` + `golden`; разработчик):** full Plan/View,
`houseplan-space-card` и hidden iso получают один joined footprint: крупный
прямой/косой стык не показывает внутреннюю butt-face, зуб или второй hatch.
- **AC8 (`unit` + `smoke`; разработчик):** clean-floor area, Glow barriers,
source-inside-body guard и sun occlusion используют joined geometry: свет не
проходит через corner/T join и не блокируется внутренней гранью бывшего
overlap.
- **AC9 (`smoke`; разработчик):** hit/selection/drag/properties/delete и Undo/Redo
сохраняют identity отдельных partitions/draft segments; joined render не
объединяет записи в config и не меняет их history granularity.
- **AC10 (`unit` + code review; разработчик/ревьюер):** room wall openings,
virtual-T, nested/partial walls, exterior shell #123, columns и одиночные
partitions сохраняют действующую геометрию; совпавший room opening не режет
independent wall.
- **AC11 (`performance` + code review; разработчик/ревьюер):** structural joined
pass cached/bounded, HA state и pointer hover не запускают full-house topology,
60-partition fixture проходит существующие budgets без их ослабления.
- **AC12 (`unit` + backend/schema review; разработчик/ревьюер):** schema,
serialized config, backend, import/export, i18n, HA/network calls и зависимости
не меняются; старый plan исправляется без migration/write.
- **AC13 (`typecheck` + `unit` + `build` + documentation review; разработчик):**
implementation-loop gates зелёные; три bundle-копии после build побайтно
одинаковы; RU/EN changelog и перечисленная документация обновлены в том же
user-visible implementation commit.
## 13. План автотестов
### 13.1. Unit
Добавить geometry regression tests в `test/physical-geometry.test.mjs`,
`test/wall-thickness.test.mjs` либо отдельный узкий файл:
1. сравнить joined L-body с ожидаемым offset envelope для 90°, acute и obtuse;
2. проверить bounded mitre и bevel threshold вокруг `MITRE_LIMIT`;
3. проверить equal/unequal thickness, reversed endpoints и permutation records;
4. проверить collinear run, flat free caps, zero-length и near-miss;
5. проверить T partition→partition, draft→partition и partition→solid room wall;
6. проверить gap/open span и X-crossing contract;
7. сравнить open-preview committed vertices с closed-room ring;
8. доказать, что предыдущие draft segment сохраняют свой `cm`, когда current
session thickness меняется;
9. проверить clean-floor boolean area и united opaque rings для Glow/source/sun;
10. regression: openings do not cut extras, columns unchanged, #123 exterior,
virtual-T и nested wall remain green;
11. проверить immutability inputs и structural fingerprint/cache invalidation.
Минимум один новый test должен быть запущен/проверен ревьюером на `origin/dev` и
краснеть на старой прямоугольной geometry, а не только подтверждать новый helper.
### 13.2. Targeted production-bundle smoke
Добавить `demo/smoke_wall_junctions.mjs` либо эквивалент:
1. открыть Plan editor и нарисовать open draft с прямым и косым join;
2. проверить rubber-band path до click и отсутствие geometry jump после click;
3. создать две partitions endpoint↔endpoint и новую partition через line-snap #137
к середине существующей wall/partition;
4. проверить единый body path, отсутствие split target record и no-write hover;
5. переключить Plan → View → static card → hidden iso и сравнить footprint/bbox
signatures;
6. проверить selection/drag/Undo/Redo отдельных records;
7. поставить Glow source и sun case по разные стороны junction и подтвердить
opaque/no-false-edge result;
8. проверить pointercancel/pinch/suppressed click — новых records нет.
По процессу smoke пишется вместе с кодом, но полный browser-suite запускается
только перед бетой.
### 13.3. Golden
Добавить deterministic joined-wall scenario минимум с кадрами:
- Plan/light: committed 90° и oblique open-draft joins плюс active rubber-band;
- Plan/dark: endpoint-to-line T-preview поверх существующей partition/room wall;
- View/static/iso: saved 90° и oblique partitions с крупно читаемым outer contour;
- lighting при необходимости: junction рядом с Glow pool, чтобы щель не стала
световой полосой.
Не связанные View baselines с одиночными partitions должны остаться
pixel-identical. Любые затронутые эталоны принимаются только из полного
просмотренного Linux artifact через
`npm run golden:accept -- --reviewed`; локальное принятие ради зелёного CI
запрещено.
### 13.4. Performance и backend
Перед бетой прогнать существующий `large-house-v1`/performance smoke с 60
partitions и Full Performance на точном SHA. Если для локального join-preview
нужен отдельный профиль, он получает новый id и не переопределяет старый budget.
Backend не меняется; отдельный backend test не требуется. Полный Linux Validate
остаётся release gate.
## 14. План реализации
1. Выделить чистое представление linear physical segments и exact junction
topology с source identity.
2. Построить bounded mitre/bevel node patches и joined independent geometry.
3. Разделить raw editable bodies и canonical joined presentation/physics frame.
4. Подключить joined frame к full/static/isometric, clean floor, Glow/source guard
и sun, сохранив opening ordering.
5. Перевести open-draft/rubber-band preview на тот же join primitive и per-segment
thickness.
6. Добавить unit и targeted smoke; подготовить golden/performance fixtures.
7. Обновить документы, оба changelog и поставляемые bundle-копии.
Точные имена helpers/files не являются продуктовым контрактом.
## 15. Release-артефакты
Изменение пользовательское. Implementation-коммит имеет `User-Visible: yes` и в
том же коммите обновляет:
- `docs/CHANGELOG.md`;
- `docs/CHANGELOG.ru.md`;
- `docs/USER-GUIDE.ru.md` — рисование открытых контуров и partitions;
- `docs/WALL-THICKNESS.md` — independent joined-body/cap/mitre contract;
- `docs/ARCHITECTURE.md` — raw editable bodies против canonical joined frame;
- `docs/LIGHT.md` и при необходимости `docs/SUN.md`/`docs/ISOMETRIC.md` — единый
opaque geometry consumer;
- `docs/TESTING.md` — unit/smoke/golden coverage;
- `docs/STATUS.md` — релизная линия после фактической реализации;
- три поставляемые bundle-копии, создаваемые build/release-процессом.
Перед бетой обязательны exact-SHA Linux Validate, полный smoke-suite, просмотр и
reviewed acceptance Linux golden artifact, performance smoke и Full Performance,
а также зелёный code review. Отдельный security report не требуется: новых
внешних данных, вызовов и разрешений нет. Изменение проходит beta до stable.
## 16. Риски и снижение
| Риск | Вероятность / ущерб | Снижение |
|---|---|---|
| Pairwise patch переполнит узел степени 3+ | средняя / высокий | radial/node envelope units, T/Y matrix, bounded area assertion |
| Разная толщина усреднится или даст self-intersection | средняя / высокий | per-ray half-depth и boolean difference tests |
| Plan исправится, а Glow/sun сохранят raw butt-faces | средняя / высокий | один canonical joined frame и occlusion smoke |
| Opening случайно прорежет coincident partition | средняя / высокий | сохранить union extras after cuts, regression unit |
| Selection потеряет identity после union | средняя / высокий | raw bodies отдельным editor-only channel, drag/Undo smoke |
| Room exterior изменится при attached partition | низкая / высокий | #123 exterior regression и room-ring authority |
| Live preview начнёт делать full union на pointermove | средняя / высокий | local target subset, performance/code review AC11 |
| Старый near-miss внезапно соединится | средняя / средний | exact epsilon contract и explicit near-miss test |
| Boolean failure сделает стену прозрачной | низкая / высокий | conservative opaque fallback и malformed fixture |
| Golden изменятся шире joined cases | средняя / средний | baseline diff audit; unrelated single partitions pixel-identical |
## 17. Откат
Откат — revert user-visible implementation commit #141 вместе с тестами,
документацией, changelog и bundle-копиями. Schema и persisted data не меняются,
поэтому migration/data rollback не нужны; после отката вернётся прежний
визуальный дефект прямоугольных bodies.
Красный golden/performance gate перед бетой блокирует выпуск. Feature flag,
ослабление budget или принятие baseline без review не используются как обход.
## 18. Принятые технические предположения — можно менять без продуктового ревью
1. Junction epsilon переиспользует существующий grid/geometry tolerance и не
вводит пользовательскую настройку.
2. Рекомендуется immutable `PhysicalGeometryFrame` либо эквивалент с raw и
joined projections; точное имя и раскладка файлов свободны.
3. Node patches могут строиться вручную через offset intersections или через
другую deterministic boolean decomposition, если все AC выполняются.
4. Room-wall geometry может оставаться внутри `wallBodiesGeometry()` либо быть
обёрнута общим helper; второй независимый renderer запрещён.
5. Preview может добавлять локальный patch поверх existing saved body либо
строить небольшой joined subset; DOM decomposition не важна при отсутствии
seam/jump.
6. Cache может использовать `_cfgEpoch` плюс structural fingerprint или полный
fingerprint; HA state/theme/cursor не входят в saved key.
7. Selection frame вправе показывать raw body выбранной записи, если base wall
body остаётся joined и hit order не меняется.
8. Имена smoke/golden scenarios и точные test files не являются продуктовым
контрактом.
9. Для malformed legacy fallback допустимы raw opaque bodies; валидные schema
inputs обязаны проходить joined path без fallback.
10. Нет открытых продуктовых вопросов: Q1–Q3 и предложенные 8/10 · P2 приняты
владельцем 2026-08-14.
+1
View File
@@ -82,6 +82,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#122](https://github.com/Matysh/houseplan-card/issues/122) Изометрический режим Stage 2: скрытый режим и визуальная полировка | [122-isometric-stage2.md](122-isometric-stage2.md) |
| [#123](https://github.com/Matysh/houseplan-card/issues/123) Split из вершины не меняет наружную геометрию стен | [123-corner-split-wall.md](123-corner-split-wall.md) |
| [#137](https://github.com/Matysh/houseplan-card/issues/137) Узлы и линии привязки в редакторе Плана | [137-plan-snap-overlay.md](137-plan-snap-overlay.md) |
| [#141](https://github.com/Matysh/houseplan-card/issues/141) Бесшовные стыки перегородок и открытых контуров | [141-wall-junctions.md](141-wall-junctions.md) |
## Правило актуализации
+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({
+169 -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,165 @@ 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);
// Keep the same MultiPolygon shape for one body and for a union. Returning
// the bare Polygon made `polyclipToPathD()` see points where it expects
// rings, so every single-segment preview (including Thickness hover)
// became an empty path.
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 +659,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 +667,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,
+32 -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,
@@ -968,9 +969,39 @@ test('split materialisation cuts a partial shared interval at the new divider',
});
test('drawWallPreviewD returns a path for open and closed outlines', () => {
const single = drawWallPreviewD([[0, 0], [10, 0]], 1, false);
assert.ok(single.includes('M'), 'one flat-capped segment remains a visible preview');
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');
});