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