feat(iso): the 2.5D floor is the Flat plane, one wall-top rise for tiles (#713)

- Vertical oblique projection: the floor matrix is the identity and a height
  rises straight up by z·sin 20°, so the on-screen wall height is unchanged
  and the cos 20° foreshortening of the plan, decor and anchors is gone.
- Device tiles and lock badges stand on the wall-top plane with one common
  shift; the #651 placement search no longer runs in the live scene (its
  removal is #714). Room names keep their Flat floor point.
- The 2.5D fit no longer reserves the 48 CSS px nudge budget.
- Switching projection keeps the camera when the previous projection was on
  screen: saving the setting, entering an editor from 2.5D and adopting a warm
  memo from the other projection re-read only the scalar zoom. A cold 2.5D
  start still opens the 2.5D home.
- Opening faces are ordered along the oblique projector (s·y + z).

Witness: demo/smoke_iso_flat_parity.mjs (AC2–AC5, AC11) is red on the old code.

Issue: #713
User-Visible: yes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-09-30 15:18:00 +03:00
parent e19f5b0a0e
commit 0d641ffbfd
26 changed files with 743 additions and 325 deletions
+43 -39
View File
@@ -251,8 +251,7 @@ import {
currentLabs, hashSpace, noteLabsRender, subscribeLabs, type LabsSnapshot,
} from './labs';
import {
ISO_FLOOR_EDGE_HEIGHT, ISO_WALL_HEIGHT, isoFloorMatrixCss, projectPlanPoint, projectedFrame,
unprojectFloorPoint, type ScenePoint,
ISO_FLOOR_EDGE_HEIGHT, ISO_WALL_HEIGHT, projectedFrame,
} from './iso-projection';
import type { IsoDecorationLayers } from './iso-openings';
import type { IsoOverlayPlacement } from './iso-overlays';
@@ -2182,7 +2181,7 @@ export class HouseplanCard extends LitElement {
private _navApplied = false; // the saved space was restored (or the user navigated)
private _labs: LabsSnapshot = { alpha: false, active: Object.freeze([]), space: '' };
private _labsUnsub?: () => void;
private _isoEnabledSeen = false; private _isoLightFloors: ReadonlySet<string> | null = null; private readonly _isoFirstFrame = new IsoFirstFrameState(); // #649/#654
private _isoEnabledSeen = false; private _projectionShown: 'flat' | 'iso' | null = null; private _isoLightFloors: ReadonlySet<string> | null = null; private readonly _isoFirstFrame = new IsoFirstFrameState(); // #649/#654
private _renderProjection: 'flat' | 'iso' = 'flat';
// ---- kiosk (wall device) mode ----
private _kioskScale: { icon: number; font: number } = { icon: 1, font: 1 }; private _kioskDialog = false;
@@ -2320,23 +2319,33 @@ export class HouseplanCard extends LitElement {
return this._mode === 'view' && this._isoEnabled ? 'iso' : 'flat';
}
private _logicalViewCenter(projection: 'flat' | 'iso'): { x: number; y: number } | null {
/** #713: the 2.5D floor is the Flat plane, so the view centre is already logical. */
private _logicalViewCenter(): { x: number; y: number } | null {
const view = this._view;
if (!view) return null;
const center: ScenePoint = [view.x + view.w / 2, view.y + view.h / 2];
const point = projection === 'iso' ? unprojectFloorPoint(center) : center;
return { x: point[0], y: point[1] };
return view && { x: view.x + view.w / 2, y: view.y + view.h / 2 };
}
/** #713: the floor is one plane in both projections. Keep the camera (screen
* scale and centre) and re-read the scalar zoom against the effective frame. */
private _rezoom(): void {
const v = this._view;
const z = v ? fitView(this._baseVb(), v.w / v.h).w / v.w : 0;
if (z >= MIN_ZOOM && z <= 8) this._zoom = z;
else if (v) this._applyView(z, v.x + v.w / 2, v.y + v.h / 2);
}
private _convertProjectionView(from: 'flat' | 'iso', to: 'flat' | 'iso'): void {
if (from === to) return;
this._clearRoomFocus(true);
const logical = this._logicalViewCenter(from);
this._view = null;
const target = logical
? to === 'iso' ? projectPlanPoint([logical.x, logical.y], 0) : [logical.x, logical.y] as ScenePoint
: null;
this._applyView(this._zoom, target?.[0], target?.[1]);
// A structural change: a running camera tween stops on its presented frame.
this._cancelCameraTransition(false);
// Only a camera that was on screen is kept; a cold 2.5D start opens home.
if (this._projectionShown === from) this._rezoom();
else {
const center = this._logicalViewCenter();
this._view = null;
this._applyView(this._zoom, center?.x, center?.y);
}
this._warmPatch({ vp: this._warmViewportState() });
this.requestUpdate();
}
@@ -3320,14 +3329,15 @@ export class HouseplanCard extends LitElement {
this._zoom = vp.zoom;
const projection = this._effectiveProjection();
const sameProjection = projection === vp.projection && this._isoEnabled === vp.activeLabsIso;
this._view = sameProjection && vp.view ? { ...vp.view } : null;
// #713: one floor plane, and the window size is in the memo key, so the
// saved camera is valid in either projection; only zoom is re-read.
this._view = vp.view ? { ...vp.view } : null;
this._viewModeSnap = sameProjection && vp.snap ? { ...vp.snap } : null;
if (!sameProjection && vp.logicalCenter) {
const center = projection === 'iso'
? projectPlanPoint([vp.logicalCenter.x, vp.logicalCenter.y], 0)
: [vp.logicalCenter.x, vp.logicalCenter.y] as ScenePoint;
this._applyView(vp.zoom, center[0], center[1]);
if (!sameProjection) {
if (this._view) this._rezoom();
else if (vp.logicalCenter) this._applyView(vp.zoom, vp.logicalCenter.x, vp.logicalCenter.y);
}
this._projectionShown = projection;
this._tool = normalizeMarkupTool(vp.tool);
this._decorTool = vp.decorTool;
this._showHidden = vp.showHidden;
@@ -3380,7 +3390,7 @@ export class HouseplanCard extends LitElement {
mode: this._mode,
projection,
activeLabsIso: this._isoEnabled,
logicalCenter: this._logicalViewCenter(projection),
logicalCenter: this._logicalViewCenter(),
zoom: this._zoom,
view: this._view ? { ...this._view } : null,
snap: this._viewModeSnap ? { ...this._viewModeSnap } : null,
@@ -4047,6 +4057,7 @@ export class HouseplanCard extends LitElement {
this._pruneDevicePressFeedback();
this._syncDayCycleClock();
this._syncRadarLive();
if (!this._booting && !this._isoFirstFrame.pending(this._desiredProjection, !!this._isoSceneRuntime)) this._projectionShown = this._renderProjection;
this._warmSnapshot(); // DEV-B703-03: the memo follows what is on screen
// Decor selection cannot exist before the lazy editor runtime is ready.
if (this._editorRuntime) this._dtMeasure();
@@ -6042,13 +6053,9 @@ export class HouseplanCard extends LitElement {
}
}
private _scenePoint(point: readonly [number, number]): ScenePoint {
return this._renderProjection === 'iso' ? projectPlanPoint(point, 0) : point;
}
/** #713: the 2.5D floor is the Flat plane; the live viewport still asks. */
private _floorView(view: { x: number; y: number; w: number; h: number }): { x: number; y: number; w: number; h: number } {
if (this._renderProjection !== 'iso') return view;
const start = unprojectFloorPoint([view.x, view.y]), end = unprojectFloorPoint([view.x + view.w, view.y + view.h]);
return { x: start[0], y: start[1], w: end[0] - start[0], h: end[1] - start[1] };
return view;
}
/** Stage 4 keeps one immutable floor point and one runtime visual point per
* raised item. This snapshot is presentation-only and never enters config. */
@@ -10888,16 +10895,13 @@ export class HouseplanCard extends LitElement {
data-hp-live-viewbox=${iso ? 'camera' : 'floor'} data-hp-live-overflow="clip"
viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
preserveAspectRatio="xMidYMid meet" aria-hidden="true" pointer-events="none">
<g transform=${iso ? isoFloorMatrixCss() : nothing}>
${renderPaperShapes(paperShapes, 'hp-paper-outline-shapes')}
</g>
${renderPaperShapes(paperShapes, 'hp-paper-outline-shapes')}
</svg>` : nothing}
<svg class="plan-svg" data-hp-layer="plan"
data-hp-live-viewbox=${iso ? 'camera' : 'floor'}
viewBox="${view.x} ${view.y} ${view.w} ${view.h}"
preserveAspectRatio="xMidYMid meet">
<g class=${iso ? 'iso-floor-scene' : nothing}
transform=${iso ? isoFloorMatrixCss() : nothing}>
<g class=${iso ? 'iso-floor-scene' : nothing}>
${''/* THE PAPER IS THE ROOMS (docs/DECOR-EDITOR.md §3.3, owner
2026-08-04). Opaque shapes stop the scene background —
bg_color or the day-cycle environment — from bleeding through the
@@ -11160,7 +11164,7 @@ export class HouseplanCard extends LitElement {
return !!marker && radarMarkerLiveInSpace(marker, this._space);
}),
view,
(point) => this._scenePoint(point),
(point) => point,
)}
${keyed(space.id, repeat(devs, (d) => d.id, (d) => this._renderDevice(
d, view, showLqi, isoOverlays?.devices.get(d.id),
@@ -11643,7 +11647,7 @@ export class HouseplanCard extends LitElement {
const calibrated = path.map((segment) => segment.map(([x, y]) => applyAffine(matrix, x, y)));
const commands = smoothVacPath(calibrated, this._cmToUnits(VAC_TRAIL_SMOOTH_RADIUS_CM));
const pointText = (point: readonly [number, number]): string => {
const scene = this._scenePoint(point);
const scene = point;
return `${scene[0].toFixed(1)} ${scene[1].toFixed(1)}`;
};
return commands.map((segment) => segment.map((command) => {
@@ -11728,7 +11732,7 @@ export class HouseplanCard extends LitElement {
if (moving && last?.length >= 2) {
const anchor = last[last.length - 1];
const [ax, ay] = applyAffine(matrix, anchor[0], anchor[1]);
const point = this._scenePoint([ax, ay]);
const point = [ax, ay];
const a1 = point[0].toFixed(1), a2 = point[1].toFixed(1);
trails.push(svg`<line class="case tip" data-mid="${d.id}" x1="${a1}" y1="${a2}" x2="${a1}" y2="${a2}"></line><line class="core tip" data-mid="${d.id}" x1="${a1}" y1="${a2}" x2="${a1}" y2="${a2}"></line>`);
}
@@ -11736,7 +11740,7 @@ export class HouseplanCard extends LitElement {
}
if (!moving || !tele.pos || !matrix) continue;
const [cx, cy] = applyAffine(matrix, tele.pos.x, tele.pos.y);
const point = this._scenePoint([cx, cy]);
const point = [cx, cy];
const left = ((point[0] - view.x) / view.w) * 100;
const top = ((point[1] - view.y) / view.h) * 100;
const stale = rt && rt.lastTs > 0 && Date.now() - rt.lastTs > VAC_STALE_MS;
@@ -11766,7 +11770,7 @@ export class HouseplanCard extends LitElement {
ghost = false,
): TemplateResult {
const pos = this._pos(d);
const point = isoPlacement?.visualScene ?? this._scenePoint([pos.x, pos.y]);
const point = isoPlacement?.visualScene ?? [pos.x, pos.y];
const left = ((point[0] - view.x) / view.w) * 100;
const top = ((point[1] - view.y) / view.h) * 100;
const presentation = this._devicePresentation(d, showLqi);
@@ -12019,7 +12023,7 @@ export class HouseplanCard extends LitElement {
// editor — that is where you name them (field report, 2026-07-27)
if (!r.name && !this._markup) return nothing;
const p = this._labelPos(r, space.id);
const point = isoPlacement?.visualScene ?? this._scenePoint([p.x, p.y]);
const point = isoPlacement?.visualScene ?? [p.x, p.y];
const left = ((point[0] - view.x) / view.w) * 100;
const top = ((point[1] - view.y) / view.h) * 100;
const op = Math.min(1, disp.opacity + 0.25);
@@ -12413,7 +12417,7 @@ export class HouseplanCard extends LitElement {
gateFace,
}, this._cellCm)[0];
const isoPlacement = isoPlacements?.get(String(o.id));
const point = isoPlacement?.visualScene ?? this._scenePoint(floorAnchor);
const point = isoPlacement?.visualScene ?? floorAnchor;
const left = ((point[0] - view.x) / view.w) * 100;
const top = ((point[1] - view.y) / view.h) * 100;
const lockState = `${locked ? 'locked' : known ? 'unlocked' : 'unknown'} ${this._isoLightFloors?.has(isoPlacement?.owner?.id ?? '') ? 'iso-floor-light' : ''}`;
+14 -9
View File
@@ -47,7 +47,7 @@ import {
spaceDisplayOf, DEFAULT_FILL_COLORS, customFillOf, DEFAULT_CUSTOM_FILL, type FillColors,
type FillColorEntry, RUN_TARGET_DOMAINS, DEFAULT_ROOM_COLOR, DEFAULT_ROOM_OPACITY, stageBgOf,
showRoomTooltipOf, volumetricViewOf, DEFAULT_TEMP_MIN, DEFAULT_TEMP_MAX, normalizeDeviceDisplay,
type DeviceDisplayMode, liveTextReference, liveTextToken, DECOR_TEXT_BASE,
type DeviceDisplayMode, liveTextReference, liveTextToken, DECOR_TEXT_BASE, fitView,
} from './logic';
import {
resolveSafeResize,
@@ -458,7 +458,7 @@ export interface HouseplanEditorHostPort {
_backdropDialog: { widthCm: number; heightCm: number; angle: string; } | null;
_backupExportDialog: { kind: "full" | "space"; planOnly: boolean; busy: boolean; error: string; } | null;
_backupImportDialog: { filename: string; size: number; token: string; preview: any; expectedConfigRev: number; expectedLayoutRev: number; duplicatePolicy: "skip" | "virtual"; confirmMissing: boolean; busy: boolean; error: string; } | null;
_baseVb: () => number[];
_baseVb: (projection?: 'flat' | 'iso') => number[];
_bdActive: boolean;
_bdBase: Rect | null;
_bdDrag: { kind: "move" | "scale" | "rotate"; pid: number; sx: number; sy: number; base: Rect; p0: { dx: number; dy: number; sx: number; sy: number; angle: number; }; fx: number; fy: number; sgx: number; sgy: number; rect0: DecorBox; before: SpaceGeometryState | null; moved: boolean; } | null;
@@ -601,7 +601,7 @@ export interface HouseplanEditorHostPort {
_lastValidStageSize: [number, number] | null;
_layout: DeviceLayout;
readonly _layoutRev: number;
_logicalViewCenter: (projection: "flat" | "iso") => { x: number; y: number; } | null;
_logicalViewCenter: () => { x: number; y: number; } | null;
_markerDialog: { devId?: string; uploadId?: string; name: string; binding: string; bindingMode: "virtual" | "ha"; bindingOpen: boolean; showEntities: boolean; bindingFilter: string; icon: string; autoIcon: string; display: DeviceDisplayMode; rippleColor: string; rippleSize: number; size: number; angle: number; tapAction: string; tapActionTouched: boolean; originalHasTapAction: boolean; originalTapAction: string | null | undefined; tapHintAnnouncement: string; toggleEntity: string; toggleEntityTouched: boolean; originalHasToggleEntity: boolean; originalToggleEntity: string | null | undefined; tapTarget: string; tapConfirm: boolean; runFilter: string; controls: string[]; controlsFilter: string; glowRadius: string; lightRole: "auto" | "always" | "never"; lightRoleTouched: boolean; originalHasIsLight: boolean; originalIsLight: boolean | null | undefined; lightEntity: string; lightEntityTouched: boolean; originalHasLightEntity: boolean; originalLightEntity: string | null | undefined; glowMode: "auto" | "color" | "fixed"; glowColor: string; glowBrightness: number; glowColorDrafted: boolean; glowBrightnessDrafted: boolean; glowTouched: boolean; originalHasGlowColor: boolean; originalGlowColor: { c: string; bri?: number | null; } | null | undefined; valueBadgeEnabled: boolean; valueBadgeSource: ValueBadgeSource | null; valueBadgePosition: ValueBadgePosition; valueBadgeTouched: boolean; originalHasValueBadge: boolean; originalValueBadge: MarkerValueBadge | null | undefined; valueSource: ValueBadgeSource | null; valueSourceTouched: boolean; originalHasValueSource: boolean; originalValueSource: ValueBadgeSource | null | undefined; useClimateTemp: boolean; model: string; link: string; description: string; pdfs: PdfRef[]; room: string; roomTouched: boolean; radar: RadarEditorDraft | null; radarEligible: boolean; radarTouched: boolean; radarRemove: boolean; hideFromPlan: boolean; busy: boolean; } | null;
_markerPreviewDevicesMemo: { base: readonly DevItem[]; preview: DevItem; devices: readonly DevItem[]; } | null;
_markerPreviewMemo: { key: string; device: DevItem | null; } | null;
@@ -989,16 +989,21 @@ public _setMode(mode: 'view' | 'plan' | 'devices' | 'decor', animate = true): vo
this.host._viewModeSnap = {
space: this.host._space,
zoom: this.host._zoom,
cx: v ? this.host._logicalViewCenter(previousProjection)?.x : undefined,
cy: v ? this.host._logicalViewCenter(previousProjection)?.y : undefined, w: previousProjection === 'flat' ? v?.w : undefined,
cx: v ? this.host._logicalViewCenter()?.x : undefined,
cy: v ? this.host._logicalViewCenter()?.y : undefined, w: previousProjection === 'flat' ? v?.w : undefined,
};
if (previousProjection === 'iso') {
const logical = this.host._logicalViewCenter('iso');
targetCenterX = logical?.x;
targetCenterY = logical?.y;
// #713: the 2.5D floor is the Flat plane. Enter the Flat editor with
// the same screen scale and centre, exactly as from a Flat View
// showing this picture: only the scalar zoom is re-read.
if (v) {
targetZoom = fitView(this.host._baseVb('flat'), v.w / v.h).w / v.w;
targetCenterX = v.x + v.w / 2;
targetCenterY = v.y + v.h / 2;
}
this.host._view = null;
this.host._mode = mode;
this.host._applyView(this.host._zoom, logical?.x, logical?.y);
this.host._applyView(targetZoom, targetCenterX, targetCenterY);
}
if (baseChanges) {
targetZoom = 1;
+3 -2
View File
@@ -163,7 +163,7 @@ export interface IsoOpeningSurface {
kind: IsoOpeningSurfaceKind;
material: IsoOpeningMaterial;
d: string;
/** Orthographic camera depth of the face centre, used for local occlusion. */
/** Camera depth of the face centre along the projector, used for local occlusion. */
cameraDepth: number;
depth: number;
jamb?: 0 | 1;
@@ -373,10 +373,11 @@ function projectedSurface(
const projected = points.map((value) => projectPlanPoint(value.point, value.z, camera));
const rotation = camera.rotDeg * Math.PI / 180;
const tilt = camera.tiltDeg * Math.PI / 180;
// #713: depth along the oblique projector (s, 1) of `y' = y − z·s`.
const cameraDepth = points.reduce((sum, value) => sum
+ (value.point[0] * Math.sin(rotation) + value.point[1] * Math.cos(rotation))
* Math.sin(tilt)
+ value.z * Math.cos(tilt), 0) / points.length;
+ value.z, 0) / points.length;
return Object.freeze({
kind,
material,
+6 -6
View File
@@ -501,10 +501,10 @@ export function resolveIsoOverlayPlacement(input: IsoOverlayPlacementInput): Iso
};
}
// Stage 4 keeps the canonical floor anchor and collision footprint, but the
// screen-facing content sits just above the floor instead of on a tall
// wall-height mast. The wall height is still validated because the same
// placement consumes wall silhouettes built from that physical height.
// The canonical floor anchor and collision footprint stay; the screen-facing
// content stands `visualOffset` above the floor (#713: the wall top for tiles
// and lock badges, 0 for room names). The wall height is still validated
// because this resolver can consume wall silhouettes built from it.
const raisedHeight = visualOffset;
const raisedScene = projectPlanPoint(floorAnchor, raisedHeight, camera);
const owner = input.ownerAlreadyResolved
@@ -1135,9 +1135,9 @@ export function resolveIsoOverlayCollisions(
a.placement.nudgeDistanceCss - b.placement.nudgeDistanceCss
|| isoOverlayCollisionKey(a.kind, a.id).localeCompare(isoOverlayCollisionKey(b.kind, b.id)));
const rot = camera.rotDeg * Math.PI / 180;
const tilt = camera.tiltDeg * Math.PI / 180;
// #713: the floor is not foreshortened, both axes share one inverse scale.
const inverseX = 1 / camera.xyScale;
const inverseY = 1 / (camera.xyScale * Math.cos(tilt));
const inverseY = inverseX;
const cosRot = Math.cos(rot), sinRot = Math.sin(rot);
const cellRange = (bounds: Bounds): readonly [number, number, number, number] => [
+8 -5
View File
@@ -37,7 +37,7 @@ export const ISO_RAISED_OVERLAY_HEIGHT = ISO_OVERLAY_VISUAL_OFFSET;
function finiteCamera(camera: IsoCamera): boolean {
return [camera.rotDeg, camera.tiltDeg, camera.xyScale, camera.zScale,
camera.origin[0], camera.origin[1]].every(Number.isFinite)
&& Math.abs(camera.xyScale) > 1e-12 && Math.abs(Math.cos(camera.tiltDeg * Math.PI / 180)) > 1e-12;
&& Math.abs(camera.xyScale) > 1e-12;
}
export function projectPlanPoint(
@@ -54,9 +54,8 @@ export function unprojectFloorPoint(
if (!finiteCamera(camera) || !Number.isFinite(point[0]) || !Number.isFinite(point[1]))
throw new Error('invalid isometric projection input');
const rot = camera.rotDeg * Math.PI / 180;
const tilt = camera.tiltDeg * Math.PI / 180;
const rx = (point[0] - camera.origin[0]) / camera.xyScale;
const ry = (point[1] - camera.origin[1]) / (camera.xyScale * Math.cos(tilt));
const ry = (point[1] - camera.origin[1]) / camera.xyScale;
return [
camera.origin[0] + rx * Math.cos(rot) + ry * Math.sin(rot),
camera.origin[1] - rx * Math.sin(rot) + ry * Math.cos(rot),
@@ -66,6 +65,10 @@ export function unprojectFloorPoint(
/**
* Canonical plan-plane affine matrix at one logical height. Floor SVG, raised
* plates and individual point projection all use this exact transform.
*
* #713: a vertical oblique projection. The floor keeps the Flat plan (no
* foreshortening), a height moves a point straight up the screen by
* `z × sin(tilt)`: the on-screen wall height of the former 20° camera.
*/
export function isoPlaneMatrix(
zUnits = 0, camera: IsoCamera = ISO_CAMERA,
@@ -76,8 +79,8 @@ export function isoPlaneMatrix(
const tilt = camera.tiltDeg * Math.PI / 180;
const a = camera.xyScale * Math.cos(rot);
const c = -camera.xyScale * Math.sin(rot);
const b = camera.xyScale * Math.sin(rot) * Math.cos(tilt);
const d = camera.xyScale * Math.cos(rot) * Math.cos(tilt);
const b = camera.xyScale * Math.sin(rot);
const d = camera.xyScale * Math.cos(rot);
const e = camera.origin[0] - a * camera.origin[0] - c * camera.origin[1];
const f = camera.origin[1] - b * camera.origin[0] - d * camera.origin[1]
- zUnits * camera.zScale * Math.sin(tilt);
+20 -76
View File
@@ -11,7 +11,7 @@ export { computeIsoSunBeams, renderIsoSunWash } from './iso-sun';
import { nothing, svg, type TemplateResult } from 'lit';
import { clampScale, islandsOf, roomPoly, type SpaceDisplay } from './logic';
import {
ISO_CAMERA, ISO_FLOOR_EDGE_HEIGHT, ISO_OVERLAY_VISUAL_OFFSET,
ISO_CAMERA, ISO_FLOOR_EDGE_HEIGHT,
ISO_RAISED_OVERLAY_HEIGHT, ISO_WALL_HEIGHT,
projectPlanPoint, projectedFrame, type PlanPoint, type ScenePoint,
} from './iso-projection';
@@ -26,8 +26,8 @@ import {
type IsoOpeningGeometryPolicy, type IsoOpeningSurface,
} from './iso-openings';
import {
ISO_OVERLAY_MAX_NUDGE_CSS_PX, isoOverlayCollisionKey, isoRoomSafePoint,
resolveIsoOverlayOwner, resolveIsoOverlayPlacement, resolveIsoOverlayRigidGroups,
ISO_OVERLAY_MAX_NUDGE_CSS_PX, isoRoomSafePoint,
resolveIsoOverlayOwner, resolveIsoOverlayPlacement,
type IsoOverlayPlacement, type IsoOverlayRoom, type IsoRaisedOverlayKind,
type IsoWallSilhouette,
} from './iso-overlays';
@@ -510,7 +510,7 @@ export function createIsoStructuralSource(
wallHeight,
raisedHeight,
floorEdgeHeight,
algorithm: 5,
algorithm: 6, // #713: vertical oblique projection
})}`;
return {
key,
@@ -689,9 +689,9 @@ export interface IsoOverlayFitEnvelopeInput {
}
/**
* Stable fit envelope for screen-facing content. It starts from unnudged
* geometry, then reserves the complete bounded CSS nudge at the fitted scale;
* current zoom can therefore never feed back into the canonical home frame.
* Stable fit envelope for screen-facing content: the structure plus the tiles'
* own visual extent. Since #713 nothing moves at runtime, so no nudge budget is
* reserved and current zoom can never feed back into the canonical home frame.
*/
export function resolveIsoOverlayFitEnvelope(
input: IsoOverlayFitEnvelopeInput,
@@ -699,36 +699,11 @@ export function resolveIsoOverlayFitEnvelope(
const entries = selectedOverlayEntries(input.entries, input.ownerId);
const overlay = rectFromPoints(entries.flatMap((entry) => overlayEntryPoints(entry, false)));
const base = overlay ? unionRect(input.baseBounds, overlay) : input.baseBounds;
const initial = input.targetView(base);
if (!initial || !(initial.w > 0) || !(initial.h > 0)
|| ![initial.x, initial.y, initial.w, initial.h].every(Number.isFinite)) return null;
const stage = input.stageSize;
if (!overlay || !stage || !(stage.width > 0) || !(stage.height > 0))
return { bounds: base, view: initial };
const scaleOf = (view: Rect) => Math.max(view.w / stage.width, view.h / stage.height);
const candidate = (scale: number): { bounds: Rect; view: Rect } | null => {
const pad = ISO_OVERLAY_MAX_NUDGE_CSS_PX * scale;
const padded = { x: overlay.x - pad, y: overlay.y - pad,
w: overlay.w + pad * 2, h: overlay.h + pad * 2 };
const bounds = unionRect(input.baseBounds, padded);
const view = input.targetView(bounds);
return view && [view.x, view.y, view.w, view.h].every(Number.isFinite)
&& view.w > 0 && view.h > 0 ? { bounds, view } : null;
};
let low = 0, high = Math.max(scaleOf(initial), Number.EPSILON), resolved = candidate(high);
for (let iteration = 0; resolved && scaleOf(resolved.view) > high * (1 + 1e-10)
&& iteration < 24; iteration++) {
low = high; high *= 2; resolved = candidate(high);
}
if (!resolved) return null;
for (let iteration = 0; iteration < 40; iteration++) {
const middle = (low + high) / 2;
const next = candidate(middle);
if (!next) return null;
if (scaleOf(next.view) <= middle) { high = middle; resolved = next; }
else low = middle;
}
return resolved;
// #713: markers no longer move at runtime, so fit reserves no nudge budget:
// the frame is the structure plus the markers' own visual extent.
const view = input.targetView(base);
return view && view.w > 0 && view.h > 0
&& [view.x, view.y, view.w, view.h].every(Number.isFinite) ? { bounds: base, view } : null;
}
export interface IsoOverlaySceneInput {
@@ -826,8 +801,10 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
: Math.max(layoutView.w, layoutView.h) / 1000;
const roomRows = isoOverlayRooms(input.space);
const rooms = roomRows.map((row) => row.overlayRoom);
// #713: device tiles and lock badges stand on the wall-top plane, one common
// straight-up shift for every marker; room names stay on the floor where
// Flat puts them. No placement search: markers may meet walls and each other.
const wallHeight = gridVisualUnits(ISO_WALL_HEIGHT, input.cellCm);
const visualOffset = gridVisualUnits(ISO_OVERLAY_VISUAL_OFFSET, input.cellCm);
const devices = new Map<string, IsoOverlayPlacement>();
const roomPlacements = new Map<RoomCfg, IsoOverlayPlacement>();
const locks = new Map<string, IsoOverlayPlacement>();
@@ -851,6 +828,7 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
footprintHalfSize: PlanPoint,
preferredRoomId?: string | null,
): IsoOverlayPlacement => {
const visualOffset = kind === 'room-label' ? 0 : wallHeight;
const collisionMode = input.resolveCollisions === false ? 'fit' : 'live';
const cacheKey = `${collisionMode}\u0000${kind}\u0000${id}`;
const shapeSignature = [floorAnchor[0], floorAnchor[1],
@@ -897,7 +875,7 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
? cached.value.placement.nudgeDistanceCss * cached.value.unitsPerPixel / unitsPerPixel
: undefined,
showBorders: true,
wallSilhouettes: input.resolveCollisions === false ? [] : input.wallSilhouettes,
wallSilhouettes: [],
wallGeometryValidated: true,
footprintHalfSize,
wallHeight,
@@ -1022,42 +1000,8 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
return refreshed;
}
const previousEntries = new Map(previous?.entries.map((entry) => [
`${entry.kind}\u0000${entry.id}`, entry,
]) || []);
const collision = mode === 'live' ? resolveIsoOverlayRigidGroups({
items: entries.flatMap((entry) => {
if (entry.kind === 'room-label') return [];
const before = previousEntries.get(`${entry.kind}\u0000${entry.id}`);
const sameShape = !!before
&& layoutHalfSizeOf(before)[0] === layoutHalfSizeOf(entry)[0]
&& layoutHalfSizeOf(before)[1] === layoutHalfSizeOf(entry)[1]
&& before.placement.floorAnchor[0] === entry.placement.floorAnchor[0]
&& before.placement.floorAnchor[1] === entry.placement.floorAnchor[1]
&& (before.placement.owner?.id || '') === (entry.placement.owner?.id || '');
return [{
id: entry.id,
kind: entry.kind,
placement: entry.placement,
screenHalfSize: layoutHalfSizeOf(entry),
...(sameShape ? { nudgeHintCss: [
before!.placement.nudgeScene[0] / unitsPerPixel,
before!.placement.nudgeScene[1] / unitsPerPixel,
] as ScenePoint } : {}),
}];
}),
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 };
}));
// #713: no placement search, so the placed entries are final.
const resolvedEntries = Object.freeze(entries);
const resolvedDevices = new Map<string, IsoOverlayPlacement>();
const resolvedLocks = new Map<string, IsoOverlayPlacement>();
for (const entry of resolvedEntries) {
@@ -1070,7 +1014,7 @@ export function buildIsoOverlayRenderScene(input: IsoOverlaySceneInput): IsoOver
locks: resolvedLocks,
entries: resolvedEntries,
collisionSignature,
residualPairs: Object.freeze([...collision.residualPairs]),
residualPairs: Object.freeze([]),
};
if (previous && sameOverlayEntries(previous.entries, scene.entries)
&& samePlacementMap(previous.devices, scene.devices)
+3 -4
View File
@@ -64,7 +64,6 @@ interface LiveEditorHost {
/** #521: guides for the gesture in flight; `nothing` while the runtime loads. */
_renderAlignGuides: () => unknown;
_livePos: (device: { id: string }) => { x: number; y: number };
_scenePoint: (point: number[]) => number[];
_renderProjection: string;
_spaceModel: () => SpaceModel | null;
_spaceWalls: WallEntry[];
@@ -309,11 +308,11 @@ const paintDevice = (host: LiveEditorHost, root: ParentNode): void => {
const element = [...root.querySelectorAll<HTMLElement>('[data-hp="device"]')]
.find((candidate) => candidate.dataset.id === drag.id);
if (!device || !element) return;
// #713: the 2.5D floor is the Flat plane, and editors are Flat anyway.
const position = host._livePos(device);
const point = host._scenePoint([position.x, position.y]);
const view = host._viewOr(host._baseVb());
element.style.left = `${((point[0] - view.x) / view.w) * 100}%`;
element.style.top = `${((point[1] - view.y) / view.h) * 100}%`;
element.style.left = `${((position.x - view.x) / view.w) * 100}%`;
element.style.top = `${((position.y - view.y) / view.h) * 100}%`;
};
const makeDecorShapeTransparent = (