From 16133394631eeecdcd536f373c723f09632e2106 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 30 Aug 2026 10:50:55 +0300 Subject: [PATCH] fix: hidden architecture no longer widens the fit: house frame (#384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- demo/smoke_space_card.mjs | 44 +++++++++++++++++++++++++++++++++++++++ docs/CANVAS.md | 7 +++++-- docs/CHANGELOG.md | 5 +++++ docs/CHANGELOG.ru.md | 5 +++++ docs/USER-GUIDE.md | 8 ++++--- docs/USER-GUIDE.ru.md | 8 ++++--- scripts/mutation-gate.mjs | 11 ++++++++++ src/space-render.ts | 12 +++++++---- 8 files changed, 88 insertions(+), 12 deletions(-) diff --git a/demo/smoke_space_card.mjs b/demo/smoke_space_card.mjs index bb07fa51..c1d06b35 100644 --- a/demo/smoke_space_card.mjs +++ b/demo/smoke_space_card.mjs @@ -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=') && diff --git a/docs/CANVAS.md b/docs/CANVAS.md index 4b0399a6..7efb4571 100644 --- a/docs/CANVAS.md +++ b/docs/CANVAS.md @@ -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 diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 49e2f9d2..36934fd9 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 diff --git a/docs/CHANGELOG.ru.md b/docs/CHANGELOG.ru.md index 39432600..fe5db322 100755 --- a/docs/CHANGELOG.ru.md +++ b/docs/CHANGELOG.ru.md @@ -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 заговорил по-немецки и по-французски, редакторы больше не входят в diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md index 43592333..ca5a3f29 100644 --- a/docs/USER-GUIDE.md +++ b/docs/USER-GUIDE.md @@ -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 diff --git a/docs/USER-GUIDE.ru.md b/docs/USER-GUIDE.ru.md index 40140d26..5f60124b 100644 --- a/docs/USER-GUIDE.ru.md +++ b/docs/USER-GUIDE.ru.md @@ -1546,9 +1546,11 @@ light_pools: true останется прежнее поведение: карточка покажет название пространства. Если важнее максимально крупно показать сам дом, задайте `fit: house`. -Карточка сохранит в кадре все комнаты, стены, перегородки, колонны и полный -размах символов проёмов, включая отдельное корректное крыло, но не будет -расширять кадр ради подложки, декора, подписей комнат и маркеров устройств. +Карточка сохранит в кадре все комнаты, видимые символы проёмов и — пока +`show_borders` оставляет их видимыми — стены, перегородки и колонны (#384: +скрытая архитектура кадр не расширяет), включая отдельное корректное крыло, +но не будет расширять кадр ради подложки, декора, подписей комнат и маркеров +устройств. Такие вспомогательные объекты остаются на сцене и могут обрезаться за границей дома. Для пустого пространства или одной подложки автоматически используется безопасное обычное кадрирование. В `fit: house` поля уже нулевые со всех сторон, diff --git a/scripts/mutation-gate.mjs b/scripts/mutation-gate.mjs index b78c745d..0bd0b139 100644 --- a/scripts/mutation-gate.mjs +++ b/scripts/mutation-gate.mjs @@ -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', diff --git a/src/space-render.ts b/src/space-render.ts index a5d4b69f..cc63c0f9 100644 --- a/src/space-render.ts +++ b/src/space-render.ts @@ -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]),