diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5ef0f8e6..9ebd338e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- In 2.5D View, device icons no longer shift when a device changes state (for + example when a light turns on and shows its brightness); a Home Assistant + update in a dense plan also no longer re-lays out every icon + ([#711](https://github.com/Matysh/houseplan-card/issues/711)). - In View, the mouse cursor over a stair link is now a pointer instead of the move cursor; stairs without a valid target keep the plan's ordinary cursor ([#693](https://github.com/Matysh/houseplan-card/issues/693)). diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 33ace2df..01683c17 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -8,6 +8,10 @@ ## Не выпущено +- В объёмном 2.5D-виде значки устройств больше не сдвигаются, когда меняется + состояние устройства (например, лампа включилась и показала яркость); + обновление Home Assistant в плотном плане больше не переразмещает все значки + ([#711](https://github.com/Matysh/houseplan-card/issues/711)). - В режиме View над лестницей-ссылкой теперь курсор-указатель вместо курсора перемещения; у лестниц без действующей цели — обычный курсор плана ([#693](https://github.com/Matysh/houseplan-card/issues/693)). diff --git a/docs/ISOMETRIC.md b/docs/ISOMETRIC.md index f919b6b2..390de7bb 100644 --- a/docs/ISOMETRIC.md +++ b/docs/ISOMETRIC.md @@ -237,6 +237,12 @@ the affine projection of the Flat layout rather than a per-marker fan toward a room safe point. Room labels never enter a cluster and stay below interactive roots. +A device never moves because its Home Assistant state changed (owner's +decision, #711). The layout sees the state-free tile of a device — its icon at +its configured size, without value text, value badges or supplemental metrics, +which change with state. Those still count in the fit bounds, and an HA-only +change refreshes that visual extent without a new collision search. + `src/iso-overlays.ts` is the pure placement boundary. A device accepts its explicit room only when that room strictly contains its floor anchor, otherwise the smallest strictly containing room (stable id tie-break); room labels use diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index e24e0128..9a608e37 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -6201,6 +6201,19 @@ const MUTANT_DEFINITIONS = [ replace: ' const names = [...(selection.direct || []), ...(selection.registered || [])]', }], }, + // #711: значок не двигается от состояния устройства. + { + id: 'iso-device-layout-follows-state-again', + guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' + + '&& node --test --test-name-pattern="#711" test/iso-scene-render.test.mjs', + because: '#711 (owner, 2026-09-30): a device never moves because its HA state changed; a layout ' + + 'footprint that includes state text re-lays out the whole cluster on every toggle', + patches: [{ + file: 'src/iso-scene-render.ts', + find: ' kind: \'device\', core, presentation: stateFreePresentation(presentation),', + replace: ' kind: \'device\', core, presentation,', + }], + }, { id: 'wall-isolated-extra-discarded', guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs ' diff --git a/src/iso-overlays.ts b/src/iso-overlays.ts index 1eb5c8c2..726bd7fa 100644 --- a/src/iso-overlays.ts +++ b/src/iso-overlays.ts @@ -1874,7 +1874,15 @@ export function resolveIsoOverlayRigidGroups( for (const candidate of sortedBoundaryCandidates(candidates)) addOffset(candidate.offset); offsets.sort(rigidOffsetOrder); - const evaluate = (offset: ScenePoint): RigidCandidate => { + // #711: offsets run in rigidOffsetOrder, and the fallback is the + // lexicographic minimum by (room, wall, overlap, order). A candidate that + // already has more room violations than the current fallback — or as many + // and more wall violations — can neither be chosen nor replace it, so its + // wall and overlap checks are skipped. The result is unchanged; in a dense + // scene most of the ~14 000 degraded-group offsets stop after the room pass. + const evaluate = ( + offset: ScenePoint, bound: RigidCandidate | null = null, + ): RigidCandidate | null => { const distance = Math.hypot(offset[0], offset[1]); const offsetScene: ScenePoint = [ offset[0] * unitsPerPixel, offset[1] * unitsPerPixel, @@ -1882,25 +1890,29 @@ export function resolveIsoOverlayRigidGroups( let roomViolations = 0, wallViolations = wallsValid ? 0 : group.items.length; let totalOverlap = 0; const details: RigidCandidateDetail[] = []; + const roomInvalidByItem: boolean[] = []; for (const item of group.items) { const base = item.placement; - const visualScene: ScenePoint = [ - base.raisedScene[0] + offsetScene[0], - base.raisedScene[1] + offsetScene[1], - ]; const ownerRoom = base.owner ? rooms.get(base.owner.id) || null : null; let roomInvalid = false; if (distance > EPS) { if (!ownerRoom) roomInvalid = true; else { - const plan = raisedSceneToPlan(visualScene, visualOffset, camera); + const plan = raisedSceneToPlan([ + base.raisedScene[0] + offsetScene[0], base.raisedScene[1] + offsetScene[1], + ], visualOffset, camera); roomInvalid = !pointStrictlyInValidatedRoom(plan, ownerRoom) || pointStrictlyInValidatedRoom(base.floorAnchor, ownerRoom) && !segmentBetweenStrictRoomPoints(base.floorAnchor, plan, ownerRoom); } } + roomInvalidByItem.push(roomInvalid); if (roomInvalid) roomViolations += 1; - + } + if (bound && roomViolations > bound.roomViolations) return null; + const wallNearByItem: boolean[] = []; + for (const item of group.items) { + const base = item.placement; const baseFootprint = base.footprint.map((point) => [ point[0] - base.nudgeScene[0], point[1] - base.nudgeScene[1], ] as ScenePoint); @@ -1911,8 +1923,19 @@ export function resolveIsoOverlayRigidGroups( const wallNear = wallsValid && !!footprintBounds && wallRows.some(({ wall, bounds }) => !!bounds && boundsNear(footprintBounds, bounds, gapUnits) && footprintNearSilhouette(footprint, footprintBounds, wall, gapUnits, true)); + wallNearByItem.push(wallNear); if (wallNear) wallViolations += 1; - + } + if (bound && roomViolations === bound.roomViolations + && wallViolations > bound.wallViolations) return null; + group.items.forEach((item, itemIndex) => { + const base = item.placement; + const visualScene: ScenePoint = [ + base.raisedScene[0] + offsetScene[0], + base.raisedScene[1] + offsetScene[1], + ]; + const roomInvalid = roomInvalidByItem[itemIndex]; + const wallNear = wallNearByItem[itemIndex]; const bounds = overlayRootBounds(item, visualScene); const conflicts: number[] = []; for (let index = 0; index < accepted.length; index++) { @@ -1923,7 +1946,7 @@ export function resolveIsoOverlayRigidGroups( } } details.push({ item, bounds, roomInvalid, wallNear, conflicts }); - } + }); return { offset, distance, roomViolations, wallViolations, overlapPenalty: totalOverlap, details, @@ -1933,7 +1956,8 @@ export function resolveIsoOverlayRigidGroups( let chosen: RigidCandidate | null = null; let fallback: RigidCandidate | null = null; for (const offset of offsets) { - const candidate = evaluate(offset); + const candidate = evaluate(offset, fallback); + if (!candidate) continue; if (!fallback || rigidFallbackOrder(candidate, fallback) < 0) fallback = candidate; if (!candidate.roomViolations && !candidate.wallViolations && candidate.overlapPenalty <= EPS) { @@ -1941,7 +1965,7 @@ export function resolveIsoOverlayRigidGroups( break; } } - chosen ||= fallback || evaluate([0, 0]); + chosen ||= fallback || evaluate([0, 0])!; const degraded = !!chosen.roomViolations || !!chosen.wallViolations || chosen.overlapPenalty > EPS; diff --git a/src/iso-scene-render.ts b/src/iso-scene-render.ts index c9b0983c..ff1ff870 100644 --- a/src/iso-scene-render.ts +++ b/src/iso-scene-render.ts @@ -136,8 +136,25 @@ export type IsoOverlayRenderEntry = { groundRadius: number; /** Screen-facing HTML footprint around visualScene, in scene units. */ screenHalfSize: PlanPoint; + /** + * #711: the footprint the layout sees. For a device it is the state-free tile — + * value text and badges change with HA state, and the owner's rule is that a + * device never moves because its state changed. Absent means screenHalfSize. + */ + layoutHalfSize?: PlanPoint; }; +const layoutHalfSizeOf = (entry: IsoOverlayRenderEntry): PlanPoint => + entry.layoutHalfSize ?? entry.screenHalfSize; + +/** #711: the tile of a device without anything its HA state writes on it. */ +const stateFreePresentation = ( + presentation: ResolvedDevicePresentation, +): ResolvedDevicePresentation => ({ + ...presentation, + valueText: null, valueFullText: null, valueBadge: null, tempText: null, humText: null, +}); + export type IsoOverlayRenderScene = { devices: ReadonlyMap; rooms: ReadonlyMap; @@ -898,12 +915,15 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver // #649: the 2.5D tile is ICON_SCALE larger; layout and collision see that size. const core = baseDeviceUnits * presentation.scale * ISO_ICON_SCALE; const halfSize = isoRaisedOverlayHalfSize({ kind: 'device', core, presentation }); + const layoutHalfSize = isoRaisedOverlayHalfSize({ + kind: 'device', core, presentation: stateFreePresentation(presentation), + }); const preferredRoomId = device.marker?.room_id || roomRows.find((row) => !!device.area && row.room.area === device.area)?.overlayRoom.id || null; const placement = place( 'device', device.id, [pos.x, pos.y], - halfSize, + layoutHalfSize, preferredRoomId, ); devices.set(device.id, placement); @@ -913,6 +933,7 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver placement, groundRadius: Math.max(core * 0.32, 2), screenHalfSize: halfSize, + layoutHalfSize, }); } @@ -975,10 +996,31 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver return [entry.kind, entry.id, placement.floorAnchor[0], placement.floorAnchor[1], placement.raisedScene[0], placement.raisedScene[1], placement.nudgeScene[0], placement.nudgeScene[1], placement.owner?.id || '', placement.status, placement.reason || '', - entry.screenHalfSize[0], entry.screenHalfSize[1], unitsPerPixel].join('|'); + layoutHalfSizeOf(entry)[0], layoutHalfSizeOf(entry)[1], unitsPerPixel].join('|'); }).sort().join('\u0001'); if (previous?.collisionSignature === collisionSignature - && samePlacementMap(previous.rooms, roomPlacements)) return previous; + && samePlacementMap(previous.rooms, roomPlacements)) { + // #711: an HA-only change keeps the layout; only the visual extent that + // scene bounds read is refreshed, without a collision search. + const visual = new Map(entries.map((entry) => [ + `${entry.kind}\u0000${entry.id}`, entry.screenHalfSize, + ])); + const changed = previous.entries.some((entry) => { + const next = visual.get(`${entry.kind}\u0000${entry.id}`); + return !!next && (next[0] !== entry.screenHalfSize[0] || next[1] !== entry.screenHalfSize[1]); + }); + if (!changed) return previous; + const refreshed: IsoOverlayRenderScene = { + ...previous, + entries: Object.freeze(previous.entries.map((entry) => { + const next = visual.get(`${entry.kind}\u0000${entry.id}`); + return next && (next[0] !== entry.screenHalfSize[0] || next[1] !== entry.screenHalfSize[1]) + ? { ...entry, screenHalfSize: next } : entry; + })), + }; + renderScenes.set(mode, refreshed); + return refreshed; + } const previousEntries = new Map(previous?.entries.map((entry) => [ `${entry.kind}\u0000${entry.id}`, entry, @@ -989,8 +1031,8 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver if (entry.kind === 'room-label') return []; const before = previousEntries.get(`${entry.kind}\u0000${entry.id}`); const sameShape = !!before - && before.screenHalfSize[0] === entry.screenHalfSize[0] - && before.screenHalfSize[1] === entry.screenHalfSize[1] + && layoutHalfSizeOf(before)[0] === layoutHalfSizeOf(entry)[0] + && layoutHalfSizeOf(before)[1] === layoutHalfSizeOf(entry)[1] && before.placement.floorAnchor[0] === entry.placement.floorAnchor[0] && before.placement.floorAnchor[1] === entry.placement.floorAnchor[1] && (before.placement.owner?.id || '') === (entry.placement.owner?.id || ''); @@ -998,7 +1040,7 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver id: entry.id, kind: entry.kind, placement: entry.placement, - screenHalfSize: entry.screenHalfSize, + screenHalfSize: layoutHalfSizeOf(entry), ...(sameShape ? { nudgeHintCss: [ before!.placement.nudgeScene[0] / unitsPerPixel, before!.placement.nudgeScene[1] / unitsPerPixel, diff --git a/test/iso-scene-render.test.mjs b/test/iso-scene-render.test.mjs index 5992891e..22071425 100644 --- a/test/iso-scene-render.test.mjs +++ b/test/iso-scene-render.test.mjs @@ -527,6 +527,58 @@ test('render scene keeps a close device cluster rigid without moving labels and 'fit probing deliberately skips live group displacement'); }); +test('#711 состояние устройства не двигает значки: раскладка не пересчитывается, границы видят бейдж', () => { + const owner = { ...room('owner', 0, 0, 400, 400), name: '', settings: {} }; + const space = { + id: 'floor-710', title: 'Floor', cellCm: 5, vb: [0, 0, 400, 400], bg: null, + rooms: [owner], wall_segments: [], room_drafts: [], partitions: [], wall_columns: [], + }; + const wallSilhouettes = [{ outer: buildIsoFootprintPolygon([150, 200], [154, 260], ISO_WALL_HEIGHT) }]; + const devices = ['lamp', 'plug', 'sensor'].map((id, index) => ({ + id, space: 'floor-710', marker: { room_id: 'owner', x: 160 + index * 4, y: 200 }, + })); + let lampOn = false; + const presentationOf = (device) => ({ + scale: 1, valueText: null, valueFullText: '', tempText: null, humText: null, lqiText: null, + valueBadge: device.id === 'lamp' && lampOn + ? { configured: true, enabled: true, text: '100 %', fullText: '100 %', position: 'right', tone: 'default' } + : null, + pulse: { animated: false, diameterScale: 1 }, + }); + const input = { + space, devices, openings: [], + view: { x: 0, y: 0, w: 400, h: 400 }, + display: { showNames: false, cardFontScale: 1 }, + layers: { structural: true, shadows: true }, + wallSilhouettes, + iconPct: 3.4, deviceBasePct: 3.4, showLqi: false, cellCm: 5, + kioskIconScale: 1, kioskFontScale: 1, + stageSize: { width: 200, height: 200 }, + positionOf: (device) => ({ x: device.marker.x, y: device.marker.y }), + presentationOf, + labelPositionOf: () => ({ x: 0, y: 0 }), + labelScaleOf: () => 1, + openingEntityAvailable: () => false, + openingWallIndex: () => ({ adjacencyEps: 0.1, edges: [] }), + }; + const off = buildIsoOverlayRenderScene(input); + lampOn = true; + const on = buildIsoOverlayRenderScene(input); + for (const id of ['lamp', 'plug', 'sensor']) { + assert.strictEqual(on.devices.get(id), off.devices.get(id), `${id}: включение лампы не пересчитывает раскладку`); + } + const lampOff = off.entries.find((entry) => entry.id === 'lamp'); + const lampOnEntry = on.entries.find((entry) => entry.id === 'lamp'); + assert.ok(lampOnEntry.screenHalfSize[0] > lampOff.screenHalfSize[0], 'видимая ширина с бейджем больше'); + assert.deepEqual(lampOnEntry.layoutHalfSize, lampOff.layoutHalfSize, 'раскладка видит плитку без состояния'); + assert.ok(isoOverlaySceneBounds(on).w >= isoOverlaySceneBounds(off).w, 'границы сцены учитывают бейдж'); + lampOn = false; + const offAgain = buildIsoOverlayRenderScene(input); + for (const id of ['lamp', 'plug', 'sensor']) { + assert.deepEqual(offAgain.devices.get(id).visualScene, off.devices.get(id).visualScene, `${id}: выключение возвращает то же место`); + } +}); + test('visible wall side quads participate in overlay collision', () => { const walls = [[[[45, 20], [55, 20], [55, 80], [45, 80]]]]; const scene = resolveIsoScene({