Opening rulers also while PLACING a new opening

The shoulder badges, the centre tick and the soft magnet used to live only
in the drag of an EXISTING opening. Placing a new one — the gesture where
you actually choose the spot — showed a bare dashed ghost.

One implementation now serves both: _opRuler() takes a wall snap, the
opening length and the Shift flag, returns the magnetised point plus the
badges/tick, and is called from _opPointerMove (drag), _openingPreview
(hover) and _openingClick (placement). The click therefore creates the
opening exactly where the preview showed it, and clearing _cursorPt makes
ghost, badges and tick disappear together the moment it lands.

Smoke: smoke_opening_measure gains a «PLACING a new opening» section (13 of
its checks fail on the previous build). TESTING.md: checklist row.
Shot: demo/shot_opening_place.mjs.
This commit is contained in:
houseplan dev
2026-08-03 22:14:54 +03:00
parent b6675dc3a4
commit bb4d4e1f6e
7 changed files with 243 additions and 57 deletions
File diff suppressed because one or more lines are too long
+27
View File
@@ -0,0 +1,27 @@
// Capture: the shoulder rulers + centre tick while PLACING a new opening
// (Opening tool, cursor hovering the wall centre) — owner 2026-08-03.
import { launch } from './serve.mjs';
const { page, browser } = await launch({ width: 900, height: 820 }, 2);
await page.evaluate(() => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.openings = [];
c._setMode('plan'); c._tool = 'opening';
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
});
await page.waitForTimeout(300);
const pt = await page.evaluate(() => {
const c = window.__card;
const stage = c.renderRoot.querySelector('.stage');
const r = stage.getBoundingClientRect();
const [vx, vy, vw, vh] = stage.querySelector('svg').getAttribute('viewBox').split(' ').map(Number);
return { x: r.left + ((293.7 - vx) / vw) * r.width, y: r.top + ((141 - vy) / vh) * r.height,
sx: r.left, sy: r.top, sw: r.width, sh: r.height };
});
await page.mouse.move(pt.x, pt.y, { steps: 4 });
await page.waitForTimeout(300);
await page.screenshot({ path: process.argv[2] || '/tmp/opening_place.png',
clip: { x: pt.sx, y: pt.sy, width: pt.sw, height: Math.min(pt.sh, 420) } });
await browser.close();
console.log('shot ok');
+104
View File
@@ -169,4 +169,108 @@ check('shared_wall_badges_equal', await badges(), ['1.44 m', '1.44 m']);
await page.mouse.up();
await settle();
// ---------- PLACING a new opening: the same rulers (owner 2026-08-03) -------
// Moving along a wall with the Opening tool must show exactly what dragging an
// existing opening shows: a badge on each shoulder of the WOULD-BE opening, a
// perpendicular tick + magnet at the wall centre, Shift opting out — and the
// whole lot vanishing the moment the opening is placed. Default length is
// 90 cm = 75 units, so the shoulders are measured from the edges c +- 37.5.
await page.evaluate(() => {
const c = window.__card;
const sp = c._serverCfg.spaces.find((s) => s.id === 'f1');
sp.rooms = [
{ id: 'r1', name: 'Living room', poly: [[0.04, 0.14], [0.55, 0.14], [0.55, 0.58], [0.04, 0.58]] },
{ id: 'r2', name: 'Kitchen', poly: [[0.55, 0.14], [0.96, 0.14], [0.96, 0.46], [0.55, 0.46]] },
];
sp.openings = [];
c._setMode('plan'); c._tool = 'opening';
c._cfgEpoch++; c.requestUpdate();
return c.updateComplete && true;
});
await settle();
const ghosts = () => page.evaluate(() => window.__card.renderRoot.querySelectorAll('.opghost').length);
// hover off-centre on r1's top wall (own edge 40..550, y = 140)
{
const [hx, hy] = await screenPt(460, 141);
await page.mouse.move(hx, hy, { steps: 3 });
await settle();
check('place_ghost_shown', await ghosts(), 1);
check('place_two_badges', (await badges()).length, 2);
// edges 422.5 / 497.5 -> 382.5 u (4.59 m) left, 52.5 u (0.63 m) right
const [a, b] = await nums();
check('place_badge_right_0_63', near(a, 0.63, 0.05));
check('place_badge_left_4_59', near(b, 4.59, 0.05));
check('place_no_tick_off_center', await tick(), 0);
}
// live update on the way — the badges follow the cursor
{
const [hx, hy] = await screenPt(200, 141);
await page.mouse.move(hx, hy, { steps: 3 });
await settle();
// edges 162.5 / 237.5 -> 122.5 u (1.47 m) left, 312.5 u (3.75 m) right
const [a, b] = await nums();
check('place_badge_updates_1_47', near(a, 1.47, 0.05));
check('place_badge_updates_3_75', near(b, 3.75, 0.05));
}
// near the wall centre (40+550)/2 = 295: tick appears and the magnet bites
{
const [hx, hy] = await screenPt(293.7, 141);
await page.mouse.move(hx, hy, { steps: 3 });
await settle();
check('place_tick_at_center', await tick(), 1);
// equal shoulders after the magnet: (510 - 75) / 2 = 217.5 u -> 2.61 m
check('place_badges_equal_at_center', await badges(), ['2.61 m', '2.61 m']);
const g = await page.evaluate(() => {
const el = window.__card.renderRoot.querySelector('.opcentertick');
return el && { x1: +el.getAttribute('x1'), x2: +el.getAttribute('x2'),
y1: +el.getAttribute('y1'), y2: +el.getAttribute('y2') };
});
check('place_tick_perpendicular', !!g && near(g.x1, 295, 1e-6) && near(g.x2, 295, 1e-6)
&& near(g.y2 - g.y1, 30, 1e-6));
// the ghost itself already sits on the magnetised centre
const gh = await page.evaluate(() => {
const el = window.__card.renderRoot.querySelector('.opghost');
return el && (+el.getAttribute('x1') + +el.getAttribute('x2')) / 2;
});
check('place_ghost_magnetised', near(gh, 295, 1e-6));
}
// Shift opts out of the magnet, the badges stay
{
await page.keyboard.down('Shift');
const [hx, hy] = await screenPt(293.7, 141);
await page.mouse.move(hx, hy, { steps: 3 });
await settle();
check('place_no_tick_with_shift', await tick(), 0);
check('place_badges_with_shift', (await badges()).length, 2);
const gh = await page.evaluate(() => {
const el = window.__card.renderRoot.querySelector('.opghost');
return el && (+el.getAttribute('x1') + +el.getAttribute('x2')) / 2;
});
check('place_ghost_free_with_shift', !!gh && !near(gh, 295, 1e-6) && near(gh, 293.7, 1));
await page.keyboard.up('Shift');
}
// the click places it AT THE MAGNET and clears every hint
{
const [hx, hy] = await screenPt(293.7, 141);
await page.mouse.move(hx, hy, { steps: 3 });
await settle();
await page.mouse.click(hx, hy);
await settle();
check('place_dialog_opened', await page.evaluate(() => !!window.__card._openingDialog), true);
check('place_dialog_x_magnetised', near(await page.evaluate(() => window.__card._openingDialog.x), 295, 1e-6));
check('place_badges_gone', (await badges()).length, 0);
check('place_tick_gone', await tick(), 0);
check('place_ghost_gone', await ghosts(), 0);
// …and confirming the dialog writes the opening at the magnetised point
await page.evaluate(() => { const c = window.__card; c._saveOpening(); return c.updateComplete && true; });
await settle();
check('place_committed_x_center', near(await page.evaluate(() =>
window.__card._serverCfg.spaces.find((s) => s.id === 'f1').openings[0].x), 0.295, 1e-6));
}
await finish(browser, { done: true });
File diff suppressed because one or more lines are too long
+6 -6
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -128,6 +128,13 @@ Run the *core flows* (marked ★ below) in each environment at least once per mi
perpendicular dashed tick appears and the center magnet-snaps — Shift
disables the magnet; badges and tick vanish on release
[auto: smoke_opening_measure + unit openingShoulders]
- [ ] The SAME rulers while PLACING a new opening (2026-08-03): with the
Opening tool, moving along a wall shows the dashed ghost together with a
badge on each shoulder of the would-be opening (default 90 cm, measured
on the snapped room's OWN edge), a perpendicular tick + magnet at that
edge's centre, Shift opting out; the click places the opening at the
magnetised point and ghost, badges and tick all disappear at once
[auto: smoke_opening_measure, the «PLACING a new opening» section]
## Onboarding ★
+87 -39
View File
@@ -160,6 +160,17 @@ const capturePointer = (ev: PointerEvent): void => {
}
};
/** Ruler badges on both shoulders of an opening + the centre-magnet tick.
* The same shape serves the DRAG of an existing opening and the PLACEMENT
* preview of a new one (owner 2026-08-03). */
/** Default length of a freshly placed opening, cm (the dialog's door preset). */
const OPENING_DEFAULT_CM = 90;
interface OpMeasure {
labels: { x: number; y: number; text: string }[];
guide: { x: number; y: number; angle: number } | null;
}
class HouseplanCard extends LitElement {
public hass?: any;
private _config?: CardConfig;
@@ -293,10 +304,10 @@ class HouseplanCard extends LitElement {
private _openingInfo: OpeningCfg | null = null;
private _opDrag: { id: string; moved: boolean; sx: number; sy: number; dirty: boolean } | null = null;
// live ruler badges + the "centered on the wall" tick while an opening is dragged
private _opMeasure: {
labels: { x: number; y: number; text: string }[];
guide: { x: number; y: number; angle: number } | null;
} | null = null;
private _opMeasure: OpMeasure | null = null;
/** Shift during the PLACEMENT hover: opts out of the centre magnet, exactly
* as it does while dragging an existing opening. */
private _opShift = false;
private _mergeDialog: { aId: string; bId: string; poly: number[][]; pick: 'a' | 'b' } | null = null;
private _splitSel: { roomId: string; pts: number[][] } | null = null; // room being cut + the cut path so far
// a split is applied only when the new room's dialog is confirmed — cancel leaves the room intact
@@ -2525,7 +2536,7 @@ class HouseplanCard extends LitElement {
return;
}
if (this._tool === 'opening') {
this._openingClick(raw);
this._openingClick(raw, ev.shiftKey);
return;
}
if (this._tool === 'merge') {
@@ -3249,7 +3260,7 @@ class HouseplanCard extends LitElement {
}
/** Opening tool: click an existing opening to edit it, or a wall to place one. */
private _openingClick(raw: number[]): void {
private _openingClick(raw: number[], shift = false): void {
const eps = this._gridPitch * 1.5;
const hit = this._openingsR.find(
(o) => Math.hypot(raw[0] - o.rx, raw[1] - o.ry) <= Math.max(o.rlen / 2, eps),
@@ -3263,11 +3274,15 @@ class HouseplanCard extends LitElement {
this._showToast(this._t('toast.opening_no_wall'));
return;
}
// the opening is born where the PREVIEW showed it — magnet included
const place = this._opRuler(snap, this._cmToUnits(OPENING_DEFAULT_CM), shift);
this._openingDialog = {
type: 'door', lengthCm: 90, contact: '', lock: '',
type: 'door', lengthCm: OPENING_DEFAULT_CM, contact: '', lock: '',
invert: false, flipH: false, flipV: false,
x: snap.x, y: snap.y, angle: snap.angle,
x: place.x, y: place.y, angle: place.angle,
};
// rulers, tick and ghost live only through the placement gesture
this._cursorPt = null;
}
/** Open the properties dialog for an existing opening. */
@@ -3317,29 +3332,11 @@ class HouseplanCard extends LitElement {
const cfg = sp?.openings?.find((x: OpeningCfg) => x.id === o.id);
if (!cfg) return;
// ruler badges on both shoulders + soft magnet to the wall's center
// (owner 2026-08-03); tol = half a grid step, Shift opts out of the magnet
// (same convention as the coarse-angle Shift elsewhere in the editor)
const rooms = this._spaceModel().rooms;
const tol = this._gridPitch / 2;
let cx = snap.x, cy = snap.y;
let sh = openingShoulders([cx, cy], snap.angle, cfg.length * NORM_W, rooms, tol);
if (sh && sh.centered && !ev.shiftKey && (cx !== sh.wallCenter[0] || cy !== sh.wallCenter[1])) {
[cx, cy] = sh.wallCenter;
sh = openingShoulders([cx, cy], snap.angle, cfg.length * NORM_W, rooms, tol);
}
if (sh) {
const imperial = this.hass?.config?.unit_system?.length === 'mi';
const lbl = (d: number, m: number[]) =>
({ x: m[0], y: m[1], text: formatLength((d / this._gridPitch) * this._cellCm, imperial) });
this._opMeasure = {
labels: [lbl(sh.sideA, sh.midA), lbl(sh.sideB, sh.midB)],
guide: sh.centered && !ev.shiftKey
? { x: sh.wallCenter[0], y: sh.wallCenter[1], angle: snap.angle }
: null,
};
} else this._opMeasure = null;
const nx = cx / NORM_W;
const ny = cy / this._spaceH;
// (owner 2026-08-03) — the very same helper the PLACEMENT preview uses
const r = this._opRuler(snap, cfg.length * NORM_W, ev.shiftKey);
this._opMeasure = r.measure;
const nx = r.x / NORM_W;
const ny = r.y / this._spaceH;
if (cfg.x !== nx || cfg.y !== ny || cfg.angle !== snap.angle) this._opDrag.dirty = true;
cfg.x = nx;
cfg.y = ny;
@@ -3347,6 +3344,42 @@ class HouseplanCard extends LitElement {
this.requestUpdate();
}
/**
* Shoulder rulers + the soft centre magnet for an opening of `rlen` sitting
* at a wall snap. ONE implementation for both gestures the owner asked to
* behave alike (2026-08-03): dragging an existing opening and placing a new
* one. `tol` is half a grid step and Shift opts out of the magnet — the same
* convention as the coarse-angle Shift elsewhere in the editor. The returned
* x/y are ALREADY magnetised, so the caller just writes them.
*/
private _opRuler(
snap: { x: number; y: number; angle: number },
rlen: number,
shift: boolean,
): { x: number; y: number; angle: number; measure: OpMeasure | null } {
const rooms = this._spaceModel().rooms;
const tol = this._gridPitch / 2;
let cx = snap.x, cy = snap.y;
let sh = openingShoulders([cx, cy], snap.angle, rlen, rooms, tol);
if (sh && sh.centered && !shift && (cx !== sh.wallCenter[0] || cy !== sh.wallCenter[1])) {
[cx, cy] = sh.wallCenter;
sh = openingShoulders([cx, cy], snap.angle, rlen, rooms, tol);
}
if (!sh) return { x: cx, y: cy, angle: snap.angle, measure: null };
const imperial = this.hass?.config?.unit_system?.length === 'mi';
const lbl = (d: number, m: number[]) =>
({ x: m[0], y: m[1], text: formatLength((d / this._gridPitch) * this._cellCm, imperial) });
return {
x: cx, y: cy, angle: snap.angle,
measure: {
labels: [lbl(sh.sideA, sh.midA), lbl(sh.sideB, sh.midB)],
guide: sh.centered && !shift
? { x: sh.wallCenter[0], y: sh.wallCenter[1], angle: snap.angle }
: null,
},
};
}
private _opPointerUp(ev: PointerEvent, o: OpeningCfg): void {
if (!this._opDrag || this._opDrag.id !== o.id) return;
const moved = this._opDrag.moved;
@@ -3550,6 +3583,7 @@ class HouseplanCard extends LitElement {
if (!this._markup) return;
if (this._tool === 'opening' || this._tool === 'openwall') {
// hover preview: raw cursor point; snapping happens in the preview getters
this._opShift = !!ev.shiftKey; // Shift opts out of the centre magnet
this._cursorPt = this._svgPoint(ev);
return;
}
@@ -3559,8 +3593,13 @@ class HouseplanCard extends LitElement {
this._cursorPt = this._snap(this._svgPoint(ev));
}
/** Dashed hover preview of an opening: same snap and default length as the click. */
private get _openingPreview(): { x: number; y: number; angle: number; rlen: number } | null {
/**
* Dashed hover preview of an opening: same snap, same default length and —
* since 2026-08-03 — the same shoulder rulers and centre magnet as a drag.
* Pure: it writes nothing, the render just reads it.
*/
private get _openingPreview():
{ x: number; y: number; angle: number; rlen: number; measure: OpMeasure | null } | null {
if (this._tool !== 'opening' || !this._cursorPt) return null;
const raw = this._cursorPt;
// an existing opening under the cursor will be edited, not added — no preview
@@ -3571,7 +3610,15 @@ class HouseplanCard extends LitElement {
if (hit) return null;
const snap = snapToWall(raw, this._spaceModel().rooms, eps);
if (!snap) return null;
return { ...snap, rlen: this._cmToUnits(90) };
const rlen = this._cmToUnits(OPENING_DEFAULT_CM);
const r = this._opRuler(snap, rlen, this._opShift);
return { x: r.x, y: r.y, angle: r.angle, rlen, measure: r.measure };
}
/** The rulers to draw right now: from the DRAG of an existing opening, or
* from the PLACEMENT preview of a new one — identical badges either way. */
private get _opMeasureView(): OpMeasure | null {
return this._opMeasure || this._openingPreview?.measure || null;
}
/** Save a room with a mandatory binding to an HA area. */
@@ -5229,6 +5276,8 @@ class HouseplanCard extends LitElement {
// day/night breathing: armed only with a compass AND sun.sun (docs/SUN.md)
const dayNight = !this._editing && this._effBgMode() === 'daynight' ? this._sunNow() : null;
const planDim = dayNight ? dayPhase(dayNight.elevation).planDim : 0;
// opening rulers: the drag of an existing one OR the placement preview
const opMeasure = this._opMeasureView;
return html`
<ha-card>
@@ -5431,7 +5480,7 @@ class HouseplanCard extends LitElement {
${this._renderSunRays(space)}
${this._renderOpenWalls(disp)}
${this._editing ? this._renderAlignGuides() : nothing}
${this._opMeasure?.guide ? this._renderOpeningCenterTick() : nothing}
${opMeasure?.guide ? this._renderOpeningCenterTick(opMeasure.guide) : nothing}
${this._markup ? this._renderMarkupLayer(vb) : nothing}
${this._renderOpenings(disp)}
${this._markup && this._tool === 'resize' ? this._renderResizeLayer(view) : nothing}
@@ -5454,8 +5503,8 @@ class HouseplanCard extends LitElement {
class="measurelabel ${l.area ? 'rszarea' : ''}"
style="left:${(((l.x - view.x) / view.w) * 100).toFixed(2)}%;top:${(((l.y - view.y) / view.h) * 100).toFixed(2)}%">${l.text}</div>`)}</div>`
: nothing}
${this._opMeasure
? html`<div class="measurelayer">${this._opMeasure.labels.map((l) => html`<div
${opMeasure
? html`<div class="measurelayer">${opMeasure.labels.map((l) => html`<div
class="measurelabel opshoulder"
style="left:${(((l.x - view.x) / view.w) * 100).toFixed(2)}%;top:${(((l.y - view.y) / view.h) * 100).toFixed(2)}%">${l.text}</div>`)}</div>`
: nothing}
@@ -6584,8 +6633,7 @@ class HouseplanCard extends LitElement {
/** Perpendicular dashed tick through the wall's center while a dragged opening
* sits exactly in the middle — same look as the alignment guides. Length is
* about the wall stroke (2.5) × 6 to each side. */
private _renderOpeningCenterTick(): TemplateResult {
const gd = this._opMeasure!.guide!;
private _renderOpeningCenterTick(gd: { x: number; y: number; angle: number }): TemplateResult {
const rad = ((gd.angle + 90) * Math.PI) / 180;
const half = 2.5 * 6;
return svg`<line class="alignline opcentertick"