feat: добавить лестницы между этажами (#663)

Прямые и винтовые лестницы получили отдельную модель, инструменты редактора, безопасную межэтажную навигацию, вычитание из чистой площади и плоское отображение в 2.5D.

Issue: #663
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-26 21:00:43 +03:00
parent 781902b3a8
commit d03a68b88a
70 changed files with 2635 additions and 107 deletions
+11
View File
@@ -0,0 +1,11 @@
/**
* Dependency-free coordinate constants shared by low-level geometry modules.
* Keep this leaf free of model/render imports: modules such as stairs are
* consumed by space-geometry itself and must not form an ESM initialisation
* cycle through that higher-level facade.
*/
export const NORM_W = 1000;
export const CANVAS_LIMIT = 5000;
export const GRID_N = 240;
export const GRID_PITCH = NORM_W / GRID_N;
export const GRID_STEP_N = 1 / GRID_N;
+2 -2
View File
@@ -27,13 +27,13 @@ export const lruWrite = <K, V>(cache: Map<K, V>, key: K, value: V, limit: number
};
export type MarkupTool = 'select' | 'draw' | 'column' | 'merge' | 'split' | 'resize'
| 'opening' | 'wallthick' | 'delroom';
| 'opening' | 'stairs' | 'wallthick' | 'delroom';
export type DecorTool = 'select' | 'backdrop' | 'line' | 'rect' | 'ellipse' | 'text'
| 'furniture' | 'image' | 'erase';
const MARKUP_TOOLS = new Set<MarkupTool>([
'select', 'draw', 'column', 'merge', 'split', 'resize',
'opening', 'wallthick', 'delroom',
'opening', 'stairs', 'wallthick', 'delroom',
]);
/** Warm viewport may contain a tool token written by an older bundle. */
+51
View File
@@ -0,0 +1,51 @@
import { floorMinusBodies, geometryArea, polyclipPathD } from './physical-geometry';
import { geometryAreaMinusStairs } from './stairs';
import type { RoomCfg, SpaceModel } from './types';
import { lruRead, lruWrite } from './card-runtime';
import type { Geom } from 'polyclip-ts';
export type CleanFloorResult = {
floor: number[][];
geom: Geom | null;
path: string;
area: number;
};
export function cleanFloorForRoom(input: {
room: RoomCfg;
floor: number[][];
space?: SpaceModel;
configEpoch: number;
resizePreview: boolean;
cache: Map<string, CleanFloorResult>;
physicalBodies(space: SpaceModel): number[][][];
}): CleanFloorResult {
const { room, floor, space } = input;
if (!space) return {
floor, geom: null, path: '', area: geometryArea([[[...floor, floor[0]]]]),
};
const roomKey = room.id || `#${space.rooms.indexOf(room)}`;
const key = `${space.id}|${input.configEpoch}|${roomKey}`;
if (!input.resizePreview) {
const cached = lruRead(input.cache, key);
if (cached.hit) return cached.value;
}
const xs = floor.map((point) => point[0]);
const ys = floor.map((point) => point[1]);
const box = [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)];
const candidates = input.physicalBodies(space).filter((body) => {
const bx = body.map((point) => point[0]);
const by = body.map((point) => point[1]);
return Math.max(...bx) >= box[0] && Math.min(...bx) <= box[2]
&& Math.max(...by) >= box[1] && Math.min(...by) <= box[3];
});
const geom = candidates.length ? floorMinusBodies(floor, candidates) : null;
const result = {
floor,
geom,
path: geom ? polyclipPathD(geom) : '',
area: geometryAreaMinusStairs(geom || [[[...floor, floor[0]]]], space.stairs),
};
if (!input.resizePreview) lruWrite(input.cache, key, result, 600);
return result;
}
+7
View File
@@ -357,6 +357,13 @@ export function canonicalizeConfigGeometryInPlace<T>(config: T): T {
scalarFields(decor, ['scale', 'angle']);
}
}
for (const stair of records(space.stairs)) {
// #663 stairs inherit furniture's continuous transform contract. A wall
// face may be an arbitrary half-thickness away from the grid, so only
// remove insignificant JSON noise; never pull the magnet result onto a
// lattice node.
scalarFields(stair, ['x', 'y', 'length', 'width', 'radius', 'angle']);
}
// Legacy-only read boundary; current v10 documents reject this field.
for (const draft of records(space.room_drafts)) latticePoints(draft.points);
+37 -37
View File
@@ -116,7 +116,7 @@ import {
} from './config-reload-authority';
import type { OptimisticAttempt } from './serialized-write-queue';
import {
COLUMN_MAX_CM, directionalOccluders, floorMinusBodies, geometryArea, geometryOuterRings,
COLUMN_MAX_CM, directionalOccluders, floorMinusBodies, geometryOuterRings,
polyclipPathD, physicalBodyParts, type PartitionOpeningCut,
} from './physical-geometry';
import { partitionOpeningFace, resolvePartitionOpeningCompat } from './partition-openings';
@@ -265,6 +265,8 @@ import {
type HaBindingStatus, type HaRegistrySnapshot,
} from './ha-binding-status';
import type { DecorShape, DecorStyle } from './editors/decor/types';
import { StairViewRuntime, type StairViewHostPort } from './stairs-view';
import { cleanFloorForRoom, type CleanFloorResult } from './clean-floor';
import {
DECOR_ASSETS_API_VERSION, decorAssetIds, projectDecorImage,
resolveDecorAssets, type DecorAsset,
@@ -430,6 +432,7 @@ interface SpaceGeometryState {
partitions?: PartitionCfg[];
wall_columns?: WallColumnCfg[];
decor?: DecorShape[];
stairs?: import('./stairs').Stair[];
plan_transform: {
plan_x?: number; plan_y?: number; plan_scale?: number;
plan_scale_x?: number; plan_scale_y?: number; plan_angle?: number;
@@ -1675,6 +1678,7 @@ export class HouseplanCard extends LitElement {
return this._mode === 'plan';
}
private _tool: MarkupTool = 'draw';
private _stairsView = new StairViewRuntime(this as unknown as StairViewHostPort);
/** UX-04: one named, 50-step command history for every plan-geometry tool. */
private _geometryHistory = new CommandStack<SpaceGeometryState>(50);
/** #74: independent session-local history for manual device placements. */
@@ -1783,9 +1787,7 @@ export class HouseplanCard extends LitElement {
/** Light cuts are type/floor-specific and differ from drawn masonry, but HA
* state ticks must not rebuild independent-wall topology. */
private _lightPhysicalBodiesCache: { key: string; all: number[][][] } | null = null;
private _cleanFloorCache = new Map<string, {
floor: number[][]; geom: any; path: string; area: number;
}>();
private _cleanFloorCache = new Map<string, CleanFloorResult>();
private _innerContourCache = new Map<string, number[][] | null>();
private readonly _glowRuntimeState: GlowRuntimeState = createGlowRuntimeState();
private readonly _glowRuntimeHost: GlowRuntimeHost = {
@@ -2891,6 +2893,10 @@ export class HouseplanCard extends LitElement {
}
if (!this._markup) return;
if ((undo || redo) && inField) return; // keep native text-field history
if ((e.key === 'Delete' || e.key === 'Backspace')
&& !inField && !inEditorSecondary && this._editorRuntime?.stairs.deleteSelected()) {
e.preventDefault(); return;
}
if ((e.key === 'Delete' || e.key === 'Backspace') && this._physicalSel
&& !inField && !inEditorSecondary) {
e.preventDefault();
@@ -2904,6 +2910,7 @@ export class HouseplanCard extends LitElement {
}
if (undo) {
e.preventDefault();
if (this._editorRuntime?.stairs.undoActiveDrag()) return;
if (this._resize?.dragging) {
this._rszCancelDrag();
return;
@@ -2932,6 +2939,7 @@ export class HouseplanCard extends LitElement {
return;
}
if (e.key !== 'Escape') return;
if (this._editorRuntime?.stairs.escape()) { e.preventDefault(); return; }
if (this._physicalDrag || this._physicalRotate) {
e.preventDefault();
this._cancelPhysicalGesture();
@@ -6732,6 +6740,7 @@ export class HouseplanCard extends LitElement {
}
private _stagePointerMove(ev: PointerEvent): void {
if (this._editorRuntime?.stairs.pointerMove(ev)) return;
if (this._physicalRotate?.pid === ev.pointerId) {
this._physicalRotateMove(ev);
return;
@@ -6902,6 +6911,8 @@ export class HouseplanCard extends LitElement {
}
}
}
this._stairsView.pointerUp(ev);
if (this._editorRuntime?.stairs.pointerUp(ev)) return;
if (this._physicalDrag?.pid === ev.pointerId) {
this._physicalUp(ev);
return;
@@ -7431,7 +7442,8 @@ export class HouseplanCard extends LitElement {
void this._requestMode(mode, animate);
return;
}
return this._editorRuntime._setMode(mode, animate);
this._editorRuntime.stairs.beforeModeChange(mode); this._editorRuntime._setMode(mode, animate);
this._editorRuntime.stairs.afterModeChange(); this._stairsView.clearGesture();
}
/** Prime the Draw thickness field to 15 cm once per Plan session. */
@@ -7696,8 +7708,10 @@ export class HouseplanCard extends LitElement {
this._decorMove = null;
this._dtDrag = null;
this._bdDrag = null;
this._stairsView.clearGesture();
return;
}
this._editorRuntime.stairs.clearGesture();
return this._editorRuntimeOrThrow()._clearGeometryGesture();
}
@@ -7707,6 +7721,8 @@ export class HouseplanCard extends LitElement {
this._flushHa();
this._editorRuntime?._cancelPointerMove('markup-hover');
if (this._roomPointer?.pointerId === ev.pointerId) this._roomPointer = null; this._doubleFit.clear();
this._stairsView.pointerCancel(ev);
if (this._editorRuntime?.stairs.pointerCancel(ev)) return;
if (this._editorRuntime) return this._editorRuntime._stagePointerCancel(ev);
this._pointers.delete(ev.pointerId);
if (this._pointers.size < 2) this._pinchStart = null;
@@ -7714,6 +7730,9 @@ export class HouseplanCard extends LitElement {
this._panStart = null;
this._panLock = null;
this._swipeStart = null;
// Keep the compatibility click from this cancelled pointer blocked, but
// do not poison the next deliberate gesture indefinitely.
if (this._suppressClick) setTimeout(() => (this._suppressClick = false), 0);
}
if (this._pointers.size === 0) this._finishViewportGesture();
}
@@ -8580,7 +8599,7 @@ export class HouseplanCard extends LitElement {
role: 'tool', invoke: () => this._activateOpeningPlacement('gate'),
},
],
}];
}, this._editorRuntimeOrThrow().stairs.toolbarGroup()];
}
/** Stable target identity + current config epoch + operation revision. */
@@ -8591,6 +8610,8 @@ export class HouseplanCard extends LitElement {
if (this._mode === 'plan') {
const sel = this._physicalSel;
if (sel) return `${base}:selection:${sel.kind}:${sel.id}`;
const stairKey = this._editorRuntime?.stairs.selectionKey();
if (stairKey) return `${base}:${stairKey}`;
return `${base}:tool:${this._tool}:${this._path.length}`;
}
if (this._mode === 'decor') {
@@ -8618,7 +8639,7 @@ export class HouseplanCard extends LitElement {
|| this._infoCard || this._rulesDialog || this._settingsDialog || this._supportDialog
|| this._alignDialog || this._importDialog || this._kioskDialog || this._summary?.blocksOtherDialogs()
|| this._backupExportDialog || this._backupImportDialog
|| this._wallDialog);
|| this._wallDialog || this._editorRuntime?.stairs.dialogOpen);
}
private _renderEditorSecondary(): TemplateResult | typeof nothing {
@@ -9474,37 +9495,13 @@ export class HouseplanCard extends LitElement {
* rooms which touch only a small subset of independent bodies. */
private _cleanFloor(
room: RoomCfg, floor: number[][], space: SpaceModel | undefined = this._spaceModel(),
): { floor: number[][]; geom: any; path: string; area: number } {
if (!space) {
return {
floor, geom: null, path: '',
area: geometryArea([[[...floor, floor[0]]]]),
};
}
const roomKey = room.id || `#${space.rooms.indexOf(room)}`;
const key = `${space.id}|${this._cfgEpoch}|${roomKey}`;
if (!this._resize?.preview) {
const cached = lruRead(this._cleanFloorCache, key);
if (cached.hit) return cached.value;
}
const xs = floor.map((p) => p[0]), ys = floor.map((p) => p[1]);
const box = [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)];
const candidates = this._physicalBodiesR(space).filter((body) => {
const bx = body.map((p) => p[0]), by = body.map((p) => p[1]);
return Math.max(...bx) >= box[0] && Math.min(...bx) <= box[2]
&& Math.max(...by) >= box[1] && Math.min(...by) <= box[3];
): CleanFloorResult {
return cleanFloorForRoom({
room, floor, space, configEpoch: this._cfgEpoch,
resizePreview: !!this._resize?.preview,
cache: this._cleanFloorCache,
physicalBodies: (model) => this._physicalBodiesR(model),
});
const geom = candidates.length ? floorMinusBodies(floor, candidates) : null;
const result = {
floor,
geom,
path: geom ? polyclipPathD(geom) : '',
area: geom ? geometryArea(geom) : geometryArea([[[...floor, floor[0]]]]),
};
if (!this._resize?.preview) {
lruWrite(this._cleanFloorCache, key, result, 600);
}
return result;
}
/** Effective auto icon for a binding selected but not saved yet. */
@@ -11069,6 +11066,8 @@ export class HouseplanCard extends LitElement {
hide_decor visual-only: the decor editor must always paint
stored shapes so they remain editable. */}
${disp.hideDecor && this._mode !== 'decor' ? nothing : this._renderDecorLayer(undefined, view)}
${this._mode === 'plan' && this._editorRuntime
? this._editorRuntime.stairs.renderLayer() : this._stairsView.renderLayer()}
${glowLayerVisible ? this._renderGlowLayer(space, disp, view) : nothing}
${this._renderSunRays(space)}
${this._editing ? svg`<g class="hp-editor-only-layer"
@@ -11206,6 +11205,7 @@ export class HouseplanCard extends LitElement {
${this._decorShapeDialog ? this._editorRuntime ? this._renderDecorShapeDialog() : nothing : nothing}
${this._backdropDialog ? this._editorRuntime ? this._renderBackdropDialog() : nothing : nothing}
${this._decorEraseConfirm ? this._editorRuntime ? this._renderDecorEraseConfirm() : nothing : nothing}
${this._editorRuntime?.stairs.dialogOpen ? this._editorRuntime.stairs.renderDialog() : nothing}
${this._spaceDialog
? (this._onboardingRuntime || this._editorRuntime) ? this._renderSpaceDialog() : nothing
: nothing}
+13 -12
View File
@@ -222,7 +222,7 @@ import {
type SupportDialogState,
type SupportPreview,
} from './support-feedback';
import { renderBackdropGuard, renderPlanBackdropGuard, stagePlanFile, uploadPlanFile } from './backdrop-pick';
import { renderBackdropGuard, renderPlanBackdropGuard, stagePlanFile, uploadPlanFile } from './backdrop-pick'; import { StairEditorRuntime, type StairEditorHostPort } from './stairs-editor';
import { CommandStack } from './command-stack';
import type { DeviceLayout, DevicePositionState } from './device-position-history';
import { contentFingerprint } from './visual-continuity';
@@ -333,12 +333,10 @@ const NORM_W = 1000; // side of the render space — the canvas is square (v1.48
/** #313: one Thickness-tool hit — a room interval or independent masonry. */
type WallThickSource = { kind: 'room' }
| { kind: 'partition'; id: string };
type WallThickHit = {
a: number[]; b: number[]; roomId: string; segs: number[][];
open: boolean; cm: number; source: WallThickSource;
};
type WallThickHit = { a: number[]; b: number[]; roomId: string; segs: number[][];
open: boolean; cm: number; source: WallThickSource };
type MarkupTool = 'select' | 'draw' | 'column' | 'merge' | 'split' | 'resize' | 'opening' | 'wallthick' | 'delroom';
type MarkupTool = 'select' | 'draw' | 'column' | 'merge' | 'split' | 'resize' | 'opening' | 'stairs' | 'wallthick' | 'delroom';
type WallFaceCandidate = WallGraphFace & {
split?: { roomId: string; mainPoly: number[][]; newPoly: number[][] };
consumeAllActive?: boolean;
@@ -375,7 +373,7 @@ interface SpaceGeometryState {
open_spans?: OpenSpanEntry[];
partitions?: PartitionCfg[];
wall_columns?: WallColumnCfg[];
decor?: DecorShape[];
decor?: DecorShape[]; stairs?: import('./stairs').Stair[];
plan_transform: {
plan_x?: number; plan_y?: number; plan_scale?: number;
plan_scale_x?: number; plan_scale_y?: number; plan_angle?: number;
@@ -803,6 +801,7 @@ export { EDITOR_LANGUAGE_RUNTIME }; // #627: see src/i18n/editor-language.ts
export class HouseplanEditorRuntime {
public readonly languageRuntime = EDITOR_LANGUAGE_RUNTIME;
public readonly stairs = new StairEditorRuntime(this.host as unknown as StairEditorHostPort);
private _junctionBaselineCache = new WeakMap<object, {
spaceId: string; fingerprint: string; violations: JunctionLimitViolation[];
}>();
@@ -1224,6 +1223,7 @@ public _activateMarkupTool(tool: MarkupTool): void {
else this.host._resize.reset();
}
this.host._tool = tool;
if (tool !== 'select' && tool !== 'stairs') this.stairs.clearSelection();
if (tool === 'resize') this.host._resize.selectRoom(null);
if (tool === 'wallthick') this.host._wallDialog = null;
}
@@ -1567,7 +1567,7 @@ public _geometrySnapshotFromConfig(config: any, spaceId: string): SpaceGeometryS
...(Array.isArray((sp as any).wall_columns)
? { wall_columns: copy((sp as any).wall_columns) }
: {}),
...(Array.isArray(sp.decor) ? { decor: copy(sp.decor) } : {}),
...(Array.isArray(sp.decor) ? { decor: copy(sp.decor) } : {}), ...(Array.isArray(sp.stairs) ? { stairs: copy(sp.stairs) } : {}),
plan_transform,
};
}
@@ -1609,7 +1609,7 @@ public _restoreGeometryStateInConfig(
}
}
const assign = (key: 'openings' | 'walls' | 'wall_segments' | 'open_spans'
| 'partitions' | 'wall_columns' | 'decor', value: unknown): void => {
| 'partitions' | 'wall_columns' | 'decor' | 'stairs', value: unknown): void => {
if (value !== undefined) (sp as any)[key] = copy(value);
else if (!(preserveIdentityHints && key === 'wall_segments')) delete (sp as any)[key];
};
@@ -1620,7 +1620,7 @@ public _restoreGeometryStateInConfig(
assign('open_spans', state.open_spans);
assign('partitions', state.partitions);
assign('wall_columns', state.wall_columns);
assign('decor', state.decor);
assign('decor', state.decor); assign('stairs', state.stairs);
if (preserveIdentityHints) {
for (const opening of sp.openings || []) {
const old: any = oldOpenings.get(opening.id);
@@ -2008,7 +2008,7 @@ public _stagePointerCancel(ev: PointerEvent): void {
if (this.host._pointers.size < 2) this.host._pinchStart = null;
if (this.host._pointers.size === 0) {
this.host._panStart = null;
this.host._panLock = null;
this.host._panLock = null; if (this['host']._suppressClick) setTimeout(() => (this['host']._suppressClick = false), 0);
}
if (this.host._viewportGestureDirty && this.host._pointers.size === 0) {
this.host._viewportGestureDirty = false;
@@ -2231,7 +2231,7 @@ public _markupClick(ev: MouseEvent): void {
if (path.some((n) => n?.classList?.contains?.('physical-hit'))) return;
const raw = this._svgPoint(ev);
if (this.host._tool === 'select') {
this.host._physicalSel = null;
this.host._physicalSel = null; this.stairs.clearSelection();
return;
}
if (this.host._tool === 'resize') {
@@ -2266,6 +2266,7 @@ public _markupClick(ev: MouseEvent): void {
this._columnClick(raw);
return;
}
if (this['host']._tool === 'stairs') { this.stairs.placeAt(raw); return; }
// Walls: every completed segment is immediately an ordinary independent wall.
this.host._wallRepairDiagnostic = null;
const resolved = this._resolvePlanDrawPoint(raw, ev.shiftKey);
+23
View File
@@ -30,6 +30,28 @@
"title.markup_column": "Säule: Klicken Sie auf einen Rasterpunkt, um eine quadratische Säule zu platzieren",
"markup.partition": "Trennwand",
"markup.column": "Säule",
"markup.stairs": "Treppe",
"stairs.straight": "Gerade",
"stairs.spiral": "Spindeltreppe",
"stairs.properties": "Treppeneigenschaften",
"stairs.type": "Typ",
"stairs.length": "Länge",
"stairs.width": "Breite",
"stairs.radius": "Radius",
"stairs.rotation": "Drehung",
"stairs.direction": "Aufstiegsrichtung",
"stairs.forward": "Vorwärts",
"stairs.backward": "Rückwärts",
"stairs.clockwise": "Im Uhrzeigersinn",
"stairs.counterclockwise": "Gegen den Uhrzeigersinn",
"stairs.target": "Führt zu Etage",
"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.",
"history.stair_add": "Treppe hinzufügen",
"history.stair_edit": "Treppe ändern",
"history.stair_move": "Treppe verschieben",
"history.stair_delete": "Treppe löschen",
"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",
@@ -281,6 +303,7 @@
"space.copy_error_partitions_limit": "Dieser Bereich enthält zu viele Wände zum Kopieren.",
"space.copy_error_openings_limit": "Dieser Bereich enthält zu viele Öffnungen zum Kopieren.",
"space.copy_error_decor_limit": "Dieser Bereich enthält zu viele Hintergrundobjekte zum Kopieren.",
"space.copy_error_stairs_limit": "Dieser Bereich enthält zu viele Treppen zum Kopieren.",
"space.copy_error_columns_limit": "Dieser Bereich enthält zu viele Säulen zum Kopieren.",
"space.copy_error_opening_host": "Eine Öffnung ist keiner Wand mehr zugeordnet. Korrigieren oder löschen Sie sie vor dem Kopieren.",
"space.copy_error_geometry": "Die Kopie würde eine unsichere Wandgeometrie erzeugen. Korrigieren Sie den Quellbereich.",
+23
View File
@@ -30,6 +30,28 @@
"title.markup_column": "Column: click a grid point to place a square column",
"markup.partition": "Partition",
"markup.column": "Column",
"markup.stairs": "Stairs",
"stairs.straight": "Straight",
"stairs.spiral": "Spiral",
"stairs.properties": "Stair properties",
"stairs.type": "Type",
"stairs.length": "Length",
"stairs.width": "Width",
"stairs.radius": "Radius",
"stairs.rotation": "Rotation",
"stairs.direction": "Ascent direction",
"stairs.forward": "Forward",
"stairs.backward": "Backward",
"stairs.clockwise": "Clockwise",
"stairs.counterclockwise": "Counterclockwise",
"stairs.target": "Leads to floor",
"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.",
"history.stair_add": "Add stairs",
"history.stair_edit": "Change stairs",
"history.stair_move": "Move stairs",
"history.stair_delete": "Delete stairs",
"markup.hint_column": "click a grid point to place a square column",
"history.wall_segment": "Add wall segment",
"history.column_add": "Add column",
@@ -281,6 +303,7 @@
"space.copy_error_partitions_limit": "This space has too many walls to copy.",
"space.copy_error_openings_limit": "This space has too many openings to copy.",
"space.copy_error_decor_limit": "This space has too many background objects to copy.",
"space.copy_error_stairs_limit": "This space has too many stairs to copy.",
"space.copy_error_columns_limit": "This space has too many columns to copy.",
"space.copy_error_opening_host": "One of the openings is no longer attached to a wall. Fix or delete it before copying.",
"space.copy_error_geometry": "The copy would create unsafe wall geometry. Fix the source space and try again.",
+23
View File
@@ -30,6 +30,28 @@
"title.markup_column": "Colonne : cliquez sur un point de la grille pour placer une colonne carrée",
"markup.partition": "Cloison",
"markup.column": "Colonne",
"markup.stairs": "Escalier",
"stairs.straight": "Droit",
"stairs.spiral": "Hélicoïdal",
"stairs.properties": "Propriétés de l’escalier",
"stairs.type": "Type",
"stairs.length": "Longueur",
"stairs.width": "Largeur",
"stairs.radius": "Rayon",
"stairs.rotation": "Rotation",
"stairs.direction": "Sens de montée",
"stairs.forward": "Avant",
"stairs.backward": "Arrière",
"stairs.clockwise": "Sens horaire",
"stairs.counterclockwise": "Sens antihoraire",
"stairs.target": "Mène à l’étage",
"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.",
"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",
"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",
@@ -281,6 +303,7 @@
"space.copy_error_partitions_limit": "Cet espace contient trop de murs pour être copié.",
"space.copy_error_openings_limit": "Cet espace contient trop d’ouvertures pour être copié.",
"space.copy_error_decor_limit": "Cet espace contient trop d’objets d’arrière-plan pour être copié.",
"space.copy_error_stairs_limit": "Cet espace contient trop d’escaliers pour être copié.",
"space.copy_error_columns_limit": "Cet espace contient trop de colonnes pour être copié.",
"space.copy_error_opening_host": "Une ouverture n’est plus rattachée à un mur. Corrigez-la ou supprimez-la avant la copie.",
"space.copy_error_geometry": "La copie créerait une géométrie de murs non sûre. Corrigez l’espace source.",
+23
View File
@@ -30,6 +30,28 @@
"title.markup_column": "Колонна: кликните точку сетки для квадратной колонны",
"markup.partition": "Перегородка",
"markup.column": "Колонна",
"markup.stairs": "Лестница",
"stairs.straight": "Прямая",
"stairs.spiral": "Винтовая",
"stairs.properties": "Свойства лестницы",
"stairs.type": "Тип",
"stairs.length": "Длина",
"stairs.width": "Ширина",
"stairs.radius": "Радиус",
"stairs.rotation": "Поворот",
"stairs.direction": "Направление подъёма",
"stairs.forward": "Вперёд",
"stairs.backward": "Назад",
"stairs.clockwise": "По часовой стрелке",
"stairs.counterclockwise": "Против часовой стрелки",
"stairs.target": "Ведёт на этаж",
"stairs.no_target": "Не выбран",
"stairs.target_warning": "Выберите другой существующий этаж, чтобы включить переход.",
"stairs.fixed_floor": "В карточке с фиксированным этажом переход отключён.",
"history.stair_add": "Добавить лестницу",
"history.stair_edit": "Изменить лестницу",
"history.stair_move": "Переместить лестницу",
"history.stair_delete": "Удалить лестницу",
"markup.hint_column": "кликните точку сетки, чтобы поставить квадратную колонну",
"history.wall_segment": "Добавление отрезка стены",
"history.column_add": "Добавление колонны",
@@ -281,6 +303,7 @@
"space.copy_error_partitions_limit": "В этом пространстве слишком много стен для копирования.",
"space.copy_error_openings_limit": "В этом пространстве слишком много проёмов для копирования.",
"space.copy_error_decor_limit": "В этом пространстве слишком много элементов подложки для копирования.",
"space.copy_error_stairs_limit": "В этом пространстве слишком много лестниц для копирования.",
"space.copy_error_columns_limit": "В этом пространстве слишком много колонн для копирования.",
"space.copy_error_opening_host": "Один из проёмов больше не привязан к стене. Исправьте или удалите его перед копированием.",
"space.copy_error_geometry": "Копия создаст небезопасную геометрию стен. Исправьте исходное пространство и повторите попытку.",
+29 -6
View File
@@ -5,7 +5,7 @@ import { canonicalFurnitureId } from '../furniture-id';
import { formatArea } from '../area-format';
import { roomPoly } from '../logic';
import {
floorMinusBodies, geometryAllRings, geometryArea, geometryOuterRings, physicalBodyParts,
floorMinusBodies, geometryAllRings, geometryOuterRings, physicalBodyParts,
pointInPhysicalGeometry,
} from '../physical-geometry';
import {
@@ -13,6 +13,9 @@ import {
type GeometryOpeningProjection,
} from '../plan-geometry-preflight';
import { labelPos, GRID_PITCH, GRID_STEP_N, NORM_W } from '../space-geometry';
import {
cachedStairRenderGeometry, geometryAreaMinusStairs, stairOutline, type Stair,
} from '../stairs';
import type { ServerConfig, SpaceModel } from '../types';
import {
innerContourForRoom, openingInnerFaceOffset, wallBodiesGeometry,
@@ -62,6 +65,7 @@ export interface PdfRawSpace {
cell_cm?: unknown;
walls?: WallEntry[];
decor?: DecorShape[];
stairs?: Stair[];
settings?: Record<string, unknown>;
[key: string]: unknown;
}
@@ -301,13 +305,10 @@ function preparePdfScene(input: PdfSceneInput): PreparedPdfScene {
const localBodies = !Number.isFinite(cachedArea)
? bodiesOverlappingRing(contour, built.extras) : [];
const candidates = localBodies.length ? floorMinusBodies(contour, localBodies) : null;
const cleanGeometry = candidates || [[[...contour, contour[0]]]];
const areaUnits = Number.isFinite(cachedArea) && (cachedArea as number) >= 0
? cachedArea as number
: candidates ? geometryArea(candidates) : Math.abs(
contour.reduce((sum, point, index) => {
const next = contour[(index + 1) % contour.length];
return sum + point[0] * next[1] - next[0] * point[1];
}, 0) / 2);
: geometryAreaMinusStairs(cleanGeometry, input.space.stairs);
roomAreas.set(room, areaUnits);
}
const architectureRings = built.geometry.status === 'failed-core'
@@ -315,6 +316,7 @@ function preparePdfScene(input: PdfSceneInput): PreparedPdfScene {
: built.geometry.components.flatMap((component) => geometryAllRings(component.geom));
const allBounds = [...architectureRings, ...built.zero.lines.map((line) => [[line[0], line[1]], [line[2], line[3]]])];
if (input.options.decor) allBounds.push(...decorBounds(input.rawSpace, true));
allBounds.push(...input.space.stairs.map((stair) => stairOutline(stair)));
for (const raster of input.rasters || []) allBounds.push(rotatedRectRing(
raster.x, raster.y, raster.drawWidth, raster.drawHeight, raster.angle,
));
@@ -460,6 +462,27 @@ function buildPdfCandidate(
}
}
// Stairs are plan geometry, not optional decor. Keep their PDF symbol and
// physical area semantics aligned with the two on-screen renderers.
const stairMatrix = {
a: pointPerUnit, b: 0, c: 0, d: pointPerUnit,
e: left - bounds.minX * pointPerUnit,
f: top - bounds.minY * pointPerUnit,
};
for (const stair of input.space.stairs) {
const geometry = cachedStairRenderGeometry(stair, built.cellCm);
commands.push({
kind: 'path', rings: [geometry.outline.map(pt)], stroke: INK, width: 0.25 * MM,
});
for (const tread of geometry.treads) commands.push({
kind: 'line', points: [pt(tread.a), pt(tread.b)], stroke: INK, width: 0.2 * MM,
});
commands.push({
kind: 'vector', ops: transformSvgPath(geometry.arrowPath, stairMatrix),
stroke: INK, width: 0.3 * MM,
});
}
if (input.options.dimensions && built.geometry.roomGeom) {
for (const [ringIndex, ring] of geometryOuterRings(built.geometry.roomGeom).entries()) {
const epsilon = dimensionEpsilonUnits(cmPerUnit);
+1
View File
@@ -31,6 +31,7 @@ const ERROR_KEYS: Record<SpaceCopyErrorCode, I18nKey> = {
partitions_limit: 'space.copy_error_partitions_limit',
openings_limit: 'space.copy_error_openings_limit',
decor_limit: 'space.copy_error_decor_limit',
stairs_limit: 'space.copy_error_stairs_limit',
columns_limit: 'space.copy_error_columns_limit',
opening_host_missing: 'space.copy_error_opening_host',
opening_host_unknown: 'space.copy_error_opening_host',
+6
View File
@@ -6,6 +6,7 @@ export const SPACE_COPY_LIMITS = Object.freeze({
openings: 500,
decor: 1000,
columns: 500,
stairs: 250,
});
export type SpaceCopyErrorCode =
@@ -16,6 +17,7 @@ export type SpaceCopyErrorCode =
| 'openings_limit'
| 'decor_limit'
| 'columns_limit'
| 'stairs_limit'
| 'opening_host_missing'
| 'opening_host_unknown'
| 'geometry_unsafe';
@@ -113,11 +115,13 @@ export function createSpaceCopyCandidate(
const openings = geometryList(source.openings);
const decor = geometryList(source.decor);
const columns = geometryList(source.wall_columns);
const stairs = geometryList(source.stairs);
const partitionCount = wallSegments.length + sourcePartitions.length;
if (partitionCount > SPACE_COPY_LIMITS.partitions) throw new SpaceCopyError('partitions_limit');
if (openings.length > SPACE_COPY_LIMITS.openings) throw new SpaceCopyError('openings_limit');
if (decor.length > SPACE_COPY_LIMITS.decor) throw new SpaceCopyError('decor_limit');
if (columns.length > SPACE_COPY_LIMITS.columns) throw new SpaceCopyError('columns_limit');
if (stairs.length > SPACE_COPY_LIMITS.stairs) throw new SpaceCopyError('stairs_limit');
let serial = 0;
const nextId = (prefix: string): string => `${prefix}${seed}${(serial++).toString(36)}`;
@@ -164,6 +168,7 @@ export function createSpaceCopyCandidate(
});
const copiedDecor = decor.map((item) => ({ ...clone(item), id: nextId('cd') }));
const copiedColumns = columns.map((item) => ({ ...clone(item), id: nextId('cc') }));
const copiedStairs = stairs.map((item) => ({ ...clone(item), id: nextId('cs') }));
const newSpaceId = `s${seed}`;
if (spaces.some((item) => item.id === newSpaceId)) throw new SpaceCopyError('source_invalid');
@@ -181,6 +186,7 @@ export function createSpaceCopyCandidate(
if (copiedOpenings.length) copiedSpace.openings = copiedOpenings;
if (copiedDecor.length) copiedSpace.decor = copiedDecor;
if (copiedColumns.length) copiedSpace.wall_columns = copiedColumns;
if (copiedStairs.length) copiedSpace.stairs = copiedStairs;
const config = clone(input);
config.spaces.splice(sourceIndex + 1, 0, copiedSpace);
+8 -7
View File
@@ -8,8 +8,10 @@ import type { ServerConfig, SpaceModel, RoomCfg, DevItem, OpeningCfg } from './t
import { boxCorners, normalizeAngle } from './editors/decor/geometry';
import { canonicalColumnAngle } from './physical-geometry';
import { gridVisualScale } from './grid-scale';
import { stairList, stairOutline } from './stairs';
import { CANVAS_LIMIT, GRID_N, GRID_PITCH, NORM_W } from './canvas-constants';
export const NORM_W = 1000; // side of the render space — the canvas is square
export { CANVAS_LIMIT, GRID_N, GRID_PITCH, GRID_STEP_N, NORM_W } from './canvas-constants';
export type StaticPassageOpening = OpeningCfg & {
type: 'passage'; rx: number; ry: number; rlen: number;
@@ -184,6 +186,7 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
cm: Number(c.cm),
...(c.shape === 'circle' ? {} : { angle: canonicalColumnAngle(c.angle) }),
})),
stairs: stairList(s.stairs),
} as SpaceModel;
});
}
@@ -194,18 +197,12 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
/** Sane coordinate range in NORMALISED units — mirrors validation.py.
* Not a frame: insurance against a stored 1e100 (HP-1500-03/HP-1501-01). */
export const CANVAS_LIMIT = 5000;
/** The same range in RENDER units. */
export const SANE_LIMIT = CANVAS_LIMIT * NORM_W;
/** Grid points across the plan width — the lattice the editor snaps to.
* It is derived from NORM_W alone, so it is the SAME step for every plan and
* it did NOT change when the canvas became infinite (docs/CANVAS.md §9). */
export const GRID_N = 240;
/** One grid step in RENDER units. */
export const GRID_PITCH = NORM_W / GRID_N;
/** One grid step in NORMALISED units — what the config and the layout store. */
export const GRID_STEP_N = 1 / GRID_N;
/** Snap a RENDER-unit coordinate to the editor's grid (docs/CANVAS.md §9). */
export function snapR(v: number): number {
@@ -328,6 +325,10 @@ export function contentItems(
): ContentItem[] {
const out: ContentItem[] = [];
for (const r of space.rooms || []) { const it = roomItem(r); if (it) out.push(it); }
for (const stair of space.stairs || []) {
const item = itemOf(stairOutline(stair));
if (item) out.push(item);
}
// The backdrop image is ONE OF the objects of the space, exactly like a room
// (docs/BACKDROP.md §4): cropping to the outlined rooms would hide the parts
// of the picture nobody has drawn over yet, and — since v1.58.0 — the
+21
View File
@@ -60,6 +60,7 @@ import { geometryOpenings } from './plan-geometry-preflight';
import { resolveDeviceAreaRelocations } from './device-area-relocation';
import { projectDecorImage } from './decor-assets';
import type { DecorShape } from './editors/decor/types';
import { cachedStairRenderGeometry, stairOutline } from './stairs';
import {
buildGlowClipGeometry, buildLightBarrierScene, forgetGlowSource, forgetGlowSpace,
glowSourceInOpaqueBody, pruneGlowSources, readGlowClip, renderGlowPools,
@@ -381,6 +382,14 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
minX: Math.min(line[0], line[2]), minY: Math.min(line[1], line[3]),
maxX: Math.max(line[0], line[2]), maxY: Math.max(line[1], line[3]),
});
for (const stair of space.stairs) {
const outline = stairOutline(stair);
const xs = outline.map((point) => point[0]), ys = outline.map((point) => point[1]);
if (xs.length) placed.push({
minX: Math.min(...xs), minY: Math.min(...ys),
maxX: Math.max(...xs), maxY: Math.max(...ys),
});
}
// Resolve architectural opening hosts before framing: tight house mode must
// include their maximum painted envelope without consulting live state.
@@ -893,6 +902,17 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
${renderOpeningVisibleGeometry(spec)}
</g>`;
});
const stairShapes = space.stairs.map((stair) => {
const geometry = cachedStairRenderGeometry(stair, cellCm);
return svg`<g class="hp-stair" data-hp="stair" data-id=${stair.id} data-kind=${stair.kind}
data-target-state="fixed"
aria-hidden="true" pointer-events="none">
<polygon class="hp-stair-outline" points=${geometry.outline.map((point) => point.join(',')).join(' ')}></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>`;
});
return html`
<div class="hp-static-stage${dayCycle ? ` daycycle phase-${dayCycle.phase}` : ''}"
@@ -941,6 +961,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
: nothing}
${passageGlowTunnels}
<g class="decorlayer" pointer-events="none">${decorImages}</g>
<g class="hp-stairs-layer" pointer-events="none">${stairShapes}</g>
${glowPools}
${wallUnion
? svg`<g class="wallbodies" style="--room-stroke:${wallStroke}">
+104
View File
@@ -0,0 +1,104 @@
import { GRID_N, NORM_W } from './canvas-constants';
import type { Stair } from './stairs';
export const STAIR_MIN_N = 0.0005;
const finite = (value: unknown): number | null => {
const number = Number(value);
return Number.isFinite(number) ? number : null;
};
const cmToNorm = (cm: number, cellCm: number): number => (
Number(cm) / ((Number(cellCm) > 0 ? Number(cellCm) : 5) * GRID_N)
);
const normToCm = (value: number, cellCm: number): number => (
Number(value) * (Number(cellCm) > 0 ? Number(cellCm) : 5) * GRID_N
);
export function normalizeStairAngle(value: unknown): number {
const number = finite(value) ?? 0;
const normalized = ((number % 360) + 360) % 360;
return Number(normalized.toFixed(6));
}
export function defaultStair(
kind: Stair['kind'], x: number, y: number, cellCm: number, id: string,
): Stair {
if (kind === 'spiral') return {
id, kind, x: x / NORM_W, y: y / NORM_W, angle: 0,
direction: 'clockwise', radius: cmToNorm(90, cellCm), target_space_id: null,
};
return {
id, kind, x: x / NORM_W, y: y / NORM_W, angle: 0,
direction: 'forward', length: cmToNorm(240, cellCm), width: cmToNorm(100, cellCm),
target_space_id: null,
};
}
export function convertStairKind(stair: Stair, kind: Stair['kind']): Stair {
if (stair.kind === kind) return { ...stair };
if (stair.kind === 'straight') return {
id: stair.id, kind: 'spiral', x: stair.x, y: stair.y, angle: stair.angle,
direction: stair.direction === 'backward' ? 'counterclockwise' : 'clockwise',
radius: Math.max(stair.length, stair.width) / 2,
target_space_id: stair.target_space_id ?? null,
};
return {
id: stair.id, kind: 'straight', x: stair.x, y: stair.y, angle: stair.angle,
direction: stair.direction === 'counterclockwise' ? 'backward' : 'forward',
length: stair.radius * 2, width: stair.radius * 2,
target_space_id: stair.target_space_id ?? null,
};
}
const supportAlong = (stair: Stair, dx: number, dy: number, scale: number): number => {
if (stair.kind === 'spiral') return stair.radius * scale;
const bearing = stair.angle * Math.PI / 180;
const ux = Math.cos(bearing), uy = Math.sin(bearing);
const vx = -uy, vy = ux;
return Math.abs(dx * ux + dy * uy) * stair.length * scale / 2
+ Math.abs(dx * vx + dy * vy) * stair.width * scale / 2;
};
/** Snap one independently movable footprint flush to the nearest other stair. */
export function snapStairToStairs(
stair: Stair,
center: readonly number[],
others: readonly Stair[],
reach: number,
scale = NORM_W,
): [number, number] {
let best: { x: number; y: number; gap: number } | null = null;
const moved = { ...stair, x: center[0] / scale, y: center[1] / scale } as Stair;
for (const other of others) {
if (other.id === stair.id) continue;
const ox = other.x * scale, oy = other.y * scale;
const dx0 = center[0] - ox, dy0 = center[1] - oy;
const distance = Math.hypot(dx0, dy0);
if (!(distance > 1e-8)) continue;
const dx = dx0 / distance, dy = dy0 / distance;
const target = supportAlong(moved, dx, dy, scale)
+ supportAlong(other, dx, dy, scale);
const gap = Math.abs(distance - target);
if (gap <= reach && (!best || gap < best.gap)) {
best = { x: ox + dx * target, y: oy + dy * target, gap };
}
}
return best ? [best.x, best.y] : [center[0], center[1]];
}
export function stairPhysicalSizeCm(stair: Stair, cellCm: number): number[] {
return stair.kind === 'straight'
? [normToCm(stair.length, cellCm), normToCm(stair.width, cellCm)]
: [normToCm(stair.radius, cellCm)];
}
export function stairTargetState(
stair: Stair, currentSpaceId: string, spaceIds: ReadonlySet<string>, fixedFloor: boolean,
): 'active' | 'missing' | 'self' | 'deleted' | 'fixed' {
if (fixedFloor) return 'fixed';
if (!stair.target_space_id) return 'missing';
if (stair.target_space_id === currentSpaceId) return 'self';
return spaceIds.has(stair.target_space_id) ? 'active' : 'deleted';
}
+528
View File
@@ -0,0 +1,528 @@
import { html, nothing, svg, type TemplateResult } from 'lit';
import { resizeFurnitureTransform } from './furniture';
import { FURN_WALL_CELLS, snapFurnitureToWall } from './furniture-placement';
import {
furnitureWallSurfacesFor, 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,
} 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;
kind: Stair['kind'];
length: string;
width: string;
radius: string;
angle: string;
direction: Stair['direction'];
targetSpaceId: string;
};
type StairDrag = {
pid: number;
id: string;
mode: 'move' | 'resize' | 'rotate';
resizeX: -1 | 0 | 1;
resizeY: -1 | 0 | 1;
start: number[];
original: Stair;
before: unknown;
moved: boolean;
};
/** Narrow internal seam owned by HouseplanCard. The cast at construction keeps
* these implementation details private to the card while this module keeps the
* stairs feature out of the already size-limited card core. */
export interface StairEditorHostPort {
hass: unknown;
_mode: 'view' | 'plan' | 'devices' | 'decor';
_tool: MarkupTool;
_curSpaceCfg: unknown;
_cfgEpoch: number;
_cleanFloorCache: Map<unknown, unknown>;
_modelCache: unknown;
_gridPitch: number;
_cellCm: number;
_imperial: boolean;
_model: SpaceModel[];
_space: string;
_hasFixedFloor: boolean;
_suppressClick: boolean;
requestUpdate(): void;
_showToast(message: string): void;
_t(key: I18nKey, vars?: Record<string, string | number>): string;
_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;
}
export class StairEditorRuntime {
private preset: Stair['kind'] = 'straight';
private selectedId: string | null = null;
private dialog: StairDialog | null = null;
private drag: StairDrag | 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 clearSelection(): void {
this.selectedId = null;
this.owner.requestUpdate();
}
public selectionKey(): string | null {
return this.selectedId ? `selection:stair:${this.selectedId}` : null;
}
private get stairs(): Stair[] {
return stairList((this.owner._curSpaceCfg as { stairs?: unknown } | null)?.stairs);
}
private write(stairs: Stair[]): void {
const space = this.owner._curSpaceCfg as { stairs?: Stair[] } | null;
if (!space) return;
if (stairs.length) space.stairs = stairs;
else delete space.stairs;
this.owner._cfgEpoch++;
this.owner._cleanFloorCache.clear();
this.owner._modelCache = null;
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;
}
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,
);
return {
...stair, x: clampCanvasN(cx / NORM_W), y: clampCanvasN(cy / NORM_W), angle,
} as Stair;
}
public activatePlacement(kind: Stair['kind']): void {
this.preset = kind;
this.selectedId = null;
this.owner._activateMarkupTool('stairs');
}
public placeAt(point: number[]): void {
const space = this.owner._curSpaceCfg as { stairs?: Stair[] } | null;
if (!space || this.stairs.length >= MAX_STAIRS_PER_SPACE) {
this.owner._showToast(this.owner._t('toast.physical_limit'));
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,
);
this.write([...this.stairs, stair]);
this.selectedId = id;
this.owner._recordGeometry(this.owner._t('history.stair_add'), before);
this.owner._saveConfigDebounced();
}
private openDialog(stair: Stair): void {
const sizes = stairPhysicalSizeCm(stair, this.owner._cellCm);
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),
direction: stair.direction,
targetSpaceId: stair.target_space_id || '',
};
this.owner.requestUpdate();
}
private updateDialog(patch: Partial<StairDialog>): void {
if (!this.dialog) return;
this.dialog = { ...this.dialog, ...patch };
this.owner.requestUpdate();
}
private saveDialog(): void {
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();
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',
};
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.owner._recordGeometry(this.owner._t('history.stair_edit'), before);
this.owner._saveConfigDebounced();
this.dialog = null;
}
private delete(id: string): void {
const before = this.owner._geometrySnapshot();
this.write(this.stairs.filter((item) => item.id !== id));
this.selectedId = null;
this.dialog = null;
this.owner._recordGeometry(this.owner._t('history.stair_delete'), before);
this.owner._saveConfigDebounced();
}
public deleteSelected(): boolean {
if (!this.selectedId) return false;
this.delete(this.selectedId);
return true;
}
private pointerDown(
event: PointerEvent,
stair: Stair,
mode: 'move' | 'resize' | 'rotate',
resizeX: -1 | 0 | 1 = 1,
resizeY: -1 | 0 | 1 = 1,
): void {
if (this.owner._mode !== 'plan'
|| (this.owner._tool !== 'select' && this.owner._tool !== 'stairs')) return;
event.preventDefault();
event.stopPropagation();
this.selectedId = stair.id;
this.drag = {
pid: event.pointerId,
id: stair.id,
mode,
resizeX,
resizeY,
start: this.owner._svgPoint(event as unknown as MouseEvent),
original: structuredClone(stair),
before: this.owner._geometrySnapshot(),
moved: false,
};
try { (event.currentTarget as Element).setPointerCapture(event.pointerId); }
catch { /* synthetic event */ }
this.owner.requestUpdate();
}
public pointerMove(event: PointerEvent): boolean {
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 = [
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);
} 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);
} 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,
};
}
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));
return true;
}
private endDrag(event: PointerEvent, cancelled: boolean): boolean {
const drag = this.drag;
if (!drag || drag.pid !== event.pointerId) return false;
if (cancelled) {
this.write(this.stairs.map((item) => item.id === drag.id ? drag.original : item));
} else if (drag.moved) {
this.owner._recordGeometry(this.owner._t('history.stair_move'), drag.before);
this.owner._saveConfigDebounced();
}
this.drag = null;
return true;
}
public pointerUp(event: PointerEvent): boolean {
return this.endDrag(event, false);
}
public pointerCancel(event: PointerEvent): boolean {
return this.endDrag(event, true);
}
public undoActiveDrag(): boolean {
if (!this.drag) return false;
const drag = this.drag;
this.write(this.stairs.map((item) => item.id === drag.id ? drag.original : item));
this.drag = null;
return true;
}
public escape(): boolean {
if (this.undoActiveDrag()) return true;
if (!this.selectedId) return false;
this.selectedId = null;
this.owner.requestUpdate();
return true;
}
public beforeModeChange(mode: StairEditorHostPort['_mode']): void {
if (mode !== this.owner._mode) this.undoActiveDrag();
}
public afterModeChange(): void {
if (this.owner._mode !== 'plan') this.selectedId = null;
}
public clearGesture(): void {
this.drag = null;
this.selectedId = null;
}
public toolbarGroup(): EditorToolbarGroup {
return {
id: 'stairs',
label: this.owner._t('markup.stairs'),
icon: 'mdi:stairs',
activeItemId: this.owner._tool === 'stairs' ? this.preset : undefined,
items: [{
id: 'straight',
label: this.owner._t('stairs.straight'),
icon: 'mdi:stairs',
role: 'tool',
invoke: () => this.activatePlacement('straight'),
}, {
id: 'spiral',
label: this.owner._t('stairs.spiral'),
icon: 'mdi:rotate-orbit',
role: 'tool',
invoke: () => this.activatePlacement('spiral'),
}],
};
}
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;
}
public renderDialog(): TemplateResult | typeof nothing {
const dialog = this.dialog;
const current = this.stairs.find((item) => item.id === dialog?.id);
if (!dialog || !current) return nothing;
const targetState = stairTargetState(
{ ...current, target_space_id: dialog.targetSpaceId || null } as Stair,
this.owner._space, new Set(this.owner._model.map((item) => item.id)),
this.owner._hasFixedFloor,
);
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 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"
.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',
)}</span></div>`;
return html`<hp-dialog .hass=${this.owner.hass} data-kind="stairs" wide
.title=${this.owner._t('stairs.properties')} icon="mdi:stairs"
@hp-close=${() => this.closeDialog()}>
<div class="body">
<label>${this.owner._t('stairs.type')}</label>
<select class="areasel"
@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);
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),
direction: converted.direction,
});
}}>
<option value="straight" ?selected=${dialog.kind === 'straight'}>${this.owner._t('stairs.straight')}</option>
<option value="spiral" ?selected=${dialog.kind === 'spiral'}>${this.owner._t('stairs.spiral')}</option>
</select>
${dialog.kind === 'straight'
? html`${field('length', 'stairs.length')}${field('width', 'stairs.width')}`
: field('radius', 'stairs.radius')}
<label>${this.owner._t('stairs.rotation')}</label>
<input class="namein tempin" type="number" step="any" .value=${dialog.angle}
@input=${(event: Event) => this.updateDialog({
angle: (event.target as HTMLInputElement).value,
})}>
<label>${this.owner._t('stairs.direction')}</label>
<div class="segmented">${directionOptions.map(([value, key]) => html`
<button class="btn ${dialog.direction === value ? 'on' : ''}"
@click=${() => this.updateDialog({
direction: value as Stair['direction'],
})}>${this.owner._t(key)}</button>`)}</div>
<label>${this.owner._t('stairs.target')}</label>
<select class="areasel"
@change=${(event: Event) => this.updateDialog({
targetSpaceId: (event.target as HTMLSelectElement).value,
})}>
<option value="" ?selected=${dialog.targetSpaceId === ''}>${this.owner._t('stairs.no_target')}</option>
${this.owner._model.filter((item) => item.id !== this.owner._space).map((item) => html`
<option value=${item.id} ?selected=${dialog.targetSpaceId === item.id}>${item.title}</option>`)}
</select>
${targetState !== 'active' ? html`<p class="hint warn">${this.owner._t(
targetState === 'fixed' ? 'stairs.fixed_floor' : 'stairs.target_warning',
)}</p>` : nothing}
</div>
<div class="row dialog-action-footer" slot="footer">
<button class="btn danger" data-hp="dialog-confirm" @click=${() => this.delete(dialog.id)}>
<ha-icon icon="mdi:delete-outline"></ha-icon>${this.owner._t('btn.delete')}</button>
<span class="spacer"></span>
<button class="btn ghost" data-hp="dialog-cancel"
@click=${() => this.closeDialog()}>${this.owner._t('btn.cancel')}</button>
<button class="btn on" data-hp="dialog-confirm" @click=${() => this.saveDialog()}>
<ha-icon icon="mdi:check"></ha-icon>${this.owner._t('btn.save')}</button>
</div>
</hp-dialog>`;
}
private closeDialog(): void {
this.dialog = null;
this.owner.requestUpdate();
}
}
+97
View File
@@ -0,0 +1,97 @@
import { nothing, svg, type TemplateResult } from 'lit';
import {
cachedStairRenderGeometry, stairList, type Stair,
} from './stairs';
import { stairTargetState } from './stairs-editor-model';
import type { SpaceModel } from './types';
export interface StairViewHostPort {
_mode: 'view' | 'plan' | 'devices' | 'decor';
_curSpaceCfg: unknown;
_model: SpaceModel[];
_space: string;
_hasFixedFloor: boolean;
_suppressClick: boolean;
_cellCm: number;
_tabClick(spaceId: string): void;
_t(key: 'markup.stairs'): string;
}
type ViewPress = { pid: number; startedAt: number };
/** Eager, read-only stair surface. All editing stays in the lazy editor graph. */
export class StairViewRuntime {
private press: ViewPress | null = null;
private suppressClick = false;
public constructor(private readonly owner: StairViewHostPort) {}
private get stairs(): Stair[] {
return stairList((this.owner._curSpaceCfg as { stairs?: unknown } | null)?.stairs);
}
public pointerDown(event: PointerEvent): void {
this.suppressClick = false;
if (event.pointerType === 'touch') {
this.press = { pid: event.pointerId, startedAt: performance.now() };
}
}
public pointerUp(event: PointerEvent): void {
const press = this.press;
if (!press || press.pid !== event.pointerId) return;
this.press = null;
if (performance.now() - press.startedAt >= 600) this.suppressClick = true;
}
public pointerCancel(event: PointerEvent): void {
if (this.press?.pid !== event.pointerId) return;
this.press = null;
this.suppressClick = true;
}
public clearGesture(): void {
this.press = null;
this.suppressClick = false;
}
public renderLayer(): TemplateResult {
const spaceIds = new Set(this.owner._model.map((item) => item.id));
const interactive = this.owner._mode === 'view';
const items = this.stairs.map((stair) => {
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 active = interactive && targetState === 'active';
const navigate = (event: Event): void => {
event.stopPropagation();
if (event.type === 'keydown') this.suppressClick = false;
if (!active || this.owner._suppressClick || this.suppressClick
|| !stair.target_space_id) return;
this.owner._tabClick(stair.target_space_id);
};
return svg`<g class="hp-stair ${active ? 'navigable' : ''} ${interactive ? 'input-enabled' : ''}"
data-hp="stair" data-id=${stair.id} data-kind=${stair.kind}
data-target-state=${targetState}
role=${active ? 'link' : 'img'} tabindex=${active ? '0' : nothing}
aria-label=${this.owner._t('markup.stairs')}
@click=${navigate}
@keydown=${(event: KeyboardEvent) => {
if (active && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
navigate(event);
}
}}>
<polygon class="hp-stair-outline" points=${outline}></polygon>
<polygon class="hp-stair-hit" points=${outline}
@pointerdown=${(event: PointerEvent) => this.pointerDown(event)}></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>`;
});
return svg`<g class="hp-stairs-layer">${items}</g>` as unknown as TemplateResult;
}
}
+249
View File
@@ -0,0 +1,249 @@
import { difference, type Geom } from 'polyclip-ts';
import { geometryArea } from './physical-geometry';
import { CANVAS_LIMIT, GRID_N, NORM_W } from './canvas-constants';
export const STAIR_TREAD_CM = 30;
export const MAX_STAIRS_PER_SPACE = 250;
export type StraightStairDirection = 'forward' | 'backward';
export type SpiralStairDirection = 'clockwise' | 'counterclockwise';
interface StairCommon {
id: string;
x: number;
y: number;
angle: number;
target_space_id?: string | null;
}
export interface StraightStair extends StairCommon {
kind: 'straight';
direction: StraightStairDirection;
length: number;
width: number;
}
export interface SpiralStair extends StairCommon {
kind: 'spiral';
direction: SpiralStairDirection;
radius: number;
}
export type Stair = StraightStair | SpiralStair;
export interface StairLine {
a: [number, number];
b: [number, number];
}
export interface StairRenderGeometry {
outline: number[][];
treads: StairLine[];
arrowPath: string;
center: [number, number];
}
type CachedRenderGeometry = {
fingerprint: string;
geometry: StairRenderGeometry;
};
/**
* Live entity updates repaint the card without changing persisted stairs.
* Keep the comparatively dense spiral/tread geometry behind an object-scoped
* cache so those unrelated updates do not rebuild it on every render.
*
* The fingerprint also makes the helper safe for the few write paths which
* temporarily mutate a config record in place before the authoritative model
* is rebuilt. Weak keys keep deleted spaces/stairs collectible.
*/
const RENDER_GEOMETRY_CACHE = new WeakMap<object, CachedRenderGeometry>();
function renderFingerprint(stair: Stair, cellCm: number, scale: number): string {
return stair.kind === 'straight'
? `${cellCm}|${scale}|${stair.kind}|${stair.x}|${stair.y}|${stair.angle}|${stair.direction}|${stair.length}|${stair.width}`
: `${cellCm}|${scale}|${stair.kind}|${stair.x}|${stair.y}|${stair.angle}|${stair.direction}|${stair.radius}`;
}
const finite = (value: unknown): number | null => {
const number = Number(value);
return Number.isFinite(number) ? number : null;
};
const cmToNorm = (cm: number, cellCm: number): number => (
Number(cm) / ((Number(cellCm) > 0 ? Number(cellCm) : 5) * GRID_N)
);
export function isStair(value: unknown): value is Stair {
if (!value || typeof value !== 'object') return false;
const item = value as Record<string, unknown>;
if (typeof item.id !== 'string' || !item.id || (item.kind !== 'straight' && item.kind !== 'spiral')) return false;
if (![item.x, item.y, item.angle].every((entry) => finite(entry) !== null)) return false;
if (Math.abs(Number(item.x)) > CANVAS_LIMIT || Math.abs(Number(item.y)) > CANVAS_LIMIT
|| Math.abs(Number(item.angle)) > 360) return false;
if (item.target_space_id != null && typeof item.target_space_id !== 'string') return false;
const validSize = (entry: unknown): boolean => {
const size = finite(entry) ?? 0;
return size > 0 && size <= CANVAS_LIMIT;
};
if (item.kind === 'straight') {
return item.direction === 'forward' || item.direction === 'backward'
? validSize(item.length) && validSize(item.width)
: false;
}
return item.direction === 'clockwise' || item.direction === 'counterclockwise'
? validSize(item.radius)
: false;
}
export function stairList(value: unknown): Stair[] {
return Array.isArray(value) ? value.filter(isStair).slice(0, MAX_STAIRS_PER_SPACE) : [];
}
const rotate = (x: number, y: number, angle: number): [number, number] => {
const radians = angle * Math.PI / 180;
const cosine = Math.cos(radians), sine = Math.sin(radians);
return [x * cosine - y * sine, x * sine + y * cosine];
};
const worldPoint = (stair: Stair, localX: number, localY: number, scale: number): [number, number] => {
const [dx, dy] = rotate(localX * scale, localY * scale, stair.angle);
return [stair.x * scale + dx, stair.y * scale + dy];
};
export function stairOutline(stair: Stair, scale = NORM_W, circleSegments = 64): number[][] {
if (stair.kind === 'straight') {
const halfLength = stair.length * scale / 2;
const halfWidth = stair.width * scale / 2;
return [
[-halfLength, -halfWidth], [halfLength, -halfWidth],
[halfLength, halfWidth], [-halfLength, halfWidth],
].map(([x, y]) => {
const [dx, dy] = rotate(x, y, stair.angle);
return [stair.x * scale + dx, stair.y * scale + dy];
});
}
const count = Math.max(24, Math.round(circleSegments));
return Array.from({ length: count }, (_, index) => {
const radians = index * Math.PI * 2 / count;
return [
(stair.x + Math.cos(radians) * stair.radius) * scale,
(stair.y + Math.sin(radians) * stair.radius) * scale,
];
});
}
const arrowHead = (
tip: [number, number], bearing: number, size: number,
): string => {
const left = rotate(-size, -size * 0.62, bearing);
const right = rotate(-size, size * 0.62, bearing);
return `M ${tip[0]} ${tip[1]} L ${tip[0] + left[0]} ${tip[1] + left[1]} M ${tip[0]} ${tip[1]} L ${tip[0] + right[0]} ${tip[1] + right[1]}`;
};
export function stairRenderGeometry(
stair: Stair, cellCm: number, scale = NORM_W,
): StairRenderGeometry {
const center: [number, number] = [stair.x * scale, stair.y * scale];
const outline = stairOutline(stair, scale);
const treadN = cmToNorm(STAIR_TREAD_CM, cellCm);
if (stair.kind === 'straight') {
const count = Math.max(0, Math.floor(stair.length / treadN));
const treads: StairLine[] = [];
for (let index = 1; index <= count; index++) {
const x = -stair.length / 2 + index * treadN;
if (x >= stair.length / 2 - 1e-10) break;
treads.push({
a: worldPoint(stair, x, -stair.width / 2, scale),
b: worldPoint(stair, x, stair.width / 2, scale),
});
}
const forward = stair.direction === 'forward';
const fromX = (forward ? -0.3 : 0.3) * stair.length;
const toX = (forward ? 0.3 : -0.3) * stair.length;
const from = worldPoint(stair, fromX, 0, scale);
const tip = worldPoint(stair, toX, 0, scale);
const bearing = stair.angle + (forward ? 0 : 180);
const size = Math.min(stair.width, stair.length) * scale * 0.16;
return {
outline, treads, center,
arrowPath: `M ${from[0]} ${from[1]} L ${tip[0]} ${tip[1]} ${arrowHead(tip, bearing, size)}`,
};
}
const travelRadius = stair.radius * 2 / 3;
const circumference = Math.PI * 2 * travelRadius;
const count = Math.max(1, Math.floor(circumference / treadN));
const sign = stair.direction === 'clockwise' ? 1 : -1;
const treads: StairLine[] = [];
for (let index = 0; index < count; index++) {
const localAngle = index * (treadN / travelRadius) * sign;
const degrees = stair.angle + localAngle * 180 / Math.PI;
const inner = rotate(stair.radius * scale * 0.18, 0, degrees);
const outer = rotate(stair.radius * scale, 0, degrees);
treads.push({
a: [center[0] + inner[0], center[1] + inner[1]],
b: [center[0] + outer[0], center[1] + outer[1]],
});
}
const radius = stair.radius * scale * 0.62;
const start = stair.angle * Math.PI / 180;
const sweep = sign * Math.PI * 1.5;
const end = start + sweep;
const from: [number, number] = [center[0] + Math.cos(start) * radius, center[1] + Math.sin(start) * radius];
const tip: [number, number] = [center[0] + Math.cos(end) * radius, center[1] + Math.sin(end) * radius];
const sweepFlag = sign > 0 ? 1 : 0;
const tangent = end * 180 / Math.PI + (sign > 0 ? 90 : -90);
return {
outline, treads, center,
arrowPath: `M ${from[0]} ${from[1]} A ${radius} ${radius} 0 1 ${sweepFlag} ${tip[0]} ${tip[1]} ${arrowHead(tip, tangent, stair.radius * scale * 0.16)}`,
};
}
export function cachedStairRenderGeometry(
stair: Stair, cellCm: number, scale = NORM_W,
): StairRenderGeometry {
const fingerprint = renderFingerprint(stair, cellCm, scale);
const cached = RENDER_GEOMETRY_CACHE.get(stair);
if (cached?.fingerprint === fingerprint) return cached.geometry;
const geometry = stairRenderGeometry(stair, cellCm, scale);
RENDER_GEOMETRY_CACHE.set(stair, { fingerprint, geometry });
return geometry;
}
export function stairFootprintGeometry(stair: Stair, scale = NORM_W): Geom {
const ring = stairOutline(stair, scale);
return (ring.length ? [[[...ring, ring[0]]]] : []) as unknown as Geom;
}
export function floorAreaMinusStairs(
floor: readonly number[][], stairs: readonly Stair[] | null | undefined, scale = NORM_W,
): number {
if (floor.length < 3) return 0;
const geometry = [[[...floor.map((point) => [point[0], point[1]]), [floor[0][0], floor[0][1]]]]] as Geom;
return geometryAreaMinusStairs(geometry, stairs, scale);
}
export function geometryAreaMinusStairs(
source: Geom, stairs: readonly Stair[] | null | undefined, scale = NORM_W,
): number {
return Math.max(0, geometryArea(geometryMinusStairs(source, stairs, scale)));
}
export function geometryMinusStairs(
source: Geom, stairs: readonly Stair[] | null | undefined, scale = NORM_W,
): Geom {
const footprints = stairList(stairs).map((stair) => stairFootprintGeometry(stair, scale));
if (!footprints.length) return source;
try {
// A single sweep avoids repeatedly rebuilding the same subject for dense floors.
return difference(source, ...footprints);
} catch {
// Keep a valid room usable if one future record reaches this layer malformed.
let geometry = source;
for (const footprint of footprints) {
try { geometry = difference(geometry, footprint); } catch { /* skip only the bad record */ }
}
return geometry;
}
}
+45
View File
@@ -1563,5 +1563,50 @@ export const planStyles = css`
transform: translate(-50%, -50%);
border: 1px solid var(--hp-accent);
}
.stage.markup.tool-stairs { cursor: crosshair; }
.hp-stair-outline {
fill: color-mix(in srgb, var(--hp-accent) 12%, transparent);
stroke: var(--primary-text-color, #202124);
stroke-width: calc(2px / var(--hp-plan-screen-scale, 1));
vector-effect: non-scaling-stroke;
}
.hp-stair-tread,
.hp-stair-arrow {
fill: none;
stroke: var(--primary-text-color, #202124);
stroke-width: calc(1.5px / var(--hp-plan-screen-scale, 1));
stroke-linecap: round;
stroke-linejoin: round;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.hp-stair-arrow { stroke-width: calc(2.5px / var(--hp-plan-screen-scale, 1)); }
.hp-stair-hit { fill: transparent; stroke: transparent; stroke-width: 14px; pointer-events: none; }
.hp-stair.input-enabled .hp-stair-hit { pointer-events: all; }
.hp-stair.navigable { cursor: pointer; }
:host([data-pointer-hover]) .hp-stair.navigable:hover .hp-stair-outline {
stroke: var(--hp-accent);
stroke-width: calc(2.5px / var(--hp-plan-screen-scale, 1));
}
.hp-stair.selected .hp-stair-outline {
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;
}
.alignmsg { margin: 0 0 8px; font-size: 13px; line-height: 1.45; }
`;
+4
View File
@@ -9,6 +9,7 @@ import { innerContourForRoom, multiWallNodesForGeometry, wallBodiesGeometry } fr
import type { Marker, ServerConfig, SpaceModel, SummaryPanelSource } from './types';
import type { HaRegistrySnapshot } from './ha-binding-status';
import type { SummaryHass } from './summary-panel-host';
import { geometryMinusStairs } from './stairs';
/** Count the unique real HA devices represented anywhere on the plan. */
export function representedHaDeviceIds(input: {
@@ -118,6 +119,9 @@ export function* cleanFloorAreaSteps(
spaceFloor = unionGeometry(spaceFloor, clean);
yield;
}
// Difference distributes over union. Subtracting the shared stair set once
// avoids rooms x stairs polygon clips on dense plans (#663).
spaceFloor = geometryMinusStairs(spaceFloor || [], space.stairs);
const cmPerUnit = prepared.cellCm / GRID_PITCH;
total += geometryArea(spaceFloor) * cmPerUnit * cmPerUnit / 1e4;
}
+2
View File
@@ -84,6 +84,8 @@ export interface SpaceModel {
wall_segments: WallSegmentEntry[];
partitions: PartitionCfg[];
wall_columns: WallColumnCfg[];
/** Independent continuous plan objects linking this floor to another one. */
stairs: import('./stairs').Stair[];
}
export interface PdfRef {