fix: make plan visuals grid-scale invariant

Issue: #239
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-08-22 13:45:26 +03:00
parent a4e8dd7ba7
commit 0d91c1e18e
31 changed files with 2017 additions and 963 deletions
+33
View File
@@ -0,0 +1,33 @@
/** Historical plan-cell size whose raw SVG constants define the visual baseline. */
export const GRID_VISUAL_REFERENCE_CELL_CM = 5;
/** Canonical centimetres in one imperial grid cell (one inch). */
export const GRID_IMPERIAL_CELL_CM = 2.54;
/** Scale a legacy SVG visual unit without changing physical or screen-space sizes. */
export function gridVisualScale(cellCm: unknown): number {
const value = typeof cellCm === 'number' ? cellCm : NaN;
if (!Number.isFinite(value) || value <= 0) return 1;
if (value === GRID_VISUAL_REFERENCE_CELL_CM) return 1;
return GRID_VISUAL_REFERENCE_CELL_CM / value;
}
export function gridVisualUnits(baseUnits: number, cellCm: unknown): number {
return baseUnits * gridVisualScale(cellCm);
}
/** Default only for newly-created spaces. Read compatibility keeps its own 5 cm fallback. */
export function newSpaceCellCm(imperial: boolean): number {
return imperial ? GRID_IMPERIAL_CELL_CM : 1;
}
/** Human-sized field projection; the canonical draft remains separate and lossless. */
export function gridCellFieldValue(cellCm: number, imperial: boolean): string {
if (!imperial) return String(cellCm);
const inches = cellCm / GRID_IMPERIAL_CELL_CM;
return String(Math.round(inches * 1_000_000) / 1_000_000);
}
export function gridCellFieldToCm(value: number, imperial: boolean): number {
return imperial ? value * GRID_IMPERIAL_CELL_CM : value;
}
+80 -35
View File
@@ -271,6 +271,9 @@ import {
type OpeningDimension, type OpeningDimensionContext,
} from './opening-dimensions';
import { safeStoredColor } from './color';
import {
gridCellFieldToCm, gridCellFieldValue, gridVisualScale, gridVisualUnits, newSpaceCellCm,
} from './grid-scale';
import {
applySpaceOrder, canStartTabDrag, markersNeedingPlacement, passedDragThreshold,
reorderSpaceIds,
@@ -1849,6 +1852,9 @@ class HouseplanCard extends LitElement {
labelLqi: boolean;
labelLight: boolean;
cellCm: number; // real-world cm represented by one grid cell
/** Projected text is separate so an untouched imperial edit stays lossless. */
cellCmInput?: string;
cellCmTouched?: boolean;
busy: boolean;
} | null = null;
private _keyHandler = (e: KeyboardEvent) => this._onKey(e);
@@ -5171,12 +5177,14 @@ class HouseplanCard extends LitElement {
flipH: !!opening.flip_h,
flipV: !!opening.flip_v,
}));
const wallHeight = gridVisualUnits(ISO_WALL_HEIGHT, this._cellCm);
const floorEdgeHeight = gridVisualUnits(ISO_FLOOR_EDGE_HEIGHT, this._cellCm);
const key = `${space.id}|${isoGeometryFingerprint({
rooms: space.rooms, walls, openCuts, openings,
partitions: space.partitions, roomDrafts: space.room_drafts, columns: space.wall_columns,
cellCm: this._cellCm, gridPitch: this._gridPitch, wallKeyPitch: this._wallKeyPitch,
camera: ISO_CAMERA, wallHeight: ISO_WALL_HEIGHT,
floorEdgeHeight: ISO_FLOOR_EDGE_HEIGHT, algorithm: 3,
camera: ISO_CAMERA, wallHeight,
floorEdgeHeight, algorithm: 3,
})}`;
return {
key,
@@ -5217,7 +5225,7 @@ class HouseplanCard extends LitElement {
flip_v: faceFlipV,
})
: { ox: 0, oy: 0, cm: 0, side: -1 as -1 | 1 };
return buildIsoOpeningBasis({ ...opening, face });
return buildIsoOpeningBasis({ ...opening, face }, wallHeight);
});
return { walls: united?.geom || [], floor, openings: Object.freeze(openingBases) };
},
@@ -5242,16 +5250,18 @@ class HouseplanCard extends LitElement {
if (cached) return { key: source.key, ...cached };
const flat = this._frameOf().rect;
const structural = source.build();
const wallHeight = gridVisualUnits(ISO_WALL_HEIGHT, this._cellCm);
const floorEdgeHeight = gridVisualUnits(ISO_FLOOR_EDGE_HEIGHT, this._cellCm);
const openingFrame = isoOpeningBounds(structural.openings);
const structuralFrame = openingFrame ? unionRect(flat, openingFrame) : flat;
const frame = projectedFrame({
rect: structuralFrame,
wallHeight: ISO_WALL_HEIGHT,
openingHeight: ISO_WALL_HEIGHT,
floorDepth: ISO_FLOOR_EDGE_HEIGHT,
wallHeight,
openingHeight: wallHeight,
floorDepth: floorEdgeHeight,
});
const geometry = buildIsoWallGeometry(structural.walls);
const floor = buildIsoFloorGeometry(structural.floor, ISO_FLOOR_EDGE_HEIGHT);
const geometry = buildIsoWallGeometry(structural.walls, ISO_CAMERA, wallHeight);
const floor = buildIsoFloorGeometry(structural.floor, floorEdgeHeight);
const value = { geometry, floor, openings: structural.openings, frame };
lruWrite(this._isoGeometryCache, source.key, value, 8);
return { key: source.key, ...value };
@@ -5293,7 +5303,10 @@ class HouseplanCard extends LitElement {
// otherwise merely hiding volume reframes every live floor pixel.
if (!this._spaceDisplayForRender().showBorders) {
const flat = this._frameOf().rect;
const frame = projectedFrame({ rect: flat, wallHeight: ISO_WALL_HEIGHT });
const frame = projectedFrame({
rect: flat,
wallHeight: gridVisualUnits(ISO_WALL_HEIGHT, this._cellCm),
});
return [frame.x, frame.y, frame.w, frame.h];
}
const frame = this._isoScene()?.frame ?? this._frameOf().rect;
@@ -11622,7 +11635,8 @@ class HouseplanCard extends LitElement {
<path class="wallbody ${solid ? 'solid' : ''}"
data-hp="wall" data-id="union" data-kind="union"
d="${united.d}" fill="${solid ? 'none' : 'url(#hp-wall-hatch)'}" fill-rule=${united.fillRule}
stroke="${stroke}" stroke-width="0.6" pointer-events="none"></path>
stroke="${stroke}" stroke-width="${gridVisualUnits(0.6, this._cellCm)}"
pointer-events="none"></path>
</g>` as unknown as TemplateResult;
}
@@ -11656,6 +11670,7 @@ class HouseplanCard extends LitElement {
layers: IsoDecorationLayers,
root: 'underlay' | 'shadows' | 'walls',
): TemplateResult {
const visualScale = gridVisualScale(this._cellCm);
return svg`<defs>
${root === 'walls' && layers.materialNuance ? svg`
<linearGradient id="hp-iso-wall-side" x1="0" y1="0" x2="0" y2="1">
@@ -11666,14 +11681,14 @@ class HouseplanCard extends LitElement {
</linearGradient>` : nothing}
${root === 'underlay' && layers.shadows ? svg`
<filter id="hp-iso-ambient-shadow" x="-12%" y="-12%" width="124%" height="130%">
<feGaussianBlur stdDeviation="7"></feGaussianBlur>
<feGaussianBlur stdDeviation="${7 * visualScale}"></feGaussianBlur>
</filter>` : nothing}
${root === 'shadows' && layers.shadows ? svg`
<filter id="hp-iso-contact-shadow" x="-8%" y="-20%" width="116%" height="140%">
<feGaussianBlur stdDeviation="2.5"></feGaussianBlur>
<feGaussianBlur stdDeviation="${2.5 * visualScale}"></feGaussianBlur>
</filter>
<filter id="hp-iso-leaf-shadow" x="-12%" y="-30%" width="124%" height="160%">
<feGaussianBlur stdDeviation="2"></feGaussianBlur>
<feGaussianBlur stdDeviation="${2 * visualScale}"></feGaussianBlur>
</filter>` : nothing}
</defs>` as unknown as TemplateResult;
}
@@ -11685,7 +11700,8 @@ class HouseplanCard extends LitElement {
return svg`<g class="iso-underlay" data-hp="iso-underlay" aria-hidden="true" pointer-events="none">
${this._renderIsoDefs(layers, 'underlay')}
${layers.shadows && floor.footprintPath
? svg`<path class="iso-ambient-shadow" d=${floor.footprintPath} transform="translate(0 8)"></path>`
? svg`<path class="iso-ambient-shadow" d=${floor.footprintPath}
transform="translate(0 ${gridVisualUnits(8, this._cellCm)})"></path>`
: nothing}
<g class="iso-floor-edge">${floor.sides.map((face) =>
svg`<path class="iso-floor-side" d=${face.d} data-component=${face.component}
@@ -11915,7 +11931,7 @@ class HouseplanCard extends LitElement {
const rad = o.angle * Math.PI / 180;
const dx = raw[0] - o.rx, dy = raw[1] - o.ry;
const localX = dx * Math.cos(rad) + dy * Math.sin(rad);
if (Math.abs(localX) > o.rlen / 2 + 12) return [];
if (Math.abs(localX) > o.rlen / 2 + gridVisualUnits(12, this._cellCm)) return [];
return [{ o, localY: -dx * Math.sin(rad) + dy * Math.cos(rad) }];
});
if (!roughHits.length) return null;
@@ -14066,6 +14082,10 @@ class HouseplanCard extends LitElement {
labelTemp: disp.labelTemp, labelHum: disp.labelHum,
labelLqi: disp.labelLqi, labelLight: disp.labelLight,
cellCm: Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5,
cellCmInput: gridCellFieldValue(
Number(sp.cell_cm) > 0 ? Number(sp.cell_cm) : 5, this._imperial,
),
cellCmTouched: false,
busy: false,
};
} else {
@@ -14085,7 +14105,9 @@ class HouseplanCard extends LitElement {
showLqi: this._config?.show_signal ?? true,
cardFontScale: 1,
labelTemp: false, labelHum: false, labelLqi: false, labelLight: false,
cellCm: 5,
cellCm: newSpaceCellCm(this._imperial),
cellCmInput: gridCellFieldValue(newSpaceCellCm(this._imperial), this._imperial),
cellCmTouched: false,
busy: false,
};
}
@@ -14456,7 +14478,9 @@ class HouseplanCard extends LitElement {
showLqi: this._config?.show_signal ?? true,
cardFontScale: 1,
labelTemp: false, labelHum: false, labelLqi: false, labelLight: false,
cellCm: 5,
cellCm: newSpaceCellCm(this._imperial),
cellCmInput: gridCellFieldValue(newSpaceCellCm(this._imperial), this._imperial),
cellCmTouched: false,
busy: false,
};
}
@@ -16468,7 +16492,7 @@ class HouseplanCard extends LitElement {
<div class="stage ${this._markup ? 'markup tool-' + this._tool + (this._tool === 'split' && !this._splitSel ? ' pickstage' : '') + (this._tool === 'boundary' ? this._boundaryStageClass : '') + (this._tool === 'wallthick' && this._wallThickHover ? ' wallhot' : '') : ''} ${this._mode === 'decor' ? 'dtool-' + this._decorTool : ''} ${space.bg ? '' : 'noplan'} mode-${this._mode}${this._bdMovable ? ' bdgrab' : ''}${this._bdDrag ? ' bdgrabbing' : ''}${dayCycle ? ` daycycle phase-${dayCycle.phase}` : ''}${this._booting ? ' hpboot' : ''}${this._bootSoft ? ' hpsettle' : ''}${this._modeTransitionBusy ? ' mode-transition' : ''}"
?inert=${this._modeTransitionBusy}
style="height:${modeVisual ? `${modeVisual.stageHeight}px` : this._kiosk ? '100dvh' : `calc(100dvh - ${this._hdrH}px)`}${transitionStageBg ? `;background:${transitionStageBg}` : ''};--wall-fill:${this._fillColors.wall_fill.c};--wall-fill-op:${this._fillColors.wall_fill.a};--hp-mode-architecture-opacity:${modeVisual ? modeVisual.architectureOpacity : this._mode === 'decor' ? 0.35 : 1};--hp-mode-view-weight:${modeVisual?.viewWeight ?? (this._mode === 'view' ? 1 : 0)};--hp-mode-editor-weight:${modeVisual?.editorWeight ?? (this._mode === 'view' ? 0 : 1)}${modeVisual ? `;--hp-mode-paper:${modeVisual.paperColor}` : ''}${dayCycle ? `;${dayCycleStageVars(dayCycle)}` : ''}"
style="height:${modeVisual ? `${modeVisual.stageHeight}px` : this._kiosk ? '100dvh' : `calc(100dvh - ${this._hdrH}px)`}${transitionStageBg ? `;background:${transitionStageBg}` : ''};--hp-cell-visual-scale:${gridVisualScale(this._cellCm)};--wall-fill:${this._fillColors.wall_fill.c};--wall-fill-op:${this._fillColors.wall_fill.a};--hp-mode-architecture-opacity:${modeVisual ? modeVisual.architectureOpacity : this._mode === 'decor' ? 0.35 : 1};--hp-mode-view-weight:${modeVisual?.viewWeight ?? (this._mode === 'view' ? 1 : 0)};--hp-mode-editor-weight:${modeVisual?.editorWeight ?? (this._mode === 'view' ? 0 : 1)}${modeVisual ? `;--hp-mode-paper:${modeVisual.paperColor}` : ''}${dayCycle ? `;${dayCycleStageVars(dayCycle)}` : ''}"
@click=${(e: MouseEvent) => this._markupClick(e)}
@wheel=${(e: WheelEvent) => this._onWheel(e)}
@pointerdown=${(e: PointerEvent) => { this._notePointer(e); this._stagePointerDown(e); }}
@@ -18300,7 +18324,8 @@ class HouseplanCard extends LitElement {
? [gd.at, gd.from[1], gd.at, pt[1] + Math.sign(pt[1] - gd.from[1]) * over]
: [gd.from[0], gd.at, pt[0] + Math.sign(pt[0] - gd.from[0]) * over, gd.at];
return svg`<line class="alignline" x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"></line>
<circle class="aligndot" cx="${gd.from[0]}" cy="${gd.from[1]}" r="${g * 0.18}"></circle>`;
<circle class="aligndot" cx="${gd.from[0]}" cy="${gd.from[1]}"
r="${gridVisualUnits(g * 0.18, this._cellCm)}"></circle>`;
})}
</g>` as unknown as TemplateResult;
}
@@ -18310,7 +18335,7 @@ class HouseplanCard extends LitElement {
* about the wall stroke (2.5) × 6 to each side. */
private _renderOpeningCenterTick(gd: { x: number; y: number; angle: number }): TemplateResult {
const rad = ((gd.angle + 90) * Math.PI) / 180;
const half = 2.5 * 6;
const half = gridVisualUnits(2.5 * 6, this._cellCm);
return svg`<line class="alignline opcentertick"
x1="${gd.x - Math.cos(rad) * half}" y1="${gd.y - Math.sin(rad) * half}"
x2="${gd.x + Math.cos(rad) * half}" y2="${gd.y + Math.sin(rad) * half}"></line>` as unknown as TemplateResult;
@@ -18402,7 +18427,9 @@ class HouseplanCard extends LitElement {
face: candidate.face,
};
const passageGeometry = candidate.type === 'passage'
? passagePlacementPreviewGeometry(candidate, this._gridPitch)
? passagePlacementPreviewGeometry(
candidate, gridVisualUnits(this._gridPitch, this._cellCm),
)
: null;
return svg`<g class="opening-preview" data-kind=${candidate.type}
aria-hidden="true" pointer-events="none"
@@ -18418,7 +18445,8 @@ class HouseplanCard extends LitElement {
` : renderOpeningVisibleGeometry(visibleSpec)}
</g>
<circle class="opening-preview-dot opghost-dot" aria-hidden="true" pointer-events="none"
cx=${candidate.x} cy=${candidate.y} r=${this._gridPitch * 0.18}></circle>` as unknown as TemplateResult;
cx=${candidate.x} cy=${candidate.y}
r=${gridVisualUnits(this._gridPitch * 0.18, this._cellCm)}></circle>` as unknown as TemplateResult;
}
private _renderOpenings(disp: SpaceDisplay): TemplateResult {
@@ -18436,7 +18464,7 @@ class HouseplanCard extends LitElement {
aria-label=${this._t('opening.partition_orphan')}
transform="translate(${o.rx} ${o.ry})"
@click=${(event: MouseEvent) => { event.stopPropagation(); this._editOpening(o); }}>
<circle r=${this._gridPitch * 0.55}></circle>
<circle r=${gridVisualUnits(this._gridPitch * 0.55, this._cellCm)}></circle>
<text text-anchor="middle" dominant-baseline="central">!</text>
</g>`;
const amt = this._openingAmt(o);
@@ -18466,11 +18494,16 @@ class HouseplanCard extends LitElement {
face,
};
const { half, outlineHalf, hitHalf } = openingVisibleMetrics(visibleSpec);
const outlinePad = gridVisualUnits(10, this._cellCm);
const hitPad = gridVisualUnits(12, this._cellCm);
return svg`<g class="opening" data-hp="opening" data-id="${o.id}" data-kind="${o.type}"
transform="translate(${o.rx} ${o.ry}) rotate(${o.angle})">
${renderOpeningVisibleGeometry(visibleSpec)}
<rect class="op-outline" x="${-half - 10}" y="${-outlineHalf}" width="${o.rlen + 20}" height="${outlineHalf * 2}" rx="6"></rect>
<rect class="op-hit" x="${-half - 12}" y="${-hitHalf}" width="${o.rlen + 24}" height="${hitHalf * 2}"
<rect class="op-outline" x="${-half - outlinePad}" y="${-outlineHalf}"
width="${o.rlen + outlinePad * 2}" height="${outlineHalf * 2}"
rx="${gridVisualUnits(6, this._cellCm)}"></rect>
<rect class="op-hit" x="${-half - hitPad}" y="${-hitHalf}"
width="${o.rlen + hitPad * 2}" height="${hitHalf * 2}"
@click=${(e: MouseEvent) => this._opClick(e, o)}
@pointerdown=${(e: PointerEvent) => this._opPointerDown(e, o)}
@pointermove=${(e: PointerEvent) => this._opPointerMove(e, o)}
@@ -18502,7 +18535,8 @@ class HouseplanCard extends LitElement {
const gateFace = o.type === 'gate'
? this._openingFace(o, openingWallIndex, !o.flip_v)
: null;
const off = gateFace ? -16 * gateFace.side : 16 * (o.flip_v ? -1 : 1);
const lockOffset = gridVisualUnits(16, this._cellCm);
const off = gateFace ? -lockOffset * gateFace.side : lockOffset * (o.flip_v ? -1 : 1);
const px = o.rx + Math.cos(rad) * off;
const py = o.ry + Math.sin(rad) * off;
const point = this._scenePoint([px, py]);
@@ -19081,14 +19115,16 @@ class HouseplanCard extends LitElement {
x2="${this._cursorPt[0]}" y2="${this._cursorPt[1]}" aria-hidden="true"></line>
${!this._activePlanSnapCandidate && !this._activePlanSnapConflicts.length
? svg`<circle class="active-vertex" cx="${this._cursorPt[0]}" cy="${this._cursorPt[1]}"
r="${g * 0.22}" aria-hidden="true"></circle>` : nothing}`
r="${gridVisualUnits(g * 0.22, this._cellCm)}" aria-hidden="true"></circle>` : nothing}`
: nothing}
${path.map((p, i) => svg`<circle class="vertex ${i === 0 ? 'first' : ''}" cx="${p[0]}" cy="${p[1]}" r="${g * 0.22}"></circle>`)}
${path.map((p, i) => svg`<circle class="vertex ${i === 0 ? 'first' : ''}"
cx="${p[0]}" cy="${p[1]}" r="${gridVisualUnits(g * 0.22, this._cellCm)}"></circle>`)}
${this._tool === 'split' && this._splitSel?.pts?.length
? svg`${this._splitSel.pts.length > 1
? svg`<polyline class="pathline" points="${this._splitSel.pts.map((p) => p.join(',')).join(' ')}"></polyline>`
: nothing}
${this._splitSel.pts.map((p, i) => svg`<circle class="vertex ${i === 0 ? 'first' : ''}" cx="${p[0]}" cy="${p[1]}" r="${g * 0.22}"></circle>`)}
${this._splitSel.pts.map((p, i) => svg`<circle class="vertex ${i === 0 ? 'first' : ''}"
cx="${p[0]}" cy="${p[1]}" r="${gridVisualUnits(g * 0.22, this._cellCm)}"></circle>`)}
${this._cursorPt
? svg`<line class="preview" x1="${this._splitSel.pts[this._splitSel.pts.length - 1][0]}" y1="${this._splitSel.pts[this._splitSel.pts.length - 1][1]}"
x2="${this._cursorPt[0]}" y2="${this._cursorPt[1]}"></line>`
@@ -20589,16 +20625,25 @@ class HouseplanCard extends LitElement {
<label>${this._t('space.scale_label')}</label>
<div class="colorrow">
<input class="namein tempin" type="number" min=${CELL_CM_MIN} max=${CELL_CM_MAX}
step="0.1" .value=${String(d.cellCm)}
<input class="namein tempin" type="number"
min=${gridCellFieldValue(CELL_CM_MIN, this._imperial)}
max=${gridCellFieldValue(CELL_CM_MAX, this._imperial)}
step="0.1" .value=${d.cellCmInput ?? gridCellFieldValue(d.cellCm, this._imperial)}
@input=${(e: Event) => {
const n = strictNumber((e.target as HTMLInputElement).value);
const raw = (e.target as HTMLInputElement).value;
const n = strictNumber(raw);
const canonical = n == null ? null : gridCellFieldToCm(n, this._imperial);
this._spaceDialog = {
...d, cellCm: n != null && n > 0
? Math.max(CELL_CM_MIN, Math.min(CELL_CM_MAX, n)) : d.cellCm,
...d,
cellCmInput: raw,
cellCmTouched: true,
cellCm: canonical != null && canonical > 0
? Math.max(CELL_CM_MIN, Math.min(CELL_CM_MAX, canonical)) : d.cellCm,
};
}} />
<span class="opl">${this._t('space.scale_unit')}</span>
<span class="opl">${this._t(
this._imperial ? 'space.scale_unit_imperial' : 'space.scale_unit',
)}</span>
</div>
<label class="dispsection">${this._t('space.display_section')}</label>
+1
View File
@@ -372,6 +372,7 @@
"btn.skip": "Skip",
"space.scale_label": "Scale (grid cell size)",
"space.scale_unit": "cm per cell",
"space.scale_unit_imperial": "in per cell",
"space.display_section": "Display",
"space.show_borders": "Always show room borders",
"space.show_names": "Show room names (drag to move)",
+1
View File
@@ -372,6 +372,7 @@
"btn.skip": "Пропустить",
"space.scale_label": "Масштаб (размер клетки сетки)",
"space.scale_unit": "см на клетку",
"space.scale_unit_imperial": "дюйм на клетку",
"space.display_section": "Отображение",
"space.show_borders": "Всегда отображать границы комнат",
"space.show_names": "Отображать названия комнат (перетаскиваются)",
+22 -11
View File
@@ -1,5 +1,6 @@
import { svg, type TemplateResult } from 'lit';
import type { OpeningCfg } from '../types';
import { gridVisualScale, gridVisualUnits } from '../grid-scale';
export interface OpeningFaceOffset {
ox: number;
@@ -34,14 +35,22 @@ export function openingVisibleMetrics(spec: OpeningVisibleSpec): OpeningVisibleM
const half = spec.length / 2;
const jambHalf = spec.face.cm > 0
? ((spec.face.cm / spec.cellCm) * spec.gridPitch) / 2
: 4;
: gridVisualUnits(4, spec.cellCm);
const gateDepth = spec.type === 'gate' ? Math.sin((10 * Math.PI) / 180) * half : 0;
return {
half,
jambHalf,
gateDepth,
outlineHalf: Math.max(16, jambHalf + 8, gateDepth + 8),
hitHalf: Math.max(20, jambHalf + 10, gateDepth + 12),
outlineHalf: Math.max(
gridVisualUnits(16, spec.cellCm),
jambHalf + gridVisualUnits(8, spec.cellCm),
gateDepth + gridVisualUnits(8, spec.cellCm),
),
hitHalf: Math.max(
gridVisualUnits(20, spec.cellCm),
jambHalf + gridVisualUnits(10, spec.cellCm),
gateDepth + gridVisualUnits(12, spec.cellCm),
),
};
}
@@ -55,6 +64,8 @@ export function renderOpeningVisibleGeometry(spec: OpeningVisibleSpec): Template
if (spec.type === 'passage') return svg``;
const amount = Math.max(0, Math.min(1, spec.amount));
const { half, jambHalf } = openingVisibleMetrics(spec);
const visualScale = gridVisualScale(spec.cellCm);
const leafHalf = 1.75 * visualScale;
const sx = spec.flipH ? -1 : 1;
const sy = spec.flipV ? -1 : 1;
@@ -75,7 +86,7 @@ export function renderOpeningVisibleGeometry(spec: OpeningVisibleSpec): Template
const arcLen = (Math.PI / 2) * half;
const glass = spec.face.cm > 0
? svg`<line class="op-glass" x1="0" y1="${-jambHalf}" x2="0" y2="${jambHalf}"
stroke="${spec.tone}" stroke-width="1.5"></line>`
stroke="${spec.tone}" stroke-width="${1.5 * visualScale}"></line>`
: svg``;
body = svg`
<g transform="translate(${swingTx} ${swingTy})">
@@ -85,12 +96,12 @@ export function renderOpeningVisibleGeometry(spec: OpeningVisibleSpec): Template
stroke="${spec.tone}" stroke-dasharray="${arcLen}" stroke-dashoffset="${arcLen * (1 - amount)}"></path>
<g transform="translate(${-half} 0)">
<g class="op-leaf" style="transform:rotate(${-90 * amount}deg)">
<rect x="0" y="-1.5" width="${half}" height="3" fill="${spec.tone}"></rect>
<rect x="0" y="${-1.5 * visualScale}" width="${half}" height="${3 * visualScale}" fill="${spec.tone}"></rect>
</g>
</g>
<g transform="translate(${half} 0)">
<g class="op-leaf" style="transform:rotate(${90 * amount}deg)">
<rect x="${-half}" y="-1.5" width="${half}" height="3" fill="${spec.tone}"></rect>
<rect x="${-half}" y="${-1.5 * visualScale}" width="${half}" height="${3 * visualScale}" fill="${spec.tone}"></rect>
</g>
</g>
${glass}
@@ -103,12 +114,12 @@ export function renderOpeningVisibleGeometry(spec: OpeningVisibleSpec): Template
<g transform="translate(${swingTx} ${swingTy})">
<g transform="translate(${-half} 0)">
<g class="op-leaf" style="transform:rotate(${gateAngle}deg)">
<rect x="0" y="-1.75" width="${half}" height="3.5" fill="${spec.tone}"></rect>
<rect x="0" y="${-leafHalf}" width="${half}" height="${leafHalf * 2}" fill="${spec.tone}"></rect>
</g>
</g>
<g transform="translate(${half} 0)">
<g class="op-leaf" style="transform:rotate(${-gateAngle}deg)">
<rect x="${-half}" y="-1.75" width="${half}" height="3.5" fill="${spec.tone}"></rect>
<rect x="${-half}" y="${-leafHalf}" width="${half}" height="${leafHalf * 2}" fill="${spec.tone}"></rect>
</g>
</g>
</g>`;
@@ -120,7 +131,7 @@ export function renderOpeningVisibleGeometry(spec: OpeningVisibleSpec): Template
stroke="${spec.tone}" stroke-dasharray="${arcLen}" stroke-dashoffset="${arcLen * (1 - amount)}"></path>
<g transform="translate(${-half} 0)">
<g class="op-leaf" style="transform:rotate(${-90 * amount}deg)">
<rect x="0" y="-1.75" width="${spec.length}" height="3.5" fill="${spec.tone}"></rect>
<rect x="0" y="${-leafHalf}" width="${spec.length}" height="${leafHalf * 2}" fill="${spec.tone}"></rect>
</g>
</g>
</g>`;
@@ -128,9 +139,9 @@ export function renderOpeningVisibleGeometry(spec: OpeningVisibleSpec): Template
return svg`<g transform="scale(${sx} ${sy})">
<line x1="${-half}" y1="${-jambHalf}" x2="${-half}" y2="${jambHalf}"
stroke="${spec.base}" stroke-width="2.5"></line>
stroke="${spec.base}" stroke-width="${2.5 * visualScale}"></line>
<line x1="${half}" y1="${-jambHalf}" x2="${half}" y2="${jambHalf}"
stroke="${spec.base}" stroke-width="2.5"></line>
stroke="${spec.base}" stroke-width="${2.5 * visualScale}"></line>
${body}
</g>`;
}
+1 -1
View File
@@ -884,7 +884,7 @@ class HouseplanSpaceCard extends LitElement {
fill: url(#hp-wall-hatch);
fill-rule: evenodd;
stroke: var(--room-stroke, var(--hp-muted, #607d8b));
stroke-width: 0.6;
stroke-width: calc(0.6px * var(--hp-cell-visual-scale, 1));
pointer-events: none;
}
.wallbody.solid {
+12 -2
View File
@@ -7,6 +7,7 @@ import { declump, contentUrl } from './logic';
import type { ServerConfig, SpaceModel, RoomCfg, DevItem, OpeningCfg } from './types';
import { boxCorners, normalizeAngle } from './editors/decor/geometry';
import { canonicalColumnAngle } from './physical-geometry';
import { gridVisualScale } from './grid-scale';
export const NORM_W = 1000; // side of the render space — the canvas is square
@@ -154,6 +155,9 @@ export function spaceModels(cfg: ServerConfig | null): SpaceModel[] {
return {
id: s.id,
title: s.title,
// Missing remains the historical 5 cm for stored-config compatibility.
cellCm: Number.isFinite(Number(s.cell_cm)) && Number(s.cell_cm) > 0
? Number(s.cell_cm) : 5,
vb: [vb[0] * NORM_W, vb[1] * H, vb[2] * NORM_W, vb[3] * H],
// the image's own placement — the centred default plus whatever the
// backdrop frame has stored (plan_x/y, per-axis scale and angle)
@@ -446,8 +450,14 @@ export function iconUnit(space: SpaceModel): number {
// no business inflating the icons. Degenerate axes are still lifted off zero,
// which cannot matter here (FLOOR < NORM_W).
const b = contentFrame(items, { pad: 0 }).core;
if (!b) return NORM_W;
return Math.max(NORM_W, Math.min(SANE_LIMIT, Math.max(b.w, b.h)));
// The historical NORM_W floor is a VISUAL size, not a physical distance.
// A physically equivalent plan at a finer cell size stores proportionally
// larger coordinates, so its floor must grow by the same factor. Without
// this, icons and room labels shrink whenever one side of the plan is below
// the old 1000-unit threshold (issue #239).
const visualFloor = NORM_W * gridVisualScale(space.cellCm ?? 5);
if (!b) return visualFloor;
return Math.max(visualFloor, Math.min(SANE_LIMIT, Math.max(b.w, b.h)));
}
/**
+3 -2
View File
@@ -44,6 +44,7 @@ import { valueBadgeTitle } from './device-value-badge';
import { contentFingerprint } from './visual-continuity';
import type { VirtualLightSnapshot } from './virtual-light-state';
import { renderOpeningTunnelFills } from './render/opening-tunnels';
import { gridVisualScale, gridVisualUnits } from './grid-scale';
import {
spaceModels, defaultPositions, markerPos, labelPos, spaceFrame, iconCqw, NORM_W,
GRID_STEP_N, GRID_PITCH, staticPassageOpenings,
@@ -507,7 +508,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
return html`
<div class="hp-static-stage${dayCycle ? ` daycycle phase-${dayCycle.phase}` : ''}"
?inert=${!!o.inert}
style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''};--wall-fill:${colors.wall_fill.c};--wall-fill-op:${colors.wall_fill.a}${dayCycle ? `;${dayCycleStageVars(dayCycle)}` : ''}">
style="aspect-ratio:${vb[2]}/${vb[3]}${stageBg ? ';background:' + stageBg : ''};--hp-cell-visual-scale:${gridVisualScale(cellCm)};--wall-fill:${colors.wall_fill.c};--wall-fill-op:${colors.wall_fill.a}${dayCycle ? `;${dayCycleStageVars(dayCycle)}` : ''}">
${renderDayCycleEnvironment(dayCycle)}
<svg viewBox="${vb[0]} ${vb[1]} ${vb[2]} ${vb[3]}" preserveAspectRatio="xMidYMid meet">
${wallUnion ? svg`<defs>
@@ -545,7 +546,7 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
stroke="none" pointer-events="none"></path>
<path class="wallbody ${solidWall ? 'solid' : ''}" data-hp="wall" data-id="union" data-kind="union"
d="${wallUnion.d}" fill="${solidWall ? 'none' : 'url(#hp-wall-hatch)'}" fill-rule=${wallUnion.fillRule}
stroke="${wallStroke}" stroke-width="0.6" pointer-events="none"></path>
stroke="${wallStroke}" stroke-width="${gridVisualUnits(0.6, cellCm)}" pointer-events="none"></path>
</g>`
: nothing}
${hostedOpeningSymbols}
+39 -28
View File
@@ -637,7 +637,7 @@ export const cardStyles = css`
.room.overlay {
fill: transparent;
stroke: transparent;
stroke-width: 2;
stroke-width: calc(2px * var(--hp-cell-visual-scale, 1));
}
:host([data-pointer-hover]) .stage.mode-view .room.overlay:not(.styled):hover {
stroke: var(--hp-accent);
@@ -646,7 +646,7 @@ export const cardStyles = css`
.room.yard {
fill: rgba(75, 140, 90, 0.14);
stroke: #4b8c5a;
stroke-width: 2;
stroke-width: calc(2px * var(--hp-cell-visual-scale, 1));
}
:host([data-pointer-hover]) .stage.mode-view .room.yard:not(.styled):hover {
stroke: var(--hp-accent);
@@ -655,7 +655,7 @@ export const cardStyles = css`
.room.styled {
stroke: var(--room-stroke, transparent);
stroke-opacity: var(--room-stroke-op, 0);
stroke-width: 2.5;
stroke-width: calc(2.5px * var(--hp-cell-visual-scale, 1));
fill: var(--room-fill, transparent);
fill-opacity: var(--room-fill-op, 0);
}
@@ -705,14 +705,14 @@ export const cardStyles = css`
transition: transform 0.6s ease;
}
.op-arc {
stroke-width: 1.5;
stroke-width: calc(1.5px * var(--hp-cell-visual-scale, 1));
transition: stroke-dashoffset 0.6s ease;
}
/* hover affordance: a rounded outline hugging the wall strip + a grab cursor */
.op-outline {
fill: none;
stroke: var(--hp-accent);
stroke-width: 1.5;
stroke-width: calc(1.5px * var(--hp-cell-visual-scale, 1));
opacity: 0;
transition: opacity 0.15s;
pointer-events: none;
@@ -743,12 +743,12 @@ export const cardStyles = css`
.stage.markup .opening.orphan circle {
fill: var(--hp-bg);
stroke: currentColor;
stroke-width: 2;
stroke-width: calc(2px * var(--hp-cell-visual-scale, 1));
}
.stage.markup .opening.orphan text {
fill: currentColor;
font-weight: 800;
font-size: 12px;
font-size: calc(12px * var(--hp-cell-visual-scale, 1));
pointer-events: none;
}
/* HP-1550-04: in the resize tool the wall handles own the hit test — the
@@ -1176,8 +1176,10 @@ export const cardStyles = css`
}
.alignline {
stroke: var(--hp-accent);
stroke-width: 1.2;
stroke-dasharray: 4 4;
stroke-width: calc(1.2px * var(--hp-cell-visual-scale, 1));
stroke-dasharray:
calc(4px * var(--hp-cell-visual-scale, 1))
calc(4px * var(--hp-cell-visual-scale, 1));
pointer-events: none;
opacity: 0.9;
}
@@ -1301,7 +1303,9 @@ export const cardStyles = css`
filter: drop-shadow(0 0 3px var(--hp-accent));
}
.decorlayer .ddraft {
stroke-dasharray: 6 5;
stroke-dasharray:
calc(6px * var(--hp-cell-visual-scale, 1))
calc(5px * var(--hp-cell-visual-scale, 1));
pointer-events: none;
}
.decorlayer text {
@@ -1479,7 +1483,7 @@ export const cardStyles = css`
}
.passage-preview-boundary {
stroke: var(--hp-open, #ff9800);
stroke-width: 2.5;
stroke-width: calc(2.5px * var(--hp-cell-visual-scale, 1));
pointer-events: none;
}
.opening-preview .op-leaf,
@@ -1531,7 +1535,7 @@ export const cardStyles = css`
fill: url(#hp-wall-hatch);
fill-rule: evenodd;
stroke: var(--room-stroke, var(--hp-muted));
stroke-width: 0.6;
stroke-width: calc(0.6px * var(--hp-cell-visual-scale, 1));
pointer-events: none;
}
/* Thin-on-screen: hatch collapses into noise — fill alone, stroke from fill path's sibling. */
@@ -1542,7 +1546,7 @@ export const cardStyles = css`
fill: var(--accent-color, #03a9f4);
fill-opacity: 0.38;
stroke: var(--accent-color, #03a9f4);
stroke-width: 2.5;
stroke-width: calc(2.5px * var(--hp-cell-visual-scale, 1));
stroke-linejoin: round;
stroke-linecap: round;
pointer-events: none;
@@ -1585,8 +1589,10 @@ export const cardStyles = css`
.stage.markup.tool-boundary.boundary-invalid { cursor: not-allowed; }
.openwall {
stroke: var(--ow-stroke, var(--hp-muted));
stroke-width: 2.5;
stroke-dasharray: 7 7;
stroke-width: calc(2.5px * var(--hp-cell-visual-scale, 1));
stroke-dasharray:
calc(7px * var(--hp-cell-visual-scale, 1))
calc(7px * var(--hp-cell-visual-scale, 1));
stroke-linecap: butt;
pointer-events: none;
opacity: 0.9;
@@ -1603,7 +1609,7 @@ export const cardStyles = css`
(owner 2026-08-04 — no teeth anywhere on a room border). */
.room-outline {
fill: none;
stroke-width: 2.5;
stroke-width: calc(2.5px * var(--hp-cell-visual-scale, 1));
stroke-linejoin: round;
stroke-linecap: round;
pointer-events: none;
@@ -1627,7 +1633,7 @@ export const cardStyles = css`
fill: none;
stroke: var(--hp-accent);
stroke-opacity: 0.28;
stroke-width: 8;
stroke-width: calc(8px * var(--hp-cell-visual-scale, 1));
stroke-linejoin: round;
stroke-linecap: round;
pointer-events: none;
@@ -1635,15 +1641,17 @@ export const cardStyles = css`
.room-hover-outline {
fill: none;
stroke: var(--hp-accent);
stroke-width: 3;
stroke-width: calc(3px * var(--hp-cell-visual-scale, 1));
stroke-linejoin: round;
stroke-linecap: round;
pointer-events: none;
}
.openwall-preview {
stroke: #ffc14d;
stroke-width: 5;
stroke-dasharray: 7 7;
stroke-width: calc(5px * var(--hp-cell-visual-scale, 1));
stroke-dasharray:
calc(7px * var(--hp-cell-visual-scale, 1))
calc(7px * var(--hp-cell-visual-scale, 1));
stroke-linecap: round;
pointer-events: none;
opacity: 0.95;
@@ -1655,7 +1663,7 @@ export const cardStyles = css`
fill: rgba(255, 193, 77, 0.28);
fill-rule: evenodd;
stroke: #ffc14d;
stroke-width: 2.5;
stroke-width: calc(2.5px * var(--hp-cell-visual-scale, 1));
stroke-dasharray: none;
stroke-linejoin: round;
}
@@ -1764,7 +1772,7 @@ export const cardStyles = css`
/* AFTER .outlined: same specificity — source order decides (gotcha x4) */
.room.picked {
stroke: #ffc14d;
stroke-width: 3;
stroke-width: calc(3px * var(--hp-cell-visual-scale, 1));
fill: rgba(255, 193, 77, 0.25);
}
/* Owner 2026-08-04: the grid is a HINT, not content — at full strength the
@@ -1787,21 +1795,23 @@ export const cardStyles = css`
so the round cap IS the corner (matches the finished .room border) */
.seg {
stroke: var(--hp-accent);
stroke-width: 2.5;
stroke-width: calc(2.5px * var(--hp-cell-visual-scale, 1));
stroke-linecap: round;
stroke-linejoin: round;
}
.pathline {
stroke: #ffc14d;
stroke-width: 3;
stroke-width: calc(3px * var(--hp-cell-visual-scale, 1));
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.preview {
stroke: #ffc14d;
stroke-width: 2;
stroke-dasharray: 6 5;
stroke-width: calc(2px * var(--hp-cell-visual-scale, 1));
stroke-dasharray:
calc(6px * var(--hp-cell-visual-scale, 1))
calc(5px * var(--hp-cell-visual-scale, 1));
opacity: 0.7;
}
.active-axis {
@@ -1838,14 +1848,14 @@ export const cardStyles = css`
fill-opacity: 0.55;
fill-rule: evenodd;
stroke: var(--accent-color, #03a9f4);
stroke-width: 0.5;
stroke-width: calc(0.5px * var(--hp-cell-visual-scale, 1));
stroke-opacity: 0.7;
pointer-events: none;
}
.vertex {
fill: #ffc14d;
stroke: #4a2800;
stroke-width: 1;
stroke-width: calc(1px * var(--hp-cell-visual-scale, 1));
}
.vertex.first {
fill: #4bd28f;
@@ -1858,6 +1868,7 @@ export const cardStyles = css`
.plan-snap-line {
fill: none;
stroke: color-mix(in srgb, var(--hp-accent) 82%, white 18%);
/* Explicit non-scaling-stroke in the SVG keeps this one screen pixel. */
stroke-width: 1;
stroke-linecap: round;
opacity: 0.92;
+2
View File
@@ -65,6 +65,8 @@ export type WallColumnCfg =
export interface SpaceModel {
id: string;
title: string;
/** Canonical centimetres represented by one stored grid cell. */
cellCm?: number;
vb: number[]; // render units
bg: { href: string; x: number; y: number; w: number; h: number; angle?: number } | null;
rooms: RoomCfg[]; // render units