Compare commits

...
Author SHA1 Message Date
claude[bot] 3477a19a32 docs: review document for #137
Issue: #137
User-Visible: no
2026-08-14 08:27:43 +00:00
Sergey Matyunin cde9bfac09 feat: add architectural snap overlay
Issue: #137
User-Visible: yes
2026-08-14 11:15:44 +03:00
claude[bot] b6e46c9220 docs: review document for #137
Issue: #137
User-Visible: no
2026-08-14 07:45:56 +00:00
Sergey Matyunin 0ce28b036f docs: specify plan snap overlay
Issue: #137
User-Visible: no
2026-08-14 10:38:48 +03:00
26 changed files with 3663 additions and 1380 deletions
+3
View File
@@ -150,6 +150,8 @@ jobs:
npm run benchmark:large-house -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/candidate.json
npm run benchmark:large-house-isometric -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/isometric-baseline.json
npm run benchmark:large-house-isometric -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/isometric-candidate.json
npm run benchmark:large-house-plan-snap -- --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-baseline.json
npm run benchmark:large-house-plan-snap -- --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/plan-snap-candidate.json
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/blend-baseline.json
npm run benchmark:glow -- --profile=large-light-blend-v1 --target-root=. --samples=7 --warmups=1 --output=../artifacts/performance/blend-candidate.json
npm run benchmark:glow -- --profile=large-house-glow-overlay-v1 --target-root=../baseline --samples=7 --warmups=1 --output=../artifacts/performance/overlay-baseline.json
@@ -164,6 +166,7 @@ jobs:
run: |
npm run benchmark:compare -- --baseline=../artifacts/performance/baseline.json --candidate=../artifacts/performance/candidate.json --output=../artifacts/performance/comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-isometric.json --baseline=../artifacts/performance/isometric-baseline.json --candidate=../artifacts/performance/isometric-candidate.json --output=../artifacts/performance/isometric-comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-plan-snap.json --baseline=../artifacts/performance/plan-snap-baseline.json --candidate=../artifacts/performance/plan-snap-candidate.json --output=../artifacts/performance/plan-snap-comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-light-blend.json --baseline=../artifacts/performance/blend-baseline.json --candidate=../artifacts/performance/blend-candidate.json --output=../artifacts/performance/blend-comparison.json
npm run benchmark:compare -- --budgets=demo/performance/budgets-large-house-glow-overlay.json --baseline=../artifacts/performance/overlay-baseline.json --candidate=../artifacts/performance/overlay-candidate.json --output=../artifacts/performance/overlay-comparison.json
File diff suppressed because one or more lines are too long
+108 -3
View File
@@ -14,11 +14,26 @@ const warmups = Math.max(0, Math.min(5, Number(valueArg('warmups')) || 1));
const output = valueArg('output') ? resolve(valueArg('output')) : null;
const targetRoot = resolve(valueArg('target-root') ?? '.');
const profile = valueArg('profile') ?? 'large-house-v1';
if (!['large-house-v1', 'large-house-isometric-v1'].includes(profile))
if (!['large-house-v1', 'large-house-isometric-v1', 'large-house-plan-snap-v1'].includes(profile))
throw new Error(`unknown large-house profile: ${profile}`);
const isometric = profile === 'large-house-isometric-v1';
const planSnap = profile === 'large-house-plan-snap-v1';
const requiresIsometric = isometric && existsSync(resolve(targetRoot, 'src/iso-projection.ts'));
const requiresPlanSnap = planSnap && existsSync(resolve(targetRoot, 'src/plan-snap-overlay.ts'));
const fixture = makeLargeHouseFixture();
if (planSnap) {
for (const [floor, space] of fixture.config.spaces.entries()) {
space.room_drafts = [0, 1].map((draft) => {
const y = 0.985 + draft * 0.025;
return {
id: `perf-draft-${floor}-${draft}`,
points: [[0.10, y], [0.38, y], [0.46, y + 0.035]],
segments: [{ cm: 15 }, { cm: 20 }],
};
});
}
fixture.counts = { ...fixture.counts, drafts: 6, pointerMoves: 120 };
}
const viewport = { width: 1440, height: 1000 };
const { page, browser } = await launch(
@@ -48,7 +63,9 @@ const rows = [];
try {
for (let iteration = 0; iteration < warmups + samples; iteration++) {
const measuredSample = iteration - warmups;
const row = await page.evaluate(async ({ fixture, sample, cardContract, isometric, requiresIsometric }) => {
const row = await page.evaluate(async ({
fixture, sample, cardContract, isometric, requiresIsometric, planSnap, requiresPlanSnap,
}) => {
const frame = () => new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)));
const until = async (predicate, timeout = 10000) => {
const started = performance.now();
@@ -104,6 +121,7 @@ try {
openingTunnel: card._openingTunnelCache ? 1 : 0,
openingWallIndex: card._openingWallIndexCache ? 1 : 0,
isoGeometry: card._isoGeometryCache?.size ?? 0,
planSnapGeometry: card._planSnapGeometryCache ? 1 : 0,
});
window.__card?.remove?.();
@@ -120,6 +138,7 @@ try {
card.setConfig({
type: 'custom:houseplan-card', title: `Performance baseline ${sample}`, icon_size: 3.4,
});
let wsCalls = 0;
const connection = {
subscribeEvents: async () => () => undefined,
subscribeMessage: async () => () => undefined,
@@ -134,6 +153,7 @@ try {
three: { floor_id: 'three', name: 'Three', level: 2 },
},
callWS: async (message) => {
wsCalls++;
if (message.type === 'houseplan/config/get')
return { config: structuredClone(fixture.config), rev: 1, can_write: true };
if (message.type === 'houseplan/layout/get')
@@ -196,6 +216,85 @@ try {
await card.updateComplete;
});
let planSnapDiagnostics = null;
const planSnapPointer = planSnap ? await duration(async () => {
card._setMode('plan');
card._tool = 'draw';
card._path = [];
card.requestUpdate();
await card.updateComplete;
await frame();
const stage = card.renderRoot.querySelector('.stage');
const overlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
if (requiresPlanSnap && !overlay) throw new Error('plan-snap candidate has no overlay');
const staticLines = overlay?.querySelectorAll('.plan-snap-line').length ?? 0;
const staticNodes = overlay?.querySelectorAll('.plan-snap-node[data-kind="endpoint"]').length ?? 0;
const cacheValue = card._planSnapGeometryCache?.value ?? null;
const configBefore = JSON.stringify(card._serverCfg);
const callsBefore = wsCalls;
const view = card._viewOr(card._baseVb());
const rect = stage.getBoundingClientRect();
const fromPlan = (x, y) => ({
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
});
const firstEndpoint = overlay?.querySelector('.plan-snap-node[data-kind="endpoint"]');
const longLine = [...(overlay?.querySelectorAll('.plan-snap-line') || [])]
.map((line) => ({
line,
a: [+line.getAttribute('x1'), +line.getAttribute('y1')],
b: [+line.getAttribute('x2'), +line.getAttribute('y2')],
}))
.sort((a, b) => Math.hypot(b.b[0] - b.a[0], b.b[1] - b.a[1])
- Math.hypot(a.b[0] - a.a[0], a.b[1] - a.a[1]))[0];
const points = [
firstEndpoint
? [+firstEndpoint.getAttribute('cx'), +firstEndpoint.getAttribute('cy')]
: [40, 40],
longLine
? [(longLine.a[0] + longLine.b[0]) / 2, (longLine.a[1] + longLine.b[1]) / 2]
: [120, 40],
[10, 10],
];
const seenKinds = new Set();
for (let index = 0; index < 120; index++) {
const point = points[index % points.length];
stage.dispatchEvent(new PointerEvent('pointermove', {
...fromPlan(point[0], point[1]),
bubbles: true, composed: true, pointerId: 880, pointerType: 'mouse',
}));
await card.updateComplete;
const active = card.renderRoot.querySelector(
'[data-hp="plan-snap-overlay"] .plan-snap-node[data-active="true"]',
);
if (active) seenKinds.add(active.getAttribute('data-kind'));
if (requiresPlanSnap && card.renderRoot.querySelectorAll(
'[data-hp="plan-snap-overlay"] .plan-snap-node[data-active="true"]',
).length > 1) throw new Error('plan-snap rendered more than one active candidate');
}
const finalOverlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
planSnapDiagnostics = {
supported: requiresPlanSnap,
staticLines,
staticNodes,
activeKinds: [...seenKinds].sort(),
cacheStable: cacheValue != null && card._planSnapGeometryCache?.value === cacheValue,
domStable: (finalOverlay?.querySelectorAll('.plan-snap-line').length ?? 0) === staticLines
&& (finalOverlay?.querySelectorAll('.plan-snap-node[data-kind="endpoint"]').length ?? 0)
=== staticNodes,
configStable: JSON.stringify(card._serverCfg) === configBefore,
wsWrites: wsCalls - callsBefore,
};
if (requiresPlanSnap && (
staticLines < fixture.counts.rooms || staticNodes < fixture.counts.rooms
|| !planSnapDiagnostics.cacheStable || !planSnapDiagnostics.domStable
|| !planSnapDiagnostics.configStable || planSnapDiagnostics.wsWrites !== 0
|| !seenKinds.has('endpoint') || !seenKinds.has('line')
)) throw new Error(`plan-snap structural contract failed: ${JSON.stringify(planSnapDiagnostics)}`);
card._setMode('view');
await card.updateComplete;
}) : null;
const resizePreview = await duration(async () => {
card._setMode('plan');
card._tool = 'resize';
@@ -279,6 +378,10 @@ try {
modelReadyMs,
firstStableRenderMs,
...(viewToggle ? { viewToggleMs: viewToggle.ms } : {}),
...(planSnapPointer ? {
planSnapPointerMs: planSnapPointer.ms,
planSnapDiagnostics,
} : {}),
spaceSwitchMs: spaceSwitch.ms,
stateUpdateMs: stateUpdate.ms,
resizePreviewMs: resizePreview.ms,
@@ -288,6 +391,7 @@ try {
longTasks: {
load: loadLongTaskResult,
...(viewToggle ? { viewToggle: viewToggle.longTasks } : {}),
...(planSnapPointer ? { planSnapPointer: planSnapPointer.longTasks } : {}),
spaceSwitch: spaceSwitch.longTasks,
stateUpdate: stateUpdate.longTasks,
resizePreview: resizePreview.longTasks,
@@ -306,7 +410,7 @@ try {
return result;
}, {
fixture, sample: measuredSample, cardContract: LARGE_HOUSE_CARD_CONTRACT,
isometric, requiresIsometric,
isometric, requiresIsometric, planSnap, requiresPlanSnap,
});
if (measuredSample >= 0) rows.push(row);
}
@@ -319,6 +423,7 @@ const metricNames = [
'resizePreviewMs', 'panZoomMs', 'settingsDialogMs', 'switchCycleMs',
];
if (isometric) metricNames.splice(2, 0, 'viewToggleMs');
if (planSnap) metricNames.splice(2, 0, 'planSnapPointerMs');
const report = {
schema: 2,
profile,
+33
View File
@@ -318,6 +318,39 @@ export async function prepareGoldenScenario(page, scenario) {
card.requestUpdate();
await card.updateComplete;
}
if (scenario.planSnap) {
const { tool, anchor, pointer, expectedKind } = scenario.planSnap;
const validPoint = (point) => Array.isArray(point) && point.length === 2
&& point.every(Number.isFinite);
if (!['draw', 'partition'].includes(tool) || !validPoint(pointer)
|| (anchor != null && !validPoint(anchor))
|| !['endpoint', 'line'].includes(expectedKind)) {
throw new Error(`invalid golden planSnap contract: ${scenario.id}`);
}
card._tool = tool;
card._activeDraftId = null;
card._path = anchor ? [[anchor[0] * 1000, anchor[1] * card._spaceH]] : [];
card._clearPlanSnapHover();
card.requestUpdate();
await card.updateComplete;
await frame();
const svgRoot = card.renderRoot.querySelector('.stage svg');
const stage = card.renderRoot.querySelector('.stage');
const screen = new DOMPoint(pointer[0] * 1000, pointer[1] * card._spaceH)
.matrixTransform(svgRoot.getScreenCTM());
stage.dispatchEvent(new PointerEvent('pointermove', {
bubbles: true, composed: true, pointerId: 992, pointerType: 'mouse',
clientX: screen.x, clientY: screen.y,
}));
await card.updateComplete;
await frame();
const overlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
const active = overlay?.querySelector('.plan-snap-node[data-active="true"]');
if (!overlay || active?.getAttribute('data-kind') !== expectedKind
|| overlay.querySelectorAll('.plan-snap-node[data-active="true"]').length !== 1) {
throw new Error(`golden plan snap candidate did not render: ${scenario.id}`);
}
}
if (scenario.openingPreview) {
const { type, pointer } = scenario.openingPreview;
if (!['window', 'door', 'gate'].includes(type)
+9 -1
View File
@@ -1,7 +1,7 @@
import { fixtureWallKey } from '../fixtures/visual-matrix.mjs';
/** Data-only HP-QA-01 capture matrix. Bump when framing or scenarios change. */
export const GOLDEN_MATRIX_VERSION = 19;
export const GOLDEN_MATRIX_VERSION = 20;
const stage = { capture: 'stage', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0005 } };
const page = { capture: 'page', threshold: { maxChannelDelta: 10, maxDiffRatio: 0.0008 } };
@@ -37,6 +37,14 @@ export const GOLDEN_SCENARIOS = Object.freeze([
theme: 'light', viewport: { width: 1000, height: 900 }, ...stage },
{ id: 'geometry-plan-editor-dark', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'plan-snap-endpoint-light', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
planSnap: { tool: 'draw', pointer: [0.48, 0.48], expectedKind: 'endpoint' },
theme: 'light', viewport: { width: 1180, height: 900 }, ...page },
{ id: 'plan-snap-line-gaps-dark', fixture: 'visual', space: 'golden-geometry', mode: 'plan',
planSnap: {
tool: 'draw', anchor: [0.70, 0.455], pointer: [0.482, 0.455], expectedKind: 'line',
},
theme: 'dark', viewport: { width: 1180, height: 900 }, ...page },
{ 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
+11
View File
@@ -12,6 +12,16 @@ flat while reporting the same profile. The dedicated
plus absolute noise/ceiling checks. Only the exact-SHA Linux workflow is gate
evidence; a local report is diagnostic.
Issue #137 adds `large-house-plan-snap-v1` without changing the meaning or
budgets of the original profile. The same 60-room/60-partition fixture gains
six saved open outlines, renders the Plan snap overlay, and sends 120 real
pointer moves across endpoint, line and miss targets. Candidate bundles fail
inside the runner if the static DOM or geometry cache grows, more than one
active node appears, endpoint/line paths are not both exercised, or config and
websocket traffic change. Its dedicated budget retains every original timing,
heap and cache ceiling and adds the measured pointer series plus a one-entry
snap-geometry cache cap. Exact-SHA Linux output is the only gate evidence.
The runner records seven measured samples after one discarded warm-up. With
this intentionally small CI sample, the nearest-rank `p95` is the observed
maximum; reports keep the conventional field name but should be read as a
@@ -110,6 +120,7 @@ Build and copy a fresh demo bundle first, then run:
```bash
npm run benchmark:large-house -- --samples=7 --warmups=1 --output=artifacts/performance/local.json
npm run benchmark:large-house-plan-snap -- --samples=7 --warmups=1 --output=artifacts/performance/plan-snap-local.json
```
A local report is diagnostic only; it cannot replace the CI comparison.
@@ -0,0 +1,95 @@
{
"schema": 1,
"profile": "large-house-plan-snap-v1",
"minimumSamples": 7,
"timings": {
"modelReadyMs": {
"stat": "median",
"maxRegressionRatio": 0.3,
"noiseAllowanceMs": 200,
"hardMaxMs": 2500
},
"firstStableRenderMs": {
"stat": "median",
"maxRegressionRatio": 0.3,
"noiseAllowanceMs": 250,
"hardMaxMs": 3000
},
"planSnapPointerMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 250,
"hardMaxMs": 5000
},
"spaceSwitchMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 75,
"hardMaxMs": 1500
},
"stateUpdateMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 75,
"hardMaxMs": 1000
},
"resizePreviewMs": {
"stat": "median",
"maxRegressionRatio": 0.4,
"noiseAllowanceMs": 150,
"hardMaxMs": 2000
},
"panZoomMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 60,
"hardMaxMs": 500
},
"settingsDialogMs": {
"stat": "median",
"maxRegressionRatio": 0.5,
"noiseAllowanceMs": 100,
"hardMaxMs": 1000
},
"switchCycleMs": {
"stat": "median",
"maxRegressionRatio": 0.35,
"noiseAllowanceMs": 250,
"hardMaxMs": 7000
}
},
"longTasks": {
"maxSingleMs": 3000,
"maxSingleRegressionRatio": 0.3,
"maxSingleNoiseAllowanceMs": 250,
"maxCountP95": 30,
"maxCountRegressionRatio": 0.35,
"countNoiseAllowance": 3,
"maxTotalP95Ms": 12000,
"maxTotalRegressionRatio": 0.3,
"noiseAllowanceMs": 150
},
"heap": {
"required": true,
"hardMaxGrowthBytes": 67108864,
"maxRegressionRatio": 0.75,
"noiseAllowanceBytes": 16777216
},
"cacheEntries": {
"cleanFloor": 160,
"glowClip": 200,
"wallUnion": 1,
"openingTunnel": 1,
"openingWallIndex": 1,
"planSnapGeometry": 1
},
"cacheGrowth": {
"cleanFloor": 0,
"glowClip": 0,
"wallUnion": 0,
"openingTunnel": 0,
"openingWallIndex": 0,
"planSnapGeometry": 0
},
"renderedDevices": 200
}
+6 -1
View File
@@ -33,13 +33,15 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
'_gridPitch',
'_loadOk',
'_model',
'_path',
'_rszDrag',
'_serverCfg',
'_settingsDialog',
'_tool',
]),
// A comparison SHA before #89 is intentionally flat; the isometric runner
// checks these two members only when the target source tree supports Stage 1.
optionalFields: Object.freeze(['_isoGeometryCache', '_setProjection']),
optionalFields: Object.freeze(['_isoGeometryCache', '_planSnapGeometryCache', '_setProjection']),
fieldTypes: Object.freeze({
_booting: 'boolean',
_cleanFloorCache: 'map',
@@ -48,8 +50,11 @@ export const LARGE_HOUSE_CARD_CONTRACT = Object.freeze({
_gridPitch: 'number',
_loadOk: 'boolean',
_model: 'array',
_path: 'array',
_isoGeometryCache: 'map',
_planSnapGeometryCache: 'object',
_setProjection: 'function',
_serverCfg: 'object',
_tool: 'string',
}),
});
+219
View File
@@ -0,0 +1,219 @@
/** Issue #137: architectural endpoint/line overlay and exact snap in Plan. */
import { launch, checkAll, finish } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 820 }, 1);
const out = await page.evaluate(async () => {
const result = {};
const card = window.__card;
const root = () => card.shadowRoot || card.renderRoot;
const update = async () => { card.requestUpdate(); await card.updateComplete; };
const cfg = {
spaces: [{
id: 'snap', title: 'Snap', cell_cm: 5, view_box: [0, 0, 1, 0.7],
rooms: [
{ id: 'left', name: 'Left', area: null,
poly: [[0.1, 0.1], [0.5, 0.1], [0.5, 0.5], [0.1, 0.5]] },
{ id: 'right', name: 'Right', area: null,
poly: [[0.5, 0.1], [0.9, 0.1], [0.9, 0.5], [0.5, 0.5]] },
],
openings: [{ id: 'door', type: 'door', x: 0.3, y: 0.1, angle: 0, length: 0.1 }],
open_spans: [{ a: [0.5, 0.2], b: [0.5, 0.3] }],
room_drafts: [{ id: 'saved', points: [[0.1, 0.6], [0.3, 0.6], [0.3, 0.7]],
segments: [{ cm: 15 }, { cm: 15 }] }],
partitions: [{ id: 'base-partition', a: [0.6, 0.6], b: [0.9, 0.6], cm: 15 }],
wall_columns: [{ id: 'ignored-column', shape: 'square', center: [0.8, 0.35], cm: 25 }],
}],
markers: [], settings: {},
};
card._serverCfg = JSON.parse(JSON.stringify(cfg));
card._layout = {};
card._space = 'snap';
card._modelCache = null;
card._frame = null;
card._cfgEpoch++;
card._setMode('plan');
card._tool = 'draw';
card._path = [];
card._activeDraftId = null;
card._clearPlanSnapHover();
await update();
const stage = root().querySelector('.stage');
const eventAt = (x, y, type = 'pointermove', extra = {}) => {
const rect = stage.getBoundingClientRect();
const view = card._viewOr(card._baseVb());
const EventType = type.startsWith('pointer') ? PointerEvent : MouseEvent;
return new EventType(type, {
clientX: rect.left + ((x - view.x) / view.w) * rect.width,
clientY: rect.top + ((y - view.y) / view.h) * rect.height,
bubbles: true,
pointerId: 41,
pointerType: 'mouse',
...extra,
});
};
const overlay = () => root().querySelector('[data-hp="plan-snap-overlay"]');
const active = () => root().querySelector('.plan-snap-node[data-active="true"]');
const close = (a, b, epsilon = 1e-5) => Math.abs(a - b) <= epsilon;
result.overlayBeforeFirstClick = !!overlay();
result.overlayIsPointerTransparent = overlay()?.getAttribute('pointer-events') === 'none'
&& getComputedStyle(overlay()).pointerEvents === 'none';
const wallBodies = root().querySelector('.wallbodies');
result.overlayAfterWallBodies = !!wallBodies && !!overlay()
&& !!(wallBodies.compareDocumentPosition(overlay()) & Node.DOCUMENT_POSITION_FOLLOWING);
result.oneLinePerSolidInterval = overlay()?.querySelectorAll('.plan-snap-line').length === 12;
result.uniqueSourceEndpointsOnly = overlay()?.querySelectorAll('.plan-snap-node[data-kind="endpoint"]').length === 11;
result.columnIsNotACandidate = ![...overlay().querySelectorAll('.plan-snap-node')].some((node) =>
close(+node.getAttribute('cx'), 800) && close(+node.getAttribute('cy'), 350));
const crosses = (x1, y1, x2, y2, px, py) => {
const dx = x2 - x1, dy = y2 - y1;
const length2 = dx * dx + dy * dy;
if (!length2) return false;
const t = ((px - x1) * dx + (py - y1) * dy) / length2;
if (t <= 0 || t >= 1) return false;
return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy)) < 0.01;
};
const lines = () => [...overlay().querySelectorAll('.plan-snap-line')].map((line) => [
+line.getAttribute('x1'), +line.getAttribute('y1'),
+line.getAttribute('x2'), +line.getAttribute('y2'),
]);
result.openingGapHasNoLine = !lines().some((line) => crosses(...line, 300, 100));
result.openSpanHasNoLine = !lines().some((line) => crosses(...line, 500, 250));
result.cutBoundariesAreNotEndpoints = ![...overlay().querySelectorAll('[data-kind="endpoint"]')].some((node) => {
const x = +node.getAttribute('cx'), y = +node.getAttribute('cy');
return (close(y, 100) && (close(x, 250) || close(x, 350)))
|| (close(x, 500) && (close(y, 200) || close(y, 300)));
});
const cachedGeometry = card._planSnapGeometryCache?.value;
stage.dispatchEvent(eventAt(104, 104));
await card.updateComplete;
result.endpointHoverIsActive = active()?.getAttribute('data-kind') === 'endpoint'
&& close(+active().getAttribute('cx'), 100) && close(+active().getAttribute('cy'), 100)
&& close(+active().getAttribute('r'), card._cmToUnits(10));
card._markupClick(eventAt(104, 104, 'click', { shiftKey: true }));
await card.updateComplete;
result.endpointOverridesGridAndShift = card._path.length === 1
&& close(card._path[0][0], 100) && close(card._path[0][1], 100);
stage.dispatchEvent(eventAt(735, 606));
await card.updateComplete;
const lineNode = active();
result.lineHoverShowsOneDynamicNode = !!lineNode
&& lineNode.getAttribute('data-kind') === 'line'
&& root().querySelectorAll('.plan-snap-node.dynamic[data-active="true"]').length === 1;
const linePoint = lineNode ? [+lineNode.getAttribute('cx'), +lineNode.getAttribute('cy')] : [NaN, NaN];
result.lineNodeStaysWallBound = close(linePoint[1], 600);
result.lineNodeQuantizesAlongWall = close((linePoint[0] - 600) / card._gridPitch,
Math.round((linePoint[0] - 600) / card._gridPitch));
result.hoverKeepsStaticGeometryCache = !!cachedGeometry
&& card._planSnapGeometryCache?.value === cachedGeometry;
const originalPartition = JSON.stringify(card._curSpaceCfg.partitions[0]);
card._markupClick(eventAt(735, 606, 'click'));
await card.updateComplete;
result.drawCommitUsesExactLineNode = card._path.length === 2
&& close(card._path[1][0], linePoint[0]) && close(card._path[1][1], linePoint[1]);
result.existingPartitionWasNotSplit = card._curSpaceCfg.partitions.length === 1
&& JSON.stringify(card._curSpaceCfg.partitions[0]) === originalPartition;
stage.dispatchEvent(eventAt(300, 100));
await card.updateComplete;
result.openingGapDoesNotActivateSnap = !active();
stage.dispatchEvent(new PointerEvent('pointerleave', { bubbles: true, pointerId: 41 }));
await card.updateComplete;
result.pointerLeaveClearsActiveNode = !active();
card._cancelPath();
card._activeDraftId = 'saved';
card._path = [[100, 600], [300, 600], [300, 700]];
await update();
result.activeDraftExcluded = !lines().some((line) => (
close(line[1], 600) && close(line[3], 600)
&& Math.min(line[0], line[2]) < 300 && Math.max(line[0], line[2]) > 100
) || (
close(line[0], 300) && close(line[2], 300)
&& Math.min(line[1], line[3]) < 700 && Math.max(line[1], line[3]) > 600
));
stage.dispatchEvent(eventAt(302, 602));
await card.updateComplete;
result.intermediateCurrentPointDoesNotSelfSnap = !active();
stage.dispatchEvent(eventAt(102, 602));
await card.updateComplete;
result.firstCurrentPointRemainsClosureTarget = active()?.getAttribute('data-kind') === 'endpoint'
&& close(+active().getAttribute('cx'), 100) && close(+active().getAttribute('cy'), 600);
stage.dispatchEvent(eventAt(302, 698));
await card.updateComplete;
result.currentAnchorDoesNotCreateZeroSegment = !active();
card._cancelPath();
card._tool = 'partition';
await update();
card._markupClick(eventAt(735, 606, 'click'));
await card.updateComplete;
result.tapWithoutHoverSnapsFirstPoint = card._path.length === 1
&& close(card._path[0][1], 600)
&& close((card._path[0][0] - 600) / card._gridPitch,
Math.round((card._path[0][0] - 600) / card._gridPitch));
card._markupClick(eventAt(896, 496, 'click'));
await card.updateComplete;
result.secondPartitionClickSnapsEndpoint = card._path.length === 0
&& card._curSpaceCfg.partitions.length === 2
&& close(card._curSpaceCfg.partitions[1].b[0] * 1000, 900)
&& close(card._curSpaceCfg.partitions[1].b[1] * 1000, 500);
result.originalSegmentStillUnchanged = JSON.stringify(card._curSpaceCfg.partitions[0]) === originalPartition;
const gestureGeometry = JSON.stringify({
drafts: card._curSpaceCfg.room_drafts,
partitions: card._curSpaceCfg.partitions,
});
card._suppressClick = true;
card._markupClick(eventAt(700, 600, 'click'));
result.suppressedClickDoesNotCommit = card._path.length === 0;
card._suppressClick = false;
stage.dispatchEvent(eventAt(700, 600, 'pointerdown', { pointerId: 51, buttons: 1 }));
stage.dispatchEvent(eventAt(760, 640, 'pointermove', { pointerId: 51, buttons: 1 }));
stage.dispatchEvent(eventAt(760, 640, 'pointerup', { pointerId: 51 }));
stage.dispatchEvent(eventAt(650, 560, 'pointerdown', { pointerId: 61, buttons: 1 }));
stage.dispatchEvent(eventAt(800, 650, 'pointerdown', { pointerId: 62, buttons: 1 }));
stage.dispatchEvent(eventAt(620, 540, 'pointermove', { pointerId: 61, buttons: 1 }));
stage.dispatchEvent(eventAt(830, 670, 'pointermove', { pointerId: 62, buttons: 1 }));
stage.dispatchEvent(eventAt(620, 540, 'pointercancel', { pointerId: 61 }));
stage.dispatchEvent(eventAt(830, 670, 'pointerup', { pointerId: 62 }));
await card.updateComplete;
result.panPinchCancelDoNotCommit = card._path.length === 0
&& JSON.stringify({
drafts: card._curSpaceCfg.room_drafts,
partitions: card._curSpaceCfg.partitions,
}) === gestureGeometry;
card._tool = 'select';
await update();
result.otherPlanToolsHaveNoOverlay = !overlay();
card._setMode('view');
await update();
result.viewHasNoOverlay = !overlay();
return result;
});
await page.emulateMedia({ forcedColors: 'active' });
out.forcedColorsStayReadable = await page.evaluate(async () => {
const card = window.__card;
card._setMode('plan');
card._tool = 'draw';
card._path = [];
card.requestUpdate();
await card.updateComplete;
const overlay = card.renderRoot.querySelector('[data-hp="plan-snap-overlay"]');
const line = overlay?.querySelector('.plan-snap-line');
const node = overlay?.querySelector('.plan-snap-node');
const lineStyle = line ? getComputedStyle(line) : null;
const nodeStyle = node ? getComputedStyle(node) : null;
return matchMedia('(forced-colors: active)').matches
&& lineStyle?.stroke !== 'none' && nodeStyle?.stroke !== 'none'
&& nodeStyle?.fill !== 'none';
});
await finish(browser, checkAll(out));
File diff suppressed because one or more lines are too long
+530 -450
View File
File diff suppressed because one or more lines are too long
+27
View File
@@ -542,3 +542,30 @@ the ±`CANVAS_LIMIT` guard. Rigid partition drag clamps one shared delta against
both endpoints, so it cannot deform the segment or let its far endpoint cross
the backend boundary. Hit areas and drag thresholds are expressed in CSS
pixels, therefore selection remains usable at every zoom.
## Architectural connection overlay
When **Room outline** or **Partition** is active in the Plan editor, a derived
pointer-transparent SVG layer exposes the centre axes of completed room walls,
saved inactive outlines and independent partitions. It is painted after their
physical wall bodies, but before interactive editor chrome. Columns, decor,
devices, the active outline and its live preview are not candidates. Door,
window, gate and intentionally open-span intervals are cut from room axes; a
cut boundary does not become a new endpoint.
The layer and hit resolver share one immutable geometry snapshot. Original
segment endpoints are deduplicated and drawn at a physical radius of 5 cm.
Inside a 12 CSS px hit zone, an endpoint wins over every line and grows to
10 cm. Otherwise the nearest solid line receives one 10 cm dynamic node: the
raw pointer is projected onto that line, then quantized by the grid step along
the line from its stable start. This keeps diagonal connections wall-bound even
when neither resulting coordinate is a global grid multiple. The same resolver
runs again on click, so hover is only a preview and never authoritative.
Endpoint and line candidates override the normal grid and Shift/45° result.
Outside the hit zone, §9.3–9.4 remain unchanged. A line connection adds only the
new segment endpoint; it does not split or rewrite the existing wall. The
current anchor is excluded to prevent zero-length segments, while the first
point of a valid room outline remains an explicit closure target. The static
geometry is cached by structural editor state; pointer movement changes at
most the single active candidate and never writes config, layout or storage.
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- 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
door/window/gate and intentional open-span gaps remain excluded
([#137](https://github.com/Matysh/houseplan-card/issues/137)).
## v1.64.0-beta.1 — 2026-08-14
- Read-only View and kiosk cards now paint a complete first frame even when
+6
View File
@@ -8,6 +8,12 @@
## Unreleased
- При рисовании инструментами «Контур комнаты» и «Перегородка» поверх
существующих стен теперь видны их осевые линии и точные конечные узлы.
Увеличенная точка заранее показывает соединение с концом стены или создание
T-соединения на её линии; разрывы дверей, окон, ворот и намеренно открытых
участков исключены ([#137](https://github.com/Matysh/houseplan-card/issues/137)).
## v1.64.0-beta.1 — 2026-08-14
- Read-only карточки в режимах View и киоска теперь сразу показывают полный
+8
View File
@@ -267,6 +267,14 @@ desktop: для точного рисования, Resize, модификато
Во время рисования показываются длина сегмента и угол. Углы, кратные 45°, выделяются. Удерживайте `Shift`, чтобы зафиксировать текущий отрезок по ближайшему такому направлению; предпросмотр и поставленная точка останутся на сетке. Новая комната не может частично перекрывать другую, но полностью вложенная «островная» комната поддерживается.
При работе инструментами **Контур комнаты** и **Перегородка** поверх уже
нарисованных стен видны тонкие осевые линии и точки их концов. Увеличенная
точка показывает точное место следующего соединения: конец стены имеет
приоритет, а увеличенная точка посреди линии создаёт T-соединение, не разрезая
существующую стену. В проёмах соединительной линии нет. Для точного наведения и
предпросмотра рекомендуется редактор на компьютере; tap на сенсорном экране
тоже выполняет привязку, но отдельный hover до касания не показывается.
Каждый законченный отрезок незамкнутого контура сохраняется сразу. В той же
сессии инструмент продолжит его автоматически; после перезагрузки выберите
кликом один из концов. Замкнутый контур можно сохранить как комнату или выбрать
+171
View File
@@ -0,0 +1,171 @@
# Код-ревью #137 — узлы и линии привязки в редакторе Плана (r1/4)
- **Issue:** https://github.com/Matysh/houseplan-card/issues/137
- **ТЗ:** `docs/specs/137-plan-snap-overlay.md`, зелёное ревью
`docs/reviews/SPEC-REVIEW-137-r1.md` (r1/4, High 0, Medium 0)
- **Диапазон:** `origin/dev..HEAD` = `0ce28b0` (ТЗ), `b6e46c9` (SPEC-REVIEW),
`cde9bfa` (implementation)
- **Реализация:** коммит `cde9bfac0948e6f4e2a59e7a6cfb2085ff034b35`,
трейлеры `Issue: #137` / `User-Visible: yes` на месте.
## Скоуп диффа
`src/plan-snap-overlay.ts` (новый чистый geometry/resolver helper),
интеграция в `src/houseplan-card.ts` (hover/click/gesture-safety, кэш,
рендер overlay), стили `src/styles.ts` (light/dark/forced-colours),
`docs/CANVAS.md` (контракт), `docs/USER-GUIDE.ru.md` + оба `CHANGELOG`,
unit `test/plan-snap-overlay.test.mjs`, smoke
`demo/smoke_plan_snap_overlay.mjs`, golden (`matrix.mjs`/`harness.mjs`,
matrix v19→v20, 2 новых сценария), performance (`demo/benchmark_large_house.mjs`,
`demo/performance/budgets-large-house-plan-snap.json`,
`.github/workflows/performance.yml`), `package.json` (новый npm-скрипт),
`tsconfig.test.json`, `demo/performance/card-contract.mjs` (новые
контрактные поля `_path`/`_serverCfg`/`_planSnapGeometryCache`). Файлов
класса A: `src/houseplan-card.ts`, `src/plan-snap-overlay.ts`, `src/styles.ts`.
i18n, backend, `manifest.json`, `hacs.json` не тронуты — соответствует
AC13/non-scope.
## Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | green, без вывода |
| Unit | `npm test` | 789/789 green (на Linux падений process-gate теста, специфичного для Windows-путей, нет) |
| Build + bundle sync | `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` | green, три копии побайтно идентичны |
| Targeted smoke | `node demo/smoke_plan_snap_overlay.mjs` | green, 31/31 проверок |
| Дисциплина «тест умеет падать» | инвертировал приоритет endpoint>line в `resolvePlanSnap` (`if (bestEndpoint && false) return bestEndpoint;`), пересобрал бандл, перезапустил smoke | smoke упал предсказуемо (4 проверки: `endpointHoverIsActive`, `endpointOverridesGridAndShift`, `firstCurrentPointRemainsClosureTarget`, `secondPartitionClickSnapsEndpoint`); откатил патч, бандл и три копии восстановлены и сверены заново |
| Golden | `npm run golden:verify` (после свежей сборки) | 2 новых сценария `plan-snap-endpoint-light` / `plan-snap-line-gaps-dark` → `missing-baseline` (ожидаемо, эталоны не приняты в реализации); 4 существующих Plan-editor сценария (`geometry-plan-editor-dark`, `tray-wide-selection-en`, `tray-wide-tool-ru`, `tray-medium-group-en`) → `different`, разобрано ниже; остальные 46 сценариев `passed` |
| Performance (sanity) | `npm run benchmark:large-house-plan-snap -- --target-root=. --samples=1 --warmups=0 --output=…` | green, ни один внутренний контракт-assert раннера не упал (endpoint+line оба сработали за 120 pointermove, `cacheStable`/`domStable`/`configStable` true, `wsWrites=0`); `planSnapPointerMs≈2766ms` внутри `hardMaxMs:5000` бюджета. Не запускал сравнение с baseline и Full Performance — это предрелизный гейт (§8, §11.4), не гейт код-ревью |
| Backend | не прогонял | диапазон не касается `custom_components/**/*.py` (AC13 подтверждён чтением diffstat) |
**Гейты, которые сознательно не прогонялись:**
- полный `demo/smoke_*.mjs` (127 сценариев) — задача задевает только Plan editor
draw/partition, остальные поверхности (View/kiosk/Devices/Decor editor,
opening placement, split и т.д.) не тронуты диффом; целевой smoke плюс
golden matrix покрывают пересечение с ними (AC1, AC11);
- `npm run golden:capture` / `golden:accept` — приём эталонов вне цикла
ревью (§13, PROCESS.md §3.13), делается релиз-инженером на полном Linux CI
артефакте;
- полный `Full Performance` workflow и сравнение с baseline — pre-beta gate
(PROCESS.md §11.4), не гейт код-ревью;
- `python -m pytest tests_backend` — diff не касается `custom_components/`.
## Разбор AC (проверено чтением + автотестом, где не указано иное)
- **AC1** — доказано smoke (`otherPlanToolsHaveNoOverlay`, `viewHasNoOverlay`)
и чтением: `_renderPlanSnapOverlay()` рендерится только когда `this._markup`
(`_mode === 'plan'`, `houseplan-card.ts:1136`) и `_tool` ∈ {draw, partition}.
Devices/Decor editor используют другой `_mode`, overlay для них не строится.
- **AC2** — доказано golden (2 новых кадра, актуальные PNG визуально
сверены: точки на пересечениях стен, линия 1 CSS px, разрыв в проёме) плюс
unit-тест дедупликации/радиусов через `wallCmToUnits`. Forced-colours —
отдельным smoke-чеком `forcedColorsStayReadable`, что закрывает L4 из
SPEC-REVIEW (golden-харнесс не эмулирует `forced-colors`, поэтому граница
доказательства здесь именно smoke, не golden — обоснованно).
- **AC3, AC8** — smoke `endpointHoverIsActive` + `endpointOverridesGridAndShift`
(клик с `shiftKey: true` всё равно берёт точную координату существующего
endpoint). Тест умеет падать — проверено мутацией (см. таблицу выше).
- **AC4** — smoke `lineHoverShowsOneDynamicNode` / `lineNodeStaysWallBound` /
`lineNodeQuantizesAlongWall` / `drawCommitUsesExactLineNode` /
`existingPartitionWasNotSplit` / `originalSegmentStillUnchanged`.
- **AC5** — unit `resolvePlanSnap`: endpoint побеждает более близкую линию,
tie разрешается одинаково для прямого и развёрнутого массива сегментов.
- **AC6** — unit `active draft and degenerate inputs are excluded` +
`current anchor is excluded...` + smoke `activeDraftExcluded`,
`intermediateCurrentPointDoesNotSelfSnap`, `firstCurrentPointRemainsClosureTarget`,
`currentAnchorDoesNotCreateZeroSegment`.
- **AC7** — unit `room cuts leave solid intervals...` + smoke
`openingGapHasNoLine`/`openSpanHasNoLine`/`cutBoundariesAreNotEndpoints`/
`columnIsNotACandidate`. Проверено чтением: `_planSnapOpeningCuts()`
переиспользует существующий кэшированный `_openingWallIndexFor` и
`resolveOpeningWallAssociation` (`houseplan-card.ts:10192` и использования)
— не заводит второй способ трактовать проёмы, как того требует §11.6 ТЗ.
Openings в этой кодовой базе привязаны только к комнатным стенам
(`openingWallIndex` итерирует `rooms`, не `partitions` — `wall-thickness.ts:1884`),
поэтому исключение вырезов только для `sourceKind:'room'` полно, а не частично.
- **AC9** — smoke `hoverKeepsStaticGeometryCache`; performance-sanity run
подтвердил `configStable`/`wsWrites=0` за 120 pointermove. Кэш ключуется
`_cfgEpoch`, который инкрементируется при каждой замене `_serverCfg`
(`houseplan-card.ts:3092`) — тот же паттерн, что у соседних кэшей
(`_openingWallIndexFor`), не изобретён заново.
- **AC10** — smoke `tapWithoutHoverSnapsFirstPoint`,
`secondPartitionClickSnapsEndpoint`, `suppressedClickDoesNotCommit`,
`panPinchCancelDoNotCommit`.
- **AC11** — smoke `otherPlanToolsHaveNoOverlay`/`viewHasNoOverlay`; golden:
все View/kiosk/Devices/Decor-сценарии (46 из 50) остались `passed`
(pixel-identical). Плановые Plan-editor сценарии обсуждены отдельно ниже.
- **AC12** — проверено чтением (кэш ключуется структурным состоянием,
не hover) плюс однократный локальный прогон
`benchmark:large-house-plan-snap` (см. таблицу) — раннер сам бросает
исключение при росте DOM/кэша, дублировании active-кандидата или
ws/config записи; на одном прогоне ни одно из условий не сработало.
Полное сравнение с baseline на exact-SHA — предрелизный гейт.
- **AC13** — проверено чтением diffstat: ни один файл `custom_components/**`,
`src/i18n/*`, `manifest.json`, `hacs.json` не изменён.
- **AC14** — typecheck/test/build зелёные, три копии бандла идентичны
(см. таблицу), `docs/CHANGELOG.md` + `docs/CHANGELOG.ru.md` +
`docs/USER-GUIDE.ru.md` + `docs/CANVAS.md` обновлены в том же коммите
`cde9bfa`, что и поведение.
## Находки
Нет находок уровня High или Medium.
**Low-1 (снято, с решением):** golden-эталоны для Plan editor меняются не
только у двух новых сценариев (`plan-snap-endpoint-light`,
`plan-snap-line-gaps-dark`), но и у четырёх уже существующих
(`geometry-plan-editor-dark`, `tray-wide-selection-en`, `tray-wide-tool-ru`,
`tray-medium-group-en`) — потому что инструмент по умолчанию при входе в Plan
editor это «Контур комнаты», и overlay теперь легитимно виден на этих кадрах.
Разница подтверждена diff-изображением (`artifacts/golden/diff/geometry-plan-editor-dark.png`):
подсвечены ровно новые линии/точки поверх существующей геометрии, ничего не
сломано. Хендофф-комментарий реализации называет только «golden matrix
подготовлена», не уточняя, что переприниматься перед бетой должны 6 кадров, а
не 2. Решение ревьюера: не блокирует — это ожидаемое следствие видимого
контракта фичи (overlay не за отдельным флагом), а не дефект кода; факт
зафиксирован здесь, чтобы релиз-менеджер не удивился при `golden:capture`
перед бетой.
## Что проверено и корректно
- Overlay строго ограничен Plan editor + draw/partition (AC1), pointer-events
отключены на группе и потомках (`aria-hidden`, `pointer-events="none"`),
подтверждено и чтением стилей, и `overlayIsPointerTransparent` в smoke.
- Приоритет endpoint > line, стабильность tie-break, дедупликация общих
вершин, эксклюзия активного draft/self-snap — все доказаны unit-тестами,
которые я убедился, что умеют падать (мутационная проверка приоритета
endpoint/line — таблица гейтов).
- Вырезы дверей/окон/ворот и open-span корректно исключают линию и не создают
постоянных endpoint на границе выреза; переиспользован существующий
`_openingWallIndexFor`/`resolveOpeningWallAssociation`, второй трактовки
проёмов не появилось.
- Кэш геометрии ключуется структурным состоянием (`_cfgEpoch` +
счётчики rooms/drafts/partitions), не пересобирается на hover — тот же
паттерн, что у соседних кэшей в файле.
- Light/dark/forced-colours читаемость подтверждена конкретным smoke-чеком
(`forcedColorsStayReadable`), что закрывает открытый вопрос L4 из
SPEC-REVIEW-137-r1 про отсутствие эмуляции `forced-colors` в golden-харнессе.
Boundary «что доказывает unit/smoke, что golden» проведена явно.
- Три bundle-копии побайтно идентичны после сборки; трейлеры коммита и оба
changelog в порядке; i18n/backend/schema не тронуты.
- Дисциплина «Medium становится issue» не нарушена: находок этого уровня нет.
## Чего не проверял
- Полный `smoke`-suite (127 сценариев) — вне периметра диффа, кроме
целевого и golden-охваченных сценариев.
- Приём golden-эталонов (`golden:accept --reviewed`) и сравнение с полным
Linux CI-артефактом — предрелизный гейт, не гейт этого ревью.
- Полный `Full Performance` workflow и сравнение `large-house-plan-snap-v1`
candidate/baseline на exact-SHA — предрелизный гейт; выполнил только
однократный локальный sanity-прогон без сравнения (см. таблицу).
- `pytest tests_backend` — diff не затрагивает `custom_components/`.
- Ручного тестирования в браузере не было (вне цикла); вместо него —
чтение кода, unit/smoke с проверкой «умеет падать» и разбор golden-кадров
(актуальные PNG просмотрены визуально).
## Вердикт
Зелёный. High: 0, Medium: 0. Единственная Low-находка снята решением
ревьюера с записью выше (ожидаемое поведение фичи, а не дефект); отдельного
issue не требуется.
+250
View File
@@ -0,0 +1,250 @@
# SPEC-REVIEW-137-r1
- **Issue:** https://github.com/Matysh/houseplan-card/issues/137
- **ТЗ под ревью:** `docs/specs/137-plan-snap-overlay.md` (коммит `0ce28b0`,
ветка `issue/137-plan-snap-overlay`)
- **Роль:** ревьюер ТЗ (не автор), этап `S4-spec-review`
- **Трек:** обычный (не `small`/`trivial`) — сложность и риск 6/10, новый
UX-контракт (hover/preview/commit), несколько поверхностей (SVG layering,
resolver, performance); лёгкий/короткий трек корректно не применён
- **Цикл:** r1/4
## Скоуп ревью
Проверялось соответствие ТЗ:
- `docs/SCOPE.md` — попадание в Core user jobs, отсутствие расширения скоупа;
- `PROCESS.md` §2.4, §2.5 (DoR), §7.1 (обязательные разделы), §3/§12 (запреты);
- `AGENTS.md` — классы файлов, ветка, трейлеры коммита ТЗ;
- каноническим документам: `docs/CANVAS.md` §9 (grid-bound/wall-bound snap),
`docs/WALL-THICKNESS.md` (T-соединения, union тел стен), `docs/UX-MODES.md`
(инструменты Plan-редактора), `docs/TOUCH-SUPPORT.md` (best-effort editors);
- `docs/USER-GUIDE.ru.md` — терминология инструментов «Контур комнаты» /
«Перегородка», существующие toast-тексты;
- фактическому состоянию кода (`src/houseplan-card.ts`) — на предмет того, что
технические утверждения ТЗ и issue-аналитики не являются непроверенной
догадкой, выданной за факт.
## Как проверялось
1. Прочитан весь тред issue #137: аналитика S2 с оценками и defaults D1–D5,
решение владельца по D1–D5, комментарий автора ТЗ с открытыми вопросами
Q1–Q7 (каждый с предлагаемым default), решение владельца «приняты defaults
Q1–Q7», и финальный комментарий «ТЗ готово» со ссылкой на коммит.
2. Сверены обязательные разделы ТЗ (§7.1 PROCESS.md) построчно — таблица ниже.
3. Прочитан код `src/houseplan-card.ts`: подтверждено существование
`_snapDrawPoint()` (:5936), `_renderMarkupLayer()` (:16532, вызывается на
:14499) и `_alignCandidates()` (:16003) — ровно те механизмы, которые ТЗ
называет текущим состоянием и точкой расширения в §3. Технический диагноз
не является голословным.
4. Прочитан `docs/CANVAS.md` §9.3 (WALL-BOUND: `snapToWall` для openings,
`snapPointAlongPoly` для точек Split на стене) — ссылка ТЗ §7.4 «тот же
класс координат, который уже применяется для wall-bound opening и split
points» подтверждена дословно, а не изобретена.
5. Прочитан `docs/WALL-THICKNESS.md` §3 («shared and T junctions show one
continuous body with no internal seams», рендер — union тел стен по
комнатам) — подтверждает архитектурную достижимость AC4/Q1: новый отрезок
может дать геометрическое T-соединение без дробления существующего
сегмента, потому что непрерывность тела стены в T уже обеспечена на уровне
рендера, а не на уровне топологии одной комнаты.
6. Прочитан `custom_components/houseplan/validation.py` — подтверждено, что
`room_drafts` и `partitions` существуют как реальные поля схемы, а не
придуманы для ТЗ.
7. Прочитан `docs/USER-GUIDE.ru.md` (раздел 8, таблица «Инструменты плана» и
`src/i18n/ru.json`): кнопка называется **«Контур комнаты»**
(`markup.add`), «Перегородка» совпадает дословно (`markup.partition`).
Существующие toast-тексты `toast.contour_cannot_close`,
`toast.contour_min_edges`, `toast.room_overlap` подтверждают ссылку ТЗ §7.5
на «существующий toast» как факт, а не догадку.
8. Прочитан `demo/performance/README.md`: фикстура `large-house-v1` реально
имеет 60 комнат / 60 перегородок — числа AC12 и §13.4 переиспользуют
существующий профиль, а не изобретают новый масштаб.
9. Прочитан `demo/golden/matrix.mjs` и `demo/golden/harness.mjs`: golden-гейт
на сегодня параметризует тему только как `light`/`dark`
(`page.emulateMedia({ colorScheme })`); отдельного `forced-colors`
emulation-пути в харнессе нет.
10. Проверена запись `docs/specs/README.md:84` — строка на #137 добавлена в
том же коммите, ссылка issue ↔ ТЗ двусторонняя.
11. Проверены трейлеры и class-принадлежность: `git diff --stat
origin/dev...HEAD` показывает только `docs/specs/137-plan-snap-overlay.md`
и `docs/specs/README.md` (класс C); коммит `0ce28b0` несёт `Issue: #137` и
`User-Visible: no` — корректно для документа ТЗ.
## Обязательные разделы (§7.1 PROCESS.md)
| Раздел | Есть | Комментарий |
|---|---|---|
| Сценарий (персона/поверхность/момент) | ✅ | §1 |
| Что человек увидит до/после | ✅ | §2 — по абзацу на «до»/«после», не строго одна фраза (см. Low-2) |
| Проблема | ✅ | §3, с указанием конкретных существующих методов |
| Скоуп / не-скоуп | ✅ | §4 / §5 |
| Контракт поведения | ✅ | §6–§8 |
| Модель данных и миграция | ✅ | §9 |
| UX и i18n | ✅ | §10 |
| AC1…ACn с доказательством | ✅ | §12, 14 штук, каждый с типом |
| План автотестов | ✅ | §13 |
| Риски | ✅ | §16 |
| Откат | ✅ | §17 |
| Release-артефакты | ✅ | §15 |
Все обязательные разделы присутствуют и содержательны, не формальные заглушки.
Дополнительно есть архитектурный/performance-контракт (§11) и явный блок
«принятые технические предположения» (§18), не требуемый §7.1 буквально, но
соответствующий духу PROCESS.md §7.1 о записи технических решений.
## Находки
Находок уровня **High** и **Medium** нет — правки в отдельный issue заводить
не требуется.
### Low-1 — терминология инструмента «Контур» вместо «Контур комнаты»
**Файл:** `docs/specs/137-plan-snap-overlay.md` (используется повсеместно,
например §1, §4, §6.1, §7.3, AC1, AC3, AC8)
ТЗ последовательно называет инструмент «Контур». В `docs/USER-GUIDE.ru.md:259,283`
и `src/i18n/ru.json` (`markup.add`) кнопка называется **«Контур комнаты»**.
Внутри самого ТЗ сокращение употребляется единообразно и не создаёт
неоднозначности для читателя документа. Но §10 ТЗ прямо обязывает будущую
правку `docs/USER-GUIDE.ru.md`, а AGENTS.md требует, чтобы формулировки
поведения брались из пользовательского словаря, а не изобретались заново;
сокращённое имя, попав как есть в реализацию или в код-ревью, может разойтись
с уже принятым пользовательским термином.
**Решение ревьюера:** Low, не блокирует. При правке `docs/USER-GUIDE.ru.md` и
при код-ревью использовать полное «Контур комнаты» там, где текст обращён к
пользователю; в ТЗ можно оставить как есть или поправить одним словом при
следующей редакции — на усмотрение автора.
### Low-2 — «что человек увидит» длиннее одной фразы
**Файл:** `docs/specs/137-plan-snap-overlay.md:33-39` (§2)
PROCESS.md §7.1 требует «одной фразой, без терминов реализации». Раздел
написан двумя короткими абзацами («До:» / «После:»), по одному предложению
каждый — по существу требование выполнено (нет терминов реализации, ясно и
конкретно), но формально это не «одна фраза», а две. Не влияет на
проверяемость AC и не создаёт риска неоднозначности.
**Решение ревьюера:** Low, не блокирует. Косметическая правка на усмотрение
автора.
### Low-3 — типы доказательства AC12–AC14 не входят буквально в перечень §2.5
**Файл:** `docs/specs/137-plan-snap-overlay.md:319-328` (AC12–AC14)
DoR (`PROCESS.md` §2.5) перечисляет типы доказательства как `unit` / `backend`
/ `smoke` / `golden` / «ревью кода». AC12 использует `performance + ревью
кода`, AC13 — `unit + backend/schema review`, AC14 —
`typecheck + unit + build + documentation review`. По существу каждый критерий
проверяем: `performance` ссылается на существующий release-blocking
`performance_smoke`/large-house benchmark (§8 PROCESS.md), а не вводит шестой
вид проверки; `typecheck`/`build` — существующие обязательные гейты (§8);
«schema review» и «documentation review» по факту являются тем же «ревью
кода», уточнённым по предмету. Тот же класс находки уже фиксировался как Low в
`SPEC-REVIEW-123-r1` и не блокировал приёмку.
**Решение ревьюера:** Low, не блокирует. Можно свести формулировки к пяти
каноническим типам при следующей правке ТЗ либо оставить как есть — критерии
не теряют проверяемости.
### Low-4 — план golden не описывает явно, чем доказывается forced-colours
**Файл:** `docs/specs/137-plan-snap-overlay.md:122-133, 285-288, 373-382`
(§6.3, AC2, §13.3)
§6.3 и AC2 требуют, чтобы линия/обычная/активная точка различались в
светлой, тёмной **и forced-colours** теме, а доказательством AC2 указаны
`unit + golden`. Но план golden (§13.3) перечисляет только два кадра —
light и dark; `demo/golden/matrix.mjs`/`harness.mjs` на сегодня умеют
эмулировать только `colorScheme` (`light`/`dark`), отдельного
`forced-colors` emulation-пути в харнессе нет. Формально ТЗ не лжёт — оно не
утверждает, что forced-colours проверяется golden-кадром, а «unit + golden»
как раз позволяет прочитать это как «visual layering — golden, а
forced-colours CSS-правило — unit». Но явно эта граница не проведена, и
читатель может ожидать third golden frame, которого план не обещает и
харнесс не поддерживает.
**Решение ревьюера:** Low, не блокирует. При написании ТЗ для code review
достаточно, чтобы разработчик явно указал в PR/хендоффе, каким именно тестом
(unit CSS-assertion или golden) доказана строка про forced-colours — это
станет предметом код-ревью AC2, а не спец-ревью.
## Что проверено и корректно
- Соответствие `docs/SCOPE.md`: задача закрывает **J4** (план без внешнего
редактора — точное соединение отрезков без Inkscape) и **J6** (плану
оставаться точным без микрозазоров при развитии геометрии). Обе строки в
статусе Closed — это улучшение внутри уже принятой функциональности, не
расширение продукта.
- Легитимность полного трека: сложность/риск 6/10, новый UX-контракт с
hover/pointer-state — критерии `small` (§5 PROCESS.md, сложность ≤3, нет
нового UX-контракта) не выполняются; лёгкий/короткий трек корректно не
применён.
- Владелец лично принял D1–D5 (аналитика) и Q1–Q7 (ТЗ) — открытых продуктовых
вопросов в финальной редакции нет, и это корректно: вопросы заданы batched,
каждый с предложенным default, и закрыты явным решением владельца
2026-08-14, а не додуманы автором. Ни одна догадка не выдана за факт без
пометки — раздел §18 отдельно перечисляет технически свободные решения и
явно фиксирует «нет открытых продуктовых вопросов».
- Технические утверждения о текущем коде (`_snapDrawPoint`,
`_renderMarkupLayer`, `_alignCandidates`, wall-bound контракт `CANVAS.md`
§9.3, T-junction union `WALL-THICKNESS.md` §3, реальные поля
`room_drafts`/`partitions`) подтверждены чтением исходников и канона, а не
являются голословными.
- Не-скоуп (§5) корректно отсекает смежные соблазны: колонны/проёмы как
snap-кандидаты, привязка к продолжениям линий/центрам, автоматическое
дробление существующей геометрии при T-соединении, полноценный touch
hover-паритет, новая схема/backend/i18n — типичные места, где скоуп мог бы
незаметно расшириться, явно исключены.
- Touch-контракт сформулирован по канону `docs/TOUCH-SUPPORT.md`: явное
`best effort / intentionally degraded` (§8 ТЗ), View/kiosk не создают DOM
оверлея, tap повторно решает кандидата — соответствует «safety floor»
(никакой геометрии без явного commit) дословно.
- AC1–AC14 однозначны, у каждого указан тип доказательства и конкретный
наблюдаемый результат (DOM-присутствие, радиус, координата commit,
приоритет кандидата, отсутствие записи в config/storage, DOM-порядок,
bounded cache/DOM). План автотестов (§13) даёт конкретный маршрут для
каждого пункта и явно требует, чтобы «каждый тест умел падать отдельно»
(§13.1, список конкретных регрессий, которые должны красить конкретный
тест) — критерий, защищающий от неспособного падать теста.
- Release-артефакты (§15) перечисляют реальные файлы, включая
`docs/CANVAS.md` (для wall-bound контракта) и `docs/USER-GUIDE.ru.md`.
Golden принимается только через `npm run golden:accept -- --reviewed` по
полному Linux-артефакту (§13.3) — соответствует §13 PROCESS.md.
Perf/golden-artефакты корректно отнесены к пре-релизному, а не
implementation-гейту (§8 PROCESS.md, §11.4).
Откат (§17) корректно опирается на отсутствие миграции данных: новая
геометрия — валидная существующая геометрия и для старой версии кода.
- Трассируемость: `docs/specs/README.md:84` обновлён тем же коммитом
(`0ce28b0`), ссылка issue ↔ ТЗ двусторонняя; ветка `issue/137-plan-snap-
overlay` и трейлеры коммита (`Issue: #137`, `User-Visible: no`) корректны
для документа класса C, который сам не меняет поведение.
- Diff `origin/dev...HEAD` не содержит ни одного файла класса A — код не
тронут до `S5-ready`, что соответствует правилу №1.
## Чего не проверял
- Не проверял реализуемость resolver'а как чистой функции с описанным в §18
API — это по правилам ТЗ свободно изменяемое техническое предположение
автора кода, не предмет ревью ТЗ.
- Не запускал автотесты, `golden`, `performance` или browser-смоки — на
этапе `spec` это не требуется; существование референсных фикстур
(`large-house-v1`, toast-строк, wall-bound кода) проверено чтением, а не
исполнением.
- Не проверял, что forced-colours CSS-правило технически осуществимо в
текущей палитре токенов editor accent/contrast (§18 п.6-7) — это открытое
для автора кода техническое решение, отмеченное в ТЗ как свободно
изменяемое.
- Не проверял корректность конкретных числовых оценок аналитики
(7/10 / 5/10 / 6/10, P2) по существу — это поле владельца (PROCESS.md §2.2),
и они уже приняты явным решением владельца до написания ТЗ.
## Вердикт
Зелёный. High: 0, Medium: 0. Четыре находки Low (терминология «Контур» vs
«Контур комнаты»; «что человек увидит» длиннее одной фразы; типы
доказательства AC12–AC14 вне буквального перечня §2.5; граница
unit/golden-доказательства для forced-colours не проговорена явно) — ни одна
не блокирует приёмку, все либо правятся косметически, либо снимаются с этой
записью на усмотрение автора без нового цикла.
+485
View File
@@ -0,0 +1,485 @@
# Issue #137 — узлы и линии привязки в редакторе Плана
- **Issue:** https://github.com/Matysh/houseplan-card/issues/137
- **Редакция:** первая редакция для независимого ревью; статус определяется только метками issue
- **Тип / приоритет:** feature + polish / P2
- **Оценка:** пользовательская ценность 7/10; ценность для разработки 5/10;
сложность и риск 6/10
- **Область:** редактор Плана, инструменты «Контур» и «Перегородка», производный
SVG-слой архитектурных сегментов и узлов, pointer/click snap
- **Модель данных:** без новых полей, миграции и backend-изменений
- **Связано:** #75, #91, `docs/SCOPE.md`, `docs/TOUCH-SUPPORT.md`,
`docs/CANVAS.md`, `docs/UX-MODES.md`
## 1. Сценарий и продуктовый контекст
**Персона:** администратор дома — основной пользователь редакторов House Plan.
**Поверхность:** desktop browser с мышью или точным pointer; редактор Плана.
Touch editor остаётся best effort по `docs/TOUCH-SUPPORT.md`.
**Момент:** пользователь выбрал «Контур» или «Перегородка» и собирается поставить
первую либо следующую точку нового отрезка рядом с уже нарисованной стеной,
сохранённым открытым контуром или независимой перегородкой.
Задача поддерживает:
- **J4:** план можно точно нарисовать встроенным GUI, без SVG/Inkscape;
- **J6:** существующую геометрию можно продолжать без микрозазоров и визуально
неопределённых соединений.
## 2. Что человек увидит до и после
**До:** существующие стены скрывают свои точные осевые линии и координаты концов,
поэтому следующий клик выглядит приблизительным и заранее не сообщает, соединятся
ли отрезки.
**После:** поверх стен видны тонкие осевые линии и точки их концов, а endpoint или
динамическая точка на линии увеличивается там, куда точно присоединится следующий
клик.
## 3. Проблема
Сейчас `_snapDrawPoint()` знает только глобальную сетку и Shift/45°. Текущий
`_renderMarkupLayer()` показывает вершины live-пути, но не даёт единого верхнего
слоя уже сохранённой архитектуры. Основная markup-геометрия также рисуется до
физических wall bodies и может быть ими закрыта.
В результате пользователь не различает три разные ситуации:
1. клик попадёт точно в существующий endpoint;
2. клик соединит новый отрезок с серединой существующей стены;
3. клик останется обычной точкой сетки рядом со стеной и оставит зазор.
## 4. Scope
В issue входят:
1. Производный pointer-transparent overlay только для активных инструментов
«Контур» и «Перегородка» в редакторе Плана.
2. Тонкие линии по фактически сплошным интервалам:
- завершённых контуров комнат;
- сохранённых открытых контуров (`room_drafts`);
- независимых перегородок (`partitions`).
3. Статические точки в реальных координатах начала и конца исходных сегментов.
4. Endpoint-snap и line-snap для первого и последующих кликов обоих инструментов.
5. Динамический промежуточный узел на выбранной линии.
6. Единый resolver кандидата для hover-preview и commit клика.
7. Светлая, тёмная и forced-colours читаемость без нового пользовательского
параметра.
8. Unit, production-bundle smoke, editor golden и performance-покрытие.
9. Описание принятого snap-контракта в документации редактора.
## 5. Non-scope
В issue не входят:
- View, kiosk, редактор Устройств и редактор Подложки;
- новые инструменты, режимы, кнопки, настройки или i18n-текст;
- колонны, мебель, декор, устройства и сами проёмы как snap-кандидаты;
- привязка к пересечению продолжений линий, центрам, касательным или
произвольным направляющим;
- автоматическое дробление существующей комнаты, draft или partition при
T-соединении;
- изменение толщины, ключей, порядка или семантики существующих сегментов;
- исправление старой off-grid геометрии и массовая оптимизация планов;
- полноценный hover-паритет редактора на coarse pointer;
- новая схема, backend API, storage key, импорт/экспорт или миграция.
## 6. Контракт отображения
### 6.1 Когда слой существует
Overlay создаётся только когда одновременно выполняются условия:
- режим — редактор Плана;
- активный инструмент — «Контур» или «Перегородка»;
- существует текущее пространство.
Он виден уже до первого клика. При смене инструмента, пространства, выходе в View,
закрытии редактора или потере pointer из сцены активный кандидат очищается; сам
статический слой исчезает, когда инструмент больше не подходит.
### 6.2 Геометрия линий
Линии повторяют оси существующих стен и перегородок и рисуются поверх их физических
тел. Они строятся из модели, не измеряют SVG DOM и не становятся новым источником
геометрии.
Из линии и line-snap исключаются:
- дверные, оконные и воротные вырезы;
- намеренно открытые `open-span` интервалы;
- нулевые и невалидные интервалы;
- сегменты текущего активного draft и live-preview.
Вырезы применяются к каноническому сегменту до отображения и hit resolution.
Статический endpoint исходного сегмента остаётся кандидатом только если к нему
примыкает положительный сплошной интервал. Граница проёма сама по себе не создаёт
новый постоянный endpoint.
### 6.3 Геометрия точек
- Обычная статическая точка имеет физический радиус **5 см** в координатах плана.
- Активный endpoint и динамический line-node имеют физический радиус **10 см** и
дополнительный контрастный контур.
- Совпадающие endpoints разных сегментов отображаются одной точкой.
- Точка остаётся привязана к модели при pan/zoom; её физический размер масштабируется
вместе с планом и не превращается в фиксированный screen-space marker.
- Тонкая линия имеет толщину **1 CSS px** независимо от zoom.
Точные цветовые токены являются технической деталью, но линия, обычная точка и
активная точка должны различаться в светлой, тёмной и forced-colours теме. Никакой
пульсации или постоянной анимации нет.
### 6.4 Layering и интерактивность
Архитектурный overlay рисуется после room/partition wall bodies и до верхнего
интерактивного editor chrome. Он:
- имеет `pointer-events:none` на группе и дочерних элементах;
- не создаёт focusable/ARIA-содержимое;
- не перехватывает click, pan, pinch, room card, opening и context tray;
- не меняет hit-testing существующих инструментов.
## 7. Контракт выбора кандидата
### 7.1 Общая зона
Resolver использует зону захвата **12 CSS px**, переведённую в координаты текущего
view. Поэтому зона pointer-попадания стабильна на экране и не зависит от zoom, хотя
видимый радиус точек остаётся физическим.
Один и тот же чистый resolver вызывается:
- на pointermove — для отображения активной точки и live-preview;
- непосредственно на click/tap — для commit, даже если до него не было pointermove.
Hover-состояние никогда не считается авторитетом для commit: клик повторно решает
кандидата из своих координат и актуальной геометрии.
### 7.2 Приоритет и стабильность
Результат всегда один:
1. если в зоне есть существующий endpoint, выбирается ближайший endpoint;
2. только при отсутствии endpoint выбирается ближайший сплошной сегмент;
3. совпадающие endpoints дедуплицируются;
4. равные расстояния разрешаются детерминированным стабильным порядком геометрии,
не зависящим от частоты pointermove;
5. текущий anchor исключается как кандидат нулевого отрезка.
Endpoint имеет приоритет, даже если другая линия формально ближе. Это делает
визуально увеличенную точку точным обещанием следующего клика и предотвращает
мерцание между endpoint и line-node.
### 7.3 Endpoint-snap
Если выбран endpoint:
- live-preview оканчивается в его точной сохранённой координате;
- точка увеличивается до 10 см;
- click сохраняет ровно ту же координату;
- правило действует для первого и последующих кликов «Контура» и «Перегородки».
Endpoint-snap внутри активной зоны сильнее обычной сетки и Shift/45°. Вне зоны
текущий grid-bound + Shift/45° контракт остаётся без изменений.
### 7.4 Line-snap и динамический узел
Если endpoint не выбран, но pointer находится в зоне линии:
1. raw pointer проецируется на ближайший сплошной сегмент;
2. расстояние вдоль сегмента квантуется с текущим шагом сетки, отсчитанным от
стабильного начала сегмента;
3. итоговая точка остаётся точно на линии;
4. на ней отображается единственный динамический узел радиусом 10 см;
5. live-preview и click используют одну и ту же итоговую координату.
На диагонали wall-bound точка не обязана одновременно быть глобальным узлом по X и
Y. Это тот же класс координат, который уже применяется для wall-bound opening и
split points в `docs/CANVAS.md` §9.3.
Динамический узел — только preview. После клика существующий сегмент не дробится и
не переписывается; новый отрезок получает endpoint в wall-bound координате, поэтому
возникает реальное геометрическое T-соединение.
### 7.5 Текущий контур и валидация
Активный draft и live-preview исключены из общего overlay и resolver. Исключение —
первая точка текущего контура, когда существующий сценарий разрешает его замыкание.
Другие вершины и сегменты текущего пути не создают ответвления или self-snap.
Snap не обходит существующие правила:
- повторный click в текущий anchor остаётся no-op;
- самопересечение, overlap, минимальное число сторон и лимиты сохраняются;
- невалидное замыкание продолжает показывать существующий toast;
- отмена, Undo/Redo и history boundary остаются прежними.
## 8. Touch, accessibility и безопасная деградация
**Touch editor: best effort / intentionally degraded.**
- Статические линии и точки могут отображаться на coarse pointer.
- Tap непосредственно выполняет тот же resolver и может точно соединить endpoint
или линию без предварительного hover.
- Отдельное состояние увеличенной точки до tap на устройстве без hover не
гарантируется.
- Pinch, cancelled pointer и синтетический click после pan не должны создавать
геометрию; действующий `_suppressClick`/gesture safety contract сохраняется.
- View и kiosk не создают overlay DOM и не меняют gestures, действия или pixels.
Вспомогательная геометрия декоративна для accessibility: она скрыта от assistive
technology, а новая клавиатурная навигация в editor не вводится.
## 9. Модель данных, совместимость и миграция
Новых данных нет.
- `rooms[].poly`, `room_drafts`, `partitions`, `walls` и `openings` сохраняют
текущую схему;
- mid-line snap не вставляет точку в существующий сегмент;
- overlay/hover не пишет config, layout или local storage;
- write происходит только в существующем commit действия «Контур» или
«Перегородка»;
- импорт, экспорт, downgrade и старые конфиги не требуют миграции;
- backend validation и integration API не меняются.
## 10. UX и i18n
Новых строк, кнопок и настроек нет, поэтому i18n-ключи не добавляются.
`docs/USER-GUIDE.ru.md` должен объяснить одной короткой секцией:
- линии/точки появляются при рисовании контура или перегородки;
- увеличенная точка показывает точное место следующего соединения;
- endpoint имеет приоритет, а точка на линии создаёт T-соединение;
- desktop остаётся рекомендуемой поверхностью редактора.
Публичное обещание не распространяется на hover-паритет touch editor.
## 11. Архитектурный и performance-контракт
1. Производная архитектурная геометрия собирается из модели и может быть вынесена
в чистый helper; SVG DOM не является источником данных.
2. Статический набор линий/endpoints кэшируется по структурному fingerprint либо
эквивалентному стабильному ключу и не пересобирается на каждый pointermove.
3. Pointermove не пишет состояние модели, config/storage и не создаёт новый массив
SVG-узлов сверх обновления единственного active candidate.
4. Количество статических SVG-элементов ограничено O(E): один элемент на сплошной
интервал и один на уникальный endpoint. Динамический line-node — не более одного.
5. Resolver может быть линейным от числа кандидатов на первом этапе, но большой
fixture обязан пройти неизменные performance-бюджеты. Пространственный индекс
допустим как техническая оптимизация, если не меняет контракт выбора.
6. Cut-геометрия переиспользует канонические opening/open-span решения и не
изобретает второй способ трактовать проёмы.
7. Overlay не создаёт websocket, HA service, fetch, timer или внешнюю зависимость.
## 12. Acceptance criteria
- **AC1 (`unit` + `smoke`; разработчик):** только в Plan editor при активном
«Контуре»/«Перегородке» поверх физических стен существует pointer-transparent
overlay завершённых room contours, неактивных saved drafts и partitions; View,
kiosk и остальные editor tools не создают его DOM.
- **AC2 (`unit` + `golden`; разработчик):** каждый уникальный исходный endpoint с
примыкающим сплошным интервалом показан одной точкой радиусом 5 см; линии имеют
1 CSS px и повторяют solid intervals; active endpoint/dynamic node имеют радиус
10 см и читаемый контур в light/dark/forced colours.
- **AC3 (`unit` + `smoke`; разработчик):** первый и последующие клики обоих
инструментов внутри 12 CSS px endpoint показывают один active endpoint, ведут
live-preview в него и сохраняют его точную координату; endpoint приоритетнее
любой линии.
- **AC4 (`unit` + `smoke`; разработчик):** при попадании на solid line вне
endpoint-priority отображается один динамический узел в wall-bound координате,
квантованной вдоль линии; click создаёт точное T-соединение и не дробит/не
переписывает существующий сегмент.
- **AC5 (`unit`; разработчик):** совпадающие endpoints дедуплицируются, ближайший
кандидат выбирается детерминированно, равные расстояния не мерцают, а resolver
pointermove и resolver click возвращают одинаковый результат для одинакового
snapshot.
- **AC6 (`unit` + `smoke`; разработчик):** активный draft/live-preview исключён из
общего snap; первая точка остаётся целью разрешённого замыкания, current anchor не
создаёт нулевой сегмент, а другие точки текущего пути не создают self-branch.
- **AC7 (`unit` + `golden`; разработчик):** door/window/gate и open-span интервалы
отсутствуют в линии и line-snap; границы выреза не становятся постоянными
endpoints; columns/openings/decor/devices не входят в кандидаты.
- **AC8 (`unit` + `smoke`; разработчик):** активный endpoint/line candidate сильнее
grid и Shift/45°, а вне 12 CSS px существующие grid-bound, Shift/45°, contour
validity, limits и toast contracts остаются без изменений.
- **AC9 (`unit` + code review; разработчик/ревьюер):** hover и candidate resolution
не пишут config/layout/local storage; commit использует существующие history,
Undo/Redo и save boundaries и добавляет только новый endpoint нового отрезка.
- **AC10 (`smoke`; разработчик):** tap без предшествующего pointermove повторно
решает кандидата и соединяет геометрию; pan, pinch, pointercancel и suppressed
synthetic click не создают отрезок. Hover-паритет coarse pointer не требуется.
- **AC11 (`smoke` + existing View golden; разработчик):** overlay не перехватывает
pointer/focus/ARIA, не меняет View/kiosk pixels и gestures, room/opening/editor
chrome interactions и lock/action contract.
- **AC12 (`performance` + code review; разработчик/ревьюер):** large-house fixture
с 60 комнатами и 60 partitions проходит overlay render и серию pointermove без
роста cache/DOM между стабильными кадрами; существующие performance budgets не
ослаблены, exact-SHA Full Performance перед бетой зелёный.
- **AC13 (`unit` + backend/schema review; разработчик/ревьюер):** config schema,
import/export, backend, storage keys, i18n и зависимости не меняются; старые планы
читаются без миграции.
- **AC14 (`typecheck` + `unit` + `build` + documentation review; разработчик):**
implementation-loop gates зелёные, три bundle-копии побайтно одинаковы, оба
changelog и пользовательская/internal документация обновлены в видимом коммите.
## 13. План автотестов
### 13.1 Unit
Добавить чистое покрытие сборщика и resolver:
1. room rectangle, polygon, saved open draft и partition дают ожидаемые segments и
endpoints;
2. совпадающие room/partition endpoints дедуплицируются независимо от направления;
3. active draft, zero-length и columns исключаются;
4. opening/open-span cuts оставляют только solid intervals и не создают endpoints
на границах cut;
5. endpoint выигрывает у более близкой линии в общей 12 px зоне;
6. nearest line projection и quantisation along segment стабильны для horizontal,
vertical и diagonal случаев;
7. tie разрешается одинаково при перестановке pointermove и не меняет snapshot;
8. current anchor исключён, first contour point разрешён только для closure;
9. candidate overrides Shift, а no-candidate сохраняет существующий grid/45 output;
10. cache/fingerprint меняется от структурной геометрии и не меняется от hover.
Каждый тест должен уметь падать отдельно: удаление dedup, смена приоритета,
возвращение точки в opening gap, off-wall округление или включение active draft
делают соответствующий тест красным.
### 13.2 Targeted browser smoke
Добавить production-bundle сценарий, например `demo/smoke_plan_snap_overlay.mjs`:
1. открыть Plan editor с комнатой, saved draft, partition, door и open span;
2. переключать «Контур»/«Перегородка» и другие tools, проверяя наличие/отсутствие
overlay и его DOM-порядок после wall bodies;
3. pointermove к endpoint — active radius/класс, preview и commit exact coordinate;
4. pointermove к середине horizontal и diagonal line — dynamic node, wall-bound
quantisation и T-join без split исходного segment;
5. доказать endpoint priority, first-click snap и Shift override;
6. доказать отсутствие snap в opening/open-span gap и сохранение обычной grid snap;
7. closure current first point работает, intermediate self-snap отсутствует;
8. tap без pointermove соединяет, а pan/pinch/cancel/suppressed click не пишет;
9. выйти в View/другой editor и убедиться, что overlay DOM исчез и pixels/actions не
изменились.
Smoke пишется вместе с кодом, но полный browser-suite запускается перед бетой.
### 13.3 Golden
Добавить либо расширить editor matrix минимум двумя кадрами:
- light: статические линии/endpoints и active endpoint поверх толстых стен;
- dark: dynamic mid-line node, opening/open-span gaps и T-preview поверх стены.
Проверяются физические размеры относительно масштаба плана, слой над wall bodies,
контраст и отсутствие линии через вырезы. View baselines должны остаться
pixel-identical.
Golden принимаются только из полного просмотренного Linux artifact через
`npm run golden:accept -- --reviewed` с обязательными трейлерами процесса.
### 13.4 Performance
Расширить large-house harness либо добавить отдельный overlay profile:
- 60 rooms, 60 partitions, opening cuts и несколько saved drafts;
- первый render статического overlay;
- серия не менее 100 pointermove по endpoint, line и miss;
- bounded DOM, cache cap/growth и отсутствие config/network writes;
- сравнение exact candidate с base при неизменных budgets.
Локальный performance-run диагностический. Перед бетой обязательны зелёные
performance smoke и Full Performance на точном SHA кандидата.
### 13.5 Backend
Backend не меняется. Нового backend-теста не требуется; полный Linux Validate
остаётся release gate.
## 14. План реализации
1. Ввести чистые типы/сборщик architectural snap geometry и resolver кандидата.
2. Подключить к нему канонические room/draft/partition segments и существующие cuts.
3. Разделить raw pointer, resolved candidate и committed point так, чтобы click
всегда re-resolve актуальный snapshot.
4. Добавить верхний pointer-inert SVG overlay и theme/forced-colours styles.
5. Добавить unit, targeted smoke, golden/performance fixtures.
6. Обновить `docs/CANVAS.md`, `docs/USER-GUIDE.ru.md`, оба changelog и bundle-копии.
Имена helper-файлов и приватных полей не являются продуктовым контрактом.
## 15. Release-артефакты
Изменение пользовательское: implementation-коммиты имеют `User-Visible: yes` и в
том же коммите обновляют:
- `docs/CHANGELOG.md`;
- `docs/CHANGELOG.ru.md`;
- `docs/USER-GUIDE.ru.md` — видимое поведение editor snap;
- `docs/CANVAS.md` — внутренний grid-bound/wall-bound контракт;
- три поставляемые bundle-копии.
Перед бетой обязательны:
- exact-SHA Linux Validate;
- полный smoke-suite;
- просмотренный и принятый Linux golden artifact;
- performance smoke и Full Performance на точном SHA с неизменными budgets;
- code review с отрицательным security/network/schema verdict.
Отдельный security report не требуется: новых внешних данных, HA calls, HTML input,
network/storage путей нет. Публикация проходит через бету до stable.
## 16. Риски и меры
| Риск | Вероятность / влияние | Мера |
|---|---|---|
| Overlay закрыт толстой стеной или opening symbol | medium / high | явный DOM-order smoke и editor golden |
| Endpoint/line меняются между hover и click | medium / high | один pure resolver, обязательный re-resolve на click |
| T-node оказывается off-wall после grid snap | medium / high | wall-bound projection + along-segment quantisation units |
| Opening gap остаётся кликабельной линией | medium / high | единая cut-геометрия, unit + golden gap case |
| Active draft привязывается сам к себе | medium / high | исключение по active id, отдельный closure case |
| Много SVG-узлов ухудшает pointermove | medium / high | structural cache, O(E) DOM, large-house performance profile |
| Точки 5 см плохо видны далеко | medium / low | это принятый физический размер; контрастный stroke и 12 px hit tolerance |
| Touch tap и hover расходятся | medium / medium | re-resolve непосредственно на tap, hover parity не обещается |
| Snap обходит overlap/self-intersection | low / high | существующая validation после resolution, regression smoke |
| Overlay попадает в View/kiosk | low / high | mode/tool guard, DOM absence smoke, unchanged View golden |
## 17. Откат
Откат — revert implementation-коммита #137 вместе с тестами, документацией,
changelog и bundle-копиями. Persisted schema не меняется, поэтому data rollback и
миграция не нужны; созданные новой версией endpoints остаются валидной существующей
геометрией и читаются старой версией.
Если перед бетой красный только новый визуальный/performance gate, релиз блокируется:
feature flag или ослабление budget не вводятся как аварийный обход.
## 18. Принятые технические предположения — можно менять без продуктового ревью
1. Рекомендуется новый чистый `src/plan-snap-overlay.ts` (или эквивалент) с типами
segment/endpoint/candidate; точное имя и раскладка файлов свободны.
2. Stable tie key может состоять из нормализованных координат, kind и persisted id;
конкретный формат не хранится и не публикуется.
3. Dedup использует существующую точность `samePoint` либо более строгую
grid-compatible нормализацию; он не объединяет визуально близкие, но разные узлы.
4. Screen tolerance вычисляется из текущего view/stage scale один раз на event;
допустима единая isotropic scale для SVG `preserveAspectRatio`.
5. Статический geometry cache может быть keyed существующим `_cfgEpoch` плюс
structural fingerprint или только полным fingerprint; HA state/theme/hover в key
не входят.
6. Цвета берутся из существующих editor accent/contrast tokens. Можно выбрать dashed
или solid line, если golden сохраняет однозначную непрерывность и 1 px толщину.
7. Forced-colours реализация может использовать media query и системные цвета;
отдельная настройка не нужна.
8. Canonical cuts можно получать до SVG render или через чистый helper, но не через
DOM measurement и не через второй независимый opening resolver.
9. Unit-тесты могут держать новые helper-файлы отдельно от монолитного card test;
browser smoke остаётся авторитетом integration/layer/click контракта.
10. Нет открытых продуктовых вопросов: D1–D5 и Q1–Q7 приняты владельцем 2026-08-14.
+1
View File
@@ -81,6 +81,7 @@ GitHub Issues и GitHub Projects (v2) остаются единственным
| [#107](https://github.com/Matysh/houseplan-card/issues/107) Переключение виртуального источника света «Всегда» | [107-virtual-light-toggle.md](107-virtual-light-toggle.md) |
| [#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) |
## Правило актуализации
+1
View File
@@ -13,6 +13,7 @@
"audit:config": "node scripts/config-audit.mjs",
"benchmark:large-house": "node demo/benchmark_large_house.mjs",
"benchmark:large-house-isometric": "node demo/benchmark_large_house.mjs --profile=large-house-isometric-v1",
"benchmark:large-house-plan-snap": "node demo/benchmark_large_house.mjs --profile=large-house-plan-snap-v1",
"benchmark:glow": "node demo/benchmark_glow.mjs",
"benchmark:compare": "node demo/performance/compare.mjs",
"golden:capture": "node demo/golden/run.mjs --mode=capture",
+170 -23
View File
@@ -59,7 +59,8 @@ import {
floorFootprintGeometry,
innerContourForRoom, roomWallProfile, outsetContour,
openingInnerFaceOffsetFromIndex, openingTunnelGeometriesFromIndex,
openingWallIndex as buildOpeningWallIndex, applyWallThicknessToNewRoom,
openingWallIndex as buildOpeningWallIndex, resolveOpeningWallAssociation,
applyWallThicknessToNewRoom,
drawWallPreviewD, DRAW_WALL_DEFAULT_CM, wallIntervals, materializeWallIntervals,
normalizeWallIntervals,
intervalCmAt, wallBodyNeedsSolid, type OpeningTunnelGeometry, type OpeningWallIndex,
@@ -128,6 +129,10 @@ import {
geometryAllRings, intersectionPaths, partitionBody, polyclipPathD,
pointInOpaquePlanBody, pointInPhysicalBody, sameColumnPlacement, physicalBodies,
} from './physical-geometry';
import {
buildPlanSnapGeometry, resolvePlanSnap,
type PlanSnapCandidate, type PlanSnapGeometry,
} from './plan-snap-overlay';
import {
LightSegment, polygonSegments, splitAtIntersections, visibilityPolygon,
} from './light-visibility';
@@ -1074,7 +1079,7 @@ class HouseplanCard extends LitElement {
if (this._activeDraftId) this._resumeDraftBySpace[this._space] = this._activeDraftId;
this._space = id;
this._path = [];
this._cursorPt = null;
this._clearPlanSnapHover();
this._clearOpeningPlacement(true);
this._tool = 'draw';
this._openWallAnchor = null;
@@ -1192,6 +1197,7 @@ class HouseplanCard extends LitElement {
* (and vice versa) on every pointer move. */
private _openingWallIndexCache = new Map<string, OpeningWallIndex>();
private _openingPlacementIntervalsCache: { key: string; value: WallInterval[] } | null = null;
private _planSnapGeometryCache: { key: string; value: PlanSnapGeometry } | null = null;
private _physicalBodiesCache: {
key: string; drafts: number[][][]; partitions: number[][][];
columns: number[][][]; all: number[][][];
@@ -1245,6 +1251,7 @@ class HouseplanCard extends LitElement {
private _rszLive: { x: number; y: number; text: string; area?: boolean }[] | null = null;
private _path: number[][] = []; // current outline (render units, vertices snapped to the grid)
private _cursorPt: number[] | null = null;
private _planSnapHover: { contextKey: string; candidate: PlanSnapCandidate | null } | null = null;
private _mergeSel: string | null = null;
/** Session-only explicit type/width chosen in the Opening sub-panel. */
private _openingPreset: OpeningPlacementPreset | null = null;
@@ -1718,7 +1725,7 @@ class HouseplanCard extends LitElement {
this._space = id;
this._selId = null;
this._path = [];
this._cursorPt = null;
this._clearPlanSnapHover();
this._clearOpeningPlacement(true);
this._tool = 'draw';
this._openWallAnchor = null;
@@ -2000,7 +2007,7 @@ class HouseplanCard extends LitElement {
// the user's first click after returning into P2.
this._openWallAnchor = null;
this._boundaryRestoreGuard = null;
this._cursorPt = null;
this._clearPlanSnapHover();
this._clearOpeningPlacement(true);
this._touchContacts.clear();
this._touchSequenceMultitouch = false;
@@ -5127,6 +5134,8 @@ class HouseplanCard extends LitElement {
if (this._tool === 'opening') {
this._cursorPt = null;
this._clearOpeningPlacement(false);
} else if (this._tool === 'draw' || this._tool === 'partition') {
this._clearPlanSnapHover();
}
const pts = [...this._pointers.values()];
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
@@ -5192,6 +5201,8 @@ class HouseplanCard extends LitElement {
if (this._tool === 'opening') {
this._cursorPt = null;
this._clearOpeningPlacement(false);
} else if (this._tool === 'draw' || this._tool === 'partition') {
this._clearPlanSnapHover();
}
const pts = [...this._pointers.values()];
const dist = Math.hypot(pts[0].x - pts[1].x, pts[0].y - pts[1].y);
@@ -5211,6 +5222,8 @@ class HouseplanCard extends LitElement {
if (this._tool === 'opening') {
this._cursorPt = null;
this._clearOpeningPlacement(false);
} else if (this._tool === 'draw' || this._tool === 'partition') {
this._clearPlanSnapHover();
}
}
// Which gesture is this? Decided once, on the first movement worth the
@@ -5242,9 +5255,13 @@ class HouseplanCard extends LitElement {
}
private _stagePointerLeave(_ev: PointerEvent): void {
if (!this._markup || this._tool !== 'opening') return;
this._cursorPt = null;
this._clearOpeningPlacement(false);
if (!this._markup) return;
if (this._tool === 'opening') {
this._cursorPt = null;
this._clearOpeningPlacement(false);
} else if (this._tool === 'draw' || this._tool === 'partition') {
this._clearPlanSnapHover();
}
}
private _stagePointerUp(ev: PointerEvent): void {
@@ -5827,7 +5844,7 @@ class HouseplanCard extends LitElement {
});
}
this._path = [];
this._cursorPt = null;
this._clearPlanSnapHover();
this._clearOpeningPlacement(true);
this._openWallAnchor = null;
this._boundaryRestoreGuard = null;
@@ -5941,6 +5958,91 @@ class HouseplanCard extends LitElement {
return this._snap(candidate);
}
/** Canonical physical opening slots on room-wall centrelines. */
private _planSnapOpeningCuts(space: SpaceModel, openCuts: number[][]): number[][] {
if (!this._openingsR.length) return [];
const index = this._openingWallIndexFor(space, openCuts).value;
const cuts: number[][] = [];
for (const opening of this._openingsR) {
const input = {
x: opening.rx, y: opening.ry,
angle: Number(opening.angle) || 0,
length: opening.rlen,
};
const association = resolveOpeningWallAssociation(index, input);
if (!association.negative && !association.positive) continue;
const rad = input.angle * Math.PI / 180;
const dx = Math.cos(rad) * input.length / 2;
const dy = Math.sin(rad) * input.length / 2;
cuts.push([input.x - dx, input.y - dy, input.x + dx, input.y + dy]);
}
return cuts;
}
/** Static architectural axes are rebuilt only when structural editor state changes. */
private _planSnapGeometrySnapshot(): { key: string; value: PlanSnapGeometry } {
const space = this._spaceModel();
const key = [
this._space, this._cfgEpoch, this._activeDraftId || '',
space.rooms.length, space.room_drafts.length, space.partitions.length,
].join('|');
if (this._planSnapGeometryCache?.key === key) return this._planSnapGeometryCache;
const openCuts = this._openCuts();
const value = buildPlanSnapGeometry({
space,
activeDraftId: this._activeDraftId,
roomCuts: [...openCuts, ...this._planSnapOpeningCuts(space, openCuts)],
epsilon: this._gridPitch * 0.0002,
});
this._planSnapGeometryCache = { key, value };
return this._planSnapGeometryCache;
}
private _planSnapContextKey(geometryKey: string): string {
const first = this._path[0];
const anchor = this._path[this._path.length - 1];
return [
geometryKey, this._tool, this._path.length,
first ? `${first[0]},${first[1]}` : '',
anchor ? `${anchor[0]},${anchor[1]}` : '',
].join('|');
}
private _resolvePlanDrawPoint(
raw: number[], lock45: boolean,
): { point: number[]; candidate: PlanSnapCandidate | null; contextKey: string } {
const snapshot = this._planSnapGeometrySnapshot();
const anchor = this._path[this._path.length - 1];
const closure = this._tool === 'draw' && this._path.length >= 3
? [{ point: this._path[0], key: 'closure:first-point' }]
: [];
const candidate = resolvePlanSnap(snapshot.value, raw, {
tolerance: this._cssPxToRender(12),
gridStep: this._gridPitch,
excludePoints: anchor ? [anchor] : [],
extraEndpoints: closure,
epsilon: this._gridPitch * 0.0002,
});
return {
point: candidate ? [...candidate.point] : this._snapDrawPoint(raw, lock45),
candidate,
contextKey: this._planSnapContextKey(snapshot.key),
};
}
private get _activePlanSnapCandidate(): PlanSnapCandidate | null {
if (!this._markup || (this._tool !== 'draw' && this._tool !== 'partition')) return null;
const hover = this._planSnapHover;
if (!hover) return null;
const snapshot = this._planSnapGeometrySnapshot();
return hover.contextKey === this._planSnapContextKey(snapshot.key) ? hover.candidate : null;
}
private _clearPlanSnapHover(clearCursor = true): void {
this._planSnapHover = null;
if (clearCursor) this._cursorPt = null;
}
private _samePt(a: number[], b: number[]): boolean {
return samePoint(a, b);
}
@@ -6105,7 +6207,7 @@ class HouseplanCard extends LitElement {
/** Drop every transient gesture before replacing committed geometry. */
private _clearGeometryGesture(): void {
this._path = [];
this._cursorPt = null;
this._clearPlanSnapHover();
this._clearOpeningPlacement(false);
this._mergeSel = null;
this._mergeDialog = null;
@@ -6137,7 +6239,7 @@ class HouseplanCard extends LitElement {
if (this._tool !== 'boundary' || !this._openWallAnchor) return false;
this._openWallAnchor = null;
this._boundaryRestoreGuard = null;
this._cursorPt = null;
this._clearPlanSnapHover();
this.requestUpdate();
return true;
}
@@ -6164,6 +6266,8 @@ class HouseplanCard extends LitElement {
if (this._tool === 'opening') {
this._cursorPt = null;
this._clearOpeningPlacement(false);
} else if (this._tool === 'draw' || this._tool === 'partition') {
this._clearPlanSnapHover();
}
const viewportGestureEnded = !!this._pinchStart || !!this._panStart;
this._pointers.delete(ev.pointerId);
@@ -6340,7 +6444,7 @@ class HouseplanCard extends LitElement {
}
this._path = [...this._path, [...this._path[0]]];
this._closingWallCm = closingCm;
this._cursorPt = null;
this._clearPlanSnapHover();
this._nameSel = '';
this._areaSel = '';
this._resetRoomDialogFields();
@@ -6409,7 +6513,7 @@ class HouseplanCard extends LitElement {
}
// draw: clicks on grid points build the outline. Nothing is written to the config
// until the contour closes — an abandoned outline leaves no lines behind.
const pt = this._snapDrawPoint(raw, ev.shiftKey);
const pt = this._resolvePlanDrawPoint(raw, ev.shiftKey).point;
if (ev.ctrlKey || ev.metaKey) {
ev.preventDefault();
this._closeRoomContour(true);
@@ -6573,7 +6677,7 @@ class HouseplanCard extends LitElement {
this._closingWallCm = Number(mergedSegments[mergedSegments.length - 1]?.cm)
|| DRAW_WALL_DEFAULT_CM;
this._path = [...persistedPoints, [...persistedPoints[0]]];
this._cursorPt = null;
this._clearPlanSnapHover();
this._nameSel = '';
this._areaSel = '';
this._resetRoomDialogFields();
@@ -6608,7 +6712,7 @@ class HouseplanCard extends LitElement {
}
private _partitionClick(raw: number[], lock45: boolean): void {
const pt = this._snapDrawPoint(raw, lock45);
const pt = this._resolvePlanDrawPoint(raw, lock45).point;
if (!this._path.length) { this._path = [pt]; return; }
const a = this._path[0];
if (this._samePt(a, pt)) return;
@@ -6625,7 +6729,7 @@ class HouseplanCard extends LitElement {
this._activeDraftId = null;
this._draftSegmentCms = [];
this._closingWallCm = null;
this._cursorPt = null;
this._clearPlanSnapHover();
this._recordGeometry(this._t('history.partition_add'), before);
this._saveConfig();
}
@@ -11030,14 +11134,18 @@ class HouseplanCard extends LitElement {
this._cursorPt = this._svgPoint(ev);
return;
}
const drawing = (this._tool === 'draw' || this._tool === 'partition')
&& this._path.length && !this._contourClosed;
const architectural = (this._tool === 'draw' || this._tool === 'partition')
&& !this._contourClosed;
const cutting = this._tool === 'split' && !!this._splitSel?.pts?.length;
if (!drawing && !cutting) return;
if (!architectural && !cutting) return;
const raw = this._svgPoint(ev);
this._cursorPt = drawing
? this._snapDrawPoint(raw, ev.shiftKey)
: this._snap(raw);
if (architectural) {
const resolved = this._resolvePlanDrawPoint(raw, ev.shiftKey);
this._planSnapHover = { contextKey: resolved.contextKey, candidate: resolved.candidate };
this._cursorPt = resolved.point;
return;
}
this._cursorPt = this._snap(raw);
}
/** One resolved architectural candidate shared by hover and click. */
@@ -11214,7 +11322,7 @@ class HouseplanCard extends LitElement {
this._activeDraftId = null;
this._draftSegmentCms = [];
this._closingWallCm = null;
this._cursorPt = null;
this._clearPlanSnapHover();
this._roomDialog = false;
this._pendingSplit = null;
this._splitSel = null;
@@ -11236,7 +11344,7 @@ class HouseplanCard extends LitElement {
this._activeDraftId = id;
this._path = draft.points.map((p) => [...p]);
this._draftSegmentCms = draft.segments.map((s) => s.cm);
this._cursorPt = null;
this._clearPlanSnapHover();
}
/** Cancel in the dialog: the outline is open again (the closing point is removed). */
@@ -14509,6 +14617,8 @@ class HouseplanCard extends LitElement {
inside thick jambs without changing the stored span. */}
${!this._editing ? this._renderOpenWalls(disp) : nothing}
${this._renderWallBodies(disp)}
${this._markup ? svg`<g class="hp-editor-only-layer"
opacity="${modeVisual?.editorWeight ?? 1}">${this._renderPlanSnapOverlay()}</g>` : nothing}
${this._markup ? svg`<g class="hp-editor-only-layer"
opacity="${modeVisual?.editorWeight ?? 1}">${this._renderOpeningPlacementPreview()}</g>` : nothing}
${opMeasure?.guide ? this._renderOpeningCenterTick(opMeasure.guide) : nothing}
@@ -16529,6 +16639,43 @@ class HouseplanCard extends LitElement {
return svg`<g class="physical-editor">${draftSegs}${partitions}${columns}${ghost}${chrome}</g>`;
}
private _renderPlanSnapOverlay(): TemplateResult {
if (!this._markup || (this._tool !== 'draw' && this._tool !== 'partition')) {
return svg`` as unknown as TemplateResult;
}
const geometry = this._planSnapGeometrySnapshot().value;
const active = this._activePlanSnapCandidate;
const staticRadius = wallCmToUnits(5, this._cellCm, this._gridPitch);
const activeRadius = wallCmToUnits(10, this._cellCm, this._gridPitch);
const activeAt = (point: readonly number[]) => !!active && samePoint(
[point[0], point[1]], active.point, this._gridPitch * 0.0002,
);
const activeHasStaticNode = !!active && geometry.endpoints.some((endpoint) => activeAt(endpoint.point));
return svg`<g class="plan-snap-overlay" data-hp="plan-snap-overlay"
data-segment-count=${geometry.segments.length}
data-endpoint-count=${geometry.endpoints.length}
aria-hidden="true" pointer-events="none">
${geometry.segments.map((segment) => svg`<line class="plan-snap-line"
data-key=${segment.key} data-source-kind=${segment.sourceKind}
x1=${segment.a[0]} y1=${segment.a[1]} x2=${segment.b[0]} y2=${segment.b[1]}
vector-effect="non-scaling-stroke" pointer-events="none"></line>`)}
${geometry.endpoints.map((endpoint) => {
const isActive = activeAt(endpoint.point);
return svg`<circle class="plan-snap-node ${isActive ? 'active' : ''}"
data-kind="endpoint" data-key=${endpoint.key} data-active=${isActive ? 'true' : 'false'}
cx=${endpoint.point[0]} cy=${endpoint.point[1]}
r=${isActive ? activeRadius : staticRadius}
pointer-events="none"></circle>`;
})}
${active && !activeHasStaticNode
? svg`<circle class="plan-snap-node active dynamic" data-kind=${active.kind}
data-key=${active.key} data-active="true"
cx=${active.point[0]} cy=${active.point[1]} r=${activeRadius}
pointer-events="none"></circle>`
: nothing}
</g>` as unknown as TemplateResult;
}
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);
+264
View File
@@ -0,0 +1,264 @@
import { cutSegments, distToSegment, roomEdges } from './logic';
import type { SpaceModel } from './types';
export type PlanSnapSourceKind = 'room' | 'draft' | 'partition';
export interface PlanSnapSegment {
a: [number, number];
b: [number, number];
key: string;
sourceKind: PlanSnapSourceKind;
sourceId: string;
}
export interface PlanSnapEndpoint {
point: [number, number];
key: string;
}
export interface PlanSnapGeometry {
segments: PlanSnapSegment[];
endpoints: PlanSnapEndpoint[];
}
export interface PlanSnapExtraEndpoint {
point: readonly number[];
key: string;
}
export type PlanSnapCandidate =
| {
kind: 'endpoint';
point: [number, number];
key: string;
distance: number;
}
| {
kind: 'line';
point: [number, number];
key: string;
distance: number;
segment: PlanSnapSegment;
};
export interface BuildPlanSnapGeometryOptions {
space: Pick<SpaceModel, 'rooms' | 'room_drafts' | 'partitions'>;
activeDraftId?: string | null;
roomCuts?: readonly number[][];
epsilon?: number;
}
export interface ResolvePlanSnapOptions {
tolerance: number;
gridStep: number;
excludePoints?: readonly (readonly number[])[];
extraEndpoints?: readonly PlanSnapExtraEndpoint[];
epsilon?: number;
}
interface SourceSegment {
a: [number, number];
b: [number, number];
kind: PlanSnapSourceKind;
id: string;
cuts: readonly number[][];
}
const DEFAULT_EPSILON = 0.001;
function finitePoint(point: readonly number[] | null | undefined): point is readonly [number, number] {
return !!point && point.length >= 2 && Number.isFinite(point[0]) && Number.isFinite(point[1]);
}
function comparePoint(a: readonly number[], b: readonly number[]): number {
return a[0] - b[0] || a[1] - b[1];
}
function pointKey(point: readonly number[]): string {
return `${point[0].toFixed(6)},${point[1].toFixed(6)}`;
}
function pointsEqual(a: readonly number[], b: readonly number[], epsilon: number): boolean {
return Math.abs(a[0] - b[0]) < epsilon && Math.abs(a[1] - b[1]) < epsilon;
}
function canonicalPair(
a: readonly number[],
b: readonly number[],
): [[number, number], [number, number]] {
const first = comparePoint(a, b) <= 0 ? a : b;
const second = first === a ? b : a;
return [[first[0], first[1]], [second[0], second[1]]];
}
function sourceKey(source: SourceSegment): string {
const [a, b] = canonicalPair(source.a, source.b);
return `${source.kind}|${source.id}|${pointKey(a)}|${pointKey(b)}`;
}
function segmentKey(source: SourceSegment, a: readonly number[], b: readonly number[]): string {
const [ca, cb] = canonicalPair(a, b);
return `${sourceKey(source)}|${pointKey(ca)}|${pointKey(cb)}`;
}
function touches(point: readonly number[], segment: readonly number[], epsilon: number): boolean {
return pointsEqual(point, [segment[0], segment[1]], epsilon)
|| pointsEqual(point, [segment[2], segment[3]], epsilon);
}
/**
* Build the immutable architectural axes used by both the overlay and snap resolver.
* Opening/open-span cuts apply only to room-owned walls; saved drafts and independent
* partitions keep their own complete axes. Cut boundaries never become static nodes.
*/
export function buildPlanSnapGeometry(options: BuildPlanSnapGeometryOptions): PlanSnapGeometry {
const epsilon = options.epsilon ?? DEFAULT_EPSILON;
const roomCuts = options.roomCuts || [];
const sources: SourceSegment[] = [];
for (const [index, segment] of roomEdges(options.space.rooms).entries()) {
if (segment.length < 4) continue;
sources.push({
a: [segment[0], segment[1]],
b: [segment[2], segment[3]],
kind: 'room',
id: `room-edge-${index}`,
cuts: roomCuts,
});
}
for (const draft of options.space.room_drafts || []) {
if (draft.id === options.activeDraftId) continue;
for (let index = 0; index + 1 < draft.points.length; index++) {
const a = draft.points[index];
const b = draft.points[index + 1];
if (!finitePoint(a) || !finitePoint(b)) continue;
sources.push({
a: [a[0], a[1]], b: [b[0], b[1]], kind: 'draft',
id: `${draft.id}:${index}`, cuts: [],
});
}
}
for (const partition of options.space.partitions || []) {
if (!finitePoint(partition.a) || !finitePoint(partition.b)) continue;
sources.push({
a: [partition.a[0], partition.a[1]],
b: [partition.b[0], partition.b[1]],
kind: 'partition',
id: partition.id,
cuts: [],
});
}
const segmentsByAxis = new Map<string, PlanSnapSegment>();
const endpointKeys = new Map<string, PlanSnapEndpoint>();
for (const source of sources) {
const sourceLine = [source.a[0], source.a[1], source.b[0], source.b[1]];
const solids = cutSegments([sourceLine], source.cuts as number[][], epsilon);
if (!solids.length) continue;
for (const solid of solids) {
if (solid.length < 4) continue;
const [a, b] = canonicalPair([solid[0], solid[1]], [solid[2], solid[3]]);
if (pointsEqual(a, b, epsilon)) continue;
const axisKey = `${pointKey(a)}|${pointKey(b)}`;
const candidate: PlanSnapSegment = {
a, b,
key: segmentKey(source, a, b),
sourceKind: source.kind,
sourceId: source.id,
};
const existing = segmentsByAxis.get(axisKey);
if (!existing || candidate.key.localeCompare(existing.key) < 0) {
segmentsByAxis.set(axisKey, candidate);
}
}
for (const point of [source.a, source.b] as const) {
if (!solids.some((solid) => touches(point, solid, epsilon))) continue;
const key = pointKey(point);
if (!endpointKeys.has(key)) endpointKeys.set(key, { point: [point[0], point[1]], key });
}
}
return {
segments: [...segmentsByAxis.values()].sort((a, b) => a.key.localeCompare(b.key)),
endpoints: [...endpointKeys.values()].sort((a, b) => a.key.localeCompare(b.key)),
};
}
function isExcluded(
point: readonly number[],
excluded: readonly (readonly number[])[],
epsilon: number,
): boolean {
return excluded.some((candidate) => finitePoint(candidate) && pointsEqual(
point, candidate, epsilon,
));
}
function better(distance: number, key: string, current: PlanSnapCandidate | null): boolean {
if (!current) return true;
const delta = distance - current.distance;
return delta < -1e-9 || (Math.abs(delta) <= 1e-9 && key.localeCompare(current.key) < 0);
}
function quantizedPoint(segment: PlanSnapSegment, pointer: readonly number[], step: number): [number, number] {
const dx = segment.b[0] - segment.a[0];
const dy = segment.b[1] - segment.a[1];
const length = Math.hypot(dx, dy);
if (!(length > 0)) return [...segment.a];
const ux = dx / length;
const uy = dy / length;
const projected = Math.max(0, Math.min(
length,
(pointer[0] - segment.a[0]) * ux + (pointer[1] - segment.a[1]) * uy,
));
const distance = step > 0
? Math.max(0, Math.min(length, Math.round(projected / step) * step))
: projected;
return [segment.a[0] + ux * distance, segment.a[1] + uy * distance];
}
/** Resolve exactly one endpoint-first or wall-bound line candidate. */
export function resolvePlanSnap(
geometry: PlanSnapGeometry,
pointer: readonly number[],
options: ResolvePlanSnapOptions,
): PlanSnapCandidate | null {
if (!finitePoint(pointer) || !(options.tolerance >= 0)) return null;
const epsilon = options.epsilon ?? DEFAULT_EPSILON;
const excluded = options.excludePoints || [];
let bestEndpoint: PlanSnapCandidate | null = null;
const endpoints: PlanSnapEndpoint[] = [
...geometry.endpoints,
...(options.extraEndpoints || [])
.filter((entry) => finitePoint(entry.point))
.map((entry): PlanSnapEndpoint => ({
point: [entry.point[0], entry.point[1]], key: entry.key,
})),
];
for (const endpoint of endpoints) {
if (isExcluded(endpoint.point, excluded, epsilon)) continue;
const distance = Math.hypot(pointer[0] - endpoint.point[0], pointer[1] - endpoint.point[1]);
if (distance > options.tolerance || !better(distance, endpoint.key, bestEndpoint)) continue;
bestEndpoint = {
kind: 'endpoint', point: [...endpoint.point], key: endpoint.key, distance,
};
}
if (bestEndpoint) return bestEndpoint;
let bestLine: PlanSnapCandidate | null = null;
for (const segment of geometry.segments) {
const line = [segment.a[0], segment.a[1], segment.b[0], segment.b[1]];
const distance = distToSegment([pointer[0], pointer[1]], line);
if (distance > options.tolerance) continue;
const point = quantizedPoint(segment, pointer, options.gridStep);
if (isExcluded(point, excluded, epsilon) || !better(distance, segment.key, bestLine)) continue;
bestLine = { kind: 'line', point, key: segment.key, distance, segment };
}
return bestLine;
}
+61
View File
@@ -1664,6 +1664,67 @@ export const cardStyles = css`
fill: #4bd28f;
stroke: #04121f;
}
.plan-snap-overlay,
.plan-snap-overlay * {
pointer-events: none;
}
.plan-snap-line {
fill: none;
stroke: color-mix(in srgb, var(--hp-accent) 82%, white 18%);
stroke-width: 1;
stroke-linecap: round;
opacity: 0.92;
}
.plan-snap-node {
fill: var(--ha-card-background, var(--card-background-color, #fff));
stroke: color-mix(in srgb, var(--hp-accent) 88%, #07131c 12%);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.plan-snap-node.active {
fill: #ffc14d;
stroke: #171006;
stroke-width: 2;
}
.plan-snap-node.active.dynamic {
fill: #4bd28f;
stroke: #04121f;
}
@media (prefers-color-scheme: dark) {
.plan-snap-line {
stroke: color-mix(in srgb, var(--hp-accent) 72%, white 28%);
}
.plan-snap-node {
fill: #17242c;
stroke: #9bdcf5;
}
.plan-snap-node.active {
fill: #ffc14d;
stroke: #fff4d6;
}
.plan-snap-node.active.dynamic {
fill: #4bd28f;
stroke: #eafff4;
}
}
@media (forced-colors: active) {
.plan-snap-line {
stroke: CanvasText;
opacity: 1;
forced-color-adjust: auto;
}
.plan-snap-node {
fill: Canvas;
stroke: CanvasText;
forced-color-adjust: auto;
}
.plan-snap-node.active,
.plan-snap-node.active.dynamic {
fill: Highlight;
stroke: HighlightText;
forced-color-adjust: auto;
}
}
.areasel,
.namein {
background: var(--hp-bg);
+8 -1
View File
@@ -60,6 +60,12 @@ test('golden matrix has stable unique ids and bounded comparison thresholds', ()
&& scenario.helpTextRegion.minPixels > 0, true, scenario.id);
assert.equal(scenario.openHelp, scenario.helpTextRegion.key, scenario.id);
}
if (scenario.planSnap) {
assert.equal(['draw', 'partition'].includes(scenario.planSnap.tool), true, scenario.id);
assert.equal(['endpoint', 'line'].includes(scenario.planSnap.expectedKind), true, scenario.id);
assert.equal(scenario.mode, 'plan', scenario.id);
assert.equal(scenario.capture, 'page', scenario.id);
}
}
});
@@ -71,7 +77,8 @@ test('golden matrix covers required geometry, rendering and adaptive surfaces',
'tray-narrow', 'opaque-glow-two-doorways', 'filled-tunnel', 'opening-placement',
'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'])
'isometric-large-warm-remount', 'split-corner-wall', 'plan-snap-endpoint',
'plan-snap-line-gaps'])
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);
+130
View File
@@ -0,0 +1,130 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildPlanSnapGeometry,
resolvePlanSnap,
} from '../test-build/plan-snap-overlay.js';
const space = (patch = {}) => ({ rooms: [], room_drafts: [], partitions: [], ...patch });
const closePoint = (actual, expected, epsilon = 1e-6) => {
assert.ok(Math.abs(actual[0] - expected[0]) <= epsilon, `${actual[0]} != ${expected[0]}`);
assert.ok(Math.abs(actual[1] - expected[1]) <= epsilon, `${actual[1]} != ${expected[1]}`);
};
test('collector includes room rectangles, polygons, saved drafts and partitions', () => {
const geometry = buildPlanSnapGeometry({
space: space({
rooms: [
{ id: 'rect', x: 0, y: 0, w: 100, h: 50 },
{ id: 'poly', poly: [[200, 0], [250, 0], [225, 50]] },
],
room_drafts: [{
id: 'saved', points: [[0, 100], [50, 100], [50, 150]], segments: [{ cm: 15 }, { cm: 15 }],
}],
partitions: [{ id: 'partition', a: [100, 100], b: [150, 100], cm: 10 }],
}),
});
assert.equal(geometry.segments.length, 10);
assert.ok(geometry.endpoints.some((entry) => entry.point[0] === 0 && entry.point[1] === 100));
assert.ok(geometry.endpoints.some((entry) => entry.point[0] === 150 && entry.point[1] === 100));
});
test('coincident endpoints and axes are deduplicated independently of direction', () => {
const geometry = buildPlanSnapGeometry({
space: space({
partitions: [
{ id: 'a', a: [0, 0], b: [100, 0], cm: 10 },
{ id: 'b', a: [100, 0], b: [0, 0], cm: 10 },
{ id: 'c', a: [100, 0], b: [100, 100], cm: 10 },
],
}),
});
assert.equal(geometry.segments.length, 2);
assert.equal(geometry.endpoints.length, 3);
});
test('active draft and degenerate inputs are excluded', () => {
const geometry = buildPlanSnapGeometry({
space: space({
room_drafts: [
{ id: 'active', points: [[0, 0], [100, 0]], segments: [{ cm: 15 }] },
{ id: 'saved', points: [[0, 10], [100, 10]], segments: [{ cm: 15 }] },
],
partitions: [{ id: 'zero', a: [20, 20], b: [20, 20], cm: 10 }],
}),
activeDraftId: 'active',
});
assert.equal(geometry.segments.length, 1);
assert.equal(geometry.segments[0].sourceId, 'saved:0');
});
test('room cuts leave solid intervals but do not create cut-boundary endpoints', () => {
const geometry = buildPlanSnapGeometry({
space: space({ rooms: [{ id: 'room', x: 0, y: 0, w: 100, h: 100 }] }),
roomCuts: [[30, 0, 70, 0], [0, 60, 0, 100]],
});
assert.ok(geometry.segments.some((segment) => segment.a[0] === 0 && segment.b[0] === 30));
assert.ok(geometry.segments.some((segment) => segment.a[0] === 70 && segment.b[0] === 100));
assert.ok(!geometry.endpoints.some((entry) => entry.point[0] === 30 && entry.point[1] === 0));
assert.ok(!geometry.endpoints.some((entry) => entry.point[0] === 70 && entry.point[1] === 0));
assert.ok(geometry.endpoints.some((entry) => entry.point[0] === 0 && entry.point[1] === 100),
'an original endpoint remains when another solid wall still meets it');
});
test('endpoint wins over a closer line and tie resolution is stable', () => {
const geometry = buildPlanSnapGeometry({
space: space({
partitions: [
{ id: 'endpoint', a: [0, 0], b: [100, 0], cm: 10 },
{ id: 'closer-line', a: [8, -100], b: [8, 100], cm: 10 },
],
}),
});
const endpoint = resolvePlanSnap(geometry, [7, 1], { tolerance: 12, gridStep: 10 });
assert.equal(endpoint?.kind, 'endpoint');
closePoint(endpoint.point, [0, 0]);
const tie = buildPlanSnapGeometry({
space: space({ partitions: [
{ id: 'right', a: [10, 0], b: [10, 100], cm: 10 },
{ id: 'left', a: [-10, 0], b: [-10, 100], cm: 10 },
] }),
});
const first = resolvePlanSnap(tie, [0, 50], { tolerance: 12, gridStep: 10 });
const second = resolvePlanSnap({
segments: [...tie.segments].reverse(), endpoints: [...tie.endpoints].reverse(),
}, [0, 50], { tolerance: 12, gridStep: 10 });
assert.equal(first?.key, second?.key);
});
test('line projection stays wall-bound and quantizes along horizontal, vertical and diagonal axes', () => {
const geometry = buildPlanSnapGeometry({
space: space({ partitions: [
{ id: 'horizontal', a: [0, 0], b: [100, 0], cm: 10 },
{ id: 'vertical', a: [200, 0], b: [200, 100], cm: 10 },
{ id: 'diagonal', a: [300, 0], b: [400, 100], cm: 10 },
] }),
});
closePoint(resolvePlanSnap(geometry, [44, 5], { tolerance: 8, gridStep: 10 }).point, [40, 0]);
closePoint(resolvePlanSnap(geometry, [205, 44], { tolerance: 8, gridStep: 10 }).point, [200, 40]);
const diagonal = resolvePlanSnap(geometry, [337, 43], { tolerance: 8, gridStep: 10 });
assert.equal(diagonal.kind, 'line');
closePoint(diagonal.point, [342.42640687119285, 42.426406871192846]);
});
test('current anchor is excluded while an explicit closure endpoint remains available', () => {
const geometry = buildPlanSnapGeometry({
space: space({ partitions: [{ id: 'wall', a: [0, 0], b: [100, 0], cm: 10 }] }),
});
assert.equal(resolvePlanSnap(geometry, [0, 0], {
tolerance: 12, gridStep: 10, excludePoints: [[0, 0]],
}), null);
const closure = resolvePlanSnap(geometry, [2, 98], {
tolerance: 12,
gridStep: 10,
excludePoints: [[100, 100]],
extraEndpoints: [{ point: [0, 100], key: 'closure' }],
});
assert.equal(closure?.kind, 'endpoint');
closePoint(closure.point, [0, 100]);
});
+1 -1
View File
@@ -29,7 +29,7 @@
"src/furniture.ts",
"src/floating-surface.ts", "src/floating-surface-controller.ts", "src/help-behavior.ts",
"src/hp-help.ts", "src/hp-dialog.ts", "src/hp-color-opacity.ts",
"src/opening-placement.ts", "src/render/opening-symbol.ts",
"src/opening-placement.ts", "src/plan-snap-overlay.ts", "src/render/opening-symbol.ts",
"src/wall-thickness.ts",
"src/physical-geometry.ts",
"src/labs.ts", "src/iso-projection.ts", "src/iso-walls.ts", "src/iso-openings.ts",