fix: refine hidden 2.5D presentation (#583)

Issue: #583
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-16 02:57:47 +03:00
parent ec2b3f3274
commit 5cae93501c
56 changed files with 1115 additions and 596 deletions
+9 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+9 -23
View File
@@ -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; }