/** * House Plan Card — an interactive house plan as a native Lovelace card. * Configuration sources: * 1) SERVER (the houseplan integration, WS houseplan/config/get) — spaces, plans, * rooms, device overrides, virtual devices. Coordinates are NORMALIZED (0..1). * 2) LEGACY fallback — baked-in country-house data (src/data/*), coordinates in a 1489×1053 canvas. * The icon layout is stored on the server (houseplan/layout/*), fallback — localStorage. */ import { html, svg, nothing, TemplateResult } from 'lit'; import { displayVersion } from './card-version'; import { guard } from 'lit/directives/guard.js'; import { renderVacuumMapsSection } from './editors/vacuum-maps-section'; import { renderMarkerDialog } from './editors/marker-dialog'; import { CELL_CM_MAX, CELL_CM_MIN, renderSpaceSettingsDialog, } from './editors/space-settings-dialog'; import { renderGeneralSettingsDialog } from './editors/general-settings-dialog'; import { renderRoomSettingsDialog } from './editors/room-settings-dialog'; import { calibrationTarget, planVacuumFit } from './vacuum-route-edit'; import { cancelHouseplanPointerMove, flushHouseplanPointerMove, queueHouseplanPointerMove, } from './pointer-move-queue'; import { commitHouseplanEditor, disposeHouseplanEditor, measureHouseplanDecorText, routeHouseplanEditorUpdate, whenHouseplanEditorSettled, } from './live-editor'; import './hp-dialog'; import type { HpDialog } from './hp-dialog'; import type { HpConfirmRequest } from './danger-confirm'; import './hp-color-opacity'; import type { ColorPickerLabels } from './hp-color-opacity'; import './hp-help'; import type { AuthoritativeConfigResponse } from './version-recovery-card'; import type { ConfigAdoption, GatedAdoptionInput, GatedAdoptionResult } from './config-adoption'; import './hp-device-preview'; import './hp-zigbee-topology-settings'; import { EXCLUDED_DOMAINS, DEFAULT_ICON_RULES, compileIconRules, isValidPattern, iconFor, type IconRule, type CompiledIconRule, } from './rules'; import { snapToGrid, snapSegment45, samePoint, pointInPolygon, markerIdForBinding, formatLength, roomPoly, pointStrictlyInside, roomsOverlap, pointOnBoundary, mergeRooms, splitRoomPath, polygonArea, closestPointOnBoundary, pointStrictlyInside as ptInside, distToSegment, cutSegments, alignGuides, segmentAngle, type AlignGuide, clampScale, migratePdfUrls, snapToWall, snapPointAlongPoly, openingAmount, openingShoulders, filterOpeningEntityCandidates, poleOfInaccessibility, subst, type FloorInfo, parseRoomRef, resolveGlowValues, normalizeGlowColorOverride, isControllable, spaceDisplayOf, DEFAULT_FILL_COLORS, customFillOf, DEFAULT_CUSTOM_FILL, type FillColors, type FillColorEntry, RUN_TARGET_DOMAINS, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, stageBgOf, showRoomTooltipOf, volumetricViewOf, DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, normalizeDeviceDisplay, type DeviceDisplayMode, liveTextReference, liveTextToken, DECOR_TEXT_BASE, fitView, } from './logic'; import { resolveSafeResize, coalesceResizeRooms, polyIsSimple, areaM2, formatArea, MIN_ROOM_CM, type SafeOpeningIn, type SafeResizeObstacle, type SafeResizeOptions, type SafeResizePlan, type SafeResizeReason, type SafeResizeResolution, } from './resize'; import { ResizeController, type ResizeProjectionResult, } from './resize-controller'; import { resizeLiveCandidateSpace, resizeLiveJunctionRoomIds } from './resize-live-preflight'; import { commitWallChainFinishGeometry, commitWallChainSegmentGeometry, } from './draft-live-commit'; import { wallChainLiveSeed } from './draft-live-preflight'; import { placeResizeAreaLabel, resizeMeasuredEdges, type ResizeAreaPlacement, } from './resize-labels'; import { RoomGearDragController } from './room-gear-drag'; import { northDegOf, bgModeOf, sunRaysOn, sunRayOriginOf, type SunRayOrigin } from './sun'; import { furnitureDefaultCm, furnitureGraphic, furnitureCorners, furnitureRenderTransform, furnitureStrokePx, resizeFurnitureTransform, furnitureRotationAngle, furnitureSignedFieldCm, furnitureSignedFieldValue, clampFurnCm, type FurnitureResizeResult, } from './furniture'; import { FURN_WALL_CELLS, resolveFurniturePlacement, snapFurnitureToWall, type FurniturePlacement } from './furniture-placement'; import { FURNITURE_ART_RUNTIME } from './furniture-art-runtime'; import { FURNITURE_ART_FINGERPRINT, GENERATED_FURNITURE_ART } from './furniture-plan-art.generated'; import { furnitureWallSurfacesFor, type FurnitureWallSurface } from './furniture-wall-surface'; import { degradeWalls, rekeyWallsAfterMoveChecked, wallRecordCarrierViolations, setWallThickness, setWallThicknessForRoom, cmToField, wallCmToUnits, multiWallNodesForGeometry, wallBodiesGeometry, wallBodiesGeometryPath, wallBodiesUnionPath, innerContourForRoom, openingInnerFaceOffsetFromIndex, resolveOpeningWallAssociation, applyWallThicknessToNewRoom, drawWallPreviewD, linearWallJoinPatches, DRAW_WALL_DEFAULT_CM, wallIntervals, materializeWallIntervals, intervalCmAt, wallHatchStepUnits, HATCH_BASE_STEP_UNITS, type OpeningWallIndex, type LinearWallSegment, type WallEntry, type WallInterval, type MultiWallNodeMap, innerEdgeSpan, ownEdgeOffsets, thicknessCmAt, } from './wall-thickness'; import { junctionLimitViolations, increasedViolations, type JunctionLimitViolation, type JunctionSharedGeometry, type LimitSegment, } from './junction-limits'; import { pointOnOpenCut, sanitizeOpenSpans, type OpenSpanEntry, } from './open-spans'; import { ContentSigner } from './signing'; import { type InitialSpaceSelection } from './initial-load'; import { selectSpaceModelById } from './space-model-selection'; import { applyRoomTempThresholdDraft, createEmptySpaceConfig, initialSpaceDisplayDraft, roomTempThresholdDraft, roomTempThresholdInputValues, strictNumber, type SpaceDialogState, } from './space-dialog'; import { rememberSpaceDialogBaseline, spaceDialogProblems } from './editors/space-form-state'; import { generalProblems, moonDraftOf, rememberGeneralBaseline, writeMoonSetting } from './editors/general-form-state'; import { openMoonStatus } from './editors/moon-status'; import { rememberRoomBaseline } from './editors/room-form-state'; import { forgetMarkerBaseline, rememberMarkerBaseline } from './editors/marker-form-state'; import { commitPlanOptimization } from './plan-optimize-write'; import { saveSpaceCopy } from './space-copy-runtime'; import { Affine, applyAffine, readVacTelemetry, autoCalibrate, vacTrailMode, areaCentroid, vacCalibrationResidualCm, vacRoomNameMatchCount, VAC_CALIBRATION_WARN_CM, FitParams, fitMatrix, reanchorFit, VacRoom, Pt as VacPt, type VacSourceCandidate, type VacSourceResolution, } from './vacuum'; import { buildDevices, deviceFromMarkerDraft, effectiveExcludedIntegrations, seedHiddenBindings, resolvedLightSources, hasOwnSpatialSource, ownControllableEntities, selectSpatialGlowSource, removedPlanBindings, isRemovedPlanEntity, deletePlanMarkerRecords, effectiveMarkerControls, persistedExternalControls, removeMarkerControlReferences, rewriteMarkerControlReferences, markerControlWouldCycle, } from './devices'; import { bindingCandidates, buildDeviceInbox, filterDeviceInbox, type DeviceInboxCategory, type DeviceInboxReason, type DeviceInboxRow, } from './device-inbox'; import { inboxBatchView, renderInboxBatchPanel, renderInboxRowSelect, writeInboxVisibility, type InboxBatchDeps, } from './device-inbox-batch'; import { formatToggleIntent, projectedTapAction, type ResolvedToggleIntent, type ResolvedToggleTarget, type ToggleNextEffect, type ToggleNoneReason, type ToggleSkipReason, } from './device-toggle'; import { toggleEntityWriteFields } from './marker-toggle-entity'; import { removeMarkerAreaSnapshots } from './device-area-relocation'; import { type VirtualLightSnapshot } from './virtual-light-state'; import type { OpeningCfg, PartitionOpeningHost, RoomCfg, PartitionCfg, WallColumnCfg, SpaceModel, PdfRef, Marker, ServerConfig, DevItem, CardConfig, MarkerValueBadge, ValueBadgePosition, ValueBadgeSource, } from './types'; import { radarConfigFromDraft, radarDraft, recognizeRadar, type RadarEditorDraft, } from './radar-editor'; import { finishMarkerDialogClose } from './marker-dialog-close'; import { renderRadarSection } from './editors/radar-section'; import { radarDiscardRequest, RadarSetupController } from './radar-setup'; import { OptimizePlansDialog, type OptimizePlansDialogState } from './optimize-plans-dialog'; import { COLUMN_MAX_CM, canonicalColumnAngle, clampColumnCm, columnBody, floorMinusBodies, geometryArea, partitionBody, pointInPhysicalBody, sameColumnPlacement, type PartitionOpeningCut, } from './physical-geometry'; import { hostedOpeningIntervalsOverlap, materializePartitionOpening, partitionOpeningJambMargin, partitionOpeningNeedsStrictValidation, partitionOpeningFace, partitionPlacementIntervals, resolvePartitionOpeningCompat, resolvePartitionOpeningStrict, } from './partition-openings'; import { buildHiddenWallDiagnosticGeometry, buildPlanSnapGeometry, resolvePlanSnapResult, resolveStrictPlanSnap, type HiddenWallDiagnosticGeometry, type PlanSnapCandidate, type PlanSnapEndpoint, type PlanSnapGeometry, type PlanSnapSegment, } from './plan-snap-overlay'; import { buildWallFaceGraph, findNewWallFacesInGraphs, findWallFaceAtPoint, chainSegmentCms, type WallFaceGraph, type WallGraphFace, type WallGraphSourceSegment, } from './wall-face-graph'; import { parameterOnPartition, planRoomDeletion } from './room-deletion'; import { planWallFaceRepair, repairMovesHostedPartition, type WallFaceRepairProposal, } from './wall-face-repair'; import './space-card'; import { EditorSecondaryController, type EditorSecondaryCopy, type EditorSecondaryModel, type EditorToolbarGroup, } from './editor-secondary'; import { spaceModels, iconCqw, gridLevels, CANVAS_LIMIT, SANE_LIMIT, GRID_PITCH, GRID_STEP_N, PLAN_SCALE_MIN, PLAN_SCALE_MAX, clampCanvasR, clampCanvasN, type Rect, } from './space-geometry'; import { WALL_SEGMENT_MODEL_VERSION, adoptWallSegmentModelCandidateInPlace, commitWallSegmentModel, fixedTopologyWallLineageHints, resolveRoomOpeningHost, wallModelOffGridValueCount, WallSegmentModelError, } from './wall-segment-model'; import { resolveZeroWalls, zeroWallHasOpening, zeroWallStyleOf } from './zero-walls'; import { snapNearAxisEndpoint } from './near-axis'; import type { SpaceReferenceRepairContext } from './space-reference-repair'; import { collectSpaceMarkerDependencies, spaceDeletionMessage } from './space-deletion'; import { checkSpacePhysicalGeometry, checkOptimizeGeometry, geometryOpenCuts, geometryOpenings, geometryPartitionOpeningCuts, spacePhysicalGeometryFingerprint, type OptimizeGeometryPreflightResult, } from './plan-geometry-preflight'; import { canonicalizeConfigGeometry, canonicalizePosition, } from './coordinate-canonicalization'; import { enqueueSerializedWrite, type OptimisticAttempt } from './serialized-write-queue'; import { applyCalibrationProposal, saveAutomaticCalibration, saveManualCalibration, saveVacuumMatrix, type CalibrationProposal, type VacuumFit } from './vacuum-calibration-write'; import { hasTranslation, langOf, t, type I18nKey } from './i18n'; import { supportT, type SupportI18nKey } from './i18n/support'; import { EDITOR_LANGUAGE_RUNTIME } from './i18n/editor-language'; import { writeZigbeeTopologySettings, zigbeeTopologySettingsOf, type ZigbeeTopologySettings } from './zigbee-topology-settings'; import { newSupportDialogState, supportApiCompatible, supportCanSubmit, supportDraftError, supportErrorCode, supportRuntimeFacts, supportSizeKiB, supportSubmissionIdentity, type SupportDialogState, type SupportPreview, } from './support-feedback'; 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'; import { PointerModalityController } from './pointer-modality'; import { type ResolvedDevicePresentation } from './device-presentation'; import { type FiniteActivityRuntime } from './activity-runtime'; import { recommendedValueBadgeSource, valueBadgeCandidates, valueBadgeWriteFields, valueSourceWriteFields, type ValueBadgeCandidate, } from './device-value-badge'; import { type RenderDeviceSnapshot } from './render-device-snapshot'; import { type HouseplanMode, type ModeVisualState } from './mode-transition'; import { projectPlanPoint } from './iso-projection'; import { type HaBindingStatus, type HaRegistrySnapshot } from './ha-binding-status'; import type { DecorShape, DecorStyle } from './editors/decor/types'; import { DECOR_ASSETS_API_VERSION, type DecorAsset, } from './decor-assets'; import { DecorImageEditor } from './decor-image-editor'; import { boxAnchors, clamp01, decorCmToUnits, decorStylePatch, decorUnitsToCm, mergeSnapGeometry, normalizeAngle, resizeDecorBox, resizedBoxTopLeft, snapDecorPoint, validDecorDraft, type DecorBox, type SnapGeometry, } from './editors/decor/geometry'; import { decorStyleToSettings } from './editors/decor/geometry'; import { openingVisibleMetrics, renderOpeningVisibleGeometry, type OpeningFaceOffset, type OpeningVisibleSpec, } from './render/opening-symbol'; import { openingDefaultLengthCm, openingPlacementPreset, passagePlacementPreviewGeometry, resolveOpeningPlacementResult, sameOpeningPlacementInput, type OpeningPlacementPreset, type OpeningPlacementType, } from './opening-placement'; import { buildOpeningDimensionContext, resolveOpeningDimensions, type OpeningDimensionContext, } from './opening-dimensions'; import type { OpeningPlacementCandidate, OpMeasure, RenderOpening } from './interaction-types'; import { safeStoredColor } from './color'; import { gridCellFieldValue, gridVisualUnits, newSpaceCellCm } from './grid-scale'; import { applyOpeningMoves, mergeCollinearPartitions, spaceMergeGeometry } from './wall-merge'; import { reconcileCoincidentPartitions } from './coincident-partitions'; import { finalizeWallChainSpace } from './writer-fixed-point'; import { captureMarkerRoomReferences, restoreMarkerRoomReferences, rewriteMarkerRoomReferences, type MarkerRoomReferenceSnapshot, } from './room-reference-transaction'; const CARD_VERSION = '1.79.0-beta.1'; // #474: the editor imports the furniture artwork statically and hands it to // the page runtime the moment this chunk evaluates — before the loader's // `install`, so the palette and the placement ghost never render `pending`. FURNITURE_ART_RUNTIME.adopt(GENERATED_FURNITURE_ART, FURNITURE_ART_FINGERPRINT); type ResizeLiveLabel = { kind: 'length'; x: number; y: number; text: string; edge: { a: [number, number]; b: [number, number] }; } | { kind: 'area'; roomId: string; x: number; y: number; text: string; placement: ResizeAreaPlacement; }; type ResizePreview = { space: string; sp: any }; type ResizeWallUnion = ReturnType; type ResizeWallArtifact = ReturnType; /** Keeps every previously valid scale at the maximum 20 cm grid scale lossless. */ const DECOR_TEXT_CM_MAX = 2000; const lruWrite = (cache: Map, key: K, value: V, limit: number): void => { cache.delete(key); cache.set(key, value); while (cache.size > limit) { const oldest = cache.keys().next().value as K | undefined; if (oldest === undefined) break; cache.delete(oldest); } }; /** DEV-B703-01: warm re-mount memo — MODULE scope, so it lives with the loaded * PAGE, not with any card instance. Lovelace re-creates card elements when * the websocket reconnects after a long-backgrounded tab; the fresh instance * used to run the whole first-open boot (veil + BOOT_MIN_MS + quiescence), * which reads as «план перезагрузился», even though the page (and HA's * chrome) never went anywhere. Within one loaded page the chrome IS already * settled, so the geometry the previous instance settled at is still valid: * remember it per (viewport size × card config) and let the next instance * open instantly in the final geometry, no veil at all. A window resize * between instances changes the key → miss → the full protective boot (the * only case where the chrome may genuinely re-settle). The config part of * the key keeps two DIFFERENT cards on one page from adopting each other's * header height (same config twice on one view is indistinguishable — and * then the heights match anyway). */ /** Rotation step of a decor text block — the same 5° a device icon turns in * (marker dialog). Shift affects angle precision only, never position. */ const DT_ANGLE_STEP = 5; const LS_KIOSK = 'houseplan_card_kiosk_v1'; // per-SCREEN size multipliers (each wall tablet differs) const NORM_W = 1000; // side of the render space — the canvas is square (v1.48.0) /** Short semantic-event / direct-terminal-transition window. Event uses three sequential 1.1 s waves; motion cool-down itself never animates. */ /** #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 MarkupTool = 'select' | 'draw' | 'column' | 'merge' | 'split' | 'resize' | 'opening' | 'stairs' | 'wallthick' | 'delroom'; type WallFaceCandidate = WallGraphFace & { split?: { roomId: string; mainPoly: number[][]; newPoly: number[][] }; consumeAllActive?: boolean; /** Face existed before this click; rejecting it must be a true no-op. */ existing?: boolean; repair?: WallFaceRepairProposal; }; type WallFaceDecision = { candidate: WallFaceCandidate; create: boolean; name?: string; area?: string | null; settings?: RoomCfg['settings']; }; type WallFaceBatch = { candidates: WallFaceCandidate[]; index: number; decisions: WallFaceDecision[]; activePath: number[][]; activeCms: number[]; activePartitionIds: string[]; }; const MAX_WALL_CHAIN_POINTS = 500; const MAX_PARTITIONS = 2000; const MAX_ROOMS = 400; const MAX_WALL_COLUMNS = 500; /** Everything whose topology or wall association changes in the plan editor. */ interface SpaceGeometryState { spaceId: string; rooms: any[]; openings?: OpeningCfg[]; walls?: WallEntry[]; wall_segments?: any[]; open_spans?: OpenSpanEntry[]; 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; }; /** Session-only: current-writer normalisation scope for wall-chain history. */ wallChainSeedIds?: string[]; /** Session-only: exact room fields touched by Delete/Merge room. */ roomReferences?: MarkerRoomReferenceSnapshot[]; } /** Tools of the decor (background) editor. `furniture` is the library * (docs/FURNITURE.md): it opens a palette and places a symbol at real size. */ type DecorTool = 'select' | 'backdrop' | 'line' | 'rect' | 'ellipse' | 'text' | 'furniture' | 'image' | 'erase'; /** * Debounce with `flush()` and `pending`. Both are load-bearing: a pending * config write MUST be flushed before the card adopts a server revision, * otherwise the edit is silently dropped (audit L2, 2026-07-27). */ interface Debounced void> { (...a: Parameters): void; flush(): void; cancel(): void; pending(): boolean; } /** * Capture the pointer for a drag, tolerating an inactive pointerId. * * `setPointerCapture` throws for synthetic events and for pointers some * browsers consider gone; that killed a drag outright. The opening pipeline * was hardened for this, the device/label/resize ones were not (audit * follow-up L4 sub-item) — now they all go through here. */ const capturePointer = (ev: PointerEvent): void => { try { (ev.target as Element | null)?.setPointerCapture?.(ev.pointerId); } catch { /* an inactive pointerId must never kill the drag */ } }; interface DeviceInboxDialogState { tab: DeviceInboxCategory; search: string; showEntities: boolean; onlyNew: boolean; limit: number; /** #44: Discovery-filters drafts. `undefined` = untouched (mirror the * stored settings); draftExcluded `null` = "recommended" (no stored key). */ filtersOpen?: boolean; draftGroupLights?: boolean; draftExcluded?: string[] | null; /** Logical row restored after a nested marker dialog closes. */ anchor?: string; busy?: string; /** #618: exact bindings selected for a batch Hide/Show. Ephemeral, lives * only in this dialog state (and therefore survives `_deviceInboxReturn`). */ selected?: string[]; } export interface HouseplanEditorHostPort { _ackNewDevice: (id: string) => void; _activeWallChainId: string | null; _activeWallChainPartitionIds: string[]; _activePlanSnapCandidate: PlanSnapCandidate | null; _activePlanSnapConflicts: PlanSnapEndpoint[]; _activityRt: Map; _adoptInitialSpace: (models: SpaceModel[], authoritative?: boolean) => InitialSpaceSelection; _adoptAuthoritative: (input: GatedAdoptionInput) => Promise; // #500: the one adoption entry _rollbackOptimistic: (attempt: OptimisticAttempt) => boolean; readonly _adoption: ConfigAdoption; // #500: identity owner; _serverCfg/_cfgRev/_layout/_layoutRev delegate to it _alignDialog: OptimizePlansDialogState | null; _alignPoint: number[] | null; _allRoomsFlat: () => { value: string; label: string; }[]; _angleField: (value: unknown) => string; _applyView: (zoom: number, cx?: number, cy?: number) => boolean; _areaSel: string; _areaToSpace: Record; _aspectJob: Promise | null; _autoIconForBinding: (binding: string) => string; _backdropGuard: import('./backdrop-pick').BackdropGuardState | null; _backdropDialog: { widthCm: number; heightCm: number; angle: string; } | null; _backupExportDialog: { kind: "full" | "space"; planOnly: boolean; busy: boolean; error: string; } | null; _backupImportDialog: { filename: string; size: number; token: string; preview: any; expectedConfigRev: number; expectedLayoutRev: number; duplicatePolicy: "skip" | "virtual"; confirmMissing: boolean; busy: boolean; error: string; } | null; _baseVb: (projection?: 'flat' | 'iso') => number[]; _bdActive: boolean; _bdBase: Rect | null; _bdDrag: { kind: "move" | "scale" | "rotate"; pid: number; sx: number; sy: number; base: Rect; p0: { dx: number; dy: number; sx: number; sy: number; angle: number; }; fx: number; fy: number; sgx: number; sgy: number; rect0: DecorBox; before: SpaceGeometryState | null; moved: boolean; } | null; _bdMovable: boolean; _bdMoved: boolean; _bdParams: { dx: number; dy: number; sx: number; sy: number; angle: number; }; _bdRect: (Rect & { angle?: number; }) | null; _bindingEntities: (binding: string) => string[]; _bindingHasAlarm: (binding: string) => boolean; _bindingHasClimate: (binding: string) => boolean; _bindingHasHaPage: (binding: string) => boolean; _bindingStatus: (binding: string) => HaBindingStatus; _bootSoftCancel: () => void; _cacheSnapshot: () => void; _canCommitSpace: (id: string, authority?: boolean) => boolean; _canEdit: boolean; _canOptimizeUndo: boolean; _cancelDevicePressFeedback: () => void; _cancelDeviceDrag: () => boolean; _cancelModeTransition: (commitTarget?: boolean) => void; _candidateDeviceSnapshot: RenderDeviceSnapshot | null; _capturedSnapshotConfigEpoch: number; _cellCm: number; _cfgEpoch: number; readonly _cfgRev: number; _clearTransientHover: (suspend?: boolean) => void; _closeInfoCard: () => void; _closingWallCm: number | null; _cmToUnits: (cm: number) => number; _colorPickerLabels: ColorPickerLabels; _commitSpace: (id: string, authority?: boolean) => boolean; _commitViewModeAtomic: (from: ModeVisualState | null, targetZoom: number, targetCenterX?: number, targetCenterY?: number) => void; _confirmDanger: (request: HpConfirmRequest) => Promise; _cancelDangerConfirm: () => void; _config: CardConfig | undefined; _contourClosed: boolean; _curSpaceCfg: any; _currentModeVisual: (mode?: HouseplanMode) => ModeVisualState | null; _cursorPt: number[] | null; _decorBoxOf: (shape: DecorShape) => DecorBox | null; _decorDraft: { kind: "line" | "rect" | "ellipse"; a: number[]; b: number[]; pid: number; } | null; _decorEraseConfirm: { id: string; kind: DecorShape["kind"]; } | null; _decorImagePalette: DecorAsset | null; _decorAssetCatalog: DecorAsset[]; _decorAssets: Map; _decorAssetBusy: boolean; _decorH: number; _decorLargeCm: (value: number) => number; _decorLargeField: (cm: number) => number; _decorList: DecorShape[]; _decorMove: { id: string; start: number[]; orig: DecorShape; pid: number; moved: boolean; before: SpaceGeometryState | null; } | null; _decorResolvedStyle: (shape?: DecorShape | null) => DecorStyle; _decorSel: string | null; _decorShapeDialog: { id: string; kind: "line" | "rect" | "ellipse" | "furniture" | "image"; color: string; opacity: number; widthCm: number; lineStyle?: "solid" | "dashed"; fill?: boolean; fillColor?: string; fillOpacity?: number; lengthCm?: number; sizeWCm?: number; sizeHCm?: number; angle: string; symbol?: string; assetId?: string; sizeWField?: string; sizeHField?: string; flipH?: boolean; flipV?: boolean; } | null; _decorSmallCm: (value: number) => number; _decorSmallField: (cm: number) => number; _decorSnapCache: { epoch: number; space: string; height: number; exclude: string; geometry: SnapGeometry; } | null; _decorStyle: DecorStyle; _decorTextCm: (value: number) => number; _decorTextDialog: { id?: string; x: number; y: number; text: string; color: string; opacity: number; angle: string; sizeCm: number; pickerEntity?: string; preserveLegacy?: boolean; } | null; _decorTextSelection: { start: number; end: number; }; _decorTextSizeCm: (shape?: DecorShape | null) => number; _decorTool: DecorTool; _defPos: Record; _deviceInbox: DeviceInboxDialogState | null; _deviceInboxMemo: { key: string; rows: DeviceInboxRow[]; } | null; _deviceInboxReturn: DeviceInboxDialogState | null; _devicePresentation: (d: DevItem, showLqi?: boolean, designPreview?: boolean) => ResolvedDevicePresentation; _devicePositionBusy: boolean; _devicePositionHistory: CommandStack; _devices: DevItem[]; _dirtyPos: Set; _display: (url: string | null | undefined) => string; _wallChainSegmentCms: number[]; _wallChainRedo: Array<{ id: string; point: number[]; cm: number }>; _drag: { id: string; sx: number; sy: number; ox: number; oy: number; moved: boolean; } | null; /** #400: device dragging has its own state since #74; `_drag` is null in * the devices mode, so anything excluding "the thing being dragged" there * must read this instead. Only the id is needed by the editor runtime. */ _deviceDrag: { id: string } | null; _drawWallCm: number | null; _drawWallField: string | null; _drawWallFieldValue: string; _drawWallMaxCm: number; _dtBox: { id: string; x: number; y: number; w: number; h: number; } | null; _dtDrag: { id: string; kind: "scale" | "rotate"; pid: number; ax: number; ay: number; r0: number; a0: number; textSizeCm0: number; angle0: number; sgx?: number; sgy?: number; orig?: DecorBox; origShape: DecorShape; before: SpaceGeometryState | null; lineEnd?: 0 | 1; moved: boolean; } | null; _dtSel: DecorShape | null; _duplicateColumnId: string | null; _duplicateColumnTimer: number; _editing: boolean; _editorChromeMode: "plan" | "devices" | "decor"; _editorSecondary: EditorSecondaryController; _editorSecondaryContextId: string; _editorSecondaryCopy: EditorSecondaryCopy; _editorSecondaryDialogBlocked: boolean; _editorToolbarGroups: readonly EditorToolbarGroup[]; _effectiveProjection: () => "flat" | "iso"; _endTabDrag: () => void; _errText: (e: any) => string; _excluded: Set; _fillColors: FillColors; _fmtLen: (a: number[], b: number[]) => string; _frame: { id: string; model: SpaceModel; layout: unknown; devs: unknown; far: boolean; grow: boolean; rect: Rect; all: Rect; outliers: number; } | null; _freeAreas: any[]; _fullRegistryHass: any; _furnPalette: { symbol: string; w: number; h: number; } | null; _furnCategory: string | null; _furnPreviewInput: { raw: [number, number]; free: boolean; } | null; _furnTouchPending: { pid: number; sx: number; sy: number; pointerType: string; cancelled: boolean; } | null; _gearPtCache: WeakMap; _geometryHistory: CommandStack; _getAuthoritativeConfig: () => Promise; _glowRadiusCm: number; _glowRadiusPlaceholder: string; _gridPitch: number; _haIntegrationVersion: string | null; _haSupportApi: number | null; _haDecorAssetsApi: number | null; _haRegistry: HaRegistrySnapshot; _hasFixedFloor: boolean; _hiddenWallDiagnosticCache: { key: string; value: HiddenWallDiagnosticGeometry; } | null; _hoverRoom: { space: string; room: RoomCfg; } | null; _iconRules: CompiledIconRule[] | undefined; _imperial: boolean; _importDialog: { floors: (FloorInfo & { checked: boolean; })[]; } | null; _importQueue: string[]; _importTotal: number; _infoCard: DevItem | null; _innerRoomContour: (space: SpaceModel, roomId: string, openCuts?: number[][], roomWalls?: ReturnType['roomGeom'], multiWallNodes?: MultiWallNodeMap | null | undefined) => number[][] | null; _junctionLimitViolations: (config: unknown, spaceId: string, sharedGeometry?: JunctionSharedGeometry | null, roomIds?: ReadonlySet) => JunctionLimitViolation[]; _isVacDev: (d: DevItem) => boolean; _kiosk: boolean; _kioskDialog: boolean; _kioskHoldTimer: number | undefined; _kioskScale: { icon: number; font: number; }; _isoEnabled: boolean; _lastValidStageSize: [number, number] | null; _layout: DeviceLayout; readonly _layoutRev: number; _logicalViewCenter: () => { x: number; y: number; } | null; _markerDialog: { devId?: string; uploadId?: string; name: string; binding: string; bindingMode: "virtual" | "ha"; bindingOpen: boolean; showEntities: boolean; bindingFilter: string; icon: string; autoIcon: string; display: DeviceDisplayMode; rippleColor: string; rippleSize: number; size: number; angle: number; tapAction: string; tapActionTouched: boolean; originalHasTapAction: boolean; originalTapAction: string | null | undefined; tapHintAnnouncement: string; toggleEntity: string; toggleEntityTouched: boolean; originalHasToggleEntity: boolean; originalToggleEntity: string | null | undefined; tapTarget: string; tapConfirm: boolean; runFilter: string; controls: string[]; controlsFilter: string; glowRadius: string; lightRole: "auto" | "always" | "never"; lightRoleTouched: boolean; originalHasIsLight: boolean; originalIsLight: boolean | null | undefined; lightEntity: string; lightEntityTouched: boolean; originalHasLightEntity: boolean; originalLightEntity: string | null | undefined; glowMode: "auto" | "color" | "fixed"; glowColor: string; glowBrightness: number; glowColorDrafted: boolean; glowBrightnessDrafted: boolean; glowTouched: boolean; originalHasGlowColor: boolean; originalGlowColor: { c: string; bri?: number | null; } | null | undefined; valueBadgeEnabled: boolean; valueBadgeSource: ValueBadgeSource | null; valueBadgePosition: ValueBadgePosition; valueBadgeTouched: boolean; originalHasValueBadge: boolean; originalValueBadge: MarkerValueBadge | null | undefined; valueSource: ValueBadgeSource | null; valueSourceTouched: boolean; originalHasValueSource: boolean; originalValueSource: ValueBadgeSource | null | undefined; useClimateTemp: boolean; model: string; link: string; description: string; pdfs: PdfRef[]; room: string; roomTouched: boolean; radar: RadarEditorDraft | null; radarEligible: boolean; radarTouched: boolean; radarRemove: boolean; hideFromPlan: boolean; busy: boolean; } | null; _markerPreviewDevicesMemo: { base: readonly DevItem[]; preview: DevItem; devices: readonly DevItem[]; } | null; _markerPreviewMemo: { key: string; device: DevItem | null; } | null; _markers: Marker[]; _markup: boolean; _maybeRebuildDevices: () => void; _mergeDialog: { aId: string; bId: string; poly: number[][]; pick: "a" | "b"; } | null; _mergeSel: string | null; _mode: "view" | "plan" | "devices" | "decor"; _modeTransitionBusy: boolean; _modeTransitionEditorCamera: { zoom: number; centerX?: number; centerY?: number; } | null; _modeTransitionForceAtomic: boolean; _modeTransitionPreparing: boolean; _modeTransitionRequest: number; _modeTransitionTargetCenterX: number | undefined; _modeTransitionTargetCenterY: number | undefined; _modeTransitionTargetZoom: number; _modeTransitionVisual: ModeVisualState | null; _model: SpaceModel[]; _modelCache: { key: string; model: SpaceModel[]; } | null; _nameSel: string; _newIds: Set; _newSyncKey: string; _norm: boolean; _normPos: (space: string, x: number, y: number) => { s: string; x: number; y: number; }; _normalizeWalls: (walls: WallEntry[] | null | undefined, cuts: number[][]) => WallEntry[]; _noteLayoutRev: (r: any) => void; _opDrag: { id: string; moved: boolean; sx: number; sy: number; dirty: boolean; before: SpaceGeometryState | null; } | null; _opMeasure: OpMeasure | null; _openBindingInHa: (binding: string) => void; _openCuts: () => number[][]; _openingAmt: (o: OpeningCfg) => number; _openingDialog: { id?: string; type: "door" | "window" | "gate" | "passage"; lengthCm: number; lengthTouched?: boolean; contact: string; lock: string; contactOpen?: boolean; contactFilter?: string; lockOpen?: boolean; lockFilter?: string; invert: boolean; flipH: boolean; flipV: boolean; host?: PartitionOpeningHost; x: number; y: number; angle: number; } | null; _openingDimensionContextCache: { key: string; value: OpeningDimensionContext; } | null; _openingEntityAvailable: (eid: string | null | undefined) => boolean; _openingFace: (opening: RenderOpening, index: OpeningWallIndex, flipV: boolean) => OpeningFaceOffset; _openingHoverCandidate: OpeningPlacementCandidate | null; _openingJambBlockCm: number | null; _openingPlacementIntervalsCache: { key: string; value: WallInterval[]; } | null; _openingPreset: OpeningPlacementPreset | null; _openingPresetRevision: number; _openingPreview: OpeningPlacementCandidate | null; _openingRebindId: string | null; _openingWallIndexFor: (space: SpaceModel, openCuts: number[][]) => { key: string; value: OpeningWallIndex; }; _openingsR: RenderOpening[]; _optimizeUndoBusy: boolean; _panLock: "pan" | "swipe" | null; _panStart: { sx: number; sy: number; vx: number; vy: number; } | null; _partitionDeleteDialog: { id: string; openings: OpeningCfg[]; } | null; _path: number[][]; _pendingNavMode: "plan" | "devices" | "decor" | null; _pendingPhysicalWrites: Map; _pendingSplit: { roomId: string; mainPoly: number[][]; newPoly: number[][]; } | null; _persistLayout: Debounced<() => void>; _physicalBodiesCache: { key: string; drafts: number[][][]; partitions: number[][][]; columns: number[][][]; patches: number[][][]; all: number[][][]; } | null; _physicalBodiesR: (space?: SpaceModel | undefined) => number[][][]; _physicalDialog: { kind: "partition" | "column"; id: string; cm: string; shape?: "square" | "circle"; angle?: string; length?: string; } | null; _physicalDrag: { pid: number; kind: "partition" | "column"; id: string; start: number[]; startClient: number[]; before: SpaceGeometryState | null; moved: boolean; base: PartitionCfg | WallColumnCfg; delta: number[]; } | null; _physicalLastTap: { kind: "partition" | "column"; id: string; at: number; } | null; _physicalPickCycle: { signature: string; index: number; x: number; y: number; at: number; } | null; _physicalRotate: { pid: number; id: string; center: number[]; startAngle: number; baseAngle: number; angle: number; before: SpaceGeometryState | null; moved: boolean; } | null; _physicalSel: { kind: "partition" | "column"; id: string; } | null; _pinchStart: { dist: number; zoom: number; } | null; _planEntityAvailable: (eid: string | null | undefined) => boolean; _planHass: any; _planSnapGeometryCache: { key: string; value: PlanSnapGeometry; } | null; _planSnapHover: { contextKey: string; candidate: PlanSnapCandidate | null; conflicts: PlanSnapEndpoint[]; } | null; _planStructuralGeometryCache: { key: string; value: PlanSnapGeometry; } | null; _pointerModality: PointerModalityController; _pointers: Map; _pos: (d: DevItem) => { x: number; y: number; }; _prepareModeTransition: (request: number, from: ModeVisualState, targetMode: HouseplanMode, targetZoom: number, targetCenterX?: number, targetCenterY?: number) => void; _reducedMotion: boolean; _rawPhysicalBodiesR: () => number[][][]; _regSignature: string; _reloadConfigOnly: (force?: boolean, observedRev?: number) => Promise; _reloadLayoutOnly: () => Promise; _renderCardPreview: (spaceScale: number, nameScale: number, labelScale: number) => TemplateResult; _renderCompass: () => TemplateResult; _renderPlanHass: any; _resign: () => void; _resize: ResizeController< ResizePreview, ResizeLiveLabel[], SpaceGeometryState, ResizeWallUnion, ResizeWallArtifact >; _restoreZoom: () => void; _redoDevicePosition: () => void; _rlResize: { id: string; space: string; k0: number; cx: number; cy: number; d0: number; } | null; _roomCenter: (r: RoomCfg) => number[]; _roomCustomFill: FillColorEntry | null; _roomDeleteDialog: { roomId: string; name: string; } | null; _roomDialog: boolean; _roomEditId: string | null; _roomFill: "" | "none" | "lqi" | "light" | "temp" | "custom"; _roomHumSrc: string; _roomLabelScale: number; _roomNameScale: number; _roomTempMin: string; _roomTempMax: string; _roomTempSrc: string; _roomSrcFilter: string; _roomSrcOpen: "temp" | "hum" | null; _roomWallOpeningInputs: (openings?: readonly RenderOpening[], space?: SpaceModel | undefined) => Array<{ x: number; y: number; angle: number; length: number; }>; _rszLimitViolation: JunctionLimitViolation | null; _rulesDialog: { rules: IconRule[]; test: string; busy: boolean; } | null; _saveConfigDebounced: Debounced<() => void>; _saveNav: () => void; _savePos: (d: DevItem, x: number, y: number) => void; _screenToVb: (sx: number, sy: number) => number[]; _segments: number[][]; _selId: string | null; _sentPos: Map; _serverCfg: ServerConfig | null; _serverStorage: boolean; _settings: { exclude_integrations?: string[]; group_lights?: boolean; show_all?: boolean; filter_seeded?: boolean; icon_rules?: { pattern: string; icon: string; }[]; show_room_tooltip?: boolean; moon?: boolean; sun_ray_origin?: SunRayOrigin; zigbee_topology?: { enabled?: boolean; z2m_base_topics?: string[] }; radar?: { version?: 1; show_live?: boolean; [key: string]: unknown }; }; _terminalFrame: 0 | 1 | 2; _settingsDialog: { colors: FillColors; glowRadius: number; glowRadiusInput: string; bgColor: string | null; northDeg: number | null; northDegInput: string; bgMode: "static" | "daynight"; sunRays: boolean; sunRayOrigin: SunRayOrigin; showRoomTooltip: boolean; zigbeeTopology: ZigbeeTopologySettings; radarShowLive: boolean; volumetricView: boolean; moon: boolean; busy: boolean; } | null; _supportDialog: SupportDialogState | null; _showAll: boolean; _showHidden: boolean; _showToast: (msg: string) => void; _signer: ContentSigner; _space: string; _spaceDialog: SpaceDialogState | null; _spaceH: number; _spaceModel: () => SpaceModel | undefined; _spaceModelById: (id: string | null | undefined) => SpaceModel | undefined; _spaceWalls: WallEntry[]; _splitSel: { roomId: string; pts: number[][]; } | null; _stageBgHex: () => string; _stageEl: HTMLElement | null; _stagedDeviceSnapshotToken: number; _suppressClick: boolean; _swipeStart: { x: number; y: number; id: number; } | null; _t: (key: I18nKey, vars?: Record) => string; _undoDevicePosition: () => void; _thickWallCuts: () => number[][]; _tip: { x: number; y: number; title: string; meta: string; lqi?: number | null; temp?: number | null; hum?: number | null; room?: boolean; } | null; _toggleConfirmationLines: (intent: ResolvedToggleIntent) => string[]; _toggleConfirmationStateText: (target: ResolvedToggleTarget) => string; _toggleIntent: (device: DevItem, devices?: readonly DevItem[]) => ResolvedToggleIntent | null; _toggleMarkerDialogVisibility: () => void; _toggleStateText: (entityId: string, fallback: string) => string; _tool: MarkupTool; _undoKind: "optimize" | "import" | null; _undoPoint: () => void; _vacAllCameraCache: { devId: string; candidates: VacSourceCandidate[]; } | null; _vacAllCamerasFor: string | null; _vacCalConfirm: CalibrationProposal | null; _vacEnsureMarker: (d: DevItem) => Marker | null; _vacEntity: (d: DevItem) => string | null; _vacMapId: (d: DevItem, tele: { mapId: string }, planHass?: any) => string; _vacFit: VacuumFit | null; _vacOpenAllCameras: (d: DevItem) => void; _vacRt: Map; _vacSource: (d: DevItem, planHass?: any) => string | null; _vacObservedMapId: (d: DevItem, source: string) => string | undefined; _vacSourceResolution: (d: DevItem, includeAllCameras?: boolean, planHass?: any) => VacSourceResolution; _vacSrvTrails: Record; _view: { x: number; y: number; w: number; h: number; } | null; _viewportGestureDirty: boolean; _viewModeSnap: { space: string; zoom: number; cx?: number; cy?: number; w?: number; } | null; _viewOr: (vb: number[]) => { x: number; y: number; w: number; h: number; }; _virtualLights: VirtualLightSnapshot; _visibleDeviceSnapshot: RenderDeviceSnapshot | null; _wallDialog: { a: number[]; b: number[]; value: string; roomId: string | null; source: WallThickSource; sx: number; sy: number; } | null; _wallFaceBatch: WallFaceBatch | null; _wallFaceGraphCache: { key: string; value: WallFaceGraph; }[]; _wallKeyPitch: number; _wallRepairDiagnostic: WallFaceRepairProposal | null; _wallThickHover: { segs: number[][]; open: boolean; d: string; } | null; _wallUnionCache: { key: string; value: ReturnType; } | null; _wallUnionPool: Map; }>; _writeChain: Promise; _writesPending: number; _sendConfigCandidate: (candidate: ServerConfig) => Promise; _writeConfig: () => Promise; _requestMode: (mode: 'view' | 'plan' | 'devices' | 'decor', animate?: boolean) => Promise; _checkOptimizeGeometry: (config: ServerConfig) => OptimizeGeometryPreflightResult; _checkSpacePhysicalGeometry: ( config: ServerConfig, spaceId: string, captureWallGeometry?: (geometry: ReturnType) => void, ) => ReturnType; _primeDrawWallField: () => void; _reloadRejectedPhysicalWrite: () => Promise; _zoom: number; _zoomBySpace: Record; hass: any; isConnected: boolean; renderRoot: HTMLElement | DocumentFragment; requestUpdate: (name?: PropertyKey, oldValue?: unknown) => void; updateComplete: Promise; } /** Lazily loaded implementation of editor-only interaction and rendering. */ export const EDITOR_RUNTIME_FINGERPRINT = '__HOUSEPLAN_SOURCE_FINGERPRINT__'; 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(); private _resizePreviewNodes: MultiWallNodeMap | null = null; private _resizeBaselineLimits: JunctionLimitViolation[] = []; private _resizeBaseFrameStable = true; private _supportExpiryTimer?: number; private _supportPreviewGeneration = 0; private _decorAssetGuardReplace: boolean | null = null; public readonly roomGear: RoomGearDragController; private readonly _decorImages: DecorImageEditor; // #592: маркерный диалог уехал в src/editors/marker-dialog.ts и обращается // к контроллеру оттуда. Остальной класс использует `public _x` — поле // следует той же договорённости, а не заводит обёртку ради одного слова. public readonly _radarSetup: RadarSetupController; /** #642: «Оптимизировать планы» — свой модуль с узким портом, не делегаты карточки. */ public readonly optimizePlans: OptimizePlansDialog; public constructor(public readonly host: HouseplanEditorHostPort) { const owner = host; this.roomGear = new RoomGearDragController({ mode: () => owner._mode, spaceId: () => owner._space, currentRoom: (roomId) => owner._spaceModel()?.rooms.find((room) => room.id === roomId), planPoint: (event) => this._svgPoint(event), queueMove: (run) => this._queuePointerMove('room-gear', run), flushMove: () => this._flushPointerMove('room-gear'), cancelMove: () => this._cancelPointerMove('room-gear'), requestUpdate: () => owner.requestUpdate(), openRoom: (room) => this._openRoomEdit(room), }); this._decorImages = new DecorImageEditor(host, { decorSnap: (raw, pointerType) => this._decorSnap(raw, pointerType), geometrySnapshot: () => this._geometrySnapshot(), clearFurniturePreview: () => this._clearFurniturePreview(), recordGeometry: (name, before) => this._recordGeometry(name, before), saveConfig: () => this._saveConfig(), saveShape: () => this._decorSaveShape(), setGuardReplace: (replace) => { this._decorAssetGuardReplace = replace; }, furnShiftDetach: () => this._furnShiftDetach(), furnPick: (symbol) => this._furnPick(symbol), furnFieldValue: (cm) => this._furnFieldValue(cm), furnFieldToCm: (value) => this._furnFieldToCm(value), }); this._radarSetup = new RadarSetupController({ hass: () => host.hass, requestUpdate: () => host.requestUpdate(), t: (key, vars) => host._t(key, vars), configFromDraft: radarConfigFromDraft, apply: (radar) => { if (host._markerDialog) host._markerDialog = { ...host._markerDialog, radar, radarEligible: true, radarTouched: true, radarRemove: false, }; }, confirmDiscard: () => host._confirmDanger(radarDiscardRequest((key) => host._t(key))), }); this.optimizePlans = new OptimizePlansDialog({ dialog: () => host._alignDialog, setDialog: (next) => { host._alignDialog = next; }, t: (key, vars) => host._t(key, vars), hass: () => host.hass, config: () => host._serverCfg, planReady: () => !!host._norm, layout: () => host._layout, integrationVersion: () => host._haIntegrationVersion, cardVersion: () => displayVersion(CARD_VERSION), requestUpdate: () => host.requestUpdate(), showToast: (message) => host._showToast(message), errorText: (error) => host._errText(error), checkGeometry: (config) => host._checkOptimizeGeometry(config), referenceContext: (removeLive) => this._optimizeReferenceContext(removeLive), showMigrationBlocked: (error) => this._showWallModelMigrationBlocked(error), clearGeometryGesture: () => this._clearGeometryGesture(), commit: (config, layout) => commitPlanOptimization(host, config, layout), reloadAfterConflict: () => Promise.all([host._reloadConfigOnly(true), host._reloadLayoutOnly()]), }); host._editorSecondary = new EditorSecondaryController({ root: () => host.renderRoot as ShadowRoot, requestUpdate: () => host.requestUpdate(), updateComplete: () => host.updateComplete, clearTip: () => { host._tip = null; }, }); host._resize = new ResizeController< ResizePreview, ResizeLiveLabel[], SpaceGeometryState, ResizeWallUnion, ResizeWallArtifact >(); } public _routeLiveEditorUpdate(name?: PropertyKey, oldValue?: unknown): boolean { const gearSpace = this.host._mode === 'plan' ? this.host._spaceModel() : null; if (gearSpace) this.roomGear.adoptPlan(gearSpace); const live = routeHouseplanEditorUpdate(this.host, name, oldValue); if (!live && this.host._resize.preview) this._resizeBaseFrameStable = false; return live; } public _commitLiveEditor(): void { commitHouseplanEditor(this.host); } public _disposeLiveEditor(): void { this._radarSetup.reset(); this.roomGear.reset(); disposeHouseplanEditor(this.host); } public _cancelRadarSetup(): void { this._radarSetup.interrupt(); } public async _whenLiveEditorSettled(): Promise { // An already queued pointer calculation runs before this continuation. // A complete Lit render may also supersede the lightweight frame, and its // updated() hook commits that same state before updateComplete resolves. await this.host.updateComplete; await whenHouseplanEditorSettled(this.host); } public _queuePointerMove(key: string, run: () => void): void { queueHouseplanPointerMove(this.host, key, run); } public _flushPointerMove(key: string): void { flushHouseplanPointerMove(this.host, key); } public _cancelPointerMove(key?: string): void { cancelHouseplanPointerMove(this.host, key); } public _help(key: Extract): TemplateResult | typeof nothing { const ariaKey = `${key}.aria` as I18nKey; const lang = langOf(this.host.hass, this.host._config?.language); if (!hasTranslation(lang, key) || !hasTranslation(lang, ariaKey)) return nothing; return html``; } public _setMode(mode: 'view' | 'plan' | 'devices' | 'decor', animate = true): void { if (mode !== this.host._mode) this.host._cancelDangerConfirm(); this.host._endTabDrag(); this._clearFurniturePreview(); // A mode command is newer than the editor remembered by a same-route warm // remount. Clear it before the same-mode early return: while can_write is // pending, Lit may still be presenting the previous editor DOM even though // the new instance already fails closed to `view`. Its close button must // cancel the deferred editor in one press, not let the server response // reopen it and force a second press (#95). this.host._pendingNavMode = null; const space = this.host._spaceModel(); if (!space) { this.host._cancelModeTransition(false); this.host._mode = 'view'; return; } if (this.host._kiosk && mode !== 'view') return; // wall devices never edit if (this.host._mode === mode) { if (!animate && mode === 'view' && this.host._modeTransitionBusy) { const from = this.host._currentModeVisual(mode); const targetZoom = this.host._modeTransitionTargetZoom; const targetCenterX = this.host._modeTransitionTargetCenterX; const targetCenterY = this.host._modeTransitionTargetCenterY; this.host._cancelModeTransition(false); this.host._commitViewModeAtomic(from, targetZoom, targetCenterX, targetCenterY); } return; } if (this.host._mode === 'devices') this.host._cancelDeviceDrag(); this.host._bootSoftCancel(); // navigation owns its own short, bounded transition if ((mode === 'plan' || mode === 'decor') && !this.host._norm) { this.host._showToast(this.host._t('toast.markup_needs_server')); return; } if (this.host._wallFaceBatch) this._roomDialogCancel(); if (this.host._mode === 'plan' && this.host._tool === 'draw' && !this._finishWallChain()) return; this.host._clearTransientHover(true); this.host._cancelDevicePressFeedback(); const previousMode = this.host._mode; const previousProjection = this.host._effectiveProjection(); const capturedVisual = this.host._currentModeVisual(previousMode); const retargeting = this.host._modeTransitionBusy; // The target toolbar replaces the old editor markup during the hidden // measurement frame. Give that incoming content its own coordinate on the // shared controller so editor-to-editor switches do not pop abruptly. const fromVisual = capturedVisual && previousMode !== 'view' && mode !== 'view' ? { ...capturedVisual, toolbarContentOpacity: this.host._reducedMotion ? 1 : 0.35 } : capturedVisual; this.host._cancelModeTransition(false); this.host._editorSecondary.closeForNavigation(); // A live decor transform is a transaction. Switching tabs must cancel and // restore it, not merely forget its pointer record after the config has // already been mutated by move/resize. if (this.host._decorMove || this.host._dtDrag || this.host._bdDrag) this._cancelDecorGesture(); const baseChanges = !space.bg && (mode === 'view') !== (previousMode === 'view'); // A running/preparing transition has not committed its target to `_zoom` // yet. Retarget from the painted frame, but preserve the latest camera // intent; otherwise rapid View -> editor A -> editor B resurrects the old // View zoom/center and settles somewhere a direct transition never would. const returningToEditor = retargeting && previousMode === 'view' && mode !== 'view' ? this.host._modeTransitionEditorCamera : null; let targetZoom = returningToEditor?.zoom ?? (retargeting ? this.host._modeTransitionTargetZoom : this.host._zoom); let targetCenterX = returningToEditor ? returningToEditor.centerX : retargeting ? this.host._modeTransitionTargetCenterX : fromVisual?.viewport.centerX; let targetCenterY = returningToEditor ? returningToEditor.centerY : retargeting ? this.host._modeTransitionTargetCenterY : fromVisual?.viewport.centerY; if (previousMode === 'view' && mode !== 'view' && !retargeting) { // remember the view-mode viewport: whatever zooming happens inside the // editors is a working tool, not what the user wants to see afterwards const v = this.host._view; this.host._viewModeSnap = { space: this.host._space, zoom: this.host._zoom, cx: v ? this.host._logicalViewCenter()?.x : undefined, cy: v ? this.host._logicalViewCenter()?.y : undefined, w: previousProjection === 'flat' ? v?.w : undefined, }; if (previousProjection === 'iso') { // #713: the 2.5D floor is the Flat plane. Enter the Flat editor with // the same screen scale and centre, exactly as from a Flat View // showing this picture: only the scalar zoom is re-read. if (v) { targetZoom = fitView(this.host._baseVb('flat'), v.w / v.h).w / v.w; targetCenterX = v.x + v.w / 2; targetCenterY = v.y + v.h / 2; } this.host._view = null; this.host._mode = mode; this.host._applyView(targetZoom, targetCenterX, targetCenterY); } if (baseChanges) { targetZoom = 1; targetCenterX = undefined; targetCenterY = undefined; } } this.host._mode = mode; if (previousMode === 'plan' && mode !== 'plan') this.roomGear.reset(); if (previousMode === 'devices' && mode !== 'devices') { this.host._showHidden = false; this.host._deviceInbox = null; this.host._deviceInboxReturn = null; this.host._deviceInboxMemo = null; } this.host._editorChromeMode = mode === 'view' ? previousMode as 'plan' | 'devices' | 'decor' : mode; if (mode === 'view') { if (previousMode !== 'view' && !retargeting) { this.host._modeTransitionEditorCamera = { zoom: this.host._zoom, centerX: fromVisual?.viewport.centerX, centerY: fromVisual?.viewport.centerY, }; } const snap = this.host._viewModeSnap; // restore the snapshot only for the space it was taken in if (snap && snap.space === this.host._space) { targetZoom = snap.zoom; const targetProjection = this.host._isoEnabled ? 'iso' : 'flat'; const center = snap.cx != null && snap.cy != null && targetProjection === 'iso' ? projectPlanPoint([snap.cx, snap.cy], 0) : null; targetCenterX = center?.[0] ?? snap.cx; targetCenterY = center?.[1] ?? snap.cy; } else if (snap) { // HP-1543-01: the floor CHANGED inside the editor — the snapshot // belongs to the floor the editor was entered from, while _zoom still // carries the editor's working zoom for the floor we are exiting on. // Dropping the snapshot alone left that editor zoom (say 500%) on // screen in view mode. The per-space store was never polluted // (_saveZoom is view-only), so the standard centred // _restoreZoom() puts back this floor's saved VIEW viewport. Its rAF // reads _zoomBySpace, not the snapshot, so the same-tick floor-tab // race the view-only guard protects against stays closed. targetZoom = this.host._zoomBySpace[this.host._space] || 1; targetCenterX = undefined; targetCenterY = undefined; } } else { this.host._modeTransitionEditorCamera = { zoom: targetZoom, centerX: targetCenterX, centerY: targetCenterY, }; } this.host._modeTransitionTargetZoom = targetZoom; this.host._modeTransitionTargetCenterX = targetCenterX; this.host._modeTransitionTargetCenterY = targetCenterY; if (previousProjection === 'iso' || (mode === 'view' && this.host._isoEnabled)) { this.host._modeTransitionForceAtomic = true; } const request = ++this.host._modeTransitionRequest; if (!animate) { // The only non-animated caller is route departure; keep the guard local // so a future editor caller cannot accidentally use View geometry. if (mode === 'view') { this.host._commitViewModeAtomic(fromVisual, targetZoom, targetCenterX, targetCenterY); } } else if (fromVisual) { this.host._modeTransitionPreparing = true; this.host._modeTransitionVisual = fromVisual; this.host._prepareModeTransition( request, fromVisual, mode, targetZoom, targetCenterX, targetCenterY, ); } else { // A zero-sized stage cannot animate. Keep the functional fallback // atomic and publish the exact target as soon as it is measurable. this.host._modeTransitionPreparing = false; this.host._modeTransitionVisual = null; this.host._zoom = targetZoom; this.host._view = null; requestAnimationFrame(() => { if (!this.host.isConnected || request !== this.host._modeTransitionRequest || this.host._mode !== mode) return; this.host._applyView(targetZoom, targetCenterX, targetCenterY); if (mode === 'view') { this.host._viewModeSnap = null; this.host._modeTransitionEditorCamera = null; } this.host.requestUpdate(); }); } this.host._path = []; this._clearPlanSnapHover(); this._clearOpeningPlacement(true); this.host._tool = 'draw'; this.host._mergeSel = null; this.host._mergeDialog = null; this.host._splitSel = null; this.host._pendingSplit = null; this.host._selId = null; this.host._physicalSel = null; this.host._physicalDialog = null; this.host._physicalDrag = null; this._rszResetController(); this.host._tip = null; this.host._hoverRoom = null; this.host._decorDraft = null; this.host._decorSel = null; this.host._decorMove = null; this.host._backdropDialog = null; // Every Background session starts predictably on Select. The image has an // explicit tool of its own; only that tool may claim its body or frame. this.host._decorTool = 'select'; this.host._bdDrag = null; this.host._dtDrag = null; this.host._dtBox = null; if (mode === 'plan') this._primeDrawWallField(); this.host._saveNav(); } public _primeDrawWallField(): void { if (this.host._drawWallField === null) { this.host._drawWallField = cmToField(DRAW_WALL_DEFAULT_CM, this.host._imperial); } } public _showPhysicalRange(max = this.host._drawWallMaxCm, min = 0): void { this.host._showToast(this.host._t('toast.physical_range', { min: cmToField(min, this.host._imperial), max: cmToField(max, this.host._imperial), unit: this.host._t(this.host._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm'), })); } public _mergeSpacePartitions(sp: any, seedIds?: string[]): number { const partitions = (sp?.partitions || []) as PartitionCfg[]; if (partitions.length < 2) return 0; const result = mergeCollinearPartitions(partitions, { pitch: GRID_STEP_N, seedIds, geometry: spaceMergeGeometry(sp), }); if (!result.merged) return 0; sp.partitions = result.partitions; applyOpeningMoves(sp.openings, sp.partitions, result.openingMoves, { coordScale: NORM_W, cellCm: this.host._cellCm, gridPitch: this.host._gridPitch, }); return result.merged; } public _finalizeWallChainPartitions(seedIds: readonly string[]): boolean { if (!seedIds.length) return true; const sp = this.host._curSpaceCfg; const model = this.host._spaceModel(); const before = this._geometrySnapshot(); if (!sp || !model || !before) return false; const seed = seedIds.map((id) => wallChainLiveSeed(sp, id)).find(Boolean) || null; const session = { path: this.host._path.map((point) => [...point]), chainId: this.host._activeWallChainId, ids: [...this.host._activeWallChainPartitionIds], cms: [...this.host._wallChainSegmentCms], redo: this.host._wallChainRedo.map((item) => ({ ...item, point: [...item.point], })), closingCm: this.host._closingWallCm, }; let result; try { result = finalizeWallChainSpace( sp, model, this.host._openCuts(), seedIds, { pitch: this.host._wallKeyPitch, cellCm: this.host._cellCm, gridPitch: this.host._gridPitch, coordScale: NORM_W, }, ); } catch { this.host._showToast(this.host._t('toast.geometry_unsafe')); return false; } if (!result.report.changed) return true; const config = this.host._serverCfg; const index = config?.spaces?.findIndex((space) => space.id === before.spaceId) ?? -1; if (index < 0) return false; config!.spaces[index] = result.space; const committed = commitWallChainFinishGeometry( this, this.host._t('history.wall_segment'), before, seed, ); if (committed) return true; // The common transaction clears gestures on refusal. A finish failure is // fail-closed: keep the visible chain so the requested navigation can be // retried after the underlying geometry is fixed. this.host._path = session.path; this.host._activeWallChainId = session.chainId; this.host._activeWallChainPartitionIds = session.ids; this.host._wallChainSegmentCms = session.cms; this.host._wallChainRedo = session.redo; this.host._closingWallCm = session.closingCm; return false; } public _finishWallChain(): boolean { if (this.host._tool !== 'draw' || this.host._wallFaceBatch || this.host._roomDialog) return true; if (!this._finalizeWallChainPartitions(this.host._activeWallChainPartitionIds)) return false; this.host._path = []; this.host._activeWallChainId = null; this.host._activeWallChainPartitionIds = []; this.host._wallChainSegmentCms = []; this.host._wallChainRedo = []; this.host._closingWallCm = null; this._clearPlanSnapHover(); return true; } public _activateMarkupTool(tool: MarkupTool): void { if (tool === this.host._tool) return; if (this.host._wallFaceBatch) this._roomDialogCancel(); if (this.host._tool === 'draw' && !this._finishWallChain()) return; else this._cancelPath(); if (this.host._tool === 'resize') { if (this.host._resize.dragging) this._rszCancelDrag(); 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; } public _limitReached(kind: 'partition' | 'column'): boolean { const sp = this.host._curSpaceCfg as any; if (!sp) return true; const reached = kind === 'partition' ? (sp.partitions || []).length >= MAX_PARTITIONS : (sp.wall_columns || []).length >= MAX_WALL_COLUMNS; if (reached) this.host._showToast(this.host._t('toast.physical_limit')); return reached; } public _svgPoint(ev: MouseEvent): number[] { const stage = this.host.renderRoot.querySelector('.stage') as HTMLElement; const r = stage.getBoundingClientRect(); return this.host._screenToVb(ev.clientX - r.left, ev.clientY - r.top); } public _snap(p: number[]): number[] { const g = this.host._gridPitch; return [clampCanvasR(snapToGrid(p[0], g)), clampCanvasR(snapToGrid(p[1], g))]; } public _snapDrawPoint(p: number[], lock45 = false): number[] { const anchor = this.host._path[this.host._path.length - 1]; const candidate = lock45 && anchor ? snapSegment45(anchor, p, this.host._gridPitch, SANE_LIMIT) : p; return this._snap(candidate); } public _planSnapOpeningCuts(space: SpaceModel, openCuts: number[][]): number[][] { if (!this.host._openingsR.length) return []; const index = this.host._openingWallIndexFor(space, openCuts).value; const cuts: number[][] = []; for (const input of this.host._roomWallOpeningInputs(this.host._openingsR, space)) { const association = resolveOpeningWallAssociation(index, input); if (!association.negative && !association.positive) continue; const rad = input.angle * Math.PI / 180; const dx = Math.cos(rad) * input.length / 2; const dy = Math.sin(rad) * input.length / 2; cuts.push([input.x - dx, input.y - dy, input.x + dx, input.y + dy]); } return cuts; } public _planSnapGeometrySnapshot(): { key: string; value: PlanSnapGeometry } { const space = this.host._spaceModel(); if (!space) { return { key: `${this.host._space}|empty`, value: { segments: [], endpoints: [] } }; } const key = [ this.host._space, this.host._cfgEpoch, space.rooms.length, space.partitions.length, ].join('|'); if (this.host._planSnapGeometryCache?.key === key) return this.host._planSnapGeometryCache; const zeroCuts = this.host._openCuts(); const value = buildPlanSnapGeometry({ space, // A zero-thickness wall is still a canonical wall axis and snap target. // Only an actual opening removes the presentation interval (#306). roomCuts: this._planSnapOpeningCuts(space, zeroCuts), partitionCuts: this._partitionOpeningCuts(space), epsilon: this.host._gridPitch * 0.0002, }); this.host._planSnapGeometryCache = { key, value }; return this.host._planSnapGeometryCache; } public _hiddenWallDiagnosticSnapshot(): { key: string; value: HiddenWallDiagnosticGeometry; } { const space = this.host._spaceModel(); if (!space) { return { key: `${this.host._space}|hidden-empty`, value: { segments: [], endpoints: [] } }; } const key = [ 'hidden', this.host._space, this.host._cfgEpoch, space.rooms.length, space.partitions.length, ].join('|'); if (this.host._hiddenWallDiagnosticCache?.key === key) { return this.host._hiddenWallDiagnosticCache; } const value = buildHiddenWallDiagnosticGeometry({ space, epsilon: this.host._gridPitch * 0.0002, }); this.host._hiddenWallDiagnosticCache = { key, value }; return this.host._hiddenWallDiagnosticCache; } public _planStructuralGeometrySnapshot(): { key: string; value: PlanSnapGeometry } { const space = this.host._spaceModel(); if (!space) { return { key: `${this.host._space}|structural-empty`, value: { segments: [], endpoints: [] } }; } const key = [ 'structural', this.host._space, this.host._cfgEpoch, space.rooms.length, space.partitions.length, ].join('|'); if (this.host._planStructuralGeometryCache?.key === key) { return this.host._planStructuralGeometryCache; } const value = buildPlanSnapGeometry({ space, epsilon: this.host._gridPitch * 0.0002, }); this.host._planStructuralGeometryCache = { key, value }; return this.host._planStructuralGeometryCache; } public _planSnapContextKey(geometryKey: string): string { const first = this.host._path[0]; const anchor = this.host._path[this.host._path.length - 1]; return [ geometryKey, this.host._tool, this.host._path.length, first ? `${first[0]},${first[1]}` : '', anchor ? `${anchor[0]},${anchor[1]}` : '', ].join('|'); } public _resolvePlanDrawPoint( raw: number[], lock45: boolean, ): { point: number[]; candidate: PlanSnapCandidate | null; conflicts: PlanSnapEndpoint[]; ambiguous: boolean; contextKey: string; } { const snapshot = this._planSnapGeometrySnapshot(); const anchor = this.host._path[this.host._path.length - 1]; const closure = this.host._tool === 'draw' && this.host._path.length >= 3 ? [{ point: this.host._path[0], key: 'closure:first-point' }] : []; // Accepted segments are immediately part of the canonical partition graph. // Keep their intermediate vertices out of ordinary snapping so the active // chain cannot snap onto itself. The first vertex is deliberately omitted: // once three points exist it remains the explicit contour-closure target. const excludedActivePoints = this.host._path.length > 1 ? this.host._path.slice(1) : anchor ? [anchor] : []; const options = { tolerance: this._cssPxToRender(12), distinguishTolerance: this._cssPxToRender(8), gridStep: this.host._gridPitch, excludePoints: excludedActivePoints, extraEndpoints: closure, epsilon: this.host._gridPitch * 0.0002, }; const resolution = lock45 && anchor ? resolveStrictPlanSnap(snapshot.value, raw, { ...options, anchor }) : resolvePlanSnapResult(snapshot.value, raw, options); const candidate = resolution.kind === 'resolved' ? resolution.candidate : null; const snapped = candidate ? [...candidate.point] : this._snapDrawPoint(raw, lock45); const point = anchor ? snapNearAxisEndpoint(anchor, snapped) : snapped; // A nearby topology endpoint loses snap ownership when the drafting rule // moves the actually persisted point away from it. Preview and click must // describe the exact same geometry (#290). const effectiveCandidate = candidate && samePoint(point, candidate.point) ? candidate : null; return { point, candidate: effectiveCandidate, conflicts: resolution.kind === 'ambiguous' ? resolution.conflicts : [], ambiguous: resolution.kind === 'ambiguous', contextKey: this._planSnapContextKey(snapshot.key), }; } public _clearPlanSnapHover(clearCursor = true): void { this.host._planSnapHover = null; this._syncPlanSnapActiveMarker(null); this._syncPlanSnapConflictMarkers([]); if (clearCursor) this.host._cursorPt = null; } public _samePt(a: readonly number[], b: readonly number[]): boolean { return samePoint(a, b); } public _dropLegacySegments(config = this.host._serverCfg): void { // «Ripple only» was removed from the UI: keep old configs readable, then // materialise the recognisable icon+activity presentation on any write. for (const marker of config?.markers || []) { if (marker.display === 'ripple') marker.display = 'icon_ripple'; } for (const sp of config?.spaces || []) { delete (sp as any).segments; const physicalIds = new Set(); const validId = (id: any): id is string => typeof id === 'string' && id.length >= 1 && id.length <= 64 && !physicalIds.has(id); const keepId = (id: string): boolean => { physicalIds.add(id); return true; }; const point = (p: any): p is number[] => Array.isArray(p) && p.length === 2 && p.every((v: any) => Number.isFinite(Number(v)) && Math.abs(Number(v)) <= CANVAS_LIMIT); if (Array.isArray((sp as any).partitions)) { (sp as any).partitions = (sp as any).partitions.filter((p: any) => p && validId(p.id) && point(p.a) && point(p.b) && Math.hypot(p.a[0] - p.b[0], p.a[1] - p.b[1]) > 1e-9 && keepId(p.id)) .map((p: any) => ({ ...p, cm: Number.isFinite(Number(p.cm)) ? Math.max(0, Math.min(100, Number(p.cm))) : 15, })); if (!(sp as any).partitions.length) delete (sp as any).partitions; } if (Array.isArray((sp as any).wall_columns)) { (sp as any).wall_columns = (sp as any).wall_columns.filter((c: any) => c && validId(c.id) && point(c.center) && keepId(c.id)) .map((c: any) => ({ id: c.id, shape: c.shape === 'circle' ? 'circle' : 'square', center: [Number(c.center[0]), Number(c.center[1])], cm: clampColumnCm(Number(c.cm) || 15), ...(c.shape === 'circle' ? {} : { angle: canonicalColumnAngle(c.angle) }), })); if (!(sp as any).wall_columns.length) delete (sp as any).wall_columns; } if (Array.isArray(sp.walls)) { const model = selectSpaceModelById(spaceModels({ spaces: [sp] } as any), sp.id); const cuts = model ? resolveZeroWalls(sp, model, NORM_W, GRID_PITCH * 0.02).contour .map((cut) => cut.map((value) => value / NORM_W)) : sanitizeOpenSpans((sp as any).open_spans) .map((e) => [e.a[0], e.a[1], e.b[0], e.b[1]]); sp.walls = degradeWalls(sp.walls, sp.rooms || [], GRID_STEP_N, 1, cuts); if (!sp.walls.length) delete sp.walls; } } } public _rollbackRejectedPhysicalWrites( entries: Array<[string, { fingerprint: string; before: SpaceGeometryState }]>, ): boolean { if (!this.host._serverCfg || !entries.length) return false; let restored = false; for (const [spaceId] of entries) { const pending = this.host._pendingPhysicalWrites.get(spaceId); if (!pending) continue; restored = this._restoreGeometryStateInConfig(this.host._serverCfg, pending.before) || restored; this.host._pendingPhysicalWrites.delete(spaceId); } if (!restored) return false; this._clearGeometryGesture(); this.host._geometryHistory.clear(); this.host._cfgEpoch++; this.host._modelCache = null; this.host._wallUnionCache = null; this.host._physicalBodiesCache = null; this.host._frame = null; this.host._regSignature = ''; this.host._adoption.refreshConfigFingerprint(); this.host._maybeRebuildDevices(); this.host.requestUpdate(); return true; } public async _reloadRejectedPhysicalWrite(): Promise { const queued = this.host._writeChain; await queued.catch(() => undefined); await this.host._reloadConfigOnly(true); } public _prepareConfigCandidate(config: ServerConfig): ServerConfig { this._dropLegacySegments(config); return canonicalizeConfigGeometry(config); } public _writeConfig(attempt: OptimisticAttempt | null = null): Promise { this.host._writesPending++; this.host._writeChain = enqueueSerializedWrite(this.host._writeChain, async () => { if (!this.host._serverCfg) return; const liveFingerprint = contentFingerprint(this.host._serverCfg); const candidate = this._prepareConfigCandidate(this.host._serverCfg); const candidateFingerprint = contentFingerprint(candidate); if (attempt && liveFingerprint === attempt.attemptedFingerprint) Object.assign(attempt, { revision: this.host._cfgRev, attempted: candidate, attemptedFingerprint: candidateFingerprint }); const strictEntries = [...this.host._pendingPhysicalWrites.entries()]; for (const [spaceId, accepted] of strictEntries) { const candidateSpace = candidate.spaces.find((space) => space.id === spaceId); const exactFingerprint = spacePhysicalGeometryFingerprint(candidateSpace); if (exactFingerprint === accepted.fingerprint) continue; let safe = false; try { safe = this.host._checkSpacePhysicalGeometry(candidate, spaceId).ok; } catch { safe = false; } if (!safe) { this._restoreGeometryStateLocal(accepted.before); this.host._pendingPhysicalWrites.delete(spaceId); this.host._geometryHistory.clear(); this.host._showToast(this.host._t('toast.geometry_unsafe')); throw Object.assign(new Error('unsafe wall geometry'), { code: 'geometry-unsafe' }); } accepted.fingerprint = exactFingerprint; } // Do not replace the reactive root merely because the pure helper // returned a clone. Besides an unnecessary render, that used to expose // unrelated write-time cleanup as a visual change (#224 review H1). // A real coordinate change still adopts the exact object sent below; // willUpdate owns the corresponding geometry-epoch bump. this.host._adoption.stageConfigCandidate(candidate); try { await this.host._sendConfigCandidate(candidate); } catch (error) { const rolledBack = this._rollbackRejectedPhysicalWrites(strictEntries); if (!rolledBack) throw error; // HA normally rejects with an Error-like object, but integrations and // test doubles may reject with a string. Never lose the rollback tag: // the caller still has to schedule an authoritative re-read after the // synchronous, fail-closed restoration (#314). const failure: any = error && typeof error === 'object' ? error : Object.assign(new Error(String(error)), { cause: error }); failure.physicalGeometryRolledBack = true; throw failure; } for (const [spaceId, accepted] of strictEntries) { if (this.host._pendingPhysicalWrites.get(spaceId)?.fingerprint === accepted.fingerprint) this.host._pendingPhysicalWrites.delete(spaceId); } }); const mine = this.host._writeChain.finally(() => { this.host._writesPending--; }); // keep the chain itself unadorned so the next link waits for the write only return mine; } public _saveConfig(): void { this.host._cfgEpoch++; this.host._saveConfigDebounced(); } public _geometrySnapshotFromConfig(config: any, spaceId: string): SpaceGeometryState | null { const sp = config?.spaces?.find((s: any) => s.id === spaceId); if (!sp) return null; const copy = (value: T): T => JSON.parse(JSON.stringify(value)); const plan_transform: SpaceGeometryState['plan_transform'] = {}; for (const key of ['plan_x', 'plan_y', 'plan_scale', 'plan_scale_x', 'plan_scale_y', 'plan_angle'] as const) { if (sp[key] !== undefined) plan_transform[key] = sp[key]; } return { spaceId, rooms: copy(sp.rooms || []), ...(Array.isArray(sp.openings) ? { openings: copy(sp.openings) } : {}), ...(Array.isArray(sp.walls) ? { walls: copy(sp.walls) } : {}), ...(Array.isArray(sp.wall_segments) ? { wall_segments: copy(sp.wall_segments) } : {}), ...(Array.isArray((sp as any).open_spans) ? { open_spans: copy((sp as any).open_spans) } : {}), ...(Array.isArray((sp as any).partitions) ? { partitions: copy((sp as any).partitions) } : {}), ...(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.stairs) ? { stairs: copy(sp.stairs) } : {}), plan_transform, }; } public _geometrySnapshot(spaceId = this.host._space): SpaceGeometryState | null { return this._geometrySnapshotFromConfig(this.host._serverCfg, spaceId); } public _recordGeometry(name: string, before: SpaceGeometryState | null): void { if (!before) return; const after = this._geometrySnapshot(before.spaceId); if (!after || JSON.stringify(before) === JSON.stringify(after)) return; if (before.wallChainSeedIds) { after.wallChainSeedIds = [...this.host._activeWallChainPartitionIds]; } if (before.roomReferences) { after.roomReferences = captureMarkerRoomReferences( this.host._serverCfg?.markers || [], before.roomReferences.map((reference) => reference.markerId), ); } this.host._geometryHistory.push({ name, before, after }); this.host.requestUpdate(); } public _restoreGeometryStateInConfig( config: any, state: SpaceGeometryState, preserveIdentityHints = false, ): boolean { const sp = config?.spaces?.find((space) => space.id === state.spaceId); if (!sp) return false; const copy = (value: T): T => JSON.parse(JSON.stringify(value)); const oldRooms = new Map((sp.rooms || []).map((room) => [room.id, room])); sp.rooms = copy(state.rooms); if (preserveIdentityHints) { for (const room of sp.rooms) { const old: any = oldRooms.get(room.id); if (!Array.isArray(room.wall_ids) && Array.isArray(old?.wall_ids) && old.wall_ids.length === room.poly?.length) room.wall_ids = copy(old.wall_ids); } } const assign = (key: 'openings' | 'walls' | 'wall_segments' | 'open_spans' | '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]; }; const oldOpenings = new Map((sp.openings || []).map((opening: any) => [opening.id, opening])); assign('openings', state.openings); assign('walls', state.walls); assign('wall_segments', state.wall_segments); assign('open_spans', state.open_spans); assign('partitions', state.partitions); assign('wall_columns', state.wall_columns); assign('decor', state.decor); assign('stairs', state.stairs); if (preserveIdentityHints) { for (const opening of sp.openings || []) { const old: any = oldOpenings.get(opening.id); if (!opening.host && old?.host?.kind === 'wall') opening.host = copy(old.host); } } for (const key of ['plan_x', 'plan_y', 'plan_scale', 'plan_scale_x', 'plan_scale_y', 'plan_angle'] as const) delete (sp as any)[key]; Object.assign(sp, copy(state.plan_transform || {})); if (state.roomReferences) { config.markers ||= []; restoreMarkerRoomReferences(config.markers, state.roomReferences); } return true; } public _canonicalWallChainHistoryState(state: SpaceGeometryState): SpaceGeometryState | null { if (this.host._activeWallChainId || !state.wallChainSeedIds?.length) return state; if (!this.host._serverCfg) return null; const candidate = JSON.parse(JSON.stringify(this.host._serverCfg)); if (!this._restoreGeometryStateInConfig(candidate, state)) return null; let committed: ServerConfig; try { committed = commitWallSegmentModel(candidate).config; } catch { return null; } const raw = committed.spaces?.find((space) => space.id === state.spaceId); const model = selectSpaceModelById(spaceModels(committed), state.spaceId); if (!raw || !model) return null; const result = finalizeWallChainSpace( raw, model, geometryOpenCuts(raw, model, GRID_PITCH, NORM_W), state.wallChainSeedIds, { pitch: GRID_STEP_N, cellCm: Number(raw.cell_cm) > 0 ? Number(raw.cell_cm) : 5, gridPitch: GRID_PITCH, coordScale: NORM_W, }, ); const index = committed.spaces.findIndex((space) => space.id === state.spaceId); committed.spaces[index] = result.space; // The finalizer can replace partitions with authoritative wall segments. // Cross that model boundary once more before recording the history state, // exactly as the production finish transaction does. Otherwise a Redo // could restore the pre-commit carrier representation and Optimize would // find work immediately afterwards. let finalized: ServerConfig; try { finalized = commitWallSegmentModel(committed).config; } catch { return null; } const normalized = this._geometrySnapshotFromConfig(finalized, state.spaceId); if (!normalized) return null; normalized.wallChainSeedIds = [...state.wallChainSeedIds]; if (state.roomReferences) normalized.roomReferences = JSON.parse(JSON.stringify(state.roomReferences)); return normalized; } public _restoreGeometryStateLocal(state: SpaceGeometryState): boolean { if (!this._restoreGeometryStateInConfig(this.host._serverCfg, state)) return false; this.host._cfgEpoch++; this.host._modelCache = null; this.host._wallUnionCache = null; this.host._physicalBodiesCache = null; this.host._frame = null; this.host.requestUpdate(); return true; } public _wallModelBlockerLabel(error: unknown): string { const reason = error instanceof WallSegmentModelError ? error.reason : 'invalid-room'; return this.host._t(`wall_model.reason.${reason}`); } public _hasLegacyZeroWallFields(config: any = this.host._serverCfg): boolean { return (config?.spaces || []).some((space) => ( (Array.isArray(space?.open_spans) && space.open_spans.length > 0) || (space?.rooms || []).some((room) => ( Array.isArray(room?.open_to) && room.open_to.length > 0 )) )); } public _showWallModelMigrationBlocked(error: unknown): void { const key = this._hasLegacyZeroWallFields() ? 'toast.zero_wall_migration_blocked' : 'toast.wall_model_migration_blocked'; this.host._showToast(this.host._t(key, { reason: this._wallModelBlockerLabel(error) })); } public _limitSegmentsOf(space: any): LimitSegment[] { const segments: LimitSegment[] = []; for (const segment of (space?.wall_segments || [])) { if (segment?.a && segment?.b) { segments.push({ id: String(segment.id || ''), a: segment.a, b: segment.b, cm: Number(segment.cm), }); } } for (const partition of (space?.partitions || [])) { if (partition?.a && partition?.b) { segments.push({ id: String(partition.id || ''), a: partition.a, b: partition.b, cm: Number(partition.cm), }); } } return segments; } public _junctionLimitViolations( config: unknown, spaceId: string, /** #330 §4.7: an already computed masonry pass of THIS config (e.g. the * resize preflight's artifact) — the union is never paid twice. */ sharedGeometry?: JunctionSharedGeometry | null, roomIds?: ReadonlySet, ): JunctionLimitViolation[] { const space = ((config as { spaces?: Array<{ id?: unknown }> } | null)?.spaces || []) .find((item) => item?.id === spaceId); return space ? junctionLimitViolations( config, spaceId, this._limitSegmentsOf(space), sharedGeometry, roomIds, ) : []; } public _junctionLimitLabel(violation: JunctionLimitViolation): string { const round = (value: number) => String(Math.round(value * 10) / 10); return this.host._t(`junction.limit_${violation.rule}` as I18nKey, { actual: round(violation.actual), limit: round(violation.limit), }); } public _junctionLimitsIntroduced( candidate: ServerConfig, previousConfig: ServerConfig, spaceId: string, candidateGeometry?: JunctionSharedGeometry | null, affectedRoomIds?: readonly string[], ): JunctionLimitViolation[] { // The baseline must be the previous document AS THE CANDIDATE SEES IT: a // legacy space carries no wall catalogue at all, so comparing it raw with // a migrated candidate reported every inherited short segment as new and // refused legitimate resizes of real plans. Both sides therefore cross // the same identity barrier first. Current-version documents are already // materialized, while legacy documents cross that barrier once. const affected = affectedRoomIds?.length ? new Set(affectedRoomIds) : undefined; let inherited: JunctionLimitViolation[] | undefined; const previousSpace = (previousConfig.spaces || []) .find((space) => space?.id === spaceId); let fingerprint = ''; try { fingerprint = spacePhysicalGeometryFingerprint(previousSpace); } catch { fingerprint = ''; } const cached = !affected && previousConfig && typeof previousConfig === 'object' ? this._junctionBaselineCache.get(previousConfig) : undefined; if (cached && fingerprint && cached.fingerprint === fingerprint && cached.spaceId === spaceId) { inherited = cached.violations; } if (!inherited) { try { const baseline = Number(previousConfig?.model_version || 0) >= WALL_SEGMENT_MODEL_VERSION ? previousConfig : commitWallSegmentModel(previousConfig).config; inherited = this.host._junctionLimitViolations( baseline, spaceId, affected ? null : undefined, affected, ); } catch { // An unmigratable baseline proves nothing about inheritance; never // refuse the write on that basis. return []; } if (!affected && previousConfig && typeof previousConfig === 'object' && fingerprint) { this._junctionBaselineCache.set(previousConfig, { spaceId, fingerprint, violations: inherited, }); } } let next: JunctionLimitViolation[] = []; let comparison = inherited; try { next = this.host._junctionLimitViolations(candidate, spaceId, candidateGeometry, affected); if (affected) comparison = inherited.filter( (item) => item.rule !== 'clearance' || affected.has(item.subject), ); } catch { // #331 §2.5: candidate judgment fails closed; only an unprovable // baseline above is allowed to fail open. return [{ rule: 'check_failed', subject: spaceId, actual: 0, limit: 0 }]; } return increasedViolations(next, comparison); } public _commitPhysicalGeometry( name: string, before: SpaceGeometryState | null, additionalAuthoredPoints: readonly (readonly number[])[] = [], recordHistory = true, ): boolean { if (!before || !this.host._serverCfg) return false; const liveCandidate = this.host._serverCfg; // The public guard already selects one space internally. Give it the // bounded carrier as well so a writer finish cannot accidentally make a // future preflight implementation inspect unrelated floors (#477/#461). const physicalCandidateFor = (config: ServerConfig): ServerConfig => { const space = config?.spaces?.find((item) => item.id === before.spaceId); return space ? { ...config, spaces: [space] } : config; }; const editedState = this._geometrySnapshotFromConfig(liveCandidate, before.spaceId); const liveSpace = liveCandidate.spaces.find((space) => space.id === before.spaceId); if (!liveSpace || spacePhysicalGeometryFingerprint(before) === spacePhysicalGeometryFingerprint(liveSpace)) return false; // Preserve #278's existing fail-closed guard. Identity materialisation may // repair/atomise a degraded legacy projection, but it must never be used to // make an otherwise rejected user edit look safe. let legacySafe = false; try { legacySafe = this.host._checkSpacePhysicalGeometry( physicalCandidateFor(liveCandidate), before.spaceId, ).ok; } catch { legacySafe = false; } if (!legacySafe) { this._clearGeometryGesture(); this._restoreGeometryStateLocal(before); this.host._showToast(this.host._t('toast.geometry_unsafe')); return false; } let committedCandidate: any; let historyBefore = before; try { // ADR 282 Stage 1: every structural writer crosses the same atomic // canonicalisation/identity barrier. The pure candidate is adopted only // after every space migrated successfully, so a blocker leaves the live // config byte-equivalent. if (Number(liveCandidate.model_version || 0) < 9) { // The first edit of a pre-v9 document must derive identity and legacy // zero-wall projection from the pre-edit carrier, not from its already // moved/split coordinates. // Materialise that baseline locally, then replay the edited legacy // projection over it so lineage and Undo share the same stable IDs. const baselineSource = JSON.parse(JSON.stringify(liveCandidate)); if (!this._restoreGeometryStateInConfig(baselineSource, before)) throw new WallSegmentModelError('invalid-room', before.spaceId); const baseline = commitWallSegmentModel(baselineSource).config; historyBefore = this._geometrySnapshotFromConfig(baseline, before.spaceId) || before; historyBefore.wallChainSeedIds = before.wallChainSeedIds; historyBefore.roomReferences = before.roomReferences; const editedWithIdentity = JSON.parse(JSON.stringify(baseline)); if (!editedState || !this._restoreGeometryStateInConfig( editedWithIdentity, editedState, true, )) throw new WallSegmentModelError('invalid-room', before.spaceId); const baselineSpace = baseline.spaces.find((space) => space.id === before.spaceId); const editedSpace = editedWithIdentity.spaces.find((space) => space.id === before.spaceId); // A fixed-topology pre-catalogue edit expresses thickness through the // legacy `walls[]` projection. Replay it onto the baseline catalogue // before v9 treats `cm` as authoritative. Geometry-changing writers // instead rely on lineage: projecting moved walls onto old baseline // coordinates would incorrectly turn the moved atom into zero. if (!Array.isArray(editedState.wall_segments) && JSON.stringify(before.rooms) === JSON.stringify(editedState.rooms)) { for (const segment of editedSpace?.wall_segments || []) { segment.cm = thicknessCmAt( editedState.walls, segment.a, segment.b, GRID_STEP_N, 1, ); } } const lineageHints = fixedTopologyWallLineageHints( baselineSpace, before.rooms, editedSpace, ); committedCandidate = commitWallSegmentModel(editedWithIdentity, { lineageHints, lineageSpaceId: before.spaceId, }).config; } else committedCandidate = commitWallSegmentModel(liveCandidate).config; } catch (error) { this._clearGeometryGesture(); this._restoreGeometryStateLocal(before); this._showWallModelMigrationBlocked(error); return false; } let safe = false; try { const afterSpace = committedCandidate.spaces.find((space) => space.id === before.spaceId); // A completed point of the active, grid-snapped chain is authored input. // Include the session carrier in the growth baseline so later conversion // from partitions to room masonry does not count it as newly derived. const authoredPoints = this.host._path.length >= 2 ? this.host._path.map((point) => [point[0] / NORM_W, point[1] / NORM_W]) : []; authoredPoints.push(...additionalAuthoredPoints.map((point) => [point[0], point[1]])); safe = wallModelOffGridValueCount(afterSpace) <= wallModelOffGridValueCount(historyBefore, authoredPoints) && this.host._checkSpacePhysicalGeometry( physicalCandidateFor(committedCandidate), before.spaceId, ).ok; } catch { safe = false; } if (!safe) { this._clearGeometryGesture(); this._restoreGeometryStateLocal(before); this.host._showToast(this.host._t('toast.geometry_unsafe')); return false; } // #329: the owner's junction limits refuse the WRITE. An existing plan's // inherited violations are never re-judged (spec §3), so the candidate is // compared against the pre-edit document. const beforeConfig = JSON.parse(JSON.stringify(liveCandidate)); this._restoreGeometryStateInConfig(beforeConfig, before); const introduced = this._junctionLimitsIntroduced( committedCandidate, beforeConfig, before.spaceId, ); if (introduced.length) { this._clearGeometryGesture(); this._restoreGeometryStateLocal(before); this.host._showToast(this._junctionLimitLabel(introduced[0])); return false; } adoptWallSegmentModelCandidateInPlace(liveCandidate, committedCandidate); if (recordHistory) this._recordGeometry(name, historyBefore); const afterSpace = liveCandidate.spaces.find((space) => space.id === before.spaceId); const pending = this.host._pendingPhysicalWrites.get(before.spaceId); this.host._pendingPhysicalWrites.set(before.spaceId, { before: pending?.before || historyBefore, fingerprint: spacePhysicalGeometryFingerprint(afterSpace), }); this._saveConfig(); return true; } public _clearGeometryGesture(): void { this._clearFurniturePreview(); this.host._path = []; this._clearPlanSnapHover(); this._clearOpeningPlacement(false); this.host._mergeSel = null; this.host._mergeDialog = null; this.host._splitSel = null; this.host._pendingSplit = null; this.host._wallFaceBatch = null; this.host._wallRepairDiagnostic = null; this.host._roomDeleteDialog = null; this.host._wallDialog = null; this.host._physicalDialog = null; this.host._physicalSel = null; this.host._physicalDrag = null; this.host._physicalRotate = null; this.host._activeWallChainId = null; this.host._activeWallChainPartitionIds = []; this.host._wallChainSegmentCms = []; this.host._wallChainRedo = []; this.host._closingWallCm = null; this.host._openingDialog = null; this._rszResetController(); this.host._decorDraft = null; this.host._decorMove = null; this.host._dtDrag = null; this.host._bdDrag = null; } public _stagePointerCancel(ev: PointerEvent): void { clearTimeout(this.host._kioskHoldTimer); if (this.host._swipeStart?.id === ev.pointerId) this.host._swipeStart = null; if (this.host._furnTouchPending?.pid === ev.pointerId) { this._clearFurniturePreview(); this.host._pointers.delete(ev.pointerId); this.host.requestUpdate(); return; } if (this.host._physicalDrag?.pid === ev.pointerId || this.host._physicalRotate?.pid === ev.pointerId) { this._cancelPhysicalGesture(); return; } if (this.host._decorDraft?.pid === ev.pointerId) { this.host._decorDraft = null; this.host.requestUpdate(); return; } if (this.host._decorMove?.pid === ev.pointerId || this.host._dtDrag?.pid === ev.pointerId || this.host._bdDrag?.pid === ev.pointerId) { this._cancelDecorGesture(); return; } if (this.host._tool === 'opening') { this.host._cursorPt = null; this._clearOpeningPlacement(false); } else if (this.host._tool === 'draw') { this._clearPlanSnapHover(); } this.host._pointers.delete(ev.pointerId); if (this.host._pointers.size < 2) this.host._pinchStart = null; if (this.host._pointers.size === 0) { this.host._panStart = 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; this.host.requestUpdate(); } } public _applyGeometryState( state: SpaceGeometryState, allowHistoryBoundaryRepair = false, ): boolean { const target = this._canonicalWallChainHistoryState(state); if (!target || !this.host._canCommitSpace(target.spaceId)) return false; const before = this._geometrySnapshot(target.spaceId); if (!before || !this._restoreGeometryStateLocal(target)) return false; const restoredCandidate = this.host._serverCfg; const physicalChanged = spacePhysicalGeometryFingerprint(before) !== spacePhysicalGeometryFingerprint(target); if (physicalChanged) { let safe = false; try { const check = restoredCandidate ? this.host._checkSpacePhysicalGeometry(restoredCandidate, target.spaceId) : null; safe = !!check?.ok || !!(allowHistoryBoundaryRepair && check?.reason === 'wall-degraded-extra'); } catch { safe = false; } if (!safe) { this._restoreGeometryStateLocal(before); this.host._showToast(this.host._t('toast.geometry_unsafe')); return false; } } let committedCandidate: any; try { committedCandidate = commitWallSegmentModel(restoredCandidate).config; } catch (error) { this._restoreGeometryStateLocal(before); this._showWallModelMigrationBlocked(error); return false; } if (physicalChanged) { let safe = false; try { const check = committedCandidate ? this.host._checkSpacePhysicalGeometry(committedCandidate, target.spaceId) : null; // A history snapshot can predate the write-time wall degradation that // canonicalized its command. Restore that one repairable baseline so // Undo remains byte-exact immediately; _writeConfig still degrades and // strictly validates the outbound candidate before it can leave the // card. Every other preflight failure stays fail-closed. safe = !!check?.ok || !!(allowHistoryBoundaryRepair && check?.reason === 'wall-degraded-extra'); } catch { safe = false; } if (!safe) { this._restoreGeometryStateLocal(before); this.host._showToast(this.host._t('toast.geometry_unsafe')); return false; } adoptWallSegmentModelCandidateInPlace(restoredCandidate, committedCandidate); const afterSpace = restoredCandidate?.spaces.find((space) => space.id === target.spaceId); const pending = this.host._pendingPhysicalWrites.get(target.spaceId); this.host._pendingPhysicalWrites.set(target.spaceId, { before: pending?.before || before, fingerprint: spacePhysicalGeometryFingerprint(afterSpace), }); } else adoptWallSegmentModelCandidateInPlace(restoredCandidate, committedCandidate); this._clearGeometryGesture(); if (this.host._space !== target.spaceId) { this.host._commitSpace(target.spaceId); this.host._saveNav(); this.host._restoreZoom(); } this.host._modelCache = null; this.host._frame = null; this.host._regSignature = ''; this.host._maybeRebuildDevices(); this._saveConfig(); this.host.requestUpdate(); return true; } public _undoGeometry = (): void => { if (this.host._physicalDrag || this.host._physicalRotate) { this._cancelPhysicalGesture(); return; } if (this.host._decorDraft) { this.host._decorDraft = null; this.host.requestUpdate(); return; } if (this.host._decorMove || this.host._dtDrag || this.host._bdDrag) { this._cancelDecorGesture(); return; } if (this.host._resize.dragging) { this._rszCancelDrag(); return; } const command = this.host._geometryHistory.undo(); if (!command) return; if (!this._applyGeometryState(command.before, true)) { this.host._geometryHistory.clear(); return; } this.host._showToast(this.host._t('history.undone', { name: command.name })); }; public _redoGeometry = (): void => { // Redo and Undo share the same transaction boundary. The first invocation // cancels an in-progress transform; only the next one navigates history. if (this.host._physicalDrag || this.host._physicalRotate) { this._cancelPhysicalGesture(); return; } if (this.host._decorDraft) { this.host._decorDraft = null; this.host.requestUpdate(); return; } if (this.host._decorMove || this.host._dtDrag || this.host._bdDrag) { this._cancelDecorGesture(); return; } if (this.host._resize.dragging) { this._rszCancelDrag(); return; } if (this.host._activeWallChainId && this.host._wallChainRedo.length) { const chainId = this.host._activeWallChainId; const path = this.host._path.map((point) => [...point]); const ids = [...this.host._activeWallChainPartitionIds]; const cms = [...this.host._wallChainSegmentCms]; const redo = [...this.host._wallChainRedo]; const terminal = redo[redo.length - 1]; const command = this.host._geometryHistory.redo(); if (!command) return; if (!this._applyGeometryState(command.after, true)) { this.host._geometryHistory.clear(); return; } this.host._activeWallChainId = chainId; this.host._path = [...path, [...terminal.point]]; this.host._activeWallChainPartitionIds = [...ids, terminal.id]; this.host._wallChainSegmentCms = [...cms, terminal.cm]; this.host._wallChainRedo = redo.slice(0, -1); this._clearPlanSnapHover(); this.host._showToast(this.host._t('history.redone', { name: command.name })); return; } const command = this.host._geometryHistory.redo(); if (!command) return; if (!this._applyGeometryState(command.after, true)) { this.host._geometryHistory.clear(); return; } this.host._showToast(this.host._t('history.redone', { name: command.name })); }; public _roomAt(p: number[]): RoomCfg | undefined { return this.host._spaceModel()?.rooms.find((r) => { const poly = roomPoly(r); return !!poly && pointStrictlyInside(p, poly); }); } public _overlapRoom(verts: number[][]): RoomCfg | undefined { return this.host._spaceModel()?.rooms.find((r) => { const poly = roomPoly(r); return !!poly && roomsOverlap(verts, poly); }); } public _pointInRoom(p: number[], r: RoomCfg): boolean { if (r.poly) return pointInPolygon(p, r.poly); return ( r.x != null && p[0] >= r.x! && p[0] <= r.x! + r.w! && p[1] >= r.y! && p[1] <= r.y! + r.h! ); } public _contourSelfIntersects(poly: number[][]): boolean { if (!polyIsSimple(poly)) return true; // transverse crossings const n = poly.length; const eps = 0.001; for (let i = 0; i < n; i++) { const a = poly[i], b = poly[(i + 1) % n]; for (let j = i + 1; j < n; j++) { // Neighbours deliberately share one endpoint, including first ↔ last. if (j === i + 1 || (i === 0 && j === n - 1)) continue; const c = poly[j], d = poly[(j + 1) % n]; const cd = [c[0], c[1], d[0], d[1]]; const ab = [a[0], a[1], b[0], b[1]]; // This also catches a closing edge passing through an older vertex and // collinear overlap, which a proper-crossing test intentionally omits. if (distToSegment(a, cd) <= eps || distToSegment(b, cd) <= eps || distToSegment(c, ab) <= eps || distToSegment(d, ab) <= eps) return true; } } return false; } public _canAppendWallPoint(): boolean { if (this.host._drawWallCm == null) { this._showPhysicalRange(100); return false; } if (this.host._path.length >= MAX_WALL_CHAIN_POINTS) { this.host._showToast(this.host._t('toast.physical_limit')); return false; } const spCfg = this.host._curSpaceCfg as any; if ((spCfg?.partitions || []).length >= MAX_PARTITIONS) { this.host._showToast(this.host._t('toast.physical_limit')); return false; } return true; } public _markupClick(ev: MouseEvent): void { if (this.host._vacFit) return; // the fit overlay owns all pointer input if (!this.host._markup) return; const space = this.host._spaceModel(); if (!space) { this._clearGeometryGesture(); return; } // a pan or pinch just happened — the synthesized click is not a draw if (this.host._suppressClick) return; // Room cards swallow markup clicks: dragging, resizing or just clicking a // card must not feed the active tool (draw point, delete room, merge/split // pick, opening placement). The drag itself already stops pointer events, // but the synthesized `click` afterwards still bubbles to the stage. if (this.host._drag || this.host._rlResize) return; const path = (ev.composedPath?.() || []) as any[]; if (path.some((n) => n?.classList?.contains?.('roomlabel') || n?.classList?.contains?.('rlhandle'))) return; 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.stairs.clearSelection(); return; } if (this.host._tool === 'resize') { // a click picks the room for the scale frame; handle drags never get here if (this.host._resize.dragging || path.some((n) => n?.classList?.contains?.('rszhandle'))) return; const room = [...space.rooms].reverse().find((r) => this._pointInRoom(raw, r)); this.host._resize.selectRoom(room?.id || null); this.host.requestUpdate(); return; } if (this.host._tool === 'delroom') { this._deleteRoomClick(raw); return; } if (this.host._tool === 'opening') { this._openingClick(raw); return; } if (this.host._tool === 'merge') { this._mergeClick(raw); return; } if (this.host._tool === 'wallthick') { this._wallThickClick(raw); return; } if (this.host._tool === 'split') { this._splitClick(raw); return; } if (this.host._tool === 'column') { 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); if (resolved.ambiguous) { this.host._planSnapHover = { contextKey: resolved.contextKey, candidate: null, conflicts: resolved.conflicts, }; this._syncPlanSnapActiveMarker(null); this._syncPlanSnapConflictMarkers(resolved.conflicts); this.host._showToast(this.host._t('toast.plan_snap_ambiguous')); return; } let pt = resolved.point; if (ev.ctrlKey || ev.metaKey) { // The closure shortcut owns the gesture even before the chain has the // two existing edges required to form a room. Falling through here // would turn a refused Ctrl/Cmd+click into an ordinary drawing click. ev.preventDefault(); if (this.host._path.length < 3) return; // Preserve the established shortcut, but commit its closing wall through // the same graph/draft path as an ordinary click on the first node. pt = snapNearAxisEndpoint( this.host._path[this.host._path.length - 1], this.host._path[0], ); } // Island rooms (v1.34.0): drawing INSIDE an existing room is legal — the // contour may become a nested room (a column, an inner room). Partial // overlaps are still rejected, but only at closing time, when the whole // outline is known (roomsOverlap treats full nesting as legal). if (!this.host._path.length) { // A free idle click can name a room inside masonry that was completed in // an earlier session. Shift and architectural snap hits retain the Walls // gesture, so this offer never steals an intentional new chain. if (!ev.shiftKey && !resolved.candidate && this._offerExistingWallFace(raw)) return; this.host._activeWallChainId = `chain-${Date.now().toString(36)}`; this.host._activeWallChainPartitionIds = []; this.host._wallChainSegmentCms = []; this.host._wallChainRedo = []; this.host._path = [pt]; return; } const last = this.host._path[this.host._path.length - 1]; if (this._samePt(pt, last)) return; // repeated click on the same point if (!this._canAppendWallPoint()) return; const cm = this.host._drawWallCm; if (cm == null) { this._showPhysicalRange(100); return; } const sp = this.host._curSpaceCfg as any; if (!sp) return; const beforePath = this.host._path.map((point) => [...point]); const beforeChainId = this.host._activeWallChainId; const beforeIds = [...this.host._activeWallChainPartitionIds]; const beforeCms = [...this.host._wallChainSegmentCms]; const beforeGraphSources = this._wallGraphSources([]); const before = this._geometrySnapshot(); if (before) before.wallChainSeedIds = [...beforeIds]; const id = `partition-${crypto.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}`; sp.partitions ||= []; sp.partitions.push({ id, a: [last[0] / NORM_W, last[1] / NORM_W], b: [pt[0] / NORM_W, pt[1] / NORM_W], cm, }); this.host._path = [...this.host._path, pt]; this.host._wallChainSegmentCms = [...this.host._wallChainSegmentCms, cm]; this.host._activeWallChainPartitionIds = [...beforeIds, id]; this.host._wallChainRedo = []; if (!commitWallChainSegmentGeometry(this, this.host._t('history.wall_segment'), before)) { this.host._activeWallChainId = beforeChainId; this.host._activeWallChainPartitionIds = beforeIds; this.host._path = beforePath; this.host._wallChainSegmentCms = beforeCms; return; } this._offerWallFaces(beforePath, beforePath.length - 1, beforeGraphSources); } public _activeWallSourceKey(index: number): string { const id = this.host._activeWallChainPartitionIds[index]; const segment = id ? this._planStructuralGeometrySnapshot().value.segments.find((item) => ( item.sourceKind === 'partition' && item.sourceId === id )) : null; return segment ? `static:${segment.key}` : `active:${this.host._activeWallChainId || 'session'}:${index}`; } public _wallGraphSources(_path: readonly (readonly number[])[]): WallGraphSourceSegment[] { const staticGeometry = this._planStructuralGeometrySnapshot().value; return staticGeometry.segments.map((segment) => ({ a: segment.a, b: segment.b, key: `static:${segment.key}`, })); } public _wallFaceGraph( sources: readonly WallGraphSourceSegment[], epsilon: number, ): WallFaceGraph { // Exact JSON identity avoids stale topology. Source keys include real // open-span cuts/provenance; presentation opening cuts are intentionally // absent from the structural graph (#185). const key = `${this.host._space}|${epsilon}|${JSON.stringify(sources)}`; const cachedIndex = this.host._wallFaceGraphCache.findIndex((entry) => entry.key === key); if (cachedIndex >= 0) { const [cached] = this.host._wallFaceGraphCache.splice(cachedIndex, 1); this.host._wallFaceGraphCache.push(cached); return cached.value; } const value = buildWallFaceGraph(sources, epsilon); this.host._wallFaceGraphCache.push({ key, value }); if (this.host._wallFaceGraphCache.length > 4) this.host._wallFaceGraphCache.shift(); return value; } public _offerWallFaces( beforePath: number[][], addedSegmentIndex = this.host._path.length - 2, beforeGraphSources?: WallGraphSourceSegment[], ): void { if (this.host._path.length < 2 || this.host._wallFaceBatch || this.host._roomDialog) return; const space = this.host._spaceModel(); if (!space) return; const addedSourceKey = this._activeWallSourceKey(addedSegmentIndex); const epsilon = this.host._gridPitch * 0.0002; let faces: WallGraphFace[]; let after: WallGraphSourceSegment[]; try { const before = beforeGraphSources || this._wallGraphSources(beforePath); after = this._wallGraphSources(this.host._path); faces = findNewWallFacesInGraphs( this._wallFaceGraph(before, epsilon), this._wallFaceGraph(after, epsilon), addedSourceKey, ); } catch { // Topology is advisory. The just-persisted draft remains the source of // truth and drawing can continue when malformed legacy geometry exists. return; } if (!faces.length) { const repair = planWallFaceRepair(after, { requiredSourceKey: addedSourceKey, maxDistance: wallCmToUnits(2, this.host._cellCm, this.host._gridPitch), gridStep: this.host._gridPitch, epsilon, }); if (repair.kind === 'ambiguous') { this.host._wallRepairDiagnostic = repair.proposals[0] || null; this.host._showToast(this.host._t( this.host._drawWallCm === 0 ? 'toast.zero_wall_ambiguous' : 'toast.wall_repair_ambiguous', )); return; } if (repair.kind === 'repair' && !this._overlapRoom(repair.face.ring)) { this._beginWallFaceBatch([{ ...repair.face, repair: repair.proposal }]); } return; } // A clean wall-to-wall cut is one product decision about the smaller part, // even though the planar delta contains both resulting faces. for (const room of space.rooms) { const poly = roomPoly(room); if (!room.id || !poly) continue; const parts = splitRoomPath(poly, this.host._path, this.host._gridPitch * 0.02); if (!parts) continue; const [p1, p2] = parts; const main = polygonArea(p1) >= polygonArea(p2) ? p1 : p2; const fresh = main === p1 ? p2 : p1; const matched = [...faces].sort((left, right) => Math.abs(left.area - polygonArea(fresh)) - Math.abs(right.area - polygonArea(fresh)) || left.key.localeCompare(right.key))[0]; if (!matched) return; this._beginWallFaceBatch([{ ...matched, ring: fresh.map((point) => [point[0], point[1]] as [number, number]), split: { roomId: room.id, mainPoly: main, newPoly: fresh }, consumeAllActive: true, }]); return; } const eligible = faces.filter((face) => { if (face.ring.length < 3 || this._contourSelfIntersects(face.ring)) return false; // roomsOverlap rejects exact duplicates and partial overlap, while full // nesting in either direction deliberately remains eligible. return !this._overlapRoom(face.ring); }); if (eligible.length) this._beginWallFaceBatch(eligible); } public _beginWallFaceBatch(candidates: WallFaceCandidate[]): void { this.host._wallRepairDiagnostic = null; this.host._wallFaceBatch = { candidates: [...candidates].sort((left, right) => left.area - right.area || left.key.localeCompare(right.key)), index: 0, decisions: [], activePath: this.host._path.map((point) => [...point]), activeCms: [...this.host._wallChainSegmentCms], activePartitionIds: [...this.host._activeWallChainPartitionIds], }; this._clearPlanSnapHover(); this.host._nameSel = ''; this.host._areaSel = ''; this._resetRoomDialogFields(); this.host._roomDialog = true; } public _offerExistingWallFace(raw: number[]): boolean { if (this.host._path.length || this.host._wallFaceBatch || this.host._roomDialog) return false; const epsilon = this.host._gridPitch * 0.0002; try { const graph = this._wallFaceGraph(this._wallGraphSources([]), epsilon); let face = findWallFaceAtPoint(graph, raw, epsilon); let repair: WallFaceRepairProposal | undefined; if (!face) { const result = planWallFaceRepair(this._wallGraphSources([]), { point: raw, maxDistance: wallCmToUnits(2, this.host._cellCm, this.host._gridPitch), gridStep: this.host._gridPitch, epsilon, }); if (result.kind === 'ambiguous') { this.host._wallRepairDiagnostic = result.proposals[0] || null; this.host._showToast(this.host._t( this.host._drawWallCm === 0 ? 'toast.zero_wall_ambiguous' : 'toast.wall_repair_ambiguous', )); return true; } if (result.kind === 'repair') { face = result.face; repair = result.proposal; } else if (result.kind === 'none') { const diagnostic = planWallFaceRepair(this._wallGraphSources([]), { point: raw, maxDistance: this._cssPxToRender(12), gridStep: this.host._gridPitch, epsilon, }); if (diagnostic.kind === 'repair') { this.host._wallRepairDiagnostic = diagnostic.proposal; this.host._showToast(this.host._t('toast.wall_repair_too_large')); return true; } } } if (!face || face.ring.length < 3 || this._contourSelfIntersects(face.ring) || this._overlapRoom(face.ring)) return false; this._beginWallFaceBatch([{ ...face, existing: !repair, repair }]); return true; } catch { return false; } } public _columnClick(raw: number[]): void { const center = this._snap(raw); const cm = this.host._drawWallCm; if (cm == null) { this._showPhysicalRange(COLUMN_MAX_CM, 1); return; } if (!this.host._curSpaceCfg || this._limitReached('column')) return; const model = this.host._spaceModel(); if (!model) return; const candidate: WallColumnCfg = { id: 'column-' + Date.now().toString(36), shape: 'square', center, cm: clampColumnCm(cm), angle: 0, }; const duplicate = (model.wall_columns || []).find((c) => sameColumnPlacement(c, candidate, this.host._gridPitch * 0.02)); if (duplicate) { clearTimeout(this.host._duplicateColumnTimer); this.host._duplicateColumnId = duplicate.id; this.host._duplicateColumnTimer = window.setTimeout(() => { this.host._duplicateColumnId = null; }, 900); this.host._showToast(this.host._t('toast.column_duplicate')); return; } const before = this._geometrySnapshot(); const sp = this.host._curSpaceCfg as any; sp.wall_columns ||= []; sp.wall_columns.push({ ...candidate, center: [center[0] / NORM_W, center[1] / NORM_W] }); this._commitPhysicalGeometry(this.host._t('history.column_add'), before); } public _openPhysicalDialog( kind: 'partition' | 'column', id: string, ): void { const model = this.host._spaceModel(); if (!model) return; if (kind === 'partition') { const p = model.partitions.find((x) => x.id === id); if (p) this.host._physicalDialog = { kind, id, cm: cmToField(p.cm, this.host._imperial), length: this.host._fmtLen(p.a, p.b), }; } else if (kind === 'column') { const c = model.wall_columns.find((x) => x.id === id); if (c) this.host._physicalDialog = { kind, id, cm: cmToField(c.cm, this.host._imperial), shape: c.shape, angle: this.host._angleField(c.shape === 'square' ? canonicalColumnAngle(c.angle) : 0), }; } } public _savePhysicalDialog = (): void => { const d = this.host._physicalDialog; const sp = this.host._curSpaceCfg as any; const model = this.host._spaceModel(); if (!d || !sp || !model) return; const raw = strictNumber(d.cm); if (raw == null) { this._showPhysicalRange( d.kind === 'column' ? COLUMN_MAX_CM : 100, d.kind === 'column' ? 1 : 0, ); return; } const cmRaw = this.host._imperial ? raw * 2.54 : raw; const max = d.kind === 'column' ? COLUMN_MAX_CM : 100; const min = d.kind === 'column' ? 1 : 0; if (!Number.isFinite(cmRaw) || cmRaw < min || cmRaw > max) { this._showPhysicalRange(max, min); return; } if (d.kind === 'column') { const current = model.wall_columns.find((x) => x.id === d.id); if (!current) return; const rawAngle = strictNumber(d.angle || '0'); if (d.shape !== 'circle' && (rawAngle == null || rawAngle < 0 || rawAngle >= 90)) { this.host._showToast(this.host._t('toast.physical_angle')); return; } const candidate: WallColumnCfg = d.shape === 'circle' ? { id: d.id, shape: 'circle', center: current.center, cm: cmRaw } : { id: d.id, shape: 'square', center: current.center, cm: cmRaw, angle: rawAngle! }; if (model.wall_columns.some((c) => c.id !== d.id && sameColumnPlacement(c, candidate, this.host._gridPitch * 0.02))) { this.host._showToast(this.host._t('toast.column_duplicate')); return; } } const before = this._geometrySnapshot(); if (d.kind === 'partition') { const p = (sp.partitions || []).find((x: any) => x.id === d.id); if (p && cmRaw === 0 && zeroWallHasOpening(sp.openings, { kind: 'partition', id: d.id, })) { this.host._showToast(this.host._t('toast.zero_wall_opening_conflict')); return; } if (p) p.cm = cmRaw; } else if (d.kind === 'column') { const c = (sp.wall_columns || []).find((x: any) => x.id === d.id); if (c) { c.cm = clampColumnCm(cmRaw); c.shape = d.shape === 'circle' ? 'circle' : 'square'; if (c.shape === 'square') c.angle = strictNumber(d.angle || '0')!; else delete c.angle; } } const committed = this._commitPhysicalGeometry(this.host._t('history.physical_edit'), before); this.host._physicalDialog = null; if (!committed) this.host.requestUpdate(); }; public _deletePhysicalSelection = async (): Promise => { const sel = this.host._physicalSel; const sp = this.host._curSpaceCfg as any; if (!sel || !sp) return; const before = this._geometrySnapshot(); if (sel.kind === 'partition') { const hosted = (sp.openings || []) .filter((opening: OpeningCfg) => opening.host?.kind === 'partition' && opening.host.id === sel.id) .sort((a: OpeningCfg, b: OpeningCfg) => (a.host?.t || 0) - (b.host?.t || 0)); if (hosted.length) { this.host._partitionDeleteDialog = { id: sel.id, openings: hosted.map((opening: OpeningCfg) => JSON.parse(JSON.stringify(opening))), }; return; } } const key = sel.kind === 'partition' ? 'partitions' : 'wall_columns'; sp[key] = (sp[key] || []).filter((x: any) => x.id !== sel.id); if (!sp[key].length) delete sp[key]; if (this.host._activeWallChainPartitionIds.includes(sel.id)) this._cancelPath(); this.host._physicalSel = null; this.host._physicalDialog = null; this._commitPhysicalGeometry(this.host._t('history.physical_delete'), before); }; public _confirmPartitionDelete = (): void => { const dialog = this.host._partitionDeleteDialog; const sp = this.host._curSpaceCfg as any; if (!dialog || !sp) return; const before = this._geometrySnapshot(); sp.partitions = (sp.partitions || []).filter((partition: PartitionCfg) => partition.id !== dialog.id); if (!sp.partitions.length) delete sp.partitions; sp.openings = (sp.openings || []).filter((opening: OpeningCfg) => opening.host?.kind !== 'partition' || opening.host.id !== dialog.id); if (!sp.openings.length) delete sp.openings; this.host._partitionDeleteDialog = null; this.host._physicalSel = null; this.host._physicalDialog = null; this._commitPhysicalGeometry(this.host._t('history.physical_delete'), before); }; public _physicalDown(ev: PointerEvent, kind: 'partition' | 'column', id: string): void { const model = this.host._spaceModel(); if (!model) return; ev.stopPropagation(); capturePointer(ev); const point = this._svgPoint(ev); const candidates: Array<{ kind: 'partition' | 'column'; id: string }> = []; for (const c of [...model.wall_columns].reverse()) { if (pointInPhysicalBody(point, columnBody(c, this.host._cellCm, this.host._gridPitch))) candidates.push({ kind: 'column', id: c.id }); } for (const p of [...model.partitions].reverse()) { const body = partitionBody(p.a, p.b, p.cm, this.host._cellCm, this.host._gridPitch); if (body && pointInPhysicalBody(point, body)) candidates.push({ kind: 'partition', id: p.id }); } if (!candidates.some((x) => x.kind === kind && x.id === id)) candidates.unshift({ kind, id }); const signature = candidates.map((x) => `${x.kind}:${x.id}`).sort().join('|'); const now = performance.now(); const cycle = this.host._physicalPickCycle; const repeat = candidates.length > 1 && cycle?.signature === signature && now - cycle.at > 380 && now - cycle.at <= 1200 && Math.hypot(ev.clientX - cycle.x, ev.clientY - cycle.y) <= 10; const index = repeat ? (cycle.index + 1) % candidates.length : Math.max(0, candidates.findIndex((x) => x.kind === kind && x.id === id)); this.host._physicalPickCycle = { signature, index, x: ev.clientX, y: ev.clientY, at: now }; kind = candidates[index].kind; id = candidates[index].id; const base = kind === 'partition' ? model.partitions.find((x) => x.id === id) : model.wall_columns.find((x) => x.id === id); if (!base) return; this.host._physicalSel = { kind, id }; this.host._physicalDrag = { pid: ev.pointerId, kind, id, start: this._svgPoint(ev), startClient: [ev.clientX, ev.clientY], before: this._geometrySnapshot(), moved: false, base: JSON.parse(JSON.stringify(base)), delta: [0, 0], }; } public _clampPhysicalDelta( kind: 'partition' | 'column', base: PartitionCfg | WallColumnCfg, delta: number[], ): number[] { const points = kind === 'partition' ? [(base as PartitionCfg).a, (base as PartitionCfg).b] : [(base as WallColumnCfg).center]; const xs = points.map((p) => p[0]), ys = points.map((p) => p[1]); return [ Math.max(-CANVAS_LIMIT * NORM_W - Math.min(...xs), Math.min(CANVAS_LIMIT * NORM_W - Math.max(...xs), delta[0])), Math.max(-CANVAS_LIMIT * NORM_W - Math.min(...ys), Math.min(CANVAS_LIMIT * NORM_W - Math.max(...ys), delta[1])), ]; } public _physicalMove(ev: PointerEvent): void { ev.stopPropagation(); queueHouseplanPointerMove(this.host, 'physical', () => this._physicalMoveNow(ev)); } private _physicalMoveNow(ev: PointerEvent): void { const drag = this.host._physicalDrag; if (!drag || drag.pid !== ev.pointerId) return; ev.stopPropagation(); const raw = this._svgPoint(ev); const anchor = drag.kind === 'partition' ? (drag.base as PartitionCfg).a : (drag.base as WallColumnCfg).center; const target = this._snap([ anchor[0] + raw[0] - drag.start[0], anchor[1] + raw[1] - drag.start[1], ]); const delta = this._clampPhysicalDelta( drag.kind, drag.base, [target[0] - anchor[0], target[1] - anchor[1]], ); this.host._physicalDrag = { ...drag, delta, moved: drag.moved || Math.hypot( ev.clientX - drag.startClient[0], ev.clientY - drag.startClient[1], ) >= 5, }; } public _physicalUp(ev: PointerEvent): void { flushHouseplanPointerMove(this.host, 'physical'); const drag = this.host._physicalDrag; if (!drag || drag.pid !== ev.pointerId) return; ev.stopPropagation(); this.host._physicalDrag = null; if (!drag.moved) { this._registerPhysicalTap(drag.kind, drag.id); return; } const model = this.host._spaceModel(); if (!this.host._curSpaceCfg || !model) return; const sp = this.host._curSpaceCfg as any; if (drag.kind === 'partition') { const p = (sp.partitions || []).find((x: any) => x.id === drag.id); const base = drag.base as PartitionCfg; if (p) { const moved: PartitionCfg = { ...base, a: [base.a[0] + drag.delta[0], base.a[1] + drag.delta[1]], b: [base.b[0] + drag.delta[0], base.b[1] + drag.delta[1]], }; p.a = [moved.a[0] / NORM_W, moved.a[1] / NORM_W]; p.b = [moved.b[0] / NORM_W, moved.b[1] / NORM_W]; for (const opening of sp.openings || []) { if (opening.host?.kind !== 'partition' || opening.host.id !== drag.id) continue; const resolved = resolvePartitionOpeningCompat( opening, [moved], NORM_W, this.host._cellCm, this.host._gridPitch, ).resolved; if (resolved) Object.assign( opening, materializePartitionOpening(opening, resolved, NORM_W), ); } } } else { const c = (sp.wall_columns || []).find((x: any) => x.id === drag.id); const base = drag.base as WallColumnCfg; const candidate: WallColumnCfg = { ...base, center: [base.center[0] + drag.delta[0], base.center[1] + drag.delta[1]], } as WallColumnCfg; if (model.wall_columns.some((x) => x.id !== drag.id && sameColumnPlacement(x, candidate, this.host._gridPitch * 0.02))) { this.host._showToast(this.host._t('toast.column_duplicate')); return; } if (c) c.center = [ (base.center[0] + drag.delta[0]) / NORM_W, (base.center[1] + drag.delta[1]) / NORM_W, ]; } this._commitPhysicalGeometry(this.host._t('history.physical_move'), drag.before); } public _registerPhysicalTap( kind: 'partition' | 'column', id: string, ): void { const now = performance.now(); const twice = this.host._physicalLastTap?.kind === kind && this.host._physicalLastTap.id === id && now - this.host._physicalLastTap.at <= 360; this.host._physicalLastTap = { kind, id, at: now }; if (twice) { this.host._physicalLastTap = null; this._openPhysicalDialog(kind, id); } } public _cancelPhysicalGesture(): void { cancelHouseplanPointerMove(this.host, 'physical'); cancelHouseplanPointerMove(this.host, 'physical-rotate'); this.host._physicalDrag = null; this.host._physicalRotate = null; this.host.requestUpdate(); } public _physicalRotateDown(ev: PointerEvent, c: WallColumnCfg): void { if (c.shape !== 'square' || !this.host._spaceModel()) return; ev.preventDefault(); ev.stopPropagation(); capturePointer(ev); const p = this._svgPoint(ev); this.host._physicalSel = { kind: 'column', id: c.id }; this.host._physicalRotate = { pid: ev.pointerId, id: c.id, center: [...c.center], startAngle: Math.atan2(p[1] - c.center[1], p[0] - c.center[0]) * 180 / Math.PI, baseAngle: canonicalColumnAngle(c.angle), angle: canonicalColumnAngle(c.angle), before: this._geometrySnapshot(), moved: false, }; } public _physicalRotateMove(ev: PointerEvent): void { ev.preventDefault(); ev.stopPropagation(); queueHouseplanPointerMove(this.host, 'physical-rotate', () => this._physicalRotateMoveNow(ev)); } private _physicalRotateMoveNow(ev: PointerEvent): void { const drag = this.host._physicalRotate; if (!drag || drag.pid !== ev.pointerId) return; ev.preventDefault(); ev.stopPropagation(); const p = this._svgPoint(ev); const pointerAngle = Math.atan2(p[1] - drag.center[1], p[0] - drag.center[0]) * 180 / Math.PI; const raw = drag.baseAngle + pointerAngle - drag.startAngle; const angle = canonicalColumnAngle(ev.shiftKey ? raw : Math.round(raw / 5) * 5); this.host._physicalRotate = { ...drag, angle, moved: drag.moved || Math.abs(raw - drag.baseAngle) >= 0.5 }; } public _physicalRotateUp(ev: PointerEvent): void { flushHouseplanPointerMove(this.host, 'physical-rotate'); const drag = this.host._physicalRotate; if (!drag || drag.pid !== ev.pointerId) return; ev.preventDefault(); ev.stopPropagation(); this.host._physicalRotate = null; const model = this.host._spaceModel(); if (!drag.moved || !this.host._curSpaceCfg || !model) return; const current = model.wall_columns.find((c) => c.id === drag.id); if (!current || current.shape !== 'square') return; const candidate: WallColumnCfg = { ...current, angle: drag.angle }; if (model.wall_columns.some((c) => c.id !== drag.id && sameColumnPlacement(c, candidate, this.host._gridPitch * 0.02))) { this.host._showToast(this.host._t('toast.column_duplicate')); return; } const stored = (this.host._curSpaceCfg as any).wall_columns?.find((c: any) => c.id === drag.id); if (!stored) return; stored.angle = drag.angle; this._commitPhysicalGeometry(this.host._t('history.physical_edit'), drag.before); } public _rszRooms(): { id: string; poly: number[][]; wall_ids?: string[] }[] { const out: { id: string; poly: number[][]; wall_ids?: string[] }[] = []; const space = this.host._spaceModel(); if (!space) return out; for (const r of space.rooms) { const poly = r.id ? roomPoly(r) : null; if (poly) out.push({ id: r.id!, poly, wall_ids: Array.isArray(r.wall_ids) ? [...r.wall_ids] : undefined, }); } return coalesceResizeRooms(out, Math.max(1e-12, this.host._gridPitch * 1e-12)); } public _rszOpenings(): SafeOpeningIn[] { return this.host._openingsR.map((o) => ({ id: o.id, x: o.rx, y: o.ry, length: o.rlen, hosted: !!o.host, angle: o.angle, type: o.type, })); } public _rszObstacles(): SafeResizeObstacle[] { const space = this.host._spaceModel(); if (!space) return []; const obstacles: SafeResizeObstacle[] = []; for (const partition of space.partitions || []) { obstacles.push({ kind: 'segment', a: [...partition.a], b: [...partition.b], half: wallCmToUnits(partition.cm, this.host._cellCm, this.host._gridPitch) / 2, }); } for (const column of space.wall_columns || []) { const half = wallCmToUnits(column.cm, this.host._cellCm, this.host._gridPitch) / 2; obstacles.push({ kind: 'circle', center: [...column.center], radius: column.shape === 'square' ? half * Math.SQRT2 : half, }); } return obstacles; } public _rszOptsFor(a: number[], b: number[]): SafeResizeOptions { const cm = thicknessCmAt(this.host._spaceWalls, a, b, this.host._wallKeyPitch, NORM_W); const exact = this.host._spaceWalls.filter((wall) => Array.isArray(wall.a) && Array.isArray(wall.b) && wall.a.length >= 2 && wall.b.length >= 2).map((wall) => ({ wall, a: [wall.a![0] * NORM_W, wall.a![1] * this.host._spaceH], b: [wall.b![0] * NORM_W, wall.b![1] * this.host._spaceH], })); const axis = Math.abs(a[0] - b[0]) <= this.host._gridPitch * 0.05 ? 'v' : Math.abs(a[1] - b[1]) <= this.host._gridPitch * 0.05 ? 'h' : null; const overlaps = exact.filter((entry) => { if (!axis) return false; if (axis === 'h') { if (Math.abs(entry.a[1] - a[1]) > this.host._gridPitch * 0.05 || Math.abs(entry.b[1] - a[1]) > this.host._gridPitch * 0.05) return false; return Math.min(Math.max(a[0], b[0]), Math.max(entry.a[0], entry.b[0])) - Math.max(Math.min(a[0], b[0]), Math.min(entry.a[0], entry.b[0])) > this.host._gridPitch * 0.05; } if (Math.abs(entry.a[0] - a[0]) > this.host._gridPitch * 0.05 || Math.abs(entry.b[0] - a[0]) > this.host._gridPitch * 0.05) return false; return Math.min(Math.max(a[1], b[1]), Math.max(entry.a[1], entry.b[1])) - Math.max(Math.min(a[1], b[1]), Math.min(entry.a[1], entry.b[1])) > this.host._gridPitch * 0.05; }); const exactCms = new Set(overlaps.map((entry) => Number(entry.wall.cm)).filter((value) => value > 0)); return { minDim: this.host._cmToUnits(MIN_ROOM_CM), eps: this.host._gridPitch * 0.05, step: this.host._gridPitch, movingHalf: cm > 0 ? wallCmToUnits(cm, this.host._cellCm, this.host._gridPitch) / 2 : 0, obstacles: this._rszObstacles(), thicknessConflict: exactCms.size > 1, }; } public _rszResolution( roomId: string, edge: number, renderSnapshot?: string, ): SafeResizeResolution { const snap = this.host._resize.snapshotIdentity || renderSnapshot || this._rszSnapshot(); const key = `${this.host._space}|${this.host._cellCm}|${this.host._gridPitch}|${snap}`; const cacheKey = `${roomId}:${edge}`; return this.host._resize.resolve(key, cacheKey, () => { const rooms = [...(this.host._resize.rooms || this._rszRooms())]; const room = rooms.find((candidate) => candidate.id === roomId); const a = room?.poly?.[edge] || [0, 0]; const b = room?.poly?.[(edge + 1) % (room?.poly?.length || 1)] || [0, 0]; return resolveSafeResize( rooms, [...(this.host._resize.openings || this._rszOpenings())], roomId, edge, this._rszOptsFor(a, b), ); }); } public _rszSnapshot(): string { return JSON.stringify(this._geometrySnapshot() || { spaceId: this.host._space, rooms: [], openings: [], walls: [], open_spans: [], }); } public _rszResetController(): void { const hadPreview = this.host._resize.preview !== null; this.host._resize.reset(); if (hadPreview) this.host._cfgEpoch++; } public _rszProjectPreview( snapshot: string, polys: Record, ops: Record, changedRoomIds: readonly string[], sourceRooms: readonly { id: string; poly: number[][]; wall_ids?: string[] }[], ): ResizeProjectionResult { // #329 AC7a: only the LAST projection may explain the refusal, so the // previous verdict never leaks into a rejection of another kind. this.host._rszLimitViolation = null; const real = this.host._serverCfg?.spaces.find((s: any) => s.id === this.host._space); if (!real || !this.host._serverCfg) return { ok: false, reason: 'missing-context' }; const s = JSON.parse(snapshot); // fresh deep copies every move — free to mutate const sp: any = { ...real, rooms: s.rooms, openings: s.openings || [], }; for (const key of ['walls', 'wall_segments', 'open_spans', 'partitions', 'wall_columns', 'decor'] as const) { if (s[key] !== undefined) (sp as any)[key] = s[key]; else delete (sp as any)[key]; } for (const key of ['plan_x', 'plan_y', 'plan_scale', 'plan_scale_x', 'plan_scale_y', 'plan_angle'] as const) { if (s.plan_transform?.[key] !== undefined) (sp as any)[key] = s.plan_transform[key]; else delete (sp as any)[key]; } const H = this.host._spaceH; for (const [id, poly] of Object.entries(polys)) { const r = sp.rooms.find((x: any) => x.id === id); if (!r) continue; r.poly = poly.map((p) => [p[0] / NORM_W, p[1] / H]); delete r.x; delete r.y; delete r.w; delete r.h; // a resized room is saved as a polygon } for (const [id, c] of Object.entries(ops)) { const o = (sp.openings || []).find((x: any) => x.id === id); if (!o) continue; o.x = c[0] / NORM_W; o.y = c[1] / H; } // Geometry that belongs to a wall must ride in the SAME live overlay as // its room polygons. Map from the immutable snapshot on every move (never // from the previous preview), so atomic identity and thickness cannot lag // behind or accumulate rounding error during a long drag. const oldSpans: [number[], number[]][] = []; const newSpans: [number[], number[]][] = []; for (const id of changedRoomIds) { const oldR = sourceRooms.find((r) => r.id === id); const nr = sp.rooms.find((x: any) => x.id === id); if (!oldR || !nr?.poly) continue; const newPoly = nr.poly.map((p: number[]) => [p[0] * NORM_W, p[1] * H] as number[]); if (oldR.poly.length !== newPoly.length) continue; const ids = Array.isArray((oldR as any).wall_ids) ? (oldR as any).wall_ids : []; for (let i = 0; i < oldR.poly.length; i++) { oldSpans.push([oldR.poly[i], oldR.poly[(i + 1) % oldR.poly.length]]); newSpans.push([newPoly[i], newPoly[(i + 1) % newPoly.length]]); const id = ids[i]; const segment = typeof id === 'string' ? (sp.wall_segments || []).find((item) => item.id === id) : null; if (segment) { segment.a = [newPoly[i][0] / NORM_W, newPoly[i][1] / NORM_W]; segment.b = [ newPoly[(i + 1) % newPoly.length][0] / NORM_W, newPoly[(i + 1) % newPoly.length][1] / NORM_W, ]; } } } if (oldSpans.length) { if (Array.isArray(sp.walls) && sp.walls.length) { const rekeyed = rekeyWallsAfterMoveChecked( sp.walls, oldSpans, newSpans, this.host._wallKeyPitch, NORM_W, 'fixed-topology', ); if (rekeyed.rejected) return { ok: false, reason: 'wall-metadata' }; sp.walls = rekeyed.walls; } } const wallCarriers: [number[], number[]][] = []; for (const room of sp.rooms || []) { const poly = roomPoly(room); if (!poly || poly.length < 2) continue; for (let index = 0; index < poly.length; index++) { wallCarriers.push([ [poly[index][0] * NORM_W, poly[index][1] * NORM_W], [poly[(index + 1) % poly.length][0] * NORM_W, poly[(index + 1) % poly.length][1] * NORM_W], ]); } } // Old plans may contain explicit historical debt (for example an authored // off-grid thickness breakpoint). Safe Resize must not silently repair it, // but it may not create a new invalid record either. Remove byte-identical // old records as a multiset and prove only newly written/split records. // That keeps pointermove bounded by the touched thickness profile instead // of comparing every historical record with every carrier twice per frame. const wallSignature = (wall: WallEntry): string => JSON.stringify([wall?.key, wall?.cm, wall?.a, wall?.b]); const oldWallCounts = new Map(); for (const wall of s.walls || []) { const key = wallSignature(wall); oldWallCounts.set(key, (oldWallCounts.get(key) || 0) + 1); } const changedWalls: WallEntry[] = []; for (const wall of sp.walls || []) { const key = wallSignature(wall); const remaining = oldWallCounts.get(key) || 0; if (remaining) oldWallCounts.set(key, remaining - 1); else changedWalls.push(wall); } if (wallRecordCarrierViolations(changedWalls, wallCarriers, this.host._wallKeyPitch, NORM_W, s.walls || []).length) return { ok: false, reason: 'wall-metadata' }; const liveRoomIds = resizeLiveJunctionRoomIds(sp.rooms || [], changedRoomIds); const liveSpace = resizeLiveCandidateSpace(sp, liveRoomIds); if (!liveSpace || !this._rszSpaceCandidateGeometry(this.host._space, liveSpace).ok) return { ok: false, reason: 'physical-geometry' }; // #329 AC7a: a step that would ADD a junction-limit violation is never // projected, so the drag stops at the last allowed position instead of // committing an impossible plan. const limitCandidate = { ...this.host._serverCfg, spaces: (this.host._serverCfg?.spaces || []).map( (space) => (space?.id === this.host._space ? sp : space), ), }; try { this._resizePreviewNodes = multiWallNodesForGeometry( liveSpace.rooms, liveSpace.walls as unknown as WallEntry[], [], GRID_STEP_N, this.host._cellCm, this.host._gridPitch, NORM_W, ); } catch { this._resizePreviewNodes = null; } let limited: JunctionLimitViolation[]; try { const affected = new Set(changedRoomIds); const next = this.host._junctionLimitViolations(limitCandidate, this.host._space, { status: 'lightweight', multiWallNodes: this._resizePreviewNodes }, affected); const baseline = this._resizeBaselineLimits.filter( (item) => item.rule !== 'clearance' || affected.has(item.subject)); limited = increasedViolations(next, baseline); } catch { limited = [{ rule: 'check_failed', subject: this.host._space, actual: 0, limit: 0 }]; } if (limited.length) { this.host._rszLimitViolation = limited[0]; return { ok: false, reason: 'junction-limit' }; } return { ok: true, value: { preview: { space: this.host._space, sp }, beforeWalls: s.walls || [], afterWalls: sp.walls || [], artifact: null, }, }; } public _rszAcceptPreview( preview: ResizePreview | null, wallGeometry: ResizeWallArtifact | null, ): void { this.host._cfgEpoch++; if (this.host._physicalBodiesCache) this.host._physicalBodiesCache.key = `${this.host._space}|${this.host._cfgEpoch}|${this.host._cellCm}|${this.host._gridPitch}`; if (!preview || !wallGeometry) return; const projected = wallBodiesGeometryPath(wallGeometry); if (!projected) return; const key = `${this.host._space}|${this.host._cfgEpoch}|${preview.sp.rooms.length}`; Object.defineProperty(projected, 'sourceFingerprint', { value: contentFingerprint([preview.sp, this.host._cellCm, this.host._gridPitch]), enumerable: false, }); const entry = { key, value: projected }; lruWrite(this.host._wallUnionPool, key, entry, 8); this.host._wallUnionCache = entry; } public _rszSpaceCandidateGeometry(spaceId: string, sp: any): { ok: boolean; wallGeometry: ReturnType | null; } { if (!this.host._serverCfg) return { ok: false, wallGeometry: null }; const candidate = { ...this.host._serverCfg, spaces: this.host._serverCfg.spaces.map((space) => space.id === spaceId ? sp : space), } as ServerConfig; let wallGeometry: ReturnType | null = null; try { const check = this.host._checkSpacePhysicalGeometry( candidate, spaceId, (geometry) => { wallGeometry = geometry; }, ); return { ok: check.ok, wallGeometry }; } catch { return { ok: false, wallGeometry: null }; } } public _rszSpaceCandidateRenderable(spaceId: string, sp: any): boolean { if (!this.host._serverCfg) return false; try { const candidate = { ...this.host._serverCfg, spaces: this.host._serverCfg.spaces.map((space) => space.id === spaceId ? sp : space), } as ServerConfig; return this.host._checkSpacePhysicalGeometry(candidate, spaceId).ok; } catch { return false; } } public _rszCandidateRenderable(preview: { space: string; sp: any } | null): boolean { return !!preview && preview.space === this.host._space && this._rszSpaceCandidateRenderable(preview.space, preview.sp); } public _rszEdgeDown(ev: PointerEvent, roomId: string, edge: number): void { if (this.host._tool !== 'resize' || this.host._resize.dragging) return; this._resizeBaseFrameStable = true; ev.stopPropagation(); ev.preventDefault(); const rooms = this._rszRooms(); const resolution = this._rszResolution(roomId, edge); if (!resolution.enabled) { this.host._showToast(this._rszReasonText(resolution.reason)); return; } capturePointer(ev); const plan = resolution.plan; try { this._resizeBaselineLimits = this.host._junctionLimitViolations( this.host._serverCfg, this.host._space, null, new Set(plan.roomIds)); } catch { this._resizeBaselineLimits = []; } const start = this._svgPoint(ev); const wallUnionKey = `${this.host._space}|${this.host._cfgEpoch}|${rooms.length}`; const wallUnionBefore = this.host._wallUnionCache?.key === wallUnionKey ? this.host._wallUnionCache.value : null; const snapshotIdentity = this._rszSnapshot(); this.host._resize.begin({ pointerId: ev.pointerId, start: [start[0], start[1]], roomId, plan, options: this._rszOptsFor(plan.a, plan.b), rooms, openings: this._rszOpenings(), snapshotIdentity, before: JSON.parse(snapshotIdentity) as SpaceGeometryState, wallUnionBefore, epochBefore: this.host._cfgEpoch, }); } public _rszReasonText(reason: SafeResizeReason): string { return this.host._t(`resize.disabled.${reason}` as I18nKey); } public _rszDisabledActivate(ev: Event, reason: SafeResizeReason): void { ev.stopPropagation(); ev.preventDefault(); this.host._showToast(this._rszReasonText(reason)); } public _rszDisabledKey(ev: KeyboardEvent, reason: SafeResizeReason): void { if (ev.key !== 'Enter' && ev.key !== ' ') return; this._rszDisabledActivate(ev, reason); } public _rszMove(ev: PointerEvent): void { if (!this.host._resize.ownsPointer(ev.pointerId)) return; ev.stopPropagation(); queueHouseplanPointerMove(this.host, 'resize', () => this._rszMoveNow(ev)); } private _rszMoveNow(ev: PointerEvent): void { if (!this.host._resize.ownsPointer(ev.pointerId)) return; ev.stopPropagation(); const p = this._svgPoint(ev); const result = this.host._resize.move({ pointerId: ev.pointerId, point: [p[0], p[1]], step: this.host._gridPitch, snap: (point) => { const snapped = this._snap(point); return [snapped[0], snapped[1]]; }, project: (snapshot, polys, openings, changedRoomIds, rooms) => this._rszProjectPreview(snapshot, polys, openings, changedRoomIds, rooms), publish: (preview, artifact) => this._rszAcceptPreview(preview, artifact), measure: (candidate, plan) => this._rszEdgeLabels(candidate, plan), }); if (result.kind === 'rejected') { // Persistence metadata is part of the geometry transaction. If wall or // virtual-span rekeying would be lossy, keep the last complete preview: // the pointer visibly stops there and pointerup can commit only that // already-rendered safe candidate. if (result.notify) { // #329 AC7a: a step stopped by a junction limit names THAT rule — the // generic "geometry cannot be saved" wording would hide which limit // the wall ran into. this.host._showToast(this.host._rszLimitViolation ? `${this.host._t('resize.limit_stopped')} — ` + this._junctionLimitLabel(this.host._rszLimitViolation) : this.host._t('resize.preview_failed')); } this.host.requestUpdate(); return; } if (result.kind === 'accepted') this.host.requestUpdate(); } public _rszUp(ev: PointerEvent): void { flushHouseplanPointerMove(this.host, 'resize'); if (!this.host._resize.ownsPointer(ev.pointerId)) return; ev.stopPropagation(); const result = this.host._resize.finish({ pointerId: ev.pointerId, currentSnapshotIdentity: this._rszSnapshot(), validatePreview: (preview) => this._rszCandidateRenderable(preview), }); if (result.kind === 'no-op') { // HP-1550-01: nothing to restore — the preview never touched the config this.host._cfgEpoch++; this.host.requestUpdate(); return; } if (result.kind === 'rejected') { this.host._cfgEpoch++; this.host._showToast(this.host._t('resize.commit_failed')); this.host.requestUpdate(); return; } // Commit the exact preview in one step. No simplify/rebuild pass is allowed: // preview and persistence share the same fixed-topology candidate. const preview = result.preview; const sp = this.host._serverCfg?.spaces.find((space) => space.id === preview.space); if (sp) { sp.rooms = preview.sp.rooms; sp.openings = preview.sp.openings; if (Array.isArray(preview.sp.walls)) { if (preview.sp.walls.length) sp.walls = preview.sp.walls; else delete sp.walls; } if (Array.isArray(preview.sp.wall_segments)) sp.wall_segments = preview.sp.wall_segments; } // the click synthesized after the drag must not re-pick the selection this.host._suppressClick = true; setTimeout(() => (this.host._suppressClick = false), 0); this._commitPhysicalGeometry(this.host._t('history.resize_room'), result.before); this.host.requestUpdate(); } public _rszCancelDrag(pointerId?: number): void { cancelHouseplanPointerMove(this.host, 'resize'); const result = this.host._resize.cancel(this._rszSnapshot(), pointerId); if (result.kind === 'no-op') return; // An identical cancel reuses the pre-drag structural caches and writes nothing. if (result.restoreEpoch !== null) this.host._cfgEpoch = result.restoreEpoch; else this.host._cfgEpoch++; if (this.host._physicalBodiesCache) this.host._physicalBodiesCache.key = `${this.host._space}|${this.host._cfgEpoch}|${this.host._cellCm}|${this.host._gridPitch}`; if (result.restoreWallUnion) { // Alias the already-proved pre-drag union under the restored epoch. const space = this.host._spaceModel(); if (space) { const key = `${this.host._space}|${this.host._cfgEpoch}|${space.rooms.length}`; const entry = { key, value: result.restoreWallUnion }; lruWrite(this.host._wallUnionPool, key, entry, 8); this.host._wallUnionCache = entry; } } // Retain the canonical frame only when the drag was actually painted in // the isolated plan-editor layer. Programmatic/non-editor callers still // need a full render to replace their preview DOM. if (result.restoreEpoch !== null && this.host._mode === 'plan' && this._resizeBaseFrameStable) this.host._terminalFrame = 1; this.host.requestUpdate(); } public _rszPointerCancel(ev: PointerEvent): void { if (!this.host._resize.ownsPointer(ev.pointerId)) return; ev.stopPropagation(); this._rszCancelDrag(ev.pointerId); } public _rszEdgeLabels( res: { polys: Record }, plan: SafeResizePlan, sourceRooms: readonly { id: string; poly: number[][] }[] | null = this.host._resize.rooms, ): ResizeLiveLabel[] { const rooms = sourceRooms; const labels: ResizeLiveLabel[] = []; const own = res.polys[plan.roomId] || rooms?.find((r) => r.id === plan.roomId)!.poly; if (!own || !rooms) return labels; const n = own.length; // Длины — между внутренними гранями, как и площадь ниже (#233). Раньше // здесь считалась осевая длина, и одно облачко подписей несло две разные // конвенции: «3.00 × 4.00» по центрам стен рядом с площадью по полу. const spanCms = this._rszInnerSpanCms(plan.roomId, own, res.polys); // The moving wall is obvious under the pointer. Only its two side walls // remain useful measurements, and each one owns the matching highlight. for (const edge of resizeMeasuredEdges(own, plan.edge)) { const a = own[edge], b = own[(edge + 1) % n]; const cm = spanCms?.[edge]; labels.push({ kind: 'length', x: (a[0] + b[0]) / 2, y: (a[1] + b[1]) / 2, text: cm == null ? this.host._fmtLen(a, b) : formatLength(cm, this.host.hass?.config?.unit_system?.length === 'mi'), edge: { a: [a[0], a[1]], b: [b[0], b[1]] }, }); } // Live areas sit beside the moving wall, one on each owner side. Placement // is pure screen-space math fed by the stage size cached by ResizeObserver; // no pointermove DOM measurement is allowed. const imperial = this.host.hass?.config?.unit_system?.length === 'mi'; const ids = plan.roomIds; const walls = this.host._spaceWalls; const physical = this.host._physicalBodiesR(); const base = this.host._baseVb(); const currentView = this.host._view && this.host._view.w > 0 && this.host._view.h > 0 ? this.host._view : { x: base[0], y: base[1], w: base[2], h: base[3] }; const [cachedW, cachedH] = this.host._lastValidStageSize || [currentView.w, currentView.h]; const view = { ...currentView, stageWidth: Math.max(1, cachedW), stageHeight: Math.max(1, cachedH), }; const space = this.host._spaceModel(); const cfgSize = this.host._config?.icon_size ?? 2.5; const iconPct = cfgSize > 8 ? 2.5 : cfgSize; const iconPx = space ? iconCqw(iconPct, space, currentView.w, this.host._kiosk ? this.host._kioskScale.icon : 1) * view.stageWidth / 100 : 24; const gearHeightPx = Math.max(10, iconPx * 0.77); const gearText = this.host._t('room.settings_short'); // Mirrors the CSS proportions conservatively: icon + gap + two paddings + // localized text at the button's 0.42*height font size. const gearWidthPx = gearHeightPx * ( 0.55 + 0.35 * 0.42 + 0.76 + Math.max(1, gearText.length) * 0.66 * 0.42 ); for (const id of ids) { const poly = res.polys[id] || rooms.find((r) => r.id === id)!.poly; const room = space?.rooms.find((candidate) => candidate.id === id); const gearCenter = room ? (this.roomGear.previewCenter(room, poly, space!.id) || poleOfInaccessibility(poly)) : poleOfInaccessibility(poly); // The preview is already the active render model. Reuse the same shared // masonry union + contour cache that the following render consumes; // rebuilding both independently here doubled one Resize frame. const floor = walls.length && space ? (innerContourForRoom( space.rooms, id, walls, this.host._openCuts(), this.host._wallKeyPitch, this.host._cellCm, this.host._gridPitch, NORM_W, null, this._resizePreviewNodes, ) || poly) : poly; const m2 = physical.length ? geometryArea(floorMinusBodies(floor, physical)) * Math.pow(this.host._cellCm / this.host._gridPitch, 2) / 1e4 : areaM2(floor, this.host._gridPitch, this.host._cellCm); const text = formatArea(m2, imperial); const placement = placeResizeAreaLabel({ poly, edge: plan.edgeByRoom[id], text, view, gearCenter, gearWidthPx, gearHeightPx, }); labels.push({ kind: 'area', roomId: id, x: placement.anchor[0], y: placement.anchor[1], text, placement, }); } return labels; } public _rszInnerSpanCms( roomId: string, own: number[][], polys: Record, ): number[] | null { const rooms = Object.keys(polys).length ? Object.entries(polys).map(([id, poly]) => ({ id, poly })) : this.host._spaceModel()?.rooms; if (!rooms?.length) return null; const walls = this.host._spaceWalls; if (!walls.length) return null; const openCuts = this.host._openCuts(); const offsets = ownEdgeOffsets( rooms, roomId, walls, openCuts, this.host._wallKeyPitch, this.host._cellCm, this.host._gridPitch, NORM_W, ); if (!offsets || offsets.length !== own.length) return null; const perUnitCm = this.host._cellCm / this.host._gridPitch; return own.map((_, edge) => innerEdgeSpan(own, edge, offsets) * perUnitCm); } public _renderResizeMeasurements(): TemplateResult | typeof nothing { const live = this.host._resize.liveLabels; if (!live?.length) return nothing; const lengths = live.filter((label) => label.kind === 'length'); const areas = live.filter((label) => label.kind === 'area'); return svg``; } public _renderResizeLayer( view: { x: number; y: number; w: number; h: number }, roomIds?: readonly string[], ): TemplateResult { const hr = Math.max(view.w * 0.013, 5); // finger-sized HIT radius on touch, like .vacfithandle // Wall-handle glyph: half the old circle — a wall segment with two arrows // pointing perpendicular to it (the directions the wall can be dragged). // Drawn in local coords (wall along X), rotated per edge at render time. // The invisible circle above keeps the full finger-sized hit area and the // HP-1550-04 hit-test priority over openings. const s = hr / 2, f = (v: number) => v.toFixed(1); const iconD = `M ${f(-0.7 * s)} 0 H ${f(0.7 * s)}` + ` M 0 ${f(-0.22 * s)} V ${f(-s)} M ${f(-0.32 * s)} ${f(-0.6 * s)} L 0 ${f(-s)} L ${f(0.32 * s)} ${f(-0.6 * s)}` + ` M 0 ${f(0.22 * s)} V ${f(s)} M ${f(-0.32 * s)} ${f(0.6 * s)} L 0 ${f(s)} L ${f(0.32 * s)} ${f(0.6 * s)}`; const parts: TemplateResult[] = []; const rooms = this._rszRooms(); // A geometry snapshot is a deep clone/serialization. It is the cache key // for every handle in this frame, so compute it once per layer render — // never once per room edge. During a drag the immutable gesture snapshot // remains authoritative inside _rszResolution. const renderSnapshot = this.host._resize.snapshotIdentity || this._rszSnapshot(); const visibleRooms = roomIds?.length ? new Set(roomIds) : null; for (const r of rooms) { if (visibleRooms && !visibleRooms.has(r.id)) continue; for (let i = 0; i < r.poly.length; i++) { const a = r.poly[i], b = r.poly[(i + 1) % r.poly.length]; if (Math.hypot(b[0] - a[0], b[1] - a[1]) < this.host._gridPitch) continue; const mx = f((a[0] + b[0]) / 2), my = f((a[1] + b[1]) / 2); const ang = f(Math.atan2(b[1] - a[1], b[0] - a[0]) * 180 / Math.PI); const resolution = this._rszResolution(r.id, i, renderSnapshot); const disabled = !resolution.enabled; const reason = disabled ? this._rszReasonText(resolution.reason) : this.host._t('title.markup_resize'); parts.push(svg` this._rszEdgeDown(e, r.id, i)} @pointermove=${(e: PointerEvent) => this._rszMove(e)} @pointerup=${(e: PointerEvent) => this._rszUp(e)} @pointercancel=${(e: PointerEvent) => this._rszPointerCancel(e)} @lostpointercapture=${(e: PointerEvent) => this._rszPointerCancel(e)} @click=${disabled ? (e: Event) => this._rszDisabledActivate(e, resolution.reason) : null} @keydown=${disabled ? (e: KeyboardEvent) => this._rszDisabledKey(e, resolution.reason) : null}> ${reason} `); parts.push(svg``); } } return svg`${parts}`; } public _partitionOpeningCuts( space: SpaceModel | undefined = this.host._spaceModel(), accept: (opening: OpeningCfg) => boolean = () => true, ): PartitionOpeningCut[] { if (!space) return []; const config = this.host._curSpaceCfg?.id === space.id ? this.host._curSpaceCfg : null; const openings = geometryOpenings( config, space, this.host._cellCm, this.host._gridPitch, NORM_W, ); return geometryPartitionOpeningCuts(openings, accept); } public _decorSnapGeometry(excludeId?: string): SnapGeometry { const space = this.host._spaceModel(); if (!space) return { points: [], segments: [] }; const cacheKey = excludeId || ''; const cached = this.host._decorSnapCache; if (cached && cached.epoch === this.host._cfgEpoch && cached.space === this.host._space && cached.height === this.host._decorH && cached.exclude === cacheKey) { return cached.geometry; } const parts: SnapGeometry[] = []; for (const shape of this.host._decorList) { if (shape.id === excludeId) continue; if (shape.kind === 'line') { const a = [shape.x1 * NORM_W, shape.y1 * this.host._decorH]; const b = [shape.x2 * NORM_W, shape.y2 * this.host._decorH]; parts.push({ points: [a, b, [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]], segments: [{ a, b }], }); } else if (shape.kind === 'text') { parts.push({ points: [[shape.x * NORM_W, shape.y * this.host._decorH]], segments: [] }); } else { const box = this.host._decorBoxOf(shape); if (box) parts.push(boxAnchors(box)); } } for (const room of space.rooms) { const poly = roomPoly(room); if (!poly?.length) continue; parts.push({ points: poly.flatMap((p, i) => { const q = poly[(i + 1) % poly.length]; return [p, [(p[0] + q[0]) / 2, (p[1] + q[1]) / 2]]; }), segments: poly.map((p, i) => ({ a: p, b: poly[(i + 1) % poly.length] })), }); } const geometry = mergeSnapGeometry(parts); this.host._decorSnapCache = { epoch: this.host._cfgEpoch, space: this.host._space, height: this.host._decorH, exclude: cacheKey, geometry, }; return geometry; } public _decorSnap(raw: number[], pointerType = 'mouse', excludeId?: string): number[] { const stage = this.host._stageEl; const view = this.host._viewOr(this.host._baseVb()); const px = pointerType === 'touch' || pointerType === 'pen' ? 14 : 8; const tolerance = stage ? (view.w / Math.max(1, stage.clientWidth)) * px : this.host._gridPitch; return snapDecorPoint( raw, this._decorSnapGeometry(excludeId), tolerance, (point) => this._snap(point), ).point; } public _replaceDecor(id: string, patch: Partial): void { const sp = this.host._curSpaceCfg; if (!sp) return; sp.decor = this.host._decorList.map((shape) => shape.id === id ? { ...shape, ...patch } : shape); // The edited shape is excluded from the gesture's magnet candidates, so // keep the cached geometry of every *other* object across pointermoves. // A different exclude id/cache key rebuilds it when the next gesture starts. this.host.requestUpdate(); } public _cancelDecorGesture(): void { cancelHouseplanPointerMove(this.host, 'decor-transform'); cancelHouseplanPointerMove(this.host, 'decor-move'); cancelHouseplanPointerMove(this.host, 'backdrop'); const before = this.host._decorMove?.before || this.host._dtDrag?.before || this.host._bdDrag?.before; const sp = before && this.host._serverCfg?.spaces.find((space) => space.id === before.spaceId); if (before && sp) { const copy = (value: T): T => JSON.parse(JSON.stringify(value)); if (before.decor !== undefined) sp.decor = copy(before.decor); else delete sp.decor; for (const key of ['plan_x', 'plan_y', 'plan_scale', 'plan_scale_x', 'plan_scale_y', 'plan_angle'] as const) delete sp[key]; Object.assign(sp, copy(before.plan_transform || {})); // The restored transform is byte-identical; keep geometry caches. this.host._decorSnapCache = null; } this.host._decorMove = null; this.host._dtDrag = null; this.host._bdDrag = null; // Mark after named terminal setters; only the unnamed cancel frame may be retained. if (before && sp && this.host._mode === 'decor') this.host._terminalFrame = 1; this.host.requestUpdate(); } public _decorPointerDown(ev: PointerEvent): boolean { const t = this.host._decorTool; // #369(е): only the primary mouse button places objects — a right/middle // click with an armed tool must not stamp furniture or start a shape. // Touch/pen keep their own transactions (button is 0/-1 there). if (ev.pointerType === 'mouse' && ev.button !== 0 && t !== 'select' && t !== 'erase') return false; // A DRAWING tool owns the whole canvas. Pressing on top of an existing // shape must start a NEW figure at that very point — otherwise a line can // never begin at the end of another line, because the old line grabs the // press first (owner, 2026-08-04). Only select/erase talk to shapes, and // only for them does the shape's own handler get to deal with the event. const onShape = (t === 'select' || t === 'erase') ? ((ev.target as HTMLElement).closest?.('.dshape') as SVGElement | null) : null; if (onShape) return true; // the shape's own handler deals with it if (t === 'line' || t === 'rect' || t === 'ellipse') { ev.preventDefault(); const p = this._decorSnap(this._svgPoint(ev), ev.pointerType); this.host._decorDraft = { kind: t, a: p, b: p, pid: ev.pointerId }; capturePointer(ev); return true; } if (t === 'text') { // …and the press did NOT land on an existing label: those are the one // exception to the inertness above (see _decorShapeDown). const p = this._decorSnap(this._svgPoint(ev), ev.pointerType); this.host._decorTextDialog = { x: clampCanvasN(p[0] / NORM_W), y: clampCanvasN(p[1] / this.host._decorH), text: '', color: this.host._decorStyle.color, opacity: this.host._decorStyle.opacity, angle: '0', sizeCm: decorUnitsToCm(DECOR_TEXT_BASE, this.host._cellCm, this.host._gridPitch), }; this.host._decorTextSelection = { start: 0, end: 0 }; return true; } if (t === 'furniture' || t === 'image') { // The furniture tool is a STAMP: the palette arms a symbol, the press // puts it down at its real size and the editor goes back to `select` // with the new piece selected (owner: «сразу выделен»). Without an armed // symbol the press does nothing but keep the pan — pressing the canvas // must not silently place whatever was chosen last week. if (t === 'furniture' ? !this.host._furnPalette : !this.host._decorImagePalette) return false; ev.preventDefault(); // A mouse has already shown the exact result and keeps the traditional // one-press stamp. Touch/pen has no hover, so commit only after a clean // pointerup; pointercancel, movement and a second contact stay fail-dark. const pointerType = ev.pointerType || 'mouse'; if (pointerType === 'mouse') { if (t === 'furniture') this._furnPlace(this._svgPoint(ev), ev.shiftKey, pointerType); else this._decorImagePlace(this._svgPoint(ev), pointerType); return true; } const pending = this.host._furnTouchPending; if (pending && pending.pid !== ev.pointerId) { pending.cancelled = true; this.host._furnPreviewInput = null; return true; } this.host._furnTouchPending = { pid: ev.pointerId, sx: ev.clientX, sy: ev.clientY, pointerType, cancelled: false, }; this.host._furnPreviewInput = null; capturePointer(ev); return true; } // Empty-space selection belongs only to Select. Erase is an object // command: a miss must be a true no-op. With SVG text its painted glyphs // do not cover the whole logical label box, so clearing here made a click // between letters appear to erase only the selection outline. if (t === 'select') this.host._decorSel = null; // …and under its own tool the picture is grabbable by its body // (docs/DECOR-EDITOR.md §3.2). Only INSIDE the image rect: press beside the // picture and the plane still pans with one finger. if (this.host._bdMovable) { const r = this.host._bdRect!; const p = this._svgPoint(ev); const cx = r.x + r.w / 2, cy = r.y + r.h / 2; const a = (-normalizeAngle(r.angle) * Math.PI) / 180; const dx = p[0] - cx, dy = p[1] - cy; const local = [cx + dx * Math.cos(a) - dy * Math.sin(a), cy + dx * Math.sin(a) + dy * Math.cos(a)]; if (local[0] >= r.x && local[0] <= r.x + r.w && local[1] >= r.y && local[1] <= r.y + r.h) { ev.preventDefault(); return this._bdStart(ev); } } return false; // pan is allowed } public _decorCommitDraft(): void { const d = this.host._decorDraft; this.host._decorDraft = null; if (!d) return; const min = this.host._gridPitch * 0.5; if (!validDecorDraft(d.kind, d.a, d.b, min)) return; const W = NORM_W, H = this.host._decorH; const st = this.host._decorStyle; const before = this._geometrySnapshot(); const id = 'dc' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5); // creation had no canvas guard at all while the MOVE did — a draft could be // born outside the range the mover then refused to leave. One limit, both // ends of the gesture (docs/CANVAS.md §9). const cn = clampCanvasN; let shape: DecorShape; if (d.kind === 'line') { shape = { id, kind: 'line', x1: cn(d.a[0] / W), y1: cn(d.a[1] / H), x2: cn(d.b[0] / W), y2: cn(d.b[1] / H), ...decorStylePatch(st, false) } as DecorShape; } else { const x = cn(Math.min(d.a[0], d.b[0]) / W), y = cn(Math.min(d.a[1], d.b[1]) / H); const w = Math.abs(d.b[0] - d.a[0]) / W, h = Math.abs(d.b[1] - d.a[1]) / H; shape = { id, kind: d.kind, x, y, w, h, ...decorStylePatch(st, true) } as DecorShape; } const sp = this.host._curSpaceCfg; sp.decor = [...this.host._decorList, shape]; this.host._decorSel = id; this._recordGeometry(this.host._t('history.decor_add'), before); this._saveConfig(); this.host.requestUpdate(); } public _decorShapeDown(ev: PointerEvent, shape: DecorShape): void { if (this.host._mode !== 'decor') return; // Under any other tool the shape is not a target at all: the press has to // reach the stage, where the drawing tool starts a new figure (or the // backdrop tool grabs the picture). Swallowing it here was the bug — the // click on a line end did nothing but keep the old selection alive. const t = this.host._decorTool; // ONE exception (owner, 2026-08-04): under the TEXT tool an existing LABEL // is a target again, and pressing it opens its editor instead of starting // a new label on top of it. Everything else stays inert — a press on a // line or a rectangle under the text tool still reaches the stage and // creates a new label there (the CSS keeps them pointer-inert, this is // the belt to that pair of braces). if (t === 'text') { if (shape.kind !== 'text') return; ev.stopPropagation(); ev.preventDefault(); this._decorOpenText(shape); return; } if (t !== 'select' && t !== 'erase') return; ev.stopPropagation(); ev.preventDefault(); if (t === 'erase') { this.host._decorEraseConfirm = { id: shape.id, kind: shape.kind }; return; } this.host._decorSel = shape.id; this.host._decorMove = { id: shape.id, start: this._svgPoint(ev), orig: JSON.parse(JSON.stringify(shape)), pid: ev.pointerId, moved: false, before: this._geometrySnapshot(), }; capturePointer(ev); } public _decorMoveUpdate(ev: PointerEvent): void { const m = this.host._decorMove; if (!m) return; if (m.orig?.kind === 'furniture') { this._furnMoveUpdate(ev); return; } const p = this._svgPoint(ev); const o0: any = m.orig; // The delta used to be what got snapped, which preserves whatever off-grid // offset the shape already had: a legacy shape at 0.3013 stayed at 0.3013 // for ever, one step at a time. Snap the RESULTING ANCHOR instead, so one // drag is enough to put any shape on the grid (docs/CANVAS.md §9). const ax0 = (o0.kind === 'line' ? o0.x1 : o0.x) * NORM_W; const ay0 = (o0.kind === 'line' ? o0.y1 : o0.y) * this.host._decorH; const anchor = this._decorSnap( [ax0 + (p[0] - m.start[0]), ay0 + (p[1] - m.start[1])], ev.pointerType, m.id, ); let dx = (anchor[0] - ax0) / NORM_W; let dy = (anchor[1] - ay0) / this.host._decorH; // audit follow-up L4 gave decor a bounds clamp of -0.25..1.25 — the plan // was a sheet with edges then. It is not any more (docs/CANVAS.md): the // clamp is now the same garbage limit the backend enforces, so decor can // follow a plan that lives at 2.7 and still cannot be flung to 1e100. const o: any = m.orig; const curX = o.kind === 'line' ? Math.min(o.x1, o.x2) : o.x; const curY = o.kind === 'line' ? Math.min(o.y1, o.y2) : o.y; const w = o.kind === 'line' ? Math.abs(o.x2 - o.x1) : (o.w || 0); const h = o.kind === 'line' ? Math.abs(o.y2 - o.y1) : (o.h || 0); const lim = CANVAS_LIMIT; dx = Math.max(-lim - curX, Math.min(lim - curX - w, dx)); dy = Math.max(-lim - curY, Math.min(lim - curY - h, dy)); if (dx || dy) m.moved = true; const sp = this.host._curSpaceCfg; sp.decor = this.host._decorList.map((x) => { if (x.id !== m.id) return x; const o: any = m.orig; if (x.kind === 'line') return { ...x, x1: o.x1 + dx, y1: o.y1 + dy, x2: o.x2 + dx, y2: o.y2 + dy }; return { ...x, x: o.x + dx, y: o.y + dy }; }); this.host.requestUpdate(); } public _dmUp(): void { flushHouseplanPointerMove(this.host, 'decor-move'); const move = this.host._decorMove; if (move?.moved) { this._recordGeometry(this.host._t('history.decor_move'), move.before); this._saveConfig(); } this.host._decorMove = null; this.host.requestUpdate(); } public _decorShapeDbl(ev: MouseEvent, shape: DecorShape): void { if (this.host._mode !== 'decor' || this.host._decorTool !== 'select') return; ev.preventDefault(); ev.stopPropagation(); this.host._decorMove = null; this.host._decorSel = shape.id; this._openDecorProperties(shape); } public _openDecorProperties(shape: DecorShape): void { if (this.host._mode !== 'decor' || this.host._decorTool !== 'select') return; if (shape.kind === 'text') { this._decorOpenText(shape); return; } if (!['line', 'rect', 'ellipse', 'furniture', 'image'].includes(shape.kind)) return; if (shape.kind === 'image') void this._decorImageCatalogLoad(); const style = this.host._decorResolvedStyle(shape); const line = shape.kind === 'line' ? shape : null; const box = this.host._decorBoxOf(shape); this.host._decorShapeDialog = { id: shape.id, kind: shape.kind as 'line' | 'rect' | 'ellipse' | 'furniture' | 'image', color: style.color, opacity: style.opacity, widthCm: style.widthCm, angle: this.host._angleField(line ? normalizeAngle(segmentAngle( [line.x1 * NORM_W, line.y1 * this.host._decorH], [line.x2 * NORM_W, line.y2 * this.host._decorH], )) : normalizeAngle((shape as any).angle)), ...(line ? { lengthCm: decorUnitsToCm( Math.hypot((line.x2 - line.x1) * NORM_W, (line.y2 - line.y1) * this.host._decorH), this.host._cellCm, this.host._gridPitch, ), lineStyle: line.line_style === 'dashed' ? 'dashed' : 'solid', } : {}), ...(box ? { sizeWCm: decorUnitsToCm(box.w, this.host._cellCm, this.host._gridPitch), sizeHCm: decorUnitsToCm(box.h, this.host._cellCm, this.host._gridPitch), } : {}), ...(shape.kind === 'furniture' ? { symbol: shape.symbol, flipH: !!shape.flip_h, flipV: !!shape.flip_v, sizeWField: furnitureSignedFieldValue( decorUnitsToCm(box!.w, this.host._cellCm, this.host._gridPitch), !!shape.flip_h, this.host._imperial, ), sizeHField: furnitureSignedFieldValue( decorUnitsToCm(box!.h, this.host._cellCm, this.host._gridPitch), !!shape.flip_v, this.host._imperial, ), } : {}), ...(shape.kind === 'image' ? { assetId: shape.asset_id, flipH: !!shape.flip_h, flipV: !!shape.flip_v, sizeWField: furnitureSignedFieldValue( decorUnitsToCm(box!.w, this.host._cellCm, this.host._gridPitch), !!shape.flip_h, this.host._imperial, ), sizeHField: furnitureSignedFieldValue( decorUnitsToCm(box!.h, this.host._cellCm, this.host._gridPitch), !!shape.flip_v, this.host._imperial, ), } : {}), ...(shape.kind === 'rect' || shape.kind === 'ellipse' ? { fill: style.fill, fillColor: style.fillColor, fillOpacity: style.fillOpacity, } : {}), }; } public _decorOpenText(shape: DecorShape): void { if (shape.kind !== 'text') return; let text = String(shape.text ?? ''); const hasInline = [...text.matchAll(/\{([^{}\r\n]+)\}/g)] .some((m) => !!liveTextReference(m[1])); const explicitUnit = String(shape.unit ?? '').trim(); // Old dialogs represented the entity state as attr="state"; inline text // represents that same value with a bare `{entity}` token. const legacyAttr = String(shape.attr ?? '').trim().toLowerCase() === 'state' ? null : shape.attr; const legacyToken = !hasInline && !explicitUnit ? liveTextToken(shape.entity, legacyAttr) : ''; const preserveLegacy = !!String(shape.entity ?? '').trim() && !hasInline && (!legacyToken || !!explicitUnit); if (legacyToken && !preserveLegacy) { const slot = text.indexOf('{}'); text = slot >= 0 ? text.slice(0, slot) + legacyToken + text.slice(slot + 2) : `${text}${text ? ' ' : ''}${legacyToken}`; } this.host._decorTextDialog = { id: shape.id, x: shape.x, y: shape.y, text, color: safeStoredColor(shape.color, this.host._decorStyle.color), opacity: clamp01(shape.opacity, this.host._decorStyle.opacity), angle: this.host._angleField(shape.angle), sizeCm: this.host._decorTextSizeCm(shape), pickerEntity: shape.entity || '', preserveLegacy: preserveLegacy || undefined, }; this.host._decorTextSelection = { start: text.length, end: text.length }; } public _decorRememberTextSelection(el: HTMLTextAreaElement): void { this.host._decorTextSelection = { start: el.selectionStart ?? el.value.length, end: el.selectionEnd ?? el.value.length, }; } public _decorInsertLiveVariable(attr: string | null): void { const d = this.host._decorTextDialog; if (!d) return; const token = liveTextToken(d.pickerEntity, attr); if (!token) return; const old = d.text; const start = Math.max(0, Math.min(old.length, this.host._decorTextSelection.start)); const end = Math.max(start, Math.min(old.length, this.host._decorTextSelection.end)); if (old.length - (end - start) + token.length > 200) return; const text = old.slice(0, start) + token + old.slice(end); const caret = start + token.length; this.host._decorTextSelection = { start: caret, end: caret }; // Inserting a new inline reference is the explicit replacement of any // unrepresentable legacy link; the stale side fields can now be dropped. this.host._decorTextDialog = { ...d, text, preserveLegacy: undefined }; this.host.updateComplete.then(() => { const el = this.host.renderRoot.querySelector('textarea.dtarea'); if (!el) return; el.focus(); el.setSelectionRange(caret, caret); }); } public _decorSaveText(): void { const d = this.host._decorTextDialog; // The user's own line breaks are kept; the surrounding whitespace is not. const text = String(d?.text ?? '').replace(/\r\n?/g, '\n').trim(); if (!d || !text) { this.host._decorTextDialog = null; return; } const before = this._geometrySnapshot(); const sp = this.host._curSpaceCfg; const textStyle = { color: d.color, opacity: clamp01(d.opacity), size_cm: Number(Math.max(0.1, Math.min(DECOR_TEXT_CM_MAX, d.sizeCm)).toFixed(4)), ...(normalizeAngle(d.angle) ? { angle: normalizeAngle(d.angle) } : {}), }; if (d.id) { sp.decor = this.host._decorList.map((x) => { if (x.id !== d.id) return x; if (x.kind !== 'text') return x; // A legacy unit or non-canonical attribute cannot be represented by // the inline grammar. Keep those fields until the user explicitly // replaces the binding; ordinary representable links still migrate. if (d.preserveLegacy) { const { angle: _angle, size: _size, scale: _scale, ...straight } = x; return { ...straight, text, ...textStyle }; } // beta.9 and earlier stored one live link beside the text. Saving in // the new editor migrates it to inline tokens and drops the old fields. const { entity, attr, unit, ...rest } = x; const { angle: _angle, size: _size, scale: _scale, ...straight } = rest; return { ...straight, text, ...textStyle }; }); } else { const id = 'dc' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5); sp.decor = [...this.host._decorList, { id, kind: 'text', x: d.x, y: d.y, text, ...textStyle }]; this.host._decorSel = id; } this.host._decorTextDialog = null; this._recordGeometry(this.host._t(d.id ? 'history.decor_edit' : 'history.decor_add'), before); this._saveConfig(); this.host.requestUpdate(); } public _decorSaveShape(): void { const d = this.host._decorShapeDialog; if (!d) return; const before = this._geometrySnapshot(); const style: DecorStyle = { color: d.color, opacity: clamp01(d.opacity), widthCm: Math.max(0.1, Math.min(100, Number(d.widthCm) || 0.1)), fill: !!d.fill, fillColor: d.fillColor || d.color, fillOpacity: clamp01(d.fillOpacity, 0.25), }; const sp = this.host._curSpaceCfg; const furnitureWcm = d.kind === 'furniture' || d.kind === 'image' ? furnitureSignedFieldCm( d.sizeWField, this.host._imperial, CANVAS_LIMIT * this.host._cellCm, ) : null; const furnitureHcm = d.kind === 'furniture' || d.kind === 'image' ? furnitureSignedFieldCm( d.sizeHField, this.host._imperial, CANVAS_LIMIT * this.host._cellCm, ) : null; if ((d.kind === 'furniture' || d.kind === 'image') && (furnitureWcm === null || furnitureHcm === null)) return; sp.decor = this.host._decorList.map((shape) => { if (shape.id !== d.id) return shape; const fillable = d.kind === 'rect' || d.kind === 'ellipse'; const visual = decorStylePatch(style, fillable); if (shape.kind === 'line') { const cx = ((shape.x1 + shape.x2) / 2) * NORM_W; const cy = ((shape.y1 + shape.y2) / 2) * this.host._decorH; const length = decorCmToUnits(Math.max(0.1, Number(d.lengthCm) || 0.1), this.host._cellCm, this.host._gridPitch); const rad = (normalizeAngle(d.angle) * Math.PI) / 180; const dx = Math.cos(rad) * length / 2, dy = Math.sin(rad) * length / 2; const a = this._snap([cx - dx, cy - dy]); const b = this._snap([cx + dx, cy + dy]); const { width: _legacyWidth, line_style: _oldLineStyle, ...rest } = shape; return { ...rest, ...visual, x1: clampCanvasN(a[0] / NORM_W), y1: clampCanvasN(a[1] / this.host._decorH), x2: clampCanvasN(b[0] / NORM_W), y2: clampCanvasN(b[1] / this.host._decorH), ...(d.lineStyle === 'dashed' ? { line_style: 'dashed' as const } : {}), } as DecorShape; } if (shape.kind === 'furniture') { const oldW = shape.w * NORM_W, oldH = shape.h * this.host._decorH; const w = decorCmToUnits(Math.abs(furnitureWcm!), this.host._cellCm, this.host._gridPitch); const h = decorCmToUnits(Math.abs(furnitureHcm!), this.host._cellCm, this.host._gridPitch); const cx = shape.x * NORM_W + oldW / 2, cy = shape.y * this.host._decorH + oldH / 2; const angle = normalizeAngle(d.angle); const { width: _legacyWidth, angle: _oldAngle, flip_h: _oldFlipH, flip_v: _oldFlipV, ...rest } = shape; return { ...rest, ...visual, x: clampCanvasN((cx - w / 2) / NORM_W), y: clampCanvasN((cy - h / 2) / this.host._decorH), w: w / NORM_W, h: h / this.host._decorH, ...(d.symbol ? { symbol: d.symbol } : {}), ...(furnitureWcm! < 0 ? { flip_h: true } : {}), ...(furnitureHcm! < 0 ? { flip_v: true } : {}), ...(angle ? { angle } : {}), } as DecorShape; } if (shape.kind === 'image') { const oldW = shape.w * NORM_W, oldH = shape.h * this.host._decorH; const w = decorCmToUnits(Math.abs(furnitureWcm!), this.host._cellCm, this.host._gridPitch); const h = decorCmToUnits(Math.abs(furnitureHcm!), this.host._cellCm, this.host._gridPitch); const cx = shape.x * NORM_W + oldW / 2, cy = shape.y * this.host._decorH + oldH / 2; const angle = normalizeAngle(d.angle); return { id: shape.id, kind: 'image' as const, asset_id: d.assetId || shape.asset_id, x: clampCanvasN((cx - w / 2) / NORM_W), y: clampCanvasN((cy - h / 2) / this.host._decorH), w: w / NORM_W, h: h / this.host._decorH, opacity: clamp01(d.opacity, 1), ...(furnitureWcm! < 0 ? { flip_h: true } : {}), ...(furnitureHcm! < 0 ? { flip_v: true } : {}), ...(angle ? { angle } : {}), } as DecorShape; } if (shape.kind === 'rect' || shape.kind === 'ellipse') { const oldW = shape.w * NORM_W, oldH = shape.h * this.host._decorH; const w = Math.max(this.host._gridPitch, snapToGrid( decorCmToUnits(Number(d.sizeWCm), this.host._cellCm, this.host._gridPitch), this.host._gridPitch, )); const h = Math.max(this.host._gridPitch, snapToGrid( decorCmToUnits(Number(d.sizeHCm), this.host._cellCm, this.host._gridPitch), this.host._gridPitch, )); const cx = shape.x * NORM_W + oldW / 2, cy = shape.y * this.host._decorH + oldH / 2; const angle = normalizeAngle(d.angle); const topLeft = resizedBoxTopLeft( { x: cx - w / 2, y: cy - h / 2 }, angle, (point) => this._snap(point), ); const { width: _legacyWidth, angle: _oldAngle, ...rest } = shape; return { ...rest, ...visual, x: clampCanvasN(topLeft[0] / NORM_W), y: clampCanvasN(topLeft[1] / this.host._decorH), w: w / NORM_W, h: h / this.host._decorH, ...(angle ? { angle } : {}), } as DecorShape; } return shape; }); if (d.kind !== 'image') this._updateDecorStyle({ ...style, fill: d.kind === 'rect' || d.kind === 'ellipse' ? style.fill : this.host._decorStyle.fill, fillColor: d.kind === 'rect' || d.kind === 'ellipse' ? style.fillColor : this.host._decorStyle.fillColor, fillOpacity: d.kind === 'rect' || d.kind === 'ellipse' ? style.fillOpacity : this.host._decorStyle.fillOpacity, }); this.host._decorShapeDialog = null; this._recordGeometry(this.host._t('history.decor_edit'), before); this._saveConfig(); this.host.requestUpdate(); } public _dtPivot(sh: DecorShape): number[] { if (sh.kind === 'line') return [ ((sh.x1 + sh.x2) / 2) * NORM_W, ((sh.y1 + sh.y2) / 2) * this.host._decorH, ]; if (sh.kind === 'furniture' || sh.kind === 'image' || sh.kind === 'rect' || sh.kind === 'ellipse') return [(sh.x + sh.w / 2) * NORM_W, (sh.y + sh.h / 2) * this.host._decorH]; return [sh.x * NORM_W, sh.y * this.host._decorH]; } public _dtApply(id: string, patch: { textSizeCm?: number; angle?: number }): void { const sp = this.host._curSpaceCfg; if (!sp) return; sp.decor = this.host._decorList.map((x) => { if (x.id !== id) return x; const rest: any = { ...x }; if (x.kind === 'text' && patch.textSizeCm !== undefined) { delete rest.size; delete rest.scale; } const out: any = { ...rest }; if (patch.textSizeCm !== undefined) out.size_cm = Number(Math.max(0.1, Math.min(DECOR_TEXT_CM_MAX, patch.textSizeCm)).toFixed(4)); if (patch.angle !== undefined) { if (patch.angle) out.angle = Number(patch.angle.toFixed(2)); else delete out.angle; // straight again = the field goes away } return out; }); this.host._cfgEpoch++; this.host.requestUpdate(); } public _dtStart( ev: PointerEvent, kind: 'scale' | 'rotate', corner?: number[], lineEnd?: 0 | 1, ): void { const sh = this.host._dtSel; if (!sh) return; ev.stopPropagation(); ev.preventDefault(); const [ax, ay] = this._dtPivot(sh); const p = this._svgPoint(ev); const box = this.host._decorBoxOf(sh); this.host._dtDrag = { id: sh.id, kind, pid: ev.pointerId, ax, ay, r0: Math.hypot(p[0] - ax, p[1] - ay), a0: (Math.atan2(p[1] - ay, p[0] - ax) * 180) / Math.PI, textSizeCm0: sh.kind === 'text' ? this.host._decorTextSizeCm(sh) : 1, angle0: sh.kind === 'line' ? 0 : Number(sh.angle) || 0, // which corner is being pulled, and the box it started from: a piece of // furniture is resized about the OPPOSITE corner, so both have to be // remembered — a label, scaled about its anchor, needs neither sgx: corner?.[0], sgy: corner?.[1], orig: box || undefined, origShape: JSON.parse(JSON.stringify(sh)), before: this._geometrySnapshot(), lineEnd, moved: false, }; capturePointer(ev); } public _dtMove(ev: PointerEvent): void { const d = this.host._dtDrag; if (!d) return; const p = this._svgPoint(ev); if (d.lineEnd !== undefined && d.origShape.kind === 'line') { const at = this._decorSnap(p, ev.pointerType, d.id); const nx = clampCanvasN(at[0] / NORM_W), ny = clampCanvasN(at[1] / this.host._decorH); const old = d.origShape; const ox = d.lineEnd === 0 ? old.x1 : old.x2; const oy = d.lineEnd === 0 ? old.y1 : old.y2; if (Math.abs(nx - ox) > 1e-9 || Math.abs(ny - oy) > 1e-9) d.moved = true; this._replaceDecor(d.id, d.lineEnd === 0 ? { x1: nx, y1: ny } : { x2: nx, y2: ny }); return; } if (d.kind === 'scale' && d.orig) { if (d.origShape.kind === 'furniture' || d.origShape.kind === 'image') { const box = resizeFurnitureTransform( { ...d.orig, flip_h: d.origShape.flip_h, flip_v: d.origShape.flip_v }, d.sgx ?? 1, d.sgy ?? 1, p[0], p[1], !ev.shiftKey, decorCmToUnits(0.1, this.host._cellCm, this.host._gridPitch), ); const changed = Math.abs(box.x - d.orig.x) > 1e-9 || Math.abs(box.y - d.orig.y) > 1e-9 || Math.abs(box.w - d.orig.w) > 1e-9 || Math.abs(box.h - d.orig.h) > 1e-9 || !!box.flip_h !== !!d.origShape.flip_h || !!box.flip_v !== !!d.origShape.flip_v; if (!changed && !d.moved) return; d.moved ||= changed; this._decorApplyFurnitureBox(d.id, box); return; } // Box geometry preserves ratio by default and separates axes with Shift. // Each dimension lands on a whole cell. const box = resizeDecorBox( d.orig, d.sgx ?? 1, d.sgy ?? 1, p[0], p[1], !ev.shiftKey, this.host._gridPitch, this.host._gridPitch, ); const changed = Math.abs(box.x - d.orig.x) > 1e-6 || Math.abs(box.y - d.orig.y) > 1e-6 || Math.abs(box.w - d.orig.w) > 1e-6 || Math.abs(box.h - d.orig.h) > 1e-6; if (!changed && !d.moved) return; d.moved ||= changed; this._decorApplyBox(d.id, box); return; } if (d.kind === 'scale') { // uniform, about the anchor: the distance from the anchor is invariant // under the block's own rotation, so a rotated block scales the same way const r = Math.hypot(p[0] - d.ax, p[1] - d.ay); if (d.r0 < 1e-6) return; const sizeCm = Math.max(0.1, Math.min(DECOR_TEXT_CM_MAX, d.textSizeCm0 * (r / d.r0))); const changed = Math.abs(sizeCm - d.textSizeCm0) > 1e-6; if (!changed && !d.moved) return; d.moved ||= changed; this._dtApply(d.id, { textSizeCm: sizeCm }); return; } const a = (Math.atan2(p[1] - d.ay, p[0] - d.ax) * 180) / Math.PI; let ang: number; if (d.origShape.kind === 'furniture' || d.origShape.kind === 'image') { ang = furnitureRotationAngle(d.angle0, d.a0, a, ev.shiftKey); } else { ang = d.angle0 + (a - d.a0); // Existing non-furniture contract: 5° normally, Shift = free. if (!ev.shiftKey) ang = Math.round(ang / DT_ANGLE_STEP) * DT_ANGLE_STEP; ang = ((ang % 360) + 360) % 360; if (ang > 180) ang -= 360; } const changed = Math.abs(ang - d.angle0) > 1e-6; if (!changed && !d.moved) return; d.moved ||= changed; this._dtApply(d.id, { angle: ang }); } public _dtUp(): void { const d = this.host._dtDrag; this.host._dtDrag = null; if (d?.moved) { this._recordGeometry(this.host._t('history.decor_transform'), d.before); this._saveConfig(); } this.host.requestUpdate(); } public _dtMeasure(): void { measureHouseplanDecorText(this.host); } public _deleteDecor(id: string): void { if (!this.host._decorList.some((shape) => shape.id === id)) return; const before = this._geometrySnapshot(); const sp = this.host._curSpaceCfg; sp.decor = this.host._decorList.filter((shape) => shape.id !== id); if (this.host._decorSel === id) this.host._decorSel = null; this._recordGeometry(this.host._t('history.decor_delete'), before); this._saveConfig(); this.host.requestUpdate(); } public _decorDeleteSel(): void { if (this.host._decorSel) this._deleteDecor(this.host._decorSel); } public _confirmDecorErase(): void { const pending = this.host._decorEraseConfirm; this.host._decorEraseConfirm = null; if (pending) this._deleteDecor(pending.id); } public _furnFieldValue(cm: number): number { return Math.round((this.host._imperial ? cm / 30.48 : cm / 100) * 100) / 100; } public _furnFieldToCm(v: number): number { return clampFurnCm(this.host._imperial ? v * 30.48 : v * 100); } /** #369(д): the preview must follow Shift even without mouse movement. */ private _furnShiftListener = (ev: KeyboardEvent): void => { if (ev.key !== 'Shift') return; const input = this.host._furnPreviewInput; if (!input) return; const free = ev.type === 'keydown'; if (input.free === free) return; this.host._furnPreviewInput = { ...input, free }; this.host.requestUpdate(); }; private _furnShiftAttached = false; private _furnShiftAttach(): void { if (this._furnShiftAttached) return; this._furnShiftAttached = true; window.addEventListener('keydown', this._furnShiftListener); window.addEventListener('keyup', this._furnShiftListener); } public _furnShiftDetach(): void { if (!this._furnShiftAttached) return; this._furnShiftAttached = false; window.removeEventListener('keydown', this._furnShiftListener); window.removeEventListener('keyup', this._furnShiftListener); } public _furnPick(symbol: string): void { const d = furnitureDefaultCm(symbol); this.host._furnPalette = { symbol, w: d.w, h: d.h }; this._furnShiftAttach(); } /** Cached room-facing surfaces plus already-physical independent body faces. */ private get _furnWalls(): readonly FurnitureWallSurface[] { return furnitureWallSurfacesFor(this.host); } public _resolveFurniturePlacement( raw: number[], free = false, pointerType = 'mouse', ): FurniturePlacement | null { const pal = this.host._furnPalette; const sp = this.host._curSpaceCfg; if (!pal || !sp) return null; const W = NORM_W, H = this.host._decorH; const snapped = this._decorSnap(raw, pointerType); return resolveFurniturePlacement({ symbol: pal.symbol, widthCm: pal.w, depthCm: pal.h, point: [snapped[0], snapped[1]], canvasW: W, canvasH: H, cellCm: this.host._cellCm, gridPitch: this.host._gridPitch, walls: this._furnWalls, wallReach: this.host._gridPitch * FURN_WALL_CELLS, intentPoint: [raw[0], raw[1]], free, }); } public _furniturePreviewPlacement(): FurniturePlacement | null { const input = this.host._furnPreviewInput; if (!input || this.host._mode !== 'decor' || this.host._decorTool !== 'furniture' || !this.host._furnPalette || !this.host._pointerModality.hoverEnabled) return null; return this._resolveFurniturePlacement(input.raw, input.free, 'mouse'); } /** One real furniture path, in the same decor composition group as saved * shapes. It paints after them and the whole decor layer remains below walls. */ public _renderFurniturePlacementPreview( furnitureScreenScale: number, ): TemplateResult | typeof nothing { const placement = this._furniturePreviewPlacement(); if (!placement) return nothing; const art = furnitureGraphic(placement.symbol); if (!art) return nothing; const transform = furnitureRenderTransform( placement, NORM_W, this.host._decorH, art.viewW, art.viewH, ); const style = this.host._decorStyle; const strokeWidth = furnitureStrokePx( decorCmToUnits(style.widthCm, this.host._cellCm, this.host._gridPitch), furnitureScreenScale, ); return svg``; } /** Live width/depth labels for a furniture corner resize. */ public _furnLive(): { x: number; y: number; text: string }[] | null { const drag = this.host._dtDrag; if (!drag || drag.kind !== 'scale' || !drag.orig) return null; const shape = this.host._decorList.find((item) => item.id === drag.id); if (!shape || shape.kind !== 'furniture') return null; const w = shape.w * NORM_W; const h = shape.h * this.host._decorH; const corners = furnitureCorners( shape.x * NORM_W, shape.y * this.host._decorH, w, h, Number(shape.angle) || 0, ); const mid = (a: number[], b: number[]) => [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2]; const top = mid(corners[0], corners[1]); const left = mid(corners[0], corners[3]); return [ { x: top[0], y: top[1], text: this.host._fmtLen([0, 0], [w, 0]) }, { x: left[0], y: left[1], text: this.host._fmtLen([0, 0], [0, h]) }, ]; } public _furnPlace(raw: number[], free = false, pointerType = 'mouse'): void { const placement = this._resolveFurniturePlacement(raw, free, pointerType); if (!placement) return; const sp = this.host._curSpaceCfg; if (!sp) return; const before = this._geometrySnapshot(); const id = 'df' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5); const shape: any = { id, kind: 'furniture', symbol: placement.symbol, x: placement.x, y: placement.y, w: placement.w, h: placement.h, ...decorStylePatch(this.host._decorStyle, false), }; // a straight piece stores no angle at all, exactly as a straight label // stores none (docs/DECOR-EDITOR.md §5.3) if (placement.angle) shape.angle = placement.angle; sp.decor = [...this.host._decorList, shape]; this.host._decorSel = id; // …and the editor goes back to the tool that can move what was just placed this.host._decorTool = 'select'; this.host._furnPalette = null; this.host._furnCategory = null; this._furnShiftDetach(); this._clearFurniturePreview(); this._recordGeometry(this.host._t('history.decor_add'), before); this._saveConfig(); this.host.requestUpdate(); } public _clearFurniturePreview(): void { this.host._furnPreviewInput = null; this.host._furnTouchPending = null; } /** Track only real fine-pointer hover. Touch/pen own a delayed one-tap * transaction below and must never resurrect a compatibility mouse ghost. */ public _furnPointerMove(ev: PointerEvent, hoverAllowed: boolean): boolean { const pending = this.host._furnTouchPending; if (pending?.pid === ev.pointerId) { if (Math.abs(ev.clientX - pending.sx) + Math.abs(ev.clientY - pending.sy) > 8) { pending.cancelled = true; } this.host._furnPreviewInput = null; return true; } const armed = this.host._decorTool === 'furniture' ? !!this.host._furnPalette : this.host._decorTool === 'image' && !!this.host._decorImagePalette; if (this.host._mode !== 'decor' || !armed) { this._clearFurniturePreview(); return false; } if (ev.pointerType !== 'mouse' || !hoverAllowed) { this.host._furnPreviewInput = null; return false; } const raw = this._svgPoint(ev); this.host._furnPreviewInput = { raw: [raw[0], raw[1]], free: ev.shiftKey }; return true; } public _furnPointerLeave(ev: PointerEvent): void { if (this.host._furnTouchPending?.pid === ev.pointerId) { this.host._furnTouchPending.cancelled = true; } this.host._furnPreviewInput = null; } public _furnPointerUp(ev: PointerEvent): boolean { const pending = this.host._furnTouchPending; if (!pending || pending.pid !== ev.pointerId) return false; this.host._furnTouchPending = null; if (!pending.cancelled && this.host._mode === 'decor' && ((this.host._decorTool === 'furniture' && this.host._furnPalette) || (this.host._decorTool === 'image' && this.host._decorImagePalette))) { if (this.host._decorTool === 'furniture') this._furnPlace(this._svgPoint(ev), ev.shiftKey, pending.pointerType); else this._decorImagePlace(this._svgPoint(ev), pending.pointerType); } return true; } public _furnMoveUpdate(ev: PointerEvent): void { const m = this.host._decorMove; if (!m) return; if (m.orig.kind !== 'furniture') return; const o: any = m.orig; const sp = this.host._curSpaceCfg; if (!sp) return; const W = NORM_W, H = this.host._decorH; const p = this._svgPoint(ev); const rawCx = (o.x + o.w / 2) * W + (p[0] - m.start[0]); const rawCy = (o.y + o.h / 2) * H + (p[1] - m.start[1]); let x: number, y: number; let angle = Number(o.angle) || 0; const angleRad = angle * Math.PI / 180, preferredNormal: [number, number] = [-Math.sin(angleRad), Math.cos(angleRad)]; const snap = ev.shiftKey ? null : snapFurnitureToWall( rawCx, rawCy, o.h * H, this._furnWalls, this.host._gridPitch * FURN_WALL_CELLS, this.host._gridPitch, [rawCx, rawCy], preferredNormal); if (snap) { x = snap.cx / W - o.w / 2; y = snap.cy / H - o.h / 2; angle = snap.angle; } else { const a = this._decorSnap( [rawCx - (o.w / 2) * W, rawCy - (o.h / 2) * H], ev.pointerType, m.id, ); x = a[0] / W; y = a[1] / H; } x = clampCanvasN(x); y = clampCanvasN(y); if (Math.abs(x - o.x) > 1e-9 || Math.abs(y - o.y) > 1e-9 || Math.abs(angle - (Number(o.angle) || 0)) > 1e-9) m.moved = true; sp.decor = this.host._decorList.map((s) => { if (s.id !== m.id) return s; const out: any = { ...s, x, y }; if (angle) out.angle = Number(angle.toFixed(2)); else delete out.angle; return out; }); this.host.requestUpdate(); } public _decorApplyBox(id: string, box: { x: number; y: number; w: number; h: number }): void { const sp = this.host._curSpaceCfg; if (!sp) return; const W = NORM_W, H = this.host._decorH; sp.decor = this.host._decorList.map((s) => { if (s.id !== id) return s; const topLeft = resizedBoxTopLeft( box, (s as any).angle, (point) => this._snap(point), ); return { ...s, x: clampCanvasN(topLeft[0] / W), y: clampCanvasN(topLeft[1] / H), w: Math.max(this.host._gridPitch / W, Math.min(CANVAS_LIMIT * 2, box.w / W)), h: Math.max(this.host._gridPitch / H, Math.min(CANVAS_LIMIT * 2, box.h / H)), } as DecorShape; }); this.host._cfgEpoch++; this.host.requestUpdate(); } public _decorApplyFurnitureBox(id: string, box: FurnitureResizeResult): void { const sp = this.host._curSpaceCfg; if (!sp) return; const W = NORM_W, H = this.host._decorH; const minUnits = decorCmToUnits(0.1, this.host._cellCm, this.host._gridPitch); sp.decor = this.host._decorList.map((shape) => { if (shape.id !== id || (shape.kind !== 'furniture' && shape.kind !== 'image')) return shape; const { flip_h: _oldFlipH, flip_v: _oldFlipV, ...rest } = shape; return { ...rest, x: clampCanvasN(box.x / W), y: clampCanvasN(box.y / H), w: Math.max(minUnits / W, Math.min(CANVAS_LIMIT * 2, box.w / W)), h: Math.max(minUnits / H, Math.min(CANVAS_LIMIT * 2, box.h / H)), ...(box.flip_h ? { flip_h: true } : {}), ...(box.flip_v ? { flip_v: true } : {}), } as DecorShape; }); this.host._cfgEpoch++; this.host.requestUpdate(); } public _decorImagePlace(raw: number[], pointerType = 'mouse'): void { this._decorImages.place(raw, pointerType); } public _renderDecorImagePlacementPreview(): TemplateResult | typeof nothing { return this._decorImages.renderPlacementPreview(); } public _renderMissingDecorImage( shape: Extract, cls: string, transform: string, x: number, y: number, w: number, h: number, down: (event: PointerEvent) => void, dbl: (event: MouseEvent) => void, ): TemplateResult { return this._decorImages.renderMissing(shape, cls, transform, x, y, w, h, down, dbl); } public async _decorImageUpload(ev: Event, replaceSelection = false): Promise { await this._decorImages.uploadFromInput(ev, replaceSelection); } private async _uploadDecorImage( file: Blob, name: string, replaceSelection: boolean, ): Promise { await this._decorImages.upload(file, name, replaceSelection); } public async _decorImageDelete(asset: DecorAsset): Promise { await this._decorImages.delete(asset); } private async _decorImageCatalogLoad(): Promise { await this._decorImages.loadCatalog(); } public _renderDecorImagePalette(): TemplateResult { return this._decorImages.renderImagePalette(); } public _renderFurnPalette(): TemplateResult { return this._decorImages.renderFurniturePalette(); } public _openBackdropDialog(ev?: Event): void { if (!this.host._bdMovable || !this.host._bdRect) return; ev?.preventDefault(); ev?.stopPropagation(); this.host._bdDrag = null; const r = this.host._bdRect; this.host._backdropDialog = { widthCm: decorUnitsToCm(r.w, this.host._cellCm, this.host._gridPitch), heightCm: decorUnitsToCm(r.h, this.host._cellCm, this.host._gridPitch), angle: this.host._angleField(r.angle), }; } public _saveBackdropDialog(): void { const d = this.host._backdropDialog; const base = this.host._bdBase, current = this.host._bdRect; if (!d || !base || !current) return; const angle = normalizeAngle(d.angle); const before = this._geometrySnapshot(); const w = Math.min(base.w * PLAN_SCALE_MAX, Math.max( base.w * PLAN_SCALE_MIN, snapToGrid( decorCmToUnits(d.widthCm, this.host._cellCm, this.host._gridPitch), this.host._gridPitch, ), )); const h = Math.min(base.h * PLAN_SCALE_MAX, Math.max( base.h * PLAN_SCALE_MIN, snapToGrid( decorCmToUnits(d.heightCm, this.host._cellCm, this.host._gridPitch), this.host._gridPitch, ), )); const cx = current.x + current.w / 2, cy = current.y + current.h / 2; const topLeft = resizedBoxTopLeft( { x: cx - w / 2, y: cy - h / 2 }, angle, (point) => this._snap(point), ); this._bdApply((topLeft[0] - base.x) / NORM_W, (topLeft[1] - base.y) / NORM_W, w / base.w, h / base.h, angle); this.host._backdropDialog = null; this._recordGeometry(this.host._t('history.backdrop_transform'), before); this._saveConfig(); } public _bdApply(dx: number, dy: number, sx: number, sy: number, angle: number): void { const sp = this.host._curSpaceCfg; if (!sp) return; // 6 decimals: the same precision the rest of the normalised config keeps, // and enough for a 1000-unit canvas to be exact to a thousandth of a pixel sp.plan_x = Number(clampCanvasN(dx).toFixed(6)); sp.plan_y = Number(clampCanvasN(dy).toFixed(6)); delete sp.plan_scale; sp.plan_scale_x = Number(Math.min(PLAN_SCALE_MAX, Math.max(PLAN_SCALE_MIN, sx)).toFixed(6)); sp.plan_scale_y = Number(Math.min(PLAN_SCALE_MAX, Math.max(PLAN_SCALE_MIN, sy)).toFixed(6)); const a = normalizeAngle(angle); if (a) sp.plan_angle = Number(a.toFixed(2)); else delete sp.plan_angle; this.host._cfgEpoch++; this.host.requestUpdate(); } public _bdStart(ev: PointerEvent, corner?: number[], rotate = false): boolean { const base = this.host._bdBase, r = this.host._bdRect; if (!base || !r) return false; const p = this._svgPoint(ev); const sgx = corner ? corner[0] : 0; const sgy = corner ? corner[1] : 0; // the corner that stays put is the OPPOSITE one const fx = sgx > 0 ? r.x : r.x + r.w; const fy = sgy > 0 ? r.y : r.y + r.h; this.host._bdDrag = { kind: rotate ? 'rotate' : corner ? 'scale' : 'move', pid: ev.pointerId, sx: p[0], sy: p[1], base, p0: this.host._bdParams, fx, fy, sgx, sgy, rect0: { x: r.x, y: r.y, w: r.w, h: r.h, angle: r.angle }, before: this._geometrySnapshot(), moved: false, }; capturePointer(ev); return true; } public _bdMove(ev: PointerEvent): void { queueHouseplanPointerMove(this.host, 'backdrop', () => this._bdMoveNow(ev)); } private _bdMoveNow(ev: PointerEvent): void { const d = this.host._bdDrag; if (!d) return; const p = this._svgPoint(ev); const b = d.base; if (d.kind === 'move') { const x0 = d.rect0.x, y0 = d.rect0.y; const at = this._snap([x0 + (p[0] - d.sx), y0 + (p[1] - d.sy)]); const changed = Math.abs(at[0] - x0) > 1e-9 || Math.abs(at[1] - y0) > 1e-9; if (!changed && !d.moved) return; d.moved ||= changed; this._bdApply((at[0] - b.x) / NORM_W, (at[1] - b.y) / NORM_W, d.p0.sx, d.p0.sy, d.p0.angle); return; } if (d.kind === 'rotate') { const cx = d.rect0.x + d.rect0.w / 2, cy = d.rect0.y + d.rect0.h / 2; const a0 = Math.atan2(d.sy - cy, d.sx - cx) * 180 / Math.PI; let angle = d.p0.angle + (Math.atan2(p[1] - cy, p[0] - cx) * 180 / Math.PI - a0); if (!ev.shiftKey) angle = Math.round(angle / DT_ANGLE_STEP) * DT_ANGLE_STEP; angle = normalizeAngle(angle); const changed = Math.abs(angle - d.p0.angle) > 1e-9; if (!changed && !d.moved) return; d.moved ||= changed; this._bdApply(d.p0.dx, d.p0.dy, d.p0.sx, d.p0.sy, angle); return; } const box = resizeDecorBox( d.rect0, d.sgx, d.sgy, p[0], p[1], !ev.shiftKey, this.host._gridPitch, Math.min(b.w, b.h) * PLAN_SCALE_MIN, ); const sx = box.w / Math.max(1e-9, b.w), sy = box.h / Math.max(1e-9, b.h); const changed = Math.abs(sx - d.p0.sx) > 1e-9 || Math.abs(sy - d.p0.sy) > 1e-9 || Math.abs(box.x - d.rect0.x) > 1e-9 || Math.abs(box.y - d.rect0.y) > 1e-9; if (!changed && !d.moved) return; d.moved ||= changed; const topLeft = resizedBoxTopLeft( box, d.p0.angle, (point) => this._snap(point), ); this._bdApply((topLeft[0] - b.x) / NORM_W, (topLeft[1] - b.y) / NORM_W, sx, sy, d.p0.angle); } public _bdReset(): void { const sp = this.host._curSpaceCfg; if (!sp) return; const before = this._geometrySnapshot(); delete sp.plan_x; delete sp.plan_y; delete sp.plan_scale; delete sp.plan_scale_x; delete sp.plan_scale_y; delete sp.plan_angle; this.host._bdDrag = null; this._recordGeometry(this.host._t('history.backdrop_transform'), before); this._saveConfig(); this.host._showToast(this.host._t('decor.backdrop_reset_done')); this.host.requestUpdate(); } public _bdUp(): void { flushHouseplanPointerMove(this.host, 'backdrop'); const d = this.host._bdDrag; this.host._bdDrag = null; if (d?.moved) { this._recordGeometry(this.host._t('history.backdrop_transform'), d.before); this._saveConfig(); } this.host.requestUpdate(); } public _renderBackdropFrame(view: { x: number; y: number; w: number; h: number }): TemplateResult | typeof nothing { const r = this.host._bdRect; if (!this.host._bdActive || !r) return nothing; // Two radii, one gesture — the split the text frame uses (docs/DECOR-EDITOR.md §5 // §3) and, since 2026-08-05, every corner handle in the card. `hr` is the // HIT radius: a fraction of the visible view, so the target stays // finger-sized at any zoom. `kr` is what you SEE — a quarter of it, because // a blob the size of a room is not a handle, it is an occlusion (owner: // «уменьшить в 4 раза… они постоянно гигантские»). The clickable area is // unchanged; only the ink shrank. const hr = Math.max(view.w, view.h) * 0.02; const kr = hr / 4; const cx = r.x + r.w / 2, cy = r.y + r.h / 2; const angle = normalizeAngle(r.angle); const arm = hr * 2.2; const corners: [number, number, string][] = [ [-1, -1, 'nwse'], [1, -1, 'nesw'], [1, 1, 'nwse'], [-1, 1, 'nesw'], ]; return svg` { e.stopPropagation(); e.preventDefault(); this._bdStart(e, undefined, true); }}> ${corners.map(([sx, sy, cur]) => { const cx = sx < 0 ? r.x : r.x + r.w; const cy = sy < 0 ? r.y : r.y + r.h; return svg` { e.stopPropagation(); e.preventDefault(); this._bdStart(e, [sx, sy]); }}>`; })} ` as unknown as TemplateResult; } public _renderEditorGroupLauncher(group: EditorToolbarGroup): TemplateResult { return this.host._editorSecondary.renderGroupLauncher( group, this.host._editorToolbarGroups, this.host._editorSecondaryCopy, ); } public _runEditorContext(contextId: string, action: () => T): T | undefined { return this.host._editorSecondary.runContext( contextId, this.host._editorSecondaryContextId, action, ); } public _renderEditorGroupModel(group: EditorToolbarGroup): EditorSecondaryModel { return this.host._editorSecondary.renderGroupModel( group, this.host._editorSecondaryContextId, this.host._editorSecondaryCopy, ); } public _renderDrawWallControl(): TemplateResult { const min = this.host._tool === 'column' ? 1 : 0; return html``; } public _renderPlanSecondary(): EditorSecondaryModel | null { const contextId = this.host._editorSecondaryContextId; const sel = this.host._physicalSel; if (sel) { const exists = sel.kind === 'partition' ? !!this.host._curSpaceCfg.partitions?.some((item: PartitionCfg) => item.id === sel.id) : !!this.host._curSpaceCfg.wall_columns?.some((item: WallColumnCfg) => item.id === sel.id); if (!exists) return null; const label = sel.kind === 'partition' ? this.host._t('markup.partition') : sel.kind === 'column' ? this.host._t('markup.column') : this.host._t('markup.add'); return { contextId, kind: 'selection', ariaLabel: this.host._t('editor.context_actions', { object: label }), visibleLabel: label, content: html` `, }; } const hasThickness = this.host._tool === 'draw' || this.host._tool === 'column'; const hintKey = this.host._tool === 'column' ? 'markup.hint_column' : this.host._tool === 'resize' ? 'markup.hint_resize' : this.host._tool === 'wallthick' ? 'markup.hint_wallthick' : null; const drawHint = this.host._tool === 'draw' ? this.host._t(this.host._path.length ? 'markup.hint_points' : 'markup.hint_start', this.host._path.length ? { n: this.host._path.length } : undefined) : ''; if (!hasThickness && !hintKey && !drawHint) return null; const operation = this.host._tool === 'draw' && this.host._path.length > 0; return { contextId, kind: operation ? 'operation' : 'tool', ariaLabel: this.host._t('editor.tool_options', { tool: this.host._t((this.host._tool === 'draw' ? 'markup.add' : this.host._tool === 'column' ? 'markup.column' : this.host._tool === 'resize' ? 'markup.resize' : 'markup.wallthick') as any), }), content: html` ${hasThickness ? this._renderDrawWallControl() : nothing} ${drawHint ? html`${drawHint}` : nothing} ${hintKey ? html`${this.host._t(hintKey as any)}` : nothing} ${this.host._tool === 'draw' && this.host._path.length ? html`` : nothing}`, }; } public _renderDecorSecondary(): EditorSecondaryModel | null { const contextId = this.host._editorSecondaryContextId; if (this.host._decorTool === 'furniture') { return { contextId, kind: 'palette', ariaLabel: this.host._t('editor.palette', { tool: this.host._t('decor.furniture') }), launcherId: 'furniture', dismissPolicy: this.host._furnPalette ? 'stay-open-on-canvas' : 'outside', dismiss: () => { if (this.host._decorTool !== 'furniture') return; this._clearFurniturePreview(); this.host._furnPalette = null; this._furnShiftDetach(); this.host._furnCategory = null; this.host._decorTool = 'select'; this.host.requestUpdate(); }, content: this._renderFurnPalette(), }; } if (this.host._decorTool === 'image') { return { contextId, kind: 'palette', ariaLabel: this.host._t('editor.palette', { tool: this.host._t('decor.image') }), launcherId: 'image', dismissPolicy: this.host._decorImagePalette ? 'stay-open-on-canvas' : 'outside', dismiss: () => { if (this.host._decorTool !== 'image') return; this._clearFurniturePreview(); this.host._decorImagePalette = null; this.host._decorTool = 'select'; this.host.requestUpdate(); }, content: this._renderDecorImagePalette(), }; } const selected = this.host._dtSel; if (selected) { const label = this.host._t((`decor.${selected.kind}`) as any); return { contextId, kind: 'selection', ariaLabel: this.host._t('editor.context_actions', { object: label }), visibleLabel: label, content: html` `, }; } const draws = this.host._decorTool === 'line' || this.host._decorTool === 'rect' || this.host._decorTool === 'ellipse'; const canFill = this.host._decorTool === 'rect' || this.host._decorTool === 'ellipse'; const backdrop = this.host._decorTool === 'backdrop' && !!this.host._bdRect; if (!draws && !backdrop) return null; return { contextId, kind: 'tool', ariaLabel: this.host._t('editor.tool_options', { tool: this.host._t((draws ? `decor.${this.host._decorTool}` : 'decor.backdrop') as any), }), content: html` ${draws ? html` ) => this._updateDecorStyle({ ...this.host._decorStyle, ...e.detail })}> ${canFill ? html` ) => this._updateDecorStyle({ ...this.host._decorStyle, fillColor: e.detail.color, fillOpacity: e.detail.opacity })}>` : nothing} ` : nothing} ${backdrop ? html`${this.host._t('decor.backdrop_hint')}` : nothing}`, }; } public _withBackdropReset(model: EditorSecondaryModel | null): EditorSecondaryModel | null { if (!this.host._bdMoved || this.host._bdDrag || model?.kind === 'palette') return model; const contextId = this.host._editorSecondaryContextId; const reset = html``; if (!model) { return { contextId, kind: 'tool', ariaLabel: this.host._t('editor.tool_options', { tool: this.host._t('decor.backdrop') }), content: reset, }; } return { ...model, kind: model.kind === 'selection' ? 'mixed' : model.kind, content: html`${model.content}${reset}`, }; } public _renderEditorSecondary(): TemplateResult | typeof nothing { if (!this.host._editing) return nothing; const group = this.host._editorSecondary.activeGroup(this.host._editorToolbarGroups); const model = group ? this._renderEditorGroupModel(group) : this.host._mode === 'plan' ? this._renderPlanSecondary() : this.host._mode === 'decor' ? this._withBackdropReset(this._renderDecorSecondary()) : null; return this.host._editorSecondary.render(model, this.host._editorSecondaryDialogBlocked); } public _renderDecorBar(): TemplateResult { const tools = [ ['select', 'mdi:cursor-default-outline', 'decor.select'], // moving the picture is a TOOL (docs/DECOR-EDITOR.md §3.2) — offered only when // there IS a picture, so a hand-drawn space's bar is unchanged ...(this.host._bdRect ? [['backdrop', 'mdi:image-move', 'decor.backdrop'] as const] : []), ['line', 'mdi:vector-line', 'decor.line'], ['rect', 'mdi:rectangle-outline', 'decor.rect'], ['ellipse', 'mdi:ellipse-outline', 'decor.ellipse'], ['text', 'mdi:format-text', 'decor.text'], // the library sits next to the shapes it belongs with (docs/FURNITURE.md) ['furniture', 'mdi:sofa-outline', 'decor.furniture'], ...(this.host._haDecorAssetsApi === DECOR_ASSETS_API_VERSION ? [['image', 'mdi:image-plus-outline', 'decor.image'] as const] : []), ['erase', 'mdi:eraser', 'decor.erase'], ] as const; const undoName = this.host._geometryHistory.undoName; const redoName = this.host._geometryHistory.redoName; return html`
${tools.map( ([t, ic, k]) => html``, )} ${this.host._editorToolbarGroups.map((group) => this._renderEditorGroupLauncher(group))} ) => this._updateDecorStyle({ ...this.host._decorStyle, ...e.detail })}>
`; } public _renderDecorEraseConfirm(): TemplateResult { const pending = this.host._decorEraseConfirm!; const kind = this.host._t(`decor.${pending.kind}` as any); return html` (this.host._decorEraseConfirm = null)}>

${this.host._t('confirm.erase_decor', { kind })}

`; } public _renderDecorTextDialog(): TemplateResult { const d = this.host._decorTextDialog!; const ent = (d.pickerEntity || '').trim(); const st = ent ? this.host.hass?.states?.[ent] : null; return html` (this.host._decorTextDialog = null)}>
${''/* a textarea, not an input: the user's own line breaks are kept and rendered (centred). Enter is a NEW LINE here, so saving moved to Ctrl/Cmd+Enter and the button. */} ) => (this.host._decorTextDialog = { ...d, ...e.detail })}>
(this.host._decorTextDialog = { ...d, sizeCm: this.host._decorTextCm(Number((e.target as HTMLInputElement).value)) })} /> ${this.host._t(this.host._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm')}
(this.host._decorTextDialog = { ...d, angle: (e.target as HTMLInputElement).value })} /> (this.host._decorTextDialog = { ...d, pickerEntity: (e.target as HTMLInputElement).value, })} /> ${Object.keys(this.host.hass?.states || {}).map((id) => html``)} ${ent ? html` ` : nothing}
`; } public _renderDecorShapeDialog(): TemplateResult { return this._decorImages.renderShapeDialog(); } public _renderBackdropDialog(): TemplateResult { const d = this.host._backdropDialog!; const unit = this.host._t(this.host._imperial ? 'gs.unit_ft' : 'gs.unit_m'); return html` (this.host._backdropDialog = null)}>
(this.host._backdropDialog = { ...d, widthCm: this.host._decorLargeCm(Number((e.target as HTMLInputElement).value)) })} /> × (this.host._backdropDialog = { ...d, heightCm: this.host._decorLargeCm(Number((e.target as HTMLInputElement).value)) })} /> ${unit}
(this.host._backdropDialog = { ...d, angle: (e.target as HTMLInputElement).value })} />
`; } public _cssPxToRender(px: number): number { const stage = this.host._stageEl; const view = this.host._viewOr(this.host._baseVb()); if (!stage?.clientWidth || !stage.clientHeight) return (this.host._gridPitch / 8) * px; return Math.max(view.w / stage.clientWidth, view.h / stage.clientHeight) * px; } public _deleteRoomClick(raw: number[]): void { const space = this.host._spaceModel(); if (!space) return; const room = [...space.rooms].reverse().find((r) => this._pointInRoom(raw, r)); if (!room) { this.host._showToast(this.host._t('toast.delete_room_pick')); return; } if (!room.id) return; this.host._roomDeleteDialog = { roomId: room.id, name: room.name }; } public _confirmRoomDelete = (keepWalls: boolean): void => { const dialog = this.host._roomDeleteDialog; const sp = this.host._curSpaceCfg; const space = this.host._spaceModel(); if (!dialog || !sp || !space) return; const room = space.rooms.find((candidate) => candidate.id === dialog.roomId); if (!room) { this.host._roomDeleteDialog = null; return; } const openCuts = this.host._openCuts(); const allIntervals = wallIntervals( space.rooms, this.host._spaceWalls, openCuts, this.host._wallKeyPitch, this.host._cellCm, this.host._gridPitch, NORM_W, ); const roomIntervals = allIntervals.filter((interval) => interval.roomId === room.id); const plan = planRoomDeletion( roomIntervals, space.partitions, this.host._openingsR.map((opening) => ({ ...opening, x: opening.rx, y: opening.ry, })), this.host._gridPitch * 0.02, ); const newCount = new Set(plan.materialize .filter((item) => !item.reusePartitionId) .map((item) => item.interval.key)).size; if (keepWalls && (sp.partitions || []).length + newCount > MAX_PARTITIONS) { this.host._showToast(this.host._t('toast.physical_limit')); return; } const before = this._geometrySnapshot(); const referenceRewrite = rewriteMarkerRoomReferences( this.host._serverCfg?.markers || [], { kind: 'delete', roomId: dialog.roomId }, ); if (before && referenceRewrite.changed) before.roomReferences = referenceRewrite.before; const materializedWalls = materializeWallIntervals( space.rooms, this.host._spaceWalls, openCuts, this.host._wallKeyPitch, this.host._cellCm, this.host._gridPitch, NORM_W, ); if (keepWalls) { sp.partitions ||= []; const targetByInterval = new Map(); const createdByKey = new Map(); const sourceWallId = (interval: { a: readonly number[]; b: readonly number[] }): string | null => { const match = (sp.wall_segments || []).find((segment: any) => { const a = [Number(segment.a?.[0]) * NORM_W, Number(segment.a?.[1]) * NORM_W]; const b = [Number(segment.b?.[0]) * NORM_W, Number(segment.b?.[1]) * NORM_W]; return (this._samePt(interval.a, a) && this._samePt(interval.b, b)) || (this._samePt(interval.a, b) && this._samePt(interval.b, a)); }); return typeof match?.id === 'string' && match.id ? match.id : null; }; const seed = Date.now().toString(36); for (const item of plan.materialize) { let target = item.reusePartitionId ? sp.partitions.find((partition: PartitionCfg) => partition.id === item.reusePartitionId) : createdByKey.get(item.interval.key); if (!target) { target = { id: sourceWallId(item.interval) || `partition-room-${seed}-${createdByKey.size}`, a: [item.interval.a[0] / NORM_W, item.interval.a[1] / NORM_W], b: [item.interval.b[0] / NORM_W, item.interval.b[1] / NORM_W], cm: item.interval.cm, }; sp.partitions.push(target); createdByKey.set(item.interval.key, target); } targetByInterval.set(item.interval.key, target); } for (const opening of sp.openings || []) { const intervalKey = plan.openingIntervals.get(opening.id); const target = intervalKey ? targetByInterval.get(intervalKey) : null; const rendered = this.host._openingsR.find((candidate) => candidate.id === opening.id); if (!target || !rendered) continue; const renderTarget = { a: [target.a[0] * NORM_W, target.a[1] * NORM_W], b: [target.b[0] * NORM_W, target.b[1] * NORM_W], }; opening.host = { kind: 'partition', id: target.id, t: parameterOnPartition([rendered.rx, rendered.ry], renderTarget), }; } } else if (plan.removeOpeningIds.length) { const remove = new Set(plan.removeOpeningIds); sp.openings = (sp.openings || []).filter((opening: OpeningCfg) => !remove.has(opening.id)); if (!sp.openings.length) delete sp.openings; } sp.rooms = sp.rooms.filter((r: any) => r.id !== room.id); if (referenceRewrite.changed && this.host._serverCfg) { this.host._serverCfg.markers = referenceRewrite.markers; } this.host._cfgEpoch++; const normalized = this.host._normalizeWalls(materializedWalls, this.host._openCuts()); if (normalized.length) sp.walls = normalized; else delete sp.walls; this.host._roomDeleteDialog = null; this._commitPhysicalGeometry(this.host._t( keepWalls ? 'history.delete_room_keep_walls' : 'history.delete_room_with_walls', ), before); this.host._regSignature = ''; this.host._maybeRebuildDevices(); this.host.requestUpdate(); }; public _wallThickHit(raw: number[]): WallThickHit | null { const space = this.host._spaceModel(); if (!space) return null; const pull = this.host._gridPitch * 6; const cuts = this.host._openCuts(); type Hit = WallThickHit; // #313: the tool serves every wall — room intervals AND independent // masonry. On an exact overlap (the #308 duplicate) the independent wall // wins: it owns the hit zone, exactly like the select tool, and it is // the body the eye actually sees. let best: { hit: Hit; d: number; independent: boolean } | null = null; const offer = (hit: Hit, d: number, independent: boolean): void => { if (d > pull) return; if (!best || d < best.d - 1e-9 || (independent && !best.independent && d <= best.d + 1e-9)) { best = { hit, d, independent }; } }; for (const iv of wallIntervals( space.rooms, this.host._spaceWalls, cuts, this.host._wallKeyPitch, this.host._cellCm, this.host._gridPitch, NORM_W, )) { offer({ a: iv.a, b: iv.b, roomId: iv.roomId, segs: [[iv.a[0], iv.a[1], iv.b[0], iv.b[1]]], open: iv.open, cm: iv.cm, source: { kind: 'room' }, }, distToSegment(raw, [iv.a[0], iv.a[1], iv.b[0], iv.b[1]]), false); } for (const partition of space.partitions || []) { const a = [partition.a[0], partition.a[1]]; const b = [partition.b[0], partition.b[1]]; offer({ a, b, roomId: '', segs: [[a[0], a[1], b[0], b[1]]], open: false, cm: Number(partition.cm) || 0, source: { kind: 'partition', id: partition.id }, }, distToSegment(raw, [a[0], a[1], b[0], b[1]]), true); } return best ? (best as { hit: Hit }).hit : null; } public _wallThickClick(raw: number[]): void { const hit = this._wallThickHit(raw); if (!hit) { this.host._showToast(this.host._t('toast.wallthick_pick')); return; } const cm = hit.cm; const view = this.host._viewOr(this.host._baseVb()); const mx = (hit.a[0] + hit.b[0]) / 2, my = (hit.a[1] + hit.b[1]) / 2; this.host._wallDialog = { a: hit.a, b: hit.b, value: cmToField(cm, this.host._imperial), roomId: hit.roomId, source: hit.source, sx: ((mx - view.x) / view.w) * 100, sy: ((my - view.y) / view.h) * 100, }; } public _wallThickApply(allRoom: boolean): void { const d = this.host._wallDialog; if (!d) return; const sp = this.host._curSpaceCfg; const space = this.host._spaceModel(); if (!sp || !space) return; const text = d.value.trim(); const raw = text ? strictNumber(text) : null; if (raw == null) { this._showPhysicalRange(100); return; } const cmRaw = this.host._imperial ? raw * 2.54 : raw; if (!Number.isFinite(cmRaw) || cmRaw < 0 || cmRaw > 100) { this._showPhysicalRange(100); return; } // Independent walls own their records. Zero is the same // canonical bodyless state here as it is for a contour atom (#306). if (d.source.kind !== 'room') { const before = this._geometrySnapshot(); const partition = (sp.partitions || []) .find((item: PartitionCfg) => item.id === (d.source as { id: string }).id); if (!partition) return; if (cmRaw === 0 && zeroWallHasOpening(sp.openings, { kind: 'partition', id: partition.id, })) { this.host._showToast(this.host._t('toast.zero_wall_opening_conflict')); return; } partition.cm = cmRaw; this.host._wallDialog = null; const committed = this._commitPhysicalGeometry( this.host._t('history.wall_thickness'), before, ); if (committed) this.host._showToast(this.host._t('toast.wallthick_set')); this.host.requestUpdate(); return; } const before = this._geometrySnapshot(); const cm = cmRaw > 0 ? cmRaw : null; if (cmRaw === 0) { const eps = this.host._gridPitch * 0.02; const target = [d.a[0], d.a[1], d.b[0], d.b[1]]; const room = (sp.rooms || []).find((item) => item.id === d.roomId); const ids = new Set(allRoom && room && Array.isArray(room.wall_ids) ? room.wall_ids : (sp.wall_segments || []).filter((segment: any) => { const a = [Number(segment.a?.[0]) * NORM_W, Number(segment.a?.[1]) * NORM_W]; const b = [Number(segment.b?.[0]) * NORM_W, Number(segment.b?.[1]) * NORM_W]; return distToSegment(a, target) <= eps && distToSegment(b, target) <= eps; }).map((segment: any) => String(segment.id))); const blocked = (sp.openings || []).some((opening: OpeningCfg) => { if (opening.host?.kind === 'partition') return false; if (opening.host?.kind === 'wall' && ids.has(opening.host.id)) return true; if (opening.host) return false; const point = [Number(opening.x) * NORM_W, Number(opening.y) * NORM_W]; return point.every(Number.isFinite) && distToSegment(point, target) <= eps; }); if (blocked) { this.host._showToast(this.host._t('toast.zero_wall_opening_conflict')); return; } } let openCuts = this.host._openCuts(); if (cmRaw > 0 && openCuts.length) { // The selected zero axis is a cut only in the PRE-edit body profile. // Remove exactly the carriers being restored before the compatibility // wall normalizer runs; otherwise it discards the new positive record as // if the segment were still a zero interval (#306). const targets: number[][] = []; if (allRoom && d.roomId) { const room = (sp.rooms || []).find((item) => item.id === d.roomId); const ids = new Set(Array.isArray(room?.wall_ids) ? room.wall_ids : []); for (const segment of sp.wall_segments || []) { if (!ids.has(segment.id)) continue; targets.push([ Number(segment.a?.[0]) * NORM_W, Number(segment.a?.[1]) * NORM_W, Number(segment.b?.[0]) * NORM_W, Number(segment.b?.[1]) * NORM_W, ]); } // A pre-v9 room has no catalogue IDs yet. Its current zero axes still // come from the rendered room outline, so use that outline as the // carrier set for this first explicit positive-thickness edit. The // common v9 barrier below will materialise stable IDs atomically. if (!targets.length) { const renderedRoom = space.rooms.find((item) => item.id === d.roomId); const poly = renderedRoom ? roomPoly(renderedRoom) : null; for (let index = 0; poly && index < poly.length; index++) { const a = poly[index], b = poly[(index + 1) % poly.length]; targets.push([a[0], a[1], b[0], b[1]]); } } } else targets.push([d.a[0], d.a[1], d.b[0], d.b[1]]); const eps = this.host._gridPitch * 0.02; openCuts = openCuts.filter((cut) => !targets.some((target) => ( distToSegment([cut[0], cut[1]], target) <= eps && distToSegment([cut[2], cut[3]], target) <= eps && distToSegment([target[0], target[1]], cut) <= eps && distToSegment([target[2], target[3]], cut) <= eps ))); } let next: WallEntry[]; if (allRoom && d.roomId) { next = setWallThicknessForRoom( sp.walls, space.rooms, d.roomId, cm, this.host._wallKeyPitch, openCuts, NORM_W, ); } else { next = setWallThickness(sp.walls, d.a, d.b, cm, this.host._wallKeyPitch, NORM_W); } next = this.host._normalizeWalls(next, openCuts); // ADR 282 Stage 1: this writer knows that absence from `next` is an // explicit zero chosen by the user, not a missing compatibility record. // Mirror that intent into the canonical catalogue before the common // barrier; otherwise its lineage fallback would resurrect the old cm. for (const segment of sp.wall_segments || []) { segment.cm = thicknessCmAt( next, segment.a, segment.b, GRID_STEP_N, 1, ); } if (next.length) sp.walls = next; else delete sp.walls; this.host._wallDialog = null; if (this._commitPhysicalGeometry(this.host._t('history.wall_thickness'), before)) this.host._showToast(this.host._t(cmRaw === 0 ? 'toast.wallthick_cleared' : 'toast.wallthick_set')); this.host.requestUpdate(); } public _wallHatchDefs(color: string): TemplateResult { if (!this.host._spaceWalls.length && !this.host._physicalBodiesR().length && !this.host._markup) return svg`` as unknown as TemplateResult; // The step is a physical distance now — 9.6 cm of plan, whatever the grid // scale — so it must NOT be compensated for zoom: a wall that changes its // hatching as you zoom is exactly what #230 is about (spec §4.2). const step = wallHatchStepUnits(this.host._cellCm); const stripe = 2 * (step / HATCH_BASE_STEP_UNITS); // Bake the wall colour into the pattern — CSS vars on content // do not inherit from the filled path, so var(--room-stroke) fell back // to grey while the wall outline used the real border colour. const stroke = color || '#607d8b'; return svg` ` as unknown as TemplateResult; } public _renderWallThickUi(): TemplateResult { const hover = this.host._wallThickHover; if (!hover || !hover.d) return svg`` as unknown as TemplateResult; return svg`` as unknown as TemplateResult; } public _renderWallThickDialog(): TemplateResult { const d = this.host._wallDialog; if (!d) return html``; return html`
e.stopPropagation()}>
{ this.host._wallDialog = { ...d, value: (e.target as HTMLInputElement).value }; }} @keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); this._wallThickApply(false); } }} /> ${this.host._t(this.host._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm')}
${d.source.kind === 'room' ? html`` : nothing}
`; } public _openingAt(raw: readonly number[]): RenderOpening | null { if (!this.host._openingsR.length) return null; const roughHits = this.host._openingsR.flatMap((o) => { const rad = o.angle * Math.PI / 180; const dx = raw[0] - o.rx, dy = raw[1] - o.ry; const localX = dx * Math.cos(rad) + dy * Math.sin(rad); if (Math.abs(localX) > o.rlen / 2 + gridVisualUnits(12, this.host._cellCm)) return []; return [{ o, localY: -dx * Math.sin(rad) + dy * Math.cos(rad) }]; }); if (!roughHits.length) return null; const space = this.host._spaceModel(); if (!space) return null; const openCuts = this.host._openCuts(); const wallIndex = this.host._openingWallIndexFor(space, openCuts).value; return roughHits.find(({ o, localY }) => { const faceFlipV = o.type === 'gate' ? !o.flip_v : o.flip_v; const face = o.partitionHost || this.host._spaceWalls.length || o.type === 'gate' ? this.host._openingFace(o, wallIndex, !!faceFlipV) : { ox: 0, oy: 0, cm: 0, side: -1 as -1 | 1 }; const metrics = openingVisibleMetrics({ type: o.type, length: o.rlen, angle: o.angle, amount: this.host._openingAmt(o), flipH: !!o.flip_h, flipV: !!o.flip_v, base: '', tone: '', cellCm: this.host._cellCm, gridPitch: this.host._gridPitch, face, }); // Match the committed `.op-hit` rectangle exactly. The old radial test // made a 3 m gate suppress placement in a huge circular area far away // from its visible/hit geometry. return Math.abs(localY) <= metrics.hitHalf; })?.o || null; } public _resolveOpeningPlacement(raw: readonly number[]): OpeningPlacementCandidate | null { const preset = this.host._openingPreset; if (this.host._tool !== 'opening' || !preset) return null; const space = this.host._spaceModel(); if (!space) return null; const openCuts = this.host._openCuts(); const wallIndex = this.host._openingWallIndexFor(space, openCuts); const placementKey = `${wallIndex.key}|partitions:${this.host._cfgEpoch}`; if (!this.host._openingPlacementIntervalsCache || this.host._openingPlacementIntervalsCache.key !== placementKey) { this.host._openingPlacementIntervalsCache = { key: placementKey, value: [ ...wallIntervals( space.rooms, this.host._spaceWalls, openCuts, this.host._wallKeyPitch, this.host._cellCm, this.host._gridPitch, NORM_W, ), ...partitionPlacementIntervals(space.partitions, this.host._cellCm, this.host._gridPitch), ], }; } if (!this.host._openingDimensionContextCache || this.host._openingDimensionContextCache.key !== placementKey) { this.host._openingDimensionContextCache = { key: placementKey, value: buildOpeningDimensionContext({ rooms: space.rooms, walls: this.host._spaceWalls, openCuts, partitions: space.partitions, roomOpenings: this.host._roomWallOpeningInputs(), partitionCuts: this._partitionOpeningCuts(space), pitch: this.host._wallKeyPitch, cellCm: this.host._cellCm, gridPitch: this.host._gridPitch, coordScale: NORM_W, epsilon: this.host._gridPitch * 0.0002, }), }; } const resolution = resolveOpeningPlacementResult({ pointer: [raw[0], raw[1]], preset, geometryRevision: this.host._cfgEpoch, renderedLength: this.host._cmToUnits(preset.lengthCm), intervals: this.host._openingPlacementIntervalsCache.value, baseTolerance: this.host._gridPitch * 1.5, bodyPointerPadding: this._cssPxToRender( this.host._pointerModality.modality === 'touch' || this.host._pointerModality.modality === 'pen' ? 10 : 6, ), gridStep: this.host._gridPitch, }); this.host._openingJambBlockCm = resolution.jambBlockedTarget ? (resolution.jambBlockedTarget.physicalHalfWidth / this.host._gridPitch) * this.host._cellCm : null; const core = resolution.candidate; if (!core) return null; const faceFlipV = core.type === 'gate' ? !core.flipV : core.flipV; let face: OpeningFaceOffset; if (core.host) { const hosted = resolvePartitionOpeningCompat({ id: 'preview', type: core.type, x: core.x / NORM_W, y: core.y / NORM_W, angle: core.angle, length: core.renderedLength / NORM_W, host: core.host, }, space.partitions, NORM_W, this.host._cellCm, this.host._gridPitch).resolved; face = hosted ? partitionOpeningFace(hosted, faceFlipV) : { ox: 0, oy: 0, cm: 0, side: -1 }; } else { face = openingInnerFaceOffsetFromIndex( wallIndex.value, { x: core.x, y: core.y, angle: core.angle, length: core.renderedLength, flip_v: faceFlipV }, ); } const imperial = this.host.hass?.config?.unit_system?.length === 'mi'; // #238: placement labels are physical dimensions. They consume the ONE // resolved candidate above and never re-run pointer snap; existing-opening // drag deliberately keeps `_opRuler`/`openingShoulders` below. const dimensions = resolveOpeningDimensions( core, this.host._openingDimensionContextCache.value, ); const labels = dimensions.map((dimension) => ({ x: dimension.label[0], y: dimension.label[1], text: formatLength((dimension.distance / this.host._gridPitch) * this.host._cellCm, imperial), dimension, })); return { ...core, face, measure: { labels, guide: core.measure.guide } }; } public _activateOpeningPlacement(type: OpeningPlacementType): void { this._activateMarkupTool('opening'); if (this.host._tool !== 'opening') return; this.host._openingPreset = openingPlacementPreset(type, ++this.host._openingPresetRevision); this.host._openingHoverCandidate = null; this.host._cursorPt = null; } public _clearOpeningPlacement(clearPreset: boolean): void { this.host._openingHoverCandidate = null; this.host._openingJambBlockCm = null; if (clearPreset) { this.host._openingPreset = null; this.host._openingRebindId = null; } } public _openingClick(raw: number[]): void { const space = this.host._spaceModel(); if (!space) return; // Rebind is an explicit one-shot placement: the orphan's stale fallback // position must not win hit testing when the replacement wall crosses it. const hit = this.host._openingRebindId ? null : this._openingAt(raw); if (hit) { this._editOpening(hit); return; } const preset = this.host._openingPreset; if (!preset) return; const cached = this.host._openingHoverCandidate; const place = cached && sameOpeningPlacementInput( cached, [raw[0], raw[1]], preset.revision, this.host._cfgEpoch, ) ? cached : this._resolveOpeningPlacement(raw); if (!place) { if (this.host._openingJambBlockCm != null) { this.host._showToast(this.host._t('opening.partition_jamb_margin', { distance: formatLength(this.host._openingJambBlockCm, this.host._imperial), })); return; } const eps = this.host._gridPitch * 1.5; const snap = snapToWall(raw, space.rooms, eps); if (snap && pointOnOpenCut(snap.x, snap.y, snap.angle, this.host._openCuts(), eps)) { this.host._showToast(this.host._t('toast.opening_on_zero_wall')); return; } this.host._showToast(this.host._t('toast.opening_no_wall')); return; } const rebound = this.host._openingRebindId ? this.host._curSpaceCfg?.openings?.find((item: OpeningCfg) => item.id === this.host._openingRebindId) : null; this.host._openingDialog = { ...(rebound ? { id: rebound.id, type: rebound.type, lengthCm: Math.round((rebound.length * NORM_W / this.host._gridPitch) * this.host._cellCm), lengthTouched: false, contact: rebound.contact || '', lock: rebound.lock || '', invert: !!rebound.invert, flipH: !!rebound.flip_h, flipV: !!rebound.flip_v, } : { type: place.type, lengthCm: place.lengthCm, contact: '', lock: '', invert: false, flipH: place.flipH, flipV: place.flipV, }), ...(place.host ? { host: place.host } : {}), x: place.x, y: place.y, angle: place.angle, }; this.host._openingRebindId = null; // rulers, tick and ghost live only through the placement gesture this.host._openingHoverCandidate = null; this.host._cursorPt = null; } public _editOpening(o: RenderOpening): void { this.host._openingDialog = { id: o.id, type: o.type, lengthCm: Math.round((o.rlen / this.host._gridPitch) * this.host._cellCm), lengthTouched: false, contact: o.contact || '', lock: o.lock || '', invert: !!o.invert, flipH: !!o.flip_h, flipV: !!o.flip_v, ...(o.host?.kind === 'partition' ? { host: { ...o.host } } : {}), x: o.rx, y: o.ry, angle: o.angle, }; } public _opPointerDown(ev: PointerEvent, o: OpeningCfg): void { if (this.host._mode !== 'plan' || !this.host._spaceModel()) return; // HP-1550-04: in the resize tool the wall handles own the geometry — a door // in the middle of a wall must neither swallow the handle nor start its own // drag (it travels with the wall through the resize pipeline instead) if (this.host._tool === 'resize') return; ev.preventDefault(); ev.stopPropagation(); try { capturePointer(ev); } catch { /* an inactive pointerId (synthetic events, some browsers) must not kill the drag */ } this.host._opDrag = { id: o.id, moved: false, sx: ev.clientX, sy: ev.clientY, dirty: false, before: this._geometrySnapshot(), }; } public _opPointerMove(ev: PointerEvent, o: OpeningCfg): void { queueHouseplanPointerMove(this.host, 'opening', () => this._opPointerMoveNow(ev, o)); } private _opPointerMoveNow(ev: PointerEvent, o: OpeningCfg): void { if (!this.host._opDrag || this.host._opDrag.id !== o.id) return; // audit L4: the other drag pipelines require 3 px before calling it a drag. // Without it every tap counted as a drag: the properties dialog never // opened and an unchanged config was written (which then broadcast the // event behind the L2 data loss). if (Math.abs(ev.clientX - this.host._opDrag.sx) + Math.abs(ev.clientY - this.host._opDrag.sy) <= 3) return; const space = this.host._spaceModel(); if (!space) return; const raw = this._svgPoint(ev); const sp = this.host._curSpaceCfg; const cfg = sp?.openings?.find((x: OpeningCfg) => x.id === o.id); if (!cfg) return; if (cfg.host?.kind === 'partition') { const partition = space.partitions.find((item) => item.id === cfg.host!.id); if (!partition) return; const dx = partition.b[0] - partition.a[0], dy = partition.b[1] - partition.a[1]; const length = Math.hypot(dx, dy); if (!(length > 1e-9)) return; const ux = dx / length, uy = dy / length; const perpendicular = Math.abs( (raw[0] - partition.a[0]) * uy - (raw[1] - partition.a[1]) * ux, ); if (perpendicular > this.host._gridPitch * 4) return; const half = cfg.length * NORM_W / 2; const jamb = partitionOpeningJambMargin(partition, this.host._cellCm, this.host._gridPitch); const shoulder = half + jamb; let along = (raw[0] - partition.a[0]) * ux + (raw[1] - partition.a[1]) * uy; along = Math.round(along / this.host._gridPitch) * this.host._gridPitch; along = Math.max(shoulder, Math.min(length - shoulder, along)); if (length < shoulder * 2 - 1e-9) return; const t = along / length; const nx = (partition.a[0] + ux * along) / NORM_W; const ny = (partition.a[1] + uy * along) / this.host._spaceH; let angle = Math.atan2(dy, dx) * 180 / Math.PI; if (angle >= 90) angle -= 180; else if (angle < -90) angle += 180; const changed = cfg.host.t !== t || cfg.x !== nx || cfg.y !== ny || cfg.angle !== angle; this.host._opDrag.moved = true; if (changed) this.host._opDrag.dirty = true; cfg.host = { ...cfg.host, t }; cfg.x = nx; cfg.y = ny; cfg.angle = angle; this.host._opMeasure = null; if (changed) this.host._cfgEpoch++; this.host.requestUpdate(); return; } const snap = snapToWall(raw, space.rooms, this.host._gridPitch * 4); if (!snap) return; // too far from any wall: the opening stays where it was this.host._opDrag.moved = true; // ruler badges on both shoulders + soft magnet to the wall's center // (owner 2026-08-03) — the very same helper the PLACEMENT preview uses const r = this._opRuler(snap, cfg.length * NORM_W); this.host._opMeasure = r.measure; const nx = r.x / NORM_W; const ny = r.y / this.host._spaceH; const geometryChanged = cfg.x !== nx || cfg.y !== ny || cfg.angle !== snap.angle; if (geometryChanged) this.host._opDrag.dirty = true; cfg.x = nx; cfg.y = ny; cfg.angle = snap.angle; if (Number(this.host._serverCfg?.model_version || 0) >= 9) { const host = resolveRoomOpeningHost(cfg, sp?.wall_segments || []); if (host) cfg.host = host; else delete cfg.host; } // The wall cut and room-coloured tunnel are geometry caches too. Advance // the preview epoch so both follow the live opening instead of remaining at // its pre-drag location until pointerup/save. if (geometryChanged) this.host._cfgEpoch++; this.host.requestUpdate(); } public _opRuler( snap: { x: number; y: number; angle: number }, rlen: number, ): { x: number; y: number; angle: number; measure: OpMeasure | null } { const rooms = this.host._spaceModel()?.rooms || []; const tol = this.host._gridPitch / 2; let cx = snap.x, cy = snap.y; let sh = openingShoulders([cx, cy], snap.angle, rlen, rooms, tol); if (sh && sh.centered && (cx !== sh.wallCenter[0] || cy !== sh.wallCenter[1])) { [cx, cy] = sh.wallCenter; sh = openingShoulders([cx, cy], snap.angle, rlen, rooms, tol); } else if (sh) { // Not centred: quantise the offset ALONG the wall to the grid step // (docs/CANVAS.md §9.3). Grid-BOUND would lift the opening off a diagonal // wall, so the wall stays the master and the grid only says WHERE on it. // The magnet is consulted first on purpose — a wall whose middle is not a // node must still be able to hold a centred window. const [ax, ay] = sh.wallA, [bx, by] = sh.wallB; const wx = bx - ax, wy = by - ay; const len = Math.hypot(wx, wy); if (len > 0) { const g = this.host._gridPitch; const half = Math.min(rlen / 2, len / 2); let along = Math.round((((cx - ax) * wx + (cy - ay) * wy) / len) / g) * g; along = Math.max(half, Math.min(len - half, along)); cx = ax + (along / len) * wx; cy = ay + (along / len) * wy; sh = openingShoulders([cx, cy], snap.angle, rlen, rooms, tol) || sh; } } if (!sh) return { x: cx, y: cy, angle: snap.angle, measure: null }; const imperial = this.host.hass?.config?.unit_system?.length === 'mi'; const lbl = (d: number, m: number[]) => ({ x: m[0], y: m[1], text: formatLength((d / this.host._gridPitch) * this.host._cellCm, imperial) }); return { x: cx, y: cy, angle: snap.angle, measure: { labels: [lbl(sh.sideA, sh.midA), lbl(sh.sideB, sh.midB)], guide: sh.centered ? { x: sh.wallCenter[0], y: sh.wallCenter[1], angle: snap.angle } : null, }, }; } public _opPointerUp(ev: PointerEvent, o: OpeningCfg): void { if (!this.host._opDrag || this.host._opDrag.id !== o.id) return; flushHouseplanPointerMove(this.host, 'opening'); const drag = this.host._opDrag; const moved = drag.moved; this.host._opMeasure = null; // badges and the center tick live only through the drag // only write when the geometry actually changed (audit L4) if (moved && drag.dirty) { this._commitPhysicalGeometry(this.host._t('history.move_opening'), drag.before); } // keep the flag until the click event that follows pointerup, then let it go const finish = () => { this.host._opDrag = null; this.host.requestUpdate(); }; if (moved) window.setTimeout(finish, 0); else finish(); } public _opClick(ev: MouseEvent, o: RenderOpening): void { // HP-1550-04: in the resize tool a click over an opening falls through to // the stage (room picking) instead of opening the editor dialog if (this.host._mode === 'plan' && this.host._tool === 'resize') return; ev.stopPropagation(); if (this.host._opDrag?.moved) return; // that click was the tail of a drag // openings are inert outside Plan mode (owner's decision: View must not // interact with them at all); in Plan any click on an opening edits it if (this.host._mode === 'plan') this._editOpening(o); } public _saveOpening(): void { const d = this.host._openingDialog; const sp = this.host._curSpaceCfg; const model = this.host._spaceModel(); if (!d || !sp || !model) return; const before = this._geometrySnapshot(); const H = this.host._spaceH; const previous = (sp.openings || []).find((item: OpeningCfg) => item.id === d.id); const o: OpeningCfg = { ...(previous || {}), id: d.id || 'o' + Date.now().toString(36), type: d.type, x: d.x / NORM_W, y: d.y / H, angle: d.angle, length: previous && !d.lengthTouched ? previous.length : this.host._cmToUnits(Math.max(20, d.lengthCm)) / NORM_W, ...(d.host ? { host: { ...d.host } } : {}), }; if (o.host?.kind === 'partition') { const strict = partitionOpeningNeedsStrictValidation(previous, o); const resolution = strict ? resolvePartitionOpeningStrict( o, model.partitions, NORM_W, this.host._cellCm, this.host._gridPitch, ) : resolvePartitionOpeningCompat( o, model.partitions, NORM_W, this.host._cellCm, this.host._gridPitch, ); if (!resolution.resolved) { const partition = model.partitions.find((item) => item.id === o.host!.id); if (resolution.reason === 'does-not-fit-jamb' && partition) { const margin = partitionOpeningJambMargin( partition, this.host._cellCm, this.host._gridPitch, ); this.host._showToast(this.host._t('opening.partition_jamb_margin', { distance: formatLength( (margin / this.host._gridPitch) * this.host._cellCm, this.host._imperial, ), })); } else { this.host._showToast(this.host._t('opening.partition_orphan')); } return; } const siblings = (sp.openings || []).flatMap((item: OpeningCfg) => { if (!item.host || item.id === o.id) return []; const sibling = resolvePartitionOpeningCompat( item, model.partitions, NORM_W, this.host._cellCm, this.host._gridPitch, ).resolved; return sibling ? [sibling] : []; }); if (hostedOpeningIntervalsOverlap(resolution.resolved, siblings)) { this.host._showToast(this.host._t('toast.opening_no_wall')); return; } Object.assign(o, materializePartitionOpening(o, resolution.resolved, NORM_W)); } else if (Number(this.host._serverCfg?.model_version || 0) >= 9) { const host = resolveRoomOpeningHost(o, sp.wall_segments || []); if (!host) { this.host._showToast(this.host._t('toast.opening_no_wall')); return; } o.host = host; } else { delete o.host; } if (d.type === 'passage') { // The canonical passage record contains geometry only. Delete keys // instead of writing null/false so imports and old broken records have // one unambiguous representation after an explicit edit. delete o.contact; delete o.lock; delete o.invert; delete o.flip_h; delete o.flip_v; } else { o.contact = d.contact || null; o.lock = d.type === 'door' || d.type === 'gate' ? d.lock || null : null; o.invert = d.invert || undefined; o.flip_h = d.type !== 'gate' && d.flipH || undefined; o.flip_v = d.flipV || undefined; } sp.openings = sp.openings || []; const i = sp.openings.findIndex((x: OpeningCfg) => x.id === o.id); if (i >= 0) sp.openings[i] = o; else sp.openings.push(o); this._commitPhysicalGeometry( this.host._t(d.id ? 'history.edit_opening' : 'history.add_opening'), before, ); this.host._openingDialog = null; this.host.requestUpdate(); } public _deleteOpening(): void { const d = this.host._openingDialog; const sp = this.host._curSpaceCfg; if (!d?.id || !sp?.openings) return; const before = this._geometrySnapshot(); sp.openings = sp.openings.filter((x: OpeningCfg) => x.id !== d.id); this._commitPhysicalGeometry(this.host._t('history.delete_opening'), before); this.host._openingDialog = null; this.host.requestUpdate(); } public _rebindPartitionOpening = (): void => { const d = this.host._openingDialog; if (!d?.id) return; const openingId = d.id; this._activateMarkupTool('opening'); if (this.host._tool !== 'opening') return; // Tool activation clears every stale placement session. Publish the // rebind identity only after that gate so the next click cannot degrade // into creating a second opening. this.host._openingRebindId = openingId; this.host._openingPreset = { type: d.type, lengthCm: d.lengthCm, flipH: d.flipH, flipV: d.flipV, revision: ++this.host._openingPresetRevision, }; this.host._openingDialog = null; this.host._openingHoverCandidate = null; }; public _contactCandidates(): { value: string; label: string }[] { const out: [string, string, number][] = []; for (const eid of Object.keys(this.host.hass.states)) { if (!this.host._openingEntityAvailable(eid)) continue; const dom = eid.split('.')[0]; if (dom !== 'binary_sensor' && dom !== 'cover') continue; const st = this.host.hass.states[eid]; const dc = st?.attributes?.device_class || ''; const doorish = ['door', 'window', 'opening', 'garage_door', 'garage'].includes(dc); if (dom === 'cover' && !doorish) continue; out.push([eid, st?.attributes?.friendly_name || eid, doorish ? 0 : 1]); } return out .sort((a, b) => a[2] - b[2] || a[1].localeCompare(b[1])) .map(([value, label]) => ({ value, label })); } public _lockCandidates(): { value: string; label: string }[] { return Object.keys(this.host.hass.states) .filter((eid) => eid.startsWith('lock.') && this.host._openingEntityAvailable(eid)) .map((eid) => ({ value: eid, label: this.host.hass.states[eid]?.attributes?.friendly_name || eid })) .sort((a, b) => a.label.localeCompare(b.label)); } public _toggleOpeningEntityPicker(kind: 'contact' | 'lock'): void { const d = this.host._openingDialog; if (!d) return; const nextOpen = kind === 'contact' ? !d.contactOpen : !d.lockOpen; this.host._openingDialog = { ...d, contactOpen: kind === 'contact' ? nextOpen : false, lockOpen: kind === 'lock' ? nextOpen : false, }; } public _filterOpeningEntities(kind: 'contact' | 'lock', value: string): void { const d = this.host._openingDialog; if (!d) return; this.host._openingDialog = kind === 'contact' ? { ...d, contactFilter: value } : { ...d, lockFilter: value }; } public _selectOpeningEntity(kind: 'contact' | 'lock', value: string): void { const d = this.host._openingDialog; if (!d) return; this.host._openingDialog = kind === 'contact' ? { ...d, contact: value, contactOpen: false, contactFilter: '' } : { ...d, lock: value, lockOpen: false, lockFilter: '' }; } public _mergeClick(raw: number[]): void { const space = this.host._spaceModel(); if (!space) return; const rooms = space.rooms; const hit = [...rooms].reverse().find((r) => this._pointInRoom(raw, r)); if (!hit?.id) return; const hitId = hit.id; if (!this.host._mergeSel || this.host._mergeSel === hitId) { this.host._mergeSel = this.host._mergeSel === hitId ? null : hitId; // click again = deselect return; } const a = rooms.find((r) => r.id === this.host._mergeSel); const pa = a ? roomPoly(a) : null; const pb = roomPoly(hit); const merged = pa && pb ? mergeRooms(pa, pb) : null; if (!merged) { // only rooms sharing a wall collapse into one outline (see mergeRooms) this.host._showToast(this.host._t('toast.merge_not_adjacent')); this.host._mergeSel = null; return; } this.host._mergeDialog = { aId: this.host._mergeSel, bId: hitId, poly: merged, pick: 'a' }; this.host._mergeSel = null; } public _commitMerge(): void { const d = this.host._mergeDialog; const sp = this.host._curSpaceCfg; if (!d || !sp || !this.host._spaceModel()) return; const before = this._geometrySnapshot(); const H = this.host._spaceH; const keepId = d.pick === 'a' ? d.aId : d.bId; const dropId = d.pick === 'a' ? d.bId : d.aId; const referenceRewrite = rewriteMarkerRoomReferences( this.host._serverCfg?.markers || [], { kind: 'merge', dropId, keepId }, ); if (before && referenceRewrite.changed) before.roomReferences = referenceRewrite.before; const keep = sp.rooms.find((r: any) => r.id === keepId); if (!keep) { this.host._mergeDialog = null; return; } // the kept room keeps its id, so its label position and devices stay put keep.poly = d.poly.map((p) => [p[0] / NORM_W, p[1] / H]); delete keep.x; delete keep.y; delete keep.w; delete keep.h; // a merged room is never a rect sp.rooms = sp.rooms.filter((r: any) => r.id !== dropId); if (referenceRewrite.changed && this.host._serverCfg) { this.host._serverCfg.markers = referenceRewrite.markers; } const committed = this._commitPhysicalGeometry(this.host._t('history.merge_rooms'), before); this.host._mergeDialog = null; this.host._regSignature = ''; this.host._maybeRebuildDevices(); if (committed) this.host._showToast(this.host._t('toast.rooms_merged', { name: keep.name || '' })); } public _splitClick(raw: number[]): void { const space = this.host._spaceModel(); if (!space) return; const rooms = space.rooms; if (!this.host._splitSel) { const hit = [...rooms].reverse().find((r) => this._pointInRoom(raw, r)); if (!hit?.id) return; this.host._splitSel = { roomId: hit.id, pts: [] }; return; } const room = rooms.find((r) => r.id === this.host._splitSel!.roomId); const poly = room ? roomPoly(room) : null; if (!room || !poly) { this.host._splitSel = null; return; } // A split point lands on the room's nearest wall — the user aims at a wall, // and rooms need not be grid-aligned (imported/legacy polygons), so snapping // to the grid would miss the outline. The pull is capped: a click far from // any wall (e.g. an accidental one in the middle of the room) is a miss and // gets the toast, not a wall the user never meant. splitRoom() still rejects // any cut that is not a clean wall-to-wall chord. // …and it is still QUANTISED: the offset ALONG the wall moves in whole grid // steps (docs/CANVAS.md §9), which on the axis-aligned, grid-drawn walls the // editor itself makes IS a grid node. There is no free-position bypass. const eps = this.host._gridPitch * 0.02; const pull = this.host._gridPitch * 6; // ≈2.5% of the plan width — generous but intentional const raw0 = closestPointOnBoundary(raw, poly); const near = raw0 ? (snapPointAlongPoly(raw0, poly, this.host._gridPitch) || raw0) : raw0; const wallPt = raw0 && near && Math.hypot(raw0[0] - raw[0], raw0[1] - raw[1]) <= pull ? near : null; const onWall = !!wallPt && pointOnBoundary(wallPt, poly, eps); const cur = this.host._splitSel.pts; if (!cur.length) { // the cut starts on a wall if (!onWall) { this.host._showToast(this.host._t('toast.split_pick_wall')); return; } this.host._splitSel = { ...this.host._splitSel, pts: [wallPt!] }; return; } if (!onWall) { // an interior click adds an intermediate vertex of the cut path const mid = this._snap(raw); if (!ptInside(mid, poly, eps)) { this.host._showToast(this.host._t('toast.split_pick_inside')); return; } this.host._splitSel = { ...this.host._splitSel, pts: [...cur, mid] }; return; } // a wall point finishes the cut const parts = splitRoomPath(poly, [...cur, wallPt!], eps); if (!parts) { this.host._showToast(this.host._t('toast.split_bad_cut')); return; } this._resetRoomDialogFields(); // the bigger part stays the room it was — name, area and devices go with it const [p1, p2] = parts; const main = polygonArea(p1) >= polygonArea(p2) ? p1 : p2; const fresh = main === p1 ? p2 : p1; this.host._pendingSplit = { roomId: room.id!, mainPoly: main, newPoly: fresh }; this.host._cursorPt = null; this.host._nameSel = ''; this.host._areaSel = ''; this.host._roomDialog = true; } public _markupMove(ev: MouseEvent): void { if (!this.host._markup) return; if (this.host._tool === 'column') { this.host._cursorPt = this._snap(this._svgPoint(ev)); return; } if (this.host._tool === 'opening' || this.host._tool === 'wallthick') { // hover preview: raw cursor point; snapping happens in the preview getters this.host._cursorPt = this._svgPoint(ev); return; } const architectural = this.host._tool === 'draw' && !this.host._contourClosed; const cutting = this.host._tool === 'split' && !!this.host._splitSel?.pts?.length; if (!architectural && !cutting) return; const raw = this._svgPoint(ev); if (architectural) { const resolved = this._resolvePlanDrawPoint(raw, ev.shiftKey); this.host._planSnapHover = { contextKey: resolved.contextKey, candidate: resolved.candidate, conflicts: resolved.conflicts, }; this._syncPlanSnapConflictMarkers(resolved.conflicts); this._syncPlanSnapActiveMarker(resolved.candidate); // Before the first click there is no rubber-band to repaint. Updating the // dedicated marker avoids walking the complete large-house Lit tree for // every mouse move while click still resolves from the event coordinate. if (!this.host._path.length) return; this.host._cursorPt = resolved.point; return; } this.host._cursorPt = this._snap(raw); } public _saveRoom(): void { if (!this.host._areaSel && !this.host._nameSel.trim()) return; if (!roomTempThresholdDraft(this.host._roomTempMin, this.host._roomTempMax).valid) return; if (this.host._wallFaceBatch) { this._decideWallFace(true); return; } this._commitRoom(); } public _decideWallFace(create: boolean): void { const batch = this.host._wallFaceBatch; if (!batch) return; const candidate = batch.candidates[batch.index]; if (!candidate) return; if (!create && (candidate.existing || candidate.repair) && batch.candidates.length === 1) { this.host._wallFaceBatch = null; this.host._roomDialog = false; this.host._nameSel = ''; this.host._areaSel = ''; this.host.requestUpdate(); return; } const decision: WallFaceDecision = create ? { candidate, create: true, name: this.host._nameSel.trim(), area: this.host._areaSel || null, settings: this._roomSettingsFromDialog() ? JSON.parse(JSON.stringify(this._roomSettingsFromDialog())) : null, } : { candidate, create: false }; const decisions = [...batch.decisions, decision]; const nextIndex = batch.index + 1; if (nextIndex < batch.candidates.length) { this.host._wallFaceBatch = { ...batch, decisions, index: nextIndex }; this.host._nameSel = ''; this.host._areaSel = ''; this._resetRoomDialogFields(); this.host.requestUpdate(); return; } this.host._wallFaceBatch = { ...batch, decisions }; this._applyWallFaceBatch(); } public _wallSourceCmAt( point: number[], activePath: number[][], activeCms: number[], ): number { const validCm = (value: unknown): number | null => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null; const epsilon = this.host._gridPitch * 0.02; const model = this.host._spaceModel(); if (!model) return DRAW_WALL_DEFAULT_CM; // Already-saved independent masonry is authoritative when the new chain // traces it. Room-owned walls are preserved by the canonical wall helpers. for (const segment of this._planSnapGeometrySnapshot().value.segments) { if (segment.sourceKind === 'room' || distToSegment(point, [segment.a[0], segment.a[1], segment.b[0], segment.b[1]]) > epsilon) continue; if (segment.sourceKind === 'partition') { return validCm(model.partitions.find((item) => item.id === segment.sourceId)?.cm) ?? DRAW_WALL_DEFAULT_CM; } } // Толщина отрезка активной цепочки решается тем же резолвером (#234): // именно это значение подсвечивает инструмент «Толщина», и расхождение с // записью здесь было тем способом, которым дефект и обнаружился. const resolved = chainSegmentCms( Math.max(0, activePath.length - 1), activeCms, this.host._drawWallCm, DRAW_WALL_DEFAULT_CM, ); for (let i = 0; i + 1 < activePath.length; i++) { const a = activePath[i], b = activePath[i + 1]; if (distToSegment(point, [a[0], a[1], b[0], b[1]]) <= epsilon) { return resolved[i] ?? DRAW_WALL_DEFAULT_CM; } } return DRAW_WALL_DEFAULT_CM; } public _activePathWithRepair( path: number[][], proposal: WallFaceRepairProposal | undefined, ): number[][] { const result = path.map((point) => [...point]); if (!proposal) return result; const source = this.host._activeWallChainPartitionIds.findIndex((_, index) => ( this._activeWallSourceKey(index) === proposal.sourceKey )); const index = source >= 0 ? source + (proposal.endpoint === 'b' ? 1 : 0) : -1; if (index >= 0 && index < result.length) result[index] = [...proposal.to]; return result; } public _validateWallRepair( proposal: WallFaceRepairProposal, activePath: number[][], ): boolean { const epsilon = this.host._gridPitch * 0.0002; const sources = this._wallGraphSources(activePath); const source = sources.find((item) => item.key === proposal.sourceKey); const target = sources.find((item) => item.key === proposal.targetSourceKey); if (!source || !target) return false; const current = proposal.endpoint === 'a' ? source.a : source.b; if (Math.hypot(current[0] - proposal.from[0], current[1] - proposal.from[1]) > epsilon) return false; if (distToSegment(proposal.to, [target.a[0], target.a[1], target.b[0], target.b[1]]) > epsilon) { return false; } if (repairMovesHostedPartition( proposal, (this.host._curSpaceCfg as any)?.openings || [], )) return false; return true; } public _applyWallRepair( proposal: WallFaceRepairProposal, batch: WallFaceBatch, ): boolean { const sp = this.host._curSpaceCfg as any; if (!sp || !this._validateWallRepair(proposal, batch.activePath)) return false; const write = (point: number[]): void => { point[0] = proposal.to[0] / NORM_W; point[1] = proposal.to[1] / NORM_W; }; if (!proposal.sourceKey.startsWith('static:')) return false; const [kind, sourceId] = proposal.sourceKey.slice('static:'.length).split('|'); if (kind === 'partition') { const partition = (sp.partitions || []).find((item: PartitionCfg) => item.id === sourceId); if (!partition) return false; const renderA = [partition.a[0] * NORM_W, partition.a[1] * NORM_W]; const renderB = [partition.b[0] * NORM_W, partition.b[1] * NORM_W]; const point = Math.hypot(renderA[0] - proposal.from[0], renderA[1] - proposal.from[1]) <= this.host._gridPitch * 0.0002 ? partition.a : Math.hypot(renderB[0] - proposal.from[0], renderB[1] - proposal.from[1]) <= this.host._gridPitch * 0.0002 ? partition.b : null; if (!point) return false; write(point); const activeIndex = batch.activePartitionIds.indexOf(sourceId); if (activeIndex >= 0) { const pathIndex = activeIndex + (proposal.endpoint === 'b' ? 1 : 0); if (batch.activePath[pathIndex]) batch.activePath[pathIndex] = [...proposal.to]; } return true; } return false; } public _applyWallFaceBatch(): void { const batch = this.host._wallFaceBatch; const sp = this.host._curSpaceCfg as any; const model = this.host._spaceModel(); if (!batch || !sp || !model) return; const abort = (message: string, vars?: Record): void => { this.host._showToast(this.host._t(message as any, vars)); this._roomDialogCancel(); }; const accepted = batch.decisions.filter((decision) => decision.create); const acceptedRings = accepted.map((decision) => decision.candidate.ring); const existingRooms = model.rooms; for (let i = 0; i < acceptedRings.length; i++) { if (this._contourSelfIntersects(acceptedRings[i]) || polygonArea(acceptedRings[i]) <= 1e-6) { abort('toast.contour_cannot_close'); return; } for (let j = i + 1; j < acceptedRings.length; j++) { if (roomsOverlap(acceptedRings[i], acceptedRings[j])) { abort('toast.contour_cannot_close'); return; } } const ownSplit = accepted[i].candidate.split?.roomId; const clash = existingRooms.find((room) => room.id !== ownSplit && !!roomPoly(room) && roomsOverlap(acceptedRings[i], roomPoly(room)!)); if (clash) { abort('toast.room_overlap', { name: clash.name || '' }); return; } } if ((sp.rooms || []).length + accepted.length > MAX_ROOMS) { abort('toast.physical_limit'); return; } if (accepted.some((decision) => decision.candidate.split && !sp.rooms.some((room) => room.id === decision.candidate.split!.roomId))) { abort('toast.contour_cannot_close'); return; } const repairs = accepted .map((decision) => decision.candidate.repair) .filter((repair): repair is WallFaceRepairProposal => !!repair); if (repairs.length > 1 || (repairs[0] && !this._validateWallRepair(repairs[0], batch.activePath))) { abort('toast.wall_repair_changed'); return; } if (!accepted.length) { if (!this._finalizeWallChainPartitions(batch.activePartitionIds)) return; this.host._path = []; this.host._activeWallChainId = null; this.host._activeWallChainPartitionIds = []; this.host._wallChainSegmentCms = []; this.host._wallChainRedo = []; this.host._closingWallCm = null; this.host._wallFaceBatch = null; this.host._roomDialog = false; this.host._nameSel = ''; this.host._areaSel = ''; this.host.requestUpdate(); this.host._showToast(this.host._t('toast.wall_chain_saved')); return; } const activePartitionIds = new Set(batch.activePartitionIds); const epsilon = this.host._gridPitch * 0.0002; const roomLineage = accepted.map((decision) => decision.candidate.ring.map((a, index, ring) => { const b = ring[(index + 1) % ring.length]; const carriers = model.partitions.filter((partition) => { const length = Math.hypot(partition.b[0] - partition.a[0], partition.b[1] - partition.a[1]); if (!(length > epsilon)) return false; const ux = (partition.b[0] - partition.a[0]) / length; const uy = (partition.b[1] - partition.a[1]) / length; const along = (point: number[]) => ( (point[0] - partition.a[0]) * ux + (point[1] - partition.a[1]) * uy ); return distToSegment(a, [partition.a[0], partition.a[1], partition.b[0], partition.b[1]]) <= epsilon && distToSegment(b, [partition.a[0], partition.a[1], partition.b[0], partition.b[1]]) <= epsilon && along(a) >= -epsilon && along(a) <= length + epsilon && along(b) >= -epsilon && along(b) <= length + epsilon; }); carriers.sort((left, right) => ( Number(activePartitionIds.has(right.id)) - Number(activePartitionIds.has(left.id)) || Math.hypot(left.b[0] - left.a[0], left.b[1] - left.a[1]) - Math.hypot(right.b[0] - right.a[0], right.b[1] - right.a[1]) || left.id.localeCompare(right.id) )); return carriers[0]?.id || ''; })); const before = this._geometrySnapshot(); if (!before) { abort('toast.geometry_unsafe'); return; } const abortMutation = (): void => { this._restoreGeometryStateLocal(before); abort('toast.geometry_unsafe'); }; if (repairs[0] && !this._applyWallRepair(repairs[0], batch)) { abort('toast.wall_repair_changed'); return; } const hasSplit = accepted.some((decision) => !!decision.candidate.split); const splitWalls = hasSplit ? materializeWallIntervals( model.rooms, sp.walls, this.host._openCuts(), this.host._wallKeyPitch, this.host._cellCm, this.host._gridPitch, NORM_W, ) : null; const wallsBeforeSplit = hasSplit && Array.isArray(sp.walls) ? sp.walls : null; for (const decision of accepted) { const split = decision.candidate.split; if (!split) continue; const main = sp.rooms.find((room) => room.id === split.roomId); if (!main) { abortMutation(); return; } main.poly = split.mainPoly.map((point) => [point[0] / NORM_W, point[1] / this.host._spaceH]); delete main.x; delete main.y; delete main.w; delete main.h; } const seed = Date.now().toString(36); const newRooms: Array<{ room: any; decision: WallFaceDecision }> = []; accepted.forEach((decision, index) => { const areaName = decision.area ? this.host.hass.areas[decision.area]?.name : ''; const room = { id: `r${seed}-${index}`, name: decision.name || areaName || this.host._t('room.default_name'), area: decision.area || null, poly: decision.candidate.ring.map((point) => [point[0] / NORM_W, point[1] / this.host._spaceH]), ...(roomLineage[index].some(Boolean) ? { wall_ids: roomLineage[index] } : {}), ...(decision.settings ? { settings: JSON.parse(JSON.stringify(decision.settings)) } : {}), }; sp.rooms.push(room); newRooms.push({ room, decision }); }); if (hasSplit) { const next = this.host._normalizeWalls(splitWalls, this.host._openCuts()); if (next.length) sp.walls = next; else if (!wallsBeforeSplit?.length) delete sp.walls; else sp.walls = wallsBeforeSplit; } if (newRooms.length) { // Make the new model visible to the canonical wall helpers before they // key intervals. This is still inside the one history/save transaction. this.host._cfgEpoch++; const openCuts = this.host._openCuts(); const updatedModel = this.host._spaceModel(); if (!updatedModel) { abortMutation(); return; } let walls = sp.walls; for (const { room, decision } of newRooms) { const ring = decision.candidate.ring; const fallback = this._wallSourceCmAt( ring[0], batch.activePath, batch.activeCms, ); walls = applyWallThicknessToNewRoom( walls, updatedModel.rooms, room.id, fallback, this.host._wallKeyPitch, openCuts, NORM_W, ); for (const interval of wallIntervals( updatedModel.rooms, walls, openCuts, this.host._wallKeyPitch, this.host._cellCm, this.host._gridPitch, NORM_W, )) { if (interval.roomId !== room.id || interval.kind !== 'outer') continue; const midpoint = [ (interval.a[0] + interval.b[0]) / 2, (interval.a[1] + interval.b[1]) / 2, ]; walls = setWallThickness( walls, interval.a, interval.b, this._wallSourceCmAt(midpoint, batch.activePath, batch.activeCms), this.host._wallKeyPitch, NORM_W, ); } } const normalized = this.host._normalizeWalls(walls, openCuts); if (normalized.length) sp.walls = normalized; else delete sp.walls; // Every accepted click already wrote an ordinary partition. Once the // chain creates room faces, consume the coincident carriers into the // canonical room-wall representation in this same transaction. this.host._cfgEpoch++; const reconciledModel = this.host._spaceModel(); if (!reconciledModel) { abortMutation(); return; } const reconciled = reconcileCoincidentPartitions( sp, reconciledModel, sp.walls, openCuts, { pitch: this.host._wallKeyPitch, cellCm: this.host._cellCm, gridPitch: this.host._gridPitch, coordScale: NORM_W, allowCoincidentPartitions: true, // #478 room acceptance consumes its wall carriers. }, ); if (reconciled.walls.length) sp.walls = reconciled.walls; else delete sp.walls; if (reconciled.partitions.length) sp.partitions = reconciled.partitions; else delete sp.partitions; if (reconciled.openings.length) sp.openings = reconciled.openings; else delete sp.openings; } if (!this._commitPhysicalGeometry(this.host._t('history.wall_face_batch'), before)) return; this.host._path = []; this.host._activeWallChainId = null; this.host._activeWallChainPartitionIds = []; this.host._wallChainSegmentCms = []; this.host._wallChainRedo = []; this.host._closingWallCm = null; this.host._wallFaceBatch = null; this.host._roomDialog = false; this.host._nameSel = ''; this.host._areaSel = ''; this.host._regSignature = ''; this.host._maybeRebuildDevices(); // Match the ordinary room-save contract: newly area-bound devices are // auto-added and their initial positions are pinned immediately, while a // clean split leaves the surviving parent's existing layout untouched. const boundAreas = new Set(accepted .map((decision) => decision.area) .filter((area): area is string => !!area)); if (boundAreas.size) { const next = { ...this.host._layout }; for (const device of this.host._devices) { if (!boundAreas.has(device.area || '') || device.space !== this.host._space) continue; if (this.host._layout[device.id]) continue; const position = this.host._defPos[device.id]; if (!position) continue; next[device.id] = { s: this.host._space, x: position.x / NORM_W, y: position.y / NORM_W, }; this.host._dirtyPos.add(device.id); } this.host._layout = next; this.host._persistLayout(); } this.host._showToast(this.host._t(accepted.length ? 'toast.wall_rooms_saved' : 'toast.wall_chain_saved', { n: accepted.length, })); } public _commitRoom(): void { const sp = this.host._curSpaceCfg; const space = this.host._spaceModel(); if (!sp || !space) return; const before = this._geometrySnapshot(); if (!before) return; const abortMutation = (): void => { this._restoreGeometryStateLocal(before); this.host._showToast(this.host._t('toast.geometry_unsafe')); }; const H = this.host._spaceH; const wasSplit = !!this.host._pendingSplit; // Split changes the two source-wall edges into shorter children. Preserve // the effective old profile while the original outline still proves the // full extent of legacy midpoint-only wall keys; after the mutation that // information is irrecoverable and one child would normalise to 0 cm. const splitWalls = wasSplit ? materializeWallIntervals( space.rooms, sp.walls, this.host._openCuts(), this.host._wallKeyPitch, this.host._cellCm, this.host._gridPitch, NORM_W, ) : null; const wallsBeforeSplit = wasSplit && Array.isArray(sp.walls) ? sp.walls : null; let verts: number[][]; if (this.host._pendingSplit) { // apply the cut now: the bigger part keeps the original room, this dialog names the rest const main = sp.rooms.find((r: any) => r.id === this.host._pendingSplit!.roomId); if (!main) { this.host._pendingSplit = null; this.host._splitSel = null; this.host._roomDialog = false; return; } main.poly = this.host._pendingSplit.mainPoly.map((p) => [p[0] / NORM_W, p[1] / H]); delete main.x; delete main.y; delete main.w; delete main.h; verts = this.host._pendingSplit.newPoly; } else { if (!this.host._contourClosed) return; verts = this.host._path.slice(0, -1); // without the duplicated closing vertex } const areaName = this.host._areaSel ? this.host.hass.areas[this.host._areaSel]?.name : ''; const newRoom: any = { id: 'r' + Date.now().toString(36), name: this.host._nameSel || areaName || this.host._t('room.default_name'), area: this.host._areaSel || null, poly: verts.map((p) => [p[0] / NORM_W, p[1] / H]), ...(this._roomSettingsFromDialog() ? { settings: this._roomSettingsFromDialog() } : {}), }; sp.rooms.push(newRoom); // A Split rewrites the parent outline and adds a child, so a span that used // to sit between the parent and a neighbour may now belong to the child. // Geometry first, then spans and the derived open_to — otherwise border // trimming reads the new geometry while glow reads the old connectivity // (AUD-159B6-02). if (wasSplit) { const next = this.host._normalizeWalls(splitWalls, this.host._openCuts()); if (next.length) sp.walls = next; else if (!wallsBeforeSplit?.length) delete sp.walls; else sp.walls = wallsBeforeSplit; } // Draw-session wall thickness: apply to new edges only; keep neighbour cm // on shared stretches. Split naming does not use the Draw field. if (!wasSplit) { const edgeCms = chainSegmentCms( verts.length, [...this.host._wallChainSegmentCms, this.host._closingWallCm ?? undefined], this.host._drawWallCm, DRAW_WALL_DEFAULT_CM, ); const cm = edgeCms[0]; if (cm != null) { this.host._cfgEpoch++; // the new room must be in the model before keying const openCuts = this.host._openCuts(); const updatedSpace = this.host._spaceModel(); if (!updatedSpace) { abortMutation(); return; } let next = applyWallThicknessToNewRoom( sp.walls, updatedSpace.rooms, newRoom.id, cm, this.host._wallKeyPitch, openCuts, NORM_W, ); // The room may have been drawn while the toolbar thickness changed. // Shared stretches keep the already-existing physical wall; every // outer atomic stretch receives the value of its source draft edge. for (const iv of wallIntervals( updatedSpace.rooms, next, openCuts, this.host._wallKeyPitch, this.host._cellCm, this.host._gridPitch, NORM_W, )) { if (iv.roomId !== newRoom.id || iv.kind !== 'outer') continue; const mid = [(iv.a[0] + iv.b[0]) / 2, (iv.a[1] + iv.b[1]) / 2]; const source = verts.findIndex((a, i) => { const b = verts[(i + 1) % verts.length]; return distToSegment(mid, [a[0], a[1], b[0], b[1]]) <= this.host._gridPitch * 0.02; }); if (source >= 0) next = setWallThickness( next, iv.a, iv.b, edgeCms[source], this.host._wallKeyPitch, NORM_W, ); } next = this.host._normalizeWalls(next, openCuts); if (next.length) sp.walls = next; else delete sp.walls; const reconciledModel = this.host._spaceModel(); if (!reconciledModel) { abortMutation(); return; } const reconciled = reconcileCoincidentPartitions( sp, reconciledModel, sp.walls, openCuts, { pitch: this.host._wallKeyPitch, cellCm: this.host._cellCm, gridPitch: this.host._gridPitch, coordScale: NORM_W, allowCoincidentPartitions: true, }, ); if (reconciled.walls.length) sp.walls = reconciled.walls; else delete sp.walls; if (reconciled.partitions.length) sp.partitions = reconciled.partitions; else delete sp.partitions; if (reconciled.openings.length) sp.openings = reconciled.openings; else delete sp.openings; } } const committed = this._commitPhysicalGeometry( this.host._t(wasSplit ? 'history.split_room' : 'history.add_room'), before, ); this.host._path = []; this.host._activeWallChainId = null; this.host._activeWallChainPartitionIds = []; this.host._wallChainSegmentCms = []; this.host._wallChainRedo = []; this.host._closingWallCm = null; this.host._pendingSplit = null; this.host._splitSel = null; const boundArea = this.host._areaSel; this.host._areaSel = ''; this.host._nameSel = ''; this.host._roomDialog = false; this.host._regSignature = ''; this.host._maybeRebuildDevices(); if (!committed) return; // auto-add the area's device icons + PIN their positions in the layout, // so that icons do not get reshuffled when the order in the HA registry changes. let added = 0; if (boundArea) { const H2 = NORM_W; const next = { ...this.host._layout }; for (const d of this.host._devices) { if (d.area !== boundArea || d.space !== this.host._space) continue; added++; if (this.host._layout[d.id]) continue; // placed manually — leave it alone const dp = this.host._defPos[d.id]; if (!dp) continue; next[d.id] = { s: this.host._space, x: dp.x / NORM_W, y: dp.y / H2 }; this.host._dirtyPos.add(d.id); } this.host._layout = next; this.host._persistLayout(); } const roomsN = this.host._model.find((s) => s.id === this.host._space)?.rooms.length || 0; this.host._showToast( boundArea ? this.host._t('toast.room_saved', { n: roomsN, added }) : this.host._t('toast.room_saved_no_area', { n: roomsN }), ); } public _cancelPath(): void { this.host._wallRepairDiagnostic = null; this.host._path = []; this.host._activeWallChainId = null; this.host._activeWallChainPartitionIds = []; this.host._wallChainSegmentCms = []; this.host._wallChainRedo = []; this.host._closingWallCm = null; this._clearPlanSnapHover(); this.host._roomDialog = false; this.host._pendingSplit = null; this.host._wallFaceBatch = null; this.host._splitSel = null; this.host._mergeSel = null; this.host._mergeDialog = null; this.host._physicalSel = null; this.host._physicalDrag = null; this.host._physicalRotate = null; this._clearOpeningPlacement(true); } public _roomDialogCancel(): void { this.host._roomDialog = false; if (this.host._roomEditId) { this.host._roomEditId = null; this.host._nameSel = ''; this.host._areaSel = ''; return; } if (this.host._wallFaceBatch) { const batch = this.host._wallFaceBatch; this.host._wallFaceBatch = null; this.host._path = batch.activePath.map((point) => [...point]); this.host._wallChainSegmentCms = [...batch.activeCms]; this.host._activeWallChainPartitionIds = [...batch.activePartitionIds]; this.host._activeWallChainId ||= `chain-${Date.now().toString(36)}`; this.host._nameSel = ''; this.host._areaSel = ''; this.host.requestUpdate(); return; } if (this.host._pendingSplit) { // nothing was applied yet — drop the cut entirely, the room stays whole this.host._pendingSplit = null; this.host._splitSel = null; return; } this.host._undoPoint(); } public _openDeviceInbox(): void { this.host._deviceInboxReturn = null; this.host._deviceInbox = this.host._deviceInbox || { tab: 'on_plan', search: '', showEntities: false, onlyNew: false, limit: 100, }; } public _closeMarkerDialog(): void { if (this._radarSetup.isDirty()) { void this._closeMarkerDialogGuarded(); return; } this._radarSetup.reset(); finishMarkerDialogClose(this.host); } private async _closeMarkerDialogGuarded(): Promise { if (!await this._radarSetup.discardIfAllowed()) { await this.host.updateComplete; this.host.renderRoot .querySelector('#marker-dialog')?.rejectClose(); return; } this._closeMarkerDialog(); } public _deviceInboxCandidates(showEntities: boolean) { return bindingCandidates({ hass: this.host._planHass, devices: this.host._devices, markers: this.host._markers, showEntities, labels: { device: this.host._t('marker.sub_device'), z2mGroup: this.host._t('marker.sub_z2m_group'), group: this.host._t('marker.sub_group'), helper: this.host._t('marker.sub_helper'), entity: this.host._t('marker.sub_entity'), }, }); } public _deviceInboxRows(): DeviceInboxRow[] { const dialog = this.host._deviceInbox || this.host._deviceInboxReturn; const showEntities = !!dialog?.showEntities; const key = [ this.host._haRegistry.revision, this.host._cfgRev, this.host._cfgEpoch, this.host._regSignature, this.host._newSyncKey, showEntities ? 1 : 0, this.host._showAll ? 1 : 0, langOf(this.host.hass, this.host._config?.language), ].join('|'); if (this.host._deviceInboxMemo?.key === key) return this.host._deviceInboxMemo.rows; const candidates = this._deviceInboxCandidates(showEntities); const bindings = new Set(candidates.map((item) => item.value)); for (const marker of this.host._markers) if (marker.binding && marker.binding !== 'virtual') bindings.add(marker.binding); for (const device of this.host._devices) { if (device.bindingKind && device.bindingKind !== 'virtual' && device.bindingRef) { bindings.add(`${device.bindingKind}:${device.bindingRef}`); } } const statuses = new Map(); for (const binding of bindings) statuses.set(binding, this.host._bindingStatus(binding)); const areaMap = this.host._areaToSpace; const areaNames: Record = {}; for (const [id, area] of Object.entries(this.host.hass?.areas || {})) { areaNames[id] = areaMap[id]?.room?.name || area?.name || id; } // A room may keep a valid HA area reference even when the current user has // only a limited area-registry snapshot. Its plan-visible name is still // authoritative for the catalog and search. for (const [id, target] of Object.entries(areaMap)) { areaNames[id] = target.room.name || areaNames[id] || id; } const spaceNames = Object.fromEntries(this.host._model.map((space) => [space.id, space.title])); const spaceByArea = Object.fromEntries( Object.entries(areaMap).map(([area, value]) => [area, value.space]), ); const integrationByBinding: Record = {}; const devicePlatforms = new Map>(); for (const [entityId, entity] of Object.entries(this.host._fullRegistryHass.entities || {})) { const platform = String(entity?.platform || '').trim(); if (platform) integrationByBinding[`entity:${entityId}`] = platform; if (platform && entity?.device_id) { const set = devicePlatforms.get(entity.device_id) || new Set(); set.add(platform); devicePlatforms.set(entity.device_id, set); } } for (const [deviceId, platforms] of devicePlatforms) { integrationByBinding[`device:${deviceId}`] = [...platforms].sort().join(', '); } const reasonByBinding: Record = {}; for (const [deviceId, device] of Object.entries(this.host._fullRegistryHass.devices || {})) { const binding = `device:${deviceId}`; const platforms = devicePlatforms.get(deviceId) || new Set(); const identifierDomain = Array.isArray(device?.identifiers?.[0]) ? String(device.identifiers[0][0] || '') : ''; // #44 r1-M2: a device without a single platform-bearing entity still has // an integration name — its identifier domain. Without this fallback the // reason text would render the raw {integration} placeholder. if (identifierDomain && !integrationByBinding[binding]) { integrationByBinding[binding] = identifierDomain; } const excluded = [identifierDomain, ...platforms].some((domain) => this.host._excluded.has(domain)); if (device?.entry_type === 'service') reasonByBinding[binding] = 'service_entry'; else if (excluded) reasonByBinding[binding] = 'excluded_integration'; else if (device?.model === 'Group') reasonByBinding[binding] = 'grouped_light'; else if (/scene/i.test(device?.model || '')) reasonByBinding[binding] = 'excluded_domain'; else if (/bridge/i.test(`${device?.model || ''}${device?.name || ''}`) || (identifierDomain === 'myheat' && device?.via_device_id)) { reasonByBinding[binding] = 'represented_by_parent'; } } const rows = buildDeviceInbox({ devices: this.host._devices, markers: this.host._markers, candidates, statuses, newDeviceIds: this.host._newIds, showHiddenOnPlan: this.host._showAll, areaNames, spaceNames, spaceByArea, integrationByBinding, reasonByBinding, }); this.host._deviceInboxMemo = { key, rows }; return rows; } public _deviceForInboxRow(row: DeviceInboxRow): DevItem | null { const runtime = row.deviceId ? this.host._devices.find((item) => item.id === row.deviceId) : null; if (runtime) return runtime; const marker = row.markerId ? this.host._markers.find((item) => item.id === row.markerId) : null; if (!marker || marker.removed) return null; return { id: marker.id, name: row.name, model: row.model, area: row.areaId, space: row.spaceId || this.host._space, hidden: marker.hidden === true || row.status.kind === 'ha_disabled', userHidden: marker.hidden === true, bindingStatus: row.status, icon: row.icon, entities: row.status.kind === 'active' ? row.status.enabledEntityIds : [], allEntities: row.status.allEntityIds, primary: row.status.kind === 'active' ? row.status.enabledEntityIds[0] : undefined, marker, bindingKind: row.kind, bindingRef: row.binding.slice(row.binding.indexOf(':') + 1), pdfs: marker.pdfs || [], }; } public _openInboxMarker(row: DeviceInboxRow, add = false): void { const snapshot = this.host._deviceInbox; if (!snapshot) return; this.host._deviceInboxReturn = { ...snapshot, anchor: row.key }; this.host._deviceInbox = null; if (!add) { const device = this._deviceForInboxRow(row); if (device) this._openMarkerDialog(device); else this._closeMarkerDialog(); return; } this._openMarkerDialog(); if (!this.host._markerDialog) { this._closeMarkerDialog(); return; } this.host._markerDialog = { ...this.host._markerDialog, bindingMode: 'ha', binding: row.binding, bindingOpen: false, showEntities: row.kind === 'entity', name: '', }; } public async _setInboxHidden(row: DeviceInboxRow, hidden: boolean): Promise { if (row.status.kind !== 'active') return; await writeInboxVisibility(this.host, this._inboxBatchDeps(), [row], hidden, false); } private _inboxBatchDeps(): InboxBatchDeps { return { rows: () => this._deviceInboxRows(), saveConfigNow: () => this._saveConfigNow() }; } public _findInboxDevice(row: DeviceInboxRow): void { if (!row.canFind) return; const device = this._deviceForInboxRow(row); if (!device) return; this.host._deviceInbox = null; if (device.space && device.space !== this.host._space) { if (!this.host._commitSpace(device.space)) return; this.host._restoreZoom(); } const focus = () => { const current = this.host._devices.find((item) => item.id === device.id) || device; const point = this.host._pos(current); this.host._applyView(this.host._zoom, point.x, point.y); this.host._selId = current.id; this.host.requestUpdate(); window.setTimeout(() => { if (this.host._selId === current.id) { this.host._selId = null; this.host.requestUpdate(); } }, 1500); }; requestAnimationFrame(() => requestAnimationFrame(focus)); } public _deviceInboxTabKey = (event: KeyboardEvent): void => { if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; const dialog = this.host._deviceInbox; if (!dialog) return; const tabs: DeviceInboxCategory[] = ['on_plan', 'available', 'hidden', 'readd']; const offset = event.key === 'ArrowRight' ? 1 : -1; const index = (tabs.indexOf(dialog.tab) + offset + tabs.length) % tabs.length; this.host._deviceInbox = { ...dialog, tab: tabs[index], limit: 100, onlyNew: false, selected: [] }; event.preventDefault(); }; public _openMarkerDialog(d?: DevItem): void { // A global-camera snapshot belongs to one explicit expansion only; never // carry it across device-dialog sessions. this.host._vacAllCamerasFor = null; this.host._vacAllCameraCache = null; if (d) this.host._ackNewDevice(d.id); if (!this.host._norm) { this.host._showToast(this.host._t('toast.marker_needs_server')); return; } if (d) { const marker = d.marker; const hasIsLight = Object.prototype.hasOwnProperty.call(marker || {}, 'is_light'); const hasLightEntity = Object.prototype.hasOwnProperty.call(marker || {}, 'light_entity'); const hasGlowColor = Object.prototype.hasOwnProperty.call(marker || {}, 'glow_color'); const hasValueBadge = Object.prototype.hasOwnProperty.call(marker || {}, 'value_badge'); const hasValueSource = Object.prototype.hasOwnProperty.call(marker || {}, 'value_source'); const hasTapAction = Object.prototype.hasOwnProperty.call(marker || {}, 'tap_action'); const hasToggleEntity = Object.prototype.hasOwnProperty.call(marker || {}, 'toggle_entity'); const glowOverride = normalizeGlowColorOverride(marker?.glow_color); const currentBadge = this.host._devicePresentation(d, true).valueBadge; const badgeCandidates = valueBadgeCandidates(this.host._planHass, d, this.host._devices); const recommendedBadge = recommendedValueBadgeSource(this.host._planHass, d, badgeCandidates); const radarRecognition = recognizeRadar(d, this.host._fullRegistryHass); const radarSpace = this.host._spaceModelById(d.space) || this.host._spaceModel(); const markerPoint = this.host._pos(d); const radar = radarSpace ? radarDraft(d, radarSpace, [markerPoint.x, markerPoint.y], this.host._fullRegistryHass) : null; this.host._markerDialog = { devId: d.id, name: d.name, binding: d.bindingKind === 'virtual' ? 'virtual' : d.bindingKind + ':' + d.bindingRef, bindingMode: d.bindingKind === 'virtual' ? 'virtual' : 'ha', bindingOpen: false, // a marker bound to an ENTITY of a device only shows up with the box on showEntities: d.bindingKind === 'entity' && !!this.host._fullRegistryHass.entities[d.bindingRef || '']?.device_id, bindingFilter: '', icon: d.marker?.icon || '', autoIcon: d.icon || '', display: normalizeDeviceDisplay(d.marker?.display), rippleColor: safeStoredColor(d.marker?.ripple_color, ''), rippleSize: Number(d.marker?.ripple_size) > 0 ? Number(d.marker!.ripple_size) : 1.5, size: Number(d.marker?.size) > 0 ? Number(d.marker!.size) : 1, angle: Number(d.marker?.angle) || 0, tapAction: projectedTapAction(d.marker?.tap_action, d.primary?.split('.')[0]), tapActionTouched: false, originalHasTapAction: hasTapAction, originalTapAction: d.marker?.tap_action, tapHintAnnouncement: '', toggleEntity: marker?.toggle_entity || '', toggleEntityTouched: false, originalHasToggleEntity: hasToggleEntity, originalToggleEntity: marker?.toggle_entity, tapTarget: d.marker?.tap_target || '', tapConfirm: d.marker?.tap_confirm === true, runFilter: '', // Keep unknown, temporarily inactive and duplicate external targets // byte-for-byte across Open → Save. Runtime uses the filtered // effective projection; a legacy self-reference is not a light source. controls: persistedExternalControls(d.marker?.binding, d.marker?.controls, d.entities), controlsFilter: '', lightRole: marker?.is_light === true ? 'always' : marker?.is_light === false ? 'never' : 'auto', lightRoleTouched: false, originalHasIsLight: hasIsLight, originalIsLight: marker?.is_light, lightEntity: marker?.light_entity || '', lightEntityTouched: false, originalHasLightEntity: hasLightEntity, originalLightEntity: marker?.light_entity, glowMode: glowOverride?.bri != null ? 'fixed' : glowOverride ? 'color' : 'auto', glowColor: glowOverride?.c || this.host._fillColors.glow_light.c, glowBrightness: Math.max(1, Math.round((glowOverride?.bri ?? 1) * 100)), glowColorDrafted: !!glowOverride, glowBrightnessDrafted: glowOverride?.bri != null, glowTouched: false, originalHasGlowColor: hasGlowColor && (!!glowOverride || marker?.glow_color === null), originalGlowColor: glowOverride || (marker?.glow_color === null ? null : undefined), valueBadgeEnabled: hasValueBadge ? marker?.value_badge?.enabled === true : !!currentBadge, valueBadgeSource: marker?.value_badge?.source || currentBadge?.source || recommendedBadge, valueBadgePosition: marker?.value_badge?.position || currentBadge?.position || 'right', valueBadgeTouched: false, originalHasValueBadge: hasValueBadge, originalValueBadge: marker?.value_badge, valueSource: marker?.value_source || null, valueSourceTouched: false, originalHasValueSource: hasValueSource, originalValueSource: marker?.value_source, useClimateTemp: d.marker?.use_climate_temp === true, glowRadius: Number(d.marker?.glow_radius_cm) > 0 ? String(this.host._imperial ? Math.round((Number(d.marker!.glow_radius_cm) / 30.48) * 10) / 10 : Math.round(Number(d.marker!.glow_radius_cm)) / 100) : '', model: d.model || '', link: d.link || '', description: d.description || '', pdfs: [...(d.pdfs || [])], room: d.marker?.room_id ? d.space + '#@' + d.marker.room_id : d.space && d.area ? d.space + '#' + d.area : '', roomTouched: false, radar, radarEligible: radarRecognition.eligible, radarTouched: false, radarRemove: false, hideFromPlan: d.marker?.hidden === true, busy: false, }; } else { this.host._markerDialog = { name: '', binding: 'virtual', bindingMode: 'virtual', bindingOpen: false, showEntities: false, bindingFilter: '', icon: '', autoIcon: '', display: 'badge', rippleColor: '', rippleSize: 1.5, size: 1, angle: 0, tapAction: 'info', tapActionTouched: false, originalHasTapAction: false, originalTapAction: undefined, tapHintAnnouncement: '', toggleEntity: '', toggleEntityTouched: false, originalHasToggleEntity: false, originalToggleEntity: undefined, tapTarget: '', tapConfirm: false, runFilter: '', controls: [], controlsFilter: '', lightRole: 'auto', lightRoleTouched: false, originalHasIsLight: false, originalIsLight: undefined, lightEntity: '', lightEntityTouched: false, originalHasLightEntity: false, originalLightEntity: undefined, glowMode: 'auto', glowColor: this.host._fillColors.glow_light.c, glowBrightness: 100, glowColorDrafted: false, glowBrightnessDrafted: false, glowTouched: false, originalHasGlowColor: false, originalGlowColor: undefined, valueBadgeEnabled: false, valueBadgeSource: null, valueBadgePosition: 'right', valueBadgeTouched: false, originalHasValueBadge: false, originalValueBadge: undefined, valueSource: null, valueSourceTouched: false, originalHasValueSource: false, originalValueSource: undefined, useClimateTemp: false, glowRadius: '', model: '', link: '', description: '', pdfs: [], room: '', roomTouched: false, radar: null, radarEligible: false, radarTouched: false, radarRemove: false, hideFromPlan: false, busy: false, uploadId: 'up_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), }; } // #600 К10: снимок черновика — Save только при изменениях в edit if (this.host._markerDialog) rememberMarkerBaseline(this.host, this.host._markerDialog); } public _runCandidates(): { value: string; label: string; sub: string }[] { const out: { value: string; label: string; sub: string }[] = []; for (const dom of RUN_TARGET_DOMAINS) { for (const [eid, st] of Object.entries(this.host.hass.states)) { if (!eid.startsWith(dom + '.')) continue; if (!this.host._planEntityAvailable(eid)) continue; out.push({ value: eid, label: st?.attributes?.friendly_name || eid, sub: this.host._t(('run.' + dom) as any), }); } } return out.sort((a, b) => a.sub.localeCompare(b.sub) || a.label.localeCompare(b.label)); } public _bindingCandidates(): { value: string; label: string; sub: string }[] { const list = bindingCandidates({ hass: this.host._planHass, devices: this.host._devices, markers: this.host._markers, showEntities: !!this.host._markerDialog?.showEntities, currentBinding: this.host._markerDialog?.binding, currentDeviceId: this.host._markerDialog?.devId, labels: { device: this.host._t('marker.sub_device'), z2mGroup: this.host._t('marker.sub_z2m_group'), group: this.host._t('marker.sub_group'), helper: this.host._t('marker.sub_helper'), entity: this.host._t('marker.sub_entity'), }, }); const f = (this.host._markerDialog?.bindingFilter || '').toLowerCase().trim(); const filtered = f ? list.filter((o) => (o.label + ' ' + o.sub + ' ' + o.value).toLowerCase().includes(f)) : list; filtered.sort((a, b) => a.label.localeCompare(b.label)); return filtered.slice(0, 200); } public _keepClosedAsPartitions = (): void => { if (this.host._wallFaceBatch) { this._decideWallFace(false); return; } if (!this.host._contourClosed || this.host._pendingSplit || !this.host._curSpaceCfg) return; // Every accepted edge is already an ordinary partition. Rejecting all // detected faces only ends the session; it must not duplicate the chain. if (!this._finalizeWallChainPartitions(this.host._activeWallChainPartitionIds)) return; this.host._roomDialog = false; this.host._path = []; this.host._activeWallChainId = null; this.host._activeWallChainPartitionIds = []; this.host._wallChainSegmentCms = []; this.host._wallChainRedo = []; this.host._closingWallCm = null; this.host.requestUpdate(); }; public _backupErrorText(e: any): string { const code = e?.code ?? e?.error; if (typeof code === 'string') { const key = `backup.error.${code}`; const translated = this.host._t(key as I18nKey); if (translated !== key) return translated; } return this.host._errText(e); } public async _pickMarkerFiles(ev: Event): Promise { const input = ev.target as HTMLInputElement; const files = input.files ? [...input.files] : []; input.value = ''; if (!files.length || !this.host._markerDialog) return; const mid = this.host._markerDialog.uploadId || this.host._markerDialog.devId || 'new'; const uploaded: PdfRef[] = []; for (const file of files) { try { const fd = new FormData(); fd.append('marker_id', mid); fd.append('file', file, file.name); // fetchWithAuth refreshes a stale access_token itself; the fallback is the raw token const resp: Response = this.host.hass?.fetchWithAuth ? await this.host.hass.fetchWithAuth('/api/houseplan/upload', { method: 'POST', body: fd }) : await fetch('/api/houseplan/upload', { method: 'POST', body: fd, headers: this.host.hass?.auth?.data?.access_token ? { authorization: `Bearer ${this.host.hass.auth.data.access_token}` } : {}, }); const json = await resp.json().catch(() => ({})); if (!resp.ok || json.error) { const map: Record = { too_large: this.host._t('err.too_large', { mb: json.max_mb || 50 }), bad_ext: this.host._t('err.bad_ext'), unauthorized: this.host._t('err.unauthorized'), }; throw new Error(map[json.error] || json.error || 'HTTP ' + resp.status); } uploaded.push({ name: json.name || file.name, url: json.url }); } catch (e: any) { this.host._showToast(this.host._t('toast.file_failed', { name: file.name, err: this.host._errText(e) })); } } // the dialog might have closed during the upload — add only if it is still open if (uploaded.length && this.host._markerDialog) { this.host._markerDialog = { ...this.host._markerDialog, pdfs: [...this.host._markerDialog.pdfs, ...uploaded] }; this.host._showToast(this.host._t('toast.files_attached', { n: uploaded.length })); } } public _removeMarkerPdf(url: string): void { if (!this.host._markerDialog) return; this.host._markerDialog = { ...this.host._markerDialog, pdfs: this.host._markerDialog.pdfs.filter((p) => p.url !== url), }; } public _markerLightFields(d: NonNullable): Partial { const fields: Partial = {}; if (!d.lightRoleTouched) { if (d.originalHasIsLight) fields.is_light = d.originalIsLight ?? null; } else if (d.lightRole === 'always') fields.is_light = true; else if (d.lightRole === 'never') fields.is_light = false; if (!d.lightEntityTouched) { if (d.originalHasLightEntity) fields.light_entity = d.originalLightEntity ?? null; } else if (d.lightEntity) { fields.light_entity = d.lightEntity; } if (!d.glowTouched) { if (d.originalHasGlowColor) fields.glow_color = d.originalGlowColor ?? null; } else if (d.glowMode !== 'auto') { const glow: NonNullable = { c: safeStoredColor(d.glowColor, this.host._fillColors.glow_light.c), }; if (d.glowMode === 'fixed') { glow.bri = Math.max(0.01, Math.min(1, Math.round(d.glowBrightness) / 100)); } fields.glow_color = glow; } return fields; } public _markerTapActionFields( d: NonNullable, ): Pick | Record { if (!d.tapActionTouched) { return d.originalHasTapAction ? { tap_action: d.originalTapAction ?? null } : {}; } return { tap_action: d.tapAction || null }; } public _markerToggleEntityFields( d: NonNullable, ): Pick | Record { return toggleEntityWriteFields({ touched: d.toggleEntityTouched, originalHas: d.originalHasToggleEntity, original: d.originalToggleEntity, value: d.toggleEntity, }); } public async _saveMarker(): Promise { const dlg = this.host._markerDialog; if (!dlg || dlg.busy) return; const effectiveTapAction = this._effectiveMarkerTapAction(dlg); if (dlg.bindingMode === 'ha' && (!dlg.binding || dlg.binding === 'virtual')) return; if (dlg.binding === 'virtual' && !dlg.name.trim()) return; if (effectiveTapAction === 'run' && !dlg.tapTarget) return; if (dlg.valueBadgeTouched && dlg.valueBadgeEnabled && !dlg.valueBadgeSource) return; if (dlg.bindingMode === 'ha') { const status = this.host._bindingStatus(dlg.binding); const previous = dlg.devId ? this.host._markers.find((marker) => marker.id === dlg.devId) : null; const replacingWithInactive = !previous || previous.binding !== dlg.binding; if (status.kind !== 'active' && replacingWithInactive) { this.host._showToast(this.host._t(status.kind === 'ha_disabled' ? 'toast.ha_disabled_add' : 'toast.ha_binding_unverified')); return; } // Saving metadata for an already-disabled binding is allowed, but a // blocked Show attempt may never erase an older explicit user hidden. if (status.kind === 'ha_disabled' && previous?.hidden === true && !dlg.hideFromPlan) { this.host._markerDialog = { ...dlg, hideFromPlan: true }; this.host._showToast(this.host._t(status.reason === 'entity' ? 'toast.ha_disabled_show_entity' : 'toast.ha_disabled_show_device')); return; } } const cfg = this.host._serverCfg; if (!cfg) return; const baseRevision = this.host._cfgRev; const baseContent = contentFingerprint(cfg); let candidate = JSON.parse(JSON.stringify(cfg)) as ServerConfig; const markers = candidate.markers || []; const id = markerIdForBinding(dlg.binding, dlg.devId, () => 'v_' + Date.now().toString(36)); const oldId = dlg.devId; const prevDev = oldId ? this.host._devices.find((x) => x.id === oldId) : null; const previousMarker = oldId ? markers.find((candidate) => candidate.id === oldId) : null; const previousExplicit = !!previousMarker && ( (typeof previousMarker.area === 'string' && previousMarker.area.length > 0) || (previousMarker.area === null && !!previousMarker.space && !!previousMarker.room_id) ); // Opening a registry-following marker shows its effective HA room in the // select, but that display value is not an explicit override. Preserve // absence until the user actually changes the selector; this also prevents // a dialog opened during an HA Area transition from saving its stale draft. const writePlacement = dlg.binding === 'virtual' || dlg.roomTouched || previousExplicit; const roomRef = dlg.binding === 'virtual' || dlg.roomTouched ? parseRoomRef(dlg.room) : null; let space: string | null = previousExplicit && !dlg.roomTouched ? previousMarker?.space || null : roomRef?.space || null; const area: string | null = previousExplicit && !dlg.roomTouched ? previousMarker?.area || null : roomRef?.area || null; const roomId: string | null = previousExplicit && !dlg.roomTouched ? previousMarker?.room_id || null : roomRef?.roomId || null; const explicitSpaceId = space || prevDev?.space || null; const targetSpaceModel = explicitSpaceId ? this.host._spaceModelById(explicitSpaceId) : this.host._spaceModel(); if (!targetSpaceModel) return; const targetSpaceId = targetSpaceModel.id; if (dlg.binding === 'virtual' && !space) space = targetSpaceId; let radarField: Pick | Record = {}; if (dlg.radarTouched) { if (dlg.radarRemove) { radarField = { radar: null }; } else if (dlg.radar) { const radar = radarConfigFromDraft(dlg.radar, targetSpaceModel.cellCm || 5); if (!radar) { this.host._showToast(this.host._t('radar.invalid')); return; } radarField = { radar }; } } else if (previousMarker && Object.prototype.hasOwnProperty.call(previousMarker, 'radar')) { radarField = { radar: previousMarker.radar }; } this.host._markerDialog = { ...dlg, busy: true }; let attempt: OptimisticAttempt | null = null; let configAccepted = false; try { const replacedRemovedIds = dlg.binding === 'virtual' ? [] : markers .filter((m) => m.removed && m.binding === dlg.binding) .map((m) => m.id); const replacingRemoved = replacedRemovedIds.length > 0; const controls = persistedExternalControls( dlg.binding, dlg.controls, this.host._bindingEntities(dlg.binding), ); // the vacuum block is edited live outside the dialog transaction — // the rebuild below must carry it over, not erase it const prevVac = markers.find((m0: Marker) => m0.id === id || m0.id === oldId)?.vacuum || null; const marker: Marker = { id, vacuum: prevVac, ...radarField, binding: dlg.binding, name: dlg.name.trim() || null, icon: dlg.icon || null, display: dlg.display !== 'badge' ? dlg.display : null, ripple_color: dlg.display === 'icon_ripple' && dlg.rippleColor ? dlg.rippleColor : null, ripple_size: dlg.display === 'icon_ripple' && dlg.rippleSize !== 1.5 ? dlg.rippleSize : null, size: dlg.size !== 1 ? dlg.size : null, angle: dlg.angle ? dlg.angle : null, ...this._markerTapActionFields(dlg), ...this._markerToggleEntityFields(dlg), tap_target: effectiveTapAction === 'run' ? dlg.tapTarget || null : null, tap_confirm: dlg.tapConfirm ? true : null, controls: controls.length ? controls : null, // pdfs may be rewritten below when rebinding changes the marker id ...this._markerLightFields(dlg), ...this._markerValueBadgeFields(dlg), ...this._markerValueSourceFields(dlg), use_climate_temp: dlg.useClimateTemp ? true : null, glow_radius_cm: (() => { const v = strictNumber(dlg.glowRadius); if (v == null || v <= 0) return null; return Math.round(this.host._imperial ? v * 30.48 : v * 100); })(), model: dlg.model.trim() || null, link: dlg.link.trim() || null, description: dlg.description.trim() || null, pdfs: dlg.pdfs, // Explicit false, not absence: a marker of any kind is what tells the // seeder "the user decided" — unticking must not invite a re-hide // (docs/FILTERING.md). hidden: dlg.hideFromPlan ? true : false, }; // save the room choice (always for virtual ones; for bound ones — if chosen) if (writePlacement) { marker.space = space; marker.area = area; marker.room_id = roomId; } // the room changed → move the icon to its center const prevRoomId = prevDev?.marker?.room_id ?? null; const roomChanged = dlg.roomTouched && prevDev != null && (prevDev.space !== space || prevDev.area !== area || prevRoomId !== roomId); // Rebinding changes the marker id, so the uploaded files must follow. // Order matters (review CR-2): COPY first, save the config, and only then // delete the old folder. If the save is rejected, the old urls in the // stored config still resolve — the files never left. A failed copy // leaves the urls untouched and tells the user (review CR-3). let cleanupOldFiles = false; // a new icon uploaded into its own staging folder; an edited one into its // own id. Either way the files move to the final id here. const fileSrc = dlg.uploadId || oldId; if (fileSrc && fileSrc !== id && marker.pdfs?.length) { try { const res: any = await this.host.hass.callWS({ type: 'houseplan/files/migrate', from_id: fileSrc, to_id: id, }); const mapping = res?.mapping || {}; marker.pdfs = migratePdfUrls(marker.pdfs, fileSrc, id, mapping); cleanupOldFiles = Object.keys(mapping).length > 0; } catch (e: any) { this.host._showToast(this.host._t('toast.files_migrate_failed', { err: this.host._errText(e) })); } } // Rebinding changes source identity. Rewrite every marker:* edge in the // same config transaction before replacing the marker itself. candidate.markers = markers; if (oldId && oldId !== id) candidate.markers = rewriteMarkerControlReferences(candidate.markers, oldId, id); if (oldId && oldId !== id && marker.value_badge?.source?.kind === 'derived_marker_state' && marker.value_badge.source.ref === `marker:${oldId}`) { marker.value_badge.source = { kind: 'derived_marker_state', ref: `marker:${id}` }; } if (oldId && oldId !== id && marker.value_source?.kind === 'derived_marker_state' && marker.value_source.ref === `marker:${oldId}`) { marker.value_source = { kind: 'derived_marker_state', ref: `marker:${id}` }; } // remove the previous marker (by the old id and by the new id) candidate.markers = candidate.markers.filter( (m) => m.id !== id && m.id !== oldId && (marker.binding === 'virtual' || m.binding !== marker.binding), ); candidate.markers.push(marker); const obsoleteAreaSnapshotIds = new Set(replacedRemovedIds); if (oldId && oldId !== id) obsoleteAreaSnapshotIds.add(oldId); obsoleteAreaSnapshotIds.delete(id); if (obsoleteAreaSnapshotIds.size && candidate.settings?.marker_area_snapshot) { candidate.settings = { ...candidate.settings, marker_area_snapshot: removeMarkerAreaSnapshots( candidate.settings.marker_area_snapshot, obsoleteAreaSnapshotIds, ), }; } // Position rule (owner's decision, v1.33.4): editing an existing icon — // rebinding it to another HA device/entity or to another room — must NOT // move it. Its current position (saved or the ephemeral auto one) is // migrated to the new marker id. Only two cases still center the icon: // a truly NEW icon, and a move to a room in a DIFFERENT space (keeping // the old coordinates there would be meaningless). // Write POINT-WISE (layout/update), not the whole layout — a full layout/set // overwrites positions changed in other windows (the v1.4.4 incident). let newPos: { s: string; x: number; y: number } | null = null; const prevRec = oldId ? this.host._layout[oldId] : null; const prevPos = prevRec ? { s: prevRec.s || prevDev?.space || this.host._space, x: prevRec.x, y: prevRec.y } : oldId && prevDev && this.host._defPos[oldId] ? this.host._normPos(prevDev.space, this.host._defPos[oldId].x, this.host._defPos[oldId].y) : null; if (!replacingRemoved && prevPos && prevPos.s === targetSpaceId) { // stays in place; pin it under the (possibly new) id if (id !== oldId || !this.host._layout[id] || roomChanged) { newPos = { s: prevPos.s, x: prevPos.x, y: prevPos.y }; } } else if (replacingRemoved || !this.host._layout[id] || roomChanged) { let cx = targetSpaceModel.vb[0] + targetSpaceModel.vb[2] / 2; let cy = targetSpaceModel.vb[1] + targetSpaceModel.vb[3] / 2; const room = roomId ? targetSpaceModel.rooms.find((r) => r.id === roomId) : area ? targetSpaceModel.rooms.find((r) => r.area === area) : undefined; if (room) [cx, cy] = this.host._roomCenter(room); newPos = this.host._normPos(targetSpaceId, cx, cy); } // File copy is async: never install a candidate built from a replaced root. if (this.host._serverCfg !== cfg || this.host._cfgRev !== baseRevision || contentFingerprint(cfg) !== baseContent) { if (this.host._markerDialog) this.host._markerDialog = { ...this.host._markerDialog, busy: false }; this.host._showToast(this.host._t('toast.conflict')); return; } candidate = this._prepareConfigCandidate(candidate); attempt = this.host._adoption.beginOptimistic(cfg, candidate); this.host._serverCfg = candidate; this.host._regSignature = ''; this.host._maybeRebuildDevices(); this.host.requestUpdate(); if (this.host._saveConfigDebounced.pending()) this.host._saveConfigDebounced.cancel(); await this._saveConfigNow(attempt); configAccepted = true; if (newPos) { const pos = canonicalizePosition(newPos); this.host._layout = { ...this.host._layout, [id]: pos }; this.host._noteLayoutRev(await this.host.hass.callWS({ type: 'houseplan/layout/update', device_id: id, pos, })); } const obsoleteIds = new Set(replacedRemovedIds); if (oldId && oldId !== id) obsoleteIds.add(oldId); obsoleteIds.delete(id); for (const obsoleteId of obsoleteIds) { // Rebinding or replacing a legacy tombstone changed the icon id. delete this.host._layout[obsoleteId]; await this.host.hass.callWS({ type: 'houseplan/layout/delete', device_id: obsoleteId }) .then((r: any) => this.host._noteLayoutRev(r)).catch(() => undefined); } // the config is committed — now it is safe to drop the old folder if (cleanupOldFiles && fileSrc) { await this.host.hass .callWS({ type: 'houseplan/files/cleanup', marker_id: fileSrc }) .catch(() => undefined); // leftovers are harmless; broken links are not } forgetMarkerBaseline(this.host); this._closeMarkerDialog(); this.host._cancelDeviceDrag(); this.host._devicePositionHistory.clear(); this.host._regSignature = ''; this.host._maybeRebuildDevices(); this.host._showToast(this.host._t('toast.marker_saved')); } catch (e: any) { // audit L3: the dialog may have been closed (Esc) while the save was // in flight — spreading null yields a truthy husk and the renderer // then crashes, blanking the whole card. The toast below is the // only remaining signal, so it must still fire. if (!configAccepted && attempt) { this.host._rollbackOptimistic(attempt); this.host._regSignature = ''; this.host._maybeRebuildDevices(); this.host.requestUpdate(); } if (this.host._markerDialog) this.host._markerDialog = { ...this.host._markerDialog, busy: false }; this.host._showToast(this.host._t('toast.error', { err: this.host._errText(e) })); } } public async _deleteMarker(): Promise { const dlg = this.host._markerDialog; if (!dlg || dlg.busy || !dlg.devId) return; const d = dlg.devId ? this.host._devices.find((x) => x.id === dlg.devId) : null; const persisted = this.host._markers.find((marker) => marker.id === dlg.devId); if (!d && !persisted) return; const expectedBinding = d ? d.bindingKind === 'virtual' ? 'virtual' : d.bindingKind && d.bindingRef ? `${d.bindingKind}:${d.bindingRef}` : '' : persisted!.binding; if (!expectedBinding) return; const label = dlg.name || this.host._t('device.fallback'); const targetId = dlg.devId; const accepted = await this.host._confirmDanger({ key: 'remove-marker', kind: 'destructive', title: this.host._t('confirm.remove_marker_title'), message: this.host._t('confirm.remove_marker_body'), objectName: label, confirmLabel: this.host._t('btn.delete'), cancelLabel: this.host._t('btn.cancel'), }); const currentDialog = this.host._markerDialog; if (!accepted || !currentDialog || currentDialog.busy || currentDialog.devId !== targetId) return; const currentDevice = this.host._devices.find((item) => item.id === targetId); const currentPersisted = this.host._markers.find((marker) => marker.id === targetId); if (!currentDevice && !currentPersisted) return; const cfg = this.host._serverCfg; if (!cfg) return; cfg.markers = cfg.markers || []; const previousMarkers = cfg.markers; const binding = currentDevice ? currentDevice.bindingKind === 'virtual' ? 'virtual' : currentDevice.bindingKind && currentDevice.bindingRef ? `${currentDevice.bindingKind}:${currentDevice.bindingRef}` : '' : currentPersisted!.binding; if (!binding || binding !== expectedBinding) return; const deletion = deletePlanMarkerRecords( cfg.markers, targetId, binding, binding === 'virtual', ); cfg.markers = removeMarkerControlReferences(deletion.markers, deletion.cleanupIds); const cleanupIds = deletion.cleanupIds; if (cleanupIds.size && cfg.settings?.marker_area_snapshot) { cfg.settings = { ...cfg.settings, marker_area_snapshot: removeMarkerAreaSnapshots( cfg.settings.marker_area_snapshot, cleanupIds, ), }; } this.host._markerDialog = { ...currentDialog, busy: true }; try { await this._saveConfigNow(); // Housekeeping follows the durable config write. Every call is // idempotent and best-effort: the device is already deleted even if an // old integration cannot yet serve one of the cleanup commands. for (const id of cleanupIds) { delete this.host._layout[id]; delete this.host._defPos[id]; this.host._dirtyPos.delete(id); this.host._sentPos.delete(id); const activity = this.host._activityRt.get(id); if (activity) clearTimeout(activity.timer); this.host._activityRt.delete(id); this.host._vacRt.delete(id); delete this.host._vacSrvTrails[id]; await this.host.hass.callWS({ type: 'houseplan/layout/delete', device_id: id }) .then((r: any) => this.host._noteLayoutRev(r)).catch(() => undefined); await this.host.hass.callWS({ type: 'houseplan/files/cleanup', marker_id: id }) .catch(() => undefined); await this.host.hass.callWS({ type: 'houseplan/trail/delete', marker_id: id }) .catch(() => undefined); } if (this.host._deviceInboxReturn) { this.host._deviceInboxReturn = { ...this.host._deviceInboxReturn, tab: 'readd', anchor: binding }; } this._closeMarkerDialog(); this.host._cancelDeviceDrag(); this.host._devicePositionHistory.clear(); if (this.host._infoCard?.id === targetId) this.host._closeInfoCard(); if (this.host._selId === targetId) this.host._selId = null; if (this.host._drag?.id === targetId) this.host._drag = null; this.host._regSignature = ''; this.host._maybeRebuildDevices(); this.host._showToast(this.host._t('toast.marker_removed')); } catch (e: any) { if (this.host._serverCfg === cfg) cfg.markers = previousMarkers; if (this.host._markerDialog) this.host._markerDialog = { ...this.host._markerDialog, busy: false }; this.host._showToast(this.host._t('toast.error', { err: this.host._errText(e) })); } } public _openSpaceDialog(mode: 'edit' | 'create', spaceId?: string): void { if (!this.host._serverStorage || !this.host._serverCfg) { this.host._showToast(this.host._t('toast.integration_missing')); return; } if (mode === 'edit') { const sp = this.host._serverCfg!.spaces.find((x: any) => x.id === spaceId); if (!sp) return; const disp = spaceDisplayOf(sp); const storedCustom = sp.settings?.custom_fill && typeof sp.settings.custom_fill === 'object' ? customFillOf(sp.settings.custom_fill) : null; // Space-level None is no longer a separate UI choice. Project it to the // same custom colour at zero opacity, so merely opening/saving settings // cannot change either the visible floor or the Glow darkness. const dialogCustom = disp.fill === 'none' ? { ...(storedCustom || DEFAULT_CUSTOM_FILL), a: 0 } : storedCustom; this.host._spaceDialog = { mode, spaceId, title: sp.title, planUrl: sp.plan_url || null, planFile: null, source: sp.plan_url ? 'file' : 'draw', showBorders: disp.showBorders, showNames: disp.showNames, zeroWallStyle: zeroWallStyleOf(sp), displayTouched: true, hideDecor: disp.hideDecor, hideOpenings: disp.hideOpenings, roomColor: disp.color, roomOpacity: disp.opacity, // `none` is a legacy space token. The current editor represents its // exact appearance as a transparent user colour. fillMode: disp.fill === 'none' ? 'custom' : disp.fill, customFill: dialogCustom, glowEnabled: disp.glow, bgColor: disp.bgColor, bgMode: sp.settings?.bg_mode === 'static' || sp.settings?.bg_mode === 'daynight' ? sp.settings.bg_mode : null, northDeg: northDegOf({}, sp.settings), northDegInput: northDegOf({}, sp.settings) === null ? '' : String(northDegOf({}, sp.settings)), sunRays: typeof sp.settings?.sun_rays === 'boolean' ? sp.settings.sun_rays : null, tempMin: disp.tempMin, tempMax: disp.tempMax, tempMinInput: String(disp.tempMin), tempMaxInput: String(disp.tempMax), showLqi: disp.showLqi ?? this.host._config?.show_signal ?? true, cardFontScale: disp.cardFontScale, labelTemp: disp.labelTemp, labelHum: disp.labelHum, labelLqi: disp.labelLqi, labelLight: disp.labelLight, cellCm: Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5, cellCmInput: gridCellFieldValue( Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5, this.host._imperial, ), cellCmTouched: false, busy: false, }; } else { this.host._spaceDialog = { mode, title: '', planUrl: null, planFile: null, ...initialSpaceDisplayDraft(), hideDecor: false, hideOpenings: false, zeroWallStyle: 'dashed', roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'custom', // `custom` replaces the old `none` choice in the editor, but creating // a space must preserve the old no-floor visual by default. The user // can make this colour visible explicitly with the opacity control. customFill: { ...DEFAULT_CUSTOM_FILL, a: 0 }, glowEnabled: true, bgColor: null, bgMode: 'daynight', northDeg: null, northDegInput: '', sunRays: null, tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX, tempMinInput: String(DEFAULT_TEMP_MIN), tempMaxInput: String(DEFAULT_TEMP_MAX), showLqi: this.host._config?.show_signal ?? true, cardFontScale: 1, labelTemp: false, labelHum: false, labelLqi: false, labelLight: false, cellCm: newSpaceCellCm(this.host._imperial), cellCmInput: gridCellFieldValue(newSpaceCellCm(this.host._imperial), this.host._imperial), cellCmTouched: false, busy: false, }; } // #600 К10: снимок на момент открытия — от него считается «есть изменения». if (this.host._spaceDialog) rememberSpaceDialogBaseline(this.host, this.host._spaceDialog); } public async _pickPlanFile(ev: Event): Promise { const input = ev.target as HTMLInputElement; const file = input.files?.[0]; if (!file || !this.host._spaceDialog) return; // #39: re-selecting the same file after a guard decision must fire again. input.value = ''; this._decorAssetGuardReplace = null; // #617: the plan limit, the format check and the #39 guard live in one // shared helper, so the editor and onboarding cannot drift apart. const payload = await stagePlanFile(this.host, file); if (!payload || !this.host._spaceDialog) return; this.host._spaceDialog = { ...this.host._spaceDialog, planFile: payload }; } public _renderBackdropGuard(): TemplateResult | typeof nothing { const decorReplace = this._decorAssetGuardReplace; if (decorReplace !== null) { return renderBackdropGuard( this.host, () => undefined, () => { this.host._backdropGuard = null; this._decorAssetGuardReplace = null; }, this.host.hass, (blob, name) => this._uploadDecorImage(blob, name, decorReplace), (this.host._backdropGuard?.file.size || 0) <= 2 * 1024 * 1024, ) ?? nothing; } return renderPlanBackdropGuard( this.host, (payload) => { if (this.host._spaceDialog) { this.host._spaceDialog = { ...this.host._spaceDialog, planFile: payload }; } }, () => { this.host._backdropGuard = null; }, this.host.hass, ) ?? nothing; } public _toggleServerPlans = async (): Promise => { const d = this.host._spaceDialog; if (!d) return; if (d.pickSaved) { this.host._spaceDialog = { ...d, pickSaved: false }; return; } this.host._spaceDialog = { ...d, pickSaved: true, savedBusy: true }; try { const r: any = await this.host.hass.callWS({ type: 'houseplan/plans/list' }); const cur = this.host._spaceDialog; if (cur) this.host._spaceDialog = { ...cur, saved: r?.plans || [], savedBusy: false }; } catch (e: any) { const cur = this.host._spaceDialog; if (cur) this.host._spaceDialog = { ...cur, saved: [], savedBusy: false }; this.host._showToast(this.host._t('toast.plans_list_failed', { err: this.host._errText(e) })); } }; public _useServerPlan(url: string): void { const d = this.host._spaceDialog; if (!d) return; // Attach immediately — the click should not wait for anything. The OLD // file's proportions go right away: they describe the previous image, and // a Save racing the read must get "unknown", never "the wrong shape". this.host._spaceDialog = { ...d, planUrl: url, planFile: null, pickSaved: false, savedAspect: undefined }; this.host._aspectJob = this._readPlanAspect(url); } public async _readPlanAspect(url: string): Promise { for (let i = 0; i < 40; i++) { // ~6 s, then give up quietly const src = this.host._display(url); if (src) { const ratio = await new Promise((res) => { const img = new Image(); img.onload = () => res(img.naturalWidth && img.naturalHeight ? img.naturalWidth / img.naturalHeight : 0); img.onerror = () => res(0); img.src = src; }); const cur = this.host._spaceDialog; if (cur && cur.planUrl === url && Number.isFinite(ratio) && ratio > 0) { this.host._spaceDialog = { ...cur, savedAspect: ratio }; return ratio; } return 0; } await new Promise((r) => setTimeout(r, 150)); if (this.host._spaceDialog?.planUrl !== url) return 0; // the user moved on } return 0; } public async _deleteServerPlan(name: string): Promise { const dialog = this.host._spaceDialog; const plan = dialog?.saved?.find((candidate) => candidate.name === name); if (!dialog || !plan || plan.used_by.length || plan.url === dialog.planUrl) return; const accepted = await this.host._confirmDanger({ key: 'delete-plan', kind: 'destructive', title: this.host._t('confirm.delete_plan_title'), message: this.host._t('confirm.delete_plan_body'), objectName: name, confirmLabel: this.host._t('btn.delete'), cancelLabel: this.host._t('btn.cancel'), }); const currentDialog = this.host._spaceDialog; const currentPlan = currentDialog?.saved?.find((candidate) => candidate.name === name); if (!accepted || !currentDialog || !currentPlan || currentPlan.url !== plan.url || currentPlan.modified !== plan.modified || currentPlan.used_by.length || currentPlan.url === currentDialog.planUrl) return; try { await this.host.hass.callWS({ type: 'houseplan/plans/delete', name }); const d = this.host._spaceDialog; if (d?.saved) this.host._spaceDialog = { ...d, saved: d.saved.filter((p) => p.name !== name) }; } catch (e: any) { this.host._showToast(this.host._t('toast.plan_delete_failed', { err: this.host._errText(e) })); } } public _renderServerPlans(d: NonNullable): TemplateResult { if (d.savedBusy) return html`
${this.host._t('space.loading')}
`; const list = d.saved || []; if (!list.length) return html`
${this.host._t('space.no_saved')}
`; const kb = (n: number) => (n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : Math.round(n / 1024) + ' KB'); return html`
${list.map((p) => html`
${p.name} ${kb(p.size)}${p.used_by.length ? ' · ' + this.host._t('space.used_by', { list: p.used_by.join(', ') }) : ''}
`)}
`; } public async _saveSpaceDialog(): Promise { const d = this.host._spaceDialog; if (!d || d.busy || !d.title.trim()) return; if (spaceDialogProblems(d, 'space-dialog', this.host._imperial).length) return; if (d.source === 'file' && !d.planFile && !d.planUrl) { this.host._showToast(this.host._t('toast.plan_required')); return; } const wasFirst = d.mode === 'create' && (this.host._serverCfg?.spaces.length || 0) === 0; this.host._spaceDialog = { ...d, busy: true }; try { const spaceId = d.mode === 'create' ? 's' + Date.now().toString(36) : d.spaceId!; /* Upload BEFORE touching the config, and never hold a reference to a config object across an await. `houseplan/config/get` runs on every `houseplan_config_updated` event and REPLACES `_serverCfg`; a space object captured before the upload is then detached, so plan_url, aspect and settings were written into an orphan and the save shipped the untouched config. Symptom: the file lands on disk, the plan never appears, and re-saving does not help (owner's install, 2026-07-27). */ let uploaded: { url: string; aspect: number } | null = null; if (d.source === 'file' && d.planFile) { // #617: over HTTP, never WebSocket — a big plan used to close the socket. const resp = await uploadPlanFile( this.host.hass, (key, vars) => this.host._t(key, vars), spaceId, d.planFile, ); uploaded = { url: resp.url, aspect: d.planFile.aspect }; } // A plan picked from the server list reads its proportions from the // image, and Save used to outrun that read: the snapshot still carried // the PREVIOUS file's ratio and a wide plan came out at the old shape // for good (HP-1490-04). Wait for the read — it is bounded (~6 s) and // the dialog is already busy. Unknown stays unknown, never the old // value: a square fallback is honest, an inherited ratio is not. let pickedAspect: number | null = d.savedAspect || null; if (!uploaded && d.source === 'file' && d.planUrl && !pickedAspect && this.host._aspectJob) { pickedAspect = (await this.host._aspectJob) || null; } // from here on: no awaits until the save, so `sp` cannot be orphaned const cfg = this.host._serverCfg!; let sp: any; if (d.mode === 'create') { sp = createEmptySpaceConfig(spaceId, d.title.trim()); cfg.spaces.push(sp); } else { sp = cfg.spaces.find((x: any) => x.id === spaceId); if (!sp) throw new Error('space ' + spaceId + ' is gone from the config'); sp.title = d.title.trim(); } if (uploaded) { sp.plan_url = uploaded.url; // the image's own proportions, so it can be centred before it loads sp.plan_aspect = uploaded.aspect; } else if (d.source === 'file' && d.planUrl && d.planUrl !== sp.plan_url) { // picked from the server list: no upload, just a reference — and the // previous image's proportions never survive the switch sp.plan_url = d.planUrl; sp.plan_aspect = pickedAspect; } // switching an existing space to "draw" detaches its background image // (the uploaded file stays on disk; only the reference is cleared). // Its transform goes with it — there is nothing left for plan_x/plan_y/ // plan_scale to describe, and a stale one would silently apply to the // NEXT picture uploaded here (docs/DECOR-EDITOR.md §3 §1). if (d.source === 'draw') { sp.plan_url = null; sp.plan_aspect = null; delete sp.plan_x; delete sp.plan_y; delete sp.plan_scale; delete sp.plan_scale_x; delete sp.plan_scale_y; delete sp.plan_angle; } // Persist exactly what the dialog showed. Source defaults are projected // visibly while editing the create state, never hidden here at Save. sp.settings = { ...(sp.settings || {}), show_borders: d.showBorders, show_names: d.showNames, // written only when ON: a plan that never hid anything stores nothing hide_decor: d.hideDecor || undefined, hide_openings: d.hideOpenings || undefined, room_color: d.roomColor, room_opacity: d.roomOpacity, bg_color: d.bgColor || undefined, // empty = inherit the general setting bg_mode: d.bgMode || undefined, // sun on the plan (docs/SUN.md); empty = inherit north_deg: d.northDeg ?? undefined, sun_rays: d.sunRays ?? undefined, fill_mode: d.fillMode, custom_fill: d.customFill || undefined, // Always materialize the independent value. In particular, editing a // legacy fill_mode:'glow' space preserves its appearance atomically // while replacing the old token with the selected data fill. glow_enabled: d.glowEnabled, temp_min: Number.isFinite(d.tempMin) ? Math.min(d.tempMin, d.tempMax) : DEFAULT_TEMP_MIN, temp_max: Number.isFinite(d.tempMax) ? Math.max(d.tempMin, d.tempMax) : DEFAULT_TEMP_MAX, show_lqi: d.showLqi, card_font_scale: d.cardFontScale !== 1 ? d.cardFontScale : undefined, label_temp: d.labelTemp, label_hum: d.labelHum, label_lqi: d.labelLqi, label_light: d.labelLight, }; // This is a geometry policy of the space, not a visual room setting. sp.zero_wall_style = d.zeroWallStyle; sp.cell_cm = Number.isFinite(d.cellCm) && d.cellCm > 0 ? Math.max(CELL_CM_MIN, Math.min(CELL_CM_MAX, d.cellCm)) : 5; // Nothing to clean up from here: the backend collects the superseded // file inside the same locked transaction that accepted this config // (review R3-1). A cleanup driven from the client could not be ordered // against another client's commit and deleted its freshly saved plan. await this._saveConfigNow(); this.host._spaceDialog = null; if (d.mode === 'create') this.host._commitSpace(sp.id); this.host._regSignature = ''; this.host._maybeRebuildDevices(); if (this.host._importQueue.length) { // floors-import wizard: proceed to the next floor this._openNextImport(); } else if (wasFirst || this.host._importTotal > 0) { // guide the user onward: straight into room markup mode this.host._importTotal = 0; this.host._commitSpace(this.host._serverCfg!.spaces[0]?.id || this.host._space); this._setMode('plan'); this.host._tool = 'draw'; this.host._path = []; this.host._cursorPt = null; this._primeDrawWallField(); this.host._showToast(this.host._t(wasFirst && !this.host._importTotal ? 'toast.space_added_onboard' : 'import.done')); } else { this.host._showToast(d.mode === 'create' ? this.host._t('toast.space_added') : this.host._t('toast.space_saved')); // A freshly created space is empty — View has nothing to show. Open // the plan editor (draw tool) so the user can mark rooms immediately, // same as the first-space onboard path above. if (d.mode === 'create') { if (this.host._mode !== 'plan') this._setMode('plan'); else { this.host._tool = 'draw'; this.host._path = []; this.host._cursorPt = null; this._primeDrawWallField(); this.host._saveNav(); } } } } catch (e: any) { // The dialog edits the reactive config optimistically before config/set. // A rejected create used to leave that unsaved space on screen; the next // delete then flushed the same malformed write and repeated its error. // Re-adopt server truth for every failed space transaction. Conflict is // already reloaded by _saveConfigNow; do not issue the same read twice. // A lost response is safe too: if the server did commit, config/get // returns it. if (e?.code !== 'conflict') await this.host._reloadConfigOnly(true); // audit L3: the dialog may have been closed (Esc) while the save was // in flight — spreading null yields a truthy husk and the renderer // then crashes, blanking the whole card. The toast below is the // only remaining signal, so it must still fire. if (this.host._spaceDialog) this.host._spaceDialog = { ...this.host._spaceDialog, busy: false }; this.host._showToast(this.host._t('toast.error', { err: this.host._errText(e) })); } } public async _deleteSpace(): Promise { const d = this.host._spaceDialog; if (!d || d.mode !== 'edit') return; const serverCfg = this.host._serverCfg; if (!serverCfg) return; const sp = serverCfg.spaces.find((x: any) => x.id === d.spaceId); const dependencies = collectSpaceMarkerDependencies( serverCfg, this.host._layout || {}, d.spaceId || '', ); const deletingLastSpace = serverCfg.spaces.length === 1 && serverCfg.spaces[0]?.id === d.spaceId; if (dependencies.count && !deletingLastSpace) { this.host._spaceDialog = { ...d, deleteBlockers: dependencies.count }; return; } if (!sp) return; const spaceId = d.spaceId!; const accepted = await this.host._confirmDanger({ key: 'delete-space', kind: 'destructive', title: this.host._t('confirm.delete_space_title'), message: spaceDeletionMessage(this.host._t('confirm.delete_space_body'), this.host._t('confirm.delete_space_vac_routes'), dependencies.routeCount), objectName: sp.title, confirmLabel: this.host._t('btn.delete'), cancelLabel: this.host._t('btn.cancel'), }); const currentDialog = this.host._spaceDialog; const currentConfig = this.host._serverCfg; if (!accepted || !currentDialog || currentDialog.mode !== 'edit' || currentDialog.busy || currentDialog.spaceId !== spaceId || !currentConfig) return; const currentSpace = currentConfig.spaces.find((candidate) => candidate.id === spaceId); if (!currentSpace) return; const currentDependencies = collectSpaceMarkerDependencies( currentConfig, this.host._layout || {}, spaceId, ); const currentlyDeletingLastSpace = currentConfig.spaces.length === 1 && currentConfig.spaces[0]?.id === spaceId; if (currentDependencies.count && !currentlyDeletingLastSpace) { this.host._spaceDialog = { ...currentDialog, deleteBlockers: currentDependencies.count, }; return; } this.host._spaceDialog = { ...currentDialog, deleteBlockers: 0, busy: true }; try { if (this.host._saveConfigDebounced.pending()) this.host._saveConfigDebounced.flush(); if (this.host._persistLayout.pending()) this.host._persistLayout.flush(); await this.host._writeChain; await this.host.hass.callWS({ type: 'houseplan/space/delete', space_id: spaceId, expected_config_rev: this.host._cfgRev, expected_layout_rev: this.host._layoutRev, }); const [configResponse, layoutResponse] = await Promise.all([ this.host._getAuthoritativeConfig(), this.host.hass.callWS({ type: 'houseplan/layout/get' }), ]); // #500: revisions come with the re-read bodies, never from the delete reply. const adopted = await this.host._adoptAuthoritative({ cfgResp: configResponse, layResp: layoutResponse, reason: 'space-delete', profile: 'post-write' }); // Asset wait: nothing adopted, the scheduled reload owns the tail (same as every reload path). if (adopted.status !== 'adopted') { this.host._spaceDialog = { ...currentDialog, busy: false }; this.host.requestUpdate(); return; } this.host._spaceDialog = null; if (this.host._space === spaceId) this.host._commitSpace(this.host._serverCfg!.spaces[0]?.id || ''); this.host._regSignature = ''; this.host._maybeRebuildDevices(); this.host._showToast(this.host._t('toast.space_deleted')); } catch (e: any) { if (e?.code === 'conflict' || e?.code === 'space_in_use') { await Promise.all([this.host._reloadConfigOnly(true), this.host._reloadLayoutOnly()]); } const refreshedConfig = this.host._serverCfg; if (this.host._spaceDialog && refreshedConfig) { const refreshed = collectSpaceMarkerDependencies( refreshedConfig, this.host._layout || {}, spaceId, ); const stillLastSpace = refreshedConfig.spaces.length === 1 && refreshedConfig.spaces[0]?.id === spaceId; this.host._spaceDialog = { ...this.host._spaceDialog, busy: false, deleteBlockers: stillLastSpace ? 0 : refreshed.count, }; } this.host._showToast(this.host._t('toast.delete_failed', { err: this.host._errText(e) })); } } public async _saveConfigNow(attempt: OptimisticAttempt | null = null): Promise { this.host._cfgEpoch++; try { // same queue as the debounced writer: a dialog saving while a background // write is still out must not race it into a self-inflicted conflict await this._writeConfig(attempt); } catch (e: any) { if (e?.physicalGeometryRolledBack) await this._reloadRejectedPhysicalWrite(); else if (e?.code === 'conflict') await this.host._reloadConfigOnly(); throw e; } } public _saveSpaceCopy(): Promise { return saveSpaceCopy(this.host, { clearGeometryGesture: () => this._clearGeometryGesture(), optimizeReferenceContext: () => this._optimizeReferenceContext(false), reportPreflightFailure: (result, config) => this.optimizePlans.reportPreflightFailure(result, config), saveConfigNow: (attempt) => this._saveConfigNow(attempt), setMode: () => this._setMode('plan'), showWallModelMigrationBlocked: (error) => this._showWallModelMigrationBlocked(error), }); } public _startImport(): void { const dlg = this.host._importDialog; if (!dlg) return; const titles = dlg.floors.filter((f) => f.checked).map((f) => f.name); this.host._importDialog = null; if (!titles.length) { this._openSpaceDialog('create'); return; } this.host._importQueue = titles; this.host._importTotal = titles.length; this._openNextImport(); } public _openNextImport(): void { const title = this.host._importQueue.shift(); if (title === undefined) return; this.host._spaceDialog = { mode: 'create', title, planUrl: null, planFile: null, ...initialSpaceDisplayDraft(), hideDecor: false, hideOpenings: false, zeroWallStyle: 'dashed', roomColor: DEFAULT_ROOM_COLOR, roomOpacity: DEFAULT_ROOM_OPACITY, fillMode: 'custom', customFill: null, glowEnabled: true, bgColor: null, bgMode: 'daynight', northDeg: null, northDegInput: '', sunRays: null, tempMin: DEFAULT_TEMP_MIN, tempMax: DEFAULT_TEMP_MAX, tempMinInput: String(DEFAULT_TEMP_MIN), tempMaxInput: String(DEFAULT_TEMP_MAX), showLqi: this.host._config?.show_signal ?? true, cardFontScale: 1, labelTemp: false, labelHum: false, labelLqi: false, labelLight: false, cellCm: newSpaceCellCm(this.host._imperial), cellCmInput: gridCellFieldValue(newSpaceCellCm(this.host._imperial), this.host._imperial), cellCmTouched: false, busy: false, }; } public _skipImport(): void { this.host._spaceDialog = null; if (this.host._importQueue.length) this._openNextImport(); else if (this.host._importTotal > 0 && this.host._model.length) { this.host._importTotal = 0; this.host._commitSpace(this.host._serverCfg!.spaces[0]?.id || this.host._space); this._setMode('plan'); this.host._showToast(this.host._t('import.done')); } } public _renderImportDialog(): TemplateResult { const d = this.host._importDialog!; const n = d.floors.filter((f) => f.checked).length; return html` (this.host._importDialog = null)}>
${this.host._t('import.hint')}
${d.floors.map( (f, i) => html``, )}
`; } public _openSettingsDialog = (): void => { if (!this.host._norm) return; // deep copy so the dialog edits do not leak into the live palette const cm = this.host._glowRadiusCm; const glowRadius = this.host._imperial ? Math.round((cm / 30.48) * 10) / 10 : Math.round(cm) / 100; this.host._settingsDialog = { colors: JSON.parse(JSON.stringify(this.host._fillColors)), glowRadius, glowRadiusInput: String(glowRadius), bgColor: stageBgOf(this.host._settings, { bgColor: null }) || null, northDeg: northDegOf(this.host._settings, {}), northDegInput: northDegOf(this.host._settings, {}) === null ? '' : String(northDegOf(this.host._settings, {})), bgMode: bgModeOf(this.host._settings, {}), sunRays: sunRaysOn(this.host._settings, {}), sunRayOrigin: sunRayOriginOf(this.host._settings), radarShowLive: this.host._settings.radar?.show_live !== false, volumetricView: volumetricViewOf(this.host._settings), moon: moonDraftOf(this.host._settings), showRoomTooltip: showRoomTooltipOf(this.host._settings), zigbeeTopology: zigbeeTopologySettingsOf(this.host._settings), busy: false, }; // #600 К10: снимок на момент открытия — от него считается «есть изменения». rememberGeneralBaseline(this.host, this.host._settingsDialog); openMoonStatus(this.host); // #718 K7: the status line, outside the draft }; /** #731: the card revived General settings — a new opening, so a new snapshot. */ public _openMoonStatus(): void { openMoonStatus(this.host); } public _openSupportDialog = (): void => { if (!this.host._norm || !this.host._canEdit) return; clearTimeout(this._supportExpiryTimer); this._supportExpiryTimer = undefined; this._supportPreviewGeneration += 1; this.host._supportDialog = newSupportDialogState(); }; private _supportPatch( draftId: string, patch: Partial, ): SupportDialogState | null { const current = this.host._supportDialog; if (!current || current.draftId !== draftId) return null; const next = { ...current, ...patch }; this.host._supportDialog = next; return next; } private async _focusSupport(selector: string): Promise { await this.host.updateComplete; this.host.renderRoot.querySelector(selector)?.focus({ preventScroll: true }); } private _updateSupportDraft(field: 'contact' | 'message', value: string): void { const current = this.host._supportDialog; if (!current || current.status === 'building' || current.status === 'sending' || current.status === 'success') return; const candidate: SupportDialogState = { ...current, [field]: value }; // Validate text independently from the optional preview. Editing must clear // stale transport errors, while an expired prepared package remains explicit. const textError = supportDraftError({ ...candidate, attach: false, preview: null }); const showRequired = textError === 'message_required' && current.errorCode === 'validation.message_required'; const immediateError = textError === 'message_too_long' || textError === 'contact_too_long'; if (showRequired || immediateError) { this._supportPatch(current.draftId, { [field]: value, status: 'error', errorCode: `validation.${textError}`, }); return; } if (candidate.attach && candidate.preview && candidate.preview.expiresAt <= Date.now()) { this._supportPatch(current.draftId, { [field]: value, status: 'error', errorCode: 'support_preview_expired', }); return; } this._supportPatch(current.draftId, { [field]: value, status: candidate.attach && candidate.preview ? 'ready' : 'idle', errorCode: '', }); } public async _discardSupportPreview(token: string): Promise { if (!token) return; try { await this.host.hass.callWS({ type: 'houseplan/support/preview/discard', token }); } catch { // Cleanup is best-effort; backend TTL is the final privacy guard. } } public async _closeSupportDialog(): Promise { const dialog = this.host._supportDialog; if (!dialog) return; if (dialog.status === 'building' || dialog.status === 'sending') { const lang = langOf(this.host.hass, this.host._config?.language); const accepted = await this.host._confirmDanger({ key: 'close-support-busy', kind: 'warning', title: supportT(lang, 'support.close_busy_title'), message: supportT(lang, 'support.close_busy_body'), confirmLabel: this.host._t('btn.close'), cancelLabel: this.host._t('btn.cancel'), }); if (!accepted) { await this.host.updateComplete; this.host.renderRoot.querySelector('#support-dialog')?.rejectClose(); return; } } clearTimeout(this._supportExpiryTimer); this._supportExpiryTimer = undefined; this.host._supportDialog = null; if (dialog.preview?.token && dialog.status !== 'success') { void this._discardSupportPreview(dialog.preview.token); } } private _scheduleSupportExpiry(draftId: string, preview: SupportPreview): void { clearTimeout(this._supportExpiryTimer); const delay = Math.max(0, preview.expiresAt - Date.now()); this._supportExpiryTimer = window.setTimeout(() => { const current = this.host._supportDialog; if (!current || current.draftId !== draftId || current.preview?.token !== preview.token || !current.attach || current.status === 'success' || current.status === 'sending') return; this._supportPatch(draftId, { status: 'error', errorCode: 'support_preview_expired', }); }, Math.min(delay + 20, 2_147_483_647)); } private _supportFacts(): ReturnType { const registry = this.host._haRegistry; return supportRuntimeFacts({ userAgent: globalThis.navigator?.userAgent || '', language: langOf(this.host.hass, this.host._config?.language), registryAccess: registry.access, registryLastSuccess: registry.lastSuccess, }); } private _supportPreviewRequestIsCurrent(draftId: string, generation: number): boolean { const current = this.host._supportDialog; return generation === this._supportPreviewGeneration && current?.draftId === draftId && current.attach; } private async _buildSupportPreview(draftId: string): Promise { const current = this.host._supportDialog; if (!current || current.draftId !== draftId || !current.attach || !supportApiCompatible(this.host._haSupportApi)) return; const generation = ++this._supportPreviewGeneration; this._supportPatch(draftId, { status: 'building', errorCode: '' }); let issuedToken = ''; try { const response: unknown = await this.host.hass.callWS({ type: 'houseplan/support/preview', card_version: displayVersion(CARD_VERSION), ...this._supportFacts(), draft_id: draftId, }); const payload = response && typeof response === 'object' ? response as Partial> : {}; const now = Date.now(); const text = typeof payload.text === 'string' ? payload.text : ''; const size = Number(payload.size); const expiresIn = Number(payload.expires_in); const spaces = Number(payload.spaces); const token = String(payload.token || ''); if (/^[0-9a-f]{48}$/.test(token)) issuedToken = token; const sha256 = String(payload.sha256 || ''); const version = Number(payload.version); const format = String(payload.format || ''); const byteSize = new TextEncoder().encode(text).byteLength; if (!/^[0-9a-f]{48}$/.test(token) || !/^[0-9a-f]{64}$/.test(sha256) || format !== 'houseplan-support-package' || version !== 1 || !Number.isInteger(size) || size !== byteSize || !Number.isInteger(spaces) || spaces < 0 || !Number.isFinite(expiresIn) || expiresIn <= 0 || expiresIn > 600 || !text.endsWith('\n')) { throw { code: 'support_rejected' }; } const preview: SupportPreview = { token, expiresAt: now + expiresIn * 1000, size, sha256, spaces, format, version, text, preparedAt: now, }; if (!this._supportPreviewRequestIsCurrent(draftId, generation)) { issuedToken = ''; void this._discardSupportPreview(token); return; } if (!this._supportPatch(draftId, { status: 'ready', preview, errorCode: '', rawOpen: false, })) { issuedToken = ''; void this._discardSupportPreview(token); return; } issuedToken = ''; this._scheduleSupportExpiry(draftId, preview); } catch (error: unknown) { // The backend has allocated every syntactically valid token it returns. // Cleanup is independent from UI currentness: invalid, stale or locally // unadoptable responses must not occupy a slot until TTL. if (issuedToken) { const token = issuedToken; issuedToken = ''; void this._discardSupportPreview(token); } if (!this._supportPreviewRequestIsCurrent(draftId, generation)) return; if (!this._supportPatch(draftId, { status: 'error', errorCode: supportErrorCode(error), })) return; void this._focusSupport('#support-error'); } } public async _setSupportAttachment(attach: boolean): Promise { const current = this.host._supportDialog; if (!current || current.status === 'sending' || current.status === 'success') return; const token = current.preview?.token || ''; if (!attach) { this._supportPreviewGeneration += 1; clearTimeout(this._supportExpiryTimer); this._supportExpiryTimer = undefined; this._supportPatch(current.draftId, { attach: false, status: 'idle', preview: null, rawOpen: false, errorCode: '', }); if (token) void this._discardSupportPreview(token); return; } this._supportPatch(current.draftId, { attach: true, errorCode: '' }); await this._buildSupportPreview(current.draftId); } public async _refreshSupportPreview(): Promise { const current = this.host._supportDialog; if (!current || !current.attach || current.status === 'building' || current.status === 'sending') return; await this._buildSupportPreview(current.draftId); } public _downloadSupportPreview(): void { const preview = this.host._supportDialog?.preview; if (!preview) return; const blob = new Blob([preview.text], { type: 'application/json;charset=utf-8' }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = `houseplan-support-${preview.sha256.slice(0, 12)}.json`; anchor.style.display = 'none'; document.body.append(anchor); anchor.click(); anchor.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 0); } private async _copySupportText(text: string, successKey: SupportI18nKey): Promise { const lang = langOf(this.host.hass, this.host._config?.language); try { await navigator.clipboard.writeText(text); this.host._showToast(supportT(lang, successKey)); } catch { this.host._showToast(supportT(lang, 'support.copy_failed')); } } public async _submitSupport(): Promise { const current = this.host._supportDialog; if (!current || !supportApiCompatible(this.host._haSupportApi)) return; const validation = supportDraftError(current); if (validation) { this._supportPatch(current.draftId, { status: 'error', errorCode: `validation.${validation}`, }); void this._focusSupport('#support-message'); return; } if (!supportCanSubmit(current)) return; const submission = supportSubmissionIdentity(current); const sending = this._supportPatch(current.draftId, { status: 'sending', errorCode: '', idempotencyKey: submission.idempotencyKey, submissionFingerprint: submission.fingerprint, }); if (!sending) return; try { const response: unknown = await this.host.hass.callWS({ type: 'houseplan/support/submit', message: sending.message.trim(), contact: sending.contact.trim(), ...(sending.attach && sending.preview ? { preview_token: sending.preview.token } : {}), idempotency_key: sending.idempotencyKey, }); const reportId = response && typeof response === 'object' && 'report_id' in response ? String((response as { report_id?: unknown }).report_id || '') : ''; if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(reportId)) { throw { code: 'support_unavailable' }; } clearTimeout(this._supportExpiryTimer); this._supportExpiryTimer = undefined; if (!this._supportPatch(current.draftId, { status: 'success', reportId, errorCode: '', })) return; void this._focusSupport('#support-receipt'); } catch (error: unknown) { if (!this._supportPatch(current.draftId, { status: 'error', errorCode: supportErrorCode(error), })) return; void this._focusSupport('#support-error'); } } private _supportErrorText(state: SupportDialogState): string { const lang = langOf(this.host.hass, this.host._config?.language); if (state.errorCode.startsWith('validation.')) { const suffix = state.errorCode.slice('validation.'.length); return supportT(lang, `support.validation.${suffix}` as SupportI18nKey); } return supportT( lang, `support.error.${state.errorCode || 'support_unavailable'}` as SupportI18nKey, ); } private _supportMessageKeydown(event: KeyboardEvent): void { if (event.key !== 'Enter' || (!event.ctrlKey && !event.metaKey)) return; event.preventDefault(); void this._submitSupport(); } public _renderSupportDialog(): TemplateResult { const state = this.host._supportDialog!; const lang = langOf(this.host.hass, this.host._config?.language); const guideUrl = lang === 'ru' ? 'https://github.com/Matysh/houseplan-card/blob/main/docs/USER-GUIDE.ru.md' : 'https://github.com/Matysh/houseplan-card/blob/main/docs/USER-GUIDE.md'; const compatible = supportApiCompatible(this.host._haSupportApi); const st = (key: SupportI18nKey, vars?: Record): string => supportT(lang, key, vars); const busy = state.status === 'building' || state.status === 'sending'; const validation = supportDraftError(state); const preparedMinutes = state.preview ? Math.max(0, Math.floor((Date.now() - state.preview.preparedAt) / 60_000)) : 0; const manualRecovery = state.status === 'error' && !state.errorCode.startsWith('validation.'); const contactInvalid = state.errorCode === 'validation.contact_too_long'; const messageInvalid = state.errorCode === 'validation.message_required' || state.errorCode === 'validation.message_too_long'; return html` void this._closeSupportDialog()}>

${st('support.about_group')}

${this.host._t('gs.about_version', { v: displayVersion(CARD_VERSION) })}

${st('support.guide_group')}

${st('support.guide')}
${compatible ? html`

${st('support.form_group')}

this._updateSupportDraft( 'contact', (event.target as HTMLInputElement).value, )} />

${st('support.attach_hint')}

${state.attach ? html`

${st('support.geometry_warning')}

` : nothing} ${state.status === 'building' ? html`
${st('support.building')}
` : nothing} ${state.attach && state.preview ? html`
${st('support.preview_summary', { version: state.preview.version, spaces: state.preview.spaces, size: supportSizeKiB(state.preview.size), })}
SHA-256${state.preview.sha256}
${st('support.prepared', { n: preparedMinutes })}
this._supportPatch(state.draftId, { rawOpen: (event.currentTarget as HTMLDetailsElement).open, })}> ${st('support.show_data')} ${state.rawOpen ? html`` : nothing}
` : nothing}

${st('support.privacy')}

${state.status === 'error' ? html` ` : nothing} ${state.status === 'error' && state.attach && !state.preview ? html`
` : nothing} ${manualRecovery ? html`
${st('support.manual_recovery')}
${state.preview ? html`` : nothing} Telegram GitHub
` : nothing} ${state.status === 'success' ? html`
${st('support.success', { id: state.reportId })}
` : nothing}
` : html`
${st('support.update_required')}
`}
${compatible && state.status !== 'success' ? html` ` : nothing}
`; } public _checkOptimizeGeometryImpl(config: ServerConfig): OptimizeGeometryPreflightResult { return checkOptimizeGeometry(config, { fallbackSpaceName: (index) => this.host._t('gs.align_preflight_space', { n: String(index), }), }); } public _checkSpacePhysicalGeometryImpl( config: ServerConfig, spaceId: string, captureWallGeometry?: ( geometry: ReturnType, ) => void, ) { return checkSpacePhysicalGeometry(config, spaceId, { fallbackSpaceName: (index) => this.host._t('gs.align_preflight_space', { n: String(index) }), captureWallGeometry: captureWallGeometry ? (_input, geometry) => captureWallGeometry(geometry) : undefined, }); } public _optimizeReferenceContext( removeLiveMissingPositions: boolean, ): SpaceReferenceRepairContext { const registry = this.host._haRegistry; const full = this.host._fullRegistryHass; const names: Record = {}; const humanName = (...values: unknown[]): string => { for (const value of values) { const name = String(value || '').trim(); if (name) return name; } return ''; }; for (const [deviceId, device] of Object.entries(registry.devices || {})) { names[deviceId] = humanName(device?.name_by_user, device?.name, device?.model); } for (const [entityId, entity] of Object.entries(registry.entities || {})) { names[`lg_${entityId}`] = humanName( this.host.hass?.states?.[entityId]?.attributes?.friendly_name, entity?.name, entity?.original_name, ); } for (const device of this.host._devices) names[device.id] = humanName(device.name, names[device.id]); for (const marker of this.host._serverCfg?.markers || []) { const separator = String(marker.binding || '').indexOf(':'); const kind = separator > 0 ? marker.binding.slice(0, separator) : ''; const ref = separator > 0 ? marker.binding.slice(separator + 1) : ''; const bound = kind === 'device' ? full?.devices?.[ref] : kind === 'entity' ? full?.entities?.[ref] : null; names[marker.id] = humanName( marker.name, kind === 'device' ? bound?.name_by_user : null, bound?.name, kind === 'entity' ? this.host.hass?.states?.[ref]?.attributes?.friendly_name : null, names[marker.id], ); } return { effectiveAreaByMarker: Object.fromEntries( this.host._devices .filter((device) => !device.virtual && !!device.area) .map((device) => [device.id, device.area]), ), ownerRoster: { authoritative: registry.authoritative, deviceIds: Object.keys(registry.devices || {}), // State-only YAML entities are positive existence evidence even though // an authoritative Entity Registry cannot list them. entityIds: [...new Set([ ...Object.keys(registry.entities || {}), ...Object.keys(this.host.hass?.states || {}), ])], names, }, removeLiveMissingPositions, }; } public async _undoPlanOptimization(): Promise { if (!this.host._canOptimizeUndo || this.host._optimizeUndoBusy) return; const undoKind = this.host._undoKind; this._clearGeometryGesture(); this.host._optimizeUndoBusy = true; this.host.requestUpdate(); try { await this.host.hass.callWS({ type: 'houseplan/plan/optimize_undo', expected_config_rev: this.host._cfgRev, expected_layout_rev: this.host._layoutRev, }); const [cfgResp, layResp] = await Promise.all([ this.host._getAuthoritativeConfig(), this.host.hass.callWS({ type: 'houseplan/layout/get' }), ]); // config/get is the sole authority for runtime capabilities, including // integration_version. Reuse the full-card adopter instead of leaving // this direct optimization-undo path with stale version state (#462). const adopted = await this.host._adoptAuthoritative({ cfgResp, layResp, reason: 'optimize-undo', profile: 'post-write' }); if (adopted.status !== 'adopted') return; // asset wait: the scheduled reload owns the tail this.host._geometryHistory.clear(); this.host._cancelDeviceDrag(); this.host._devicePositionHistory.clear(); this.host._canOptimizeUndo = false; this.host._undoKind = null; this.host._cfgEpoch++; this.host._modelCache = null; this.host._frame = null; this.host._regSignature = ''; this.host._maybeRebuildDevices(); this.host._cacheSnapshot(); this.host.requestUpdate(); this.host._showToast(this.host._t(undoKind === 'import' ? 'backup.import_undone' : 'gs.optimize_undone')); } catch (e: any) { this.host._canOptimizeUndo = false; this.host._undoKind = null; this.host._showToast(this.host._t('toast.error', { err: this.host._errText(e) })); } finally { this.host._optimizeUndoBusy = false; this.host.requestUpdate(); } } public _openBackupExport = (): void => { this.host._settingsDialog = null; this.host._backupExportDialog = { kind: 'full', planOnly: false, busy: false, error: '' }; }; public async _runBackupExport(): Promise { const d = this.host._backupExportDialog; if (!d || d.busy) return; this.host._backupExportDialog = { ...d, busy: true, error: '' }; try { const response: any = await this.host.hass.callWS({ type: 'houseplan/export/create', kind: d.kind, space_id: d.kind === 'space' ? this.host._space : undefined, ...(d.kind === 'space' && d.planOnly ? { plan_only: true } : {}), card_version: displayVersion(CARD_VERSION), }); const blob = new Blob([JSON.stringify(response.document, null, 2) + '\n'], { type: 'application/json;charset=utf-8', }); const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; anchor.download = response.filename || `houseplan-${d.kind}.json`; anchor.style.display = 'none'; document.body.append(anchor); anchor.click(); anchor.remove(); window.setTimeout(() => URL.revokeObjectURL(url), 0); this.host._backupExportDialog = null; this.host._showToast(this.host._t('backup.export_done')); } catch (error: any) { if (this.host._backupExportDialog) { this.host._backupExportDialog = { ...this.host._backupExportDialog, busy: false, error: this._backupErrorText(error) }; } } } public async _pickBackupImport(ev: Event): Promise { const input = ev.target as HTMLInputElement; const file = input.files?.[0]; input.value = ''; if (!file) return; this.host._settingsDialog = null; this.host._backupImportDialog = { filename: file.name, size: file.size, token: '', preview: null, expectedConfigRev: this.host._cfgRev, expectedLayoutRev: this.host._layoutRev, duplicatePolicy: 'skip', confirmMissing: false, busy: true, error: '', }; try { if (this.host._saveConfigDebounced.pending()) this.host._saveConfigDebounced.flush(); await this.host._writeChain; if (this.host._persistLayout.pending()) this.host._persistLayout.flush(); const path = '/api/houseplan/import/preview?duplicate_policy=skip'; const init = { method: 'POST', body: file, headers: { 'content-type': 'application/json' } }; const response: Response = this.host.hass?.fetchWithAuth ? await this.host.hass.fetchWithAuth(path, init) : await fetch(path, { ...init, headers: { ...init.headers, ...(this.host.hass?.auth?.data?.access_token ? { authorization: `Bearer ${this.host.hass.auth.data.access_token}` } : {}), }, }); const data = await response.json().catch(() => ({})); if (!response.ok || data.error) throw Object.assign(new Error(data.message || data.error || `HTTP ${response.status}`), { code: data.error }); if (this.host._backupImportDialog) { this.host._backupImportDialog = { ...this.host._backupImportDialog, token: data.token, preview: data.preview, expectedConfigRev: data.expected_config_rev, expectedLayoutRev: data.expected_layout_rev, busy: false, }; } } catch (error: any) { if (this.host._backupImportDialog) { this.host._backupImportDialog = { ...this.host._backupImportDialog, busy: false, error: this._backupErrorText(error) }; } } } public async _setBackupDuplicatePolicy(policy: 'skip' | 'virtual'): Promise { const d = this.host._backupImportDialog; if (!d || d.busy || !d.token || policy === d.duplicatePolicy) return; this.host._backupImportDialog = { ...d, duplicatePolicy: policy, busy: true, error: '' }; try { const response: any = await this.host.hass.callWS({ type: 'houseplan/import/revalidate', token: d.token, duplicate_policy: policy, }); if (this.host._backupImportDialog) { this.host._backupImportDialog = { ...this.host._backupImportDialog, preview: { ...d.preview, ...response.preview }, expectedConfigRev: response.expected_config_rev, expectedLayoutRev: response.expected_layout_rev, confirmMissing: false, busy: false, }; } } catch (error: any) { if (this.host._backupImportDialog) { this.host._backupImportDialog = { ...this.host._backupImportDialog, duplicatePolicy: d.duplicatePolicy, preview: d.preview, confirmMissing: d.confirmMissing, busy: false, error: this._backupErrorText(error), }; } } } public async _applyBackupImport(): Promise { const d = this.host._backupImportDialog; if (!d || d.busy || !d.token || !d.preview) return; if (d.preview.confirmation_required && !d.confirmMissing) return; this.host._backupImportDialog = { ...d, busy: true, error: '' }; const previousSpace = this.host._space; try { const result: any = await this.host.hass.callWS({ type: 'houseplan/import/apply', token: d.token, expected_config_rev: d.expectedConfigRev, expected_layout_rev: d.expectedLayoutRev, duplicate_policy: d.duplicatePolicy, confirm_missing_content: d.confirmMissing, }); const [configResponse, layoutResponse] = await Promise.all([ this.host._getAuthoritativeConfig(), this.host.hass.callWS({ type: 'houseplan/layout/get' }), ]); const adopted = await this.host._adoptAuthoritative({ cfgResp: configResponse, layResp: layoutResponse, reason: 'import-apply', profile: 'post-write' }); if (adopted.status !== 'adopted') { this.host._backupImportDialog = { ...d, busy: false, error: '' }; this.host.requestUpdate(); return; } this.host._geometryHistory.clear(); this.host._dirtyPos.clear(); this.host._sentPos.clear(); this.host._defPos = {}; this.host._cfgEpoch++; this.host._modelCache = null; this.host._frame = null; this.host._visibleDeviceSnapshot = null; this.host._candidateDeviceSnapshot = null; this.host._stagedDeviceSnapshotToken = -1; this.host._capturedSnapshotConfigEpoch = -1; this.host._regSignature = ''; this.host._signer.invalidate(this.host.hass); this.host._resign(); this.host._maybeRebuildDevices(); const spaces = this.host._serverCfg?.spaces || []; const nextSpace = result.kind === 'space' && result.space_id ? result.space_id : spaces.some((space) => space.id === previousSpace) ? previousSpace : spaces[0]?.id || this.host._space; if (this.host._hasFixedFloor) this.host._adoptInitialSpace(this.host._model, true); else this.host._commitSpace(nextSpace); this.host._backupImportDialog = null; this.host._cacheSnapshot(); this.host.requestUpdate(); const outcomeCounts = result.kind === 'space' ? d.preview.counts : result.counts; this.host._showToast(this.host._t(result.kind === 'space' ? 'backup.space_done' : 'backup.full_done', { spaces: String(outcomeCounts?.spaces || 0), rooms: String(outcomeCounts?.rooms || 0), markers: String(outcomeCounts?.markers || 0), refs: String(result.repaired_target_refs || 0), })); } catch (error: any) { if (error?.code === 'conflict' && this.host._backupImportDialog?.token) { try { const current = this.host._backupImportDialog; const refreshed: any = await this.host.hass.callWS({ type: 'houseplan/import/revalidate', token: current.token, duplicate_policy: current.duplicatePolicy, }); this.host._backupImportDialog = { ...current, preview: { ...current.preview, ...refreshed.preview }, expectedConfigRev: refreshed.expected_config_rev, expectedLayoutRev: refreshed.expected_layout_rev, confirmMissing: false, busy: false, error: this.host._t('backup.revalidated'), }; return; } catch (refreshError: any) { error = refreshError; } } if (this.host._backupImportDialog) { this.host._backupImportDialog = { ...this.host._backupImportDialog, busy: false, error: this._backupErrorText(error) }; } } } public _renderBackupExportDialog(): TemplateResult { const d = this.host._backupExportDialog!; const currentSpace = (this.host._serverCfg?.spaces || []).find((space) => space.id === this.host._space); return html` (this.host._backupExportDialog = null)}>
${this.host._t('backup.export_hint')}
${d.kind === 'space' && currentSpace ? html`` : nothing}
${this.host._t('backup.privacy_warning')}
${d.error ? html`` : nothing}
`; } public _renderBackupImportDialog(): TemplateResult { type ContentItem = { kind?: string; url: string; state?: string }; const d = this.host._backupImportDialog!; const p = d.preview; const decorContent = ((p?.content || []) as ContentItem[]) .filter((item) => item.kind === 'decor_asset'); const decorAssetCount = new Set(decorContent.map((item) => item.url)).size; const missingDecor = decorContent.filter((item) => item.state === 'missing_preserved'); const missingDecorAssetCount = new Set(missingDecor.map((item) => item.url)).size; const counts = p?.counts || {}; const draftMigration = p?.migration || {}; const report = p?.reference_report || {}; const sum = (values: any): number => Object.values(values || {}).reduce( (total: number, value: any) => total + (Number(value) || 0), 0, ); const allReportRows: Array<[ 'incoming_remapped' | 'target_repaired' | 'preserved_unresolved' | 'collisions' | 'dropped_links' | 'bounded_lineages', number, ]> = [ ['incoming_remapped', sum(report.remapped?.incoming)], ['target_repaired', sum(report.remapped?.target)], ['preserved_unresolved', sum(report.preservedUnresolved)], ['collisions', sum(report.collisions)], ['dropped_links', sum(report.droppedIncomingLinks)], ['bounded_lineages', Number(report.boundedLineages) || 0], ]; const reportRows = allReportRows.filter(([, value]) => value > 0); return html` (this.host._backupImportDialog = null)}>
${d.filename}${(d.size / 1024).toFixed(1)} KB
${d.busy && !p ? html`
${this.host._t('backup.reading')}
` : nothing} ${d.error ? html`` : nothing} ${p ? html`
${this.host._t(p.kind === 'full' ? 'backup.full' : 'backup.current_space')} ${p.plan_only ? html`${this.host._t('backup.plan_only_preview')}` : nothing} ${this.host._t(p.source === 'same' ? 'backup.same_source' : 'backup.foreign_source')} ${this.host._t('backup.created', { value: p.created_at || '—' })} ${this.host._t('backup.versions', { card: p.card_version || '—', integration: p.integration_version || '—', model: String(p.model_version ?? '—'), })}
${this.host._t('backup.count_spaces', { n: p.kind === 'full' ? `${p.current_counts?.spaces || 0} → ${counts.spaces || 0}` : String(counts.spaces || 0) })} ${this.host._t('backup.count_rooms', { n: p.kind === 'full' ? `${p.current_counts?.rooms || 0} → ${counts.rooms || 0}` : String(counts.rooms || 0) })} ${this.host._t('backup.count_walls', { n: p.kind === 'full' ? `${p.current_counts?.walls || 0} → ${counts.walls || 0}` : String(counts.walls || 0) })} ${this.host._t('backup.count_openings', { n: p.kind === 'full' ? `${p.current_counts?.openings || 0} → ${counts.openings || 0}` : String(counts.openings || 0) })} ${this.host._t('backup.count_decor', { n: p.kind === 'full' ? `${p.current_counts?.decor || 0} → ${counts.decor || 0}` : String(counts.decor || 0) })} ${this.host._t('backup.count_markers', { n: p.kind === 'full' ? `${p.current_counts?.markers || 0} → ${counts.markers || 0}` : String(counts.markers || 0) })} ${this.host._t('backup.count_layout', { n: p.kind === 'full' ? `${p.current_counts?.layout || 0} → ${counts.layout || 0}` : String(counts.layout || 0) })}
${p.bindings ? html`
${this.host._t('backup.bindings', { device: String(p.bindings.device || 0), entity: String(p.bindings.entity || 0), virtual: String(p.bindings.virtual || 0), legacy: String(p.legacy_positions || 0), })}
${this.host._t('backup.binding_status', { active: String(p.bindings.active || 0), disabled: String(p.bindings.disabled || 0), missing: String(p.bindings.missing || 0), })}
` : nothing} ${p.missing_areas?.length ? html`
${this.host._t('backup.missing_areas', { areas: p.missing_areas.join(', '), })}
` : nothing} ${p.dropped_marker_links ? html`
${this.host._t('backup.dropped_marker_links', { n: String(p.dropped_marker_links), })}
` : nothing} ${draftMigration.room_drafts || draftMigration.room_draft_segments ? html`
${this.host._t('backup.room_drafts_migrated', { drafts: String(draftMigration.room_drafts || 0), segments: String(draftMigration.room_draft_segments || 0), })}
` : nothing} ${p.repaired_target_refs ? html`
${this.host._t('backup.repaired_target_refs', { n: String(p.repaired_target_refs), })}
` : nothing} ${p.preserved_unresolved_refs ? html`
${this.host._t('backup.preserved_unresolved_refs', { n: String(p.preserved_unresolved_refs), })}
${this.host._t('backup.preserved_unresolved_hint')}
` : nothing} ${reportRows.length ? html`
${this.host._t('backup.import_details')}
${reportRows.map(([key, value]) => html`${this.host._t( `backup.import_detail.${key}`, { n: String(value) }, )}`)} ${(report.examples || []).slice(0, 8).map((item) => html` ${item.owner} → ${item.reference} `)}
` : nothing} ${p.kind === 'full' ? html`
${this.host._t('backup.replace_warning')}
${p.source === 'foreign' ? html`
${this.host._t('backup.foreign_bookkeeping')}
` : nothing}` : html`
${this.host._t('backup.final_name')}${p.space_title}
${this.host._t('backup.target_settings')}
${p.duplicates ? html`
${this.host._t('backup.duplicates', { n: String(p.duplicates) })}
` : nothing}`} ${p.content?.length ? html`
${this.host._t('backup.content')} ${decorContent.length ? html`${this.host._t('backup.decor_images_summary', { assets: decorAssetCount, objects: decorContent.length, missing: missingDecorAssetCount, })}` : nothing} ${(p.content as ContentItem[]).filter((item) => item.kind !== 'decor_asset').map((item) => html`${item.url} · ${this.host._t( item.state === 'available' ? 'backup.content_available' : item.state === 'external' ? 'backup.content_external' : 'backup.content_detach_required', )}`)}
` : nothing} ${p.confirmation_required ? html`` : nothing} ` : nothing}
${p ? html`` : nothing}
`; } public _setFillColor(key: keyof FillColors, patch: Partial<{ c: string; a: number }>): void { const d = this.host._settingsDialog!; this.host._settingsDialog = { ...d, colors: { ...d.colors, [key]: { ...d.colors[key], ...patch } } }; } /** #377: every UI change of the session default style flows through here — * the style updates instantly, the persist is debounced so a palette drag * produces one config write. */ public _updateDecorStyle(next: DecorStyle): void { this.host._decorStyle = next; if (this._decorStylePersistTimer !== null) window.clearTimeout(this._decorStylePersistTimer); this._decorStylePersistTimer = window.setTimeout(() => { this._decorStylePersistTimer = null; this._persistDecorStyle(); }, 1000); } private _decorStylePersistTimer: number | null = null; /** #377: mirror the current default style into settings.decor_default_style. * The default itself is stored as the ABSENCE of the key; a no-op diff does * not touch the store. Uses the ordinary serialized write path (expected_rev). */ private _persistDecorStyle(): void { const cfg = this.host._serverCfg; if (!cfg) return; const patch = decorStyleToSettings(this.host._decorStyle); const settings: Record = { ...(cfg.settings as object) }; const before = JSON.stringify(settings.decor_default_style ?? null); if (patch) settings.decor_default_style = patch; else delete settings.decor_default_style; if (JSON.stringify(settings.decor_default_style ?? null) === before) return; this.host._serverCfg = { ...cfg, settings } as typeof cfg; this._saveConfig(); } public async _saveSettingsDialog(): Promise { const d = this.host._settingsDialog; if (!d || d.busy) return; if (generalProblems(d).length) return; this.host._settingsDialog = { ...d, busy: true }; let attempt: OptimisticAttempt | null = null; try { const cfg = this.host._serverCfg!; const isDefault = JSON.stringify(d.colors) === JSON.stringify(DEFAULT_FILL_COLORS); let settings: ServerConfig['settings'] & Record = { ...cfg.settings }; if (isDefault) delete settings.fill_colors; else settings.fill_colors = d.colors; const cm = this.host._imperial ? d.glowRadius * 30.48 : d.glowRadius * 100; if (Number.isFinite(cm) && cm > 0 && Math.round(cm) !== 300) settings.glow_radius_cm = Math.round(cm); else delete settings.glow_radius_cm; if (d.bgColor) settings.bg_color = d.bgColor; else delete settings.bg_color; // Legacy absence means static; new installations/spaces materialise daynight. if (d.northDeg !== null && Number.isInteger(d.northDeg) && d.northDeg >= 0 && d.northDeg <= 359) settings.north_deg = d.northDeg; else delete settings.north_deg; settings.bg_mode = d.bgMode; if (d.sunRays) settings.sun_rays = true; else delete settings.sun_rays; settings.sun_ray_origin = d.sunRayOrigin; if (d.showRoomTooltip) delete settings.show_room_tooltip; else settings.show_room_tooltip = false; if (d.volumetricView) settings.volumetric_view = true; else delete settings.volumetric_view; writeMoonSetting(settings, d.moon); const radarSettings = settings.radar; if (!radarSettings || radarSettings.version == null || radarSettings.version === 1) { settings.radar = { ...(radarSettings || {}), version: 1 }; if (d.radarShowLive) delete settings.radar.show_live; else settings.radar.show_live = false; } settings = writeZigbeeTopologySettings(settings, d.zigbeeTopology); // Old configs may still contain this accepted field, but weather no // longer affects sunlight; saving general settings cleans it up. delete settings.weather_entity; const nextConfig = { ...cfg, settings }; attempt = this.host._adoption.beginOptimistic(cfg, nextConfig); this.host._serverCfg = nextConfig; await this._saveConfigNow(attempt); if (!d.showRoomTooltip && this.host._tip?.room) this.host._tip = null; this.host._settingsDialog = null; this.host.requestUpdate(); this.host._showToast(this.host._t('gs.saved')); } catch (e: any) { // Esc may close the dialog in flight; the toast must still fire (audit L3). // Never overwrite an authoritative conflict reload or newer edit (#439). if (attempt) this.host._rollbackOptimistic(attempt); if (this.host._settingsDialog) this.host._settingsDialog = { ...this.host._settingsDialog, busy: false }; this.host._showToast(this.host._t('toast.error', { err: this.host._errText(e) })); } } public _boolInput(checked: boolean, onChange: (v: boolean) => void, disabled = false): TemplateResult { const h = (e: Event) => onChange(!!(e.target as HTMLInputElement).checked); return customElements.get('ha-switch') ? html`` : html``; } public _rangeInput( min: number, max: number, step: number, value: number, onInput: (v: number) => void, disabled = false, ariaLabel?: string, ): TemplateResult { const h = (e: Event) => { const n = Number((e.target as HTMLInputElement).value); if (Number.isFinite(n)) onInput(n); }; return customElements.get('ha-slider') ? html`` : html``; } public _renderSettingsDialog(): TemplateResult { return renderGeneralSettingsDialog.call(this); } public _openRulesDialog = (): void => { if (!this.host._norm) return; const custom = this.host._settings.icon_rules; const rules = (custom && custom.length ? custom : DEFAULT_ICON_RULES).map((r) => ({ ...r })); this.host._rulesDialog = { rules, test: '', busy: false }; }; public _rulesSet(rules: IconRule[]): void { this.host._rulesDialog = { ...this.host._rulesDialog!, rules }; } public async _saveRules(): Promise { const dlg = this.host._rulesDialog; if (!dlg || dlg.busy) return; const cleaned = dlg.rules.filter((r) => r.pattern.trim() && r.icon.trim()); this.host._rulesDialog = { ...dlg, busy: true }; try { const cfg = this.host._serverCfg!; const isDefault = JSON.stringify(cleaned) === JSON.stringify(DEFAULT_ICON_RULES); const settings: any = { ...cfg.settings }; if (isDefault) delete settings.icon_rules; else settings.icon_rules = cleaned; this.host._serverCfg = { ...cfg, settings }; await this._saveConfigNow(); this.host._rulesDialog = null; this.host._regSignature = ''; this.host._maybeRebuildDevices(); this.host._showToast(this.host._t('rules.saved')); } catch (e: any) { // audit L3: the dialog may have been closed (Esc) while the save was // in flight — spreading null yields a truthy husk and the renderer // then crashes, blanking the whole card. The toast below is the // only remaining signal, so it must still fire. if (this.host._rulesDialog) this.host._rulesDialog = { ...this.host._rulesDialog, busy: false }; this.host._showToast(this.host._t('toast.error', { err: this.host._errText(e) })); } } public _renderRulesDialog(): TemplateResult { const d = this.host._rulesDialog!; const compiled = compileIconRules(d.rules); const testIcon = d.test.trim() ? iconFor(d.test, '', compiled) : null; const move = (i: number, delta: number) => { const r = [...d.rules]; const j = i + delta; if (j < 0 || j >= r.length) return; [r[i], r[j]] = [r[j], r[i]]; this._rulesSet(r); }; return html` (this.host._rulesDialog = null)}>
${this.host._t('rules.hint')}
(this.host._rulesDialog = { ...d, test: (e.target as HTMLInputElement).value })} /> ${testIcon ? html`${testIcon}` : nothing}
${d.rules.map((r, i) => { const bad = r.pattern.trim() !== '' && !isValidPattern(r.pattern); return html`
{ const rules = [...d.rules]; rules[i] = { ...r, pattern: (e.target as HTMLInputElement).value }; this._rulesSet(rules); }} /> { const rules = [...d.rules]; rules[i] = { ...r, icon: (e.target as HTMLInputElement).value }; this._rulesSet(rules); }} /> move(i, -1)}> move(i, 1)}> this._rulesSet(d.rules.filter((_, j) => j !== i))}>
`; })}
`; } public _saveKioskScale(patch: Partial<{ icon: number; font: number }>): void { this.host._kioskScale = { ...this.host._kioskScale, ...patch }; try { localStorage.setItem(LS_KIOSK, JSON.stringify(this.host._kioskScale)); } catch { /* ignore */ } this.host.requestUpdate(); } public _renderKioskDialog(): TemplateResult { const k = this.host._kioskScale; const row = (key: 'icon' | 'font', label: string) => html`
${this._rangeInput(50, 300, 5, Math.round(k[key] * 100), (n) => this._saveKioskScale({ [key]: n / 100 }))} ${Math.round(k[key] * 100)}%
`; return html` (this.host._kioskDialog = false)}>
${this.host._t('kiosk.hint')}
${row('icon', this.host._t('kiosk.icon_scale'))} ${row('font', this.host._t('kiosk.font_scale'))}
`; } public _renderVacSection(dlg: any): TemplateResult | typeof nothing { const dev = this.host._devices.find((x) => x.id === dlg.devId); if (!dev || !this.host._isVacDev(dev)) return nothing; const v = dev.marker?.vacuum || {}; const includeAllCameras = this.host._vacAllCamerasFor === dev.id; const resolution = this.host._vacSourceResolution(dev, includeAllCameras); const src = resolution.entityId; const selected = resolution.candidates.find((candidate) => candidate.entityId === src) || null; const tele = src ? readVacTelemetry(this.host.hass?.states[src]?.attributes) : null; const canUseSource = v.live !== false && (resolution.status === 'ok' || resolution.status === 'unsupported'); const planRooms = this._vacPlanRoomAnchors(dev.space); const roomMatches = tele ? vacRoomNameMatchCount(tele.rooms, planRooms.map((room) => room.name)) : 0; const tierA = !!(canUseSource && tele && roomMatches >= 3); const setVac = (patch: Record) => { // HP-1540-01: materialise the marker first — find() alone silently // dropped every edit for a vacuum that had never been saved const m = this.host._vacEnsureMarker(dev); if (!m) return; m.vacuum = { ...(m.vacuum || {}), ...patch }; this.host._regSignature = ''; // First-use markers are materialised above, so the dialog must rebuild // immediately instead of waiting for an unrelated HA state tick. this.host._maybeRebuildDevices(); this._saveConfig(); this.host.requestUpdate(); }; const sameDevice = new Set(dev.entities || []); const primaryCandidates = resolution.candidates.filter((candidate) => sameDevice.has(candidate.entityId) || candidate.entityId === v.source); const globalCandidates = includeAllCameras ? resolution.candidates.filter((candidate) => candidate.entityId.startsWith('camera.') && !primaryCandidates.some((entry) => entry.entityId === candidate.entityId)) : []; const capability = (candidate: VacSourceCandidate) => [ candidate.hasPosition ? this.host._t('vac.cap_position') : '', candidate.hasRooms ? this.host._t('vac.cap_rooms_short') : '', candidate.hasPath ? this.host._t('vac.cap_path') : '', candidate.hasMapId ? this.host._t('vac.cap_map') : '', ].filter(Boolean).join(' · ') || this.host._t('vac.cap_none'); const candidateButton = (candidate: VacSourceCandidate) => html` `; const knownSameDeviceXcme = resolution.candidates.some((candidate) => sameDevice.has(candidate.entityId) && candidate.category === 'known_xcme_incomplete'); const showXcmeHint = knownSameDeviceXcme || (!!resolution.pinned && !!src?.startsWith('camera.') && !selected?.hasPosition); const openPicker = () => { const picker = [...this.host.renderRoot.querySelectorAll('details.vacpicker')] .find((details) => details.dataset.devId === dev.id); if (!picker) return; picker.open = true; picker.querySelector('summary')?.focus(); }; return html`
${this.host._t('vac.diag_source')}${src || this.host._t('vac.source_none')}
${selected?.platform ? html`
${this.host._t('vac.diag_platform')}${selected.platform}
` : nothing}
${this.host._t('vac.diag_status')}${this.host._t((`vac.source_status_${resolution.status}`) as any)}
${this.host._t('vac.diag_position')}${tele?.pos ? this.host._t('common.yes') : this.host._t('common.no')}
${this.host._t('vac.diag_rooms')}${subst(this.host._t('vac.diag_rooms_value'), { total: String(tele?.rooms.length || 0), matched: String(roomMatches), readiness: this.host._t(roomMatches >= 3 ? 'vac.autocal_ready' : 'vac.autocal_not_ready'), })}
${this.host._t('vac.diag_path')}${tele?.path.length ? this.host._t('common.yes') : this.host._t('common.no')}
${this.host._t('vac.diag_map')}${tele?.mapId ?? 'default'}
${(resolution.status === 'missing' || resolution.status === 'disabled' || resolution.status === 'unverified') ? html`
${this.host._t((`vac.source_banner_${resolution.status}`) as any)}
` : nothing} ${showXcmeHint ? html`
${this.host._t('vac.xcme_hint')}
attributes:
  - vacuum_position
  - rooms
  - path
  - map_name
` : nothing}
${this.host._t('vac.choose_source')}
${primaryCandidates.map(candidateButton)}
{ const open = (event.currentTarget as HTMLDetailsElement).open; if (open) this.host._vacOpenAllCameras(dev); else { this.host._vacAllCamerasFor = null; this.host._vacAllCameraCache = null; } }}> ${this.host._t('vac.all_cameras')}
${this.host._t('vac.all_cameras_warn')}
${includeAllCameras ? (globalCandidates.length ? globalCandidates.map(candidateButton) : html`
${this.host._t('vac.all_cameras_empty')}
`) : nothing}
${tierA ? html`` : nothing} ${canUseSource ? html`` : nothing} ${this.host._t('vac.documentation')}
${tele ? html` ${renderVacuumMapsSection(this, dev, resolution)} ` : nothing}
`; } public _vacMapId(d: DevItem, tele: { mapId: string }, planHass = this.host._planHass): string { // #358: the View card owns map-id resolution — it runs inside willUpdate // for every vacuum with telemetry on tabs that never load this runtime. return this.host._vacMapId(d, tele, planHass); } public _vacSaveMatrix( markerId: string, source: string, mapId: string, matrix: Affine, routeId = '', ): Promise { return saveVacuumMatrix(this, markerId, source, mapId, matrix, routeId); } public _vacPlanRoomAnchors(spaceId: string | null | undefined): Array<{ name: string; cx: number; cy: number; }> { return (this.host._spaceModelById(spaceId)?.rooms || []) // HP-1540-04: legacy rectangle rooms (x/y/w/h) are still first-class; // roomPoly() gives the same outline the renderer uses for them. .map((room) => ({ room, poly: roomPoly(room) })) .filter(({ room, poly }: any) => room.name && poly) .map(({ room, poly }: any) => { const centroid = areaCentroid(poly); return centroid ? { name: String(room.name), cx: centroid[0], cy: centroid[1] } : null; }) .filter(Boolean) as Array<{ name: string; cx: number; cy: number }>; } public async _vacAutoCalibrate(d: DevItem): Promise { if (this.host._markerDialog?.busy) return; const src = this.host._vacSource(d); const tele = src ? readVacTelemetry(this.host.hass?.states[src]?.attributes) : null; if (!src || !tele || tele.rooms.length < 3) { this.host._showToast(this.host._t('vac.autocal_no_rooms')); return; } const mapId = this._vacMapId(d, tele); const target = calibrationTarget(d.id, d.marker?.vacuum ?? null, d.space, src, mapId); const res = autoCalibrate(tele.rooms, this._vacPlanRoomAnchors(target.space)); if (!res) { this.host._showToast(this.host._t('vac.autocal_no_match')); return; } const rawSpace = this.host._serverCfg?.spaces?.find((space) => space.id === target.space); const rawCellCm = Number(rawSpace?.cell_cm); const cellCm = Number.isFinite(rawCellCm) && rawCellCm > 0 ? rawCellCm : 5; const residualCm = vacCalibrationResidualCm(res.residual, this.host._gridPitch, cellCm); if (residualCm > VAC_CALIBRATION_WARN_CM) { this.host._vacCalConfirm = { markerId: d.id, source: src, mapId, routeId: target.routeId, space: target.space, matrix: res.matrix, rooms: res.matched.length, error: formatLength(residualCm, this.host.hass?.config?.unit_system?.length === 'mi'), }; return; } await saveAutomaticCalibration(this, { markerId: d.id, source: src, mapId, routeId: target.routeId, space: target.space, matrix: res.matrix, rooms: res.matched.length, }); } public _vacApplyCalibrationProposal(manual: boolean): Promise { return applyCalibrationProposal(this, manual); } public _vacStartFit(d: DevItem, routeId = ''): void { if (this.host._markerDialog?.busy) return; const src = this.host._vacSource(d); const tele = src ? readVacTelemetry(this.host.hass?.states[src]?.attributes) : null; if (!src || !tele) { this.host._showToast(this.host._t('vac.cal_need_pos')); return; } const mapId = this._vacMapId(d, tele); const plan = planVacuumFit(d.id, d.marker?.vacuum ?? null, { routeId, source: src, mapId, dockSpace: d.space, rooms: tele.rooms, viewBoxOf: (id) => (this.host._spaceModelById(id)?.vb as [number, number, number, number]) ?? null, }); if (!plan) return; this.host._markerDialog = null; if (plan.space !== this.host._space && !this.host._commitSpace(plan.space)) return; this.host._vacFit = { markerId: d.id, source: src, mapId, routeId: plan.routeId, p: plan.params, drag: null }; } public _vacFitSave(): Promise { return saveManualCalibration(this, fitMatrix); } public _vacFitTurn(patch: Partial): void { const f = this.host._vacFit; if (!f || f.busy) return; const tele = readVacTelemetry(this.host.hass?.states[f.source]?.attributes); const c = this._vacGhostCentre(tele?.rooms || []); const next = { ...f.p, ...patch } as FitParams; this.host._vacFit = { ...f, p: reanchorFit(next, f.p, c[0], c[1]) }; } public _vacGhostCentre(rooms: VacRoom[]): VacPt { const xs: number[] = [], ys: number[] = []; for (const r of rooms) { xs.push(r.x0 ?? r.cx, r.x1 ?? r.cx); ys.push(r.y0 ?? r.cy, r.y1 ?? r.cy); } if (!xs.length) return [0, 0]; return [(Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2]; } public _vacDelta(view: { w: number; h: number }, dxPx: number, dyPx: number): VacPt { const st = this.host._stageEl; const w = st?.clientWidth || 1, h = st?.clientHeight || 1; return [(dxPx / w) * view.w, (dyPx / h) * view.h]; } public _vacFitPointer(ev: PointerEvent, view: { x: number; y: number; w: number; h: number }): void { const f = this.host._vacFit; if (!f) return; ev.stopPropagation(); if (f.busy) return; if (ev.type === 'pointerdown') { const t = ev.target as HTMLElement; const corner = t.getAttribute?.('data-corner'); try { (ev.currentTarget as HTMLElement).setPointerCapture?.(ev.pointerId); } catch { /* synthetic pointers (tests) have no active id — capture is a nicety */ } this.host._vacFit = { ...f, drag: corner ? { kind: 'scale', sx: ev.clientX, sy: ev.clientY, p0: { ...f.p }, fx: Number(corner.split(',')[0]), fy: Number(corner.split(',')[1]) } : { kind: 'move', sx: ev.clientX, sy: ev.clientY, p0: { ...f.p }, fx: 0, fy: 0 } }; return; } const d = f.drag; if (!d) return; if (ev.type === 'pointermove') { const [dx, dy] = this._vacDelta(view, ev.clientX - d.sx, ev.clientY - d.sy); if (d.kind === 'move') { this.host._vacFit = { ...f, p: { ...d.p0, ox: d.p0.ox + dx, oy: d.p0.oy + dy } }; } else { // corner-stretch: uniform scale about the OPPOSITE corner (fx, fy — // the fixed corner in robot coords), like a graphics-editor frame const tele = readVacTelemetry(this.host.hass?.states[f.source]?.attributes); const c = this._vacGhostCentre(tele?.rooms || []); const m0 = fitMatrix(d.p0); const [gx, gy] = applyAffine(m0, c[0], c[1]); const [px0, py0] = applyAffine(m0, d.fx, d.fy); const span0 = Math.hypot(gx - px0, gy - py0) || 1; // distance change of the dragged corner (opposite of fixed) from centre const [cx0, cy0] = [2 * gx - px0, 2 * gy - py0]; const span1 = Math.hypot(cx0 + dx * 2 - px0, cy0 + dy * 2 - py0) / 2; const k = Math.max(0.05, span1 / span0); const next = { ...d.p0, s: d.p0.s * k } as FitParams; this.host._vacFit = { ...f, p: reanchorFit(next, d.p0, d.fx, d.fy) }; } return; } if (ev.type === 'pointerup' || ev.type === 'pointercancel') { this.host._vacFit = { ...f, drag: null }; } } public _renderVacFit(view: { x: number; y: number; w: number; h: number }): TemplateResult | typeof nothing { const f = this.host._vacFit; if (!f) return nothing; const tele = readVacTelemetry(this.host._renderPlanHass?.states?.[f.source]?.attributes); if (!tele) return nothing; const m = fitMatrix(f.p); const rects: TemplateResult[] = []; const xs: number[] = [], ys: number[] = []; for (const r of tele.rooms) { if (r.x0 == null) continue; const cs = [[r.x0!, r.y0!], [r.x1!, r.y0!], [r.x1!, r.y1!], [r.x0!, r.y1!]] .map(([x, y]) => applyAffine(m, x, y)); cs.forEach(([x, y]) => { xs.push(x); ys.push(y); }); const [lx, ly] = applyAffine(m, r.cx, r.cy); rects.push(svg` ${r.name}`); } let dot: TemplateResult | typeof nothing = nothing; if (tele.pos) { const [dx2, dy2] = applyAffine(m, tele.pos.x, tele.pos.y); dot = svg``; } // corner handles on the ghost bbox; data-corner carries the FIXED corner // (the opposite one) in robot coordinates const handles: TemplateResult[] = []; if (xs.length) { const inv = ((): ((x: number, y: number) => VacPt) => { const det = m[0] * m[4] - m[1] * m[3]; return (x, y) => [ (m[4] * (x - m[2]) - m[1] * (y - m[5])) / det, (-m[3] * (x - m[2]) + m[0] * (y - m[5])) / det, ]; })(); const x0 = Math.min(...xs), x1 = Math.max(...xs); const y0 = Math.min(...ys), y1 = Math.max(...ys); // hit radius (finger-sized: these are grabbed on tablets) and the bead // you actually see, a quarter of it — same split as .bdhandle/.bdknob const r = view.w * 0.022; const kr = r / 4; for (const [hx, hy, ox2, oy2] of [[x0, y0, x1, y1], [x1, y0, x0, y1], [x1, y1, x0, y0], [x0, y1, x1, y0]] as number[][]) { const fixed = inv(ox2, oy2); handles.push(svg` `); } } return html` this._vacFitPointer(e, view)} @pointermove=${(e: PointerEvent) => this._vacFitPointer(e, view)} @pointerup=${(e: PointerEvent) => this._vacFitPointer(e, view)} @pointercancel=${(e: PointerEvent) => this._vacFitPointer(e, view)}>${rects}${dot}${handles}`; } public _resetRoomDialogFields(): void { this.host._roomEditId = null; this.host._roomFill = ''; this.host._roomCustomFill = null; this.host._roomTempMin = ''; this.host._roomTempMax = ''; this.host._roomTempSrc = ''; this.host._roomHumSrc = ''; this.host._roomSrcOpen = null; this.host._roomSrcFilter = ''; this.host._roomNameScale = 1; this.host._roomLabelScale = 1; } public _openRoomEdit(r: RoomCfg): void { if (!r.id) return; this.host._roomEditId = r.id; this.host._nameSel = r.name || ''; this.host._areaSel = r.area || ''; this.host._roomFill = r.settings?.fill_mode === 'glow' ? '' : ((r.settings?.fill_mode as any) || ''); // #581: a colour stored without the room's own custom mode is an orphan and stays out of the draft const rawCustom = this.host._roomFill === 'custom' ? r.settings?.custom_fill : null; this.host._roomCustomFill = rawCustom && typeof rawCustom === 'object' ? customFillOf(rawCustom, spaceDisplayOf(this.host._curSpaceCfg).customFill) : null; [this.host._roomTempMin, this.host._roomTempMax] = roomTempThresholdInputValues(r.settings); this.host._roomTempSrc = r.settings?.temp_source || ''; this.host._roomHumSrc = r.settings?.hum_source || ''; this.host._roomNameScale = clampScale(r.settings?.name_scale); this.host._roomLabelScale = clampScale(r.settings?.label_scale); this.host._roomSrcOpen = null; this.host._roomSrcFilter = ''; this.host._roomDialog = true; rememberRoomBaseline(this.host); // #600 К10: Save только при изменениях } public _roomSettingsFromDialog(): RoomCfg['settings'] { const st: any = {}; if (this.host._roomFill) st.fill_mode = this.host._roomFill; if (this.host._roomFill === 'custom' && this.host._roomCustomFill) st.custom_fill = this.host._roomCustomFill; // #581 if (this.host._roomTempSrc) st.temp_source = this.host._roomTempSrc; if (this.host._roomHumSrc) st.hum_source = this.host._roomHumSrc; applyRoomTempThresholdDraft(st, this.host._roomTempMin, this.host._roomTempMax); if (this.host._roomNameScale !== 1) st.name_scale = this.host._roomNameScale; if (this.host._roomLabelScale !== 1) st.label_scale = this.host._roomLabelScale; return Object.keys(st).length ? st : null; } public _saveRoomEdit(): void { const sp = this.host._curSpaceCfg; const room = sp?.rooms.find((x: any) => x.id === this.host._roomEditId); if (!room) { this.host._roomDialog = false; this.host._roomEditId = null; return; } room.name = this.host._nameSel.trim() || room.name; room.area = this.host._areaSel || null; // Preserve unknown/future room settings. A legacy fill_mode:'glow' materializes its effective // Glow in the same write (an unrelated room edit must not switch the overlay off). #581: the room // colour is kept only with the room's own custom mode — "as the space" forgets it, orphans included. const previous = room.settings || {}; const next: any = { ...previous }; if (!applyRoomTempThresholdDraft(next, this.host._roomTempMin, this.host._roomTempMax)) return; if (this.host._roomFill) next.fill_mode = this.host._roomFill; else delete next.fill_mode; if (this.host._roomFill === 'custom' && this.host._roomCustomFill) next.custom_fill = this.host._roomCustomFill; else delete next.custom_fill; if (previous.fill_mode === 'glow' && typeof previous.glow !== 'boolean') next.glow = true; if (this.host._roomTempSrc) next.temp_source = this.host._roomTempSrc; else delete next.temp_source; if (this.host._roomHumSrc) next.hum_source = this.host._roomHumSrc; else delete next.hum_source; if (this.host._roomNameScale !== 1) next.name_scale = this.host._roomNameScale; else delete next.name_scale; if (this.host._roomLabelScale !== 1) next.label_scale = this.host._roomLabelScale; else delete next.label_scale; if (Object.keys(next).length) room.settings = next; else delete room.settings; this._saveConfig(); this.host._roomDialog = false; this.host._roomEditId = null; this.host._nameSel = ''; this.host._areaSel = ''; this.host._regSignature = ''; this.host._maybeRebuildDevices(); this.host.requestUpdate(); this.host._showToast(this.host._t('toast.room_updated')); } public _roomSrcCandidates(): { value: string; label: string; sub: string }[] { const h = this.host._planHass; const removed = removedPlanBindings(this.host._markers); const q = this.host._roomSrcFilter.trim().toLowerCase(); const list: { value: string; label: string; sub: string }[] = []; for (const dev of Object.values(h.devices)) { if (dev.entry_type === 'service') continue; if (removed.devices.has(dev.id)) continue; const name = (dev.name_by_user || dev.name || dev.id).trim(); if (q && !name.toLowerCase().includes(q)) continue; list.push({ value: 'device:' + dev.id, label: name, sub: dev.model || this.host._t('marker.sub_device') }); } for (const [eid, reg] of Object.entries(h.entities)) { if (!eid.startsWith('sensor.') || reg.hidden) continue; if (isRemovedPlanEntity(h, eid, removed)) continue; const label = reg.name || h.states[eid]?.attributes?.friendly_name || eid; if (q && !(label + ' ' + eid).toLowerCase().includes(q)) continue; list.push({ value: 'entity:' + eid, label, sub: eid }); } list.sort((a, b) => a.label.localeCompare(b.label)); return list.slice(0, 200); } public _roomSrcLabel(src: string): string { const i = src.indexOf(':'); const k = src.slice(0, i); const ref = src.slice(i + 1); if (k === 'device') { return this.host._fullRegistryHass.devices[ref]?.name_by_user || this.host._fullRegistryHass.devices[ref]?.name || ref; } return this.host._fullRegistryHass.entities[ref]?.name || this.host.hass.states[ref]?.attributes?.friendly_name || ref; } public _labelPos(r: RoomCfg, spaceId: string): { x: number; y: number } { const saved = this.host._layout['rl_' + (r.id || '')]; if (saved && saved.s === spaceId) { return { x: saved.x * NORM_W, y: saved.y * NORM_W }; } // a label nobody has dragged sits at the room's CENTROID, which is not a // node for an odd-sized or polygonal room — put it on the nearest one const c = this._snap(this.host._roomCenter(r)); return { x: c[0], y: c[1] }; } public _labelDown(ev: PointerEvent, r: RoomCfg, spaceId: string): void { if (this.host._mode !== 'plan') return; ev.preventDefault(); ev.stopPropagation(); const p = this._labelPos(r, spaceId); this.host._drag = { id: 'rl_' + (r.id || ''), sx: ev.clientX, sy: ev.clientY, ox: p.x, oy: p.y, moved: false }; capturePointer(ev); this.host._tip = null; } public _labelMove(ev: PointerEvent, r: RoomCfg, spaceId: string): void { const id = 'rl_' + (r.id || ''); if (!this.host._drag || this.host._drag.id !== id) return; const stage = this.host._stageEl; if (!stage) return; const space = this.host._spaceModelById(spaceId); if (!space) return; const vb = space.vb; const rect = stage.getBoundingClientRect(); const v = this.host._viewOr(vb); const dx = ((ev.clientX - this.host._drag.sx) / rect.width) * v.w; const dy = ((ev.clientY - this.host._drag.sy) / rect.height) * v.h; if (Math.abs(ev.clientX - this.host._drag.sx) + Math.abs(ev.clientY - this.host._drag.sy) > 3) this.host._drag.moved = true; // DEV-B58-01, and worse than the marker's: this clamped to the space's // STORED view_box, which for every existing plan is the old unit square. // A room drawn at 2.5 had a name that could not be dragged to its own room. const nx = clampCanvasR(this.host._drag.ox + dx); const ny = clampCanvasR(this.host._drag.oy + dy); this.host._savePos({ id, space: spaceId } as DevItem, nx, ny); } public _labelUp(r: RoomCfg): void { const id = 'rl_' + (r.id || ''); if (!this.host._drag || this.host._drag.id !== id) return; const moved = this.host._drag.moved; this.host._drag = moved ? this.host._drag : null; if (moved) window.setTimeout(() => (this.host._drag = null), 0); } public _labelScale(r: RoomCfg): number { const k = (this.host._layout['rl_' + (r.id || '')] as any)?.k; return typeof k === 'number' && Number.isFinite(k) ? Math.min(3, Math.max(0.5, k)) : 1; } public _rlResizeDown(ev: PointerEvent, r: RoomCfg, spaceId: string): void { if (this.host._mode !== 'plan') return; ev.preventDefault(); ev.stopPropagation(); const card = (ev.target as HTMLElement).closest('.roomlabel') as HTMLElement | null; if (!card) return; const b = card.getBoundingClientRect(); const cx = b.left + b.width / 2; const cy = b.top + b.height / 2; const d0 = Math.max(8, Math.hypot(ev.clientX - cx, ev.clientY - cy)); this.host._rlResize = { id: 'rl_' + (r.id || ''), space: spaceId, k0: this._labelScale(r), cx, cy, d0 }; capturePointer(ev); } public _rlResizeMove(ev: PointerEvent): void { const rs = this.host._rlResize; if (!rs) return; ev.stopPropagation(); const dist = Math.max(8, Math.hypot(ev.clientX - rs.cx, ev.clientY - rs.cy)); const k = Math.min(3, Math.max(0.5, rs.k0 * (dist / rs.d0))); const rec: any = this.host._layout[rs.id]; if (!rec) { // the card was never dragged: pin its current default position first const roomId = rs.id.slice(3); const sp = this.host._spaceModelById(rs.space); if (!sp) return; const room = sp.rooms.find((x) => x.id === roomId); if (!room) return; const p = this._labelPos(room, rs.space); this.host._layout = { ...this.host._layout, [rs.id]: { s: rs.space, x: p.x / NORM_W, y: p.y / NORM_W, k }, }; } else { this.host._layout = { ...this.host._layout, [rs.id]: { ...rec, k } }; } this.host._dirtyPos.add(rs.id); } public _rlResizeUp(): void { if (!this.host._rlResize) return; this.host._rlResize = null; this.host._persistLayout(); } public _cancelRoomGearForMultitouch(): boolean { return this.roomGear.cancelForMultitouch(); } public _renderRoomGear( r: RoomCfg, space: SpaceModel, view: { x: number; y: number; w: number; h: number }, preview: boolean, ): TemplateResult | typeof nothing { if (!r.id) return nothing; this.roomGear.adoptPlan(space); const polygon = roomPoly(r); const c = preview ? this.roomGear.previewCenter(r, polygon, space.id) : this.roomGear.center(r, polygon, space.id); if (!c) return nothing; const left = ((c[0] - view.x) / view.w) * 100; const top = ((c[1] - view.y) / view.h) * 100; const dragging = this.roomGear.dragging(this.roomGear.key(r.id, space.id)); return html``; } public _alignCandidates(): number[][] { const out: number[][] = []; const spm = this.host._spaceModel(); if (this.host._markup) { if (!spm) return out; if (this.host._drag?.id.startsWith('rl_')) { // room-card drag: centers of the OTHER room cards const dragged = this.host._drag.id.slice(3); for (const r of spm.rooms) { if (!r.name || r.id === dragged) continue; const p = this._labelPos(r, this.host._space); out.push([p.x, p.y]); } return out; } // drawing: room vertices + current path/split points for (const r of spm.rooms) { const poly = roomPoly(r); if (poly) for (const p of poly) out.push(p); } if (this.host._tool === 'draw') for (const p of this.host._path) out.push(p); if (this.host._tool === 'split' && this.host._splitSel?.pts) for (const p of this.host._splitSel.pts) out.push(p); return out; } if (this.host._mode === 'devices') { // other icons of this space only (owner's decision) // // #400: the dragged marker is excluded by `_deviceDrag`, not `_drag`. // Device dragging moved into its own state with #74, and `_drag` is // null in this mode — so the marker being moved was listed among its // own alignment candidates. Nothing looked wrong because a point always // matches itself within tolerance: the guide was drawn from the marker // to itself and is indistinguishable from an honest one. const draggedId = this.host._deviceDrag?.id ?? this.host._drag?.id; for (const d of this.host._devices) { if (d.space !== this.host._space || d.id === draggedId || d.bindingStatus?.kind === 'ha_disabled') continue; const p = this.host._pos(d); out.push([p.x, p.y]); } return out; } if (this.host._mode === 'decor') { const movingId = this.host._decorMove?.id; out.push(...this._decorSnapGeometry(movingId).points); if (this.host._decorDraft) out.push(this.host._decorDraft.a); return out; } return out; } public _renderAlignGuides(): TemplateResult { const pt = this.host._alignPoint; if (!pt) return svg`` as unknown as TemplateResult; // exact node match for grid-snapped things; half a cell for free-moving cards const tol = this.host._drag?.id.startsWith('rl_') ? this.host._gridPitch * 0.5 : this.host._gridPitch * 0.05; const guides = alignGuides(pt, this._alignCandidates(), tol); if (!guides.length) return svg`` as unknown as TemplateResult; const g = this.host._gridPitch; const over = g * 1.5; // extend a little past the point return svg` ${guides.map((gd: AlignGuide) => { const [x1, y1, x2, y2] = gd.axis === 'x' ? [gd.at, gd.from[1], gd.at, pt[1] + Math.sign(pt[1] - gd.from[1]) * over] : [gd.from[0], gd.at, pt[0] + Math.sign(pt[0] - gd.from[0]) * over, gd.at]; return svg` `; })} ` as unknown as TemplateResult; } public _renderOpeningCenterTick(gd: { x: number; y: number; angle: number }): TemplateResult { const rad = ((gd.angle + 90) * Math.PI) / 180; const half = gridVisualUnits(2.5 * 6, this.host._cellCm); return svg`` as unknown as TemplateResult; } public _renderOpeningDimensionGuides(measure: OpMeasure): TemplateResult { const dimensions = measure.labels.flatMap((label) => label.dimension ? [label.dimension] : []); if (!dimensions.length) return svg`` as unknown as TemplateResult; const tickHalf = this._cssPxToRender(4); return svg`` as unknown as TemplateResult; } public _renderOpeningPlacementPreview(): TemplateResult { const candidate = this.host._openingPreview; if (!candidate) return svg``; const visibleSpec: OpeningVisibleSpec = { type: candidate.type, length: candidate.renderedLength, angle: candidate.angle, amount: openingAmount(candidate.type, null), flipH: candidate.flipH, flipV: candidate.flipV, base: 'var(--hp-open)', tone: 'var(--hp-open)', cellCm: this.host._cellCm, gridPitch: this.host._gridPitch, face: candidate.face, }; const passageGeometry = candidate.type === 'passage' ? passagePlacementPreviewGeometry( candidate, gridVisualUnits(this.host._gridPitch, this.host._cellCm), ) : null; return svg` ` as unknown as TemplateResult; } public _renderOpeningDialog(): TemplateResult { const d = this.host._openingDialog!; const partitions = this.host._spaceModel()?.partitions || []; const previous = d.id ? this.host._curSpaceCfg?.openings?.find((item: OpeningCfg) => item.id === d.id) : null; const dialogOpening: OpeningCfg = { id: d.id || 'preview', type: d.type, x: d.x / NORM_W, y: d.y / this.host._spaceH, angle: d.angle, length: previous && !d.lengthTouched ? previous.length : this.host._cmToUnits(Math.max(20, d.lengthCm)) / NORM_W, ...(d.host ? { host: d.host } : {}), }; const strict = partitionOpeningNeedsStrictValidation(previous, dialogOpening); const hostResolution = d.host ? strict ? resolvePartitionOpeningStrict( dialogOpening, partitions, NORM_W, this.host._cellCm, this.host._gridPitch, ) : resolvePartitionOpeningCompat( dialogOpening, partitions, NORM_W, this.host._cellCm, this.host._gridPitch, ) : null; const jambInvalid = hostResolution?.reason === 'does-not-fit-jamb'; const hostPartition = d.host ? partitions.find((partition) => partition.id === d.host!.id) : null; const jambDistance = hostPartition ? formatLength(hostPartition.cm / 2, this.host._imperial) : ''; const orphan = !!d.host && !hostResolution?.resolved && !jambInvalid; const icon = d.type === 'gate' ? 'mdi:gate' : d.type === 'window' ? 'mdi:window-closed-variant' : d.type === 'passage' ? 'mdi:arch' : 'mdi:door'; const picker = (kind: 'contact' | 'lock', list: { value: string; label: string }[]) => { const cur = kind === 'contact' ? d.contact : d.lock; const open = kind === 'contact' ? !!d.contactOpen : !!d.lockOpen; const filter = kind === 'contact' ? d.contactFilter || '' : d.lockFilter || ''; const selected = list.find((candidate) => candidate.value === cur); const selectedLabel = selected?.label || this.host.hass.states[cur]?.attributes?.friendly_name || this.host._fullRegistryHass.entities[cur]?.name || cur; const filtered = filterOpeningEntityCandidates(list, filter); return html` ${open ? html`
this._filterOpeningEntities( kind, (e.target as HTMLInputElement).value, )} />
${filtered.map((candidate) => html` `)} ${!filtered.length ? html`
${this.host._t('marker.nothing_found')}
` : nothing}
` : nothing}`; }; return html` (this.host._openingDialog = null)}>
${d.host ? html`
${orphan || jambInvalid ? html`` : nothing} ${orphan ? this.host._t('opening.partition_orphan') : jambInvalid ? this.host._t('opening.partition_jamb_margin', { distance: jambDistance }) : this.host._t('opening.host_partition')}
` : nothing} { const n = strictNumber((e.target as HTMLInputElement).value); if (n != null) this.host._openingDialog = { ...d, lengthCm: n, lengthTouched: true }; }} /> ${d.type === 'passage' && (d.contact || d.lock) ? html`
${this.host._t('opening.passage_binding_warning')}
` : nothing} ${d.type !== 'passage' ? html` ${picker('contact', this._contactCandidates())} ${d.contact ? html`` : nothing}` : nothing} ${d.type === 'door' || d.type === 'gate' ? html` ${picker('lock', this._lockCandidates())}` : nothing} ${d.type !== 'gate' && d.type !== 'passage' ? html`` : nothing} ${d.type !== 'passage' ? html`` : nothing}
`; } public _gridLevels(): { fine: number; coarse: number } | null { const stage = this.host._stageEl; const v = this.host._viewOr(this.host._baseVb()); const px = stage && stage.clientWidth && v.w ? stage.clientWidth / v.w : 1; return gridLevels(this.host._gridPitch, px); } public _renderMarkupDefs(_vb: number[]): TemplateResult { const lv = this._gridLevels(); if (!lv) return svg`` as unknown as TemplateResult; const g = this.host._gridPitch * lv.fine; const G = this.host._gridPitch * lv.coarse; const dotR = this.host._gridPitch * lv.fine * 0.14; // Two patterns, CAD-style: the fine dots thin out as you zoom away // (gridLevels drops whole decades of them) while every coarse node keeps // a bigger, darker dot, so the eye never loses the scale reference. return svg` `; } public _renderPhysicalEditorLayer(): TemplateResult { const space = this.host._spaceModel(); if (!space) return svg`` as unknown as TemplateResult; const g = this.host._gridPitch; const view = this.host._viewOr(this.host._baseVb()); const stage = this.host._stageEl; const unitsPerPx = stage?.clientWidth ? view.w / stage.clientWidth : g / 8; const touchStroke = 24; const handleR = Math.max(g * 0.22, unitsPerPx * 8); const rotateHandleR = Math.max(handleR, unitsPerPx * 12); const path = (poly: number[][]) => `M ${poly.map((p) => `${p[0]} ${p[1]}`).join(' L ')} Z`; const selected = (kind: string, id: string) => (this.host._physicalSel?.kind === kind && this.host._physicalSel.id === id) || (kind === 'column' && this.host._duplicateColumnId === id); const partitions = (space.partitions || []).map((p) => { const body = partitionBody(p.a, p.b, p.cm, this.host._cellCm, this.host._gridPitch); return body ? svg` this._physicalDown(e, 'partition', p.id)} @pointermove=${(e: PointerEvent) => this._physicalMove(e)} @pointerup=${(e: PointerEvent) => this._physicalUp(e)}>` : svg` this._physicalDown(e, 'partition', p.id)} @pointermove=${(e: PointerEvent) => this._physicalMove(e)} @pointerup=${(e: PointerEvent) => this._physicalUp(e)}>`; }); const columns = (space.wall_columns || []).map((c) => { const shown = this.host._physicalRotate?.id === c.id ? { ...c, angle: this.host._physicalRotate.angle } as WallColumnCfg : c; const body = columnBody(shown, this.host._cellCm, this.host._gridPitch); return svg` this._physicalDown(e, 'column', c.id)} @pointermove=${(e: PointerEvent) => this._physicalMove(e)} @pointerup=${(e: PointerEvent) => this._physicalUp(e)}>`; }); const drag = this.host._physicalDrag; const ghost = (() => { if (!drag?.moved) return nothing; if (drag.kind === 'partition' && Number((drag.base as PartitionCfg).cm) === 0) { const partition = drag.base as PartitionCfg; return svg``; } const poly = drag.kind === 'partition' ? partitionBody((drag.base as PartitionCfg).a, (drag.base as PartitionCfg).b, (drag.base as PartitionCfg).cm, this.host._cellCm, this.host._gridPitch) : columnBody(drag.base as WallColumnCfg, this.host._cellCm, this.host._gridPitch); return poly ? svg`` : nothing; })(); const chrome = (() => { const sel = this.host._physicalSel; if (!sel) return nothing; if (sel.kind === 'partition') { const p = space.partitions.find((x) => x.id === sel.id); if (!p) return nothing; const body = partitionBody(p.a, p.b, p.cm, this.host._cellCm, this.host._gridPitch); const mx = (p.a[0] + p.b[0]) / 2, my = (p.a[1] + p.b[1]) / 2; return svg` ${body ? svg`` : svg``} `; } const base = space.wall_columns.find((x) => x.id === sel.id); if (!base) return nothing; const c = this.host._physicalRotate?.id === base.id ? { ...base, angle: this.host._physicalRotate.angle } as WallColumnCfg : base; const body = columnBody(c, this.host._cellCm, this.host._gridPitch); if (c.shape !== 'square') return svg` `; const size = wallCmToUnits(c.cm, this.host._cellCm, this.host._gridPitch); const angle = canonicalColumnAngle(c.angle) * Math.PI / 180; const ux = Math.sin(angle), uy = -Math.cos(angle); const edge = [c.center[0] + ux * size / 2, c.center[1] + uy * size / 2]; const handle = [edge[0] + ux * Math.max(g, unitsPerPx * 24), edge[1] + uy * Math.max(g, unitsPerPx * 24)]; return svg` this._physicalRotateDown(e, base)} @pointermove=${(e: PointerEvent) => this._physicalRotateMove(e)} @pointerup=${(e: PointerEvent) => this._physicalRotateUp(e)}> `; })(); return svg`${partitions}${columns}${ghost}${chrome}`; } public _renderHiddenWallDiagnosticOverlay(): TemplateResult { if (!this.host._markup) return svg`` as unknown as TemplateResult; const geometry = this._hiddenWallDiagnosticSnapshot().value; if (!geometry.segments.length) return svg`` as unknown as TemplateResult; const radius = wallCmToUnits(5, this.host._cellCm, this.host._gridPitch); return svg`` as unknown as TemplateResult; } public _renderPlanSnapOverlay(): TemplateResult { if (!this.host._markup) { return svg`` as unknown as TemplateResult; } const geometry = this._planSnapGeometrySnapshot().value; const active = this.host._activePlanSnapCandidate; const conflictKeys = new Set(this.host._activePlanSnapConflicts.map((item) => item.key)); const staticRadius = wallCmToUnits(5, this.host._cellCm, this.host._gridPitch); const activeRadius = wallCmToUnits(10, this.host._cellCm, this.host._gridPitch); return svg`` as unknown as TemplateResult; } public _syncPlanSnapActiveMarker(candidate: PlanSnapCandidate | null): void { const marker = this.host.renderRoot?.querySelector( '[data-hp="plan-snap-active-marker"]', ); if (!marker) return; marker.setAttribute('class', `plan-snap-node${candidate ? ' active' : ''}${ candidate?.kind === 'line' ? ' dynamic' : '' }`); marker.setAttribute('data-active', candidate ? 'true' : 'false'); marker.setAttribute('visibility', candidate ? 'visible' : 'hidden'); if (!candidate) { marker.removeAttribute('data-kind'); marker.removeAttribute('data-key'); return; } marker.setAttribute('data-kind', candidate.kind); marker.setAttribute('data-key', candidate.key); marker.setAttribute('cx', String(candidate.point[0])); marker.setAttribute('cy', String(candidate.point[1])); marker.setAttribute('r', String(wallCmToUnits(10, this.host._cellCm, this.host._gridPitch))); } public _syncPlanSnapConflictMarkers(conflicts: readonly PlanSnapEndpoint[]): void { const keys = new Set(conflicts.map((item) => item.key)); for (const marker of this.host.renderRoot?.querySelectorAll( '.plan-snap-node[data-kind="endpoint"]', ) || []) { marker.setAttribute('class', `plan-snap-node${keys.has(marker.dataset.key || '') ? ' conflict' : ''}`); } } public _planSnapPhysicalSegment(segment: PlanSnapSegment): LinearWallSegment | null { let cm = 0; const space = this.host._spaceModel(); if (!space) return null; if (segment.sourceKind === 'partition') { cm = Number(space.partitions.find((item) => item.id === segment.sourceId)?.cm) || 0; } else { cm = intervalCmAt( space.rooms, this.host._spaceWalls, this.host._openCuts(), [segment.a[0], segment.a[1], segment.b[0], segment.b[1]], this.host._wallKeyPitch, this.host._cellCm, this.host._gridPitch, NORM_W, ); } if (!(cm > 0)) return null; return { a: [...segment.a], b: [...segment.b], halfDepth: wallCmToUnits(cm, this.host._cellCm, this.host._gridPitch) / 2, }; } public _drawPreviewJoinPatchD( points: number[][], halfDepths: number[], ): string { if (points.length < 2) return ''; const preview: LinearWallSegment[] = []; for (let i = 0; i + 1 < points.length; i++) { if (!(halfDepths[i] > 0)) continue; preview.push({ a: points[i], b: points[i + 1], halfDepth: halfDepths[i] }); } if (!preview.length) return ''; const eps = this.host._gridPitch * 0.0002; const touching = this._planSnapGeometrySnapshot().value.segments .filter((segment) => points.some((point) => distToSegment(point, [ segment.a[0], segment.a[1], segment.b[0], segment.b[1], ]) <= eps)) .map((segment) => this._planSnapPhysicalSegment(segment)) .filter((segment): segment is LinearWallSegment => !!segment); const patches = linearWallJoinPatches([...preview, ...touching], eps); return patches.map((patch) => `M ${patch.map((point) => `${point[0]} ${point[1]}`).join(' L ')} Z`).join(' '); } public _renderMarkupLayer(vb: number[]): TemplateResult { // Derived axes omit zero and positive-body stretches; their dedicated // layers paint those segments with the correct style and z-order. const openCuts = this.host._openCuts(); const thickCuts = this.host._thickWallCuts(); const allCuts = openCuts.concat(thickCuts); const segs = allCuts.length ? cutSegments(this.host._segments, allCuts, this.host._gridPitch * 0.02) : this.host._segments; const path = this.host._path; const g = this.host._gridPitch; const view = this.host._viewOr(this.host._baseVb()); const drawCm = this.host._tool === 'draw' ? this.host._drawWallCm : null; const previewPts = (() => { if (this.host._tool !== 'draw' || !path.length || drawCm == null) return null; if (this.host._contourClosed) return path; if (this.host._cursorPt) return [...path, this.host._cursorPt]; return path.length >= 2 ? path : null; })(); // Превью берёт толщины из того же резолвера, что и запись (#234). Раньше // здесь была вторая формула, и расходились они ровно на пропуске: на экране // текущее поле, в конфиге — 15 см. const previewHalfDepths = previewPts ? chainSegmentCms( previewPts.length - 1, this.host._contourClosed ? [...this.host._wallChainSegmentCms, this.host._closingWallCm ?? undefined] : this.host._wallChainSegmentCms, drawCm, DRAW_WALL_DEFAULT_CM, ).map((cm) => wallCmToUnits(cm, this.host._cellCm, this.host._gridPitch) / 2) : []; const previewD = previewPts ? drawWallPreviewD( previewPts, wallCmToUnits(drawCm!, this.host._cellCm, this.host._gridPitch) / 2, this.host._contourClosed, previewHalfDepths, ) : ''; const previewJoinPatchD = previewPts ? this._drawPreviewJoinPatchD(previewPts, previewHalfDepths) : ''; const repairPreview = this.host._wallFaceBatch?.candidates[this.host._wallFaceBatch.index]?.repair || this.host._wallRepairDiagnostic; return svg` ${this._gridLevels() ? svg`` : nothing} ${segs.map((s) => svg``)} ${this._renderPhysicalEditorLayer()} ${this.host._tool === 'column' && this.host._cursorPt && this.host._drawWallCm ? svg` { const c: WallColumnCfg = { id: 'preview', shape: 'square', center: this.host._cursorPt!, cm: this.host._drawWallCm! }; const body = columnBody(c, this.host._cellCm, this.host._gridPitch); return `M ${body.map((p) => `${p[0]} ${p[1]}`).join(' L ')} Z`; })()}>` : nothing} ${previewD ? svg` ` : nothing} ${previewPts && drawCm === 0 ? svg` point.join(',')).join(' ')}>` : nothing} ${previewJoinPatchD ? svg` ` : nothing} ${repairPreview ? svg`` : nothing} ${this.host._tool === 'split' && this.host._splitSel?.pts?.length ? svg`${this.host._splitSel.pts.length > 1 ? svg`` : nothing} ${this.host._splitSel.pts.map((p, i) => svg``)} ${this.host._cursorPt ? svg`` : nothing}` : nothing} `; } public _renderActiveChainInk(): TemplateResult { const path = this.host._path; const g = this.host._gridPitch; return svg` ${path.length > 1 ? svg`` : nothing} ${path.length && this.host._cursorPt && this.host._tool === 'draw' && !this.host._contourClosed ? svg` ${!this.host._activePlanSnapCandidate && !this.host._activePlanSnapConflicts.length ? svg`` : nothing}` : nothing} ${path.map((p, i) => svg``)} ` as unknown as TemplateResult; } public _renderPartitionDeleteDialog(): TemplateResult { const dialog = this.host._partitionDeleteDialog!; const imperial = this.host.hass?.config?.unit_system?.length === 'mi'; return html` (this.host._partitionDeleteDialog = null)}>

${this.host._t('confirm.delete_partition_openings_body', { count: dialog.openings.length, })}

    ${dialog.openings.map((opening) => html`
  • ${this.host._t( 'confirm.delete_partition_openings_item', { type: this.host._t(`opening.${opening.type}` as I18nKey), length: formatLength( (opening.length * NORM_W / this.host._gridPitch) * this.host._cellCm, imperial, ), }, )}
  • `)}
`; } public _renderRoomDeleteDialog(): TemplateResult { const dialog = this.host._roomDeleteDialog!; return html` (this.host._roomDeleteDialog = null)}>

${this.host._t('confirm.delete_room_body')}

`; } public _renderPhysicalDialog(): TemplateResult { const d = this.host._physicalDialog!; const column = d.kind === 'column'; return html` (this.host._physicalDialog = null)}>
${column ? html` ` : nothing}
(this.host._physicalDialog = { ...d, cm: (e.target as HTMLInputElement).value, })} /> ${this.host._t(this.host._imperial ? 'wallthick.unit_in' : 'wallthick.unit_cm')}
${column && d.shape === 'square' ? html` (this.host._physicalDialog = { ...d, angle: (e.target as HTMLInputElement).value, })} />` : nothing} ${d.length ? html`
${this.host._t('physical.length')}: ${d.length}
` : nothing}
`; } public _renderMarkupBar(): TemplateResult { const undoName = this.host._geometryHistory.undoName; const redoName = this.host._geometryHistory.redoName; const undoTitle = undoName ? this.host._t('history.undo_named', { name: undoName }) : this.host._t('history.undo_empty'); const redoTitle = redoName ? this.host._t('history.redo_named', { name: redoName }) : this.host._t('history.redo_empty'); return html`
${this.host._editorToolbarGroups.map((group) => this._renderEditorGroupLauncher(group))}
`; } public _renderDevicesBar(): TemplateResult { const undoName = this.host._devicePositionHistory.undoName; const redoName = this.host._devicePositionHistory.redoName; const undoTitle = undoName ? this.host._t('history.undo_named', { name: undoName }) : this.host._t('history.undo_empty'); const redoTitle = redoName ? this.host._t('history.redo_named', { name: redoName }) : this.host._t('history.redo_empty'); return html`
${this.host._editorToolbarGroups.map((group) => this._renderEditorGroupLauncher(group))}
`; } /** #44: drafts merged over the stored settings — the single truth the * preview, the chips and Save all read. */ public _discoveryFilterState(dialog: DeviceInboxDialogState): { groupLights: boolean; excluded: string[]; usesProductList: boolean; dirty: boolean; } { const settings = this.host._settings as { group_lights?: boolean; exclude_integrations?: string[] }; const groupLights = dialog.draftGroupLights !== undefined ? dialog.draftGroupLights : settings.group_lights !== false; const stored = settings.exclude_integrations; const excluded = dialog.draftExcluded !== undefined ? (dialog.draftExcluded === null ? [...EXCLUDED_DOMAINS] : dialog.draftExcluded) : (stored ? [...stored] : [...EXCLUDED_DOMAINS]); const usesProductList = dialog.draftExcluded !== undefined ? dialog.draftExcluded === null : !stored; const dirty = dialog.draftGroupLights !== undefined || dialog.draftExcluded !== undefined; return { groupLights, excluded: excluded.sort(), usesProductList, dirty }; } /** Integrations really present in the HA registry (platforms + device * identifier domains), for the exclusion search. */ private _registryIntegrations(): string[] { const found = new Set(); // Реестр HA нетипизирован на своей стороне, но здесь читаются ровно два // поля и оба защищённо, поэтому хватает минимальной структурной формы — // она честнее `any`: описывает то, на что код действительно опирается. const full = this.host._fullRegistryHass as { entities?: Record; devices?: Record; } | null | undefined; for (const reg of Object.values(full?.entities || {})) { if (reg?.platform) found.add(String(reg.platform)); } for (const device of Object.values(full?.devices || {})) { const first = device?.identifiers?.[0]; const domain = Array.isArray(first) ? first[0] : null; if (domain) found.add(String(domain)); } return [...found].sort(); } private _discoveryPreviewMemo: { key: string; value: { appear: number; hide: number; lights: number } } | null = null; /** #44 AC6: the preview runs the SAME buildDevices the plan runs — draft * settings in, binding sets compared. Memoised so hass ticks while the * section is open do not redo discovery. */ public _discoveryFilterPreview(dialog: DeviceInboxDialogState): { appear: number; hide: number; lights: number } { const draft = this._discoveryFilterState(dialog); const key = [ draft.groupLights ? 1 : 0, draft.usesProductList ? 'p' : draft.excluded.join(','), this.host._cfgEpoch, this.host._regSignature, ].join('|'); if (this._discoveryPreviewMemo?.key === key) return this._discoveryPreviewMemo.value; const ctx = { hass: this.host.hass, registry: this.host._haRegistry, areaToSpace: Object.fromEntries( Object.entries(this.host._areaToSpace).map(([a, v]) => [a, v.space]), ), markers: this.host._markers, showAll: this.host._showAll, firstSpaceId: this.host._model[0]?.id || '', loc: (k: string) => this.host._t(k as never), iconRules: this.host._iconRules, }; // Two shared builders, zero copies of the filter logic: the exclusion // materialises through seedHiddenBindings (hidden markers), the grouping // changes the buildDevices candidate set — the preview diffs BOTH. const seededOf = (settings: object, excluded: ReadonlySet) => new Set( seedHiddenBindings({ ...ctx, settings, excluded } as never)); const candidatesOf = (settings: object, excluded: ReadonlySet) => new Set( buildDevices({ ...ctx, settings, excluded } as never) .map((device) => device.bindingRef || device.id)); const currentExcluded = effectiveExcludedIntegrations(this.host._settings); const draftSettings = { ...this.host._settings, group_lights: draft.groupLights }; const draftExcluded = new Set(draft.excluded); const seededNow = seededOf(this.host._settings, currentExcluded); const seededNext = seededOf(draftSettings, draftExcluded); const candidatesNow = candidatesOf(this.host._settings, currentExcluded); const candidatesNext = candidatesOf(draftSettings, draftExcluded); let appear = 0, hide = 0, lights = 0; const isLight = (binding: string) => binding.includes('light.'); for (const binding of seededNow) if (!seededNext.has(binding)) { appear++; if (isLight(binding)) lights++; } for (const binding of seededNext) if (!seededNow.has(binding)) { hide++; if (isLight(binding)) lights++; } for (const binding of candidatesNext) if (!candidatesNow.has(binding)) { appear++; if (isLight(binding)) lights++; } for (const binding of candidatesNow) if (!candidatesNext.has(binding)) { hide++; if (isLight(binding)) lights++; } const value = { appear, hide, lights }; this._discoveryPreviewMemo = { key, value }; return value; } /** #44: one settings write per Save; defaults are stored as ABSENCE. */ public async _saveDiscoveryFilters(dialog: DeviceInboxDialogState): Promise { const cfg = this.host._serverCfg; if (!cfg) return; const draft = this._discoveryFilterState(dialog); const settings: Record = { ...(cfg.settings as object) }; if (draft.groupLights) delete settings.group_lights; else settings.group_lights = false; if (draft.usesProductList) delete settings.exclude_integrations; else settings.exclude_integrations = draft.excluded; this.host._serverCfg = { ...cfg, settings } as typeof cfg; this.host._deviceInboxMemo = null; this._discoveryPreviewMemo = null; this.host._regSignature = ''; // force the device rebuild to see new filters if (this.host._deviceInbox) { this.host._deviceInbox = { ...dialog, draftGroupLights: undefined, draftExcluded: undefined, }; } this._saveConfig(); this.host._maybeRebuildDevices(); this.host.requestUpdate(); } private _renderDiscoveryFilters(dialog: DeviceInboxDialogState): TemplateResult { const draft = this._discoveryFilterState(dialog); const patch = (delta: Partial) => (this.host._deviceInbox = { ...dialog, ...delta }); const preview = dialog.filtersOpen && draft.dirty ? this._discoveryFilterPreview(dialog) : null; const known = this._registryIntegrations().filter((name) => !draft.excluded.includes(name)); return html`
patch({ filtersOpen: (event.target as HTMLDetailsElement).open })}> ${this.host._t('device_inbox.filters_title' as never)}
${this.host._t('device_inbox.filters_excluded' as never)}
${draft.excluded.map((name) => html`${name} `)}
{ const input = event.target as HTMLInputElement; const name = input.value.trim(); if (name && !draft.excluded.includes(name)) { patch({ draftExcluded: [...draft.excluded, name] }); } input.value = ''; }} /> ${known.map((name) => html``)}
${preview ? html`
${this.host._t('device_inbox.filters_preview_appear' as never, { count: String(preview.appear) })} · ${this.host._t('device_inbox.filters_preview_hide' as never, { count: String(preview.hide) })} · ${this.host._t('device_inbox.filters_preview_lights' as never, { count: String(preview.lights) })}
` : nothing}
`; } public _renderDeviceInbox(): TemplateResult { const dialog = this.host._deviceInbox!; const rows = this._deviceInboxRows(); const counts = Object.fromEntries((['on_plan', 'available', 'hidden', 'readd'] as const) .map((category) => [category, rows.filter((row) => row.category === category).length])); const filtered = filterDeviceInbox( rows, dialog.tab, dialog.search, dialog.tab === 'on_plan' && dialog.onlyNew, ); const visible = filtered.slice(0, dialog.limit); const batch = inboxBatchView(dialog, filtered); // #618 B1: On plan / Hidden only const tabLabel = (tab: DeviceInboxCategory) => this.host._t(`device_inbox.tab_${tab}` as I18nKey); const emptyKey = `device_inbox.empty_${dialog.tab}` as I18nKey; const openVirtual = () => { this.host._deviceInboxReturn = { ...dialog }; this.host._deviceInbox = null; this._openMarkerDialog(); if (!this.host._markerDialog) this._closeMarkerDialog(); }; return html` (this.host._deviceInbox = null)}>
(this.host._deviceInbox = { ...dialog, search: (event.target as HTMLInputElement).value, limit: 100, selected: [], })} />
this._deviceInboxTabKey(event)}> ${(['on_plan', 'available', 'hidden', 'readd'] as DeviceInboxCategory[]).map((tab) => html` `)}
${dialog.tab === 'on_plan' ? html`` : nothing} ${dialog.tab === 'available' ? html`` : nothing} ${this._help('device_inbox.show_hidden.help')}
${renderInboxBatchPanel(this.host, this._inboxBatchDeps(), dialog, batch)} ${dialog.tab === 'available' ? this._renderDiscoveryFilters(dialog) : nothing}
${visible.length ? visible.map((row) => { const primary = row.category === 'on_plan' ? row.canFind ? html`` : html`` : row.category === 'hidden' ? html`` : html``; const status = row.status.kind === 'active' ? '' : this.host._t(`device_inbox.status_${row.status.kind}` as I18nKey); return html`
${renderInboxRowSelect(this.host, dialog, batch, row)}
${row.name} ${row.isNew ? html`${this.host._t('device_inbox.new')}` : nothing}
${[row.model, row.integration, row.spaceName, row.areaName].filter(Boolean).join(' · ')}
${row.reason === 'excluded_integration' && row.integration ? this.host._t('device_inbox.reason_excluded_integration', { integration: row.integration }) : this.host._t(`device_inbox.reason_${row.reason}` as I18nKey)} ${status ? html`${status}` : nothing}
${row.binding}
${primary} ${row.canEdit || row.canHide || row.category === 'available' || row.category === 'hidden' || this.host._bindingHasHaPage(row.binding) ? html`
${row.canEdit && !(row.category === 'on_plan' && !row.canFind) ? html`` : nothing} ${row.canHide ? html`` : nothing} ${row.category === 'available' ? html`` : nothing} ${row.category === 'hidden' ? html`` : nothing} ${this.host._bindingHasHaPage(row.binding) ? html`` : nothing}
` : nothing}
`; }) : html`
${this.host._t(emptyKey)}
`}
${filtered.length > visible.length ? html`` : nothing}
`; } public _markerValueBadgeFields( d: NonNullable, ): Pick | Record { return valueBadgeWriteFields({ touched: d.valueBadgeTouched, originalHas: d.originalHasValueBadge, original: d.originalValueBadge, enabled: d.valueBadgeEnabled, source: d.valueBadgeSource, position: d.valueBadgePosition, }); } public _markerValueSourceFields( d: NonNullable, ): Pick | Record { return valueSourceWriteFields({ touched: d.valueSourceTouched, originalHas: d.originalHasValueSource, original: d.originalValueSource, source: d.valueSource, }); } public _markerDraft(d: NonNullable): Marker | null { if (d.bindingMode === 'ha' && (!d.binding || d.binding === 'virtual')) return null; const roomRef = parseRoomRef(d.room); const id = markerIdForBinding(d.binding, d.devId, () => '__hp_device_preview__'); const previous = this.host._markers.find((marker) => marker.id === id || marker.id === d.devId); const controls = persistedExternalControls( d.binding, d.controls, this.host._bindingEntities(d.binding), ); const effectiveTapAction = this._effectiveStoredTapAction(d); const marker: Marker = { id, binding: d.binding, name: d.name.trim() || null, icon: d.icon || null, display: d.display !== 'badge' ? d.display : null, ripple_color: d.display === 'icon_ripple' && d.rippleColor ? d.rippleColor : null, ripple_size: d.display === 'icon_ripple' && d.rippleSize !== 1.5 ? d.rippleSize : null, size: d.size !== 1 ? d.size : null, angle: d.angle || null, ...this._markerTapActionFields(d), ...this._markerToggleEntityFields(d), tap_target: effectiveTapAction === 'run' ? d.tapTarget || null : null, tap_confirm: d.tapConfirm ? true : null, controls: controls.length ? controls : null, ...this._markerLightFields(d), ...this._markerValueBadgeFields(d), ...this._markerValueSourceFields(d), use_climate_temp: d.useClimateTemp ? true : null, glow_radius_cm: (() => { const value = strictNumber(d.glowRadius); if (value == null || value <= 0) return null; return Math.round(this.host._imperial ? value * 30.48 : value * 100); })(), model: d.model.trim() || null, link: d.link.trim() || null, description: d.description.trim() || null, pdfs: d.pdfs, hidden: d.hideFromPlan, vacuum: previous?.vacuum || null, }; const previousExplicit = !!previous && ( (typeof previous.area === 'string' && previous.area.length > 0) || (previous.area === null && !!previous.space && !!previous.room_id) ); if (d.binding === 'virtual' || d.roomTouched) { marker.space = roomRef?.space || (d.binding === 'virtual' ? this.host._space : null); marker.area = roomRef?.area || null; marker.room_id = roomRef?.roomId || null; } else if (previousExplicit) { marker.space = previous!.space; marker.area = previous!.area; marker.room_id = previous!.room_id; } return marker; } public _markerPreviewDevice(d: NonNullable): DevItem | null { const marker = this._markerDraft(d); if (!marker) return null; const key = `${this.host._haRegistry.revision}\n${contentFingerprint(this.host._markers)}\n${JSON.stringify(marker)}`; if (this.host._markerPreviewMemo?.key === key) return this.host._markerPreviewMemo.device; const device = deviceFromMarkerDraft({ hass: this.host.hass, registry: this.host._haRegistry, areaToSpace: Object.fromEntries( Object.entries(this.host._areaToSpace).map(([area, value]) => [area, value.space]), ), marker, siblingMarkers: this.host._markers, settings: this.host._settings, excluded: this.host._excluded, showAll: this.host._showAll, firstSpaceId: this.host._model[0]?.id || this.host._space, loc: (key) => this.host._t(key), iconRules: this.host._iconRules, }); this.host._markerPreviewMemo = { key, device }; return device; } public _markerPreviewDevices(preview: DevItem): readonly DevItem[] { const memo = this.host._markerPreviewDevicesMemo; if (memo?.base === this.host._devices && memo.preview === preview) return memo.devices; const devices = [...this.host._devices.filter((item) => item.id !== preview.id), preview]; this.host._markerPreviewDevicesMemo = { base: this.host._devices, preview, devices }; return devices; } public _toggleIntent( device: DevItem, devices: readonly DevItem[] = this.host._devices, ): ResolvedToggleIntent | null { // #357: the View card owns toggle resolution — a plain tap must work on a // cold tab that never loaded this runtime. Editor consumers delegate back. return this.host._toggleIntent(device, devices); } public _toggleIntentForDialog( d: NonNullable, ): ResolvedToggleIntent | null { const preview = this._markerPreviewDevice(d); if (!preview) return null; const devices = this._markerPreviewDevices(preview); return this._toggleIntent(preview, devices); } public _toggleStateText(entityId: string, fallback: string): string { return this.host._toggleStateText(entityId, fallback); } public _toggleConfirmationStateText(target: ResolvedToggleTarget): string { return this.host._toggleConfirmationStateText(target); } public _toggleConfirmationLines(intent: ResolvedToggleIntent): string[] { return this.host._toggleConfirmationLines(intent); } public _toggleHintLines(intent: ResolvedToggleIntent | null): string[] { if (!intent) return []; const effect = (value: ToggleNextEffect): string => this.host._t(`marker.toggle_effect_${value.replace('-', '_')}` as I18nKey); const skipReason = (value: ToggleSkipReason): string => this.host._t(`marker.toggle_skip_${value.replace('-', '_')}` as I18nKey); return formatToggleIntent(intent, { single: (target) => { if ('via' in target && target.via === 'virtual-light') { return this.host._t('marker.virtual_light_target', { name: target.name }); } const entityId = target.entityId || ('ref' in target ? target.ref : ''); const name = target.name || entityId; return this.host._t('marker.toggle_hint_single', { name, id: entityId }); }, group: (targets) => this.host._t('marker.toggle_hint_group', { count: targets.length, names: targets.map((target) => `${target.name} (${target.entityId})`).join(', '), }), currentNext: (target, next) => target.via === 'virtual-light' ? this.host._t('marker.virtual_light_current', { state: this.host._t(target.state === 'on' ? 'marker.virtual_light_state_on' : 'marker.virtual_light_state_off'), effect: effect(next), }) : this.host._t('marker.toggle_hint_current', { state: this._toggleStateText(target.entityId, target.state), effect: effect(next), }), groupCurrentNext: (targets, next) => this.host._t('marker.toggle_hint_group_current', { on: targets.filter((target) => target.state === 'on').length, count: targets.length, effect: effect(next), }), skipped: (targets) => this.host._t('marker.toggle_hint_skipped', { count: targets.length, targets: targets.map((target) => { const id = target.entityId || target.ref; return `${target.name || id} (${id}: ${skipReason(target.reason)})`; }).join(', '), }), none: (reason: ToggleNoneReason) => this.host._t(`marker.toggle_none_${reason.replaceAll('-', '_')}` as I18nKey), }); } public _effectiveStoredTapAction( d: NonNullable, primaryDomain?: string, ): string { return d.tapActionTouched ? d.tapAction : projectedTapAction( d.originalHasTapAction ? d.originalTapAction : null, primaryDomain, ); } public _effectiveMarkerTapAction( d: NonNullable, preview = this._markerPreviewDevice(d), ): string { return this._effectiveStoredTapAction(d, preview?.primary?.split('.')[0]); } public _announceToggleDraft( d: NonNullable, ): NonNullable { const preview = this._markerPreviewDevice(d); const next = !d.tapActionTouched ? { ...d, tapAction: this._effectiveMarkerTapAction(d, preview) } : d; const text = next.tapAction === 'toggle' ? this._toggleHintLines(this._toggleIntentForDialog(next)).join(' ') : ''; return { ...next, tapHintAnnouncement: text }; } public _valueBadgeForBinding( d: NonNullable, binding: string, ): Pick, 'valueBadgeEnabled' | 'valueBadgeSource' | 'valueBadgeTouched'> { const draft = { ...d, binding, valueBadgeEnabled: false, valueBadgeSource: null, valueBadgeTouched: true, }; const device = this._markerPreviewDevice(draft); if (!device) return { valueBadgeEnabled: false, valueBadgeSource: null, valueBadgeTouched: true }; const lightDevices = [...this.host._devices.filter((item) => item.id !== device.id), device]; const candidates = valueBadgeCandidates(this.host._planHass, device, lightDevices); const source = recommendedValueBadgeSource(this.host._planHass, device, candidates); return { valueBadgeEnabled: d.valueBadgeEnabled && !!source, valueBadgeSource: source, valueBadgeTouched: true, }; } public _markerSpatialSource(d: NonNullable) { const device = this._markerPreviewDevice(d); if (!device) return null; const preview = { ...device, hidden: false }; return selectSpatialGlowSource(resolvedLightSources(this.host._planHass, [ ...this.host._devices.filter((item) => item.id !== preview.id), preview, ], null, this.host._virtualLights).filter((source) => source.device.id === preview.id)); } public _markerAutoHasSpatialSource(d: NonNullable): boolean { const autoDraft = { ...d, lightRole: 'auto' as const, lightRoleTouched: true }; const device = this._markerPreviewDevice(autoDraft); return !!device && hasOwnSpatialSource(this.host._planHass, { ...device, hidden: false }); } public _setMarkerLightRole(role: 'auto' | 'always' | 'never'): void { const d = this.host._markerDialog; if (!d) return; this.host._markerDialog = { ...d, lightRole: role, lightRoleTouched: true }; } public _controlRefInfo(ref: string): { label: string; sub: string; icon: string; warning: boolean } { if (!ref.startsWith('marker:')) { return { label: this.host.hass.states[ref]?.attributes?.friendly_name || ref, sub: ref, icon: ref.startsWith('light.') ? 'mdi:lightbulb' : 'mdi:toggle-switch', warning: !this.host._planEntityAvailable(ref), }; } const id = ref.slice('marker:'.length); const marker = this.host._markers.find((item) => item.id === id); const device = this.host._devices.find((item) => item.id === id); if (!marker || marker.removed || marker.is_light !== true) { return { label: this.host._t('marker.control_missing_label'), sub: `${this.host._t('marker.control_broken')} (${ref})`, icon: 'mdi:alert-outline', warning: true, }; } const space = this.host._serverCfg?.spaces.find((item) => item.id === (device?.space || marker.space)); const stateful = !!device && ownControllableEntities(device).length > 0; return { label: marker.name || device?.name || id, sub: [space?.title || device?.space || marker.space, marker.room_id || device?.area, stateful ? '' : this.host._t('marker.control_passive')].filter(Boolean).join(' · '), icon: marker.icon || device?.icon || 'mdi:lightbulb-outline', warning: marker.hidden === true || device?.hidden === true, }; } public _valueBadgeCandidateLabel(candidate: ValueBadgeCandidate): string { const source = candidate.source; if (source.kind === 'derived_lqi') return this.host._t('marker.value_badge_lqi'); if (source.kind === 'derived_marker_state') { return this.host._t('marker.value_badge_marker_state', { name: candidate.label }); } if (source.kind === 'entity_state') { return this.host._t('marker.value_badge_state', { name: candidate.label }); } const entityName = this.host.hass.states[source.entity_id]?.attributes?.friendly_name || this.host._fullRegistryHass.entities[source.entity_id]?.name || source.entity_id; return this.host._t(`marker.value_badge_attr_${source.attribute}` as I18nKey, { name: entityName }); } public _controlCandidates(d: NonNullable): { value: string; label: string; sub: string; icon: string; }[] { const currentId = this._markerDraft(d)?.id || d.devId || ''; const plan: { value: string; label: string; sub: string; icon: string; search: string }[] = []; const coveredEntities = new Set(); for (const marker of this.host._markers) { if (marker.id === currentId || marker.removed || marker.hidden || marker.is_light !== true) continue; if (marker.binding !== 'virtual' && this.host._bindingStatus(marker.binding).kind !== 'active') continue; const device = this.host._devices.find((item) => item.id === marker.id); if (!device || device.hidden) continue; const info = this._controlRefInfo(`marker:${marker.id}`); for (const eid of ownControllableEntities(device)) coveredEntities.add(eid); plan.push({ value: `marker:${marker.id}`, label: info.label, sub: info.sub, icon: info.icon, search: `${info.label} ${info.sub} ${marker.id} ${ownControllableEntities(device).join(' ')}`.toLowerCase(), }); } const ha = Object.keys(this.host.hass.states || {}) .filter((eid) => isControllable(eid) && !coveredEntities.has(eid) && effectiveMarkerControls(d.binding, [eid], this.host._bindingEntities(d.binding)).length > 0 && this.host._planEntityAvailable(eid)) .map((eid) => { const info = this._controlRefInfo(eid); return { value: eid, label: info.label, sub: eid, icon: info.icon, search: `${info.label} ${eid}`.toLowerCase() }; }); const q = d.controlsFilter.trim().toLowerCase(); return [...plan, ...ha] .filter((item) => !d.controls.includes(item.value) && (!q || item.search.includes(q))) .slice(0, 12) .map(({ search: _search, ...item }) => item); } public _addControlRef(d: NonNullable, ref: string): void { if (ref.startsWith('marker:')) { const controllerId = this._markerDraft(d)?.id || d.devId || ''; if (!controllerId) { this.host._showToast(this.host._t('toast.marker_binding_required')); return; } const targetId = ref.slice('marker:'.length); const draft = this._markerDraft(d); const graph = draft ? [...this.host._markers.filter((marker) => marker.id !== controllerId), draft] : this.host._markers; if (markerControlWouldCycle(graph, controllerId, targetId)) { this.host._showToast(this.host._t('toast.marker_control_cycle')); return; } } this.host._markerDialog = this._announceToggleDraft({ ...d, controls: [...d.controls, ref], controlsFilter: '', }); } public _setMarkerGlowMode(mode: 'auto' | 'color' | 'fixed'): void { const d = this.host._markerDialog; if (!d) return; if (mode === 'auto') { this.host._markerDialog = { ...d, glowMode: mode, glowTouched: true }; return; } const source = this._markerSpatialSource(d); const values = resolveGlowValues( source ? this.host._planHass.states[source.eid] : undefined, null, this.host._fillColors.glow_light.c, ); const needColor = !d.glowColorDrafted; const needBrightness = mode === 'fixed' && !d.glowBrightnessDrafted; this.host._markerDialog = { ...d, glowMode: mode, glowColor: needColor ? values.c : d.glowColor, glowBrightness: needBrightness ? Math.max(1, Math.round(values.bri * 100)) : d.glowBrightness, glowColorDrafted: true, glowBrightnessDrafted: d.glowBrightnessDrafted || mode === 'fixed', glowTouched: true, }; } public _renderRadarSection(d: NonNullable, device: DevItem | null, placement: 'main' | 'additional' = 'main'): TemplateResult { return renderRadarSection({ dialog: d, device, registryHass: this.host._fullRegistryHass, planHass: this.host._planHass, hass: this.host.hass, spaceModelById: (id) => this.host._spaceModelById(id), currentSpace: () => this.host._spaceModel(), position: (item) => this.host._pos(item), updateDialog: (patch) => { if (this.host._markerDialog) this.host._markerDialog = { ...this.host._markerDialog, ...patch }; }, dialogDeviceId: () => this.host._markerDialog?.devId, t: (key, vars) => this.host._t(key, vars), help: (key) => this._help(key), boolInput: (value, change) => this._boolInput(value, change), toast: (message) => this.host._showToast(message), setup: this._radarSetup, configRev: this.host._cfgRev, }, placement); } public _renderMarkerDialog(): TemplateResult { return renderMarkerDialog.call(this); } public _renderSpaceDialog(): TemplateResult { return renderSpaceSettingsDialog.call(this); } public _renderMergeDialog(): TemplateResult { const d = this.host._mergeDialog!; const rooms = this.host._spaceModel()?.rooms || []; const opt = (id: string, key: 'a' | 'b') => { const r = rooms.find((x) => x.id === id); const area = r?.area ? this.host.hass.areas[r.area]?.name : null; return html``; }; return html` (this.host._mergeDialog = null)}>

${this.host._t('merge.hint')}

${opt(d.aId, 'a')} ${opt(d.bId, 'b')}
`; } public _renderRoomDialog(): TemplateResult { return renderRoomSettingsDialog.call(this); } }