mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 15:09:30 +00:00
feat: настроить визуальный стиль лестниц (#683)
Issue: #683 User-Visible: yes
This commit is contained in:
+4
-2
@@ -40,8 +40,10 @@
|
||||
"stairs.radius": "Radius",
|
||||
"stairs.rotation": "Drehung",
|
||||
"stairs.direction": "Aufstiegsrichtung",
|
||||
"stairs.forward": "Vorwärts",
|
||||
"stairs.backward": "Rückwärts",
|
||||
"stairs.up": "Aufwärts",
|
||||
"stairs.down": "Abwärts",
|
||||
"stairs.line_color": "Farbe von Stufen und Pfeil",
|
||||
"stairs.fill_color": "Füllfarbe",
|
||||
"stairs.clockwise": "Im Uhrzeigersinn",
|
||||
"stairs.counterclockwise": "Gegen den Uhrzeigersinn",
|
||||
"stairs.target": "Führt zu Etage",
|
||||
|
||||
+4
-2
@@ -40,8 +40,10 @@
|
||||
"stairs.radius": "Radius",
|
||||
"stairs.rotation": "Rotation",
|
||||
"stairs.direction": "Ascent direction",
|
||||
"stairs.forward": "Forward",
|
||||
"stairs.backward": "Backward",
|
||||
"stairs.up": "Up",
|
||||
"stairs.down": "Down",
|
||||
"stairs.line_color": "Tread and arrow colour",
|
||||
"stairs.fill_color": "Fill colour",
|
||||
"stairs.clockwise": "Clockwise",
|
||||
"stairs.counterclockwise": "Counterclockwise",
|
||||
"stairs.target": "Leads to floor",
|
||||
|
||||
+4
-2
@@ -40,8 +40,10 @@
|
||||
"stairs.radius": "Rayon",
|
||||
"stairs.rotation": "Rotation",
|
||||
"stairs.direction": "Sens de montée",
|
||||
"stairs.forward": "Avant",
|
||||
"stairs.backward": "Arrière",
|
||||
"stairs.up": "Monter",
|
||||
"stairs.down": "Descendre",
|
||||
"stairs.line_color": "Couleur des marches et de la flèche",
|
||||
"stairs.fill_color": "Couleur de remplissage",
|
||||
"stairs.clockwise": "Sens horaire",
|
||||
"stairs.counterclockwise": "Sens antihoraire",
|
||||
"stairs.target": "Mène à l’étage",
|
||||
|
||||
+4
-2
@@ -40,8 +40,10 @@
|
||||
"stairs.radius": "Радиус",
|
||||
"stairs.rotation": "Поворот",
|
||||
"stairs.direction": "Направление подъёма",
|
||||
"stairs.forward": "Вперёд",
|
||||
"stairs.backward": "Назад",
|
||||
"stairs.up": "Вверх",
|
||||
"stairs.down": "Вниз",
|
||||
"stairs.line_color": "Цвет ступеней и стрелки",
|
||||
"stairs.fill_color": "Цвет заливки",
|
||||
"stairs.clockwise": "По часовой стрелке",
|
||||
"stairs.counterclockwise": "Против часовой стрелки",
|
||||
"stairs.target": "Ведёт на этаж",
|
||||
|
||||
@@ -474,8 +474,11 @@ function buildPdfCandidate(
|
||||
commands.push({
|
||||
kind: 'path', rings: [geometry.outline.map(pt)], stroke: INK, width: 0.25 * MM,
|
||||
});
|
||||
for (const edge of geometry.trapezoid) commands.push({
|
||||
kind: 'line', points: [pt(edge.a), pt(edge.b)], stroke: INK, width: 0.25 * MM,
|
||||
});
|
||||
for (const tread of geometry.treads) commands.push({
|
||||
kind: 'line', points: [pt(tread.a), pt(tread.b)], stroke: INK, width: 0.2 * MM,
|
||||
kind: 'line', points: [pt(tread.a), pt(tread.b)], stroke: INK, width: 0.25 * MM,
|
||||
});
|
||||
commands.push({
|
||||
kind: 'vector', ops: transformSvgPath(geometry.arrowPath, stairMatrix),
|
||||
|
||||
+10
-1
@@ -60,7 +60,7 @@ import { geometryOpenings } from './plan-geometry-preflight';
|
||||
import { resolveDeviceAreaRelocations } from './device-area-relocation';
|
||||
import { projectDecorImage } from './decor-assets';
|
||||
import type { DecorShape } from './editors/decor/types';
|
||||
import { cachedStairRenderGeometry, stairOutline } from './stairs';
|
||||
import { cachedStairRenderGeometry, stairOutline, stairStyleVars } from './stairs';
|
||||
import {
|
||||
buildGlowClipGeometry, buildLightBarrierScene, forgetGlowSource, forgetGlowSpace,
|
||||
glowSourceInOpaqueBody, pruneGlowSources, readGlowClip, renderGlowPools,
|
||||
@@ -279,6 +279,12 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
if (!space) return null;
|
||||
const disp = spaceDisplayOf(o.cfg.spaces.find((s: any) => s.id === o.spaceId));
|
||||
const colors = fillColorsOf(o.cfg.settings);
|
||||
const storedDecor = (o.cfg.settings as { decor_default_style?: Record<string, unknown> })
|
||||
?.decor_default_style;
|
||||
const stairFallback = {
|
||||
color: typeof storedDecor?.color === 'string' ? storedDecor.color : '#607d8b',
|
||||
opacity: Number.isFinite(Number(storedDecor?.opacity)) ? Number(storedDecor?.opacity) : 1,
|
||||
};
|
||||
const cfgSize = o.iconSize ?? 2.5;
|
||||
const iconPct = cfgSize > 8 ? 2.5 : cfgSize;
|
||||
const deviceBasePct = effectiveDeviceBaseSize(iconPct);
|
||||
@@ -906,8 +912,11 @@ export function renderSpaceStatic(o: StaticRenderOpts): TemplateResult | null {
|
||||
const geometry = cachedStairRenderGeometry(stair, cellCm);
|
||||
return svg`<g class="hp-stair" data-hp="stair" data-id=${stair.id} data-kind=${stair.kind}
|
||||
data-target-state="fixed"
|
||||
style=${stairStyleVars(stair, stairFallback)}
|
||||
aria-hidden="true" pointer-events="none">
|
||||
<polygon class="hp-stair-outline" points=${geometry.outline.map((point) => point.join(',')).join(' ')}></polygon>
|
||||
${geometry.trapezoid.map((line) => svg`<line class="hp-stair-trapezoid"
|
||||
x1=${line.a[0]} y1=${line.a[1]} x2=${line.b[0]} y2=${line.b[1]}></line>`)}
|
||||
${geometry.treads.map((line) => svg`<line class="hp-stair-tread"
|
||||
x1=${line.a[0]} y1=${line.a[1]} x2=${line.b[0]} y2=${line.b[1]}></line>`)}
|
||||
<path class="hp-stair-arrow" d=${geometry.arrowPath}></path>
|
||||
|
||||
+7
-2
@@ -1,6 +1,6 @@
|
||||
import { GRID_N, NORM_W } from './canvas-constants';
|
||||
import type { FurnitureWallSurface } from './furniture-wall-surface';
|
||||
import type { Stair } from './stairs';
|
||||
import { stairVisualFields, type Stair, type StairVisualStyle } from './stairs';
|
||||
import { defaultStair, normalizeStairAngle } from './stairs-editor-model';
|
||||
|
||||
// #676: the editing layer works on an oriented box — centre, length along the
|
||||
@@ -147,9 +147,12 @@ export const stairMinN = (cellCm: number): number => cmToNorm(STAIR_MIN_CM, cell
|
||||
export function draftStair(
|
||||
kind: Stair['kind'], a: readonly number[], b: readonly number[],
|
||||
cellCm: number, id: string, clickUnits: number, scale = NORM_W,
|
||||
visual?: StairVisualStyle,
|
||||
): Stair {
|
||||
const dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
if (Math.max(Math.abs(dx), Math.abs(dy)) < clickUnits) return defaultStair(kind, a[0], a[1], cellCm, id);
|
||||
if (Math.max(Math.abs(dx), Math.abs(dy)) < clickUnits)
|
||||
return defaultStair(kind, a[0], a[1], cellCm, id, visual);
|
||||
const style = visual ? stairVisualFields(visual) : {};
|
||||
const minUnits = stairMinN(cellCm) * scale;
|
||||
if (kind === 'spiral') {
|
||||
const side = Math.max(Math.abs(dx), Math.abs(dy));
|
||||
@@ -159,6 +162,7 @@ export function draftStair(
|
||||
return {
|
||||
id, kind, x: cx / scale, y: cy / scale, angle: 0,
|
||||
direction: 'clockwise', radius: radius / scale, target_space_id: null,
|
||||
...style,
|
||||
};
|
||||
}
|
||||
const alongX = Math.abs(dx) >= Math.abs(dy);
|
||||
@@ -168,6 +172,7 @@ export function draftStair(
|
||||
return {
|
||||
id, kind, x: (a[0] + dx / 2) / scale, y: (a[1] + dy / 2) / scale, angle,
|
||||
direction: 'forward', length: length / scale, width: width / scale, target_space_id: null,
|
||||
...style,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+18
-10
@@ -1,5 +1,5 @@
|
||||
import { GRID_N, NORM_W } from './canvas-constants';
|
||||
import type { Stair } from './stairs';
|
||||
import { stairVisualFields, type Stair, type StairVisualStyle } from './stairs';
|
||||
|
||||
// Eager module: the View runtime imports `stairTargetState` from here, so this
|
||||
// file must stay small. The editor-only box transforms live in
|
||||
@@ -26,31 +26,39 @@ export function normalizeStairAngle(value: unknown): number {
|
||||
|
||||
export function defaultStair(
|
||||
kind: Stair['kind'], x: number, y: number, cellCm: number, id: string,
|
||||
visual?: StairVisualStyle,
|
||||
): Stair {
|
||||
const style = visual ? stairVisualFields(visual) : {};
|
||||
if (kind === 'spiral') return {
|
||||
id, kind, x: x / NORM_W, y: y / NORM_W, angle: 0,
|
||||
direction: 'clockwise', radius: cmToNorm(90, cellCm), target_space_id: null,
|
||||
...style,
|
||||
};
|
||||
return {
|
||||
id, kind, x: x / NORM_W, y: y / NORM_W, angle: 0,
|
||||
direction: 'forward', length: cmToNorm(240, cellCm), width: cmToNorm(100, cellCm),
|
||||
target_space_id: null,
|
||||
...style,
|
||||
};
|
||||
}
|
||||
|
||||
export function convertStairKind(stair: Stair, kind: Stair['kind']): Stair {
|
||||
if (stair.kind === kind) return { ...stair };
|
||||
if (stair.kind === 'straight') return {
|
||||
id: stair.id, kind: 'spiral', x: stair.x, y: stair.y, angle: stair.angle,
|
||||
direction: stair.direction === 'backward' ? 'counterclockwise' : 'clockwise',
|
||||
radius: Math.max(stair.length, stair.width) / 2,
|
||||
target_space_id: stair.target_space_id ?? null,
|
||||
};
|
||||
if (stair.kind === 'straight') {
|
||||
const { kind: _kind, direction: _direction, length, width, ...common } = stair;
|
||||
return {
|
||||
...common, kind: 'spiral',
|
||||
direction: stair.direction === 'backward' ? 'counterclockwise' : 'clockwise',
|
||||
radius: Math.max(length, width) / 2,
|
||||
target_space_id: common.target_space_id ?? null,
|
||||
};
|
||||
}
|
||||
const { kind: _kind, direction: _direction, radius, ...common } = stair;
|
||||
return {
|
||||
id: stair.id, kind: 'straight', x: stair.x, y: stair.y, angle: stair.angle,
|
||||
...common, kind: 'straight',
|
||||
direction: stair.direction === 'counterclockwise' ? 'backward' : 'forward',
|
||||
length: stair.radius * 2, width: stair.radius * 2,
|
||||
target_space_id: stair.target_space_id ?? null,
|
||||
length: radius * 2, width: radius * 2,
|
||||
target_space_id: common.target_space_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+47
-5
@@ -16,7 +16,8 @@ import {
|
||||
convertStairKind, normalizeStairAngle, snapStairToStairs, stairTargetState,
|
||||
} from './stairs-editor-model';
|
||||
import {
|
||||
cachedStairRenderGeometry, MAX_STAIRS_PER_SPACE, stairList, type Stair,
|
||||
cachedStairRenderGeometry, MAX_STAIRS_PER_SPACE, stairList, stairStyleVars,
|
||||
stairVisualFields, stairVisualStyle, type Stair, type StairVisualStyle,
|
||||
} from './stairs';
|
||||
import type { SpaceModel } from './types';
|
||||
|
||||
@@ -29,6 +30,10 @@ type StairDialog = {
|
||||
angle: string;
|
||||
direction: Stair['direction'];
|
||||
targetSpaceId: string;
|
||||
color: string;
|
||||
opacity: number;
|
||||
fillColor: string;
|
||||
fillOpacity: number;
|
||||
/** Field values at open time: an untouched field keeps the stored number bit for bit (#676 К7). */
|
||||
opened: { length: string; width: string; radius: string; angle: string };
|
||||
};
|
||||
@@ -73,6 +78,7 @@ export interface StairEditorHostPort {
|
||||
_model: SpaceModel[];
|
||||
_space: string;
|
||||
_hasFixedFloor: boolean;
|
||||
_decorStyle: { color: string; opacity: number };
|
||||
_suppressClick: boolean;
|
||||
requestUpdate(): void;
|
||||
_showToast(message: string): void;
|
||||
@@ -123,6 +129,15 @@ export class StairEditorRuntime {
|
||||
|
||||
private get minUnits(): number { return stairMinN(this.owner._cellCm) * NORM_W; }
|
||||
|
||||
private creationVisualStyle(): StairVisualStyle {
|
||||
return {
|
||||
color: this.owner._decorStyle.color,
|
||||
opacity: this.owner._decorStyle.opacity,
|
||||
fillColor: this.owner._decorStyle.color,
|
||||
fillOpacity: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Room faces from the furniture magnet plus physical bodies with an outward side (#676). */
|
||||
private surfaces(): readonly FurnitureWallSurface[] {
|
||||
const source = this.owner as unknown as FurnitureWallSurfaceSource;
|
||||
@@ -204,7 +219,10 @@ export class StairEditorRuntime {
|
||||
const id = newStairId();
|
||||
this.draft = {
|
||||
pid: event.pointerId, kind: this.preset, id, a: point, b: point,
|
||||
stair: draftStair(this.preset, point, point, this.owner._cellCm, id, this.owner._gridPitch),
|
||||
stair: draftStair(
|
||||
this.preset, point, point, this.owner._cellCm, id, this.owner._gridPitch,
|
||||
NORM_W, this.creationVisualStyle(),
|
||||
),
|
||||
surfaces: this.surfaces(),
|
||||
};
|
||||
this.selectedId = null;
|
||||
@@ -215,7 +233,10 @@ export class StairEditorRuntime {
|
||||
}
|
||||
|
||||
private draftAt(draft: StairDraft, point: number[]): Stair {
|
||||
const drawn = draftStair(draft.kind, draft.a, point, this.owner._cellCm, draft.id, this.owner._gridPitch);
|
||||
const drawn = draftStair(
|
||||
draft.kind, draft.a, point, this.owner._cellCm, draft.id, this.owner._gridPitch,
|
||||
NORM_W, stairVisualStyle(draft.stair, this.creationVisualStyle()),
|
||||
);
|
||||
const isClick = Math.max(Math.abs(point[0] - draft.a[0]), Math.abs(point[1] - draft.a[1])) < this.owner._gridPitch;
|
||||
if (isClick) return this.withMoveMagnet(drawn, [drawn.x * NORM_W, drawn.y * NORM_W], draft.surfaces);
|
||||
return magnetStairResize(
|
||||
@@ -239,7 +260,10 @@ export class StairEditorRuntime {
|
||||
const before = this.owner._geometrySnapshot();
|
||||
const id = newStairId();
|
||||
const stair = this.withMoveMagnet(
|
||||
draftStair(this.preset, point, point, this.owner._cellCm, id, this.owner._gridPitch),
|
||||
draftStair(
|
||||
this.preset, point, point, this.owner._cellCm, id, this.owner._gridPitch,
|
||||
NORM_W, this.creationVisualStyle(),
|
||||
),
|
||||
[point[0], point[1]], this.surfaces(),
|
||||
);
|
||||
this.placeStair(stair, before);
|
||||
@@ -254,12 +278,14 @@ export class StairEditorRuntime {
|
||||
radius: stairFieldOf(stair.kind === 'spiral' ? stair.radius : Math.max(stair.length, stair.width) / 2, cellCm, imperial),
|
||||
angle: String(stair.angle),
|
||||
};
|
||||
const visual = stairVisualStyle(stair, this.creationVisualStyle());
|
||||
this.dialog = {
|
||||
id: stair.id,
|
||||
kind: stair.kind,
|
||||
...fields,
|
||||
direction: stair.direction,
|
||||
targetSpaceId: stair.target_space_id || '',
|
||||
...visual,
|
||||
opened: fields,
|
||||
};
|
||||
this.owner.requestUpdate();
|
||||
@@ -300,6 +326,7 @@ export class StairEditorRuntime {
|
||||
...next,
|
||||
angle: normalizeStairAngle(angle),
|
||||
target_space_id: dialog.targetSpaceId || null,
|
||||
...stairVisualFields(dialog),
|
||||
} as Stair;
|
||||
this.dialog = null;
|
||||
const unchanged = JSON.stringify(next) === JSON.stringify({ ...current, target_space_id: current.target_space_id ?? null });
|
||||
@@ -490,6 +517,7 @@ export class StairEditorRuntime {
|
||||
return svg`<g class="hp-stair ${selected ? 'selected' : ''} ${inputEnabled ? 'input-enabled' : ''} ${draft ? 'draft' : ''}"
|
||||
data-hp="stair" data-id=${stair.id} data-kind=${stair.kind}
|
||||
data-target-state=${targetState}
|
||||
style=${stairStyleVars(stair, this.creationVisualStyle())}
|
||||
role="img"
|
||||
aria-label=${this.owner._t('markup.stairs')}
|
||||
@dblclick=${(event: MouseEvent) => {
|
||||
@@ -500,6 +528,8 @@ export class StairEditorRuntime {
|
||||
<polygon class="hp-stair-hit" points=${outline}
|
||||
@pointerdown=${(event: PointerEvent) => this.pointerDown(event, stair, 'move')}
|
||||
@click=${select}></polygon>
|
||||
${geometry.trapezoid.map((line) => svg`<line class="hp-stair-trapezoid"
|
||||
x1=${line.a[0]} y1=${line.a[1]} x2=${line.b[0]} y2=${line.b[1]}></line>`)}
|
||||
${geometry.treads.map((line) => svg`<line class="hp-stair-tread"
|
||||
x1=${line.a[0]} y1=${line.a[1]} x2=${line.b[0]} y2=${line.b[1]}></line>`)}
|
||||
<path class="hp-stair-arrow" d=${geometry.arrowPath}></path>
|
||||
@@ -562,7 +592,7 @@ export class StairEditorRuntime {
|
||||
this.owner._hasFixedFloor,
|
||||
);
|
||||
const directionOptions: ReadonlyArray<readonly [string, I18nKey]> = dialog.kind === 'straight'
|
||||
? [['forward', 'stairs.forward'], ['backward', 'stairs.backward']] as const
|
||||
? [['forward', 'stairs.up'], ['backward', 'stairs.down']] as const
|
||||
: [['clockwise', 'stairs.clockwise'], ['counterclockwise', 'stairs.counterclockwise']] as const;
|
||||
const imperial = this.owner._imperial;
|
||||
const bound = (cm: number): string => String(Math.round((imperial ? cm / 2.54 : cm) * 100) / 100);
|
||||
@@ -600,6 +630,18 @@ export class StairEditorRuntime {
|
||||
${dialog.kind === 'straight'
|
||||
? html`${field('length', 'stairs.length')}${field('width', 'stairs.width')}`
|
||||
: field('radius', 'stairs.radius')}
|
||||
<hp-color-opacity .label=${this.owner._t('stairs.line_color')}
|
||||
.color=${dialog.color} .opacity=${dialog.opacity}
|
||||
.opacityLabel=${this.owner._t('space.opacity')}
|
||||
@hp-color-opacity-change=${(event: CustomEvent<{ color: string; opacity: number }>) =>
|
||||
this.updateDialog({ color: event.detail.color, opacity: event.detail.opacity })}>
|
||||
</hp-color-opacity>
|
||||
<hp-color-opacity .label=${this.owner._t('stairs.fill_color')}
|
||||
.color=${dialog.fillColor} .opacity=${dialog.fillOpacity}
|
||||
.opacityLabel=${this.owner._t('space.opacity')}
|
||||
@hp-color-opacity-change=${(event: CustomEvent<{ color: string; opacity: number }>) =>
|
||||
this.updateDialog({ fillColor: event.detail.color, fillOpacity: event.detail.opacity })}>
|
||||
</hp-color-opacity>
|
||||
<label>${this.owner._t('stairs.rotation')}</label>
|
||||
<input class="namein tempin" type="number" step="any" .value=${dialog.angle}
|
||||
@input=${(event: Event) => this.updateDialog({
|
||||
|
||||
+5
-1
@@ -1,6 +1,6 @@
|
||||
import { nothing, svg, type TemplateResult } from 'lit';
|
||||
import {
|
||||
cachedStairRenderGeometry, stairList, type Stair,
|
||||
cachedStairRenderGeometry, stairList, stairStyleVars, type Stair,
|
||||
} from './stairs';
|
||||
import { stairTargetState } from './stairs-editor-model';
|
||||
import type { SpaceModel } from './types';
|
||||
@@ -13,6 +13,7 @@ export interface StairViewHostPort {
|
||||
_hasFixedFloor: boolean;
|
||||
_suppressClick: boolean;
|
||||
_cellCm: number;
|
||||
_decorStyle: { color: string; opacity: number };
|
||||
_tabClick(spaceId: string): void;
|
||||
_t(key: 'markup.stairs' | 'stairs.tooltip_navigate', vars?: Record<string, string | number>): string;
|
||||
/** The card's pointer tooltip: shown only for a hover-capable pointer. */
|
||||
@@ -87,6 +88,7 @@ export class StairViewRuntime {
|
||||
return svg`<g class="hp-stair ${active ? 'navigable' : ''} ${interactive ? 'input-enabled' : ''}"
|
||||
data-hp="stair" data-id=${stair.id} data-kind=${stair.kind}
|
||||
data-target-state=${targetState}
|
||||
style=${stairStyleVars(stair, this.owner._decorStyle)}
|
||||
role=${active ? 'link' : 'img'} tabindex=${active ? '0' : nothing}
|
||||
aria-label=${this.owner._t('markup.stairs')}
|
||||
@click=${navigate}
|
||||
@@ -102,6 +104,8 @@ export class StairViewRuntime {
|
||||
<polygon class="hp-stair-outline" points=${outline}></polygon>
|
||||
<polygon class="hp-stair-hit" points=${outline}
|
||||
@pointerdown=${(event: PointerEvent) => this.pointerDown(event)}></polygon>
|
||||
${geometry.trapezoid.map((line) => svg`<line class="hp-stair-trapezoid"
|
||||
x1=${line.a[0]} y1=${line.a[1]} x2=${line.b[0]} y2=${line.b[1]}></line>`)}
|
||||
${geometry.treads.map((line) => svg`<line class="hp-stair-tread"
|
||||
x1=${line.a[0]} y1=${line.a[1]} x2=${line.b[0]} y2=${line.b[1]}></line>`)}
|
||||
<path class="hp-stair-arrow" d=${geometry.arrowPath}></path>
|
||||
|
||||
+109
-21
@@ -1,6 +1,7 @@
|
||||
import { difference, type Geom } from 'polyclip-ts';
|
||||
import { geometryArea } from './physical-geometry';
|
||||
import { CANVAS_LIMIT, GRID_N, NORM_W } from './canvas-constants';
|
||||
import { safeStoredColor } from './color';
|
||||
|
||||
export const STAIR_TREAD_CM = 30;
|
||||
export const MAX_STAIRS_PER_SPACE = 250;
|
||||
@@ -14,8 +15,30 @@ interface StairCommon {
|
||||
y: number;
|
||||
angle: number;
|
||||
target_space_id?: string | null;
|
||||
color?: string;
|
||||
opacity?: number;
|
||||
fill_color?: string;
|
||||
fill_opacity?: number;
|
||||
}
|
||||
|
||||
export interface StairVisualStyle {
|
||||
color: string;
|
||||
opacity: number;
|
||||
fillColor: string;
|
||||
fillOpacity: number;
|
||||
}
|
||||
|
||||
export type StairVisualFields = Pick<
|
||||
StairCommon, 'color' | 'opacity' | 'fill_color' | 'fill_opacity'
|
||||
>;
|
||||
|
||||
export const DEFAULT_STAIR_VISUAL_STYLE: StairVisualStyle = {
|
||||
color: '#607d8b',
|
||||
opacity: 1,
|
||||
fillColor: '#607d8b',
|
||||
fillOpacity: 0,
|
||||
};
|
||||
|
||||
export interface StraightStair extends StairCommon {
|
||||
kind: 'straight';
|
||||
direction: StraightStairDirection;
|
||||
@@ -38,6 +61,7 @@ export interface StairLine {
|
||||
|
||||
export interface StairRenderGeometry {
|
||||
outline: number[][];
|
||||
trapezoid: StairLine[];
|
||||
treads: StairLine[];
|
||||
arrowPath: string;
|
||||
center: [number, number];
|
||||
@@ -70,9 +94,60 @@ const finite = (value: unknown): number | null => {
|
||||
return Number.isFinite(number) ? number : null;
|
||||
};
|
||||
|
||||
const cmToNorm = (cm: number, cellCm: number): number => (
|
||||
Number(cm) / ((Number(cellCm) > 0 ? Number(cellCm) : 5) * GRID_N)
|
||||
);
|
||||
const clamp01 = (value: unknown, fallback: number): number => {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? Math.min(1, Math.max(0, number)) : fallback;
|
||||
};
|
||||
|
||||
/** Resolve optional persisted colours without mutating a legacy stair record. */
|
||||
export function stairVisualStyle(
|
||||
stair: StairVisualFields,
|
||||
fallback: Pick<StairVisualStyle, 'color' | 'opacity'> = DEFAULT_STAIR_VISUAL_STYLE,
|
||||
): StairVisualStyle {
|
||||
const fallbackColor = safeStoredColor(fallback.color, DEFAULT_STAIR_VISUAL_STYLE.color);
|
||||
const color = safeStoredColor(stair.color, fallbackColor);
|
||||
return {
|
||||
color,
|
||||
opacity: clamp01(stair.opacity, clamp01(fallback.opacity, 1)),
|
||||
fillColor: safeStoredColor(stair.fill_color, color),
|
||||
fillOpacity: clamp01(stair.fill_opacity, 0),
|
||||
};
|
||||
}
|
||||
|
||||
export function stairVisualFields(style: StairVisualStyle): StairVisualFields {
|
||||
const resolved = stairVisualStyle({
|
||||
color: style.color,
|
||||
opacity: style.opacity,
|
||||
fill_color: style.fillColor,
|
||||
fill_opacity: style.fillOpacity,
|
||||
});
|
||||
return {
|
||||
color: resolved.color,
|
||||
opacity: resolved.opacity,
|
||||
fill_color: resolved.fillColor,
|
||||
fill_opacity: resolved.fillOpacity,
|
||||
};
|
||||
}
|
||||
|
||||
export function stairStyleVars(
|
||||
stair: Stair,
|
||||
fallback: Pick<StairVisualStyle, 'color' | 'opacity'> = DEFAULT_STAIR_VISUAL_STYLE,
|
||||
): string {
|
||||
const style = stairVisualStyle(stair, fallback);
|
||||
return `--hp-stair-line:${style.color};--hp-stair-line-opacity:${style.opacity};`
|
||||
+ `--hp-stair-fill:${style.fillColor};--hp-stair-fill-opacity:${style.fillOpacity}`;
|
||||
}
|
||||
|
||||
/** Number of equal intervals whose physical size is closest to 30 cm. */
|
||||
export function stairIntervalCount(pathCm: number): number {
|
||||
const length = Number(pathCm);
|
||||
if (!(length > 0) || !Number.isFinite(length)) return 1;
|
||||
const quotient = length / STAIR_TREAD_CM;
|
||||
const lower = Math.max(1, Math.floor(quotient));
|
||||
const upper = Math.max(1, Math.ceil(quotient));
|
||||
const error = (count: number): number => Math.abs(length / count - STAIR_TREAD_CM);
|
||||
return error(upper) <= error(lower) ? upper : lower;
|
||||
}
|
||||
|
||||
export function isStair(value: unknown): value is Stair {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
@@ -146,39 +221,52 @@ export function stairRenderGeometry(
|
||||
): StairRenderGeometry {
|
||||
const center: [number, number] = [stair.x * scale, stair.y * scale];
|
||||
const outline = stairOutline(stair, scale);
|
||||
const treadN = cmToNorm(STAIR_TREAD_CM, cellCm);
|
||||
if (stair.kind === 'straight') {
|
||||
const forward = stair.direction === 'forward';
|
||||
const count = Math.max(0, Math.floor(stair.length / treadN));
|
||||
const count = stairIntervalCount(stair.length * cellCm * GRID_N);
|
||||
const startHalfWidth = stair.width * (stair.direction === 'forward' ? 0.4 : 0.5);
|
||||
const endHalfWidth = stair.width * (stair.direction === 'forward' ? 0.5 : 0.4);
|
||||
const startX = -stair.length / 2;
|
||||
const endX = stair.length / 2;
|
||||
const startTop = worldPoint(stair, startX, -startHalfWidth, scale);
|
||||
const startBottom = worldPoint(stair, startX, startHalfWidth, scale);
|
||||
const endTop = worldPoint(stair, endX, -endHalfWidth, scale);
|
||||
const endBottom = worldPoint(stair, endX, endHalfWidth, scale);
|
||||
// The 100% base is already the matching short side of the outer outline.
|
||||
// Draw it only once; the trapezoid contributes its two legs and 80% base.
|
||||
const trapezoid: StairLine[] = [
|
||||
{ a: startTop, b: endTop },
|
||||
{ a: startBottom, b: endBottom },
|
||||
stair.direction === 'forward'
|
||||
? { a: startTop, b: startBottom }
|
||||
: { a: endTop, b: endBottom },
|
||||
];
|
||||
const treads: StairLine[] = [];
|
||||
for (let index = 1; index <= count; index++) {
|
||||
const x = (forward ? -1 : 1) * stair.length / 2
|
||||
+ (forward ? 1 : -1) * index * treadN;
|
||||
if (forward ? x >= stair.length / 2 - 1e-10 : x <= -stair.length / 2 + 1e-10) break;
|
||||
for (let index = 1; index < count; index++) {
|
||||
const fraction = index / count;
|
||||
const x = startX + stair.length * fraction;
|
||||
const halfWidth = startHalfWidth + (endHalfWidth - startHalfWidth) * fraction;
|
||||
treads.push({
|
||||
a: worldPoint(stair, x, -stair.width / 2, scale),
|
||||
b: worldPoint(stair, x, stair.width / 2, scale),
|
||||
a: worldPoint(stair, x, -halfWidth, scale),
|
||||
b: worldPoint(stair, x, halfWidth, scale),
|
||||
});
|
||||
}
|
||||
const fromX = (forward ? -0.3 : 0.3) * stair.length;
|
||||
const toX = (forward ? 0.3 : -0.3) * stair.length;
|
||||
const from = worldPoint(stair, fromX, 0, scale);
|
||||
const tip = worldPoint(stair, toX, 0, scale);
|
||||
const bearing = stair.angle + (forward ? 0 : 180);
|
||||
const from = worldPoint(stair, -0.3 * stair.length, 0, scale);
|
||||
const tip = worldPoint(stair, 0.3 * stair.length, 0, scale);
|
||||
const bearing = stair.angle;
|
||||
const size = Math.min(stair.width, stair.length) * scale * 0.16;
|
||||
return {
|
||||
outline, treads, center,
|
||||
outline, trapezoid, treads, center,
|
||||
arrowPath: `M ${from[0]} ${from[1]} L ${tip[0]} ${tip[1]} ${arrowHead(tip, bearing, size)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const travelRadius = stair.radius * 2 / 3;
|
||||
const circumference = Math.PI * 2 * travelRadius;
|
||||
const count = Math.max(1, Math.floor(circumference / treadN));
|
||||
const count = stairIntervalCount(circumference * cellCm * GRID_N);
|
||||
const sign = stair.direction === 'clockwise' ? 1 : -1;
|
||||
const treads: StairLine[] = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
const localAngle = index * (treadN / travelRadius) * sign;
|
||||
const localAngle = index * (Math.PI * 2 / count) * sign;
|
||||
const degrees = stair.angle + localAngle * 180 / Math.PI;
|
||||
const inner = rotate(stair.radius * scale * 0.18, 0, degrees);
|
||||
const outer = rotate(stair.radius * scale, 0, degrees);
|
||||
@@ -196,7 +284,7 @@ export function stairRenderGeometry(
|
||||
const sweepFlag = sign > 0 ? 1 : 0;
|
||||
const tangent = end * 180 / Math.PI + (sign > 0 ? 90 : -90);
|
||||
return {
|
||||
outline, treads, center,
|
||||
outline, trapezoid: [], treads, center,
|
||||
arrowPath: `M ${from[0]} ${from[1]} A ${radius} ${radius} 0 1 ${sweepFlag} ${tip[0]} ${tip[1]} ${arrowHead(tip, tangent, stair.radius * scale * 0.16)}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1567,16 +1567,20 @@ export const planStyles = css`
|
||||
}
|
||||
.stage.markup.tool-stairs { cursor: crosshair; }
|
||||
.hp-stair-outline {
|
||||
fill: color-mix(in srgb, var(--hp-accent) 12%, transparent);
|
||||
stroke: var(--primary-text-color, #202124);
|
||||
fill: var(--hp-stair-fill, #607d8b);
|
||||
fill-opacity: var(--hp-stair-fill-opacity, 0);
|
||||
stroke: var(--hp-stair-line, #607d8b);
|
||||
stroke-opacity: var(--hp-stair-line-opacity, 1);
|
||||
stroke-width: calc(2px / var(--hp-plan-screen-scale, 1));
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
.hp-stair-trapezoid,
|
||||
.hp-stair-tread,
|
||||
.hp-stair-arrow {
|
||||
fill: none;
|
||||
stroke: var(--primary-text-color, #202124);
|
||||
stroke-width: calc(1.5px / var(--hp-plan-screen-scale, 1));
|
||||
stroke: var(--hp-stair-line, #607d8b);
|
||||
stroke-opacity: var(--hp-stair-line-opacity, 1);
|
||||
stroke-width: calc(2px / var(--hp-plan-screen-scale, 1));
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
vector-effect: non-scaling-stroke;
|
||||
@@ -1588,10 +1592,12 @@ export const planStyles = css`
|
||||
.hp-stair.navigable { cursor: pointer; }
|
||||
:host([data-pointer-hover]) .hp-stair.navigable:hover .hp-stair-outline {
|
||||
stroke: var(--hp-accent);
|
||||
stroke-opacity: 1;
|
||||
stroke-width: calc(2.5px / var(--hp-plan-screen-scale, 1));
|
||||
}
|
||||
.hp-stair.selected .hp-stair-outline {
|
||||
stroke: var(--hp-accent);
|
||||
stroke-opacity: 1;
|
||||
stroke-width: calc(3px / var(--hp-plan-screen-scale, 1));
|
||||
}
|
||||
/* #676: the frame and its handles are the decor frame's chrome (.dtframe,
|
||||
|
||||
Reference in New Issue
Block a user