feat: preview furniture placement

Issue: #359
User-Visible: yes
This commit is contained in:
Matysh
2026-08-29 00:34:06 +03:00
parent 3c281bb12c
commit 8b66d67d3d
29 changed files with 942 additions and 493 deletions
+65 -1
View File
@@ -28,7 +28,7 @@
* colour, so a plan stays a drawing (owner: «символы рисуются линейно»).
*/
import { NORM_W, GRID_PITCH, CANVAS_LIMIT } from './space-geometry';
import { NORM_W, GRID_PITCH, CANVAS_LIMIT, clampCanvasN } from './space-geometry';
import { GENERATED_FURNITURE_PLAN } from './furniture-plan-art.generated';
/** Groups the palette shows, in the order it shows them. */
@@ -395,6 +395,33 @@ export interface FurnitureSnap {
/** Wall segments (`[x1,y1,x2,y2]`) in whatever units the caller works in. */
export type WallSeg = number[];
/** Final normalised box shared by the placement ghost and the saved decor
* record. Keeping this result pure prevents a preview/commit jump when wall
* magnetism or the canvas guard participates (#359). */
export interface FurniturePlacement {
symbol: string;
x: number;
y: number;
w: number;
h: number;
angle: number;
}
export interface FurniturePlacementInput {
symbol: string;
widthCm: number;
depthCm: number;
/** Already resolved decor/grid snap point, in render units. */
point: readonly [number, number];
canvasW: number;
canvasH: number;
cellCm: number;
gridPitch: number;
walls: WallSeg[];
wallReach: number;
free?: boolean;
}
const norm180 = (a: number): number => {
let x = ((a % 360) + 360) % 360;
if (x > 180) x -= 360;
@@ -458,6 +485,43 @@ export function snapFurnitureToWall(
return best;
}
/**
* Resolve the exact geometry one furniture stamp will own.
*
* The caller owns the higher-level decor/room/grid snap because that geometry
* is editor state. Everything after that point is pure and therefore shared
* byte-for-byte by hover preview and commit. Unknown symbols fail dark: a
* newer config or a stale palette cannot create invisible furniture.
*/
export function resolveFurniturePlacement(input: FurniturePlacementInput): FurniturePlacement | null {
const {
symbol, widthCm, depthCm, point, canvasW, canvasH, cellCm, gridPitch,
walls, wallReach, free = false,
} = input;
if (!furnitureGraphic(symbol) || !(canvasW > 0) || !(canvasH > 0)
|| !Number.isFinite(point[0]) || !Number.isFinite(point[1])) return null;
const w = clampFurnSize(cmToNorm(widthCm, cellCm, gridPitch, canvasW));
const h = clampFurnSize(cmToNorm(depthCm, cellCm, gridPitch, canvasW));
let cx = point[0], cy = point[1];
let angle = 0;
const snap = free ? null : snapFurnitureToWall(
cx, cy, h * canvasH, walls, wallReach, gridPitch,
);
if (snap) {
cx = snap.cx;
cy = snap.cy;
angle = snap.angle;
}
return {
symbol,
x: clampCanvasN(cx / canvasW - w / 2),
y: clampCanvasN(cy / canvasH - h / 2),
w,
h,
angle: Number(angle.toFixed(2)),
};
}
/**
* The four corners of a rotated box, world units, in the order the frame
* draws them: NW, NE, SE, SW. Pure, so the frame, the gestures and the tests
+53 -3
View File
@@ -63,7 +63,8 @@ import { dayCycleStageVars, renderDayCycleEnvironment } from './day-cycle-render
import {
FURNITURE_GROUPS, furnitureOfGroup, furnitureSymbol, furnitureDefaultCm,
furnitureGraphic, furnitureCorners, snapFurnitureToWall,
cmToNorm, clampFurnSize, clampFurnCm, FURN_WALL_CELLS, type FurnitureGroup,
cmToNorm, clampFurnSize, clampFurnCm, FURN_WALL_CELLS,
type FurnitureGroup, type FurniturePlacement,
} from './furniture';
import {
degradeWalls, rekeyWallsAfterMoveChecked, wallRecordCarrierViolations,
@@ -1037,6 +1038,15 @@ export class HouseplanCard extends LitElement {
private _furnPalette: { symbol: string; w: number; h: number } | null = null;
/** Category currently open in the two-level furniture palette. */
private _furnCategory: string | null = null;
/** Last fine-pointer location while one furniture stamp is armed. The final
* geometry is deliberately resolved on render from the live palette so a
* Width/Depth edit updates the ghost without another mouse move (#359). */
private _furnPreviewInput: { raw: [number, number]; free: boolean } | null = null;
/** Touch/pen has no hover. Delay its one-shot stamp until a clean pointerup
* so pointercancel or a second contact cannot save accidental geometry. */
private _furnTouchPending: {
pid: number; sx: number; sy: number; pointerType: string; cancelled: boolean;
} | null = null;
/** The selected shape's own unrotated frame (text is measured from SVG). */
private _dtBox: { id: string; x: number; y: number; w: number; h: number } | null = null;
/**
@@ -1437,6 +1447,7 @@ export class HouseplanCard extends LitElement {
if (id !== this._space) {
this._clearTransientHover(true);
this._cancelDevicePressFeedback();
this._editorRuntime?._clearFurniturePreview();
}
this._space = id;
return true;
@@ -2431,6 +2442,7 @@ export class HouseplanCard extends LitElement {
_backdropDialog: { state: true },
_furnPalette: { state: true },
_furnCategory: { state: true },
_furnPreviewInput: { state: true },
_bdDrag: { state: true },
_dtBox: { state: true },
_dtDrag: { state: true },
@@ -2676,6 +2688,7 @@ export class HouseplanCard extends LitElement {
this._warmRelease();
this._clearPlanSnapHover();
this._clearOpeningPlacement(true);
this._editorRuntime?._clearFurniturePreview();
this._touchContacts.clear();
this._touchSequenceMultitouch = false;
this._touchClickBlockUntil = 0;
@@ -2781,6 +2794,7 @@ export class HouseplanCard extends LitElement {
// The palette is one explicit surface: Escape closes it and returns
// to Select in one step, regardless of whether a symbol was armed.
else if (this._decorTool === 'furniture') {
this._editorRuntime?._clearFurniturePreview();
this._furnPalette = null;
this._furnCategory = null;
this._decorTool = 'select';
@@ -6156,6 +6170,10 @@ export class HouseplanCard extends LitElement {
this._decorMoveUpdate(ev);
return;
}
if (this._mode === 'decor' && this._decorTool === 'furniture' && this._editorRuntime) {
this._notePointer(ev);
if (this._editorRuntime._furnPointerMove(ev, this._pointerModality.hoverEnabled)) return;
}
if (!this._pointers.has(ev.pointerId)) {
this._markupMove(ev);
return;
@@ -6220,7 +6238,8 @@ export class HouseplanCard extends LitElement {
}
}
private _stagePointerLeave(_ev: PointerEvent): void {
private _stagePointerLeave(ev: PointerEvent): void {
if (this._mode === 'decor') this._editorRuntime?._furnPointerLeave(ev);
if (!this._markup) return;
if (this._tool === 'opening') {
this._cursorPt = null;
@@ -6297,6 +6316,7 @@ export class HouseplanCard extends LitElement {
this._decorMove = null;
return;
}
if (this._editorRuntime?._furnPointerUp(ev)) return;
const viewportGestureEnded = !!this._pinchStart || !!this._panStart;
this._pointers.delete(ev.pointerId);
if (this._pointers.size < 2) this._pinchStart = null;
@@ -8018,6 +8038,36 @@ export class HouseplanCard extends LitElement {
</g>` as unknown as TemplateResult;
}
private get _furniturePreviewPlacement(): FurniturePlacement | null {
const input = this._furnPreviewInput;
if (!input || !this._editorRuntime || this._mode !== 'decor'
|| this._decorTool !== 'furniture' || !this._furnPalette
|| !this._pointerModality.hoverEnabled) return null;
return this._editorRuntime._resolveFurniturePlacement(input.raw, input.free, 'mouse');
}
/** One real furniture path, in the same decor composition group as saved
* shapes. It paints after them (so it is visible) and the whole decor layer
* still paints below physical walls (#359). */
private _renderFurniturePlacementPreview(): TemplateResult | typeof nothing {
const placement = this._furniturePreviewPlacement;
if (!placement) return nothing;
const art = furnitureGraphic(placement.symbol);
if (!art) return nothing;
const W = NORM_W, H = this._decorH;
const W2 = placement.w * W, H2 = placement.h * H;
const cx = placement.x * W + W2 / 2, cy = placement.y * H + H2 / 2;
const transform = `${placement.angle ? `rotate(${placement.angle} ${cx} ${cy}) ` : ''}`
+ `translate(${placement.x * W} ${placement.y * H}) scale(${W2 / art.viewW} ${H2 / art.viewH})`;
const style = this._decorStyle;
return svg`<path class="furniture-placement-preview dfurn"
data-symbol=${placement.symbol} d=${art.d} transform=${transform}
stroke=${style.color} stroke-opacity=${style.opacity}
stroke-width=${decorCmToUnits(style.widthCm, this._cellCm, this._gridPitch)}
fill="none" stroke-linecap="round" stroke-linejoin="round"
vector-effect="non-scaling-stroke" aria-hidden="true" pointer-events="none"></path>`;
}
private _renderDecorLayer(): TemplateResult {
const W = NORM_W, H = this._decorH;
const editing = this._mode === 'decor';
@@ -8137,7 +8187,7 @@ export class HouseplanCard extends LitElement {
stroke="${st.color}" stroke-opacity="${st.opacity}" stroke-width="${sw}" fill="${st.fill ? st.fillColor : 'none'}" fill-opacity="${st.fill ? st.fillOpacity : 0}"></ellipse>`;
}
}
return svg`<g class="decorlayer">${shapes}${draft}</g>` as unknown as TemplateResult;
return svg`<g class="decorlayer">${shapes}${draft}${this._renderFurniturePlacementPreview()}</g>` as unknown as TemplateResult;
}
// ================= shared editor secondary surface =================
+122 -16
View File
@@ -65,7 +65,9 @@ import { dayCycleStageVars, renderDayCycleEnvironment } from './day-cycle-render
import {
FURNITURE_GROUPS, furnitureOfGroup, furnitureDefaultCm,
furnitureGraphic, furnitureCorners, snapFurnitureToWall,
cmToNorm, clampFurnSize, clampFurnCm, FURN_WALL_CELLS, type FurnitureGroup,
resolveFurniturePlacement,
clampFurnCm, FURN_WALL_CELLS,
type FurnitureGroup, type FurniturePlacement,
} from './furniture';
import { GENERATED_FURNITURE_MENU } from './furniture-menu-art.generated';
import {
@@ -917,6 +919,10 @@ export interface HouseplanEditorHostPort {
_fullRegistryHass: any;
_furnPalette: { symbol: string; w: number; h: number; } | null;
_furnCategory: string | null;
_furnPreviewInput: { raw: [number, number]; free: boolean; } | null;
_furnTouchPending: {
pid: number; sx: number; sy: number; pointerType: string; cancelled: boolean;
} | null;
_furnWallReach: number;
_furnWalls: number[][];
_gearPtCache: WeakMap<number[][], number[]>;
@@ -1169,6 +1175,7 @@ public _help(key: Extract<I18nKey, `${string}.help`>): TemplateResult | typeof n
public _setMode(mode: 'view' | 'plan' | 'devices' | 'decor', animate = true): void {
this.host._endTabDrag();
this._clearFurniturePreview();
// A mode command is newer than the editor remembered by a same-route warm
// remount. Clear it before the same-mode early return: while can_write is
// pending, Lit may still be presenting the previous editor DOM even though
@@ -2197,6 +2204,7 @@ public _commitPhysicalGeometry(
}
public _clearGeometryGesture(): void {
this._clearFurniturePreview();
this.host._path = [];
this._clearPlanSnapHover();
this._clearOpeningPlacement(false);
@@ -2226,6 +2234,12 @@ public _clearGeometryGesture(): void {
public _stagePointerCancel(ev: PointerEvent): void {
clearTimeout(this.host._kioskHoldTimer);
if (this.host._swipeStart?.id === ev.pointerId) this.host._swipeStart = null;
if (this.host._furnTouchPending?.pid === ev.pointerId) {
this._clearFurniturePreview();
this.host._pointers.delete(ev.pointerId);
this.host.requestUpdate();
return;
}
if (this.host._physicalDrag?.pid === ev.pointerId || this.host._physicalRotate?.pid === ev.pointerId) {
this._cancelPhysicalGesture();
return;
@@ -4095,7 +4109,29 @@ public _decorPointerDown(ev: PointerEvent): boolean {
// must not silently place whatever was chosen last week.
if (!this.host._furnPalette) return false;
ev.preventDefault();
this._furnPlace(this._svgPoint(ev), ev.shiftKey);
// A mouse has already shown the exact result and keeps the traditional
// one-press stamp. Touch/pen has no hover, so commit only after a clean
// pointerup; pointercancel, movement and a second contact stay fail-dark.
const pointerType = ev.pointerType || 'mouse';
if (pointerType === 'mouse') {
this._furnPlace(this._svgPoint(ev), ev.shiftKey, pointerType);
return true;
}
const pending = this.host._furnTouchPending;
if (pending && pending.pid !== ev.pointerId) {
pending.cancelled = true;
this.host._furnPreviewInput = null;
return true;
}
this.host._furnTouchPending = {
pid: ev.pointerId,
sx: ev.clientX,
sy: ev.clientY,
pointerType,
cancelled: false,
};
this.host._furnPreviewInput = null;
capturePointer(ev);
return true;
}
// Empty-space selection belongs only to Select. Erase is an object
@@ -4639,41 +4675,104 @@ public _furnPick(symbol: string): void {
this.host._furnPalette = { symbol, w: d.w, h: d.h };
}
public _furnPlace(raw: number[], free = false): void {
public _resolveFurniturePlacement(
raw: number[], free = false, pointerType = 'mouse',
): FurniturePlacement | null {
const pal = this.host._furnPalette;
const sp = this.host._curSpaceCfg;
if (!pal || !sp) return;
if (!pal || !sp) return null;
const W = NORM_W, H = this.host._decorH;
const wN = clampFurnSize(cmToNorm(pal.w, this.host._cellCm, this.host._gridPitch, W));
const hN = clampFurnSize(cmToNorm(pal.h, this.host._cellCm, this.host._gridPitch, W));
const snapped = this._decorSnap(raw, pointerType);
return resolveFurniturePlacement({
symbol: pal.symbol,
widthCm: pal.w,
depthCm: pal.h,
point: [snapped[0], snapped[1]],
canvasW: W,
canvasH: H,
cellCm: this.host._cellCm,
gridPitch: this.host._gridPitch,
walls: this.host._furnWalls,
wallReach: this.host._furnWallReach,
free,
});
}
public _furnPlace(raw: number[], free = false, pointerType = 'mouse'): void {
const placement = this._resolveFurniturePlacement(raw, free, pointerType);
if (!placement) return;
const sp = this.host._curSpaceCfg;
if (!sp) return;
const before = this._geometrySnapshot();
const c = this._decorSnap(raw);
let cx = c[0], cy = c[1];
let angle = 0;
const snap = free ? null : snapFurnitureToWall(cx, cy, hN * H, this.host._furnWalls,
this.host._furnWallReach, this.host._gridPitch);
if (snap) { cx = snap.cx; cy = snap.cy; angle = snap.angle; }
const id = 'df' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
const shape: any = {
id, kind: 'furniture', symbol: pal.symbol,
x: clampCanvasN(cx / W - wN / 2), y: clampCanvasN(cy / H - hN / 2),
w: wN, h: hN,
id, kind: 'furniture', symbol: placement.symbol,
x: placement.x, y: placement.y, w: placement.w, h: placement.h,
...decorStylePatch(this.host._decorStyle, false),
};
// a straight piece stores no angle at all, exactly as a straight label
// stores none (docs/LIVE-TEXT.md §3)
if (angle) shape.angle = Number(angle.toFixed(2));
if (placement.angle) shape.angle = placement.angle;
sp.decor = [...this.host._decorList, shape];
this.host._decorSel = id;
// …and the editor goes back to the tool that can move what was just placed
this.host._decorTool = 'select';
this.host._furnPalette = null;
this.host._furnCategory = null;
this._clearFurniturePreview();
this._recordGeometry(this.host._t('history.decor_add'), before);
this._saveConfig();
this.host.requestUpdate();
}
public _clearFurniturePreview(): void {
this.host._furnPreviewInput = null;
this.host._furnTouchPending = null;
}
/** Track only real fine-pointer hover. Touch/pen own a delayed one-tap
* transaction below and must never resurrect a compatibility mouse ghost. */
public _furnPointerMove(ev: PointerEvent, hoverAllowed: boolean): boolean {
const pending = this.host._furnTouchPending;
if (pending?.pid === ev.pointerId) {
if (Math.abs(ev.clientX - pending.sx) + Math.abs(ev.clientY - pending.sy) > 8) {
pending.cancelled = true;
}
this.host._furnPreviewInput = null;
return true;
}
if (this.host._mode !== 'decor' || this.host._decorTool !== 'furniture'
|| !this.host._furnPalette) {
this._clearFurniturePreview();
return false;
}
if (ev.pointerType !== 'mouse' || !hoverAllowed) {
this.host._furnPreviewInput = null;
return false;
}
const raw = this._svgPoint(ev);
this.host._furnPreviewInput = { raw: [raw[0], raw[1]], free: ev.shiftKey };
return true;
}
public _furnPointerLeave(ev: PointerEvent): void {
if (this.host._furnTouchPending?.pid === ev.pointerId) {
this.host._furnTouchPending.cancelled = true;
}
this.host._furnPreviewInput = null;
}
public _furnPointerUp(ev: PointerEvent): boolean {
const pending = this.host._furnTouchPending;
if (!pending || pending.pid !== ev.pointerId) return false;
this.host._furnTouchPending = null;
if (!pending.cancelled && this.host._mode === 'decor'
&& this.host._decorTool === 'furniture' && this.host._furnPalette) {
this._furnPlace(this._svgPoint(ev), ev.shiftKey, pending.pointerType);
}
return true;
}
public _furnMoveUpdate(ev: PointerEvent): void {
const m = this.host._decorMove!;
if (m.orig.kind !== 'furniture') return;
@@ -4765,6 +4864,7 @@ public _renderFurnPalette(): TemplateResult {
<span class="spacer"></span>
<button class="btn furnclose" title=${this.host._t('btn.close')}
@click=${() => {
this._clearFurniturePreview();
this.host._furnPalette = null;
this.host._furnCategory = null;
this.host._decorTool = 'select';
@@ -4775,6 +4875,7 @@ public _renderFurnPalette(): TemplateResult {
<div class="furnbody">
${category ? html`
<button class="btn ghost furnback" @click=${() => {
this._clearFurniturePreview();
this.host._furnPalette = null;
this.host._furnCategory = null;
}}>
@@ -4798,6 +4899,7 @@ public _renderFurnPalette(): TemplateResult {
class="furnitem furncategory" data-category=${item.id}
title=${this.host._t(`furn.cat_${item.id}` as I18nKey)}
@click=${() => {
this._clearFurniturePreview();
this.host._furnPalette = null;
this.host._furnCategory = item.id;
}}>
@@ -5117,6 +5219,7 @@ public _renderDecorSecondary(): EditorSecondaryModel | null {
dismissPolicy: this.host._furnPalette ? 'stay-open-on-canvas' : 'outside',
dismiss: () => {
if (this.host._decorTool !== 'furniture') return;
this._clearFurniturePreview();
this.host._furnPalette = null;
this.host._furnCategory = null;
this.host._decorTool = 'select';
@@ -5239,6 +5342,7 @@ public _renderDecorBar(): TemplateResult {
data-editor-palette=${t === 'furniture' ? 'furniture' : nothing}
@click=${() => {
if (t === 'furniture' && this.host._decorTool === 'furniture') {
this._clearFurniturePreview();
this.host._furnPalette = null;
this.host._furnCategory = null;
this.host._decorTool = 'select';
@@ -5250,9 +5354,11 @@ public _renderDecorBar(): TemplateResult {
// the palette belongs to its tool and to nothing else: leaving the
// tool disarms whatever was chosen, so no later click can stamp it
if (t !== 'furniture') {
this._clearFurniturePreview();
this.host._furnPalette = null;
this.host._furnCategory = null;
} else {
this._clearFurniturePreview();
this.host._furnCategory = null;
}
}}
+4
View File
@@ -737,6 +737,10 @@ export const planStyles = css`
calc(5px * var(--hp-cell-visual-scale, 1));
pointer-events: none;
}
.decorlayer .furniture-placement-preview {
opacity: 0.55;
pointer-events: none;
}
.decorlayer text {
font-weight: 600;
user-select: none;