mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 04:09:17 +00:00
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:
@@ -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)).
|
||||
|
||||
@@ -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)).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user