mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 06:59:46 +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:
+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,
|
||||
|
||||
Reference in New Issue
Block a user