mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-05 06:08:59 +00:00
fix: refine hidden 2.5D presentation (#583)
Issue: #583 User-Visible: no
This commit is contained in:
+9
-2
@@ -88,7 +88,7 @@ export interface IsoOpeningGeometryPolicy {
|
||||
}
|
||||
|
||||
export const ISO_OPENING_GEOMETRY_POLICY: Readonly<IsoOpeningGeometryPolicy> = Object.freeze({
|
||||
revision: 2,
|
||||
revision: 3,
|
||||
leafThicknessRatio: 0.04,
|
||||
frameThicknessRatio: 0.05,
|
||||
doorTurnDeg: 50,
|
||||
@@ -230,7 +230,14 @@ function leafBasis(
|
||||
// the structural basis as well would cancel that direction on shared and
|
||||
// partition hosts, just like a nested scaleY in the flat renderer.
|
||||
const sy = input.type === 'gate' ? 1 : input.flipV ? -1 : 1;
|
||||
const offset = openingSymbolOffset(input.type, input.flipV, input.angle, input.face);
|
||||
// Flat symbols stay on the saved centreline (#250). A volumetric door/gate
|
||||
// has a physical host face, however: centring its prism inside the masonry
|
||||
// makes half of the leaf look embedded in the wall. Only the derived Iso
|
||||
// leaf moves to that selected face; saved coordinates and the Flat symbol do
|
||||
// not change. Windows keep their frame/sash centred across the reveal.
|
||||
const offset = input.type === 'door' || input.type === 'gate'
|
||||
? input.face
|
||||
: openingSymbolOffset(input.type, input.flipV, input.angle, input.face);
|
||||
const origin: PlanPoint = [input.x + offset.ox, input.y + offset.oy];
|
||||
const hinge = add(origin, transformVector(localHinge, input.angle, sx, sy));
|
||||
const closedVector = transformVector(localVector, input.angle, sx, sy);
|
||||
|
||||
+292
-1
@@ -3,6 +3,7 @@ import {
|
||||
ISO_OVERLAY_VISUAL_OFFSET,
|
||||
ISO_WALL_HEIGHT,
|
||||
projectPlanPoint,
|
||||
unprojectFloorPoint,
|
||||
type IsoCamera,
|
||||
type PlanPoint,
|
||||
type ScenePoint,
|
||||
@@ -105,11 +106,42 @@ export interface IsoOverlayPlacement {
|
||||
tether: IsoOverlayTetherGeometry;
|
||||
status: 'ok' | 'degraded';
|
||||
reason: 'invalid-wall-geometry' | 'missing-owner' | 'invalid-safe-point'
|
||||
| 'owner-boundary' | 'nudge-cap' | null;
|
||||
| 'owner-boundary' | 'nudge-cap' | 'overlay-collision' | null;
|
||||
}
|
||||
|
||||
export type IsoOverlayCollisionKind = Exclude<IsoRaisedOverlayKind, 'room-label'>;
|
||||
|
||||
export interface IsoOverlayCollisionItem {
|
||||
id: string;
|
||||
kind: IsoOverlayCollisionKind;
|
||||
placement: IsoOverlayPlacement;
|
||||
/** Axis-aligned screen-facing half-size in scene units. */
|
||||
screenHalfSize: PlanPoint;
|
||||
}
|
||||
|
||||
export interface IsoOverlayCollisionInput {
|
||||
items: readonly IsoOverlayCollisionItem[];
|
||||
rooms: readonly IsoOverlayRoom[];
|
||||
wallSilhouettes: readonly IsoWallSilhouette[];
|
||||
sceneUnitsPerCssPixel: number;
|
||||
visualOffset?: number;
|
||||
safetyGapCssPx?: number;
|
||||
maxNudgeCssPx?: number;
|
||||
camera?: IsoCamera;
|
||||
}
|
||||
|
||||
export interface IsoOverlayCollisionResult {
|
||||
placements: ReadonlyMap<string, IsoOverlayPlacement>;
|
||||
residualPairs: readonly (readonly [string, string])[];
|
||||
}
|
||||
|
||||
const EPS = 1e-9;
|
||||
|
||||
/** Stable identity shared by the pure resolver and the render-scene maps. */
|
||||
export const isoOverlayCollisionKey = (
|
||||
kind: IsoOverlayCollisionKind, id: string,
|
||||
): string => `${kind}\u0000${id}`;
|
||||
|
||||
const finitePoint = (point: readonly number[]): boolean =>
|
||||
point.length >= 2 && Number.isFinite(point[0]) && Number.isFinite(point[1]);
|
||||
|
||||
@@ -602,3 +634,262 @@ export function resolveIsoOverlayPlacement(input: IsoOverlayPlacementInput): Iso
|
||||
status, reason,
|
||||
};
|
||||
}
|
||||
|
||||
const ISO_OVERLAY_GROUP_SEARCH_STEP_CSS_PX = 1;
|
||||
const ISO_OVERLAY_GROUP_CELL_CSS_PX = 64;
|
||||
|
||||
/**
|
||||
* One bounded, order-independent candidate lattice. It is allocated once and
|
||||
* reused by every card. The radius is the public absolute displacement cap,
|
||||
* not a per-collision allowance.
|
||||
*/
|
||||
const ISO_OVERLAY_GROUP_OFFSETS_CSS: readonly ScenePoint[] = Object.freeze((() => {
|
||||
const offsets: ScenePoint[] = [];
|
||||
const limit = Math.floor(ISO_OVERLAY_MAX_NUDGE_CSS_PX / ISO_OVERLAY_GROUP_SEARCH_STEP_CSS_PX);
|
||||
for (let y = -limit; y <= limit; y++) {
|
||||
for (let x = -limit; x <= limit; x++) {
|
||||
if (x * x + y * y > limit * limit) continue;
|
||||
offsets.push(Object.freeze([
|
||||
x * ISO_OVERLAY_GROUP_SEARCH_STEP_CSS_PX,
|
||||
y * ISO_OVERLAY_GROUP_SEARCH_STEP_CSS_PX,
|
||||
]) as ScenePoint);
|
||||
}
|
||||
}
|
||||
offsets.sort((a, b) => a[0] * a[0] + a[1] * a[1]
|
||||
- (b[0] * b[0] + b[1] * b[1])
|
||||
|| a[1] - b[1] || a[0] - b[0]);
|
||||
return offsets;
|
||||
})());
|
||||
|
||||
function overlayRootBounds(
|
||||
item: IsoOverlayCollisionItem, center: ScenePoint,
|
||||
): Bounds {
|
||||
return [
|
||||
center[0] - item.screenHalfSize[0],
|
||||
center[1] - item.screenHalfSize[1],
|
||||
center[0] + item.screenHalfSize[0],
|
||||
center[1] + item.screenHalfSize[1],
|
||||
];
|
||||
}
|
||||
|
||||
function expandedBounds(bounds: Bounds, gap: number): Bounds {
|
||||
return [bounds[0] - gap, bounds[1] - gap, bounds[2] + gap, bounds[3] + gap];
|
||||
}
|
||||
|
||||
function overlapPenalty(a: Bounds, b: Bounds, gap: number): number {
|
||||
const x = Math.min(a[2], b[2]) - Math.max(a[0], b[0]) + gap;
|
||||
const y = Math.min(a[3], b[3]) - Math.max(a[1], b[1]) + gap;
|
||||
return x > EPS && y > EPS ? x * y : 0;
|
||||
}
|
||||
|
||||
function raisedSceneToPlan(
|
||||
point: ScenePoint, visualOffset: number, camera: IsoCamera,
|
||||
): PlanPoint {
|
||||
const tilt = camera.tiltDeg * Math.PI / 180;
|
||||
return unprojectFloorPoint([
|
||||
point[0],
|
||||
point[1] + visualOffset * camera.zScale * Math.sin(tilt),
|
||||
], camera);
|
||||
}
|
||||
|
||||
function placementAtGroupOffset(
|
||||
base: IsoOverlayPlacement,
|
||||
offsetCss: ScenePoint,
|
||||
unitsPerPixel: number,
|
||||
residual: boolean,
|
||||
): IsoOverlayPlacement {
|
||||
const nudgeScene: ScenePoint = [
|
||||
offsetCss[0] * unitsPerPixel,
|
||||
offsetCss[1] * unitsPerPixel,
|
||||
];
|
||||
if (!residual
|
||||
&& Math.abs(nudgeScene[0] - base.nudgeScene[0]) <= EPS
|
||||
&& Math.abs(nudgeScene[1] - base.nudgeScene[1]) <= EPS) return base;
|
||||
const delta: ScenePoint = [
|
||||
nudgeScene[0] - base.nudgeScene[0],
|
||||
nudgeScene[1] - base.nudgeScene[1],
|
||||
];
|
||||
const visualScene: ScenePoint = [
|
||||
base.raisedScene[0] + nudgeScene[0],
|
||||
base.raisedScene[1] + nudgeScene[1],
|
||||
];
|
||||
const nudgeDistanceCss = Math.hypot(offsetCss[0], offsetCss[1]);
|
||||
return {
|
||||
...base,
|
||||
visualScene,
|
||||
footprint: base.footprint.map((point) => [
|
||||
point[0] + delta[0], point[1] + delta[1],
|
||||
] as ScenePoint),
|
||||
nudgeScene,
|
||||
nudgeCss: offsetCss,
|
||||
nudgeDistanceCss,
|
||||
nudged: nudgeDistanceCss > EPS,
|
||||
capped: base.capped || residual,
|
||||
status: residual ? 'degraded' : base.status,
|
||||
reason: residual ? 'overlay-collision' : base.reason,
|
||||
tether: tetherGeometry(base.floorScene, visualScene, false),
|
||||
};
|
||||
}
|
||||
|
||||
type AcceptedOverlay = {
|
||||
key: string;
|
||||
bounds: Bounds;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve device/lock collisions after each item has independently cleared
|
||||
* wall geometry. Room labels deliberately do not enter this pass. Earlier
|
||||
* items are the ones with the smaller already-required wall displacement;
|
||||
* ties use kind/id, never HA registry or render order.
|
||||
*/
|
||||
export function resolveIsoOverlayCollisions(
|
||||
input: IsoOverlayCollisionInput,
|
||||
): IsoOverlayCollisionResult {
|
||||
const unitsPerPixel = input.sceneUnitsPerCssPixel;
|
||||
const maxNudge = input.maxNudgeCssPx ?? ISO_OVERLAY_MAX_NUDGE_CSS_PX;
|
||||
const safetyGap = input.safetyGapCssPx ?? ISO_OVERLAY_SAFETY_GAP_CSS_PX;
|
||||
const visualOffset = input.visualOffset ?? ISO_OVERLAY_VISUAL_OFFSET;
|
||||
const camera = input.camera || ISO_CAMERA;
|
||||
if (!(unitsPerPixel > 0) || !Number.isFinite(unitsPerPixel)
|
||||
|| !(maxNudge >= 0) || !Number.isFinite(maxNudge)
|
||||
|| !(safetyGap >= 0) || !Number.isFinite(safetyGap)) {
|
||||
throw new Error('invalid isometric overlay collision input');
|
||||
}
|
||||
const gapUnits = safetyGap * unitsPerPixel;
|
||||
const cellUnits = ISO_OVERLAY_GROUP_CELL_CSS_PX * unitsPerPixel;
|
||||
const rooms = new Map(input.rooms.filter(validRoom).map((room) => [room.id, room]));
|
||||
const wallsValid = input.wallSilhouettes.every(validSilhouette);
|
||||
const accepted: AcceptedOverlay[] = [];
|
||||
const cells = new Map<string, number[]>();
|
||||
const placements = new Map<string, IsoOverlayPlacement>();
|
||||
const residualPairs: Array<readonly [string, string]> = [];
|
||||
const stableItems = [...input.items].sort((a, b) =>
|
||||
a.placement.nudgeDistanceCss - b.placement.nudgeDistanceCss
|
||||
|| isoOverlayCollisionKey(a.kind, a.id).localeCompare(isoOverlayCollisionKey(b.kind, b.id)));
|
||||
|
||||
const cellRange = (bounds: Bounds): readonly [number, number, number, number] => [
|
||||
Math.floor(bounds[0] / cellUnits), Math.floor(bounds[1] / cellUnits),
|
||||
Math.floor(bounds[2] / cellUnits), Math.floor(bounds[3] / cellUnits),
|
||||
];
|
||||
const nearby = (bounds: Bounds): number[] => {
|
||||
const [minX, minY, maxX, maxY] = cellRange(expandedBounds(bounds, gapUnits));
|
||||
const result = new Set<number>();
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
for (let x = minX; x <= maxX; x++) {
|
||||
for (const index of cells.get(`${x}:${y}`) || []) result.add(index);
|
||||
}
|
||||
}
|
||||
return [...result].sort((a, b) => a - b);
|
||||
};
|
||||
const addAccepted = (value: AcceptedOverlay): void => {
|
||||
const index = accepted.push(value) - 1;
|
||||
const [minX, minY, maxX, maxY] = cellRange(value.bounds);
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
for (let x = minX; x <= maxX; x++) {
|
||||
const key = `${x}:${y}`;
|
||||
const list = cells.get(key) || [];
|
||||
list.push(index);
|
||||
cells.set(key, list);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const item of stableItems) {
|
||||
const key = isoOverlayCollisionKey(item.kind, item.id);
|
||||
const base = item.placement;
|
||||
const baseOffsetCss: ScenePoint = [
|
||||
base.nudgeScene[0] / unitsPerPixel,
|
||||
base.nudgeScene[1] / unitsPerPixel,
|
||||
];
|
||||
const baseFootprint = base.footprint.map((point) => [
|
||||
point[0] - base.nudgeScene[0], point[1] - base.nudgeScene[1],
|
||||
] as ScenePoint);
|
||||
const ownerRoom = base.owner ? rooms.get(base.owner.id) || null : null;
|
||||
const currentPlan = raisedSceneToPlan(base.visualScene, visualOffset, camera);
|
||||
const wallCandidates = wallsValid && baseFootprint.length
|
||||
? input.wallSilhouettes.filter((wall) => {
|
||||
const wallBounds = silhouetteBounds(wall, true);
|
||||
if (!wallBounds) return false;
|
||||
const radius = maxNudge * unitsPerPixel;
|
||||
const footprintBounds = ringBounds(baseFootprint);
|
||||
return !!footprintBounds && boundsNear(expandedBounds(footprintBounds, radius),
|
||||
wallBounds, gapUnits);
|
||||
})
|
||||
: input.wallSilhouettes;
|
||||
|
||||
const candidate = (offsetCss: ScenePoint): {
|
||||
placement: IsoOverlayPlacement;
|
||||
bounds: Bounds;
|
||||
conflicts: readonly number[];
|
||||
penalty: number;
|
||||
} | null => {
|
||||
const distance = Math.hypot(offsetCss[0], offsetCss[1]);
|
||||
if (distance > maxNudge + EPS) return null;
|
||||
const offsetScene: ScenePoint = [
|
||||
offsetCss[0] * unitsPerPixel, offsetCss[1] * unitsPerPixel,
|
||||
];
|
||||
const sameAsBase = Math.abs(offsetScene[0] - base.nudgeScene[0]) <= EPS
|
||||
&& Math.abs(offsetScene[1] - base.nudgeScene[1]) <= EPS;
|
||||
const visualScene: ScenePoint = [
|
||||
base.raisedScene[0] + offsetScene[0],
|
||||
base.raisedScene[1] + offsetScene[1],
|
||||
];
|
||||
if (!sameAsBase) {
|
||||
if (!wallsValid || !ownerRoom) return null;
|
||||
const plan = raisedSceneToPlan(visualScene, visualOffset, camera);
|
||||
if (!pointStrictlyInRoom(plan, ownerRoom)) return null;
|
||||
if (pointStrictlyInRoom(currentPlan, ownerRoom)
|
||||
&& !segmentStrictlyInRoom(currentPlan, plan, ownerRoom)) return null;
|
||||
const footprint = baseFootprint.map((point) => [
|
||||
point[0] + offsetScene[0], point[1] + offsetScene[1],
|
||||
] as ScenePoint);
|
||||
const footprintBounds = ringBounds(footprint);
|
||||
if (!footprintBounds || wallCandidates.some((wall) =>
|
||||
footprintNearSilhouette(footprint, footprintBounds, wall, gapUnits, true))) return null;
|
||||
}
|
||||
const bounds = overlayRootBounds(item, visualScene);
|
||||
const conflicts = nearby(bounds).filter((index) =>
|
||||
overlapPenalty(bounds, accepted[index].bounds, gapUnits) > EPS);
|
||||
const penalty = conflicts.reduce((sum, index) =>
|
||||
sum + overlapPenalty(bounds, accepted[index].bounds, gapUnits), 0);
|
||||
return {
|
||||
placement: placementAtGroupOffset(base, offsetCss, unitsPerPixel, false),
|
||||
bounds, conflicts, penalty,
|
||||
};
|
||||
};
|
||||
|
||||
let best = candidate(baseOffsetCss);
|
||||
if (!best || best.conflicts.length) {
|
||||
for (const offset of ISO_OVERLAY_GROUP_OFFSETS_CSS) {
|
||||
if (Math.hypot(offset[0], offset[1]) > maxNudge + EPS) break;
|
||||
if (Math.abs(offset[0] - baseOffsetCss[0]) <= EPS
|
||||
&& Math.abs(offset[1] - baseOffsetCss[1]) <= EPS) continue;
|
||||
const next = candidate(offset);
|
||||
if (!next) continue;
|
||||
if (!next.conflicts.length) { best = next; break; }
|
||||
if (!best || next.penalty < best.penalty - EPS
|
||||
|| Math.abs(next.penalty - best.penalty) <= EPS
|
||||
&& next.placement.nudgeDistanceCss < best.placement.nudgeDistanceCss - EPS) best = next;
|
||||
}
|
||||
}
|
||||
if (!best) {
|
||||
const placement = placementAtGroupOffset(base, baseOffsetCss, unitsPerPixel, true);
|
||||
const bounds = overlayRootBounds(item, placement.visualScene);
|
||||
best = { placement, bounds, conflicts: nearby(bounds), penalty: Infinity };
|
||||
} else if (best.conflicts.length) {
|
||||
best = { ...best, placement: placementAtGroupOffset(
|
||||
base, best.placement.nudgeCss, unitsPerPixel, true,
|
||||
) };
|
||||
}
|
||||
for (const index of best.conflicts) {
|
||||
if (overlapPenalty(best.bounds, accepted[index].bounds, gapUnits) > EPS)
|
||||
residualPairs.push(Object.freeze([accepted[index].key, key]));
|
||||
}
|
||||
placements.set(key, best.placement);
|
||||
addAccepted({ key, bounds: best.bounds });
|
||||
}
|
||||
return {
|
||||
placements,
|
||||
residualPairs: Object.freeze(residualPairs),
|
||||
};
|
||||
}
|
||||
|
||||
+72
-43
@@ -24,8 +24,8 @@ import {
|
||||
type IsoOpeningGeometryPolicy, type IsoOpeningSurface,
|
||||
} from './iso-openings';
|
||||
import {
|
||||
ISO_OVERLAY_MAX_NUDGE_CSS_PX, isoRoomSafePoint,
|
||||
resolveIsoOverlayOwner, resolveIsoOverlayPlacement,
|
||||
ISO_OVERLAY_MAX_NUDGE_CSS_PX, isoOverlayCollisionKey, isoRoomSafePoint,
|
||||
resolveIsoOverlayCollisions, resolveIsoOverlayOwner, resolveIsoOverlayPlacement,
|
||||
type IsoOverlayPlacement, type IsoOverlayRoom, type IsoRaisedOverlayKind,
|
||||
type IsoWallSilhouette,
|
||||
} from './iso-overlays';
|
||||
@@ -78,10 +78,11 @@ const isoDepthStableKey = (entry: IsoWallDepthQueueEntry): string => {
|
||||
* Far-to-near painter queue shared by every opaque Stage 4 structural surface.
|
||||
*
|
||||
* The established screen-depth order remains authoritative between unrelated
|
||||
* walls/openings. Inside one window, however, height is physical camera depth:
|
||||
* the glass above a sill must paint over the sill's rear projection. Reorder
|
||||
* only that window's already allocated queue slots, so the fix cannot move an
|
||||
* unrelated wall or opening into a different layer.
|
||||
* walls/openings. Inside one opening, however, physical camera depth is the
|
||||
* authoritative face order: elevated window glass must paint over its rear
|
||||
* sill, and a door/gate prism must not invert its front/back faces while it
|
||||
* rotates. Reorder only that opening's already allocated queue slots, so the
|
||||
* fix cannot move an unrelated wall or opening into a different layer.
|
||||
*/
|
||||
export function buildIsoWallDepthQueue(
|
||||
geometry: IsoWallGeometry,
|
||||
@@ -101,27 +102,28 @@ export function buildIsoWallDepthQueue(
|
||||
entries.sort((a, b) => a.depth - b.depth
|
||||
|| isoDepthLayerRank(a) - isoDepthLayerRank(b)
|
||||
|| isoDepthStableKey(a).localeCompare(isoDepthStableKey(b)));
|
||||
const windowSlots = new Map<string, IsoWallDepthQueueEntry[]>();
|
||||
const openingSlots = new Map<string, IsoWallDepthQueueEntry[]>();
|
||||
for (const entry of entries) {
|
||||
if (entry.layer !== 'opening' || entry.surface.type !== 'window') continue;
|
||||
const slots = windowSlots.get(entry.surface.id) || [];
|
||||
if (entry.layer !== 'opening') continue;
|
||||
const key = `${entry.surface.type}\u0000${entry.surface.id}`;
|
||||
const slots = openingSlots.get(key) || [];
|
||||
slots.push(entry);
|
||||
windowSlots.set(entry.surface.id, slots);
|
||||
openingSlots.set(key, slots);
|
||||
}
|
||||
for (const slots of windowSlots.values()) slots.sort((a, b) => {
|
||||
for (const slots of openingSlots.values()) slots.sort((a, b) => {
|
||||
const surfaceA = a.layer === 'opening' ? a.surface : null;
|
||||
const surfaceB = b.layer === 'opening' ? b.surface : null;
|
||||
return (surfaceA?.cameraDepth ?? surfaceA?.depth ?? 0)
|
||||
- (surfaceB?.cameraDepth ?? surfaceB?.depth ?? 0)
|
||||
|| isoDepthStableKey(a).localeCompare(isoDepthStableKey(b));
|
||||
});
|
||||
const nextWindowSlot = new Map<string, number>();
|
||||
const nextOpeningSlot = new Map<string, number>();
|
||||
return Object.freeze(entries.map((entry) => {
|
||||
if (entry.layer !== 'opening' || entry.surface.type !== 'window') return entry;
|
||||
const id = entry.surface.id;
|
||||
const index = nextWindowSlot.get(id) || 0;
|
||||
nextWindowSlot.set(id, index + 1);
|
||||
return windowSlots.get(id)?.[index] || entry;
|
||||
if (entry.layer !== 'opening') return entry;
|
||||
const key = `${entry.surface.type}\u0000${entry.surface.id}`;
|
||||
const index = nextOpeningSlot.get(key) || 0;
|
||||
nextOpeningSlot.set(key, index + 1);
|
||||
return openingSlots.get(key)?.[index] || entry;
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -139,6 +141,10 @@ export type IsoOverlayRenderScene = {
|
||||
rooms: ReadonlyMap<RoomCfg, IsoOverlayPlacement>;
|
||||
locks: ReadonlyMap<string, IsoOverlayPlacement>;
|
||||
entries: readonly IsoOverlayRenderEntry[];
|
||||
/** Internal memo key for the complete raised-overlay group placement. */
|
||||
collisionSignature: string;
|
||||
/** Stable keys of pairs that could not be fully separated inside the cap. */
|
||||
residualPairs: readonly (readonly [string, string])[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -951,9 +957,6 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
|
||||
});
|
||||
}
|
||||
}
|
||||
const scene: IsoOverlayRenderScene = {
|
||||
devices, rooms: roomPlacements, locks, entries: Object.freeze(entries),
|
||||
};
|
||||
const mode = input.resolveCollisions === false ? 'fit' : 'live';
|
||||
let renderScenes = isoOverlayRenderSceneCache.get(input.wallSilhouettes);
|
||||
if (!renderScenes) {
|
||||
@@ -961,6 +964,48 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
|
||||
isoOverlayRenderSceneCache.set(input.wallSilhouettes, renderScenes);
|
||||
}
|
||||
const previous = renderScenes.get(mode);
|
||||
const collisionSignature = entries.map((entry) => {
|
||||
const placement = entry.placement;
|
||||
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('|');
|
||||
}).sort().join('\u0001');
|
||||
if (previous?.collisionSignature === collisionSignature
|
||||
&& samePlacementMap(previous.rooms, roomPlacements)) return previous;
|
||||
|
||||
const collision = mode === 'live' ? resolveIsoOverlayCollisions({
|
||||
items: entries.flatMap((entry) => entry.kind === 'room-label' ? [] : [{
|
||||
id: entry.id,
|
||||
kind: entry.kind,
|
||||
placement: entry.placement,
|
||||
screenHalfSize: entry.screenHalfSize,
|
||||
}]),
|
||||
rooms,
|
||||
wallSilhouettes: input.wallSilhouettes,
|
||||
sceneUnitsPerCssPixel: unitsPerPixel,
|
||||
visualOffset,
|
||||
}) : { placements: new Map<string, IsoOverlayPlacement>(), residualPairs: [] };
|
||||
const resolvedEntries = Object.freeze(entries.map((entry) => {
|
||||
if (entry.kind === 'room-label') return entry;
|
||||
const placement = collision.placements.get(isoOverlayCollisionKey(entry.kind, entry.id))
|
||||
|| entry.placement;
|
||||
return placement === entry.placement ? entry : { ...entry, placement };
|
||||
}));
|
||||
const resolvedDevices = new Map<string, IsoOverlayPlacement>();
|
||||
const resolvedLocks = new Map<string, IsoOverlayPlacement>();
|
||||
for (const entry of resolvedEntries) {
|
||||
if (entry.kind === 'device') resolvedDevices.set(entry.id, entry.placement);
|
||||
else if (entry.kind === 'opening-lock') resolvedLocks.set(entry.id, entry.placement);
|
||||
}
|
||||
const scene: IsoOverlayRenderScene = {
|
||||
devices: resolvedDevices,
|
||||
rooms: roomPlacements,
|
||||
locks: resolvedLocks,
|
||||
entries: resolvedEntries,
|
||||
collisionSignature,
|
||||
residualPairs: Object.freeze([...collision.residualPairs]),
|
||||
};
|
||||
if (previous && sameOverlayEntries(previous.entries, scene.entries)
|
||||
&& samePlacementMap(previous.devices, scene.devices)
|
||||
&& samePlacementMap(previous.rooms, scene.rooms)
|
||||
@@ -1095,14 +1140,14 @@ export function resolveIsoFramePresentation(input: {
|
||||
|
||||
const emptySvg = (): TemplateResult => svg`` as unknown as TemplateResult;
|
||||
|
||||
/** One scale-aware visual light vector shared by every Stage 4 shadow plane. */
|
||||
/** Scale-aware visual light vector for the one remaining building ambient shadow. */
|
||||
export function isoFixedLightTransform(cellCm: number): string {
|
||||
return `translate(${gridVisualUnits(4, cellCm)} ${gridVisualUnits(8, cellCm)})`;
|
||||
}
|
||||
|
||||
function renderIsoDefs(
|
||||
layers: IsoDecorationLayers,
|
||||
root: 'underlay' | 'shadows' | 'walls',
|
||||
root: 'underlay' | 'walls',
|
||||
cellCm: number,
|
||||
): TemplateResult {
|
||||
const visualScale = gridVisualScale(cellCm);
|
||||
@@ -1131,13 +1176,6 @@ function renderIsoDefs(
|
||||
<filter id="hp-iso-ambient-shadow" data-hp-iso-material-def x="-12%" y="-12%" width="124%" height="130%">
|
||||
<feGaussianBlur stdDeviation="${7 * visualScale}"></feGaussianBlur>
|
||||
</filter>` : nothing}
|
||||
${root === 'shadows' && layers.shadows ? svg`
|
||||
<filter id="hp-iso-contact-shadow" data-hp-iso-material-def x="-8%" y="-20%" width="116%" height="140%">
|
||||
<feGaussianBlur stdDeviation="${2.5 * visualScale}"></feGaussianBlur>
|
||||
</filter>
|
||||
<filter id="hp-iso-leaf-shadow" data-hp-iso-material-def x="-12%" y="-30%" width="124%" height="160%">
|
||||
<feGaussianBlur stdDeviation="${2 * visualScale}"></feGaussianBlur>
|
||||
</filter>` : nothing}
|
||||
</defs>` as unknown as TemplateResult;
|
||||
}
|
||||
|
||||
@@ -1176,21 +1214,12 @@ export function renderIsoUnderlay(
|
||||
}
|
||||
|
||||
export function renderIsoShadows(
|
||||
layers: IsoDecorationLayers,
|
||||
panels: readonly IsoOpeningPanel[],
|
||||
geometry: IsoWallGeometry | undefined,
|
||||
cellCm: number,
|
||||
_layers: IsoDecorationLayers,
|
||||
_panels: readonly IsoOpeningPanel[],
|
||||
_geometry: IsoWallGeometry | undefined,
|
||||
_cellCm: number,
|
||||
): TemplateResult {
|
||||
if (!layers.shadows || !geometry) return emptySvg();
|
||||
return svg`<g class="iso-shadows" data-hp="iso-shadows" aria-hidden="true" pointer-events="none">
|
||||
${renderIsoDefs(layers, 'shadows', cellCm)}
|
||||
<path class="iso-contact-shadow" d=${geometry.contactPath}
|
||||
transform=${isoFixedLightTransform(cellCm)}></path>
|
||||
<g class="iso-leaf-shadows">${panels.map((panel) =>
|
||||
svg`<path class="iso-leaf-shadow" d=${panel.shadowD}
|
||||
data-id=${panel.id} data-leaf=${panel.leaf}
|
||||
transform=${isoFixedLightTransform(cellCm)}></path>`)}</g>
|
||||
</g>` as unknown as TemplateResult;
|
||||
return emptySvg();
|
||||
}
|
||||
|
||||
export function renderIsoWalls(
|
||||
|
||||
@@ -344,7 +344,7 @@ export const planStyles = css`
|
||||
}
|
||||
.iso-opening-panel.iso-material-matte-leaf {
|
||||
fill: #a6abad;
|
||||
stroke: #737b7f;
|
||||
stroke: none;
|
||||
}
|
||||
.iso-opening-panel.iso-opening-leaf-edge,
|
||||
.iso-opening-panel.iso-opening-leaf-top { fill: #8d9497; }
|
||||
@@ -369,20 +369,6 @@ export const planStyles = css`
|
||||
fill: rgba(15, 21, 25, 0.22);
|
||||
filter: url(#hp-iso-ambient-shadow);
|
||||
}
|
||||
.iso-contact-shadow {
|
||||
fill: none;
|
||||
stroke: rgba(22, 28, 31, 0.25);
|
||||
stroke-width: 3;
|
||||
filter: url(#hp-iso-contact-shadow);
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.iso-leaf-shadow {
|
||||
fill: none;
|
||||
stroke: rgba(18, 23, 27, 0.24);
|
||||
stroke-width: 4;
|
||||
filter: url(#hp-iso-leaf-shadow);
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.stage.theme-dark .iso-side-hi { stop-color: #4c555a; }
|
||||
.stage.theme-dark .iso-side-lo { stop-color: #343c40; }
|
||||
.stage.theme-dark .iso-top-hi { stop-color: #687176; }
|
||||
@@ -393,7 +379,7 @@ export const planStyles = css`
|
||||
.stage.theme-dark .iso-opening-panel { fill: #626b70; stroke: #899399; }
|
||||
.stage.theme-dark .iso-opening-panel.iso-window { fill: #75919b; stroke: #abc6ce; }
|
||||
.stage.theme-dark .iso-opening-panel.iso-material-reveal { fill: #454e52; stroke: #727d82; }
|
||||
.stage.theme-dark .iso-opening-panel.iso-material-matte-leaf { fill: #626a6e; stroke: #899398; }
|
||||
.stage.theme-dark .iso-opening-panel.iso-material-matte-leaf { fill: #626a6e; stroke: none; }
|
||||
.stage.theme-dark .iso-opening-panel.iso-opening-leaf-edge,
|
||||
.stage.theme-dark .iso-opening-panel.iso-opening-leaf-top { fill: #4b5458; }
|
||||
.stage.theme-dark .iso-opening-panel.iso-material-light-frame,
|
||||
@@ -411,8 +397,6 @@ export const planStyles = css`
|
||||
stroke: #d9eef6;
|
||||
}
|
||||
.stage.theme-dark .iso-ambient-shadow { fill: rgba(0, 0, 0, 0.34); }
|
||||
.stage.theme-dark .iso-contact-shadow,
|
||||
.stage.theme-dark .iso-leaf-shadow { stroke: rgba(0, 0, 0, 0.38); }
|
||||
.stage.theme-dark .iso-texture-mark { fill: rgba(255, 255, 255, 0.24); }
|
||||
.stage.theme-dark .iso-texture-line { stroke: rgba(255, 255, 255, 0.18); }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@@ -427,7 +411,7 @@ export const planStyles = css`
|
||||
.stage:not(.theme-light) .iso-opening-panel { fill: #626b70; stroke: #899399; }
|
||||
.stage:not(.theme-light) .iso-opening-panel.iso-window { fill: #75919b; stroke: #abc6ce; }
|
||||
.stage:not(.theme-light) .iso-opening-panel.iso-material-reveal { fill: #454e52; stroke: #727d82; }
|
||||
.stage:not(.theme-light) .iso-opening-panel.iso-material-matte-leaf { fill: #626a6e; stroke: #899398; }
|
||||
.stage:not(.theme-light) .iso-opening-panel.iso-material-matte-leaf { fill: #626a6e; stroke: none; }
|
||||
.stage:not(.theme-light) .iso-opening-panel.iso-opening-leaf-edge,
|
||||
.stage:not(.theme-light) .iso-opening-panel.iso-opening-leaf-top { fill: #4b5458; }
|
||||
.stage:not(.theme-light) .iso-opening-panel.iso-material-light-frame,
|
||||
@@ -436,8 +420,6 @@ export const planStyles = css`
|
||||
.stage:not(.theme-light) .iso-opening-panel.iso-material-glass-side { fill: #8fbfd5; stroke: #bad9e6; }
|
||||
.stage:not(.theme-light) .iso-opening-panel.iso-material-glass-top { fill: #c9e6f2; stroke: #d9eef6; }
|
||||
.stage:not(.theme-light) .iso-ambient-shadow { fill: rgba(0, 0, 0, 0.34); }
|
||||
.stage:not(.theme-light) .iso-contact-shadow,
|
||||
.stage:not(.theme-light) .iso-leaf-shadow { stroke: rgba(0, 0, 0, 0.38); }
|
||||
.stage:not(.theme-light) .iso-texture-mark { fill: rgba(255, 255, 255, 0.24); }
|
||||
.stage:not(.theme-light) .iso-texture-line { stroke: rgba(255, 255, 255, 0.18); }
|
||||
}
|
||||
@@ -447,11 +429,11 @@ export const planStyles = css`
|
||||
stroke: CanvasText;
|
||||
forced-color-adjust: auto;
|
||||
}
|
||||
.iso-ambient-shadow, .iso-contact-shadow, .iso-leaf-shadow,
|
||||
.iso-ambient-shadow,
|
||||
.iso-material-texture { display: none; }
|
||||
}
|
||||
@supports not (filter: blur(1px)) {
|
||||
.iso-ambient-shadow, .iso-contact-shadow, .iso-leaf-shadow,
|
||||
.iso-ambient-shadow,
|
||||
.iso-material-texture { display: none; }
|
||||
}
|
||||
/* Opaque plan paper (owner 2026-08-03): the scene bg_color / daynight sky
|
||||
@@ -794,6 +776,10 @@ export const planStyles = css`
|
||||
presentation-only inline value. */
|
||||
color: #303936 !important;
|
||||
}
|
||||
/* Interactive raised overlays stay above passive room names. Collision
|
||||
placement never needs to move or promote a label to make a device usable. */
|
||||
.stage.projection-iso.mode-view .dev,
|
||||
.stage.projection-iso.mode-view .oplock { z-index: 2; }
|
||||
.stage.projection-iso.theme-dark.mode-view .roomlabel { color: #f2f0e8 !important; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.stage.projection-iso:not(.theme-light).mode-view .roomlabel { color: #f2f0e8 !important; }
|
||||
|
||||
Reference in New Issue
Block a user