fix: remove isometric overlay plates

Keep the raised overlay footprint as calculation-only geometry for collision, nudge and fit while removing its painted SVG surface and texture. Update Stage 3 contracts, smokes, golden harness rules and synchronized bundles.

Issue: #471
User-Visible: no
This commit is contained in:
Matysh
2026-09-06 11:23:51 +00:00
committed by claude[bot]
parent 73a2fc66d3
commit be5eeb41c3
48 changed files with 561 additions and 651 deletions
+35 -33
View File
@@ -55,8 +55,8 @@ export interface IsoOverlayPlacementInput extends IsoOverlayOwnerInput {
wallSilhouettes: readonly IsoWallSilhouette[];
/** Internal fast path for silhouettes produced by the cached structural scene. */
wallGeometryValidated?: boolean;
/** Half-size of the floor-parallel plate in plan units. */
plateHalfSize: PlanPoint;
/** Half-size of the invisible floor-parallel safety footprint in plan units. */
footprintHalfSize: PlanPoint;
wallHeight?: number;
visualOffset?: number;
/** Uniform viewBox units represented by one CSS pixel at the current viewport. */
@@ -91,7 +91,8 @@ export interface IsoOverlayPlacement {
floorScene: ScenePoint;
raisedScene: ScenePoint;
visualScene: ScenePoint;
plate: readonly ScenePoint[];
/** Invisible collision/fit footprint; never render it as a surface. */
footprint: readonly ScenePoint[];
nudgeScene: ScenePoint;
nudgeCss: ScenePoint;
nudgeDistanceCss: number;
@@ -266,7 +267,7 @@ export function isoOverlayPlane(kind: IsoOverlayKind, showBorders: boolean): Iso
? 'raised' : 'floor';
}
export function buildIsoPlatePolygon(
export function buildIsoFootprintPolygon(
center: PlanPoint,
halfSize: PlanPoint,
zUnits: number,
@@ -275,7 +276,7 @@ export function buildIsoPlatePolygon(
): readonly ScenePoint[] {
if (!finitePoint(center) || !finitePoint(halfSize) || halfSize[0] < 0 || halfSize[1] < 0
|| !Number.isFinite(zUnits) || !finitePoint(sceneOffset))
throw new Error('invalid isometric overlay plate');
throw new Error('invalid isometric overlay footprint');
return ([
[center[0] - halfSize[0], center[1] - halfSize[1]],
[center[0] + halfSize[0], center[1] - halfSize[1]],
@@ -353,25 +354,25 @@ function segmentBoundsNear(
&& Math.max(a[1], b[1]) >= bounds[1] - gap;
}
function plateNearSilhouette(
plate: readonly ScenePoint[], plateBounds: Bounds,
function footprintNearSilhouette(
footprint: readonly ScenePoint[], footprintBounds: Bounds,
silhouette: IsoWallSilhouette, gap: number, cacheBounds: boolean,
): boolean {
const wallBounds = silhouetteBounds(silhouette, cacheBounds);
if (!wallBounds || !boundsNear(plateBounds, wallBounds, gap)) return false;
if (plate.some((point) => pointInSilhouette(point, silhouette))) return true;
if (!wallBounds || !boundsNear(footprintBounds, wallBounds, gap)) return false;
if (footprint.some((point) => pointInSilhouette(point, silhouette))) return true;
if (silhouette.outer.some((point) =>
point[0] >= plateBounds[0] && point[0] <= plateBounds[2]
&& point[1] >= plateBounds[1] && point[1] <= plateBounds[3]
&& pointInRing(point, plate))) return true;
point[0] >= footprintBounds[0] && point[0] <= footprintBounds[2]
&& point[1] >= footprintBounds[1] && point[1] <= footprintBounds[3]
&& pointInRing(point, footprint))) return true;
for (const wallRing of [silhouette.outer, ...(silhouette.holes || [])]) {
for (let plateIndex = 0; plateIndex < plate.length; plateIndex++) {
const plateNext = (plateIndex + 1) % plate.length;
for (let footprintIndex = 0; footprintIndex < footprint.length; footprintIndex++) {
const footprintNext = (footprintIndex + 1) % footprint.length;
for (let wallIndex = 0; wallIndex < wallRing.length; wallIndex++) {
const wallNext = (wallIndex + 1) % wallRing.length;
if (!segmentBoundsNear(plateBounds, wallRing[wallIndex], wallRing[wallNext], gap))
if (!segmentBoundsNear(footprintBounds, wallRing[wallIndex], wallRing[wallNext], gap))
continue;
if (segmentDistance(plate[plateIndex], plate[plateNext],
if (segmentDistance(footprint[footprintIndex], footprint[footprintNext],
wallRing[wallIndex], wallRing[wallNext]) <= gap + EPS) return true;
}
}
@@ -414,8 +415,8 @@ export function resolveIsoOverlayPlacement(input: IsoOverlayPlacementInput): Iso
const unitsPerPixel = input.sceneUnitsPerCssPixel ?? 1;
const safetyGap = input.safetyGapCssPx ?? ISO_OVERLAY_SAFETY_GAP_CSS_PX;
const maxNudge = input.maxNudgeCssPx ?? ISO_OVERLAY_MAX_NUDGE_CSS_PX;
if (!finitePoint(input.floorAnchor) || !finitePoint(input.plateHalfSize)
|| input.plateHalfSize[0] < 0 || input.plateHalfSize[1] < 0
if (!finitePoint(input.floorAnchor) || !finitePoint(input.footprintHalfSize)
|| input.footprintHalfSize[0] < 0 || input.footprintHalfSize[1] < 0
|| !Number.isFinite(wallHeight) || wallHeight < 0
|| !Number.isFinite(visualOffset) || visualOffset < 0
|| !Number.isFinite(unitsPerPixel) || unitsPerPixel <= 0
@@ -430,7 +431,7 @@ export function resolveIsoOverlayPlacement(input: IsoOverlayPlacementInput): Iso
const tether = tetherGeometry(floorScene, floorScene, false);
return {
plane, owner: null, floorAnchor, floorScene, raisedScene: floorScene,
visualScene: floorScene, plate: [], nudgeScene: [0, 0], nudgeCss: [0, 0],
visualScene: floorScene, footprint: [], nudgeScene: [0, 0], nudgeCss: [0, 0],
nudgeDistanceCss: 0, nudged: false, nearWallBefore: false, nearWallAfter: false,
cleared: true, capped: false,
grounding: { center: floorScene, visible: false }, tether,
@@ -445,14 +446,15 @@ export function resolveIsoOverlayPlacement(input: IsoOverlayPlacementInput): Iso
: resolveIsoOverlayOwner(input);
const geometryValid = input.wallGeometryValidated ?? input.wallSilhouettes.every(validSilhouette);
const gapUnits = safetyGap * unitsPerPixel;
const basePlate = buildIsoPlatePolygon(floorAnchor, input.plateHalfSize,
const baseFootprint = buildIsoFootprintPolygon(floorAnchor, input.footprintHalfSize,
raisedHeight, camera);
const isNear = (plate: readonly ScenePoint[]): boolean => {
const bounds = ringBounds(plate);
const isNear = (footprint: readonly ScenePoint[]): boolean => {
const bounds = ringBounds(footprint);
return !!bounds && input.wallSilhouettes.some((wall) =>
plateNearSilhouette(plate, bounds, wall, gapUnits, input.wallGeometryValidated === true));
footprintNearSilhouette(footprint, bounds, wall, gapUnits,
input.wallGeometryValidated === true));
};
const nearWallBefore = geometryValid ? isNear(basePlate) : true;
const nearWallBefore = geometryValid ? isNear(baseFootprint) : true;
let distanceCss = 0;
let nudgeScene: ScenePoint = [0, 0];
@@ -487,7 +489,7 @@ export function resolveIsoOverlayPlacement(input: IsoOverlayPlacementInput): Iso
floorAnchor[1] + (owner.safePoint[1] - floorAnchor[1]) * searchRatio,
];
// The structural scene may contain hundreds of faces. Only silhouettes
// intersecting the complete swept plate envelope can affect this nudge;
// intersecting the complete swept footprint envelope can affect this nudge;
// filter them once instead of repeating the whole-scene scan for every
// coarse and binary probe.
const maxOffset: ScenePoint = [
@@ -495,8 +497,8 @@ export function resolveIsoOverlayPlacement(input: IsoOverlayPlacementInput): Iso
uy * searchLimitCss * unitsPerPixel,
];
const sweptBounds = ringBounds([
...basePlate,
...basePlate.map((point) => [
...baseFootprint,
...baseFootprint.map((point) => [
point[0] + maxOffset[0], point[1] + maxOffset[1],
] as ScenePoint),
]);
@@ -511,11 +513,11 @@ export function resolveIsoOverlayPlacement(input: IsoOverlayPlacementInput): Iso
const collidesAt = (candidateCss: number): boolean => {
const offset: ScenePoint = [ux * candidateCss * unitsPerPixel,
uy * candidateCss * unitsPerPixel];
const plate = basePlate.map((point) =>
const footprint = baseFootprint.map((point) =>
[point[0] + offset[0], point[1] + offset[1]] as ScenePoint);
const bounds = ringBounds(plate);
const bounds = ringBounds(footprint);
return !!bounds && nearbyWalls.some((wall) =>
plateNearSilhouette(plate, bounds, wall, gapUnits,
footprintNearSilhouette(footprint, bounds, wall, gapUnits,
input.wallGeometryValidated === true));
};
const hint = input.nudgeHintCss;
@@ -576,14 +578,14 @@ export function resolveIsoOverlayPlacement(input: IsoOverlayPlacementInput): Iso
}
const visualScene: ScenePoint = [raisedScene[0] + nudgeScene[0], raisedScene[1] + nudgeScene[1]];
const plate = buildIsoPlatePolygon(floorAnchor, input.plateHalfSize,
const footprint = buildIsoFootprintPolygon(floorAnchor, input.footprintHalfSize,
raisedHeight, camera, nudgeScene);
const nearWallAfter = geometryValid ? knownNearWallAfter ?? isNear(plate) : true;
const nearWallAfter = geometryValid ? knownNearWallAfter ?? isNear(footprint) : true;
const nudged = distanceCss > EPS;
const tetherVisible = nudged || nearWallBefore || nearWallAfter
|| !!input.hovered || !!input.focused || !!input.selected;
return {
plane, owner, floorAnchor, floorScene, raisedScene, visualScene, plate,
plane, owner, floorAnchor, floorScene, raisedScene, visualScene, footprint,
nudgeScene,
nudgeCss: [nudgeScene[0] / unitsPerPixel, nudgeScene[1] / unitsPerPixel],
nudgeDistanceCss: distanceCss,
+15 -32
View File
@@ -113,8 +113,8 @@ export type IsoOverlayRenderScene = {
};
/**
* CSS-derived, deliberately conservative footprint constants. The raised SVG
* plate has no DOM measurement loop, so every value is an upper bound in
* CSS-derived, deliberately conservative footprint constants. The invisible
* footprint has no DOM measurement loop, so every value is an upper bound in
* multiples of the device core / room base-font rather than a sampled pixel
* size. Keep these in one place beside the pure footprint resolver whenever
* the shared face/room-label CSS changes.
@@ -188,7 +188,7 @@ function halfSizeOf(bounds: RelativeBounds, padding: number): PlanPoint {
];
}
/** Conservative raised-plate footprint for every supported HTML overlay. */
/** Conservative invisible footprint for every supported raised HTML overlay. */
export function isoRaisedOverlayHalfSize(input: IsoRaisedFootprintInput): PlanPoint {
if (input.kind === 'opening-lock') {
const size = Number.isFinite(input.size) && input.size > 0 ? input.size : 0;
@@ -601,12 +601,12 @@ const overlayEntryPoints = (
const placement = entry.placement;
const center = final ? placement.visualScene : placement.raisedScene;
const [halfX, halfY] = entry.screenHalfSize;
const plate = final ? placement.plate : placement.plate.map((point) => [
const footprint = final ? placement.footprint : placement.footprint.map((point) => [
point[0] - placement.nudgeScene[0], point[1] - placement.nudgeScene[1],
] as ScenePoint);
return [
placement.floorScene,
...plate,
...footprint,
[center[0] - halfX, center[1] - halfY],
[center[0] + halfX, center[1] - halfY],
[center[0] + halfX, center[1] + halfY],
@@ -619,7 +619,7 @@ const selectedOverlayEntries = (
): readonly IsoOverlayRenderEntry[] => ownerId === undefined ? entries
: entries.filter((entry) => entry.placement.owner?.id === ownerId);
/** Exact AABB of the final screen-facing roots and their raised plates. */
/** Exact AABB of the final screen-facing roots and their invisible footprints. */
export function isoOverlaySceneBounds(
scene: IsoOverlayRenderScene | null, ownerId?: string,
): Rect | null {
@@ -796,13 +796,14 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
kind: IsoRaisedOverlayKind,
id: string,
floorAnchor: PlanPoint,
plateHalfSize: PlanPoint,
footprintHalfSize: PlanPoint,
preferredRoomId?: string | null,
selected = false,
): IsoOverlayPlacement => {
const collisionMode = input.resolveCollisions === false ? 'fit' : 'live';
const cacheKey = `${collisionMode}\u0000${kind}\u0000${id}`;
const shapeSignature = [floorAnchor[0], floorAnchor[1], plateHalfSize[0], plateHalfSize[1],
const shapeSignature = [floorAnchor[0], floorAnchor[1],
footprintHalfSize[0], footprintHalfSize[1],
preferredRoomId || '', wallHeight, visualOffset,
input.layers.shadows ? 1 : 0, selected ? 1 : 0].join('|');
const signature = `${shapeSignature}|${unitsPerPixel}`;
@@ -814,8 +815,8 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
const distanceAtNewScale = previous.nudgeDistanceCss
* cached.value.unitsPerPixel / unitsPerPixel;
// Zooming in only shrinks the required scene-unit safety gap. An
// unchanged plate that was clear therefore stays clear; an unchanged
// non-near plate stays non-near. Reuse the immutable scene placement so
// unchanged footprint that was clear therefore stays clear; an unchanged
// non-near footprint stays non-near. Reuse the immutable scene placement so
// Lit also keeps the raised SVG subtree. Once its CSS displacement would
// exceed the public cap, fall through to the exact resolver.
if (!previous.nearWallBefore
@@ -847,7 +848,7 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
showBorders: true,
wallSilhouettes: input.resolveCollisions === false ? [] : input.wallSilhouettes,
wallGeometryValidated: true,
plateHalfSize,
footprintHalfSize,
wallHeight,
visualOffset,
sceneUnitsPerCssPixel: unitsPerPixel,
@@ -1066,7 +1067,7 @@ export function resolveIsoFramePresentation(input: {
shadows: renderIsoShadows(renderLayers, panels, input.scene?.geometry, input.cellCm),
walls: renderIsoWalls(input.projection, renderLayers, input.scene, panels, input.cellCm),
grounds: renderIsoOverlayGrounds(overlays, renderLayers, input.cellCm),
raised: renderIsoRaisedOverlays(overlays, renderLayers),
raised: renderIsoRaisedOverlays(overlays),
});
try { return render(layers); } catch (error) {
// A material/shadow presentation failure loses only decorative nuance.
@@ -1126,12 +1127,6 @@ function renderIsoDefs(
<filter id="hp-iso-overlay-ground" data-hp-iso-material-def x="-45%" y="-80%" width="190%" height="260%">
<feGaussianBlur stdDeviation="${3 * visualScale}"></feGaussianBlur>
</filter>` : nothing}
${root === 'overlays' && layers.materialNuance ? svg`
<pattern id="hp-iso-overlay-texture" data-hp-iso-material-def patternUnits="userSpaceOnUse"
width="${10 * visualScale}" height="${10 * visualScale}">
<circle class="iso-texture-mark" cx="${2.5 * visualScale}" cy="${2.5 * visualScale}"
r="${0.45 * visualScale}"></circle>
</pattern>` : nothing}
</defs>` as unknown as TemplateResult;
}
@@ -1146,7 +1141,7 @@ export function renderIsoOverlayGrounds(
return `${entry.kind}\u0000${entry.id}\u0000${grounding.center[0]}\u0000${grounding.center[1]}`
+ `\u0000${grounding.visible ? 1 : 0}\u0000${entry.groundRadius}`;
}).join('\u0001');
return guard([signature, layers.shadows, layers.materialNuance, cellCm], () => svg`
return guard([signature, layers.shadows, cellCm], () => svg`
${renderIsoDefs(layers, 'overlays', cellCm)}
<g class="iso-overlay-grounds" data-hp="iso-overlay-grounds"
aria-hidden="true" pointer-events="none">${overlays.entries.map((entry) => {
@@ -1161,10 +1156,9 @@ export function renderIsoOverlayGrounds(
export function renderIsoRaisedOverlays(
overlays: IsoOverlayRenderScene | null,
layers: IsoDecorationLayers,
): TemplateResult {
if (!overlays) return emptySvg();
return guard([overlays, layers.materialNuance], () => svg`
return guard([overlays], () => svg`
<g class="iso-raised-overlays" data-hp="iso-raised-overlays"
aria-hidden="true" pointer-events="none">
<g class="iso-overlay-tethers">${overlays.entries.map((entry) => {
@@ -1174,17 +1168,6 @@ export function renderIsoRaisedOverlays(
x1=${tether.from[0]} y1=${tether.from[1]}
x2=${tether.to[0]} y2=${tether.to[1]}></line>` : nothing;
})}</g>
<g class="iso-overlay-plates">${overlays.entries.map((entry) => {
const points = entry.placement.plate.map((point) => `${point[0]},${point[1]}`).join(' ');
if (!points) return nothing;
return svg`<g data-hp-iso-overlay-kind=${entry.kind} data-id=${entry.id}
data-hp-iso-raised="true" data-hp-iso-nudged=${String(entry.placement.nudged)}>
<polygon class="iso-overlay-plate iso-overlay-${entry.kind}" points=${points}></polygon>
${layers.materialNuance
? svg`<polygon class="iso-overlay-plate-texture" points=${points}></polygon>`
: nothing}
</g>`;
})}</g>
</g>`) as unknown as TemplateResult;
}
+3 -27
View File
@@ -335,19 +335,6 @@ export const planStyles = css`
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.iso-overlay-plate {
fill: rgba(248, 249, 247, 0.9);
stroke: rgba(123, 132, 136, 0.58);
stroke-width: 0.8;
vector-effect: non-scaling-stroke;
pointer-events: none;
}
.iso-overlay-plate-texture {
fill: url(#hp-iso-overlay-texture);
opacity: 0.11;
stroke: none;
pointer-events: none;
}
.iso-ambient-shadow {
fill: rgba(15, 21, 25, 0.22);
filter: url(#hp-iso-ambient-shadow);
@@ -392,10 +379,6 @@ export const planStyles = css`
.stage.theme-dark .iso-texture-line { stroke: rgba(255, 255, 255, 0.18); }
.stage.theme-dark .iso-overlay-ground { fill: rgba(0, 0, 0, 0.42); }
.stage.theme-dark .iso-overlay-tether { stroke: rgba(224, 232, 235, 0.5); }
.stage.theme-dark .iso-overlay-plate {
fill: rgba(68, 77, 81, 0.92);
stroke: rgba(183, 195, 200, 0.52);
}
@media (prefers-color-scheme: dark) {
/* HA's explicit theme wins over the operating-system preference. */
.stage:not(.theme-light) .iso-side-hi { stop-color: #4c555a; }
@@ -421,27 +404,20 @@ export const planStyles = css`
.stage:not(.theme-light) .iso-texture-line { stroke: rgba(255, 255, 255, 0.18); }
.stage:not(.theme-light) .iso-overlay-ground { fill: rgba(0, 0, 0, 0.42); }
.stage:not(.theme-light) .iso-overlay-tether { stroke: rgba(224, 232, 235, 0.5); }
.stage:not(.theme-light) .iso-overlay-plate {
fill: rgba(68, 77, 81, 0.92);
stroke: rgba(183, 195, 200, 0.52);
}
}
@media (forced-colors: active) {
.iso-wall-side, .iso-wall-top, .iso-floor-side, .iso-opening-panel,
.iso-overlay-plate {
.iso-wall-side, .iso-wall-top, .iso-floor-side, .iso-opening-panel {
fill: Canvas;
stroke: CanvasText;
forced-color-adjust: auto;
}
.iso-ambient-shadow, .iso-contact-shadow, .iso-leaf-shadow,
.iso-overlay-ground, .iso-material-texture,
.iso-overlay-plate-texture { display: none; }
.iso-overlay-ground, .iso-material-texture { display: none; }
.iso-overlay-tether { stroke: CanvasText; }
}
@supports not (filter: blur(1px)) {
.iso-ambient-shadow, .iso-contact-shadow, .iso-leaf-shadow,
.iso-overlay-ground, .iso-material-texture,
.iso-overlay-plate-texture { display: none; }
.iso-overlay-ground, .iso-material-texture { display: none; }
}
/* Opaque plan paper (owner 2026-08-03): the scene bg_color / daynight sky
shows ONLY around the plan, never through it. The colour is the