mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 13:48:57 +00:00
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:
+43
-39
@@ -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' : ''}`;
|
||||
|
||||
@@ -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
@@ -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
@@ -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] => [
|
||||
|
||||
@@ -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
@@ -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
@@ -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 = (
|
||||
|
||||
Reference in New Issue
Block a user