feat: add architectural snap overlay

Issue: #137
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-14 08:27:45 +00:00
committed by claude[bot]
parent 5bebc26aeb
commit 6c48c6d5c6
22 changed files with 2756 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 до касания не показывается.
Каждый законченный отрезок незамкнутого контура сохраняется сразу. В той же
сессии инструмент продолжит его автоматически; после перезагрузки выберите
кликом один из концов. Замкнутый контур можно сохранить как комнату или выбрать
+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",