fix: hidden architecture no longer widens the fit: house frame (#384)

Wall bodies, extras and zero walls vote in the tight frame only while
show_borders renders them — mirroring the hideOpenings guard for opening
symbols. needsCanonicalWallGeometry returns to its pre-#373 form: the
union is no longer forced for extras-only plans just for the frame.

User-Visible: yes
Issue: #384
This commit is contained in:
Codex
2026-08-30 10:53:14 +03:00
parent 3bb732c803
commit 1613339463
8 changed files with 88 additions and 12 deletions
+44
View File
@@ -74,6 +74,45 @@ const res = await page.evaluate(async () => {
const compactFrame = frameOf(compact);
const compactNoButtonFrame = frameOf(compactNoButton);
const nullTitleFrame = frameOf(nullTitle);
// #384: hidden architecture must not vote in fit: house. Twin cards over a
// config clone with a far-away wall column; only show_borders differs.
const hiddenTwinFrames = await (async () => {
// Twin cards share the page config store; patch each twin's reactive
// snapshot directly — a deep clone with a far-away wall column, differing
// only in show_borders.
const twin = (borders) => {
const el = document.createElement('houseplan-space-card');
el.setConfig({ type: 'custom:houseplan-space-card', space: spaceId, fit: 'house' });
el.hass = card.hass;
host.appendChild(el);
return el;
};
const hidden = twin(false);
const shown = twin(true);
await waitForStage(hidden); await waitForStage(shown);
const patchSnap = async (el, borders) => {
// clone ONLY the config: virtualLights carries a Set that JSON kills
const snap = { ...el._snap, config: JSON.parse(JSON.stringify(el._snap.config)) };
const sp = snap.config.spaces.find((x) => x.id === spaceId);
const pts = (sp.rooms || []).flatMap((room) => (room.poly || [])
.map((pt) => Array.isArray(pt) ? pt : [pt.x, pt.y]));
const maxX = Math.max(...pts.map((pt) => pt[0]));
const midY = pts.reduce((acc, pt) => acc + pt[1], 0) / pts.length;
sp.wall_columns = [...(sp.wall_columns || []), {
id: 'far-column-384', shape: 'rect', center: [maxX + 300, midY], cm: 40, angle: 0,
}];
sp.settings = { ...(sp.settings || {}), show_borders: borders };
el._snap = snap;
await el.updateComplete;
await new Promise((resolve) => requestAnimationFrame(resolve));
await el.updateComplete;
};
await patchSnap(hidden, false);
await patchSnap(shown, true);
const frames = { hidden: frameOf(hidden), shown: frameOf(shown) };
hidden.remove(); shown.remove();
return frames;
})();
const nullTitleHasTitle = !!nullTitle.renderRoot.querySelector('.hp-static-title');
const compactCardBox = compact.renderRoot.querySelector('ha-card')?.getBoundingClientRect();
const compactStageBox = compact.renderRoot.querySelector('.hp-static-stage')?.getBoundingClientRect();
@@ -154,6 +193,7 @@ const res = await page.evaluate(async () => {
})(),
nullTitleFrame,
nullTitleHasTitle,
hiddenTwinFrames,
namedFrame,
compactFrame,
compactNoButtonFrame,
@@ -194,6 +234,10 @@ const ok =
Math.abs((res.compactFrame.y + res.compactFrame.h) - (res.frame.y + res.frame.h)) < 1e-6 &&
JSON.stringify(res.compactFrame) === JSON.stringify(res.compactNoButtonFrame) &&
!res.nullTitleHasTitle &&
res.hiddenTwinFrames && res.hiddenTwinFrames.hidden && res.hiddenTwinFrames.shown &&
// #384 AC1: скрытая архитектура не голосует — кадр без колонны строго уже
(res.hiddenTwinFrames.hidden.x + res.hiddenTwinFrames.hidden.w)
< (res.hiddenTwinFrames.shown.x + res.hiddenTwinFrames.shown.w) - 100 &&
JSON.stringify(res.nullTitleFrame) === JSON.stringify(res.compactFrame) &&
res.hasButton &&
typeof res.deepLink === 'string' && res.deepLink.includes('#space=') &&
+5 -2
View File
@@ -229,8 +229,11 @@ The full `houseplan-card` and the default `houseplan-space-card` continue to
use the canonical content frame above. A static card may explicitly select
`fit: house`. That second frame is structural and static-card-only:
* every sane room, positive/zero wall, independent wall or saved draft,
column and complete door/window/gate symbol envelope participates;
* every sane room and (when visible) complete door/window/gate symbol
envelope participates; positive/zero walls, independent walls, drafts and
columns participate only while `show_borders` renders them (#384 — hidden
architecture must not widen the tight frame, mirroring the `hide_openings`
guard for symbols);
* every structural item is kept — the outlier vote cannot discard a detached
but valid wing;
* there is no intentional outer padding, but analytic bounds include the
+5
View File
@@ -7,6 +7,11 @@
long-press House Plan details and right-click HA more-info remain available
([#381](https://github.com/Matysh/houseplan-card/issues/381)).
- `fit: house` in `houseplan-space-card` no longer lets hidden architecture
widen the tight frame: with `show_borders` off, walls, partitions and
columns stay out of the vote, matching the existing rule for hidden opening
symbols ([#384](https://github.com/Matysh/houseplan-card/issues/384)).
## v1.69.0 — 2026-08-30
- House Plan now speaks German and French, keeps the editors outside the
+5
View File
@@ -14,6 +14,11 @@
more-info по правому клику сохраняются
([#381](https://github.com/Matysh/houseplan-card/issues/381)).
- `fit: house` в `houseplan-space-card` больше не позволяет скрытой архитектуре
расширять плотный кадр: при выключенном `show_borders` стены, перегородки и
колонны не голосуют — как и скрытые символы проёмов
([#384](https://github.com/Matysh/houseplan-card/issues/384)).
## v1.69.0 — 2026-08-30
- House Plan заговорил по-немецки и по-французски, редакторы больше не входят в
+5 -3
View File
@@ -798,9 +798,11 @@ light_pools: true
- `content` (default) keeps the existing frame around all visible content and
its 5% breathing room;
- `house` removes that intentional padding and fits every room, wall,
partition, column and opening symbol. A detached but valid wing stays in the
frame. Backdrop, decor, room labels and device markers do not widen it.
- `house` removes that intentional padding and fits every room, every visible
opening symbol, and — when `show_borders` keeps them visible — every wall,
partition and column (#384: hidden architecture does not widen the frame).
A detached but valid wing stays in the frame. Backdrop, decor, room labels
and device markers do not widen it.
Choose `house` when the building should occupy as much of the card as possible.
Auxiliary objects remain rendered, but an object outside the structural bounds
+5 -3
View File
@@ -1546,9 +1546,11 @@ light_pools: true
останется прежнее поведение: карточка покажет название пространства.
Если важнее максимально крупно показать сам дом, задайте `fit: house`.
Карточка сохранит в кадре все комнаты, стены, перегородки, колонны и полный
размах символов проёмов, включая отдельное корректное крыло, но не будет
расширять кадр ради подложки, декора, подписей комнат и маркеров устройств.
Карточка сохранит в кадре все комнаты, видимые символы проёмов и — пока
`show_borders` оставляет их видимыми — стены, перегородки и колонны (#384:
скрытая архитектура кадр не расширяет), включая отдельное корректное крыло,
но не будет расширять кадр ради подложки, декора, подписей комнат и маркеров
устройств.
Такие вспомогательные объекты остаются на сцене и могут обрезаться за границей
дома. Для пустого пространства или одной подложки автоматически используется
безопасное обычное кадрирование. В `fit: house` поля уже нулевые со всех сторон,
+11
View File
@@ -735,6 +735,17 @@ const MUTANT_DEFINITIONS = [
replace: " this._persistDecorStyle();\n }, 0);",
}],
},
{
id: 'fit-house-hidden-walls-vote',
guard: 'node demo/smoke_space_card.mjs',
because: 'hidden architecture silently widening the tight frame is exactly the #384 bug: '
+ 'only the twin-frames smoke sees the rendered viewBox difference',
patches: [{
file: 'src/space-render.ts',
find: " if (disp.showBorders) for (const body of extras) {",
replace: " for (const body of extras) {",
}],
},
{
id: 'space-card-null-title-compact-narrowed',
guard: 'node --test test/space-card-audit-lows.test.mjs',
+8 -4
View File
@@ -385,7 +385,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
}));
const fit = resolveSpaceCardFit(o.fit);
const needsCanonicalWallGeometry = !!(
walls.length || (extras.length && (disp.showBorders || fit === 'house'))
walls.length || (extras.length && disp.showBorders)
);
const wallGeometryFingerprint = needsCanonicalWallGeometry
? contentFingerprint(staticPassages.length
@@ -429,17 +429,21 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
const item = roomItem(room);
if (item) structure.push(expandItem(item, roomStrokeHalf));
}
for (const component of canonicalWallGeometry?.components || []) {
// #384: hidden architecture must not vote in the tight frame. Wall
// bodies, extras and zero walls all render only under show_borders
// (see the wallUnion/zero-wall gates below) — the frame follows the
// same visibility, mirroring the hideOpenings guard for symbols.
if (disp.showBorders) for (const component of canonicalWallGeometry?.components || []) {
const item = itemOfGeometry(component.geom);
if (item) structure.push(expandItem(item, wallStrokeHalf));
}
// Keep degraded/isolated bodies reachable even if a boolean union elected
// to render them as separate components or failed one merge.
for (const body of extras) {
if (disp.showBorders) for (const body of extras) {
const item = itemOfGeometry(body);
if (item) structure.push(expandItem(item, wallStrokeHalf));
}
for (const line of zeroWalls.lines) {
if (disp.showBorders) for (const line of zeroWalls.lines) {
const item: ContentItem = {
minX: Math.min(line[0], line[2]), minY: Math.min(line[1], line[3]),
maxX: Math.max(line[0], line[2]), maxY: Math.max(line[1], line[3]),