fix(2.5D): a device icon never moves because its state changed (#711)

Owner's decision in #694: icons must not change position with state.
Since #651 the rigid overlay layout sized a device by its value text and
badges, which change with HA state: a light toggling on changed its width
from 37.2 to 26.7 CSS px and re-laid out all 62 overlays of the dense
scene (~385 of 495 ms of stateUpdate; v1.77.0 had 208 ms).

- iso-scene-render.ts: the layout sees the state-free tile (icon at its
  configured size, no value text, badge or supplemental metrics). An
  HA-only change keeps the layout and refreshes only the visual extent
  that scene bounds read, without a collision search.
- iso-overlays.ts: the rigid-group search skips candidates that already
  lose to the fallback on room, then wall violations (lexicographic
  bound). The result is unchanged — identical placement hash on the dense
  scene — at about 35 % less work.
- docs/ISOMETRIC.md, changelogs; test #711; mutant
  iso-device-layout-follows-state-again (written, not run — #709).

Local isometric-stage3-dense-v1, 3 samples: stateUpdate 522 → 89 ms
(v1.77.0: 208), modelReady 3665 → 3129, switchCycle 5544 → 4700.

Issue: #711
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-09-30 10:51:53 +03:00
parent 9caa3c2316
commit a372b354c6
7 changed files with 162 additions and 17 deletions
+4
View File
@@ -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)).
+4
View File
@@ -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)).
+6
View File
@@ -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
+13
View File
@@ -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 '
+35 -11
View File
@@ -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;
+48 -6
View File
@@ -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<string, IsoOverlayPlacement>;
rooms: ReadonlyMap<RoomCfg, IsoOverlayPlacement>;
@@ -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,
+52
View File
@@ -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({