mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
fix: переписать слой редактирования лестниц на box-контракт декора (#676)
Лестница рисуется протяжкой, как прямоугольник или эллипс декора: длинная ось протяжки — ось подъёма, стрелка смотрит от нажатия к отпусканию, клик ставит размер по умолчанию. Выделенная лестница получает рамку декора в верхнем оверлее карточки — выше тел стен, с узлами постоянного экранного размера и курсорами по мировому направлению узла; винтовая — четыре узла на касательных. Ресайз идёт от противоположной стороны, не зеркалит и не поворачивает; магнит работает по сторонам: в пределах 6 клеток и 5° сторона ложится на видимую грань стены, при перемещении лестница доворачивается не больше чем на 5°, при ресайзе и черновике — никогда. Грани перегородок и колонн получают внешнюю сторону по обходу многоугольника. Клик, синтезированный после жеста, глушится: под «Лестницей» больше не появляется копия, под «Выбором» не пропадает выделение. Диалог свойств считает в сантиметрах лестницы (30…10000), а не в конвертере толщины стены; нетронутое поле хранит число бит в бит, «Сохранить» без изменений ничего не пишет. В Просмотре наведение на лестницу с корректной целью показывает «Переход на этаж …» — ровно при том же условии, что делает её ссылкой. Чистые функции слоя — в stairs-editor-model.ts с юнитами; смок лестниц покрывает протяжку, курсоры, рамку над стенами, клик после жеста, диалог и пять состояний подсказки; реестр мутантов получил шесть свидетелей. Issue: #676 User-Visible: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
@@ -6700,6 +6700,9 @@ export class HouseplanCard extends LitElement {
|
||||
// (owner's report). Pointers that begin on interactive children still
|
||||
// stay out — labels and handles run their own drags.
|
||||
if ((ev.target as HTMLElement).closest?.('.roomlabel, .rlhandle, .rszhandle, .dev, .oplock, .op-hit, button')) return;
|
||||
// #676 К1: under the Stairs tool a press on the stage starts a drag-to-draw
|
||||
// draft; the release places the stair. The stage keeps pan/pinch otherwise.
|
||||
if (this._editorRuntime?.stairs.stagePointerDown(ev)) return;
|
||||
}
|
||||
if (this._mode === 'devices' && (ev.target as HTMLElement).closest('.dev')) return;
|
||||
if (this._mode === 'decor' && this._decorPointerDown(ev)) return;
|
||||
@@ -11122,6 +11125,11 @@ export class HouseplanCard extends LitElement {
|
||||
backdrop editor, where rooms and devices are pointer-inert. */}
|
||||
${this._renderBackdropFrame(view)}
|
||||
${this._renderTextFrame(view)}
|
||||
${''/* #676 К2: the selected stair's frame paints above wall bodies,
|
||||
where the decor frame paints, so its handles never hide
|
||||
under masonry. */}
|
||||
${this._mode === 'plan' && this._editorRuntime
|
||||
? this._editorRuntime.stairs.renderFrame(view) : nothing}
|
||||
<g data-hp-live-editor></g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
@@ -48,10 +48,13 @@
|
||||
"stairs.no_target": "Nicht ausgewählt",
|
||||
"stairs.target_warning": "Wählen Sie eine andere vorhandene Etage für die Navigation.",
|
||||
"stairs.fixed_floor": "In einer Karte mit fester Etage ist die Navigation deaktiviert.",
|
||||
"stairs.tooltip_navigate": "Zur Etage {title}",
|
||||
"history.stair_add": "Treppe hinzufügen",
|
||||
"history.stair_edit": "Treppe ändern",
|
||||
"history.stair_move": "Treppe verschieben",
|
||||
"history.stair_delete": "Treppe löschen",
|
||||
"history.stair_resize": "Treppengröße ändern",
|
||||
"history.stair_rotate": "Treppe drehen",
|
||||
"markup.hint_column": "Klicken Sie auf einen Rasterpunkt, um eine quadratische Säule zu platzieren",
|
||||
"history.wall_segment": "Wandsegment hinzufügen",
|
||||
"history.column_add": "Säule hinzufügen",
|
||||
|
||||
@@ -48,10 +48,13 @@
|
||||
"stairs.no_target": "Not selected",
|
||||
"stairs.target_warning": "Select an existing different floor to enable navigation.",
|
||||
"stairs.fixed_floor": "Navigation is disabled in a fixed-floor card.",
|
||||
"stairs.tooltip_navigate": "Go to floor {title}",
|
||||
"history.stair_add": "Add stairs",
|
||||
"history.stair_edit": "Change stairs",
|
||||
"history.stair_move": "Move stairs",
|
||||
"history.stair_delete": "Delete stairs",
|
||||
"history.stair_resize": "Resize stairs",
|
||||
"history.stair_rotate": "Rotate stairs",
|
||||
"markup.hint_column": "click a grid point to place a square column",
|
||||
"history.wall_segment": "Add wall segment",
|
||||
"history.column_add": "Add column",
|
||||
|
||||
@@ -48,10 +48,13 @@
|
||||
"stairs.no_target": "Non sélectionné",
|
||||
"stairs.target_warning": "Sélectionnez un autre étage existant pour activer la navigation.",
|
||||
"stairs.fixed_floor": "La navigation est désactivée dans une carte à étage fixe.",
|
||||
"stairs.tooltip_navigate": "Aller à l’étage {title}",
|
||||
"history.stair_add": "Ajouter un escalier",
|
||||
"history.stair_edit": "Modifier l’escalier",
|
||||
"history.stair_move": "Déplacer l’escalier",
|
||||
"history.stair_delete": "Supprimer l’escalier",
|
||||
"history.stair_resize": "Redimensionner l’escalier",
|
||||
"history.stair_rotate": "Faire pivoter l’escalier",
|
||||
"markup.hint_column": "cliquez sur un point de la grille pour placer une colonne carrée",
|
||||
"history.wall_segment": "Ajouter un segment de mur",
|
||||
"history.column_add": "Ajouter une colonne",
|
||||
|
||||
@@ -48,10 +48,13 @@
|
||||
"stairs.no_target": "Не выбран",
|
||||
"stairs.target_warning": "Выберите другой существующий этаж, чтобы включить переход.",
|
||||
"stairs.fixed_floor": "В карточке с фиксированным этажом переход отключён.",
|
||||
"stairs.tooltip_navigate": "Переход на этаж {title}",
|
||||
"history.stair_add": "Добавить лестницу",
|
||||
"history.stair_edit": "Изменить лестницу",
|
||||
"history.stair_move": "Переместить лестницу",
|
||||
"history.stair_delete": "Удалить лестницу",
|
||||
"history.stair_resize": "Изменить размер лестницы",
|
||||
"history.stair_rotate": "Повернуть лестницу",
|
||||
"markup.hint_column": "кликните точку сетки, чтобы поставить квадратную колонну",
|
||||
"history.wall_segment": "Добавление отрезка стены",
|
||||
"history.column_add": "Добавление колонны",
|
||||
|
||||
+398
-1
@@ -1,7 +1,13 @@
|
||||
import { GRID_N, NORM_W } from './canvas-constants';
|
||||
import type { FurnitureWallSurface } from './furniture-wall-surface';
|
||||
import type { Stair } from './stairs';
|
||||
|
||||
export const STAIR_MIN_N = 0.0005;
|
||||
/** Smallest length, width or radius of a stair: one tread (#676). */
|
||||
export const STAIR_MIN_CM = 30;
|
||||
/** Same ceiling furniture uses; larger is a typo, not a staircase (#676). */
|
||||
export const STAIR_MAX_CM = 10000;
|
||||
/** A stair edge this close to parallel with a wall face may snap flush (#676). */
|
||||
export const STAIR_MAGNET_ANGLE_DEG = 5;
|
||||
|
||||
const finite = (value: unknown): number | null => {
|
||||
const number = Number(value);
|
||||
@@ -102,3 +108,394 @@ export function stairTargetState(
|
||||
if (stair.target_space_id === currentSpaceId) return 'self';
|
||||
return spaceIds.has(stair.target_space_id) ? 'active' : 'deleted';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// #676: the editing layer works on an oriented box — centre, length along the
|
||||
// local rise axis (+x), width along local +y, angle — in plan units. A spiral
|
||||
// stair is the circle inscribed in the square box of side 2r.
|
||||
|
||||
export interface StairBox {
|
||||
cx: number;
|
||||
cy: number;
|
||||
w: number;
|
||||
h: number;
|
||||
angle: number;
|
||||
}
|
||||
|
||||
/** A resize or edge handle in the stair's own frame: -1 | 0 | 1 per axis. */
|
||||
export interface StairHandleSign {
|
||||
sx: -1 | 0 | 1;
|
||||
sy: -1 | 0 | 1;
|
||||
}
|
||||
|
||||
export interface StairHandle extends StairHandleSign {
|
||||
point: [number, number];
|
||||
/** World bearing of the handle's outward direction, degrees, 0 = +x, clockwise on screen. */
|
||||
normalDeg: number;
|
||||
}
|
||||
|
||||
/** One side of the box (a tangent of the circle) with its outward normal. */
|
||||
export interface StairEdge extends StairHandleSign {
|
||||
mid: [number, number];
|
||||
dir: [number, number];
|
||||
normal: [number, number];
|
||||
}
|
||||
|
||||
const axes = (angle: number): { u: [number, number]; v: [number, number] } => {
|
||||
const radians = angle * Math.PI / 180;
|
||||
const cosine = Math.cos(radians), sine = Math.sin(radians);
|
||||
return { u: [cosine, sine], v: [-sine, cosine] };
|
||||
};
|
||||
|
||||
export function stairBox(stair: Stair, scale = NORM_W): StairBox {
|
||||
const size = stair.kind === 'spiral'
|
||||
? [stair.radius * 2 * scale, stair.radius * 2 * scale]
|
||||
: [stair.length * scale, stair.width * scale];
|
||||
return { cx: stair.x * scale, cy: stair.y * scale, w: size[0], h: size[1], angle: stair.angle };
|
||||
}
|
||||
|
||||
export function applyStairBox(stair: Stair, box: StairBox, scale = NORM_W): Stair {
|
||||
const base = { ...stair, x: box.cx / scale, y: box.cy / scale, angle: normalizeStairAngle(box.angle) };
|
||||
if (stair.kind === 'spiral') return { ...base, radius: Math.min(box.w, box.h) / 2 / scale } as Stair;
|
||||
return { ...base, length: box.w / scale, width: box.h / scale } as Stair;
|
||||
}
|
||||
|
||||
/** Sides of the box in world units. A spiral stair uses the world axes. */
|
||||
export function stairEdges(stair: Stair, scale = NORM_W): StairEdge[] {
|
||||
const box = stairBox(stair, scale);
|
||||
const { u, v } = stair.kind === 'spiral' ? axes(0) : axes(box.angle);
|
||||
const edge = (sx: -1 | 0 | 1, sy: -1 | 0 | 1): StairEdge => {
|
||||
const normal: [number, number] = sx
|
||||
? [sx * u[0], sx * u[1]] : [sy * v[0], sy * v[1]];
|
||||
const dir: [number, number] = sx ? [v[0], v[1]] : [u[0], u[1]];
|
||||
const half = sx ? box.w / 2 : box.h / 2;
|
||||
return {
|
||||
sx, sy, dir, normal,
|
||||
mid: [box.cx + normal[0] * half, box.cy + normal[1] * half],
|
||||
};
|
||||
};
|
||||
return [edge(1, 0), edge(-1, 0), edge(0, 1), edge(0, -1)];
|
||||
}
|
||||
|
||||
const bearingOf = (x: number, y: number): number => {
|
||||
const degrees = Math.atan2(y, x) * 180 / Math.PI;
|
||||
return ((degrees % 360) + 360) % 360;
|
||||
};
|
||||
|
||||
/** Resize handles: eight for a box, four axis tangents for a circle. */
|
||||
export function stairHandles(stair: Stair, scale = NORM_W): StairHandle[] {
|
||||
const box = stairBox(stair, scale);
|
||||
const { u, v } = stair.kind === 'spiral' ? axes(0) : axes(box.angle);
|
||||
const signs: StairHandleSign[] = stair.kind === 'spiral'
|
||||
? [{ sx: 1, sy: 0 }, { sx: 0, sy: 1 }, { sx: -1, sy: 0 }, { sx: 0, sy: -1 }]
|
||||
: [
|
||||
{ sx: -1, sy: -1 }, { sx: 1, sy: -1 }, { sx: 1, sy: 1 }, { sx: -1, sy: 1 },
|
||||
{ sx: 0, sy: -1 }, { sx: 1, sy: 0 }, { sx: 0, sy: 1 }, { sx: -1, sy: 0 },
|
||||
];
|
||||
return signs.map(({ sx, sy }) => {
|
||||
const lx = sx * box.w / 2, ly = sy * box.h / 2;
|
||||
const nx = sx * u[0] + sy * v[0], ny = sx * u[1] + sy * v[1];
|
||||
return {
|
||||
sx, sy,
|
||||
point: [box.cx + lx * u[0] + ly * v[0], box.cy + lx * u[1] + ly * v[1]],
|
||||
normalDeg: bearingOf(nx, ny),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** The rotation stem: from the top edge's midpoint outward by `arm` units. */
|
||||
export function stairRotateHandle(
|
||||
stair: Stair, arm: number, scale = NORM_W,
|
||||
): { from: [number, number]; to: [number, number] } {
|
||||
const box = stairBox(stair, scale);
|
||||
const { v } = axes(box.angle);
|
||||
const from: [number, number] = [box.cx - v[0] * box.h / 2, box.cy - v[1] * box.h / 2];
|
||||
return { from, to: [from[0] - v[0] * arm, from[1] - v[1] * arm] };
|
||||
}
|
||||
|
||||
export type ResizeCursor = 'ew' | 'ns' | 'nwse' | 'nesw';
|
||||
|
||||
/**
|
||||
* CSS cursor for a handle by the world bearing of its outward normal: eight
|
||||
* 45° sectors, so a rotated stair still shows the arrow that matches the
|
||||
* direction the handle actually moves in (#676 К2).
|
||||
*/
|
||||
export function resizeCursor(normalDeg: number): ResizeCursor {
|
||||
const sector = ((Math.round(normalizeStairAngle(normalDeg) / 45) % 8) + 8) % 8;
|
||||
if (sector === 0 || sector === 4) return 'ew';
|
||||
if (sector === 2 || sector === 6) return 'ns';
|
||||
return sector === 1 || sector === 5 ? 'nwse' : 'nesw';
|
||||
}
|
||||
|
||||
export const stairMinN = (cellCm: number): number => cmToNorm(STAIR_MIN_CM, cellCm);
|
||||
|
||||
/**
|
||||
* Drag-to-draw (#676 К1). `a` and `b` are plan units. A drag shorter than
|
||||
* `clickUnits` on both axes is a click and places the default stair at `a`.
|
||||
* The dominant drag axis is the rise axis, the ascent points from a to b, and
|
||||
* equal extents prefer x.
|
||||
*/
|
||||
export function draftStair(
|
||||
kind: Stair['kind'], a: readonly number[], b: readonly number[],
|
||||
cellCm: number, id: string, clickUnits: number, scale = NORM_W,
|
||||
): Stair {
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
if (Math.max(Math.abs(dx), Math.abs(dy)) < clickUnits) return defaultStair(kind, a[0], a[1], cellCm, id);
|
||||
const minUnits = stairMinN(cellCm) * scale;
|
||||
if (kind === 'spiral') {
|
||||
const side = Math.max(Math.abs(dx), Math.abs(dy));
|
||||
const radius = Math.max(minUnits, side) / 2;
|
||||
const cx = a[0] + Math.sign(dx || 1) * side / 2;
|
||||
const cy = a[1] + Math.sign(dy || 1) * side / 2;
|
||||
return {
|
||||
id, kind, x: cx / scale, y: cy / scale, angle: 0,
|
||||
direction: 'clockwise', radius: radius / scale, target_space_id: null,
|
||||
};
|
||||
}
|
||||
const alongX = Math.abs(dx) >= Math.abs(dy);
|
||||
const angle = alongX ? (dx >= 0 ? 0 : 180) : (dy >= 0 ? 90 : 270);
|
||||
const length = Math.max(minUnits, alongX ? Math.abs(dx) : Math.abs(dy));
|
||||
const width = Math.max(minUnits, alongX ? Math.abs(dy) : Math.abs(dx));
|
||||
return {
|
||||
id, kind, x: (a[0] + dx / 2) / scale, y: (a[1] + dy / 2) / scale, angle,
|
||||
direction: 'forward', length: length / scale, width: width / scale, target_space_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Which edges of a drawn draft face the drag end `b` (they receive the resize magnet). */
|
||||
export function draftLeadingHandle(stair: Stair, a: readonly number[], b: readonly number[]): StairHandleSign {
|
||||
const { u, v } = stair.kind === 'spiral' ? axes(0) : axes(stair.angle);
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
const along = dx * u[0] + dy * u[1];
|
||||
const across = dx * v[0] + dy * v[1];
|
||||
const sign = (value: number): -1 | 0 | 1 => (Math.abs(value) < 1e-9 ? 0 : value > 0 ? 1 : -1);
|
||||
return { sx: sign(along), sy: sign(across) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize about the opposite side (#676 К4): the anchor never moves, sizes
|
||||
* never drop below `minUnits`, and a pointer dragged past the anchor is
|
||||
* clamped instead of mirroring the stair. Corner drags with `keepAspect`
|
||||
* follow the axis that moved farther, like furniture.
|
||||
*/
|
||||
export function resizeStair(
|
||||
stair: Stair, handle: StairHandleSign, point: readonly number[],
|
||||
options: { minUnits: number; keepAspect?: boolean }, scale = NORM_W,
|
||||
): Stair {
|
||||
const box = stairBox(stair, scale);
|
||||
const minimum = Math.max(options.minUnits, 1e-6);
|
||||
const { u, v } = stair.kind === 'spiral' ? axes(0) : axes(box.angle);
|
||||
const lx = (point[0] - box.cx) * u[0] + (point[1] - box.cy) * u[1];
|
||||
const ly = (point[0] - box.cx) * v[0] + (point[1] - box.cy) * v[1];
|
||||
let w = handle.sx ? Math.max(minimum, handle.sx * lx + box.w / 2) : box.w;
|
||||
let h = handle.sy ? Math.max(minimum, handle.sy * ly + box.h / 2) : box.h;
|
||||
if (stair.kind === 'spiral') {
|
||||
// A circle stays a circle: the dragged tangent sets the diameter.
|
||||
const side = handle.sx ? w : h;
|
||||
w = side;
|
||||
h = side;
|
||||
} else if (options.keepAspect && handle.sx && handle.sy) {
|
||||
const kx = w / box.w, ky = h / box.h;
|
||||
const factor = Math.abs(kx - 1) >= Math.abs(ky - 1) ? kx : ky;
|
||||
const floor = Math.max(minimum / box.w, minimum / box.h);
|
||||
const safe = Math.max(floor, factor);
|
||||
w = box.w * safe;
|
||||
h = box.h * safe;
|
||||
}
|
||||
// The anchor side stays: the centre moves by half the growth toward the handle.
|
||||
// A spiral handle grows the perpendicular size about the centre, so it has no shift.
|
||||
const shiftX = handle.sx ? handle.sx * (w - box.w) / 2 : 0;
|
||||
const shiftY = handle.sy ? handle.sy * (h - box.h) / 2 : 0;
|
||||
return applyStairBox(stair, {
|
||||
cx: box.cx + shiftX * u[0] + shiftY * v[0],
|
||||
cy: box.cy + shiftX * u[1] + shiftY * v[1],
|
||||
w, h, angle: box.angle,
|
||||
}, scale);
|
||||
}
|
||||
|
||||
export interface EdgeSnap {
|
||||
/** Signed travel along the edge's outward normal that lays it on the face. */
|
||||
offset: number;
|
||||
/** Signed rotation, degrees, that makes the edge exactly parallel to the face. */
|
||||
angleDelta: number;
|
||||
stableId: string;
|
||||
face: { a: readonly [number, number]; b: readonly [number, number] };
|
||||
}
|
||||
|
||||
/** Signed distance from the edge midpoint to the face line along the edge's outward normal. */
|
||||
const offsetToFaceLine = (edge: StairEdge, a: readonly [number, number], dir: readonly [number, number]): number => {
|
||||
const along = (edge.mid[0] - a[0]) * dir[0] + (edge.mid[1] - a[1]) * dir[1];
|
||||
const foot = [a[0] + dir[0] * along, a[1] + dir[1] * along];
|
||||
return (foot[0] - edge.mid[0]) * edge.normal[0] + (foot[1] - edge.mid[1]) * edge.normal[1];
|
||||
};
|
||||
|
||||
const unit = (x: number, y: number): [number, number] | null => {
|
||||
const length = Math.hypot(x, y);
|
||||
return length > 1e-9 ? [x / length, y / length] : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Faces of independent physical bodies (partitions, columns) with their
|
||||
* outward normals (#676). The furniture magnet keeps these faces two-sided and
|
||||
* picks a side from the intent point; a stair edge needs the exposed side
|
||||
* only, otherwise a stair straddling a thin wall would snap to the face hidden
|
||||
* inside the masonry. The winding of the body polygon says which side is out.
|
||||
*/
|
||||
export function physicalStairSurfaces(bodies: readonly number[][][]): FurnitureWallSurface[] {
|
||||
const out: FurnitureWallSurface[] = [];
|
||||
for (const body of bodies || []) {
|
||||
if (!Array.isArray(body) || body.length < 3) continue;
|
||||
const points = body.filter((point) => Array.isArray(point) && Number.isFinite(point[0]) && Number.isFinite(point[1]));
|
||||
if (points.length < 3) continue;
|
||||
let area = 0;
|
||||
for (let index = 0; index < points.length; index++) {
|
||||
const a = points[index], b = points[(index + 1) % points.length];
|
||||
area += a[0] * b[1] - b[0] * a[1];
|
||||
}
|
||||
if (Math.abs(area) < 1e-12) continue;
|
||||
for (let index = 0; index < points.length; index++) {
|
||||
const a = points[index], b = points[(index + 1) % points.length];
|
||||
const dir = unit(b[0] - a[0], b[1] - a[1]);
|
||||
if (!dir) continue;
|
||||
// Positive signed area: the interior lies to the left of every directed
|
||||
// edge, so the outward normal is the right-hand perpendicular.
|
||||
const normal: [number, number] = area > 0
|
||||
? [dir[1] + 0, -dir[0] + 0] : [-dir[1] + 0, dir[0] + 0]; // `+ 0` folds -0 away
|
||||
out.push({
|
||||
a: [a[0], a[1]], b: [b[0], b[1]], axisA: [a[0], a[1]], axisB: [b[0], b[1]],
|
||||
normal, owner: 'physical',
|
||||
stableId: `stair-physical:${a[0].toFixed(6)},${a[1].toFixed(6)}|${b[0].toFixed(6)},${b[1].toFixed(6)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The nearest physical wall face a side may lie flush on (#676 К3/К4): parallel
|
||||
* within `angleTolDeg`, within `reach` along the side's outward normal, the
|
||||
* side's midpoint projecting onto the face segment (extended by `reach`), and —
|
||||
* for a one-sided room face — looking at the stair, never through the masonry.
|
||||
*/
|
||||
export function snapEdgeToFaces(
|
||||
edge: StairEdge, surfaces: readonly FurnitureWallSurface[], reach: number,
|
||||
angleTolDeg = STAIR_MAGNET_ANGLE_DEG,
|
||||
): EdgeSnap | null {
|
||||
const cosTol = Math.cos(angleTolDeg * Math.PI / 180);
|
||||
let best: EdgeSnap | null = null;
|
||||
for (const surface of surfaces) {
|
||||
const a = surface?.a, b = surface?.b;
|
||||
if (!a || !b || ![a[0], a[1], b[0], b[1]].every(Number.isFinite)) continue;
|
||||
const dir = unit(b[0] - a[0], b[1] - a[1]);
|
||||
if (!dir) continue;
|
||||
const dot = dir[0] * edge.dir[0] + dir[1] * edge.dir[1];
|
||||
if (Math.abs(dot) < cosTol) continue;
|
||||
if (surface.normal) {
|
||||
const facing = surface.normal[0] * edge.normal[0] + surface.normal[1] * edge.normal[1];
|
||||
if (!(facing < 0)) continue;
|
||||
}
|
||||
const length = Math.hypot(b[0] - a[0], b[1] - a[1]);
|
||||
const along = (edge.mid[0] - a[0]) * dir[0] + (edge.mid[1] - a[1]) * dir[1];
|
||||
if (along < -reach || along > length + reach) continue;
|
||||
const offset = offsetToFaceLine(edge, a, dir);
|
||||
if (Math.abs(offset) > reach) continue;
|
||||
// Signed angle from the edge direction to the face direction, folded to (-90, 90].
|
||||
const faceDir: [number, number] = dot < 0 ? [-dir[0], -dir[1]] : dir;
|
||||
const cross = edge.dir[0] * faceDir[1] - edge.dir[1] * faceDir[0];
|
||||
const angleDelta = Math.atan2(cross, Math.abs(dot)) * 180 / Math.PI;
|
||||
const stableId = typeof surface.stableId === 'string' ? surface.stableId : '';
|
||||
if (!best || Math.abs(offset) < Math.abs(best.offset) - 1e-9
|
||||
|| (Math.abs(Math.abs(offset) - Math.abs(best.offset)) <= 1e-9
|
||||
&& stableId.localeCompare(best.stableId) < 0)) {
|
||||
best = { offset, angleDelta, stableId, face: { a, b } };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move magnet (#676 К3): the side closest to a parallel face lands flush on it;
|
||||
* a straight stair also turns by at most `angleTolDeg` to be exactly parallel.
|
||||
*/
|
||||
export function magnetStairMove(
|
||||
stair: Stair, surfaces: readonly FurnitureWallSurface[], reach: number,
|
||||
angleTolDeg = STAIR_MAGNET_ANGLE_DEG, scale = NORM_W,
|
||||
): Stair {
|
||||
let best: { edge: StairEdge; snap: EdgeSnap } | null = null;
|
||||
for (const edge of stairEdges(stair, scale)) {
|
||||
const snap = snapEdgeToFaces(edge, surfaces, reach, angleTolDeg);
|
||||
if (snap && (!best || Math.abs(snap.offset) < Math.abs(best.snap.offset))) best = { edge, snap };
|
||||
}
|
||||
if (!best) return stair;
|
||||
let next = stair;
|
||||
let edge = best.edge;
|
||||
let offset = best.snap.offset;
|
||||
if (stair.kind === 'straight' && Math.abs(best.snap.angleDelta) > 1e-9) {
|
||||
// Turn first, then measure the same side against the same face again.
|
||||
next = { ...stair, angle: normalizeStairAngle(stair.angle + best.snap.angleDelta) } as Stair;
|
||||
edge = stairEdges(next, scale).find((item) => item.sx === best!.edge.sx && item.sy === best!.edge.sy)!;
|
||||
const { a, b } = best.snap.face;
|
||||
offset = offsetToFaceLine(edge, a, unit(b[0] - a[0], b[1] - a[1])!);
|
||||
}
|
||||
return {
|
||||
...next,
|
||||
x: next.x + edge.normal[0] * offset / scale,
|
||||
y: next.y + edge.normal[1] * offset / scale,
|
||||
} as Stair;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize magnet (#676 К4): each dragged side lands flush on a parallel face by
|
||||
* growing or shrinking the stair about the anchor; the angle never changes.
|
||||
*/
|
||||
export function magnetStairResize(
|
||||
stair: Stair, handle: StairHandleSign, surfaces: readonly FurnitureWallSurface[],
|
||||
reach: number, minUnits: number, angleTolDeg = STAIR_MAGNET_ANGLE_DEG, scale = NORM_W,
|
||||
): Stair {
|
||||
let next = stair;
|
||||
for (const axis of ['x', 'y'] as const) {
|
||||
const sx = axis === 'x' ? handle.sx : 0;
|
||||
const sy = axis === 'y' ? handle.sy : 0;
|
||||
if (!sx && !sy) continue;
|
||||
const edge = stairEdges(next, scale).find((item) => item.sx === sx && item.sy === sy)!;
|
||||
const snap = snapEdgeToFaces(edge, surfaces, reach, angleTolDeg);
|
||||
if (!snap) continue;
|
||||
const box = stairBox(next, scale);
|
||||
const grown = axis === 'x' ? box.w + snap.offset : box.h + snap.offset;
|
||||
if (grown < minUnits) continue;
|
||||
const { u, v } = next.kind === 'spiral' ? axes(0) : axes(box.angle);
|
||||
const half = snap.offset / 2;
|
||||
const shift: [number, number] = sx
|
||||
? [sx * half * u[0], sx * half * u[1]] : [sy * half * v[0], sy * half * v[1]];
|
||||
next = applyStairBox(next, {
|
||||
cx: box.cx + shift[0], cy: box.cy + shift[1],
|
||||
w: next.kind === 'spiral' ? grown : axis === 'x' ? grown : box.w,
|
||||
h: next.kind === 'spiral' ? grown : axis === 'y' ? grown : box.h,
|
||||
angle: box.angle,
|
||||
}, scale);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Dialog field (cm, or inches when imperial) → cm within the stair bounds; null rejects. */
|
||||
export function stairFieldToCm(raw: string | number, imperial: boolean): number | null {
|
||||
const value = typeof raw === 'number' ? raw : parseFloat(String(raw).trim().replace(',', '.'));
|
||||
if (!Number.isFinite(value) || value <= 0) return null;
|
||||
const cm = imperial ? value * 2.54 : value;
|
||||
return cm >= STAIR_MIN_CM && cm <= STAIR_MAX_CM ? cm : null;
|
||||
}
|
||||
|
||||
/** Stored normalized size ↔ dialog field, exact when the field was not edited. */
|
||||
export function stairFieldOf(sizeN: number, cellCm: number, imperial: boolean): string {
|
||||
const cm = normToCm(sizeN, cellCm);
|
||||
const shown = imperial ? cm / 2.54 : cm;
|
||||
return String(Math.round(shown * 100) / 100);
|
||||
}
|
||||
|
||||
export function stairSizeFromField(
|
||||
raw: string, previousField: string, previousN: number, cellCm: number, imperial: boolean,
|
||||
): number | null {
|
||||
if (raw === previousField) return previousN;
|
||||
const cm = stairFieldToCm(raw, imperial);
|
||||
return cm == null ? null : cmToNorm(cm, cellCm);
|
||||
}
|
||||
|
||||
+283
-170
@@ -1,22 +1,22 @@
|
||||
import { html, nothing, svg, type TemplateResult } from 'lit';
|
||||
import { resizeFurnitureTransform } from './furniture';
|
||||
import { FURN_WALL_CELLS, snapFurnitureToWall } from './furniture-placement';
|
||||
import { FURN_WALL_CELLS } from './furniture-placement';
|
||||
import {
|
||||
furnitureWallSurfacesFor, type FurnitureWallSurfaceSource,
|
||||
furnitureWallSurfacesFor, type FurnitureWallSurface, type FurnitureWallSurfaceSource,
|
||||
} from './furniture-wall-surface';
|
||||
import { strictNumber, type MarkupTool } from './card-runtime';
|
||||
import type { EditorToolbarGroup } from './editor-secondary';
|
||||
import type { I18nKey } from './i18n';
|
||||
import { clampCanvasN, NORM_W } from './space-geometry';
|
||||
import {
|
||||
convertStairKind, defaultStair, normalizeStairAngle, snapStairToStairs,
|
||||
stairPhysicalSizeCm, stairTargetState, STAIR_MIN_N,
|
||||
convertStairKind, draftLeadingHandle, draftStair, magnetStairMove, magnetStairResize,
|
||||
normalizeStairAngle, physicalStairSurfaces, resizeCursor, resizeStair, snapStairToStairs, stairBox,
|
||||
stairFieldOf, stairHandles, stairMinN, stairRotateHandle, stairSizeFromField,
|
||||
stairTargetState, STAIR_MAX_CM, STAIR_MIN_CM, type StairHandleSign,
|
||||
} from './stairs-editor-model';
|
||||
import {
|
||||
cachedStairRenderGeometry, MAX_STAIRS_PER_SPACE, stairList, type Stair,
|
||||
} from './stairs';
|
||||
import type { SpaceModel } from './types';
|
||||
import { cmToField, fieldToCm } from './wall-thickness';
|
||||
|
||||
type StairDialog = {
|
||||
id: string;
|
||||
@@ -27,18 +27,31 @@ type StairDialog = {
|
||||
angle: string;
|
||||
direction: Stair['direction'];
|
||||
targetSpaceId: string;
|
||||
/** Field values at open time: an untouched field keeps the stored number bit for bit (#676 К7). */
|
||||
opened: { length: string; width: string; radius: string; angle: string };
|
||||
};
|
||||
|
||||
type StairDrag = {
|
||||
pid: number;
|
||||
id: string;
|
||||
mode: 'move' | 'resize' | 'rotate';
|
||||
resizeX: -1 | 0 | 1;
|
||||
resizeY: -1 | 0 | 1;
|
||||
handle: StairHandleSign;
|
||||
start: number[];
|
||||
original: Stair;
|
||||
before: unknown;
|
||||
moved: boolean;
|
||||
/** Wall faces are read once per gesture: each write bumps the epoch that keys their cache. */
|
||||
surfaces: readonly FurnitureWallSurface[];
|
||||
};
|
||||
|
||||
type StairDraft = {
|
||||
pid: number;
|
||||
kind: Stair['kind'];
|
||||
id: string;
|
||||
a: number[];
|
||||
b: number[];
|
||||
stair: Stair;
|
||||
surfaces: readonly FurnitureWallSurface[];
|
||||
};
|
||||
|
||||
/** Narrow internal seam owned by HouseplanCard. The cast at construction keeps
|
||||
@@ -65,23 +78,26 @@ export interface StairEditorHostPort {
|
||||
_geometrySnapshot(): unknown;
|
||||
_recordGeometry(name: string, before: unknown): void;
|
||||
_saveConfigDebounced(): void;
|
||||
_cmToUnits(cm: number): number;
|
||||
_svgPoint(event: MouseEvent): number[];
|
||||
_tabClick(spaceId: string): void;
|
||||
_activateMarkupTool(tool: MarkupTool): void;
|
||||
}
|
||||
|
||||
const newStairId = (): string => `stair-${crypto.randomUUID?.()
|
||||
|| `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}`;
|
||||
|
||||
export class StairEditorRuntime {
|
||||
private preset: Stair['kind'] = 'straight';
|
||||
private selectedId: string | null = null;
|
||||
private dialog: StairDialog | null = null;
|
||||
private drag: StairDrag | null = null;
|
||||
private draft: StairDraft | null = null;
|
||||
|
||||
public constructor(private readonly owner: StairEditorHostPort) {}
|
||||
|
||||
public get selected(): string | null { return this.selectedId; }
|
||||
public get dialogOpen(): boolean { return this.dialog !== null; }
|
||||
public get dragging(): boolean { return this.drag !== null; }
|
||||
public get dragging(): boolean { return this.drag !== null || this.draft !== null; }
|
||||
|
||||
public clearSelection(): void {
|
||||
this.selectedId = null;
|
||||
@@ -96,6 +112,24 @@ export class StairEditorRuntime {
|
||||
return stairList((this.owner._curSpaceCfg as { stairs?: unknown } | null)?.stairs);
|
||||
}
|
||||
|
||||
private get inputEnabled(): boolean {
|
||||
return this.owner._mode === 'plan'
|
||||
&& (this.owner._tool === 'select' || this.owner._tool === 'stairs');
|
||||
}
|
||||
|
||||
private get reach(): number { return this.owner._gridPitch * FURN_WALL_CELLS; }
|
||||
|
||||
private get minUnits(): number { return stairMinN(this.owner._cellCm) * NORM_W; }
|
||||
|
||||
/** Room faces from the furniture magnet plus physical bodies with an outward side (#676). */
|
||||
private surfaces(): readonly FurnitureWallSurface[] {
|
||||
const source = this.owner as unknown as FurnitureWallSurfaceSource;
|
||||
return [
|
||||
...furnitureWallSurfacesFor(source).filter((surface) => surface.owner === 'room'),
|
||||
...physicalStairSurfaces(source._rawPhysicalBodiesR()),
|
||||
];
|
||||
}
|
||||
|
||||
private write(stairs: Stair[]): void {
|
||||
const space = this.owner._curSpaceCfg as { stairs?: Stair[] } | null;
|
||||
if (!space) return;
|
||||
@@ -107,27 +141,27 @@ export class StairEditorRuntime {
|
||||
this.owner.requestUpdate();
|
||||
}
|
||||
|
||||
private withMagnet(stair: Stair, center: readonly number[]): Stair {
|
||||
let cx = center[0], cy = center[1], angle = stair.angle;
|
||||
const depth = (stair.kind === 'straight' ? stair.width : stair.radius * 2) * NORM_W;
|
||||
const wall = snapFurnitureToWall(
|
||||
cx, cy, depth,
|
||||
furnitureWallSurfacesFor(this.owner as unknown as FurnitureWallSurfaceSource),
|
||||
this.owner._gridPitch * FURN_WALL_CELLS, 0, [cx, cy],
|
||||
);
|
||||
if (wall) {
|
||||
cx = wall.cx;
|
||||
cy = wall.cy;
|
||||
if (stair.kind === 'straight') angle = wall.angle;
|
||||
}
|
||||
private replace(next: Stair): void {
|
||||
this.write(this.stairs.map((item) => item.id === next.id ? next : item));
|
||||
}
|
||||
|
||||
const moved = { ...stair, x: cx / NORM_W, y: cy / NORM_W, angle } as Stair;
|
||||
[cx, cy] = snapStairToStairs(
|
||||
moved, [cx, cy], this.stairs, this.owner._gridPitch * FURN_WALL_CELLS,
|
||||
/**
|
||||
* The synthesized `click` after any gesture on the stair or its handles
|
||||
* must never reach the plan tool underneath: under «Stairs» it would place
|
||||
* another stair, under «Select» it would drop the selection (#676 К6).
|
||||
*/
|
||||
private swallowNextClick(): void {
|
||||
this.owner._suppressClick = true;
|
||||
setTimeout(() => { this.owner._suppressClick = false; }, 0);
|
||||
}
|
||||
|
||||
private withMoveMagnet(stair: Stair, center: readonly number[], surfaces: readonly FurnitureWallSurface[]): Stair {
|
||||
const moved = { ...stair, x: center[0] / NORM_W, y: center[1] / NORM_W } as Stair;
|
||||
const snapped = magnetStairMove(moved, surfaces, this.reach);
|
||||
const [cx, cy] = snapStairToStairs(
|
||||
snapped, [snapped.x * NORM_W, snapped.y * NORM_W], this.stairs, this.reach,
|
||||
);
|
||||
return {
|
||||
...stair, x: clampCanvasN(cx / NORM_W), y: clampCanvasN(cy / NORM_W), angle,
|
||||
} as Stair;
|
||||
return { ...snapped, x: clampCanvasN(cx / NORM_W), y: clampCanvasN(cy / NORM_W) } as Stair;
|
||||
}
|
||||
|
||||
public activatePlacement(kind: Stair['kind']): void {
|
||||
@@ -136,6 +170,64 @@ export class StairEditorRuntime {
|
||||
this.owner._activateMarkupTool('stairs');
|
||||
}
|
||||
|
||||
private placeStair(stair: Stair, before: unknown): void {
|
||||
this.write([...this.stairs, stair]);
|
||||
this.selectedId = stair.id;
|
||||
this.owner._recordGeometry(this.owner._t('history.stair_add'), before);
|
||||
this.owner._saveConfigDebounced();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag-to-draw under the Stairs tool (#676 К1): the press starts a draft on
|
||||
* the stage, the release commits it. Returns false when the press is not a
|
||||
* placement (secondary button, second finger, no space, limit reached), so
|
||||
* the stage keeps its ordinary pan/pinch handling.
|
||||
*/
|
||||
public stagePointerDown(event: PointerEvent): boolean {
|
||||
if (this.owner._mode !== 'plan' || this.owner._tool !== 'stairs') return false;
|
||||
if (this.draft) {
|
||||
// A second contact turns the gesture into navigation: the draft is gone.
|
||||
if (this.draft.pid !== event.pointerId) this.cancelDraft();
|
||||
return false;
|
||||
}
|
||||
if (this.drag || (event.pointerType === 'mouse' && event.button !== 0) || !event.isPrimary) return false;
|
||||
const space = this.owner._curSpaceCfg as { stairs?: Stair[] } | null;
|
||||
if (!space) return false;
|
||||
if (this.stairs.length >= MAX_STAIRS_PER_SPACE) {
|
||||
this.owner._showToast(this.owner._t('toast.physical_limit'));
|
||||
return false;
|
||||
}
|
||||
event.preventDefault();
|
||||
const point = this.owner._svgPoint(event as unknown as MouseEvent);
|
||||
const id = newStairId();
|
||||
this.draft = {
|
||||
pid: event.pointerId, kind: this.preset, id, a: point, b: point,
|
||||
stair: draftStair(this.preset, point, point, this.owner._cellCm, id, this.owner._gridPitch),
|
||||
surfaces: this.surfaces(),
|
||||
};
|
||||
this.selectedId = null;
|
||||
try { (event.currentTarget as Element).setPointerCapture(event.pointerId); }
|
||||
catch { /* synthetic event */ }
|
||||
this.owner.requestUpdate();
|
||||
return true;
|
||||
}
|
||||
|
||||
private draftAt(draft: StairDraft, point: number[]): Stair {
|
||||
const drawn = draftStair(draft.kind, draft.a, point, this.owner._cellCm, draft.id, this.owner._gridPitch);
|
||||
const isClick = Math.max(Math.abs(point[0] - draft.a[0]), Math.abs(point[1] - draft.a[1])) < this.owner._gridPitch;
|
||||
if (isClick) return this.withMoveMagnet(drawn, [drawn.x * NORM_W, drawn.y * NORM_W], draft.surfaces);
|
||||
return magnetStairResize(
|
||||
drawn, draftLeadingHandle(drawn, draft.a, point), draft.surfaces, this.reach, this.minUnits,
|
||||
);
|
||||
}
|
||||
|
||||
private cancelDraft(): void {
|
||||
if (!this.draft) return;
|
||||
this.draft = null;
|
||||
this.owner.requestUpdate();
|
||||
}
|
||||
|
||||
/** Programmatic placement kept for the harness and tests: a click at `point`. */
|
||||
public placeAt(point: number[]): void {
|
||||
const space = this.owner._curSpaceCfg as { stairs?: Stair[] } | null;
|
||||
if (!space || this.stairs.length >= MAX_STAIRS_PER_SPACE) {
|
||||
@@ -143,28 +235,30 @@ export class StairEditorRuntime {
|
||||
return;
|
||||
}
|
||||
const before = this.owner._geometrySnapshot();
|
||||
const id = `stair-${crypto.randomUUID?.()
|
||||
|| `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}`;
|
||||
const stair = this.withMagnet(
|
||||
defaultStair(this.preset, point[0], point[1], this.owner._cellCm, id), point,
|
||||
const id = newStairId();
|
||||
const stair = this.withMoveMagnet(
|
||||
draftStair(this.preset, point, point, this.owner._cellCm, id, this.owner._gridPitch),
|
||||
[point[0], point[1]], this.surfaces(),
|
||||
);
|
||||
this.write([...this.stairs, stair]);
|
||||
this.selectedId = id;
|
||||
this.owner._recordGeometry(this.owner._t('history.stair_add'), before);
|
||||
this.owner._saveConfigDebounced();
|
||||
this.placeStair(stair, before);
|
||||
}
|
||||
|
||||
private openDialog(stair: Stair): void {
|
||||
const sizes = stairPhysicalSizeCm(stair, this.owner._cellCm);
|
||||
const cellCm = this.owner._cellCm;
|
||||
const imperial = this.owner._imperial;
|
||||
const fields = {
|
||||
length: stairFieldOf(stair.kind === 'straight' ? stair.length : stair.radius * 2, cellCm, imperial),
|
||||
width: stairFieldOf(stair.kind === 'straight' ? stair.width : stair.radius * 2, cellCm, imperial),
|
||||
radius: stairFieldOf(stair.kind === 'spiral' ? stair.radius : Math.max(stair.length, stair.width) / 2, cellCm, imperial),
|
||||
angle: String(stair.angle),
|
||||
};
|
||||
this.dialog = {
|
||||
id: stair.id,
|
||||
kind: stair.kind,
|
||||
length: cmToField(stair.kind === 'straight' ? sizes[0] : 100, this.owner._imperial),
|
||||
width: cmToField(stair.kind === 'straight' ? sizes[1] : 100, this.owner._imperial),
|
||||
radius: cmToField(stair.kind === 'spiral' ? sizes[0] : 90, this.owner._imperial),
|
||||
angle: String(stair.angle),
|
||||
...fields,
|
||||
direction: stair.direction,
|
||||
targetSpaceId: stair.target_space_id || '',
|
||||
opened: fields,
|
||||
};
|
||||
this.owner.requestUpdate();
|
||||
}
|
||||
@@ -179,34 +273,42 @@ export class StairEditorRuntime {
|
||||
const dialog = this.dialog;
|
||||
const current = this.stairs.find((item) => item.id === dialog?.id);
|
||||
if (!dialog || !current) return;
|
||||
const lengthCm = fieldToCm(dialog.length, this.owner._imperial);
|
||||
const widthCm = fieldToCm(dialog.width, this.owner._imperial);
|
||||
const radiusCm = fieldToCm(dialog.radius, this.owner._imperial);
|
||||
const angle = strictNumber(dialog.angle);
|
||||
if (angle == null || (dialog.kind === 'straight'
|
||||
? !(lengthCm && widthCm) : !radiusCm)) return;
|
||||
const before = this.owner._geometrySnapshot();
|
||||
const cellCm = this.owner._cellCm;
|
||||
const imperial = this.owner._imperial;
|
||||
const angle = dialog.angle === dialog.opened.angle ? current.angle : strictNumber(dialog.angle);
|
||||
if (angle == null) return;
|
||||
let next = convertStairKind(current, dialog.kind);
|
||||
if (next.kind === 'straight') next = {
|
||||
...next,
|
||||
length: Math.max(STAIR_MIN_N, this.owner._cmToUnits(lengthCm!) / NORM_W),
|
||||
width: Math.max(STAIR_MIN_N, this.owner._cmToUnits(widthCm!) / NORM_W),
|
||||
direction: dialog.direction === 'backward' ? 'backward' : 'forward',
|
||||
};
|
||||
else next = {
|
||||
...next,
|
||||
radius: Math.max(STAIR_MIN_N, this.owner._cmToUnits(radiusCm!) / NORM_W),
|
||||
direction: dialog.direction === 'counterclockwise' ? 'counterclockwise' : 'clockwise',
|
||||
};
|
||||
if (next.kind === 'straight') {
|
||||
const length = stairSizeFromField(dialog.length, dialog.opened.length, next.length, cellCm, imperial);
|
||||
const width = stairSizeFromField(dialog.width, dialog.opened.width, next.width, cellCm, imperial);
|
||||
if (length == null || width == null) return;
|
||||
next = {
|
||||
...next, length, width,
|
||||
direction: dialog.direction === 'backward' ? 'backward' : 'forward',
|
||||
};
|
||||
} else {
|
||||
const radius = stairSizeFromField(dialog.radius, dialog.opened.radius, next.radius, cellCm, imperial);
|
||||
if (radius == null) return;
|
||||
next = {
|
||||
...next, radius,
|
||||
direction: dialog.direction === 'counterclockwise' ? 'counterclockwise' : 'clockwise',
|
||||
};
|
||||
}
|
||||
next = {
|
||||
...next,
|
||||
angle: normalizeStairAngle(angle),
|
||||
target_space_id: dialog.targetSpaceId || null,
|
||||
} as Stair;
|
||||
this.write(this.stairs.map((item) => item.id === next.id ? next : item));
|
||||
this.dialog = null;
|
||||
const unchanged = JSON.stringify(next) === JSON.stringify({ ...current, target_space_id: current.target_space_id ?? null });
|
||||
if (unchanged) {
|
||||
this.owner.requestUpdate();
|
||||
return;
|
||||
}
|
||||
const before = this.owner._geometrySnapshot();
|
||||
this.replace(next);
|
||||
this.owner._recordGeometry(this.owner._t('history.stair_edit'), before);
|
||||
this.owner._saveConfigDebounced();
|
||||
this.dialog = null;
|
||||
}
|
||||
|
||||
private delete(id: string): void {
|
||||
@@ -228,11 +330,9 @@ export class StairEditorRuntime {
|
||||
event: PointerEvent,
|
||||
stair: Stair,
|
||||
mode: 'move' | 'resize' | 'rotate',
|
||||
resizeX: -1 | 0 | 1 = 1,
|
||||
resizeY: -1 | 0 | 1 = 1,
|
||||
handle: StairHandleSign = { sx: 0, sy: 0 },
|
||||
): void {
|
||||
if (this.owner._mode !== 'plan'
|
||||
|| (this.owner._tool !== 'select' && this.owner._tool !== 'stairs')) return;
|
||||
if (!this.inputEnabled || this.draft) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.selectedId = stair.id;
|
||||
@@ -240,12 +340,12 @@ export class StairEditorRuntime {
|
||||
pid: event.pointerId,
|
||||
id: stair.id,
|
||||
mode,
|
||||
resizeX,
|
||||
resizeY,
|
||||
handle,
|
||||
start: this.owner._svgPoint(event as unknown as MouseEvent),
|
||||
original: structuredClone(stair),
|
||||
before: this.owner._geometrySnapshot(),
|
||||
moved: false,
|
||||
surfaces: this.surfaces(),
|
||||
};
|
||||
try { (event.currentTarget as Element).setPointerCapture(event.pointerId); }
|
||||
catch { /* synthetic event */ }
|
||||
@@ -253,53 +353,57 @@ export class StairEditorRuntime {
|
||||
}
|
||||
|
||||
public pointerMove(event: PointerEvent): boolean {
|
||||
if (this.draft?.pid === event.pointerId) {
|
||||
const point = this.owner._svgPoint(event as unknown as MouseEvent);
|
||||
this.draft.b = point;
|
||||
this.draft.stair = this.draftAt(this.draft, point);
|
||||
this.owner.requestUpdate();
|
||||
return true;
|
||||
}
|
||||
const drag = this.drag;
|
||||
if (!drag || drag.pid !== event.pointerId) return false;
|
||||
const point = this.owner._svgPoint(event as unknown as MouseEvent);
|
||||
let next: Stair = { ...drag.original };
|
||||
if (drag.mode === 'move') {
|
||||
const center = [
|
||||
next = this.withMoveMagnet(next, [
|
||||
drag.original.x * NORM_W + point[0] - drag.start[0],
|
||||
drag.original.y * NORM_W + point[1] - drag.start[1],
|
||||
];
|
||||
next = this.withMagnet(next, center);
|
||||
], drag.surfaces);
|
||||
} else if (drag.mode === 'rotate') {
|
||||
next.angle = normalizeStairAngle(
|
||||
Math.atan2(point[1] - next.y * NORM_W, point[0] - next.x * NORM_W)
|
||||
* 180 / Math.PI + 90,
|
||||
);
|
||||
if (event.shiftKey) next.angle = Math.round(next.angle / 45) * 45;
|
||||
} else if (next.kind === 'spiral') {
|
||||
next.radius = Math.max(STAIR_MIN_N,
|
||||
Math.hypot(point[0] - next.x * NORM_W, point[1] - next.y * NORM_W) / NORM_W);
|
||||
if (event.shiftKey) next.angle = normalizeStairAngle(Math.round(next.angle / 45) * 45);
|
||||
} else {
|
||||
const resized = resizeFurnitureTransform({
|
||||
x: (next.x - next.length / 2) * NORM_W,
|
||||
y: (next.y - next.width / 2) * NORM_W,
|
||||
w: next.length * NORM_W,
|
||||
h: next.width * NORM_W,
|
||||
angle: next.angle,
|
||||
}, drag.resizeX, drag.resizeY, point[0], point[1], false, STAIR_MIN_N * NORM_W);
|
||||
next = {
|
||||
...next,
|
||||
x: (resized.x + resized.w / 2) / NORM_W,
|
||||
y: (resized.y + resized.h / 2) / NORM_W,
|
||||
length: resized.w / NORM_W,
|
||||
width: resized.h / NORM_W,
|
||||
};
|
||||
next = magnetStairResize(
|
||||
resizeStair(next, drag.handle, point, { minUnits: this.minUnits, keepAspect: event.shiftKey }),
|
||||
drag.handle, drag.surfaces, this.reach, this.minUnits,
|
||||
);
|
||||
}
|
||||
drag.moved ||= Math.hypot(point[0] - drag.start[0], point[1] - drag.start[1]) > 0.5;
|
||||
this.write(this.stairs.map((item) => item.id === next.id ? next : item));
|
||||
this.replace(next);
|
||||
return true;
|
||||
}
|
||||
|
||||
private endDrag(event: PointerEvent, cancelled: boolean): boolean {
|
||||
if (this.draft?.pid === event.pointerId) {
|
||||
const draft = this.draft;
|
||||
this.draft = null;
|
||||
this.swallowNextClick();
|
||||
if (cancelled) { this.owner.requestUpdate(); return true; }
|
||||
this.placeStair(this.draftAt(draft, draft.b), this.owner._geometrySnapshot());
|
||||
return true;
|
||||
}
|
||||
const drag = this.drag;
|
||||
if (!drag || drag.pid !== event.pointerId) return false;
|
||||
this.swallowNextClick();
|
||||
if (cancelled) {
|
||||
this.write(this.stairs.map((item) => item.id === drag.id ? drag.original : item));
|
||||
this.replace(drag.original);
|
||||
} else if (drag.moved) {
|
||||
this.owner._recordGeometry(this.owner._t('history.stair_move'), drag.before);
|
||||
const label = drag.mode === 'resize' ? 'history.stair_resize'
|
||||
: drag.mode === 'rotate' ? 'history.stair_rotate' : 'history.stair_move';
|
||||
this.owner._recordGeometry(this.owner._t(label), drag.before);
|
||||
this.owner._saveConfigDebounced();
|
||||
}
|
||||
this.drag = null;
|
||||
@@ -315,9 +419,10 @@ export class StairEditorRuntime {
|
||||
}
|
||||
|
||||
public undoActiveDrag(): boolean {
|
||||
if (this.draft) { this.cancelDraft(); return true; }
|
||||
if (!this.drag) return false;
|
||||
const drag = this.drag;
|
||||
this.write(this.stairs.map((item) => item.id === drag.id ? drag.original : item));
|
||||
this.replace(drag.original);
|
||||
this.drag = null;
|
||||
return true;
|
||||
}
|
||||
@@ -340,6 +445,7 @@ export class StairEditorRuntime {
|
||||
|
||||
public clearGesture(): void {
|
||||
this.drag = null;
|
||||
this.draft = null;
|
||||
this.selectedId = null;
|
||||
}
|
||||
|
||||
@@ -365,79 +471,83 @@ export class StairEditorRuntime {
|
||||
};
|
||||
}
|
||||
|
||||
private renderStair(stair: Stair, spaceIds: ReadonlySet<string>, selected: boolean, draft: boolean): TemplateResult {
|
||||
const geometry = cachedStairRenderGeometry(stair, this.owner._cellCm);
|
||||
const outline = geometry.outline.map((point) => point.join(',')).join(' ');
|
||||
const targetState = stairTargetState(
|
||||
stair, this.owner._space, spaceIds, this.owner._hasFixedFloor,
|
||||
);
|
||||
const inputEnabled = this.inputEnabled && !draft;
|
||||
const select = (event: Event): void => {
|
||||
if (this.owner._mode === 'plan') {
|
||||
event.stopPropagation();
|
||||
this.selectedId = stair.id;
|
||||
this.owner.requestUpdate();
|
||||
}
|
||||
};
|
||||
return svg`<g class="hp-stair ${selected ? 'selected' : ''} ${inputEnabled ? 'input-enabled' : ''} ${draft ? 'draft' : ''}"
|
||||
data-hp="stair" data-id=${stair.id} data-kind=${stair.kind}
|
||||
data-target-state=${targetState}
|
||||
role="img"
|
||||
aria-label=${this.owner._t('markup.stairs')}
|
||||
@dblclick=${(event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
if (this.owner._mode === 'plan' && !draft) this.openDialog(stair);
|
||||
}}>
|
||||
<polygon class="hp-stair-outline" points=${outline}></polygon>
|
||||
<polygon class="hp-stair-hit" points=${outline}
|
||||
@pointerdown=${(event: PointerEvent) => this.pointerDown(event, stair, 'move')}
|
||||
@click=${select}></polygon>
|
||||
${geometry.treads.map((line) => svg`<line class="hp-stair-tread"
|
||||
x1=${line.a[0]} y1=${line.a[1]} x2=${line.b[0]} y2=${line.b[1]}></line>`)}
|
||||
<path class="hp-stair-arrow" d=${geometry.arrowPath}></path>
|
||||
</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
public renderLayer(): TemplateResult {
|
||||
const spaceIds = new Set(this.owner._model.map((item) => item.id));
|
||||
const items = this.stairs.map((stair) => {
|
||||
const geometry = cachedStairRenderGeometry(stair, this.owner._cellCm);
|
||||
const outline = geometry.outline.map((point) => point.join(',')).join(' ');
|
||||
const selected = this.owner._mode === 'plan'
|
||||
&& (this.owner._tool === 'select' || this.owner._tool === 'stairs')
|
||||
&& this.selectedId === stair.id;
|
||||
const targetState = stairTargetState(
|
||||
stair, this.owner._space, spaceIds, this.owner._hasFixedFloor,
|
||||
);
|
||||
const inputEnabled = this.owner._mode === 'plan'
|
||||
&& (this.owner._tool === 'select' || this.owner._tool === 'stairs');
|
||||
const select = (event: Event): void => {
|
||||
if (this.owner._mode === 'plan') {
|
||||
event.stopPropagation();
|
||||
this.selectedId = stair.id;
|
||||
this.owner.requestUpdate();
|
||||
}
|
||||
};
|
||||
const resizeHandles = stair.kind === 'spiral'
|
||||
? [{ point: [stair.x * NORM_W + stair.radius * NORM_W, stair.y * NORM_W], sx: 1, sy: 1 }]
|
||||
: [
|
||||
{ point: geometry.outline[0], sx: -1, sy: -1 },
|
||||
{ point: geometry.outline[1], sx: 1, sy: -1 },
|
||||
{ point: geometry.outline[2], sx: 1, sy: 1 },
|
||||
{ point: geometry.outline[3], sx: -1, sy: 1 },
|
||||
{ point: geometry.outline[0].map((value, axis) =>
|
||||
(value + geometry.outline[1][axis]) / 2), sx: 0, sy: -1 },
|
||||
{ point: geometry.outline[1].map((value, axis) =>
|
||||
(value + geometry.outline[2][axis]) / 2), sx: 1, sy: 0 },
|
||||
{ point: geometry.outline[2].map((value, axis) =>
|
||||
(value + geometry.outline[3][axis]) / 2), sx: 0, sy: 1 },
|
||||
{ point: geometry.outline[3].map((value, axis) =>
|
||||
(value + geometry.outline[0][axis]) / 2), sx: -1, sy: 0 },
|
||||
];
|
||||
const bearing = stair.angle * Math.PI / 180;
|
||||
const outer = (stair.kind === 'spiral' ? stair.radius : stair.width / 2) * NORM_W;
|
||||
const rotatePoint = [
|
||||
stair.x * NORM_W + Math.sin(bearing) * (outer + this.owner._gridPitch * 2),
|
||||
stair.y * NORM_W - Math.cos(bearing) * (outer + this.owner._gridPitch * 2),
|
||||
];
|
||||
return svg`<g class="hp-stair ${selected ? 'selected' : ''} ${inputEnabled ? 'input-enabled' : ''}"
|
||||
data-hp="stair" data-id=${stair.id} data-kind=${stair.kind}
|
||||
data-target-state=${targetState}
|
||||
role="img"
|
||||
aria-label=${this.owner._t('markup.stairs')}
|
||||
@dblclick=${(event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
if (this.owner._mode === 'plan') this.openDialog(stair);
|
||||
}}>
|
||||
<polygon class="hp-stair-outline" points=${outline}></polygon>
|
||||
<polygon class="hp-stair-hit" points=${outline}
|
||||
@pointerdown=${(event: PointerEvent) => this.pointerDown(event, stair, 'move')}
|
||||
@click=${select}></polygon>
|
||||
${geometry.treads.map((line) => svg`<line class="hp-stair-tread"
|
||||
x1=${line.a[0]} y1=${line.a[1]} x2=${line.b[0]} y2=${line.b[1]}></line>`)}
|
||||
<path class="hp-stair-arrow" d=${geometry.arrowPath}></path>
|
||||
${selected ? svg`
|
||||
${resizeHandles.map((handle) => svg`<circle class="hp-stair-handle hp-stair-resize"
|
||||
cx=${handle.point[0]} cy=${handle.point[1]} r=${this.owner._gridPitch * 0.65}
|
||||
@pointerdown=${(event: PointerEvent) => this.pointerDown(
|
||||
event, stair, 'resize', handle.sx as -1 | 0 | 1, handle.sy as -1 | 0 | 1,
|
||||
)}></circle>`)}
|
||||
<line class="hp-stair-rotate-leader" x1=${stair.x * NORM_W} y1=${stair.y * NORM_W}
|
||||
x2=${rotatePoint[0]} y2=${rotatePoint[1]}></line>
|
||||
<circle class="hp-stair-handle hp-stair-rotate"
|
||||
cx=${rotatePoint[0]} cy=${rotatePoint[1]} r=${this.owner._gridPitch * 0.65}
|
||||
@pointerdown=${(event: PointerEvent) => this.pointerDown(event, stair, 'rotate')}></circle>
|
||||
` : nothing}
|
||||
</g>`;
|
||||
});
|
||||
return svg`<g class="hp-stairs-layer">${items}</g>` as unknown as TemplateResult;
|
||||
const items = this.stairs.map((stair) => this.renderStair(
|
||||
stair, spaceIds, this.inputEnabled && this.selectedId === stair.id, false,
|
||||
));
|
||||
const draft = this.draft ? this.renderStair(this.draft.stair, spaceIds, true, true) : nothing;
|
||||
return svg`<g class="hp-stairs-layer">${items}${draft}</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* The selection frame (#676 К2) lives in the card's top overlay, above wall
|
||||
* bodies, with the decor frame's chrome: finger-sized invisible hit circles,
|
||||
* quarter-size visible beads, cursors by the world bearing of each handle.
|
||||
*/
|
||||
public renderFrame(view: { w: number; h: number }): TemplateResult | typeof nothing {
|
||||
if (!this.inputEnabled) return nothing;
|
||||
const stair = this.draft?.stair ?? this.stairs.find((item) => item.id === this.selectedId);
|
||||
if (!stair) return nothing;
|
||||
const hr = Math.max(view.w, view.h) * 0.018;
|
||||
const kr = hr / 4;
|
||||
const stem = stairRotateHandle(stair, hr * 2.2);
|
||||
const box = stairBox(stair);
|
||||
const geometry = cachedStairRenderGeometry(stair, this.owner._cellCm);
|
||||
const stop = (event: Event): void => { event.stopPropagation(); };
|
||||
const handles = stairHandles(stair).map((handle) => svg`<circle
|
||||
class="dthandle hp-stair-resize dt-${resizeCursor(handle.normalDeg)}"
|
||||
cx=${handle.point[0]} cy=${handle.point[1]} r=${hr.toFixed(1)}
|
||||
@pointerdown=${(event: PointerEvent) => this.pointerDown(event, stair, 'resize', { sx: handle.sx, sy: handle.sy })}
|
||||
@click=${stop}></circle>
|
||||
<circle class="dtknob" cx=${handle.point[0]} cy=${handle.point[1]} r=${kr.toFixed(2)}></circle>`);
|
||||
const outline = stair.kind === 'spiral'
|
||||
? svg`<circle class="dtbox" cx=${box.cx} cy=${box.cy} r=${box.w / 2}></circle>`
|
||||
: svg`<polygon class="dtbox" points=${geometry.outline.map((point) => point.join(',')).join(' ')}></polygon>`;
|
||||
// A draft has no handles yet: the pointer that draws it is the only gesture.
|
||||
return svg`<g class="dtframe hp-stair-frame" data-hp="stair-frame" data-id=${stair.id}>
|
||||
${outline}
|
||||
${this.draft ? nothing : svg`
|
||||
<line class="dtstem" x1=${stem.from[0]} y1=${stem.from[1]} x2=${stem.to[0]} y2=${stem.to[1]}></line>
|
||||
<circle class="dthandle dtrot hp-stair-rotate" cx=${stem.to[0]} cy=${stem.to[1]} r=${hr.toFixed(1)}
|
||||
@pointerdown=${(event: PointerEvent) => this.pointerDown(event, stair, 'rotate')}
|
||||
@click=${stop}></circle>
|
||||
<circle class="dtknob" cx=${stem.to[0]} cy=${stem.to[1]} r=${kr.toFixed(2)}></circle>
|
||||
${handles}`}
|
||||
</g>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
public renderDialog(): TemplateResult | typeof nothing {
|
||||
@@ -452,14 +562,17 @@ export class StairEditorRuntime {
|
||||
const directionOptions: ReadonlyArray<readonly [string, I18nKey]> = dialog.kind === 'straight'
|
||||
? [['forward', 'stairs.forward'], ['backward', 'stairs.backward']] as const
|
||||
: [['clockwise', 'stairs.clockwise'], ['counterclockwise', 'stairs.counterclockwise']] as const;
|
||||
const imperial = this.owner._imperial;
|
||||
const bound = (cm: number): string => String(Math.round((imperial ? cm / 2.54 : cm) * 100) / 100);
|
||||
const field = (key: 'length' | 'width' | 'radius', label: I18nKey) => html`
|
||||
<label>${this.owner._t(label)}</label>
|
||||
<div class="row"><input class="namein tempin" type="number" min="0.01" step="any"
|
||||
<div class="row"><input class="namein tempin" type="number"
|
||||
min=${bound(STAIR_MIN_CM)} max=${bound(STAIR_MAX_CM)} step="any"
|
||||
.value=${dialog[key]}
|
||||
@input=${(event: Event) => this.updateDialog({
|
||||
[key]: (event.target as HTMLInputElement).value,
|
||||
})}><span class="opl">${this.owner._t(
|
||||
this.owner._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm',
|
||||
imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm',
|
||||
)}</span></div>`;
|
||||
return html`<hp-dialog .hass=${this.owner.hass} data-kind="stairs" wide
|
||||
.title=${this.owner._t('stairs.properties')} icon="mdi:stairs"
|
||||
@@ -470,12 +583,12 @@ export class StairEditorRuntime {
|
||||
@change=${(event: Event) => {
|
||||
const kind = (event.target as HTMLSelectElement).value as Stair['kind'];
|
||||
const converted = convertStairKind(current, kind);
|
||||
const sizes = stairPhysicalSizeCm(converted, this.owner._cellCm);
|
||||
const cellCm = this.owner._cellCm;
|
||||
this.updateDialog({
|
||||
kind,
|
||||
length: cmToField(kind === 'straight' ? sizes[0] : 100, this.owner._imperial),
|
||||
width: cmToField(kind === 'straight' ? sizes[1] : 100, this.owner._imperial),
|
||||
radius: cmToField(kind === 'spiral' ? sizes[0] : 90, this.owner._imperial),
|
||||
length: stairFieldOf(converted.kind === 'straight' ? converted.length : converted.radius * 2, cellCm, imperial),
|
||||
width: stairFieldOf(converted.kind === 'straight' ? converted.width : converted.radius * 2, cellCm, imperial),
|
||||
radius: stairFieldOf(converted.kind === 'spiral' ? converted.radius : Math.max(converted.length, converted.width) / 2, cellCm, imperial),
|
||||
direction: converted.direction,
|
||||
});
|
||||
}}>
|
||||
|
||||
+16
-1
@@ -14,7 +14,10 @@ export interface StairViewHostPort {
|
||||
_suppressClick: boolean;
|
||||
_cellCm: number;
|
||||
_tabClick(spaceId: string): void;
|
||||
_t(key: 'markup.stairs'): string;
|
||||
_t(key: 'markup.stairs' | 'stairs.tooltip_navigate', vars?: Record<string, string | number>): string;
|
||||
/** The card's pointer tooltip: shown only for a hover-capable pointer. */
|
||||
_showTip(event: PointerEvent, title: string, meta: string): void;
|
||||
_clearPointerHover(): void;
|
||||
}
|
||||
|
||||
type ViewPress = { pid: number; startedAt: number };
|
||||
@@ -65,6 +68,15 @@ export class StairViewRuntime {
|
||||
stair, this.owner._space, spaceIds, this.owner._hasFixedFloor,
|
||||
);
|
||||
const active = interactive && targetState === 'active';
|
||||
// #676 К8: the tooltip has exactly the link's condition — `active` — so a
|
||||
// missing, self, deleted or fixed-floor target never announces a floor.
|
||||
const targetTitle = active
|
||||
? this.owner._model.find((item) => item.id === stair.target_space_id)?.title ?? ''
|
||||
: '';
|
||||
const tip = (event: PointerEvent): void => {
|
||||
if (!active) return;
|
||||
this.owner._showTip(event, this.owner._t('stairs.tooltip_navigate', { title: targetTitle }), '');
|
||||
};
|
||||
const navigate = (event: Event): void => {
|
||||
event.stopPropagation();
|
||||
if (event.type === 'keydown') this.suppressClick = false;
|
||||
@@ -78,6 +90,9 @@ export class StairViewRuntime {
|
||||
role=${active ? 'link' : 'img'} tabindex=${active ? '0' : nothing}
|
||||
aria-label=${this.owner._t('markup.stairs')}
|
||||
@click=${navigate}
|
||||
@pointerenter=${tip}
|
||||
@pointermove=${tip}
|
||||
@pointerleave=${() => { if (active) this.owner._clearPointerHover(); }}
|
||||
@keydown=${(event: KeyboardEvent) => {
|
||||
if (active && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -976,10 +976,12 @@ export const planStyles = css`
|
||||
.dtframe .dtrot { cursor: grab; }
|
||||
.dtframe .dt-ew { cursor: ew-resize; }
|
||||
.dtframe .dt-ns { cursor: ns-resize; }
|
||||
.dtfurnitureframe .dtrot {
|
||||
.dtfurnitureframe .dtrot,
|
||||
.hp-stair-frame .dtrot {
|
||||
cursor: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M18.4 7.2A8 8 0 1 0 20 12' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='m15.5 3.8 3.2 3.5-4.6.8' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") 12 12, grab;
|
||||
}
|
||||
.dtfurnitureframe .dtrot:active { cursor: grabbing; }
|
||||
.dtfurnitureframe .dtrot:active,
|
||||
.hp-stair-frame .dtrot:active { cursor: grabbing; }
|
||||
.dtframe .dtendpoint { cursor: crosshair; }
|
||||
.bdframe .dtrot { cursor: grab; }
|
||||
.dtarea {
|
||||
@@ -1592,21 +1594,10 @@ export const planStyles = css`
|
||||
stroke: var(--hp-accent);
|
||||
stroke-width: calc(3px / var(--hp-plan-screen-scale, 1));
|
||||
}
|
||||
.hp-stair-handle {
|
||||
fill: var(--hp-accent);
|
||||
stroke: var(--hp-bg, #fff);
|
||||
stroke-width: 2px;
|
||||
vector-effect: non-scaling-stroke;
|
||||
pointer-events: all;
|
||||
touch-action: none;
|
||||
}
|
||||
.hp-stair-resize { cursor: nwse-resize; }
|
||||
.hp-stair-rotate { cursor: grab; }
|
||||
.hp-stair-rotate-leader {
|
||||
stroke: var(--hp-accent);
|
||||
stroke-width: 1.5px;
|
||||
vector-effect: non-scaling-stroke;
|
||||
pointer-events: none;
|
||||
}
|
||||
/* #676: the frame and its handles are the decor frame's chrome (.dtframe,
|
||||
.dthandle, .dtknob, dt-* cursors) painted in the top overlay; the draft
|
||||
being drawn is inert until it is placed */
|
||||
.hp-stair.draft .hp-stair-hit { pointer-events: none; }
|
||||
.hp-stair.input-enabled .hp-stair-hit { cursor: move; }
|
||||
.alignmsg { margin: 0 0 8px; font-size: 13px; line-height: 1.45; }
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user