mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
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:
File diff suppressed because one or more lines are too long
@@ -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');
|
||||
@@ -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
Vendored
+6
-6
File diff suppressed because one or more lines are too long
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user