feat: настроить визуальный стиль лестниц (#683)

Issue: #683
User-Visible: yes
This commit is contained in:
Sergey Matyunin
2026-09-27 23:04:28 +03:00
parent cf01489fa9
commit 5b5b808184
31 changed files with 554 additions and 93 deletions
+4 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": "Ведёт на этаж",
+4 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)}`,
};
}
+10 -4
View File
@@ -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,