feat: 新增底部Dock测量面板与回调联动
This commit is contained in:
84
src/components/bottom-dock-stack/index.css
Normal file
84
src/components/bottom-dock-stack/index.css
Normal file
@@ -0,0 +1,84 @@
|
||||
.bottom-dock-stack {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 20px;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bottom-dock-panel {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
transform: translateX(-50%);
|
||||
width: fit-content;
|
||||
max-width: calc(100vw - 40px);
|
||||
pointer-events: auto;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--bd-border, rgba(148, 163, 184, 0.35));
|
||||
background: var(--bd-bg, rgba(255, 255, 255, 0.94));
|
||||
box-shadow: var(--bd-shadow, 0 2px 8px rgba(15, 23, 42, 0.1));
|
||||
transition: transform 220ms ease, opacity 200ms ease;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.bottom-dock-panel.is-entering {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.bottom-dock-panel.is-leaving {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bottom-dock-panel-close {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
transform: translate(35%, -35%);
|
||||
z-index: 2;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--bd-close-color, #475569);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 160ms ease, color 160ms ease, border-color 160ms ease;
|
||||
}
|
||||
|
||||
.bottom-dock-panel-close:hover {
|
||||
background: var(--bd-close-bg-hover, rgba(15, 23, 42, 0.08));
|
||||
color: var(--bd-close-color-hover, #0f172a);
|
||||
border-color: var(--bd-close-border-hover, rgba(148, 163, 184, 0.3));
|
||||
}
|
||||
|
||||
.bottom-dock-panel-close:active {
|
||||
transform: translate(35%, -35%) scale(0.96);
|
||||
}
|
||||
|
||||
.bottom-dock-panel-body {
|
||||
min-height: 50px;
|
||||
padding: 10px;
|
||||
background: var(--bd-body-bg, transparent);
|
||||
width: fit-content;
|
||||
max-width: calc(100vw - 40px);
|
||||
box-sizing: border-box;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.bottom-dock-placeholder {
|
||||
border-radius: 8px;
|
||||
border: 1px dashed var(--bd-placeholder-border, rgba(148, 163, 184, 0.55));
|
||||
padding: 10px;
|
||||
color: var(--bd-placeholder-text, #64748b);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
188
src/components/bottom-dock-stack/index.ts
Normal file
188
src/components/bottom-dock-stack/index.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import './index.css';
|
||||
import type { ThemeConfig } from '../../themes/types';
|
||||
|
||||
export interface BottomDockPanelOptions {
|
||||
id: string;
|
||||
content: HTMLElement;
|
||||
closable?: boolean;
|
||||
onClose: (id: string) => void;
|
||||
}
|
||||
|
||||
interface BottomDockPanelInstance {
|
||||
id: string;
|
||||
element: HTMLElement;
|
||||
bodyElement: HTMLElement;
|
||||
closeButton: HTMLButtonElement | null;
|
||||
leaving: boolean;
|
||||
removeTimer: number | null;
|
||||
}
|
||||
|
||||
export class BottomDockStack {
|
||||
private readonly container: HTMLElement;
|
||||
private readonly root: HTMLElement;
|
||||
private readonly panelGap = 10;
|
||||
private readonly panelMap: Map<string, BottomDockPanelInstance> = new Map();
|
||||
private readonly panelOrder: string[] = [];
|
||||
private readonly resizeObserver: ResizeObserver | null;
|
||||
private readonly handleWindowResize = () => {
|
||||
this.reflow();
|
||||
};
|
||||
|
||||
constructor(container: HTMLElement) {
|
||||
this.container = container;
|
||||
this.root = document.createElement('div');
|
||||
this.root.className = 'bottom-dock-stack';
|
||||
this.container.appendChild(this.root);
|
||||
|
||||
this.resizeObserver = typeof ResizeObserver !== 'undefined'
|
||||
? new ResizeObserver(() => {
|
||||
this.reflow();
|
||||
})
|
||||
: null;
|
||||
|
||||
if (!this.resizeObserver) {
|
||||
window.addEventListener('resize', this.handleWindowResize);
|
||||
}
|
||||
}
|
||||
|
||||
public setTheme(theme: ThemeConfig): void {
|
||||
const style = this.root.style;
|
||||
style.setProperty('--bd-bg', theme.floatingBtnBg);
|
||||
style.setProperty('--bd-border', theme.floatingBtnBorder);
|
||||
style.setProperty('--bd-shadow', theme.floatingBtnShadow);
|
||||
style.setProperty('--bd-close-color', theme.floatingIconColor);
|
||||
style.setProperty('--bd-close-color-hover', theme.floatingIconColorHover);
|
||||
style.setProperty('--bd-close-bg-hover', theme.floatingBtnBgHover);
|
||||
style.setProperty('--bd-close-border-hover', theme.floatingBtnBorder);
|
||||
style.setProperty('--bd-body-bg', theme.floatingBtnBg);
|
||||
style.setProperty('--bd-placeholder-border', theme.borderSubtle);
|
||||
style.setProperty('--bd-placeholder-text', theme.textSecondary);
|
||||
}
|
||||
|
||||
public hasPanel(id: string): boolean {
|
||||
return this.panelMap.has(id);
|
||||
}
|
||||
|
||||
public addPanel(options: BottomDockPanelOptions): void {
|
||||
const existing = this.panelMap.get(options.id);
|
||||
if (existing && existing.leaving) {
|
||||
existing.leaving = false;
|
||||
existing.element.classList.remove('is-leaving');
|
||||
if (existing.removeTimer) {
|
||||
clearTimeout(existing.removeTimer);
|
||||
existing.removeTimer = null;
|
||||
}
|
||||
existing.bodyElement.replaceChildren(options.content);
|
||||
if (!this.panelOrder.includes(options.id)) {
|
||||
this.panelOrder.push(options.id);
|
||||
}
|
||||
this.reflow();
|
||||
return;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
return;
|
||||
}
|
||||
|
||||
const panel = document.createElement('section');
|
||||
panel.className = 'bottom-dock-panel is-entering';
|
||||
panel.dataset.panelId = options.id;
|
||||
|
||||
let closeButton: HTMLButtonElement | null = null;
|
||||
if (options.closable !== false) {
|
||||
closeButton = document.createElement('button');
|
||||
closeButton.className = 'bottom-dock-panel-close';
|
||||
closeButton.type = 'button';
|
||||
closeButton.setAttribute('aria-label', 'close-panel');
|
||||
closeButton.textContent = '×';
|
||||
closeButton.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
options.onClose(options.id);
|
||||
});
|
||||
panel.appendChild(closeButton);
|
||||
}
|
||||
|
||||
const body = document.createElement('div');
|
||||
body.className = 'bottom-dock-panel-body';
|
||||
body.appendChild(options.content);
|
||||
|
||||
panel.appendChild(body);
|
||||
this.root.appendChild(panel);
|
||||
|
||||
this.panelOrder.push(options.id);
|
||||
this.panelMap.set(options.id, {
|
||||
id: options.id,
|
||||
element: panel,
|
||||
bodyElement: body,
|
||||
closeButton,
|
||||
leaving: false,
|
||||
removeTimer: null
|
||||
});
|
||||
|
||||
this.resizeObserver?.observe(panel);
|
||||
|
||||
this.reflow();
|
||||
requestAnimationFrame(() => {
|
||||
panel.classList.remove('is-entering');
|
||||
});
|
||||
}
|
||||
|
||||
public removePanel(id: string): void {
|
||||
const panel = this.panelMap.get(id);
|
||||
if (!panel || panel.leaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
panel.leaving = true;
|
||||
panel.element.classList.add('is-leaving');
|
||||
|
||||
const orderIndex = this.panelOrder.indexOf(id);
|
||||
if (orderIndex >= 0) {
|
||||
this.panelOrder.splice(orderIndex, 1);
|
||||
}
|
||||
|
||||
this.reflow();
|
||||
panel.removeTimer = window.setTimeout(() => {
|
||||
this.resizeObserver?.unobserve(panel.element);
|
||||
panel.element.remove();
|
||||
this.panelMap.delete(id);
|
||||
panel.removeTimer = null;
|
||||
}, 220);
|
||||
}
|
||||
|
||||
public createPlaceholderContent(text: string): HTMLElement {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'bottom-dock-placeholder';
|
||||
el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.resizeObserver?.disconnect();
|
||||
window.removeEventListener('resize', this.handleWindowResize);
|
||||
this.panelMap.clear();
|
||||
this.panelOrder.length = 0;
|
||||
this.root.remove();
|
||||
}
|
||||
|
||||
private reflow(): void {
|
||||
let offset = 0;
|
||||
let maxWidth = 0;
|
||||
|
||||
this.panelOrder.forEach((id) => {
|
||||
const panel = this.panelMap.get(id);
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
panel.element.style.transform = `translateX(-50%) translateY(-${offset}px)`;
|
||||
offset += panel.element.offsetHeight + this.panelGap;
|
||||
maxWidth = Math.max(maxWidth, panel.element.offsetWidth);
|
||||
});
|
||||
|
||||
const panelCount = this.panelOrder.length;
|
||||
const totalHeight = panelCount > 0 ? offset - this.panelGap : 0;
|
||||
this.root.style.height = `${totalHeight}px`;
|
||||
this.root.style.width = panelCount > 0 ? `${maxWidth}px` : '0px';
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
import type { ThemeConfig } from '../../themes/types';
|
||||
import { IBimComponent } from '../../types/component';
|
||||
import { themeManager } from '../../services/theme';
|
||||
import type { EngineOptions, ModelLoadOptions, EngineInfo } from './types';
|
||||
import type {
|
||||
EngineOptions,
|
||||
ModelLoadOptions,
|
||||
EngineInfo,
|
||||
EngineSettings,
|
||||
EngineSettingsPatch,
|
||||
EngineSettingPreset,
|
||||
SettingPresetLists,
|
||||
} from './types';
|
||||
import { type MeasureMode, type ClearHeightDirection, type ClearHeightSelectType } from '../../types/measure';
|
||||
import type { MeasureUnit, MeasurePrecision } from '../measure-panel/types';
|
||||
import type { SectionBoxRange } from '../section-box-panel/types';
|
||||
@@ -11,7 +19,15 @@ import { createEngine as createEngineSDK } from 'iflow-engine-base';
|
||||
//import { createEngine as createEngineSDK } from '../../../../bim_engine_base/dist/bim-engine-sdk.es';
|
||||
import "../../../../bim_engine_base/dist/iflow-engine-base.css"
|
||||
|
||||
export type { EngineOptions, ModelLoadOptions, EngineInfo };
|
||||
export type {
|
||||
EngineOptions,
|
||||
ModelLoadOptions,
|
||||
EngineInfo,
|
||||
EngineSettings,
|
||||
EngineSettingsPatch,
|
||||
EngineSettingPreset,
|
||||
SettingPresetLists,
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建 Engine 实例的工厂函数
|
||||
@@ -678,6 +694,254 @@ export class Engine implements IBimComponent {
|
||||
|
||||
// ==================== 设置功能 ====================
|
||||
|
||||
private normalizePercent(value: number, fallback: number): number {
|
||||
if (!Number.isFinite(value)) return fallback;
|
||||
return Math.min(100, Math.max(0, Math.round(value)));
|
||||
}
|
||||
|
||||
private getSettingApi(): Record<string, unknown> | null {
|
||||
if (!this._isInitialized || !this.engine?.setting) {
|
||||
return null;
|
||||
}
|
||||
return this.engine.setting as Record<string, unknown>;
|
||||
}
|
||||
|
||||
private callSettingMethod<T = unknown>(methodName: string, ...args: unknown[]): T | undefined {
|
||||
const settingApi = this.getSettingApi();
|
||||
if (!settingApi) return undefined;
|
||||
const method = settingApi[methodName];
|
||||
if (typeof method !== 'function') return undefined;
|
||||
return (method as (...innerArgs: unknown[]) => T).apply(this.engine.setting, args);
|
||||
}
|
||||
|
||||
private hasSettingMethod(methodName: string): boolean {
|
||||
const settingApi = this.getSettingApi();
|
||||
if (!settingApi) return false;
|
||||
return typeof settingApi[methodName] === 'function';
|
||||
}
|
||||
|
||||
private mergeSettingsWithDefaults(raw: Partial<EngineSettings>): EngineSettings {
|
||||
const groundId = raw.display?.groundId ?? '0';
|
||||
const showGround = typeof raw.display?.showGround === 'boolean'
|
||||
? raw.display.showGround
|
||||
: groundId !== '0' && groundId !== '';
|
||||
|
||||
return {
|
||||
render: {
|
||||
mode: raw.render?.mode ?? 'advanced',
|
||||
contrast: this.normalizePercent(raw.render?.contrast ?? 50, 50),
|
||||
saturation: this.normalizePercent(raw.render?.saturation ?? 50, 50),
|
||||
shadowIntensity: this.normalizePercent(raw.render?.shadowIntensity ?? 50, 50),
|
||||
lightIntensity: this.normalizePercent(raw.render?.lightIntensity ?? 50, 50),
|
||||
gtaoIntensity: this.normalizePercent(raw.render?.gtaoIntensity ?? 50, 50),
|
||||
},
|
||||
display: {
|
||||
showEdge: Boolean(raw.display?.showEdge),
|
||||
edgeOpacity: this.normalizePercent(raw.display?.edgeOpacity ?? 30, 30),
|
||||
showGrid: Boolean(raw.display?.showGrid),
|
||||
showLevel: Boolean(raw.display?.showLevel),
|
||||
showGround,
|
||||
groundId,
|
||||
groundHeight: Number.isFinite(raw.display?.groundHeight) ? Number(raw.display?.groundHeight) : 0,
|
||||
},
|
||||
environment: {
|
||||
type: raw.environment?.type ?? 'none',
|
||||
hdrId: raw.environment?.hdrId ?? '0',
|
||||
hdrIntensity: this.normalizePercent(raw.environment?.hdrIntensity ?? 20, 20),
|
||||
skyPreset: raw.environment?.skyPreset ?? 'sunrise_clear',
|
||||
skyParams: raw.environment?.skyParams ?? {},
|
||||
skyIntensity: this.normalizePercent(raw.environment?.skyIntensity ?? 20, 20),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public getPresetLists(): SettingPresetLists {
|
||||
const fallback: SettingPresetLists = { ground: [], hdr: [], sky: [] };
|
||||
const settingApi = this.getSettingApi();
|
||||
if (!settingApi) return fallback;
|
||||
|
||||
const getter = settingApi.getPresetLists;
|
||||
const raw = typeof getter === 'function'
|
||||
? (getter.call(this.engine.setting) as Partial<SettingPresetLists> | null)
|
||||
: null;
|
||||
|
||||
const legacyGroundList = this.getGroundList().map((item) => ({
|
||||
id: item.id,
|
||||
names: [item.name, item.name, item.name],
|
||||
}));
|
||||
const legacyHdrList = this.getHDRBackgroundList().map((item) => ({
|
||||
id: item.id,
|
||||
names: [item.name, item.name, item.name],
|
||||
}));
|
||||
|
||||
return {
|
||||
ground: Array.isArray(raw?.ground) && raw.ground.length > 0 ? raw.ground : legacyGroundList,
|
||||
hdr: Array.isArray(raw?.hdr) && raw.hdr.length > 0 ? raw.hdr : legacyHdrList,
|
||||
sky: Array.isArray(raw?.sky) ? raw.sky : [],
|
||||
};
|
||||
}
|
||||
|
||||
public getSettings(): EngineSettings {
|
||||
const raw = this.callSettingMethod<Partial<EngineSettings>>('getSettings');
|
||||
if (raw && typeof raw === 'object') {
|
||||
return this.mergeSettingsWithDefaults(raw);
|
||||
}
|
||||
|
||||
return {
|
||||
render: {
|
||||
mode: this.getRenderMode() as EngineSettings['render']['mode'],
|
||||
contrast: this.normalizePercent(this.getSceneContrast(), 50),
|
||||
saturation: this.normalizePercent(this.getSceneSaturation(), 50),
|
||||
shadowIntensity: 50,
|
||||
lightIntensity: this.normalizePercent(this.getAmbientLightIntensity(), 50),
|
||||
gtaoIntensity: 50,
|
||||
},
|
||||
display: {
|
||||
showEdge: this.getModelEdgeActive(),
|
||||
edgeOpacity: 30,
|
||||
showGrid: false,
|
||||
showLevel: false,
|
||||
showGround: this.getGroundId() !== '0' && this.getGroundId() !== '',
|
||||
groundId: this.getGroundId() || '0',
|
||||
groundHeight: this.getGroundElevation(),
|
||||
},
|
||||
environment: {
|
||||
type: this.getHDRBackgroundId() && this.getHDRBackgroundId() !== '0' ? 'hdr' : 'none',
|
||||
hdrId: this.getHDRBackgroundId() || '0',
|
||||
hdrIntensity: 20,
|
||||
skyPreset: 'sunrise_clear',
|
||||
skyParams: {},
|
||||
skyIntensity: 20,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public async setSettings(settings: EngineSettingsPatch): Promise<void> {
|
||||
if (!this._isInitialized || !this.engine?.setting) {
|
||||
console.warn('[Engine] Cannot set settings: engine not initialized.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.hasSettingMethod('setSettings')) {
|
||||
try {
|
||||
const setSettingsResult = this.callSettingMethod('setSettings', settings);
|
||||
await Promise.resolve(setSettingsResult);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.warn('[Engine] Native setSettings failed, fallback to compatibility mode.', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (settings.render?.mode) {
|
||||
this.setRenderMode(settings.render.mode);
|
||||
}
|
||||
if (typeof settings.render?.lightIntensity === 'number') {
|
||||
this.setAmbientLightIntensity(settings.render.lightIntensity);
|
||||
}
|
||||
if (typeof settings.render?.contrast === 'number') {
|
||||
this.setSceneContrast(settings.render.contrast);
|
||||
}
|
||||
if (typeof settings.render?.saturation === 'number') {
|
||||
this.setSceneSaturation(settings.render.saturation);
|
||||
}
|
||||
|
||||
if (typeof settings.display?.showEdge === 'boolean') {
|
||||
if (settings.display.showEdge) {
|
||||
this.activeModelEdge();
|
||||
} else {
|
||||
this.disActiveModelEdge();
|
||||
}
|
||||
}
|
||||
if (typeof settings.display?.edgeOpacity === 'number') {
|
||||
this.callSettingMethod('setEdgeOpacity', settings.display.edgeOpacity);
|
||||
}
|
||||
if (typeof settings.display?.showGrid === 'boolean') {
|
||||
this.callSettingMethod('setShowGrid', settings.display.showGrid);
|
||||
}
|
||||
if (typeof settings.display?.showLevel === 'boolean') {
|
||||
this.callSettingMethod('setShowLevel', settings.display.showLevel);
|
||||
}
|
||||
if (typeof settings.display?.groundId === 'string') {
|
||||
this.setGroundId(settings.display.groundId);
|
||||
}
|
||||
if (typeof settings.display?.groundHeight === 'number') {
|
||||
this.setGroundElevation(settings.display.groundHeight);
|
||||
}
|
||||
|
||||
if (typeof settings.environment?.type === 'string') {
|
||||
const result = this.callSettingMethod('setEnvironmentType', settings.environment.type);
|
||||
if (result !== undefined) {
|
||||
await Promise.resolve(result);
|
||||
} else if (settings.environment.type === 'none') {
|
||||
this.setHDRBackgroundId('0');
|
||||
}
|
||||
}
|
||||
if (typeof settings.environment?.hdrId === 'string') {
|
||||
const result = this.callSettingMethod('setHdrId', settings.environment.hdrId);
|
||||
if (result !== undefined) {
|
||||
await Promise.resolve(result);
|
||||
} else {
|
||||
this.setHDRBackgroundId(settings.environment.hdrId);
|
||||
}
|
||||
}
|
||||
if (typeof settings.environment?.hdrIntensity === 'number') {
|
||||
this.callSettingMethod('setHdrIntensity', settings.environment.hdrIntensity);
|
||||
}
|
||||
if (typeof settings.environment?.skyPreset === 'string') {
|
||||
this.callSettingMethod('setSkyPreset', settings.environment.skyPreset);
|
||||
}
|
||||
if (typeof settings.environment?.skyIntensity === 'number') {
|
||||
this.callSettingMethod('setSkyIntensity', settings.environment.skyIntensity);
|
||||
}
|
||||
if (settings.environment?.skyParams && typeof settings.environment.skyParams === 'object') {
|
||||
this.callSettingMethod('setSkyParams', settings.environment.skyParams);
|
||||
}
|
||||
|
||||
if (typeof settings.render?.shadowIntensity === 'number') {
|
||||
this.callSettingMethod('setShadowIntensity', settings.render.shadowIntensity);
|
||||
}
|
||||
if (typeof settings.render?.gtaoIntensity === 'number') {
|
||||
this.callSettingMethod('setGtaoIntensity', settings.render.gtaoIntensity);
|
||||
}
|
||||
}
|
||||
|
||||
public async resetToDefault(): Promise<void> {
|
||||
const settingApi = this.getSettingApi();
|
||||
const resetApi = settingApi?.resetToDefault;
|
||||
if (typeof resetApi === 'function') {
|
||||
await Promise.resolve(resetApi.call(this.engine.setting));
|
||||
return;
|
||||
}
|
||||
|
||||
await this.setSettings({
|
||||
render: {
|
||||
mode: 'advanced',
|
||||
contrast: 50,
|
||||
saturation: 50,
|
||||
shadowIntensity: 50,
|
||||
lightIntensity: 50,
|
||||
gtaoIntensity: 50,
|
||||
},
|
||||
display: {
|
||||
showEdge: false,
|
||||
edgeOpacity: 30,
|
||||
showGrid: false,
|
||||
showLevel: false,
|
||||
showGround: false,
|
||||
groundId: '0',
|
||||
groundHeight: 0,
|
||||
},
|
||||
environment: {
|
||||
type: 'none',
|
||||
hdrId: '0',
|
||||
hdrIntensity: 20,
|
||||
skyPreset: 'sunrise_clear',
|
||||
skyParams: {},
|
||||
skyIntensity: 20,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---- 边线 ----
|
||||
|
||||
/** 启用模型边线显示 */
|
||||
|
||||
@@ -31,3 +31,78 @@ export interface ModelLoadOptions {
|
||||
/** 模型 ID(可选,如果不提供则自动生成) */
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface RenderSettings {
|
||||
mode: 'simple' | 'balance' | 'advanced';
|
||||
contrast: number;
|
||||
saturation: number;
|
||||
shadowIntensity: number;
|
||||
lightIntensity: number;
|
||||
gtaoIntensity: number;
|
||||
}
|
||||
|
||||
export interface DisplaySettings {
|
||||
showEdge: boolean;
|
||||
edgeOpacity: number;
|
||||
showGrid: boolean;
|
||||
showLevel: boolean;
|
||||
showGround: boolean;
|
||||
groundId: string;
|
||||
groundHeight: number;
|
||||
}
|
||||
|
||||
export interface SkyParams {
|
||||
turbidity?: number;
|
||||
rayleigh?: number;
|
||||
mieCoefficient?: number;
|
||||
mieDirectionalG?: number;
|
||||
elevation?: number;
|
||||
azimuth?: number;
|
||||
exposure?: number;
|
||||
orthoExposureScale?: number;
|
||||
cloudCoverage?: number;
|
||||
cloudDensity?: number;
|
||||
cloudElevation?: number;
|
||||
showSunDisc?: boolean;
|
||||
}
|
||||
|
||||
export interface EnvironmentSettings {
|
||||
type: 'none' | 'hdr' | 'sky';
|
||||
hdrId: string;
|
||||
hdrIntensity: number;
|
||||
skyPreset: string;
|
||||
skyParams: SkyParams;
|
||||
skyIntensity: number;
|
||||
}
|
||||
|
||||
export interface EngineSettings {
|
||||
render: RenderSettings;
|
||||
display: DisplaySettings;
|
||||
environment: EnvironmentSettings;
|
||||
}
|
||||
|
||||
export interface EngineSettingsPatch {
|
||||
render?: Partial<RenderSettings>;
|
||||
display?: Partial<DisplaySettings>;
|
||||
environment?: Partial<EnvironmentSettings>;
|
||||
}
|
||||
|
||||
export interface EngineSettingPreset {
|
||||
id: string;
|
||||
presetName: string;
|
||||
isDefault: boolean;
|
||||
settings: EngineSettings;
|
||||
readonly?: boolean;
|
||||
source?: 'sdk-default' | 'external' | 'user';
|
||||
}
|
||||
|
||||
export interface PresetListItem {
|
||||
id: string;
|
||||
names: string[];
|
||||
}
|
||||
|
||||
export interface SettingPresetLists {
|
||||
ground: PresetListItem[];
|
||||
hdr: PresetListItem[];
|
||||
sky: PresetListItem[];
|
||||
}
|
||||
|
||||
335
src/components/measure-dock-panel/index.css
Normal file
335
src/components/measure-dock-panel/index.css
Normal file
@@ -0,0 +1,335 @@
|
||||
.measure-dock-panel {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: color-mix(in srgb, var(--bim-bg-elevated, #e8ecf2) 92%, #ffffff 8%);
|
||||
box-sizing: border-box;
|
||||
color: var(--bim-text-secondary, #475569);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.measure-dock-panel-main {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.measure-dock-panel-settings {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-height: 74px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.measure-dock-settings-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.measure-dock-settings-label {
|
||||
color: var(--bim-text-secondary, #64748b);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.measure-dock-settings-select {
|
||||
width: 88px;
|
||||
height: 24px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.36);
|
||||
background: color-mix(in srgb, var(--bim-bg-inset, #edf1f6) 90%, #ffffff 10%);
|
||||
color: var(--bim-text-primary, #0f172a);
|
||||
font-size: 12px;
|
||||
padding: 0 6px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.measure-dock-settings-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.measure-dock-settings-btn {
|
||||
height: 24px;
|
||||
min-width: 52px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.36);
|
||||
background: color-mix(in srgb, var(--bim-bg-inset, #edf1f6) 90%, #ffffff 10%);
|
||||
color: var(--bim-text-secondary, #64748b);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.measure-dock-settings-btn.is-save {
|
||||
border-color: color-mix(in srgb, var(--bim-primary, #4f88ff) 46%, transparent 54%);
|
||||
color: color-mix(in srgb, var(--bim-primary, #4f88ff) 78%, #6f9dff 22%);
|
||||
}
|
||||
|
||||
.measure-dock-panel-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.measure-dock-clearheight-options {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
background: color-mix(in srgb, var(--bim-bg-inset, #edf1f6) 90%, #ffffff 10%);
|
||||
}
|
||||
|
||||
.measure-dock-clearheight-options.is-visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.measure-dock-clearheight-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.measure-dock-clearheight-label {
|
||||
min-width: 60px;
|
||||
color: var(--bim-text-secondary, #64748b);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.measure-dock-clearheight-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.measure-dock-clearheight-btn {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.36);
|
||||
background: color-mix(in srgb, var(--bim-bg-elevated, #f8fafc) 88%, #ffffff 12%);
|
||||
color: var(--bim-text-secondary, #64748b);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.measure-dock-clearheight-btn:hover {
|
||||
border-color: rgba(148, 163, 184, 0.56);
|
||||
}
|
||||
|
||||
.measure-dock-clearheight-btn.is-active {
|
||||
border-color: color-mix(in srgb, var(--bim-primary, #4f88ff) 70%, #9db9ff 30%);
|
||||
background: color-mix(in srgb, var(--bim-primary-subtle, rgba(96, 140, 255, 0.18)) 72%, #ffffff 28%);
|
||||
color: color-mix(in srgb, var(--bim-primary, #4f88ff) 78%, #6f9dff 22%);
|
||||
}
|
||||
|
||||
.measure-dock-panel-mode-zone {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.measure-dock-panel-mode-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 32px);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.measure-dock-panel-mode-row-secondary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.measure-dock-panel-mode-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
background: color-mix(in srgb, var(--bim-bg-inset, #edf1f6) 92%, #ffffff 8%);
|
||||
color: color-mix(in srgb, var(--bim-text-secondary, #64748b) 94%, #475569 6%);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.measure-dock-panel-mode-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.measure-dock-panel-mode-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.measure-dock-panel-mode-btn:hover {
|
||||
border-color: rgba(148, 163, 184, 0.5);
|
||||
background: color-mix(in srgb, var(--bim-component-bg-hover, #dce5f2) 64%, #ffffff 36%);
|
||||
}
|
||||
|
||||
.measure-dock-panel-mode-btn.is-active {
|
||||
border-color: color-mix(in srgb, var(--bim-primary, #4f88ff) 70%, #9db9ff 30%);
|
||||
background: color-mix(in srgb, var(--bim-primary-subtle, rgba(96, 140, 255, 0.18)) 72%, #ffffff 28%);
|
||||
color: color-mix(in srgb, var(--bim-primary, #4f88ff) 78%, #6f9dff 22%);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--bim-primary, #4f88ff) 35%, transparent 65%);
|
||||
}
|
||||
|
||||
.measure-dock-panel-actions {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.measure-dock-panel-action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.22);
|
||||
background: color-mix(in srgb, var(--bim-bg-inset, #edf1f6) 92%, #ffffff 8%);
|
||||
color: var(--bim-text-secondary, #475569);
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.measure-dock-panel-action-btn:hover {
|
||||
border-color: rgba(148, 163, 184, 0.5);
|
||||
color: var(--bim-text-primary, #0f172a);
|
||||
}
|
||||
|
||||
.measure-dock-panel-action-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.measure-dock-panel-action-expand {
|
||||
width: 16px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.measure-dock-panel-action-expand.is-expanded {
|
||||
height: 74px;
|
||||
}
|
||||
|
||||
.measure-dock-panel-action-expand.is-collapsed {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.measure-dock-panel-action-clear {
|
||||
background: color-mix(in srgb, var(--bim-danger, #ef4444) 16%, #ffffff 84%);
|
||||
border-color: color-mix(in srgb, var(--bim-danger, #ef4444) 26%, transparent 74%);
|
||||
color: var(--bim-danger, #ef4444);
|
||||
}
|
||||
|
||||
.measure-dock-panel-action-clear:hover {
|
||||
border-color: color-mix(in srgb, var(--bim-danger, #ef4444) 40%, transparent 60%);
|
||||
color: var(--bim-danger, #ef4444);
|
||||
}
|
||||
|
||||
.measure-dock-panel-action-settings {
|
||||
color: color-mix(in srgb, var(--bim-text-primary, #0f172a) 86%, #000 14%);
|
||||
}
|
||||
|
||||
.measure-dock-panel-mode-row .measure-dock-panel-action-btn {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.measure-dock-panel-action-expand svg {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.measure-dock-panel-action-expand.is-expanded svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.measure-dock-panel [data-tooltip] {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.measure-dock-panel [data-tooltip]::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: calc(100% + 6px);
|
||||
transform: translateX(-50%);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
background: rgba(15, 23, 42, 0.92);
|
||||
color: #f8fafc;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
z-index: 8;
|
||||
transition: opacity 60ms ease;
|
||||
}
|
||||
|
||||
.measure-dock-panel [data-tooltip]:hover::after,
|
||||
.measure-dock-panel [data-tooltip]:focus-visible::after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.measure-dock-panel-top {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.measure-dock-clearheight-group {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.measure-dock-panel-actions {
|
||||
width: 100%;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.measure-dock-panel-action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.measure-dock-panel-mode-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.measure-dock-panel-action-expand {
|
||||
width: 16px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.measure-dock-panel-action-expand.is-expanded {
|
||||
height: 74px;
|
||||
}
|
||||
}
|
||||
582
src/components/measure-dock-panel/index.ts
Normal file
582
src/components/measure-dock-panel/index.ts
Normal file
@@ -0,0 +1,582 @@
|
||||
import './index.css';
|
||||
import type { ThemeConfig } from '../../themes/types';
|
||||
import { IBimComponent } from '../../types/component';
|
||||
import { t } from '../../services/locale';
|
||||
import { MEASURE_TYPES, type ClearHeightDirection, type ClearHeightSelectType, type MeasureMode } from '../../types/measure';
|
||||
import { getIcon } from '../../utils/icon-manager';
|
||||
import type { MeasureConfig, MeasurePrecision, MeasureUnit } from '../measure-panel/types';
|
||||
|
||||
const PRIMARY_MODES: MeasureMode[] = ['distance', 'clearHeight', 'clearDistance', 'elevation'];
|
||||
const SECONDARY_MODES: MeasureMode[] = ['point', 'angle', 'area', 'slope'];
|
||||
const CONFIG_CACHE_KEY = 'bim-engine:measure:config';
|
||||
const DEFAULT_CONFIG: MeasureConfig = {
|
||||
unit: 'mm',
|
||||
precision: 2
|
||||
};
|
||||
|
||||
export interface MeasureDockPanelOptions {
|
||||
defaultMode?: MeasureMode;
|
||||
defaultExpanded?: boolean;
|
||||
defaultClearHeightDirection?: ClearHeightDirection;
|
||||
defaultClearHeightSelectType?: ClearHeightSelectType;
|
||||
onModeChange?: (mode: MeasureMode) => void;
|
||||
onClearAll?: () => void;
|
||||
onSettings?: () => void;
|
||||
onConfigSave?: (config: MeasureConfig) => void;
|
||||
onClearHeightDirectionChange?: (direction: ClearHeightDirection) => void;
|
||||
onClearHeightSelectTypeChange?: (selectType: ClearHeightSelectType) => void;
|
||||
}
|
||||
|
||||
export class MeasureDockPanel implements IBimComponent {
|
||||
public readonly element: HTMLElement;
|
||||
private readonly options: MeasureDockPanelOptions;
|
||||
|
||||
private readonly modeButtons: Map<MeasureMode, HTMLButtonElement> = new Map();
|
||||
private readonly clearBtn: HTMLButtonElement;
|
||||
private readonly expandBtn: HTMLButtonElement;
|
||||
private readonly settingsBtn: HTMLButtonElement;
|
||||
private readonly secondaryRow: HTMLElement;
|
||||
private readonly clearHeightOptions: HTMLElement;
|
||||
private readonly clearHeightDirectionLabel: HTMLElement;
|
||||
private readonly clearHeightSelectTypeLabel: HTMLElement;
|
||||
private readonly directionButtons: Map<ClearHeightDirection, HTMLButtonElement> = new Map();
|
||||
private readonly selectTypeButtons: Map<ClearHeightSelectType, HTMLButtonElement> = new Map();
|
||||
private readonly mainView: HTMLElement;
|
||||
private readonly settingsView: HTMLElement;
|
||||
private readonly settingsUnitLabel: HTMLElement;
|
||||
private readonly settingsPrecisionLabel: HTMLElement;
|
||||
private readonly settingsUnitSelect: HTMLSelectElement;
|
||||
private readonly settingsPrecisionSelect: HTMLSelectElement;
|
||||
private readonly settingsSaveBtn: HTMLButtonElement;
|
||||
private readonly settingsBackBtn: HTMLButtonElement;
|
||||
|
||||
private activeMode: MeasureMode;
|
||||
private isExpanded: boolean;
|
||||
private clearHeightDirection: ClearHeightDirection;
|
||||
private clearHeightSelectType: ClearHeightSelectType;
|
||||
private view: 'main' | 'settings' = 'main';
|
||||
private config: MeasureConfig;
|
||||
private lockedWidthPx: number | null = null;
|
||||
|
||||
constructor(options: MeasureDockPanelOptions = {}) {
|
||||
this.options = options;
|
||||
this.activeMode = options.defaultMode ?? 'distance';
|
||||
this.isExpanded = options.defaultExpanded ?? false;
|
||||
this.clearHeightDirection = options.defaultClearHeightDirection ?? 1;
|
||||
this.clearHeightSelectType = options.defaultClearHeightSelectType ?? 'point';
|
||||
this.config = this.loadConfigFromCache() ?? { ...DEFAULT_CONFIG };
|
||||
|
||||
const {
|
||||
root,
|
||||
clearBtn,
|
||||
expandBtn,
|
||||
settingsBtn,
|
||||
secondaryRow,
|
||||
clearHeightOptions,
|
||||
clearHeightDirectionLabel,
|
||||
clearHeightSelectTypeLabel,
|
||||
mainView,
|
||||
settingsView,
|
||||
settingsUnitLabel,
|
||||
settingsPrecisionLabel,
|
||||
settingsUnitSelect,
|
||||
settingsPrecisionSelect,
|
||||
settingsSaveBtn,
|
||||
settingsBackBtn
|
||||
} = this.createDom();
|
||||
this.element = root;
|
||||
this.clearBtn = clearBtn;
|
||||
this.expandBtn = expandBtn;
|
||||
this.settingsBtn = settingsBtn;
|
||||
this.secondaryRow = secondaryRow;
|
||||
this.clearHeightOptions = clearHeightOptions;
|
||||
this.clearHeightDirectionLabel = clearHeightDirectionLabel;
|
||||
this.clearHeightSelectTypeLabel = clearHeightSelectTypeLabel;
|
||||
this.mainView = mainView;
|
||||
this.settingsView = settingsView;
|
||||
this.settingsUnitLabel = settingsUnitLabel;
|
||||
this.settingsPrecisionLabel = settingsPrecisionLabel;
|
||||
this.settingsUnitSelect = settingsUnitSelect;
|
||||
this.settingsPrecisionSelect = settingsPrecisionSelect;
|
||||
this.settingsSaveBtn = settingsSaveBtn;
|
||||
this.settingsBackBtn = settingsBackBtn;
|
||||
}
|
||||
|
||||
public init(): void {
|
||||
this.setLocales();
|
||||
this.syncSettingsFormFromConfig();
|
||||
this.applyExpandedState();
|
||||
this.applyClearHeightOptionsState();
|
||||
this.applyViewState();
|
||||
this.syncActiveMode(this.activeMode);
|
||||
}
|
||||
|
||||
public setTheme(theme: ThemeConfig): void {
|
||||
const style = this.element.style;
|
||||
style.setProperty('--bim-text-primary', theme.textPrimary);
|
||||
style.setProperty('--bim-text-secondary', theme.textSecondary);
|
||||
style.setProperty('--bim-text-tertiary', theme.textTertiary);
|
||||
style.setProperty('--bim-border-default', theme.borderDefault);
|
||||
style.setProperty('--bim-border-strong', theme.borderStrong);
|
||||
style.setProperty('--bim-bg-inset', theme.bgInset);
|
||||
style.setProperty('--bim-bg-elevated', theme.bgElevated);
|
||||
style.setProperty('--bim-primary', theme.primary);
|
||||
style.setProperty('--bim-primary-subtle', theme.primarySubtle);
|
||||
style.setProperty('--bim-danger', theme.danger);
|
||||
style.setProperty('--bim-component-bg-hover', theme.componentBgHover);
|
||||
}
|
||||
|
||||
public setLocales(): void {
|
||||
for (const [mode, btn] of this.modeButtons.entries()) {
|
||||
const text = t(`measure.modes.${mode}`);
|
||||
btn.dataset.tooltip = text;
|
||||
btn.setAttribute('aria-label', text);
|
||||
}
|
||||
|
||||
const clearText = t('measure.actions.clearAll');
|
||||
this.clearBtn.dataset.tooltip = clearText;
|
||||
this.clearBtn.setAttribute('aria-label', clearText);
|
||||
|
||||
const expandText = this.isExpanded ? t('measure.actions.collapse') : t('measure.actions.expand');
|
||||
delete this.expandBtn.dataset.tooltip;
|
||||
this.expandBtn.setAttribute('aria-label', expandText);
|
||||
|
||||
const settingsText = t('measure.actions.settings');
|
||||
this.settingsBtn.dataset.tooltip = settingsText;
|
||||
this.settingsBtn.setAttribute('aria-label', settingsText);
|
||||
|
||||
this.clearHeightDirectionLabel.textContent = t('measure.clearHeight.direction');
|
||||
this.clearHeightSelectTypeLabel.textContent = t('measure.clearHeight.selectType');
|
||||
|
||||
this.directionButtons.get(0)!.textContent = t('measure.clearHeight.directionDown');
|
||||
this.directionButtons.get(1)!.textContent = t('measure.clearHeight.directionUp');
|
||||
this.selectTypeButtons.get('point')!.textContent = t('measure.clearHeight.selectPoint');
|
||||
this.selectTypeButtons.get('element')!.textContent = t('measure.clearHeight.selectElement');
|
||||
|
||||
this.settingsUnitLabel.textContent = t('measure.settings.unit');
|
||||
this.settingsPrecisionLabel.textContent = t('measure.settings.precision');
|
||||
this.settingsSaveBtn.textContent = t('measure.settings.save');
|
||||
this.settingsBackBtn.textContent = t('measure.settings.cancel');
|
||||
}
|
||||
|
||||
public switchMode(mode: MeasureMode, triggerCallback: boolean = true): void {
|
||||
this.activeMode = mode;
|
||||
this.syncActiveMode(mode);
|
||||
this.applyClearHeightOptionsState();
|
||||
this.closeSettingsView();
|
||||
if (triggerCallback) {
|
||||
this.options.onModeChange?.(mode);
|
||||
}
|
||||
|
||||
if (mode === 'clearHeight') {
|
||||
this.options.onClearHeightDirectionChange?.(this.clearHeightDirection);
|
||||
this.options.onClearHeightSelectTypeChange?.(this.clearHeightSelectType);
|
||||
}
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.element.remove();
|
||||
}
|
||||
|
||||
public getConfig(): MeasureConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
private createDom(): {
|
||||
root: HTMLElement;
|
||||
clearBtn: HTMLButtonElement;
|
||||
expandBtn: HTMLButtonElement;
|
||||
settingsBtn: HTMLButtonElement;
|
||||
secondaryRow: HTMLElement;
|
||||
clearHeightOptions: HTMLElement;
|
||||
clearHeightDirectionLabel: HTMLElement;
|
||||
clearHeightSelectTypeLabel: HTMLElement;
|
||||
mainView: HTMLElement;
|
||||
settingsView: HTMLElement;
|
||||
settingsUnitLabel: HTMLElement;
|
||||
settingsPrecisionLabel: HTMLElement;
|
||||
settingsUnitSelect: HTMLSelectElement;
|
||||
settingsPrecisionSelect: HTMLSelectElement;
|
||||
settingsSaveBtn: HTMLButtonElement;
|
||||
settingsBackBtn: HTMLButtonElement;
|
||||
} {
|
||||
const root = document.createElement('div');
|
||||
root.className = 'measure-dock-panel';
|
||||
|
||||
const mainView = document.createElement('div');
|
||||
mainView.className = 'measure-dock-panel-main';
|
||||
|
||||
const clearHeightOptions = document.createElement('div');
|
||||
clearHeightOptions.className = 'measure-dock-clearheight-options';
|
||||
|
||||
const directionGroup = document.createElement('div');
|
||||
directionGroup.className = 'measure-dock-clearheight-group';
|
||||
const clearHeightDirectionLabel = document.createElement('span');
|
||||
clearHeightDirectionLabel.className = 'measure-dock-clearheight-label';
|
||||
const directionButtons = document.createElement('div');
|
||||
directionButtons.className = 'measure-dock-clearheight-buttons';
|
||||
|
||||
const directionDown = this.createClearHeightOptionButton(() => {
|
||||
this.setClearHeightDirection(0);
|
||||
});
|
||||
const directionUp = this.createClearHeightOptionButton(() => {
|
||||
this.setClearHeightDirection(1);
|
||||
});
|
||||
this.directionButtons.set(0, directionDown);
|
||||
this.directionButtons.set(1, directionUp);
|
||||
directionButtons.appendChild(directionDown);
|
||||
directionButtons.appendChild(directionUp);
|
||||
directionGroup.appendChild(clearHeightDirectionLabel);
|
||||
directionGroup.appendChild(directionButtons);
|
||||
|
||||
const selectTypeGroup = document.createElement('div');
|
||||
selectTypeGroup.className = 'measure-dock-clearheight-group';
|
||||
const clearHeightSelectTypeLabel = document.createElement('span');
|
||||
clearHeightSelectTypeLabel.className = 'measure-dock-clearheight-label';
|
||||
const selectTypeButtons = document.createElement('div');
|
||||
selectTypeButtons.className = 'measure-dock-clearheight-buttons';
|
||||
|
||||
const selectPoint = this.createClearHeightOptionButton(() => {
|
||||
this.setClearHeightSelectType('point');
|
||||
});
|
||||
const selectElement = this.createClearHeightOptionButton(() => {
|
||||
this.setClearHeightSelectType('element');
|
||||
});
|
||||
this.selectTypeButtons.set('point', selectPoint);
|
||||
this.selectTypeButtons.set('element', selectElement);
|
||||
selectTypeButtons.appendChild(selectPoint);
|
||||
selectTypeButtons.appendChild(selectElement);
|
||||
selectTypeGroup.appendChild(clearHeightSelectTypeLabel);
|
||||
selectTypeGroup.appendChild(selectTypeButtons);
|
||||
|
||||
clearHeightOptions.appendChild(directionGroup);
|
||||
clearHeightOptions.appendChild(selectTypeGroup);
|
||||
|
||||
const top = document.createElement('div');
|
||||
top.className = 'measure-dock-panel-top';
|
||||
|
||||
const modeZone = document.createElement('div');
|
||||
modeZone.className = 'measure-dock-panel-mode-zone';
|
||||
|
||||
const primaryRow = document.createElement('div');
|
||||
primaryRow.className = 'measure-dock-panel-mode-row';
|
||||
PRIMARY_MODES.forEach((mode) => {
|
||||
primaryRow.appendChild(this.createModeButton(mode));
|
||||
});
|
||||
|
||||
const secondaryRow = document.createElement('div');
|
||||
secondaryRow.className = 'measure-dock-panel-mode-row measure-dock-panel-mode-row-secondary';
|
||||
SECONDARY_MODES.forEach((mode) => {
|
||||
secondaryRow.appendChild(this.createModeButton(mode));
|
||||
});
|
||||
|
||||
const clearBtn = this.createIconButton('measure-dock-panel-action-clear', getIcon('delete'));
|
||||
clearBtn.addEventListener('click', () => {
|
||||
this.options.onClearAll?.();
|
||||
});
|
||||
|
||||
const settingsBtn = this.createIconButton('measure-dock-panel-action-settings', getIcon('settings'));
|
||||
settingsBtn.addEventListener('click', () => {
|
||||
this.openSettingsView();
|
||||
this.options.onSettings?.();
|
||||
});
|
||||
|
||||
primaryRow.appendChild(clearBtn);
|
||||
secondaryRow.appendChild(settingsBtn);
|
||||
|
||||
modeZone.appendChild(primaryRow);
|
||||
modeZone.appendChild(secondaryRow);
|
||||
|
||||
const actionZone = document.createElement('div');
|
||||
actionZone.className = 'measure-dock-panel-actions';
|
||||
|
||||
const expandBtn = this.createIconButton('measure-dock-panel-action-expand', getIcon('expand'));
|
||||
expandBtn.addEventListener('click', () => {
|
||||
this.isExpanded = !this.isExpanded;
|
||||
this.applyExpandedState();
|
||||
this.setLocales();
|
||||
});
|
||||
|
||||
actionZone.appendChild(expandBtn);
|
||||
|
||||
top.appendChild(modeZone);
|
||||
top.appendChild(actionZone);
|
||||
|
||||
mainView.appendChild(clearHeightOptions);
|
||||
mainView.appendChild(top);
|
||||
|
||||
const {
|
||||
settingsView,
|
||||
settingsUnitLabel,
|
||||
settingsPrecisionLabel,
|
||||
settingsUnitSelect,
|
||||
settingsPrecisionSelect,
|
||||
settingsSaveBtn,
|
||||
settingsBackBtn
|
||||
} = this.createSettingsView();
|
||||
|
||||
root.appendChild(mainView);
|
||||
root.appendChild(settingsView);
|
||||
|
||||
return {
|
||||
root,
|
||||
clearBtn,
|
||||
expandBtn,
|
||||
settingsBtn,
|
||||
secondaryRow,
|
||||
clearHeightOptions,
|
||||
clearHeightDirectionLabel,
|
||||
clearHeightSelectTypeLabel,
|
||||
mainView,
|
||||
settingsView,
|
||||
settingsUnitLabel,
|
||||
settingsPrecisionLabel,
|
||||
settingsUnitSelect,
|
||||
settingsPrecisionSelect,
|
||||
settingsSaveBtn,
|
||||
settingsBackBtn
|
||||
};
|
||||
}
|
||||
|
||||
private createSettingsView(): {
|
||||
settingsView: HTMLElement;
|
||||
settingsUnitLabel: HTMLElement;
|
||||
settingsPrecisionLabel: HTMLElement;
|
||||
settingsUnitSelect: HTMLSelectElement;
|
||||
settingsPrecisionSelect: HTMLSelectElement;
|
||||
settingsSaveBtn: HTMLButtonElement;
|
||||
settingsBackBtn: HTMLButtonElement;
|
||||
} {
|
||||
const settingsView = document.createElement('div');
|
||||
settingsView.className = 'measure-dock-panel-settings';
|
||||
|
||||
const unitRow = document.createElement('div');
|
||||
unitRow.className = 'measure-dock-settings-row';
|
||||
const settingsUnitLabel = document.createElement('span');
|
||||
settingsUnitLabel.className = 'measure-dock-settings-label';
|
||||
const settingsUnitSelect = document.createElement('select');
|
||||
settingsUnitSelect.className = 'measure-dock-settings-select';
|
||||
['m', 'cm', 'mm', 'km'].forEach((unit) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = unit;
|
||||
option.textContent = unit;
|
||||
settingsUnitSelect.appendChild(option);
|
||||
});
|
||||
unitRow.appendChild(settingsUnitLabel);
|
||||
unitRow.appendChild(settingsUnitSelect);
|
||||
|
||||
const precisionRow = document.createElement('div');
|
||||
precisionRow.className = 'measure-dock-settings-row';
|
||||
const settingsPrecisionLabel = document.createElement('span');
|
||||
settingsPrecisionLabel.className = 'measure-dock-settings-label';
|
||||
const settingsPrecisionSelect = document.createElement('select');
|
||||
settingsPrecisionSelect.className = 'measure-dock-settings-select';
|
||||
[0, 1, 2, 3].forEach((precision) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = String(precision);
|
||||
option.textContent = precision === 0 ? '0' : `0.${'0'.repeat(precision)}`;
|
||||
settingsPrecisionSelect.appendChild(option);
|
||||
});
|
||||
precisionRow.appendChild(settingsPrecisionLabel);
|
||||
precisionRow.appendChild(settingsPrecisionSelect);
|
||||
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'measure-dock-settings-actions';
|
||||
const settingsSaveBtn = document.createElement('button');
|
||||
settingsSaveBtn.type = 'button';
|
||||
settingsSaveBtn.className = 'measure-dock-settings-btn is-save';
|
||||
settingsSaveBtn.addEventListener('click', () => {
|
||||
this.saveSettings();
|
||||
});
|
||||
const settingsBackBtn = document.createElement('button');
|
||||
settingsBackBtn.type = 'button';
|
||||
settingsBackBtn.className = 'measure-dock-settings-btn is-back';
|
||||
settingsBackBtn.addEventListener('click', () => {
|
||||
this.closeSettingsView();
|
||||
});
|
||||
actions.appendChild(settingsSaveBtn);
|
||||
actions.appendChild(settingsBackBtn);
|
||||
|
||||
settingsView.appendChild(unitRow);
|
||||
settingsView.appendChild(precisionRow);
|
||||
settingsView.appendChild(actions);
|
||||
|
||||
return {
|
||||
settingsView,
|
||||
settingsUnitLabel,
|
||||
settingsPrecisionLabel,
|
||||
settingsUnitSelect,
|
||||
settingsPrecisionSelect,
|
||||
settingsSaveBtn,
|
||||
settingsBackBtn
|
||||
};
|
||||
}
|
||||
|
||||
private createClearHeightOptionButton(onClick: () => void): HTMLButtonElement {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'measure-dock-clearheight-btn';
|
||||
button.addEventListener('click', onClick);
|
||||
return button;
|
||||
}
|
||||
|
||||
private createModeButton(mode: MeasureMode): HTMLButtonElement {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'measure-dock-panel-mode-btn';
|
||||
button.dataset.mode = mode;
|
||||
button.innerHTML = `<span class="measure-dock-panel-mode-icon">${MEASURE_TYPES[mode].icon}</span>`;
|
||||
button.addEventListener('click', () => {
|
||||
this.switchMode(mode);
|
||||
});
|
||||
this.modeButtons.set(mode, button);
|
||||
return button;
|
||||
}
|
||||
|
||||
private createIconButton(className: string, iconSvg: string): HTMLButtonElement {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `measure-dock-panel-action-btn ${className}`;
|
||||
button.innerHTML = iconSvg;
|
||||
return button;
|
||||
}
|
||||
|
||||
private applyExpandedState(): void {
|
||||
this.secondaryRow.style.display = this.isExpanded ? 'grid' : 'none';
|
||||
this.expandBtn.classList.toggle('is-expanded', this.isExpanded);
|
||||
this.expandBtn.classList.toggle('is-collapsed', !this.isExpanded);
|
||||
}
|
||||
|
||||
private openSettingsView(): void {
|
||||
this.lockPanelWidth();
|
||||
this.view = 'settings';
|
||||
this.syncSettingsFormFromConfig();
|
||||
this.applyViewState();
|
||||
}
|
||||
|
||||
private closeSettingsView(): void {
|
||||
if (this.view !== 'settings') {
|
||||
return;
|
||||
}
|
||||
this.view = 'main';
|
||||
this.unlockPanelWidth();
|
||||
this.applyViewState();
|
||||
}
|
||||
|
||||
private saveSettings(): void {
|
||||
const nextUnit = this.settingsUnitSelect.value as MeasureUnit;
|
||||
const nextPrecision = Number(this.settingsPrecisionSelect.value) as MeasurePrecision;
|
||||
if (!this.isValidUnit(nextUnit) || !this.isValidPrecision(nextPrecision)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.config = {
|
||||
unit: nextUnit,
|
||||
precision: nextPrecision
|
||||
};
|
||||
this.saveConfigToCache(this.config);
|
||||
this.options.onConfigSave?.(this.getConfig());
|
||||
this.view = 'main';
|
||||
this.unlockPanelWidth();
|
||||
this.applyViewState();
|
||||
}
|
||||
|
||||
private lockPanelWidth(): void {
|
||||
const width = this.element.getBoundingClientRect().width;
|
||||
if (width <= 0) {
|
||||
return;
|
||||
}
|
||||
this.lockedWidthPx = Math.ceil(width);
|
||||
this.element.style.width = `${this.lockedWidthPx}px`;
|
||||
}
|
||||
|
||||
private unlockPanelWidth(): void {
|
||||
this.lockedWidthPx = null;
|
||||
this.element.style.removeProperty('width');
|
||||
}
|
||||
|
||||
private syncSettingsFormFromConfig(): void {
|
||||
this.settingsUnitSelect.value = this.config.unit;
|
||||
this.settingsPrecisionSelect.value = String(this.config.precision);
|
||||
}
|
||||
|
||||
private applyViewState(): void {
|
||||
const showMain = this.view === 'main';
|
||||
this.mainView.style.display = showMain ? 'block' : 'none';
|
||||
this.settingsView.style.display = showMain ? 'none' : 'flex';
|
||||
}
|
||||
|
||||
private applyClearHeightOptionsState(): void {
|
||||
// this.clearHeightOptions.classList.toggle('is-visible', this.activeMode === 'clearHeight');
|
||||
this.clearHeightOptions.classList.remove('is-visible');
|
||||
|
||||
for (const [direction, button] of this.directionButtons.entries()) {
|
||||
button.classList.toggle('is-active', direction === this.clearHeightDirection);
|
||||
}
|
||||
for (const [selectType, button] of this.selectTypeButtons.entries()) {
|
||||
button.classList.toggle('is-active', selectType === this.clearHeightSelectType);
|
||||
}
|
||||
}
|
||||
|
||||
private setClearHeightDirection(direction: ClearHeightDirection): void {
|
||||
if (this.clearHeightDirection === direction) {
|
||||
return;
|
||||
}
|
||||
this.clearHeightDirection = direction;
|
||||
this.applyClearHeightOptionsState();
|
||||
this.options.onClearHeightDirectionChange?.(direction);
|
||||
}
|
||||
|
||||
private setClearHeightSelectType(selectType: ClearHeightSelectType): void {
|
||||
if (this.clearHeightSelectType === selectType) {
|
||||
return;
|
||||
}
|
||||
this.clearHeightSelectType = selectType;
|
||||
this.applyClearHeightOptionsState();
|
||||
this.options.onClearHeightSelectTypeChange?.(selectType);
|
||||
}
|
||||
|
||||
private loadConfigFromCache(): MeasureConfig | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(CONFIG_CACHE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw) as Partial<MeasureConfig>;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
if (!this.isValidUnit(parsed.unit) || !this.isValidPrecision(parsed.precision)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
unit: parsed.unit,
|
||||
precision: parsed.precision
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private saveConfigToCache(config: MeasureConfig): void {
|
||||
try {
|
||||
localStorage.setItem(CONFIG_CACHE_KEY, JSON.stringify(config));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private isValidUnit(unit: unknown): unit is MeasureUnit {
|
||||
return unit === 'm' || unit === 'cm' || unit === 'mm' || unit === 'km';
|
||||
}
|
||||
|
||||
private isValidPrecision(precision: unknown): precision is MeasurePrecision {
|
||||
return precision === 0 || precision === 1 || precision === 2 || precision === 3;
|
||||
}
|
||||
|
||||
private syncActiveMode(mode: MeasureMode): void {
|
||||
for (const [key, button] of this.modeButtons.entries()) {
|
||||
button.classList.toggle('is-active', key === mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/components/radial-toolbar/buttons/measure.ts
Normal file
28
src/components/radial-toolbar/buttons/measure.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { ManagerRegistry } from '../../../core/manager-registry';
|
||||
import type { RadialMenuItem } from '../types';
|
||||
import { getIcon } from '../../../utils/icon-manager';
|
||||
|
||||
export const createMeasureRadialButton = (registry: ManagerRegistry): RadialMenuItem => {
|
||||
return {
|
||||
id: 'measure',
|
||||
label: 'toolbar.measure',
|
||||
icon: getIcon('测量'),
|
||||
isToggle: true,
|
||||
isActive: registry.bottomDock?.isOpen('measure') ?? false,
|
||||
onToggle: (next) => {
|
||||
const dock = registry.bottomDock;
|
||||
if (!dock) {
|
||||
console.warn('[RadialToolbar] bottom dock not initialized: measure');
|
||||
return;
|
||||
}
|
||||
|
||||
if (next) {
|
||||
dock.open('measure');
|
||||
} else {
|
||||
dock.close('measure');
|
||||
}
|
||||
|
||||
console.log(`[RadialToolbar] 测量${next ? '已打开' : '已关闭'}`);
|
||||
}
|
||||
};
|
||||
};
|
||||
28
src/components/radial-toolbar/buttons/section.ts
Normal file
28
src/components/radial-toolbar/buttons/section.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { ManagerRegistry } from '../../../core/manager-registry';
|
||||
import type { RadialMenuItem } from '../types';
|
||||
import { getIcon } from '../../../utils/icon-manager';
|
||||
|
||||
export const createSectionRadialButton = (registry: ManagerRegistry): RadialMenuItem => {
|
||||
return {
|
||||
id: 'section',
|
||||
label: 'toolbar.section',
|
||||
icon: getIcon('剖切'),
|
||||
isToggle: true,
|
||||
isActive: registry.bottomDock?.isOpen('section') ?? false,
|
||||
onToggle: (next) => {
|
||||
const dock = registry.bottomDock;
|
||||
if (!dock) {
|
||||
console.warn('[RadialToolbar] bottom dock not initialized: section');
|
||||
return;
|
||||
}
|
||||
|
||||
if (next) {
|
||||
dock.open('section');
|
||||
} else {
|
||||
dock.close('section');
|
||||
}
|
||||
|
||||
console.log(`[RadialToolbar] 剖切${next ? '已打开' : '已关闭'}`);
|
||||
}
|
||||
};
|
||||
};
|
||||
15
src/components/radial-toolbar/buttons/setting.ts
Normal file
15
src/components/radial-toolbar/buttons/setting.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { ManagerRegistry } from '../../../core/manager-registry';
|
||||
import type { RadialMenuItem } from '../types';
|
||||
import { getIcon } from '../../../utils/icon-manager';
|
||||
|
||||
export const createSettingRadialButton = (registry: ManagerRegistry): RadialMenuItem => {
|
||||
return {
|
||||
id: 'setting',
|
||||
label: 'toolbar.setting',
|
||||
icon: getIcon('设置'),
|
||||
onClick: () => {
|
||||
registry.setting?.toggle();
|
||||
console.log(`[RadialToolbar] 设置${registry.setting?.isOpen() ? '已打开' : '已关闭'}`);
|
||||
}
|
||||
};
|
||||
};
|
||||
28
src/components/radial-toolbar/buttons/walk.ts
Normal file
28
src/components/radial-toolbar/buttons/walk.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { ManagerRegistry } from '../../../core/manager-registry';
|
||||
import type { RadialMenuItem } from '../types';
|
||||
import { getIcon } from '../../../utils/icon-manager';
|
||||
|
||||
export const createWalkRadialButton = (registry: ManagerRegistry): RadialMenuItem => {
|
||||
return {
|
||||
id: 'walk',
|
||||
label: 'toolbar.walk',
|
||||
icon: getIcon('漫游'),
|
||||
isToggle: true,
|
||||
isActive: registry.bottomDock?.isOpen('walk') ?? false,
|
||||
onToggle: (next) => {
|
||||
const dock = registry.bottomDock;
|
||||
if (!dock) {
|
||||
console.warn('[RadialToolbar] bottom dock not initialized: walk');
|
||||
return;
|
||||
}
|
||||
|
||||
if (next) {
|
||||
dock.open('walk');
|
||||
} else {
|
||||
dock.close('walk');
|
||||
}
|
||||
|
||||
console.log(`[RadialToolbar] 漫游${next ? '已打开' : '已关闭'}`);
|
||||
}
|
||||
};
|
||||
};
|
||||
112
src/components/radial-toolbar/index.css
Normal file
112
src/components/radial-toolbar/index.css
Normal file
@@ -0,0 +1,112 @@
|
||||
.radial-toolbar-wrapper {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.radial-main-btn,
|
||||
.radial-sub-btn {
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
outline: none;
|
||||
transition: transform 0.22s ease, opacity 0.22s ease, box-shadow 0.22s ease, background-color 0.22s ease, color 0.22s ease;
|
||||
}
|
||||
|
||||
.radial-main-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: var(--rt-main-size, 70px);
|
||||
height: var(--rt-main-size, 70px);
|
||||
background: var(--rt-main-bg, rgba(255, 255, 255, 0.92));
|
||||
border-color: var(--rt-main-border, rgba(203, 213, 225, 0.65));
|
||||
box-shadow: var(--rt-main-shadow, 0 4px 12px rgba(0, 0, 0, 0.12));
|
||||
color: var(--rt-main-icon, #334155);
|
||||
}
|
||||
|
||||
.radial-main-btn:hover {
|
||||
transform: translateZ(0) scale(1.04);
|
||||
background: var(--rt-main-bg-hover, #ffffff);
|
||||
box-shadow: var(--rt-main-shadow-hover, 0 6px 20px rgba(0, 0, 0, 0.14));
|
||||
color: var(--rt-main-icon-hover, #1e293b);
|
||||
}
|
||||
|
||||
.radial-main-btn:active {
|
||||
transform: translateZ(0) scale(0.97);
|
||||
}
|
||||
|
||||
.radial-main-btn svg {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.radial-sub-btn {
|
||||
position: absolute;
|
||||
right: var(--rt-main-offset, 10px);
|
||||
bottom: var(--rt-main-offset, 10px);
|
||||
width: var(--rt-sub-size, 50px);
|
||||
height: var(--rt-sub-size, 50px);
|
||||
background: var(--rt-sub-bg, rgba(255, 255, 255, 0.9));
|
||||
border-color: var(--rt-sub-border, rgba(203, 213, 225, 0.65));
|
||||
box-shadow: var(--rt-sub-shadow, 0 2px 8px rgba(0, 0, 0, 0.1));
|
||||
color: var(--rt-sub-icon, #334155);
|
||||
opacity: 0;
|
||||
transform: translate(0, 0) scale(0.76);
|
||||
transform-origin: center;
|
||||
--rt-delay-current: var(--rt-close-delay, 0s);
|
||||
transition-delay: var(--rt-delay-current);
|
||||
}
|
||||
|
||||
.radial-toolbar-wrapper.is-active .radial-sub-btn {
|
||||
--rt-delay-current: var(--rt-open-delay, 0s);
|
||||
opacity: 1;
|
||||
transform: translate(var(--rt-x), var(--rt-y)) scale(1);
|
||||
}
|
||||
|
||||
.radial-sub-btn:hover {
|
||||
background: var(--rt-sub-bg-hover, var(--bim-primary, #2563eb));
|
||||
box-shadow: var(--rt-sub-shadow-hover, 0 4px 14px rgba(0, 0, 0, 0.16));
|
||||
color: var(--rt-sub-icon-hover, var(--bim-text-inverse, #ffffff));
|
||||
}
|
||||
|
||||
.radial-sub-btn.is-active,
|
||||
.radial-sub-btn[data-active="true"] {
|
||||
background: var(--bim-primary, #2563eb);
|
||||
border: 1px solid var(--bim-primary-active, #1d4ed8);
|
||||
box-shadow: var(--bim-shadow-glow, 0 0 0 2px rgba(37, 99, 235, 0.28));
|
||||
color: var(--bim-text-inverse, #ffffff);
|
||||
}
|
||||
|
||||
.radial-sub-btn.is-active:hover,
|
||||
.radial-sub-btn[data-active="true"]:hover {
|
||||
background: var(--bim-primary-hover, #3b82f6);
|
||||
}
|
||||
|
||||
.radial-sub-btn.is-active .radial-sub-btn-icon,
|
||||
.radial-sub-btn[data-active="true"] .radial-sub-btn-icon {
|
||||
color: var(--bim-icon-inverse, #ffffff);
|
||||
}
|
||||
|
||||
.radial-sub-btn:active {
|
||||
transform: translate(var(--rt-x), var(--rt-y)) scale(0.94);
|
||||
}
|
||||
|
||||
.radial-sub-btn-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.radial-sub-btn-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
474
src/components/radial-toolbar/index.ts
Normal file
474
src/components/radial-toolbar/index.ts
Normal file
@@ -0,0 +1,474 @@
|
||||
import './index.css';
|
||||
import type { RadialToolbarOptions, RadialMenuItem } from './types';
|
||||
import type { ThemeConfig } from '../../themes/types';
|
||||
import { IBimComponent } from '../../types/component';
|
||||
import { getIcon } from '../../utils/icon-manager';
|
||||
import { themeManager } from '../../services/theme';
|
||||
import { localeManager, t } from '../../services/locale';
|
||||
|
||||
export class RadialToolbar implements IBimComponent {
|
||||
private container: HTMLElement;
|
||||
private wrapper: HTMLElement;
|
||||
private mainButton: HTMLButtonElement;
|
||||
private items: RadialMenuItem[] = [];
|
||||
private itemElements: HTMLButtonElement[] = [];
|
||||
private itemButtonMap: Map<string, HTMLButtonElement> = new Map();
|
||||
private toggleStateMap: Map<string, boolean> = new Map();
|
||||
private isActive = false;
|
||||
private timer: number | null = null;
|
||||
private unsubscribeTheme: (() => void) | null = null;
|
||||
private unsubscribeLocale: (() => void) | null = null;
|
||||
private readonly mainButtonLabel: string;
|
||||
private readonly onMainButtonClick?: () => void;
|
||||
private pointerX = Number.NaN;
|
||||
private pointerY = Number.NaN;
|
||||
private maxInteractiveRadius = 220;
|
||||
|
||||
private readonly closeDelay: number;
|
||||
private readonly itemsPerRing: number;
|
||||
|
||||
// 主按钮直径(px)
|
||||
private readonly MAIN_BUTTON_SIZE = 60;
|
||||
// 子按钮直径(px)
|
||||
private readonly SUB_BUTTON_SIZE = 46;
|
||||
// 第一环“子按钮中心”到“主按钮中心”的距离(px)
|
||||
// 不做防重叠兜底,允许在小半径时出现重叠。
|
||||
private readonly BASE_RADIUS = 80;
|
||||
// 多环时,相邻两环的中心半径差(px)
|
||||
private readonly RING_GAP = 40;
|
||||
// 扇形展开角度范围:当前 180~270(实际就是 90 度)
|
||||
private readonly FAN_START_DEG = 170;
|
||||
private readonly FAN_END_DEG = 280;
|
||||
// 扇形边缘留白(px),防止按钮贴边或裁切
|
||||
private readonly CANVAS_PADDING = 28;
|
||||
|
||||
constructor(options: RadialToolbarOptions) {
|
||||
this.container = options.container;
|
||||
this.items = options.items === undefined ? this.createDefaultItems() : [...options.items];
|
||||
this.mainButtonLabel = options.mainButtonLabel ?? 'toolbar.home';
|
||||
this.onMainButtonClick = options.onMainButtonClick;
|
||||
this.itemsPerRing = Math.max(3, options.itemsPerRing ?? 5);
|
||||
this.closeDelay = Math.max(100, options.closeDelay ?? 260);
|
||||
|
||||
this.wrapper = this.createWrapper();
|
||||
this.mainButton = this.createMainButton(options.mainButtonIcon);
|
||||
this.wrapper.appendChild(this.mainButton);
|
||||
|
||||
this.container.appendChild(this.wrapper);
|
||||
this.renderItems();
|
||||
this.updateLayoutMetrics();
|
||||
this.bindEvents();
|
||||
|
||||
this.setTheme(themeManager.getTheme());
|
||||
this.updateLocales();
|
||||
this.unsubscribeTheme = themeManager.subscribe((theme) => {
|
||||
this.setTheme(theme);
|
||||
});
|
||||
this.unsubscribeLocale = localeManager.subscribe(() => {
|
||||
this.updateLocales();
|
||||
});
|
||||
}
|
||||
|
||||
private createDefaultItems(): RadialMenuItem[] {
|
||||
return [
|
||||
{ id: 'zoom', label: 'toolbar.zoomBox', icon: getIcon('框选放大') },
|
||||
{ id: 'measure', label: 'toolbar.measure', icon: getIcon('测量') },
|
||||
{ id: 'section', label: 'toolbar.section', icon: getIcon('剖切') },
|
||||
{ id: 'walk', label: 'toolbar.walk', icon: getIcon('漫游') },
|
||||
{ id: 'setting', label: 'toolbar.setting', icon: getIcon('设置') }
|
||||
];
|
||||
}
|
||||
|
||||
private createWrapper(): HTMLElement {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'radial-toolbar-wrapper';
|
||||
return el;
|
||||
}
|
||||
|
||||
private createMainButton(icon?: string): HTMLButtonElement {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'radial-main-btn';
|
||||
btn.type = 'button';
|
||||
btn.innerHTML = icon ?? getIcon('主视角');
|
||||
return btn;
|
||||
}
|
||||
|
||||
private bindEvents(): void {
|
||||
this.mainButton.addEventListener('mouseenter', this.handlePointerEnter);
|
||||
this.mainButton.addEventListener('mouseleave', this.handlePointerLeave);
|
||||
this.mainButton.addEventListener('click', this.handleMainButtonClick);
|
||||
document.addEventListener('click', this.handleDocumentClick);
|
||||
document.addEventListener('mousemove', this.handleDocumentMouseMove);
|
||||
document.addEventListener('mouseleave', this.handleDocumentMouseLeave);
|
||||
document.addEventListener('visibilitychange', this.handleVisibilityChange);
|
||||
window.addEventListener('blur', this.handleWindowBlur);
|
||||
}
|
||||
|
||||
private readonly handleDocumentMouseMove = (event: MouseEvent): void => {
|
||||
this.pointerX = event.clientX;
|
||||
this.pointerY = event.clientY;
|
||||
};
|
||||
|
||||
private readonly handleDocumentMouseLeave = (): void => {
|
||||
this.pointerX = Number.NaN;
|
||||
this.pointerY = Number.NaN;
|
||||
this.collapse();
|
||||
};
|
||||
|
||||
private readonly handleVisibilityChange = (): void => {
|
||||
if (!document.hidden) {
|
||||
return;
|
||||
}
|
||||
this.pointerX = Number.NaN;
|
||||
this.pointerY = Number.NaN;
|
||||
this.collapse();
|
||||
};
|
||||
|
||||
private readonly handleWindowBlur = (): void => {
|
||||
this.pointerX = Number.NaN;
|
||||
this.pointerY = Number.NaN;
|
||||
this.collapse();
|
||||
};
|
||||
|
||||
private readonly handlePointerEnter = (): void => {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
this.expand();
|
||||
};
|
||||
|
||||
private readonly handlePointerLeave = (event: MouseEvent): void => {
|
||||
const relatedTarget = event.relatedTarget;
|
||||
if (relatedTarget instanceof Node && this.wrapper.contains(relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
this.scheduleCollapse();
|
||||
};
|
||||
|
||||
private readonly handleMainButtonClick = (event: MouseEvent): void => {
|
||||
event.stopPropagation();
|
||||
if (this.onMainButtonClick) {
|
||||
this.onMainButtonClick();
|
||||
return;
|
||||
}
|
||||
if (this.isActive) {
|
||||
this.collapse();
|
||||
return;
|
||||
}
|
||||
this.expand();
|
||||
};
|
||||
|
||||
private readonly handleDocumentClick = (event: MouseEvent): void => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
if (!this.wrapper.contains(target)) {
|
||||
this.collapse();
|
||||
}
|
||||
};
|
||||
|
||||
private renderItems(): void {
|
||||
this.itemElements.forEach((el) => el.remove());
|
||||
this.itemElements = [];
|
||||
this.itemButtonMap.clear();
|
||||
this.toggleStateMap.clear();
|
||||
|
||||
this.items.forEach((item, index) => {
|
||||
this.toggleStateMap.set(item.id, Boolean(item.isActive));
|
||||
const btn = this.createSubButton(item, index);
|
||||
this.wrapper.insertBefore(btn, this.mainButton);
|
||||
this.itemElements.push(btn);
|
||||
this.itemButtonMap.set(item.id, btn);
|
||||
});
|
||||
|
||||
this.updateItemPositions();
|
||||
}
|
||||
|
||||
private createSubButton(item: RadialMenuItem, index: number): HTMLButtonElement {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'radial-sub-btn';
|
||||
btn.type = 'button';
|
||||
btn.dataset.index = String(index);
|
||||
this.applyItemActiveClass(btn, item);
|
||||
|
||||
const iconContainer = document.createElement('span');
|
||||
iconContainer.className = 'radial-sub-btn-icon';
|
||||
if (item.icon) {
|
||||
iconContainer.innerHTML = item.icon;
|
||||
} else {
|
||||
iconContainer.textContent = this.getFallbackLabel(item.label);
|
||||
}
|
||||
|
||||
btn.appendChild(iconContainer);
|
||||
btn.addEventListener('mouseenter', this.handlePointerEnter);
|
||||
btn.addEventListener('mouseleave', this.handlePointerLeave);
|
||||
btn.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
if (item.isToggle) {
|
||||
const current = this.toggleStateMap.get(item.id) ?? false;
|
||||
const next = !current;
|
||||
this.toggleStateMap.set(item.id, next);
|
||||
item.isActive = next;
|
||||
this.applyItemActiveClass(btn, item);
|
||||
item.onToggle?.(next, item);
|
||||
this.collapse();
|
||||
return;
|
||||
}
|
||||
item.onClick?.(item);
|
||||
this.collapse();
|
||||
});
|
||||
|
||||
return btn;
|
||||
}
|
||||
|
||||
private updateItemPositions(): void {
|
||||
const total = this.itemElements.length;
|
||||
const fanSpan = this.FAN_END_DEG - this.FAN_START_DEG;
|
||||
|
||||
this.itemElements.forEach((btn, globalIndex) => {
|
||||
const ringIndex = Math.floor(globalIndex / this.itemsPerRing);
|
||||
const ringStart = ringIndex * this.itemsPerRing;
|
||||
const ringCount = Math.min(this.itemsPerRing, total - ringStart);
|
||||
const ringLocalIndex = globalIndex - ringStart;
|
||||
const ratio = ringCount === 1 ? 0.5 : ringLocalIndex / (ringCount - 1);
|
||||
const angleDeg = this.FAN_START_DEG + fanSpan * ratio;
|
||||
const angleRad = (angleDeg * Math.PI) / 180;
|
||||
const radius = this.getBaseRadius(ringCount) + ringIndex * this.RING_GAP;
|
||||
|
||||
const x = Math.cos(angleRad) * radius;
|
||||
const y = Math.sin(angleRad) * radius;
|
||||
|
||||
const openDelay = (ringLocalIndex + ringIndex * 0.5) * 0.045;
|
||||
const closeDelay = (ringCount - 1 - ringLocalIndex + ringIndex * 0.4) * 0.032;
|
||||
|
||||
btn.style.setProperty('--rt-x', `${x.toFixed(2)}px`);
|
||||
btn.style.setProperty('--rt-y', `${y.toFixed(2)}px`);
|
||||
btn.style.setProperty('--rt-open-delay', `${openDelay.toFixed(3)}s`);
|
||||
btn.style.setProperty('--rt-close-delay', `${closeDelay.toFixed(3)}s`);
|
||||
});
|
||||
}
|
||||
|
||||
private updateLayoutMetrics(): void {
|
||||
const total = this.items.length;
|
||||
const ringCount = Math.max(1, Math.ceil(total / this.itemsPerRing));
|
||||
const maxItemsPerRing = Math.max(1, Math.min(total, this.itemsPerRing));
|
||||
const maxRadius = this.getBaseRadius(maxItemsPerRing) + (ringCount - 1) * this.RING_GAP;
|
||||
const size = Math.ceil(maxRadius + this.MAIN_BUTTON_SIZE + this.SUB_BUTTON_SIZE + this.CANVAS_PADDING * 2);
|
||||
this.maxInteractiveRadius = maxRadius + this.SUB_BUTTON_SIZE * 0.7;
|
||||
|
||||
this.wrapper.style.width = `${size}px`;
|
||||
this.wrapper.style.height = `${size}px`;
|
||||
this.wrapper.style.setProperty('--rt-main-size', `${this.MAIN_BUTTON_SIZE}px`);
|
||||
this.wrapper.style.setProperty('--rt-sub-size', `${this.SUB_BUTTON_SIZE}px`);
|
||||
this.wrapper.style.setProperty('--rt-main-offset', `${(this.MAIN_BUTTON_SIZE - this.SUB_BUTTON_SIZE) / 2}px`);
|
||||
}
|
||||
|
||||
private getBaseRadius(_itemsInRing: number): number {
|
||||
return this.BASE_RADIUS;
|
||||
}
|
||||
|
||||
private scheduleCollapse(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
this.timer = window.setTimeout(() => {
|
||||
this.timer = null;
|
||||
if (this.isPointerInsideToolbar()) {
|
||||
if (this.isActive) {
|
||||
this.scheduleCollapse();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.collapse();
|
||||
}, this.closeDelay);
|
||||
}
|
||||
|
||||
private isPointerInsideToolbar(): boolean {
|
||||
if (this.mainButton.matches(':hover')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.itemElements.some((button) => button.matches(':hover'))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.isPointerInsideFanRegion();
|
||||
}
|
||||
|
||||
private isPointerInsideFanRegion(): boolean {
|
||||
if (!Number.isFinite(this.pointerX) || !Number.isFinite(this.pointerY)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mainRect = this.mainButton.getBoundingClientRect();
|
||||
const centerX = mainRect.left + mainRect.width / 2;
|
||||
const centerY = mainRect.top + mainRect.height / 2;
|
||||
const dx = this.pointerX - centerX;
|
||||
const dy = this.pointerY - centerY;
|
||||
const distance = Math.hypot(dx, dy);
|
||||
|
||||
if (distance <= this.MAIN_BUTTON_SIZE / 2) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (distance > this.maxInteractiveRadius) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let angle = (Math.atan2(dy, dx) * 180) / Math.PI;
|
||||
if (angle < 0) {
|
||||
angle += 360;
|
||||
}
|
||||
|
||||
if (this.FAN_START_DEG <= this.FAN_END_DEG) {
|
||||
return angle >= this.FAN_START_DEG && angle <= this.FAN_END_DEG;
|
||||
}
|
||||
|
||||
return angle >= this.FAN_START_DEG || angle <= this.FAN_END_DEG;
|
||||
}
|
||||
|
||||
private expand(): void {
|
||||
if (this.isActive || this.items.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.isActive = true;
|
||||
this.wrapper.classList.add('is-active');
|
||||
}
|
||||
|
||||
private collapse(): void {
|
||||
if (!this.isActive) {
|
||||
return;
|
||||
}
|
||||
this.isActive = false;
|
||||
this.wrapper.classList.remove('is-active');
|
||||
}
|
||||
|
||||
private updateLocales(): void {
|
||||
const mainLabel = t(this.mainButtonLabel);
|
||||
this.mainButton.title = mainLabel;
|
||||
this.mainButton.setAttribute('aria-label', mainLabel);
|
||||
this.itemElements.forEach((el, index) => {
|
||||
const item = this.items[index];
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
const text = t(item.label);
|
||||
el.title = text;
|
||||
el.setAttribute('aria-label', text);
|
||||
this.applyItemActiveClass(el, item);
|
||||
|
||||
if (!item.icon) {
|
||||
const iconEl = el.querySelector('.radial-sub-btn-icon');
|
||||
if (iconEl) {
|
||||
iconEl.textContent = this.getFallbackLabel(item.label);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private applyItemActiveClass(button: HTMLButtonElement, item: RadialMenuItem): void {
|
||||
if (!item.isToggle) {
|
||||
button.classList.remove('is-active');
|
||||
button.dataset.active = 'false';
|
||||
return;
|
||||
}
|
||||
|
||||
const active = this.toggleStateMap.get(item.id) ?? Boolean(item.isActive);
|
||||
button.classList.toggle('is-active', active);
|
||||
button.dataset.active = active ? 'true' : 'false';
|
||||
}
|
||||
|
||||
private getFallbackLabel(label: string): string {
|
||||
const translated = t(label).trim();
|
||||
if (!translated) {
|
||||
return '?';
|
||||
}
|
||||
return translated.charAt(0).toUpperCase();
|
||||
}
|
||||
|
||||
public setTheme(theme: ThemeConfig): void {
|
||||
this.wrapper.classList.remove('theme-light', 'theme-dark');
|
||||
this.wrapper.classList.add(`theme-${theme.name}`);
|
||||
|
||||
const style = this.wrapper.style;
|
||||
style.setProperty('--bim-primary', theme.primary);
|
||||
style.setProperty('--bim-primary-hover', theme.primaryHover);
|
||||
style.setProperty('--bim-primary-active', theme.primaryActive);
|
||||
style.setProperty('--bim-text-inverse', theme.textInverse);
|
||||
style.setProperty('--bim-icon-inverse', theme.iconInverse);
|
||||
style.setProperty('--bim-shadow-glow', theme.shadowGlow);
|
||||
|
||||
const isDark = theme.name === 'dark';
|
||||
|
||||
style.setProperty('--rt-main-bg', isDark ? 'rgba(55, 68, 86, 0.92)' : theme.floatingBtnBg);
|
||||
style.setProperty('--rt-main-bg-hover', isDark ? 'rgba(66, 82, 104, 0.96)' : theme.floatingBtnBgHover);
|
||||
style.setProperty('--rt-main-border', isDark ? 'rgba(117, 133, 154, 0.56)' : theme.floatingBtnBorder);
|
||||
style.setProperty('--rt-main-shadow', isDark ? '0 2px 8px rgba(15, 23, 42, 0.32), 0 4px 12px rgba(15, 23, 42, 0.24)' : theme.floatingBtnShadow);
|
||||
style.setProperty('--rt-main-shadow-hover', isDark ? '0 4px 12px rgba(15, 23, 42, 0.38), 0 6px 20px rgba(15, 23, 42, 0.3)' : theme.floatingBtnShadowHover);
|
||||
style.setProperty('--rt-main-icon', isDark ? '#e2e8f0' : theme.floatingIconColor);
|
||||
style.setProperty('--rt-main-icon-hover', isDark ? '#f8fafc' : theme.floatingIconColorHover);
|
||||
|
||||
style.setProperty('--rt-sub-bg', isDark ? 'rgba(55, 68, 86, 0.92)' : theme.floatingBtnBg);
|
||||
style.setProperty('--rt-sub-bg-hover', isDark ? 'rgba(66, 82, 104, 0.96)' : theme.floatingBtnBgHover);
|
||||
style.setProperty('--rt-sub-border', isDark ? 'rgba(117, 133, 154, 0.56)' : theme.floatingBtnBorder);
|
||||
style.setProperty('--rt-sub-shadow', isDark ? '0 2px 8px rgba(15, 23, 42, 0.32), 0 4px 12px rgba(15, 23, 42, 0.24)' : theme.floatingBtnShadow);
|
||||
style.setProperty('--rt-sub-shadow-hover', isDark ? '0 4px 12px rgba(15, 23, 42, 0.38), 0 6px 20px rgba(15, 23, 42, 0.3)' : theme.floatingBtnShadowHover);
|
||||
style.setProperty('--rt-sub-icon', isDark ? '#e2e8f0' : theme.floatingIconColor);
|
||||
style.setProperty('--rt-sub-icon-hover', isDark ? '#f8fafc' : theme.floatingIconColorHover);
|
||||
}
|
||||
|
||||
public setItemActive(id: string, active: boolean): void {
|
||||
const item = this.items.find((entry) => entry.id === id);
|
||||
if (!item || !item.isToggle) {
|
||||
return;
|
||||
}
|
||||
|
||||
item.isActive = active;
|
||||
this.toggleStateMap.set(id, active);
|
||||
const button = this.itemButtonMap.get(id);
|
||||
if (button) {
|
||||
this.applyItemActiveClass(button, item);
|
||||
}
|
||||
}
|
||||
|
||||
public init(): void { }
|
||||
|
||||
public setLocales(): void {
|
||||
this.updateLocales();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
if (this.unsubscribeTheme) {
|
||||
this.unsubscribeTheme();
|
||||
this.unsubscribeTheme = null;
|
||||
}
|
||||
if (this.unsubscribeLocale) {
|
||||
this.unsubscribeLocale();
|
||||
this.unsubscribeLocale = null;
|
||||
}
|
||||
|
||||
document.removeEventListener('click', this.handleDocumentClick);
|
||||
document.removeEventListener('mousemove', this.handleDocumentMouseMove);
|
||||
document.removeEventListener('mouseleave', this.handleDocumentMouseLeave);
|
||||
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
|
||||
window.removeEventListener('blur', this.handleWindowBlur);
|
||||
this.mainButton.removeEventListener('mouseenter', this.handlePointerEnter);
|
||||
this.mainButton.removeEventListener('mouseleave', this.handlePointerLeave);
|
||||
this.mainButton.removeEventListener('click', this.handleMainButtonClick);
|
||||
|
||||
this.itemElements.forEach((button) => {
|
||||
button.removeEventListener('mouseenter', this.handlePointerEnter);
|
||||
button.removeEventListener('mouseleave', this.handlePointerLeave);
|
||||
});
|
||||
|
||||
if (this.wrapper.parentNode) {
|
||||
this.wrapper.parentNode.removeChild(this.wrapper);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/components/radial-toolbar/types.ts
Normal file
19
src/components/radial-toolbar/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface RadialMenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
onClick?: (item: RadialMenuItem) => void;
|
||||
isToggle?: boolean;
|
||||
isActive?: boolean;
|
||||
onToggle?: (nextActive: boolean, item: RadialMenuItem) => void;
|
||||
}
|
||||
|
||||
export interface RadialToolbarOptions {
|
||||
container: HTMLElement;
|
||||
items?: RadialMenuItem[];
|
||||
mainButtonIcon?: string;
|
||||
mainButtonLabel?: string;
|
||||
onMainButtonClick?: () => void;
|
||||
itemsPerRing?: number;
|
||||
closeDelay?: number;
|
||||
}
|
||||
@@ -23,6 +23,9 @@ import type { EngineInfoDialogManager } from '../managers/engine-info-dialog-man
|
||||
import type { SettingDialogManager } from '../managers/setting-dialog-manager';
|
||||
import type { ComponentDetailManager } from '../managers/component-detail-manager';
|
||||
import type { AiChatManager } from '../managers/ai-chat-manager';
|
||||
import type { RadialToolbarManager } from '../managers/radial-toolbar-manager';
|
||||
import type { BottomDockManager } from '../managers/bottom-dock-manager';
|
||||
import type { MeasureDockManager } from '../managers/measure-dock-manager';
|
||||
|
||||
/**
|
||||
* Manager 注册表 - 实例模式
|
||||
@@ -72,6 +75,10 @@ export class ManagerRegistry {
|
||||
public aiChat: AiChatManager | null = null;
|
||||
/** 设置对话框管理器 */
|
||||
public setting: SettingDialogManager | null = null;
|
||||
/** 径向工具栏管理器 */
|
||||
public radialToolbar: RadialToolbarManager | null = null;
|
||||
public bottomDock: BottomDockManager | null = null;
|
||||
public measureDock: MeasureDockManager | null = null;
|
||||
|
||||
constructor() {}
|
||||
|
||||
@@ -97,6 +104,9 @@ export class ManagerRegistry {
|
||||
this.componentDetail = null;
|
||||
this.aiChat = null;
|
||||
this.setting = null;
|
||||
this.radialToolbar = null;
|
||||
this.bottomDock = null;
|
||||
this.measureDock = null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
274
src/cus-bim-engine.ts
Normal file
274
src/cus-bim-engine.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
declare const __APP_VERSION__: string;
|
||||
import './bim-engine.css';
|
||||
import { DialogManager } from './managers/dialog-manager';
|
||||
import { EngineManager } from './managers/engine-manager';
|
||||
import { RightKeyManager } from './managers/right-key-manager';
|
||||
import { RadialToolbarManager } from './managers/radial-toolbar-manager';
|
||||
import { BottomDockManager } from './managers/bottom-dock-manager';
|
||||
import { MeasureDockManager } from './managers/measure-dock-manager';
|
||||
|
||||
import { MeasureDialogManager } from './managers/measure-dialog-manager';
|
||||
import { SectionPlaneDialogManager } from './managers/section-plane-dialog-manager';
|
||||
import { SectionAxisDialogManager } from './managers/section-axis-dialog-manager';
|
||||
import { SectionBoxDialogManager } from './managers/section-box-dialog-manager';
|
||||
import { WalkControlManager } from './managers/walk-control-manager';
|
||||
import { EngineInfoDialogManager } from './managers/engine-info-dialog-manager';
|
||||
import { SettingDialogManager } from './managers/setting-dialog-manager';
|
||||
import { ComponentDetailManager } from './managers/component-detail-manager';
|
||||
import { AiChatManager } from './managers/ai-chat-manager';
|
||||
import type { EngineOptions, ModelLoadOptions } from './components/engine';
|
||||
import { localeManager } from './services/locale';
|
||||
import { themeManager } from './services/theme';
|
||||
import type { LocaleType } from './locales/types';
|
||||
import type { ThemeType, ThemeConfig } from './themes/types';
|
||||
import { ManagerRegistry } from './core/manager-registry';
|
||||
import { EngineEvents } from './types/events';
|
||||
|
||||
export type { EngineOptions, ModelLoadOptions };
|
||||
|
||||
/**
|
||||
* CusBimEngine - 定制版 BIM 引擎
|
||||
* 移除了 ButtonGroupManager、ConstructTreeManagerBtn 和 ToolbarManager
|
||||
*/
|
||||
export class CusBimEngine {
|
||||
public container: HTMLElement;
|
||||
private wrapper: HTMLElement | null = null;
|
||||
private sizeEl: HTMLElement | null = null;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
private lastSyncedWidth = -1;
|
||||
private lastSyncedHeight = -1;
|
||||
private unsubscribeTheme: (() => void) | null = null;
|
||||
private registry: ManagerRegistry;
|
||||
|
||||
public dialog: DialogManager | null = null;
|
||||
public engine: EngineManager | null = null;
|
||||
public rightKey: RightKeyManager | null = null;
|
||||
public radialToolbar: RadialToolbarManager | null = null;
|
||||
public bottomDock: BottomDockManager | null = null;
|
||||
public measureDock: MeasureDockManager | null = null;
|
||||
|
||||
public measure: MeasureDialogManager | null = null;
|
||||
public sectionPlane: SectionPlaneDialogManager | null = null;
|
||||
public sectionAxis: SectionAxisDialogManager | null = null;
|
||||
public sectionBox: SectionBoxDialogManager | null = null;
|
||||
public walkControl: WalkControlManager | null = null;
|
||||
public engineInfo: EngineInfoDialogManager | null = null;
|
||||
public componentDetail: ComponentDetailManager | null = null;
|
||||
public aiChat: AiChatManager | null = null;
|
||||
public setting: SettingDialogManager | null = null;
|
||||
|
||||
private readonly handleWindowResize = () => {
|
||||
this.updateClientSizeDisplay();
|
||||
};
|
||||
|
||||
constructor(
|
||||
container: HTMLElement | string,
|
||||
options?: {
|
||||
locale?: LocaleType;
|
||||
theme?: ThemeType;
|
||||
}
|
||||
) {
|
||||
const el = typeof container === 'string' ? document.getElementById(container) : container;
|
||||
if (!el) throw new Error('Container not found');
|
||||
this.container = el;
|
||||
|
||||
this.registry = new ManagerRegistry();
|
||||
|
||||
if (options?.locale) localeManager.setLocale(options.locale);
|
||||
if (options?.theme) {
|
||||
if (options.theme === 'custom') {
|
||||
console.warn('Custom theme should be set via setCustomTheme().');
|
||||
} else {
|
||||
themeManager.setTheme(options.theme);
|
||||
}
|
||||
}
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
public emit<K extends keyof EngineEvents>(event: K, payload: EngineEvents[K]) {
|
||||
this.registry.emit(event, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅事件
|
||||
* @param event 事件名称
|
||||
* @param listener 事件监听器
|
||||
* @returns 取消订阅函数
|
||||
*/
|
||||
public on<K extends keyof EngineEvents>(event: K, listener: (payload: EngineEvents[K]) => void): () => void {
|
||||
return this.registry.on(event, listener);
|
||||
}
|
||||
|
||||
public setLocale(locale: LocaleType) {
|
||||
localeManager.setLocale(locale);
|
||||
}
|
||||
|
||||
public getLocale(): LocaleType {
|
||||
return localeManager.getLocale();
|
||||
}
|
||||
|
||||
public setTheme(theme: 'dark' | 'light') {
|
||||
themeManager.setTheme(theme);
|
||||
}
|
||||
|
||||
public setCustomTheme(theme: ThemeConfig) {
|
||||
themeManager.setCustomTheme(theme);
|
||||
}
|
||||
|
||||
private init() {
|
||||
this.container.innerHTML = '';
|
||||
this.wrapper = document.createElement('div');
|
||||
this.wrapper.className = 'bim-engine-wrapper';
|
||||
this.container.appendChild(this.wrapper);
|
||||
|
||||
const versionEl = document.createElement('div');
|
||||
versionEl.className = 'bim-engine-version';
|
||||
versionEl.textContent = `v${__APP_VERSION__}`;
|
||||
this.wrapper.appendChild(versionEl);
|
||||
|
||||
this.sizeEl = document.createElement('div');
|
||||
this.sizeEl.className = 'bim-engine-size';
|
||||
this.wrapper.appendChild(this.sizeEl);
|
||||
this.updateClientSizeDisplay();
|
||||
this.bindSizeObserver();
|
||||
|
||||
this.registry.container = this.container;
|
||||
this.registry.wrapper = this.wrapper;
|
||||
|
||||
this.engine = new EngineManager(this.wrapper, this.registry);
|
||||
this.dialog = new DialogManager(this.wrapper, this.registry);
|
||||
this.rightKey = new RightKeyManager(this.wrapper, this.registry);
|
||||
this.bottomDock = new BottomDockManager(this.wrapper, this.registry);
|
||||
this.registry.bottomDock = this.bottomDock;
|
||||
|
||||
this.registry.engine3d = this.engine;
|
||||
this.registry.dialog = this.dialog;
|
||||
this.registry.rightKey = this.rightKey;
|
||||
|
||||
this.measureDock = new MeasureDockManager(this.registry);
|
||||
this.registry.measureDock = this.measureDock;
|
||||
this.measureDock.init();
|
||||
|
||||
this.radialToolbar = new RadialToolbarManager(this.wrapper, this.registry);
|
||||
|
||||
this.measure = new MeasureDialogManager(this.registry);
|
||||
this.sectionPlane = new SectionPlaneDialogManager(this.registry);
|
||||
this.sectionAxis = new SectionAxisDialogManager(this.registry);
|
||||
this.sectionBox = new SectionBoxDialogManager(this.registry);
|
||||
this.walkControl = new WalkControlManager(this.registry);
|
||||
this.walkControl.init();
|
||||
this.engineInfo = new EngineInfoDialogManager(this.registry);
|
||||
this.engineInfo.init();
|
||||
|
||||
this.registry.radialToolbar = this.radialToolbar;
|
||||
|
||||
this.registry.measure = this.measure;
|
||||
this.registry.sectionPlane = this.sectionPlane;
|
||||
this.registry.sectionAxis = this.sectionAxis;
|
||||
this.registry.sectionBox = this.sectionBox;
|
||||
this.registry.walkControl = this.walkControl;
|
||||
this.registry.engineInfo = this.engineInfo;
|
||||
|
||||
|
||||
this.componentDetail = new ComponentDetailManager(this.registry);
|
||||
this.registry.componentDetail = this.componentDetail;
|
||||
this.componentDetail.init();
|
||||
|
||||
this.aiChat = new AiChatManager(this.registry);
|
||||
this.registry.aiChat = this.aiChat;
|
||||
this.aiChat.init();
|
||||
|
||||
this.setting = new SettingDialogManager(this.registry);
|
||||
this.registry.setting = this.setting;
|
||||
this.setting.init();
|
||||
|
||||
this.updateTheme(themeManager.getTheme());
|
||||
this.unsubscribeTheme = themeManager.subscribe((theme) => {
|
||||
this.updateTheme(theme);
|
||||
});
|
||||
}
|
||||
|
||||
private updateTheme(theme: ThemeConfig) {
|
||||
if (this.wrapper) {
|
||||
this.wrapper.style.color = theme.textPrimary;
|
||||
}
|
||||
}
|
||||
|
||||
private updateClientSizeDisplay(): void {
|
||||
const width = this.container.clientWidth;
|
||||
const height = this.container.clientHeight;
|
||||
|
||||
if (this.sizeEl) {
|
||||
this.sizeEl.textContent = `${width}px x ${height}px`;
|
||||
}
|
||||
|
||||
this.syncEngineSize(width, height);
|
||||
}
|
||||
|
||||
private syncEngineSize(width: number, height: number): void {
|
||||
if (width <= 0 || height <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (width === this.lastSyncedWidth && height === this.lastSyncedHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastSyncedWidth = width;
|
||||
this.lastSyncedHeight = height;
|
||||
this.engine?.getEngineComponent()?.resize(width, height);
|
||||
}
|
||||
|
||||
private bindSizeObserver(): void {
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
this.updateClientSizeDisplay();
|
||||
});
|
||||
this.resizeObserver.observe(this.container);
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('resize', this.handleWindowResize);
|
||||
}
|
||||
|
||||
private unbindSizeObserver(): void {
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
this.resizeObserver = null;
|
||||
return;
|
||||
}
|
||||
|
||||
window.removeEventListener('resize', this.handleWindowResize);
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.unbindSizeObserver();
|
||||
|
||||
if (this.unsubscribeTheme) {
|
||||
this.unsubscribeTheme();
|
||||
this.unsubscribeTheme = null;
|
||||
}
|
||||
|
||||
this.radialToolbar?.destroy();
|
||||
this.measureDock?.destroy();
|
||||
this.bottomDock?.destroy();
|
||||
this.engine?.destroy();
|
||||
this.dialog?.destroy();
|
||||
this.rightKey?.destroy();
|
||||
|
||||
this.measure?.destroy();
|
||||
this.sectionPlane?.destroy();
|
||||
this.sectionAxis?.destroy();
|
||||
this.sectionBox?.destroy();
|
||||
this.walkControl?.destroy();
|
||||
this.aiChat?.destroy();
|
||||
this.setting?.destroy();
|
||||
|
||||
this.sizeEl = null;
|
||||
this.lastSyncedWidth = -1;
|
||||
this.lastSyncedHeight = -1;
|
||||
this.container.innerHTML = '';
|
||||
this.registry.reset();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// Main Entry
|
||||
export * from './bim-engine';
|
||||
export * from './cus-bim-engine';
|
||||
export * from './bim-engine-2d';
|
||||
export * from './bim-engine-720';
|
||||
|
||||
|
||||
@@ -221,17 +221,49 @@ export const enUS: TranslationDictionary = {
|
||||
},
|
||||
setting: {
|
||||
dialogTitle: 'Settings',
|
||||
presetSelect: 'Preset',
|
||||
presetSelectPlaceholder: 'Select preset',
|
||||
defaultPresetLabel: 'Default Preset',
|
||||
savePreset: 'Save Preset',
|
||||
deletePreset: 'Delete',
|
||||
deletePresetForbidden: 'Default preset cannot be deleted',
|
||||
presetDefaultName: 'Preset',
|
||||
presetNamePrompt: 'Enter preset name',
|
||||
presetNameRequired: 'Preset name is required',
|
||||
presetNameDuplicate: 'Preset name already exists',
|
||||
restorePreset: 'Restore Preset',
|
||||
saveAsNewPreset: 'Save As Preset',
|
||||
selectPresetFirst: 'Please select a preset first',
|
||||
presetButton: 'Preset',
|
||||
undoChanges: 'Undo Changes',
|
||||
saveAsDialogTitle: 'Save As Preset',
|
||||
deleteDialogTitle: 'Delete Preset',
|
||||
deleteDialogMessage: 'Delete current preset? This action cannot be undone.',
|
||||
presetNamePlaceholder: 'Enter preset name',
|
||||
confirm: 'Confirm',
|
||||
cancel: 'Cancel',
|
||||
presetPickPrompt: 'Please input preset number',
|
||||
presetPickInvalid: 'Invalid preset number',
|
||||
renderMode: 'Render Mode',
|
||||
modes: {
|
||||
simple: 'Performance',
|
||||
balance: 'Balanced',
|
||||
advanced: 'Quality',
|
||||
},
|
||||
displaySection: 'Display',
|
||||
edgeLine: 'Edge Lines',
|
||||
edgeOpacity: 'Edge Opacity',
|
||||
showGrid: 'Show Grid',
|
||||
showLevel: 'Show Level',
|
||||
contrast: 'Contrast',
|
||||
saturation: 'Saturation',
|
||||
lightIntensity: 'Light Intensity',
|
||||
environment: 'Environment',
|
||||
environmentType: {
|
||||
none: 'None',
|
||||
hdr: 'HDR',
|
||||
sky: 'Sky',
|
||||
},
|
||||
backgroundVisible: 'Show Background',
|
||||
ground: 'Ground',
|
||||
groundElevation: 'Ground Elevation',
|
||||
|
||||
@@ -242,6 +242,29 @@ export interface TranslationDictionary {
|
||||
};
|
||||
setting: {
|
||||
dialogTitle: string;
|
||||
presetSelect: string;
|
||||
presetSelectPlaceholder: string;
|
||||
defaultPresetLabel: string;
|
||||
savePreset: string;
|
||||
deletePreset: string;
|
||||
deleteDialogTitle: string;
|
||||
deleteDialogMessage: string;
|
||||
deletePresetForbidden: string;
|
||||
presetDefaultName: string;
|
||||
presetNamePrompt: string;
|
||||
presetNameRequired: string;
|
||||
presetNameDuplicate: string;
|
||||
restorePreset: string;
|
||||
saveAsNewPreset: string;
|
||||
selectPresetFirst: string;
|
||||
presetButton: string;
|
||||
undoChanges: string;
|
||||
saveAsDialogTitle: string;
|
||||
presetNamePlaceholder: string;
|
||||
confirm: string;
|
||||
cancel: string;
|
||||
presetPickPrompt: string;
|
||||
presetPickInvalid: string;
|
||||
/** 渲染模式 */
|
||||
renderMode: string;
|
||||
modes: {
|
||||
@@ -251,6 +274,9 @@ export interface TranslationDictionary {
|
||||
};
|
||||
/** 边线 */
|
||||
edgeLine: string;
|
||||
edgeOpacity: string;
|
||||
showGrid: string;
|
||||
showLevel: string;
|
||||
/** 对比度 */
|
||||
contrast: string;
|
||||
/** 饱和度 */
|
||||
@@ -263,14 +289,20 @@ export interface TranslationDictionary {
|
||||
backgroundVisible: string;
|
||||
/** 显示地面 */
|
||||
ground: string;
|
||||
displaySection: string;
|
||||
/** 地面高度 */
|
||||
groundElevation: string;
|
||||
/** 地面高度单位 */
|
||||
groundElevationUnit: string;
|
||||
environmentType: {
|
||||
none: string;
|
||||
hdr: string;
|
||||
sky: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 语言<E8AFAD><E8A880>码类型
|
||||
*/
|
||||
export type LocaleType = 'zh-CN' | 'en-US';
|
||||
export type LocaleType = 'zh-CN' | 'en-US' | 'zh-TW';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {TranslationDictionary} from './types';
|
||||
import { TranslationDictionary } from './types';
|
||||
|
||||
export const zhCN: TranslationDictionary = {
|
||||
common: {
|
||||
@@ -221,17 +221,49 @@ export const zhCN: TranslationDictionary = {
|
||||
},
|
||||
setting: {
|
||||
dialogTitle: '设置',
|
||||
presetSelect: '选择预设',
|
||||
presetSelectPlaceholder: '请选择预设',
|
||||
defaultPresetLabel: '默认预设',
|
||||
savePreset: '保存预设',
|
||||
deletePreset: '删除预设',
|
||||
deletePresetForbidden: '默认预设不允许删除',
|
||||
presetDefaultName: '预设',
|
||||
presetNamePrompt: '请输入预设名称',
|
||||
presetNameRequired: '预设名称不能为空',
|
||||
presetNameDuplicate: '预设名称已存在,请使用其他名称',
|
||||
restorePreset: '恢复预设',
|
||||
saveAsNewPreset: '存为新预设',
|
||||
selectPresetFirst: '请先选择预设',
|
||||
presetButton: '预设',
|
||||
undoChanges: '撤销修改',
|
||||
saveAsDialogTitle: '存为新预设',
|
||||
deleteDialogTitle: '删除预设',
|
||||
deleteDialogMessage: '确认删除当前预设吗?删除后不可恢复。',
|
||||
presetNamePlaceholder: '请输入预设名称',
|
||||
confirm: '确定',
|
||||
cancel: '取消',
|
||||
presetPickPrompt: '请输入预设序号',
|
||||
presetPickInvalid: '预设序号无效',
|
||||
renderMode: '渲染模式',
|
||||
modes: {
|
||||
simple: '性能模式',
|
||||
balance: '平衡模式',
|
||||
advanced: '效果模式',
|
||||
},
|
||||
displaySection: '显示设置',
|
||||
edgeLine: '边线',
|
||||
edgeOpacity: '边线透明度',
|
||||
showGrid: '显示轴网',
|
||||
showLevel: '显示标高',
|
||||
contrast: '对比度',
|
||||
saturation: '饱和度',
|
||||
lightIntensity: '光照强度',
|
||||
environment: '环境背景',
|
||||
environmentType: {
|
||||
none: '无',
|
||||
hdr: 'HDR背景',
|
||||
sky: '天空盒',
|
||||
},
|
||||
backgroundVisible: '显示背景',
|
||||
ground: '显示地面',
|
||||
groundElevation: '地面高度',
|
||||
|
||||
272
src/locales/zh-TW.ts
Normal file
272
src/locales/zh-TW.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { TranslationDictionary } from './types';
|
||||
|
||||
export const zhTW: TranslationDictionary = {
|
||||
common: {
|
||||
title: 'BimEngine',
|
||||
description: '這是一個使用 BIM-ENGINE。',
|
||||
openTestDialog: '打開測試彈窗',
|
||||
openInfoDialog: '打開資訊彈窗 (封裝版)',
|
||||
},
|
||||
toolbar: {
|
||||
home: '首頁',
|
||||
measure: '測量',
|
||||
zoomBox: '選框放大',
|
||||
info: '資訊',
|
||||
location: '定位',
|
||||
setting: '設定',
|
||||
walk: '漫遊',
|
||||
map: '地圖',
|
||||
property: '構件詳情',
|
||||
fullscreen: '全屏',
|
||||
walkMenu: '漫遊選單',
|
||||
walkPerson: '第一人稱',
|
||||
walkBird: '第三人稱',
|
||||
tree: '模型樹',
|
||||
section: '剖切',
|
||||
sectionPlane: '拾取面剖切',
|
||||
sectionAxis: '軸向剖切',
|
||||
sectionBox: '剖切盒',
|
||||
cameraSwitch: '相機切換',
|
||||
},
|
||||
dialog: {
|
||||
testTitle: '測試彈窗',
|
||||
testContent: '<div style="padding: 10px;">這是一個 <b>可拖曳</b> 且 <b>可縮放</b> 的彈窗。<br><br>你可以嘗試拖動標題欄,或者拖動右下角改變大小。</div>',
|
||||
},
|
||||
menu: {
|
||||
info: '資訊',
|
||||
home: '首頁',
|
||||
componentDetail: '構件詳情',
|
||||
hideSelected: '隱藏選中構件',
|
||||
transparentSelected: '半透明選中構件',
|
||||
cancelTranslucent: '取消半透明',
|
||||
isolateSelected: '隔離選中構件',
|
||||
hideOthers: '其他構件隱藏',
|
||||
transparentOthers: '其他構件半透明',
|
||||
fitSectionBox: '剖切盒適應',
|
||||
showAll: '顯示全部',
|
||||
quickSelect: '快速選擇',
|
||||
selectSameType: '選擇同類模型',
|
||||
selectSameLevel: '選擇同層模型',
|
||||
selectSameLevelType: '選擇同層同類模型'
|
||||
},
|
||||
tree: {
|
||||
searchPlaceholder: '請輸入要搜尋的內容',
|
||||
},
|
||||
constructTree: {
|
||||
title: '目錄樹',
|
||||
},
|
||||
tab: {
|
||||
component: '構件',
|
||||
system: '系統',
|
||||
space: '空間',
|
||||
type: '類型',
|
||||
major: '專業',
|
||||
},
|
||||
panel: {
|
||||
property: {
|
||||
title: '構件詳情',
|
||||
base: '基本屬性',
|
||||
material: '材質資訊',
|
||||
advanced: '進階設定',
|
||||
tab: {
|
||||
props: '屬性',
|
||||
material: '材質'
|
||||
}
|
||||
},
|
||||
componentDetail: {
|
||||
title: '構件詳情',
|
||||
noSelection: '請先選中構件'
|
||||
}
|
||||
},
|
||||
measure: {
|
||||
btnName: '測量',
|
||||
dialogTitle: '測量',
|
||||
modes: {
|
||||
clearHeight: '淨高',
|
||||
clearDistance: '淨距',
|
||||
distance: '距離',
|
||||
elevation: '標高',
|
||||
point: '座標',
|
||||
angle: '角度',
|
||||
area: '面積',
|
||||
slope: '坡度',
|
||||
},
|
||||
actions: {
|
||||
expand: '展開',
|
||||
collapse: '收起',
|
||||
clearAll: '刪除全部',
|
||||
settings: '設定',
|
||||
},
|
||||
labels: {
|
||||
currentMode: '當前測量方式:',
|
||||
x: 'X:',
|
||||
y: 'Y:',
|
||||
z: 'Z:',
|
||||
value: {
|
||||
clearHeight: '淨高:',
|
||||
clearDistance: '淨距:',
|
||||
distance: '距離:',
|
||||
elevation: '標高:',
|
||||
point: '座標:',
|
||||
angle: '角度:',
|
||||
area: '面積:',
|
||||
slope: '坡度:',
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
title: '設定',
|
||||
unit: '單位:',
|
||||
precision: '精度:',
|
||||
hint: '距離、淨距、淨高和標高預設使用該單位;角度和面積有各自預設單位。',
|
||||
save: '儲存設定',
|
||||
cancel: '取消',
|
||||
},
|
||||
clearHeight: {
|
||||
direction: '朝向:',
|
||||
directionDown: '朝下',
|
||||
directionUp: '朝上',
|
||||
selectType: '選擇對象:',
|
||||
selectPoint: '選擇點',
|
||||
selectElement: '選擇構件',
|
||||
}
|
||||
},
|
||||
sectionPlane: {
|
||||
dialogTitle: '拾取面剖切',
|
||||
actions: {
|
||||
hide: '隱藏',
|
||||
reverse: '反向',
|
||||
reset: '重設'
|
||||
}
|
||||
},
|
||||
sectionAxis: {
|
||||
dialogTitle: '軸向剖切',
|
||||
actions: {
|
||||
hide: '隱藏',
|
||||
reverse: '反向',
|
||||
axisX: 'X',
|
||||
axisY: 'Y',
|
||||
axisZ: 'Z'
|
||||
}
|
||||
},
|
||||
sectionBox: {
|
||||
dialogTitle: '剖切盒',
|
||||
actions: {
|
||||
hide: '隱藏',
|
||||
reverse: '反向',
|
||||
fitToModel: '適應',
|
||||
reset: '重設'
|
||||
},
|
||||
axes: {
|
||||
x: 'X',
|
||||
y: 'Y',
|
||||
z: 'Z'
|
||||
}
|
||||
},
|
||||
walkControl: {
|
||||
speed: '移動速度:',
|
||||
gravity: '重力',
|
||||
collision: '碰撞',
|
||||
characterModel: {
|
||||
label: '建築工人',
|
||||
constructionWorker: '建築工人',
|
||||
officeMale: '辦公室男性'
|
||||
},
|
||||
walkMode: {
|
||||
label: '行走模式',
|
||||
walk: '行走模式',
|
||||
run: '奔跑模式'
|
||||
},
|
||||
exit: '退出',
|
||||
path: {
|
||||
dialogTitle: '路徑漫遊',
|
||||
duration: '漫遊時間',
|
||||
durationUnit: '秒',
|
||||
loop: '循環播放',
|
||||
addPoint: '新增漫遊點',
|
||||
deleteAll: '刪除全部',
|
||||
point: '漫遊點',
|
||||
play: '播放漫遊',
|
||||
stop: '停止漫遊',
|
||||
noPoints: '暫無漫遊點,請新增'
|
||||
}
|
||||
},
|
||||
info: {
|
||||
dialogTitle: '基本資訊',
|
||||
meshCount: '構件數量',
|
||||
totalTriangles: '三角面數量',
|
||||
totalVertices: '頂點數量',
|
||||
},
|
||||
aiChat: {
|
||||
title: 'AI 助手',
|
||||
placeholder: '輸入你的問題...',
|
||||
quickPrompt: {
|
||||
summarize: '總結這個模型',
|
||||
explain: '解釋選中的構件',
|
||||
generate: '生成報告'
|
||||
},
|
||||
action: {
|
||||
new: '新建對話',
|
||||
history: '歷史記錄',
|
||||
settings: '設定',
|
||||
close: '關閉'
|
||||
},
|
||||
helper: {
|
||||
newline: 'Shift + Enter 換行',
|
||||
send: 'Enter 傳送'
|
||||
},
|
||||
thinking: '正在思考...',
|
||||
other: '其他',
|
||||
otherPlaceholder: '請輸入自定義答案',
|
||||
submit: '提交'
|
||||
},
|
||||
setting: {
|
||||
dialogTitle: '設定',
|
||||
presetSelect: '選擇預設',
|
||||
presetSelectPlaceholder: '請選擇預設',
|
||||
defaultPresetLabel: '預設預設',
|
||||
savePreset: '儲存預設',
|
||||
deletePreset: '刪除預設',
|
||||
deletePresetForbidden: '預設預設不允許刪除',
|
||||
presetDefaultName: '預設',
|
||||
presetNamePrompt: '請輸入預設名稱',
|
||||
presetNameRequired: '預設名稱不能為空',
|
||||
presetNameDuplicate: '預設名稱已存在,請使用其他名稱',
|
||||
restorePreset: '還原預設',
|
||||
saveAsNewPreset: '另存為新預設',
|
||||
selectPresetFirst: '請先選擇預設',
|
||||
presetButton: '預設',
|
||||
undoChanges: '撤銷修改',
|
||||
saveAsDialogTitle: '另存為新預設',
|
||||
deleteDialogTitle: '刪除預設',
|
||||
deleteDialogMessage: '確認刪除目前預設嗎?刪除後無法復原。',
|
||||
presetNamePlaceholder: '請輸入預設名稱',
|
||||
confirm: '確定',
|
||||
cancel: '取消',
|
||||
presetPickPrompt: '請輸入預設序號',
|
||||
presetPickInvalid: '預設序號無效',
|
||||
renderMode: '渲染模式',
|
||||
modes: {
|
||||
simple: '效能模式',
|
||||
balance: '平衡模式',
|
||||
advanced: '效果模式',
|
||||
},
|
||||
displaySection: '顯示設定',
|
||||
edgeLine: '邊線',
|
||||
edgeOpacity: '邊線透明度',
|
||||
showGrid: '顯示軸網',
|
||||
showLevel: '顯示標高',
|
||||
contrast: '對比度',
|
||||
saturation: '飽和度',
|
||||
lightIntensity: '光照強度',
|
||||
environment: '環境背景',
|
||||
environmentType: {
|
||||
none: '無',
|
||||
hdr: 'HDR背景',
|
||||
sky: '天空盒',
|
||||
},
|
||||
backgroundVisible: '顯示背景',
|
||||
ground: '顯示地面',
|
||||
groundElevation: '地面高度',
|
||||
groundElevationUnit: 'm',
|
||||
}
|
||||
};
|
||||
156
src/managers/bottom-dock-manager.ts
Normal file
156
src/managers/bottom-dock-manager.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { BaseManager } from '../core/base-manager';
|
||||
import { ManagerRegistry } from '../core/manager-registry';
|
||||
import { BottomDockStack } from '../components/bottom-dock-stack';
|
||||
import { themeManager } from '../services/theme';
|
||||
|
||||
export interface BottomDockPanelDefinition {
|
||||
id: string;
|
||||
title: string;
|
||||
closable?: boolean;
|
||||
createContent?: () => HTMLElement;
|
||||
}
|
||||
|
||||
export interface BottomDockStateChange {
|
||||
id: string;
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
type BottomDockStateListener = (state: BottomDockStateChange) => void;
|
||||
|
||||
export class BottomDockManager extends BaseManager {
|
||||
private stack: BottomDockStack;
|
||||
private definitions: Map<string, BottomDockPanelDefinition> = new Map();
|
||||
private openStates: Map<string, boolean> = new Map();
|
||||
private listeners: Set<BottomDockStateListener> = new Set();
|
||||
private unsubscribeTheme: (() => void) | null = null;
|
||||
|
||||
constructor(container: HTMLElement, registry: ManagerRegistry) {
|
||||
super(registry);
|
||||
this.stack = new BottomDockStack(container);
|
||||
this.stack.setTheme(themeManager.getTheme());
|
||||
this.unsubscribeTheme = themeManager.subscribe((theme) => {
|
||||
this.stack.setTheme(theme);
|
||||
});
|
||||
this.registerDefaultPanels();
|
||||
}
|
||||
|
||||
public register(definition: BottomDockPanelDefinition): void {
|
||||
this.definitions.set(definition.id, definition);
|
||||
if (!this.openStates.has(definition.id)) {
|
||||
this.openStates.set(definition.id, false);
|
||||
}
|
||||
}
|
||||
|
||||
public unregister(id: string): void {
|
||||
if (this.isOpen(id)) {
|
||||
this.close(id);
|
||||
}
|
||||
this.definitions.delete(id);
|
||||
this.openStates.delete(id);
|
||||
}
|
||||
|
||||
public toggle(id: string): void {
|
||||
if (this.isOpen(id)) {
|
||||
this.close(id);
|
||||
return;
|
||||
}
|
||||
this.open(id);
|
||||
}
|
||||
|
||||
public open(id: string): void {
|
||||
const definition = this.definitions.get(id);
|
||||
if (!definition) {
|
||||
console.warn(`[BottomDock] Unknown panel id: ${id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isOpen(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = definition.createContent
|
||||
? definition.createContent()
|
||||
: this.stack.createPlaceholderContent(`${definition.title} 面板内容占位`);
|
||||
|
||||
this.stack.addPanel({
|
||||
id,
|
||||
content,
|
||||
closable: definition.closable !== false,
|
||||
onClose: () => {
|
||||
this.close(id);
|
||||
}
|
||||
});
|
||||
|
||||
this.openStates.set(id, true);
|
||||
this.emitState({ id, open: true });
|
||||
}
|
||||
|
||||
public close(id: string): void {
|
||||
if (!this.isOpen(id)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stack.removePanel(id);
|
||||
this.openStates.set(id, false);
|
||||
this.emitState({ id, open: false });
|
||||
}
|
||||
|
||||
public isOpen(id: string): boolean {
|
||||
return this.openStates.get(id) ?? false;
|
||||
}
|
||||
|
||||
public createPlaceholderContent(text: string): HTMLElement {
|
||||
return this.stack.createPlaceholderContent(text);
|
||||
}
|
||||
|
||||
public onStateChange(listener: BottomDockStateListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => {
|
||||
this.listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
if (this.unsubscribeTheme) {
|
||||
this.unsubscribeTheme();
|
||||
this.unsubscribeTheme = null;
|
||||
}
|
||||
this.listeners.clear();
|
||||
this.definitions.clear();
|
||||
this.openStates.clear();
|
||||
this.stack.destroy();
|
||||
super.destroy();
|
||||
}
|
||||
|
||||
private emitState(state: BottomDockStateChange): void {
|
||||
this.listeners.forEach((listener) => {
|
||||
listener(state);
|
||||
});
|
||||
}
|
||||
|
||||
private registerDefaultPanels(): void {
|
||||
this.register({
|
||||
id: 'measure',
|
||||
title: '测量',
|
||||
createContent: () => {
|
||||
const measurePanel = this.registry.measureDock?.getPanelElement();
|
||||
if (measurePanel) {
|
||||
return measurePanel;
|
||||
}
|
||||
return this.stack.createPlaceholderContent('测量面板占位');
|
||||
}
|
||||
});
|
||||
|
||||
this.register({
|
||||
id: 'section',
|
||||
title: '剖切',
|
||||
createContent: () => this.stack.createPlaceholderContent('剖切面板占位')
|
||||
});
|
||||
|
||||
this.register({
|
||||
id: 'walk',
|
||||
title: '漫游',
|
||||
createContent: () => this.stack.createPlaceholderContent('漫游面板占位')
|
||||
});
|
||||
}
|
||||
}
|
||||
178
src/managers/measure-dock-manager.ts
Normal file
178
src/managers/measure-dock-manager.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { BaseManager } from '../core/base-manager';
|
||||
import { ManagerRegistry } from '../core/manager-registry';
|
||||
import { MeasureDockPanel } from '../components/measure-dock-panel';
|
||||
import { themeManager } from '../services/theme';
|
||||
import { localeManager } from '../services/locale';
|
||||
import { getModeBycallBackType, type CallBackType } from '../types/measure';
|
||||
|
||||
interface EngineMeasureData {
|
||||
type: CallBackType;
|
||||
}
|
||||
|
||||
export class MeasureDockManager extends BaseManager {
|
||||
private panel: MeasureDockPanel | null = null;
|
||||
private unsubscribeTheme: (() => void) | null = null;
|
||||
private unsubscribeLocale: (() => void) | null = null;
|
||||
private unsubscribeDockState: (() => void) | null = null;
|
||||
private unsubscribeMeasureEvents: (() => void) | null = null;
|
||||
|
||||
constructor(registry: ManagerRegistry) {
|
||||
super(registry);
|
||||
}
|
||||
|
||||
public init(): void {
|
||||
if (!this.unsubscribeTheme) {
|
||||
this.unsubscribeTheme = themeManager.subscribe(() => {
|
||||
this.applyPresentation();
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.unsubscribeLocale) {
|
||||
this.unsubscribeLocale = localeManager.subscribe(() => {
|
||||
this.applyPresentation();
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.unsubscribeDockState) {
|
||||
this.unsubscribeDockState = this.registry.bottomDock?.onStateChange((state) => {
|
||||
if (state.id !== 'measure' || state.open) {
|
||||
if (state.id === 'measure' && state.open) {
|
||||
this.ensureMeasureEventSubscription();
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.engineComponent?.deactivateMeasure();
|
||||
}) ?? null;
|
||||
}
|
||||
this.ensureMeasureEventSubscription();
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
if (this.unsubscribeTheme) {
|
||||
this.unsubscribeTheme();
|
||||
this.unsubscribeTheme = null;
|
||||
}
|
||||
if (this.unsubscribeLocale) {
|
||||
this.unsubscribeLocale();
|
||||
this.unsubscribeLocale = null;
|
||||
}
|
||||
if (this.unsubscribeDockState) {
|
||||
this.unsubscribeDockState();
|
||||
this.unsubscribeDockState = null;
|
||||
}
|
||||
if (this.unsubscribeMeasureEvents) {
|
||||
this.unsubscribeMeasureEvents();
|
||||
this.unsubscribeMeasureEvents = null;
|
||||
}
|
||||
|
||||
this.panel?.destroy();
|
||||
this.panel = null;
|
||||
super.destroy();
|
||||
}
|
||||
|
||||
public getPanelElement(): HTMLElement {
|
||||
this.ensureMeasureEventSubscription();
|
||||
if (!this.panel) {
|
||||
this.panel = new MeasureDockPanel({
|
||||
defaultMode: 'distance',
|
||||
defaultExpanded: false,
|
||||
defaultClearHeightDirection: 1,
|
||||
defaultClearHeightSelectType: 'point',
|
||||
onModeChange: (mode) => {
|
||||
this.engineComponent?.activateMeasure(mode);
|
||||
},
|
||||
onClearAll: () => {
|
||||
this.engineComponent?.clearAllMeasures();
|
||||
},
|
||||
onConfigSave: (config) => {
|
||||
this.engineComponent?.saveMeasureSetting({
|
||||
unit: config.unit,
|
||||
precision: config.precision
|
||||
});
|
||||
},
|
||||
onClearHeightDirectionChange: (direction) => {
|
||||
this.engineComponent?.setClearHeightDirection(direction);
|
||||
},
|
||||
onClearHeightSelectTypeChange: (selectType) => {
|
||||
this.engineComponent?.setClearHeightSelectType(selectType);
|
||||
}
|
||||
});
|
||||
this.panel.init();
|
||||
this.panel.switchMode('distance');
|
||||
this.engineComponent?.setClearHeightDirection(1);
|
||||
this.engineComponent?.setClearHeightSelectType('point');
|
||||
const config = this.panel.getConfig();
|
||||
this.engineComponent?.saveMeasureSetting({
|
||||
unit: config.unit,
|
||||
precision: config.precision
|
||||
});
|
||||
} else {
|
||||
this.panel.switchMode('distance');
|
||||
}
|
||||
this.applyPresentation();
|
||||
return this.panel.element;
|
||||
}
|
||||
|
||||
private ensureMeasureEventSubscription(): void {
|
||||
if (this.unsubscribeMeasureEvents) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ec = this.engineComponent;
|
||||
if (!ec) {
|
||||
console.warn('[MeasureDockManager] skip callback binding: engine component not ready yet');
|
||||
return;
|
||||
}
|
||||
|
||||
const changedHandler = (data: EngineMeasureData) => {
|
||||
this.handleMeasureCallback('measure-changed', data);
|
||||
};
|
||||
const clickHandler = (data: EngineMeasureData) => {
|
||||
this.handleMeasureCallback('measure-click', data);
|
||||
};
|
||||
|
||||
ec.onRawEvent('measure-changed', changedHandler);
|
||||
ec.onRawEvent('measure-click', clickHandler);
|
||||
this.unsubscribeMeasureEvents = () => {
|
||||
ec.offRawEvent('measure-changed', changedHandler);
|
||||
ec.offRawEvent('measure-click', clickHandler);
|
||||
};
|
||||
|
||||
console.log('[MeasureDockManager] raw event callbacks bound');
|
||||
}
|
||||
|
||||
private applyPresentation(): void {
|
||||
if (!this.panel) {
|
||||
return;
|
||||
}
|
||||
this.panel.setTheme(themeManager.getTheme());
|
||||
this.panel.setLocales();
|
||||
}
|
||||
|
||||
private handleMeasureCallback(eventName: 'measure-changed' | 'measure-click', data: EngineMeasureData): void {
|
||||
const isOpen = this.registry.bottomDock?.isOpen('measure') ?? false;
|
||||
const mode = getModeBycallBackType(data.type);
|
||||
|
||||
console.log('[MeasureDockManager] callback received', {
|
||||
event: eventName,
|
||||
type: data.type,
|
||||
mode,
|
||||
dockOpen: isOpen,
|
||||
hasPanel: Boolean(this.panel)
|
||||
});
|
||||
|
||||
if (!isOpen) {
|
||||
console.log('[MeasureDockManager] skip mode switch: measure dock is closed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mode || !this.panel) {
|
||||
console.log('[MeasureDockManager] skip mode switch: invalid mode or panel not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
this.panel.switchMode(mode, false);
|
||||
this.engineComponent?.activateMeasure(mode);
|
||||
console.log('[MeasureDockManager] switched mode by callback', { event: eventName, mode });
|
||||
}
|
||||
}
|
||||
73
src/managers/radial-toolbar-manager.ts
Normal file
73
src/managers/radial-toolbar-manager.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { BaseManager } from '../core/base-manager';
|
||||
import { ManagerRegistry } from '../core/manager-registry';
|
||||
import { RadialToolbar } from '../components/radial-toolbar';
|
||||
import type { RadialMenuItem } from '../components/radial-toolbar/types';
|
||||
import { themeManager } from '../services/theme';
|
||||
import { getIcon } from '../utils/icon-manager';
|
||||
import type { BottomDockStateChange } from './bottom-dock-manager';
|
||||
import { createMeasureRadialButton } from '../components/radial-toolbar/buttons/measure';
|
||||
import { createSectionRadialButton } from '../components/radial-toolbar/buttons/section';
|
||||
import { createSettingRadialButton } from '../components/radial-toolbar/buttons/setting';
|
||||
import { createWalkRadialButton } from '../components/radial-toolbar/buttons/walk';
|
||||
|
||||
export interface RadialToolbarManagerOptions {
|
||||
items?: RadialMenuItem[];
|
||||
itemsPerRing?: number;
|
||||
}
|
||||
|
||||
export class RadialToolbarManager extends BaseManager {
|
||||
private toolbar: RadialToolbar | null = null;
|
||||
private unsubscribeDockState: (() => void) | null = null;
|
||||
|
||||
constructor(container: HTMLElement, registry: ManagerRegistry, options?: RadialToolbarManagerOptions) {
|
||||
super(registry);
|
||||
this.toolbar = new RadialToolbar({
|
||||
container,
|
||||
items: options?.items ?? this.createDefaultItems(),
|
||||
itemsPerRing: options?.itemsPerRing ?? 4,
|
||||
mainButtonIcon: getIcon('主视角'),
|
||||
mainButtonLabel: 'toolbar.home',
|
||||
onMainButtonClick: () => {
|
||||
console.log('[RadialToolbar] main: home');
|
||||
this.registry.engine3d?.getEngineComponent()?.CameraGoHome();
|
||||
}
|
||||
});
|
||||
this.toolbar.setTheme(themeManager.getTheme());
|
||||
|
||||
this.unsubscribeDockState = this.registry.bottomDock?.onStateChange(({ id, open }: BottomDockStateChange) => {
|
||||
this.toolbar?.setItemActive(id, open);
|
||||
}) ?? null;
|
||||
|
||||
this.syncInitialToggleStates();
|
||||
}
|
||||
|
||||
private createDefaultItems(): RadialMenuItem[] {
|
||||
return [
|
||||
createSettingRadialButton(this.registry),
|
||||
createMeasureRadialButton(this.registry),
|
||||
createSectionRadialButton(this.registry),
|
||||
createWalkRadialButton(this.registry),
|
||||
];
|
||||
}
|
||||
|
||||
private syncInitialToggleStates(): void {
|
||||
const dock = this.registry.bottomDock;
|
||||
if (!dock || !this.toolbar) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.toolbar.setItemActive('measure', dock.isOpen('measure'));
|
||||
this.toolbar.setItemActive('section', dock.isOpen('section'));
|
||||
this.toolbar.setItemActive('walk', dock.isOpen('walk'));
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
if (this.unsubscribeDockState) {
|
||||
this.unsubscribeDockState();
|
||||
this.unsubscribeDockState = null;
|
||||
}
|
||||
this.toolbar?.destroy();
|
||||
this.toolbar = null;
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,12 +29,7 @@ export class WalkControlManager extends BaseManager {
|
||||
|
||||
/** 显示漫游控制面板 */
|
||||
public show(): void {
|
||||
if (!this.registry.toolbar) {
|
||||
console.warn('Toolbar not initialized');
|
||||
return;
|
||||
}
|
||||
|
||||
this.registry.toolbar.hide();
|
||||
this.registry.toolbar?.hide();
|
||||
|
||||
// 打开漫游面板时,默认激活第一人称模式
|
||||
console.log('[WalkControl] 打开漫游面板,激活第一人称模式');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { LocaleType, TranslationDictionary } from '../locales/types';
|
||||
import { zhCN } from '../locales/zh-CN';
|
||||
import { enUS } from '../locales/en-US';
|
||||
import { zhTW } from '../locales/zh-TW';
|
||||
|
||||
type LocaleChangeListener = (locale: LocaleType) => void;
|
||||
|
||||
@@ -12,6 +13,7 @@ export class LocaleManager {
|
||||
private messages: Record<LocaleType, TranslationDictionary> = {
|
||||
'zh-CN': zhCN,
|
||||
'en-US': enUS,
|
||||
'zh-TW': zhTW,
|
||||
};
|
||||
private listeners: LocaleChangeListener[] = [];
|
||||
|
||||
@@ -30,6 +32,7 @@ export class LocaleManager {
|
||||
* 切换语言
|
||||
*/
|
||||
public setLocale(locale: LocaleType) {
|
||||
if (!this.messages[locale]) return;
|
||||
if (this.currentLocale === locale) return;
|
||||
this.currentLocale = locale;
|
||||
this.notifyListeners();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { EngineSettingPreset, EngineSettings } from '../components/engine/types';
|
||||
|
||||
export interface EngineEvents {
|
||||
// UI Events
|
||||
'ui:open-dialog': { id: string; data?: any };
|
||||
@@ -41,6 +43,17 @@ export interface EngineEvents {
|
||||
// 测量事件
|
||||
'measure:changed': { type: string; value: number; unit: string; points?: { x: number; y: number; z: number }[] };
|
||||
|
||||
'setting:preset-saved': {
|
||||
preset: EngineSettingPreset;
|
||||
currentSettings: EngineSettings;
|
||||
timestamp: number;
|
||||
};
|
||||
'setting:preset-changed': {
|
||||
preset: EngineSettingPreset;
|
||||
timestamp: number;
|
||||
};
|
||||
'setting:preset-deleted': EngineSettingPreset;
|
||||
|
||||
// AI 聊天事件
|
||||
'aiChat:opened': {};
|
||||
'aiChat:closed': {};
|
||||
|
||||
Reference in New Issue
Block a user