fix: keep the active chain axis and nodes above the wall bodies (#307)

Каждый клик персистит сегмент цепочки в room_drafts, и его непрозрачная
кладка рисовалась поверх markup-слоя — жёлтая ось и узлы исчезали на уже
поставленных частях. Разметка активной цепочки вынесена в свой слой между
телами стен и снап-оверлеем: ось и узлы видны, снап-геометрия не тронута,
самопривязка по-прежнему запрещена. Смок с пиксельными пробами падает на
прежнем порядке слоёв.

Issue: #307
User-Visible: yes
This commit is contained in:
Codex
2026-08-25 16:53:09 +00:00
committed by claude[bot]
parent 729e2d05af
commit 1134345444
7 changed files with 211 additions and 39 deletions
File diff suppressed because one or more lines are too long
+130
View File
@@ -0,0 +1,130 @@
/**
* Issue #307: the active wall-chain axis and nodes must stay visible on
* already-placed segments. Every click persists the segment into room_drafts,
* whose opaque masonry paints above the markup layer; the chain ink therefore
* lives in its own layer between the wall bodies and the snap overlay.
*/
import { launch, check, 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 NORM_W = 1000;
const chain = [[0.2, 0.3], [0.5, 0.3], [0.5, 0.55]];
const cfg = {
spaces: [{
id: 'ink', title: 'Ink', cell_cm: 5, view_box: [0, 0, 1, 0.7],
rooms: [{ id: 'room', name: 'Room', area: null,
poly: [[0.1, 0.1], [0.9, 0.1], [0.9, 0.25], [0.1, 0.25]] }],
room_drafts: [{ id: 'draft-307', points: chain,
segments: [{ cm: 20 }, { cm: 20 }] }],
}],
markers: [], settings: {},
};
card._serverCfg = JSON.parse(JSON.stringify(cfg));
card._layout = {};
card._space = 'ink';
card._modelCache = null;
card._frame = null;
card._cfgEpoch++;
card._setMode('plan');
card._tool = 'draw';
card._path = chain.map((p) => [p[0] * NORM_W, p[1] * NORM_W]);
card._activeDraftId = 'draft-307';
card._draftSegmentCms = [20, 20];
card._clearPlanSnapHover();
await update();
const stage = root().querySelector('.stage');
const rect = stage.getBoundingClientRect();
const view = card._viewOr(card._baseVb());
stage.dispatchEvent(new PointerEvent('pointermove', {
clientX: rect.left + ((0.75 * NORM_W - view.x) / view.w) * rect.width,
clientY: rect.top + ((0.55 * NORM_W - view.y) / view.h) * rect.height,
bubbles: true, pointerId: 71, pointerType: 'mouse',
}));
await update();
const pathline = root().querySelector('polyline.pathline');
const vertices = [...root().querySelectorAll('circle.vertex')];
const bodies = [...root().querySelectorAll('.wallbody-fill')];
const overlay = root().querySelector('[data-hp="plan-snap-overlay"]');
result.pathlinePresent = !!pathline;
result.vertexCount = vertices.length === 3;
result.rubberBandAxis = !!root().querySelector('line.active-axis');
result.rubberBandVertex = !!root().querySelector('circle.active-vertex');
// AC2: document order — wall bodies → chain ink → snap overlay.
const after = (a, b) => !!(a && b
&& (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING));
result.draftHasBody = bodies.length > 0;
result.inkAboveBodies = bodies.every((body) => after(body, pathline));
result.inkBelowSnapOverlay = after(pathline, overlay);
// Pixel probes read the composited stage: the persisted segment's midpoint
// must show the yellow axis, the shared chain node must show a node dot.
return result;
});
// AC1: pixel colours at the persisted segment's axis midpoint and chain node.
// The probes are mapped to CSS right before the shot: the draw toolbar mounts
// above the stage and shifts the SVG after the first render.
const probeInfo = await page.evaluate(async () => {
const card = window.__card;
const root = card.shadowRoot || card.renderRoot;
// The stage camera keeps animating after the first render; wait until the
// screen matrix is stable so the probes match the screenshot that follows.
const svgEl = root.querySelector('polyline.pathline').ownerSVGElement;
let last = '';
for (let i = 0; i < 40; i++) {
await new Promise((r) => setTimeout(r, 100));
const m = svgEl.getScreenCTM();
const key = [m.a, m.b, m.c, m.d, m.e, m.f].join(',');
if (key === last) break;
last = key;
}
const stage = root.querySelector('.stage');
const rect = stage.getBoundingClientRect();
const pathline = root.querySelector('polyline.pathline');
const svg = pathline.ownerSVGElement;
const toCss = ([ux, uy]) => {
const point = new DOMPoint(ux, uy).matrixTransform(svg.getScreenCTM());
return [point.x - rect.left, point.y - rect.top];
};
return {
probes: { axisMid: toCss([350, 300]), node: toCss([500, 300]) },
rect: { width: rect.width, height: rect.height },
};
});
const shot = await page.locator('.stage').first().screenshot({ animations: 'disabled' });
const pixels = await page.evaluate(async ([bytes, info]) => {
const bitmap = await createImageBitmap(new Blob([new Uint8Array(bytes)], { type: 'image/png' }));
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
const scaleX = bitmap.width / info.rect.width;
const scaleY = bitmap.height / info.rect.height;
const at = ([cx, cy]) => {
const d = ctx.getImageData(Math.round(cx * scaleX), Math.round(cy * scaleY), 1, 1).data;
return [d[0], d[1], d[2]];
};
const yellowish = ([r, g, b]) => r > 200 && g > 130 && g < 220 && b < 120;
// The node dot is tiny at this zoom: its centre may land on the dark rim
// (#4a2800) instead of the yellow fill. Warm ink of either kind passes;
// masonry (cold white) and the background (blue-tinted) do not.
const warmInk = ([r, g, b]) => r > 110 && r > g && g > b && b < 130;
return {
axisMidYellow: yellowish(at(info.probes.axisMid)),
nodeYellow: warmInk(at(info.probes.node)),
axisMidRgb: at(info.probes.axisMid),
nodeRgb: at(info.probes.node),
};
}, [[...shot], probeInfo]);
checkAll(out);
check('axisMidYellow', pixels.axisMidYellow, true);
check('nodeYellow', pixels.nodeYellow, true);
console.log('pixel probes:', JSON.stringify(pixels));
await finish(browser);
+12 -8
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -2,6 +2,12 @@
## Unreleased
- The active wall chain keeps its axis and node markers visible on already
placed segments while drawing. The chain ink now paints above the wall
masonry (and below the snap overlay), so each click no longer buries the
yellow axis under the freshly persisted wall body
([#307](https://github.com/Matysh/houseplan-card/issues/307)).
## v1.67.0-rc.3 — 2026-08-25
- Wall junctions no longer leave white wedges and notches. A node where three
+6
View File
@@ -8,6 +8,12 @@
## Не выпущено
- Во время рисования цепочки стен осевая линия и узлы остаются видимыми на уже
поставленных сегментах. Разметка цепочки теперь рисуется поверх кладки стен
(и под снап-оверлеем), поэтому каждый клик больше не прячет жёлтую ось под
только что записанным телом стены
([#307](https://github.com/Matysh/houseplan-card/issues/307)).
## v1.67.0-rc.3 — 2026-08-25
- Стыки стен больше не оставляют белых клиньев и зазубрин. Узел, где сходятся
+11 -11
View File
@@ -2,7 +2,7 @@
"version": 1,
"fixture": "synthetic-only",
"chromium": "151.0.7922.34",
"sourceFingerprint": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
"sourceFingerprint": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
"captureScriptSha256": "ce2e9542fed9dade3085be87d16f69adb2ac8262893ad78ad966b1b9673f2983",
"command": "npm run build && node demo/docs/capture.mjs",
"scenarios": {
@@ -14,7 +14,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
"imageSha256": "0ca982495a7749907c48ebbddfd089c54385a4347f79f64e94af2c5a8d5c03eb"
},
"view-touch": {
@@ -25,7 +25,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
"imageSha256": "1c8e28ff34736b936ba7c9ec047d1da27aaf6fbb01411155f2d5c05282c02c41"
},
"space-create": {
@@ -36,7 +36,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
"imageSha256": "329750c81a7a958c4fd105b2c80f85dce55ed87df9ddf0ea08084e4428e85f04"
},
"room-contour-close": {
@@ -47,7 +47,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
"imageSha256": "ff2e903e548423aa0aa52680e20b8ad951c704e01130cb4b180222015a241276"
},
"plan-context-tray": {
@@ -58,7 +58,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
"imageSha256": "ff55925f079411023442e7e7dd5ed528c331aaa3fd3fc2fbbf3b1aaf34a85718"
},
"device-editor": {
@@ -69,7 +69,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
"imageSha256": "6b9cd281450e7782cff292f11efc58d227bedd0df86afc3be987feb2edec9dc3"
},
"device-display-preview": {
@@ -80,7 +80,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
"imageSha256": "6f628abe6b89d7c4b352e6d73ac0b126526f6fdba5927bb774c5b884e484ca19"
},
"background-editor": {
@@ -91,7 +91,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
"imageSha256": "a1d1a905f1410f73231d2ea5d1dfbd8ae79c2fe8b0a79cff87ebb977ac7a3103"
},
"room-card": {
@@ -102,7 +102,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
"imageSha256": "48a5685039a5e0de2a28190d857b193c86e2e63606e3c7a8c40fc9d2b190da4e"
},
"device-info": {
@@ -113,7 +113,7 @@
},
"theme": "dark",
"language": "en",
"sourceSha256": "fd3fbbc336f92644cfeff177f4d36c9b16cd427ec91977e40432ccd54b735c93",
"sourceSha256": "e9ccc3f9aecc3ce4de775233b9e31aedca19c8a474d9839d96a82ebd79d0f272",
"imageSha256": "29a342811aaedaebc54ba337792d60cb454a7cf4c3063b210f6c4bafe0a4d87b"
}
}
+34 -12
View File
@@ -17578,6 +17578,12 @@ class HouseplanCard extends LitElement {
${''/* Static architectural axes and endpoints are a Plan-editor
invariant. Tool-specific hover/snap state is resolved inside
the overlay and remains exclusive to Walls. */}
${''/* Active chain ink paints above the wall bodies: persisted
draft segments already own opaque masonry, which would
otherwise cover the chain axis and nodes (#307). It stays
below the snap overlay so snap highlights keep priority. */}
${this._markup ? svg`<g class="hp-editor-only-layer"
opacity="${modeVisual?.editorWeight ?? 1}">${this._renderActiveChainInk()}</g>` : nothing}
${this._markup ? svg`<g class="hp-editor-only-layer"
opacity="${modeVisual?.editorWeight ?? 1}">${this._renderPlanSnapOverlay()}</g>` : nothing}
${disp.hideOpenings && !this._markup
@@ -20025,18 +20031,6 @@ class HouseplanCard extends LitElement {
x1=${repairPreview.from[0]} y1=${repairPreview.from[1]}
x2=${repairPreview.to[0]} y2=${repairPreview.to[1]}
aria-hidden="true" pointer-events="none"></line>` : nothing}
${path.length > 1
? svg`<polyline class="pathline" points="${path.map((p) => p.join(',')).join(' ')}"></polyline>`
: nothing}
${path.length && this._cursorPt && this._tool === 'draw' && !this._contourClosed
? svg`<line class="active-axis" x1="${path[path.length - 1][0]}" y1="${path[path.length - 1][1]}"
x2="${this._cursorPt[0]}" y2="${this._cursorPt[1]}" aria-hidden="true"></line>
${!this._activePlanSnapCandidate && !this._activePlanSnapConflicts.length
? svg`<circle class="active-vertex" cx="${this._cursorPt[0]}" cy="${this._cursorPt[1]}"
r="${gridVisualUnits(g * 0.22, this._cellCm)}" aria-hidden="true"></circle>` : nothing}`
: nothing}
${path.map((p, i) => svg`<circle class="vertex ${i === 0 ? 'first' : ''}"
cx="${p[0]}" cy="${p[1]}" r="${gridVisualUnits(g * 0.22, this._cellCm)}"></circle>`)}
${this._tool === 'split' && this._splitSel?.pts?.length
? svg`${this._splitSel.pts.length > 1
? svg`<polyline class="pathline" points="${this._splitSel.pts.map((p) => p.join(',')).join(' ')}"></polyline>`
@@ -20051,6 +20045,34 @@ class HouseplanCard extends LitElement {
`;
}
/**
* Axis and node ink of the active wall/room chain. Every click persists the
* segment into `room_drafts`, whose opaque masonry paints ABOVE the markup
* layer, so drawing this ink inside `_renderMarkupLayer` left already-placed
* segments looking like bare walls (#307). The chain ink therefore paints in
* its own layer after the wall bodies and before the snap overlay: yellow
* "work in progress" styling stays distinct from finished walls, and the
* snap geometry keeps excluding the active draft (no self-snapping).
*/
private _renderActiveChainInk(): TemplateResult {
const path = this._path;
const g = this._gridPitch;
return svg`
${path.length > 1
? svg`<polyline class="pathline" points="${path.map((p) => p.join(',')).join(' ')}"></polyline>`
: nothing}
${path.length && this._cursorPt && this._tool === 'draw' && !this._contourClosed
? svg`<line class="active-axis" x1="${path[path.length - 1][0]}" y1="${path[path.length - 1][1]}"
x2="${this._cursorPt[0]}" y2="${this._cursorPt[1]}" aria-hidden="true"></line>
${!this._activePlanSnapCandidate && !this._activePlanSnapConflicts.length
? svg`<circle class="active-vertex" cx="${this._cursorPt[0]}" cy="${this._cursorPt[1]}"
r="${gridVisualUnits(g * 0.22, this._cellCm)}" aria-hidden="true"></circle>` : nothing}`
: nothing}
${path.map((p, i) => svg`<circle class="vertex ${i === 0 ? 'first' : ''}"
cx="${p[0]}" cy="${p[1]}" r="${gridVisualUnits(g * 0.22, this._cellCm)}"></circle>`)}
` as unknown as TemplateResult;
}
private _renderPartitionDeleteDialog(): TemplateResult {
const dialog = this._partitionDeleteDialog!;
const imperial = this.hass?.config?.unit_system?.length === 'mi';