mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 15:09:30 +00:00
feat: добавить лестницы между этажами (#663)
Прямые и винтовые лестницы получили отдельную модель, инструменты редактора, безопасную межэтажную навигацию, вычитание из чистой площади и плоское отображение в 2.5D. Issue: #663 User-Visible: yes
This commit is contained in:
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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}">
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user