mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 11:18:48 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3477a19a32 | ||
|
|
cde9bfac09 | ||
|
|
b6e46c9220 | ||
|
|
0ce28b036f |
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
+530
-450
File diff suppressed because one or more lines are too long
Vendored
+530
-450
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 и киоска теперь сразу показывают полный
|
||||
|
||||
@@ -267,6 +267,14 @@ desktop: для точного рисования, Resize, модификато
|
||||
|
||||
Во время рисования показываются длина сегмента и угол. Углы, кратные 45°, выделяются. Удерживайте `Shift`, чтобы зафиксировать текущий отрезок по ближайшему такому направлению; предпросмотр и поставленная точка останутся на сетке. Новая комната не может частично перекрывать другую, но полностью вложенная «островная» комната поддерживается.
|
||||
|
||||
При работе инструментами **Контур комнаты** и **Перегородка** поверх уже
|
||||
нарисованных стен видны тонкие осевые линии и точки их концов. Увеличенная
|
||||
точка показывает точное место следующего соединения: конец стены имеет
|
||||
приоритет, а увеличенная точка посреди линии создаёт T-соединение, не разрезая
|
||||
существующую стену. В проёмах соединительной линии нет. Для точного наведения и
|
||||
предпросмотра рекомендуется редактор на компьютере; tap на сенсорном экране
|
||||
тоже выполняет привязку, но отдельный hover до касания не показывается.
|
||||
|
||||
Каждый законченный отрезок незамкнутого контура сохраняется сразу. В той же
|
||||
сессии инструмент продолжит его автоматически; после перезагрузки выберите
|
||||
кликом один из концов. Замкнутый контур можно сохранить как комнату или выбрать
|
||||
|
||||
@@ -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 не требуется.
|
||||
@@ -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 не проговорена явно) — ни одна
|
||||
не блокирует приёмку, все либо правятся косметически, либо снимаются с этой
|
||||
записью на усмотрение автора без нового цикла.
|
||||
@@ -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.
|
||||
@@ -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) |
|
||||
|
||||
## Правило актуализации
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user