import { html, nothing, svg, type TemplateResult } from 'lit'; import { FURN_WALL_CELLS } from './furniture-placement'; import { furnitureWallSurfacesFor, type FurnitureWallSurface, type FurnitureWallSurfaceSource, } from './furniture-wall-surface'; import { strictNumber, type MarkupTool } from './card-runtime'; import type { EditorToolbarGroup } from './editor-secondary'; import type { I18nKey } from './i18n'; import { clampCanvasN, NORM_W } from './space-geometry'; import { draftLeadingHandle, draftStair, magnetStairMove, magnetStairResize, physicalStairSurfaces, resizeCursor, resizeStair, stairBox, stairFieldOf, stairHandles, stairMinN, stairRotateHandle, stairSizeFromField, STAIR_MAX_CM, STAIR_MIN_CM, type StairHandleSign, } from './stairs-box'; import { convertStairKind, normalizeStairAngle, snapStairToStairs, stairTargetState, } from './stairs-editor-model'; import { cachedStairMarkup, cachedStairRenderGeometry, MAX_STAIRS_PER_SPACE, stairList, stairStyleVars, stairVisualFields, stairVisualStyle, type Stair, type StairVisualStyle, } from './stairs'; import type { SpaceModel } from './types'; type StairDialog = { id: string; kind: Stair['kind']; length: string; width: string; radius: string; angle: string; direction: Stair['direction']; targetSpaceId: string; color: string; opacity: number; fillColor: string; fillOpacity: number; /** Field values at open time: an untouched field keeps the stored number bit for bit (#676 К7). */ opened: { length: string; width: string; radius: string; angle: string }; }; type StairDrag = { pid: number; id: string; mode: 'move' | 'resize' | 'rotate'; handle: StairHandleSign; start: number[]; original: Stair; before: unknown; moved: boolean; /** Wall faces are read once per gesture: each write bumps the epoch that keys their cache. */ surfaces: readonly FurnitureWallSurface[]; }; type StairDraft = { pid: number; kind: Stair['kind']; id: string; a: number[]; b: number[]; stair: Stair; surfaces: readonly FurnitureWallSurface[]; }; /** Narrow internal seam owned by HouseplanCard. The cast at construction keeps * 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; _modelCache: unknown; _gridPitch: number; _cellCm: number; _imperial: boolean; _model: SpaceModel[]; _space: string; _hasFixedFloor: boolean; _decorStyle: { color: string; opacity: number }; _suppressClick: boolean; requestUpdate(): void; _showToast(message: string): void; _t(key: I18nKey, vars?: Record): string; _geometrySnapshot(): unknown; _recordGeometry(name: string, before: unknown): void; _saveConfigDebounced(): void; _svgPoint(event: MouseEvent): number[]; _tabClick(spaceId: string): void; _activateMarkupTool(tool: MarkupTool): void; } const newStairId = (): string => `stair-${crypto.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}`; export class StairEditorRuntime { private preset: Stair['kind'] = 'straight'; private selectedId: string | null = null; private dialog: StairDialog | null = null; private drag: StairDrag | null = null; private draft: StairDraft | null = null; public constructor(private readonly owner: StairEditorHostPort) {} public get selected(): string | null { return this.selectedId; } public get dialogOpen(): boolean { return this.dialog !== null; } public get dragging(): boolean { return this.drag !== null || this.draft !== 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 get inputEnabled(): boolean { return this.owner._mode === 'plan' && (this.owner._tool === 'select' || this.owner._tool === 'stairs'); } private get reach(): number { return this.owner._gridPitch * FURN_WALL_CELLS; } private get minUnits(): number { return stairMinN(this.owner._cellCm) * NORM_W; } private creationVisualStyle(): StairVisualStyle { return { color: this.owner._decorStyle.color, opacity: this.owner._decorStyle.opacity, fillColor: this.owner._decorStyle.color, fillOpacity: 0, }; } /** Room faces from the furniture magnet plus physical bodies with an outward side (#676). */ private surfaces(): readonly FurnitureWallSurface[] { const source = this.owner as unknown as FurnitureWallSurfaceSource; return [ ...furnitureWallSurfacesFor(source).filter((surface) => surface.owner === 'room'), ...physicalStairSurfaces(source._rawPhysicalBodiesR()), ]; } private write(stairs: Stair[]): void { const space = this.owner._curSpaceCfg as { stairs?: Stair[] } | null; if (!space) return; 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 replace(next: Stair): void { this.write(this.stairs.map((item) => item.id === next.id ? next : item)); } /** * The synthesized `click` after any gesture on the stair or its handles * must never reach the plan tool underneath: under «Stairs» it would place * another stair, under «Select» it would drop the selection (#676 К6). */ private swallowNextClick(): void { this.owner._suppressClick = true; setTimeout(() => { this.owner._suppressClick = false; }, 0); } private withMoveMagnet(stair: Stair, center: readonly number[], surfaces: readonly FurnitureWallSurface[]): Stair { const moved = { ...stair, x: center[0] / NORM_W, y: center[1] / NORM_W } as Stair; const snapped = magnetStairMove(moved, surfaces, this.reach); const [cx, cy] = snapStairToStairs( snapped, [snapped.x * NORM_W, snapped.y * NORM_W], this.stairs, this.reach, ); return { ...snapped, x: clampCanvasN(cx / NORM_W), y: clampCanvasN(cy / NORM_W) } as Stair; } public activatePlacement(kind: Stair['kind']): void { this.preset = kind; this.selectedId = null; this.owner._activateMarkupTool('stairs'); } private placeStair(stair: Stair, before: unknown): void { this.write([...this.stairs, stair]); this.selectedId = stair.id; this.owner._recordGeometry(this.owner._t('history.stair_add'), before); this.owner._saveConfigDebounced(); } /** * Drag-to-draw under the Stairs tool (#676 К1): the press starts a draft on * the stage, the release commits it. Returns false when the press is not a * placement (secondary button, second finger, no space, limit reached), so * the stage keeps its ordinary pan/pinch handling. */ public stagePointerDown(event: PointerEvent): boolean { if (this.owner._mode !== 'plan' || this.owner._tool !== 'stairs') return false; if (this.draft) { // A second contact turns the gesture into navigation: the draft is gone. if (this.draft.pid !== event.pointerId) this.cancelDraft(); return false; } if (this.drag || (event.pointerType === 'mouse' && event.button !== 0) || !event.isPrimary) return false; const space = this.owner._curSpaceCfg as { stairs?: Stair[] } | null; if (!space) return false; if (this.stairs.length >= MAX_STAIRS_PER_SPACE) { this.owner._showToast(this.owner._t('toast.physical_limit')); return false; } event.preventDefault(); const point = this.owner._svgPoint(event as unknown as MouseEvent); const id = newStairId(); this.draft = { pid: event.pointerId, kind: this.preset, id, a: point, b: point, stair: draftStair( this.preset, point, point, this.owner._cellCm, id, this.owner._gridPitch, NORM_W, this.creationVisualStyle(), ), surfaces: this.surfaces(), }; this.selectedId = null; try { (event.currentTarget as Element).setPointerCapture(event.pointerId); } catch { /* synthetic event */ } this.owner.requestUpdate(); return true; } private draftAt(draft: StairDraft, point: number[]): Stair { const drawn = draftStair( draft.kind, draft.a, point, this.owner._cellCm, draft.id, this.owner._gridPitch, NORM_W, stairVisualStyle(draft.stair, this.creationVisualStyle()), ); const isClick = Math.max(Math.abs(point[0] - draft.a[0]), Math.abs(point[1] - draft.a[1])) < this.owner._gridPitch; if (isClick) return this.withMoveMagnet(drawn, [drawn.x * NORM_W, drawn.y * NORM_W], draft.surfaces); return magnetStairResize( drawn, draftLeadingHandle(drawn, draft.a, point), draft.surfaces, this.reach, this.minUnits, ); } private cancelDraft(): void { if (!this.draft) return; this.draft = null; this.owner.requestUpdate(); } /** Programmatic placement kept for the harness and tests: a click at `point`. */ public placeAt(point: number[]): void { const space = this.owner._curSpaceCfg as { stairs?: Stair[] } | null; if (!space || this.stairs.length >= MAX_STAIRS_PER_SPACE) { this.owner._showToast(this.owner._t('toast.physical_limit')); return; } const before = this.owner._geometrySnapshot(); const id = newStairId(); const stair = this.withMoveMagnet( draftStair( this.preset, point, point, this.owner._cellCm, id, this.owner._gridPitch, NORM_W, this.creationVisualStyle(), ), [point[0], point[1]], this.surfaces(), ); this.placeStair(stair, before); } private openDialog(stair: Stair): void { const cellCm = this.owner._cellCm; const imperial = this.owner._imperial; const fields = { length: stairFieldOf(stair.kind === 'straight' ? stair.length : stair.radius * 2, cellCm, imperial), width: stairFieldOf(stair.kind === 'straight' ? stair.width : stair.radius * 2, cellCm, imperial), radius: stairFieldOf(stair.kind === 'spiral' ? stair.radius : Math.max(stair.length, stair.width) / 2, cellCm, imperial), angle: String(stair.angle), }; const visual = stairVisualStyle(stair, this.creationVisualStyle()); this.dialog = { id: stair.id, kind: stair.kind, ...fields, direction: stair.direction, targetSpaceId: stair.target_space_id || '', ...visual, opened: fields, }; this.owner.requestUpdate(); } private updateDialog(patch: Partial): 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 cellCm = this.owner._cellCm; const imperial = this.owner._imperial; const angle = dialog.angle === dialog.opened.angle ? current.angle : strictNumber(dialog.angle); if (angle == null) return; let next = convertStairKind(current, dialog.kind); if (next.kind === 'straight') { const length = stairSizeFromField(dialog.length, dialog.opened.length, next.length, cellCm, imperial); const width = stairSizeFromField(dialog.width, dialog.opened.width, next.width, cellCm, imperial); if (length == null || width == null) return; next = { ...next, length, width, direction: dialog.direction === 'backward' ? 'backward' : 'forward', }; } else { const radius = stairSizeFromField(dialog.radius, dialog.opened.radius, next.radius, cellCm, imperial); if (radius == null) return; next = { ...next, radius, direction: dialog.direction === 'counterclockwise' ? 'counterclockwise' : 'clockwise', }; } next = { ...next, angle: normalizeStairAngle(angle), target_space_id: dialog.targetSpaceId || null, ...stairVisualFields(dialog), } as Stair; this.dialog = null; const unchanged = JSON.stringify(next) === JSON.stringify({ ...current, target_space_id: current.target_space_id ?? null }); if (unchanged) { this.owner.requestUpdate(); return; } const before = this.owner._geometrySnapshot(); this.replace(next); this.owner._recordGeometry(this.owner._t('history.stair_edit'), before); this.owner._saveConfigDebounced(); } 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', handle: StairHandleSign = { sx: 0, sy: 0 }, ): void { if (!this.inputEnabled || this.draft) return; event.preventDefault(); event.stopPropagation(); this.selectedId = stair.id; this.drag = { pid: event.pointerId, id: stair.id, mode, handle, start: this.owner._svgPoint(event as unknown as MouseEvent), original: structuredClone(stair), before: this.owner._geometrySnapshot(), moved: false, surfaces: this.surfaces(), }; try { (event.currentTarget as Element).setPointerCapture(event.pointerId); } catch { /* synthetic event */ } this.owner.requestUpdate(); } public pointerMove(event: PointerEvent): boolean { if (this.draft?.pid === event.pointerId) { const point = this.owner._svgPoint(event as unknown as MouseEvent); this.draft.b = point; this.draft.stair = this.draftAt(this.draft, point); this.owner.requestUpdate(); return true; } const drag = this.drag; if (!drag || drag.pid !== event.pointerId) return false; const point = this.owner._svgPoint(event as unknown as MouseEvent); let next: Stair = { ...drag.original }; if (drag.mode === 'move') { next = this.withMoveMagnet(next, [ drag.original.x * NORM_W + point[0] - drag.start[0], drag.original.y * NORM_W + point[1] - drag.start[1], ], drag.surfaces); } else if (drag.mode === 'rotate') { next.angle = normalizeStairAngle( Math.atan2(point[1] - next.y * NORM_W, point[0] - next.x * NORM_W) * 180 / Math.PI + 90, ); if (event.shiftKey) next.angle = normalizeStairAngle(Math.round(next.angle / 45) * 45); } else { next = magnetStairResize( resizeStair(next, drag.handle, point, { minUnits: this.minUnits, keepAspect: event.shiftKey }), drag.handle, drag.surfaces, this.reach, this.minUnits, ); } drag.moved ||= Math.hypot(point[0] - drag.start[0], point[1] - drag.start[1]) > 0.5; this.replace(next); return true; } private endDrag(event: PointerEvent, cancelled: boolean): boolean { if (this.draft?.pid === event.pointerId) { const draft = this.draft; this.draft = null; this.swallowNextClick(); if (cancelled) { this.owner.requestUpdate(); return true; } this.placeStair(this.draftAt(draft, draft.b), this.owner._geometrySnapshot()); return true; } const drag = this.drag; if (!drag || drag.pid !== event.pointerId) return false; this.swallowNextClick(); if (cancelled) { this.replace(drag.original); } else if (drag.moved) { const label = drag.mode === 'resize' ? 'history.stair_resize' : drag.mode === 'rotate' ? 'history.stair_rotate' : 'history.stair_move'; this.owner._recordGeometry(this.owner._t(label), drag.before); this.owner._saveConfigDebounced(); } this.drag = null; 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.draft) { this.cancelDraft(); return true; } if (!this.drag) return false; const drag = this.drag; this.replace(drag.original); 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.draft = 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'), }], }; } private renderStair(stair: Stair, spaceIds: ReadonlySet, selected: boolean, draft: boolean): TemplateResult { const geometry = cachedStairRenderGeometry(stair, this.owner._cellCm); const markup = cachedStairMarkup(geometry); const targetState = stairTargetState( stair, this.owner._space, spaceIds, this.owner._hasFixedFloor, ); const inputEnabled = this.inputEnabled && !draft; const select = (event: Event): void => { if (this.owner._mode === 'plan') { event.stopPropagation(); this.selectedId = stair.id; this.owner.requestUpdate(); } }; return svg` { event.stopPropagation(); if (this.owner._mode === 'plan' && !draft) this.openDialog(stair); }}> this.pointerDown(event, stair, 'move')} @click=${select}> ${geometry.trapezoid.map((line) => svg``)} ${markup.treads ? svg`` : nothing} ` as unknown as TemplateResult; } public renderLayer(): TemplateResult { const spaceIds = new Set(this.owner._model.map((item) => item.id)); const items = this.stairs.map((stair) => this.renderStair( stair, spaceIds, this.inputEnabled && this.selectedId === stair.id, false, )); const draft = this.draft ? this.renderStair(this.draft.stair, spaceIds, true, true) : nothing; return svg`${items}${draft}` as unknown as TemplateResult; } /** * The selection frame (#676 К2) lives in the card's top overlay, above wall * bodies, with the decor frame's chrome: finger-sized invisible hit circles, * quarter-size visible beads, cursors by the world bearing of each handle. */ public renderFrame(view: { w: number; h: number }): TemplateResult | typeof nothing { if (!this.inputEnabled) return nothing; const stair = this.draft?.stair ?? this.stairs.find((item) => item.id === this.selectedId); if (!stair) return nothing; const hr = Math.max(view.w, view.h) * 0.018; const kr = hr / 4; const stem = stairRotateHandle(stair, hr * 2.2); const box = stairBox(stair); const geometry = cachedStairRenderGeometry(stair, this.owner._cellCm); const stop = (event: Event): void => { event.stopPropagation(); }; const handles = stairHandles(stair).map((handle) => svg` this.pointerDown(event, stair, 'resize', { sx: handle.sx, sy: handle.sy })} @click=${stop}> `); const outline = stair.kind === 'spiral' ? svg`` : svg` point.join(',')).join(' ')}>`; // A draft has no handles yet: the pointer that draws it is the only gesture. return svg` ${outline} ${this.draft ? nothing : svg` this.pointerDown(event, stair, 'rotate')} @click=${stop}> ${handles}`} ` 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 = dialog.kind === 'straight' ? [['forward', 'stairs.up'], ['backward', 'stairs.down']] as const : [['clockwise', 'stairs.clockwise'], ['counterclockwise', 'stairs.counterclockwise']] as const; const imperial = this.owner._imperial; const bound = (cm: number): string => String(Math.round((imperial ? cm / 2.54 : cm) * 100) / 100); const field = (key: 'length' | 'width' | 'radius', label: I18nKey) => html`
this.updateDialog({ [key]: (event.target as HTMLInputElement).value, })}>${this.owner._t( imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm', )}
`; return html` this.closeDialog()}>
${dialog.kind === 'straight' ? html`${field('length', 'stairs.length')}${field('width', 'stairs.width')}` : field('radius', 'stairs.radius')} ) => this.updateDialog({ color: event.detail.color, opacity: event.detail.opacity })}> ) => this.updateDialog({ fillColor: event.detail.color, fillOpacity: event.detail.opacity })}> this.updateDialog({ angle: (event.target as HTMLInputElement).value, })}>
${directionOptions.map(([value, key]) => html` `)}
${targetState !== 'active' ? html`

${this.owner._t( targetState === 'fixed' ? 'stairs.fixed_floor' : 'stairs.target_warning', )}

` : nothing}
`; } private closeDialog(): void { this.dialog = null; this.owner.requestUpdate(); } }