feat: 新增底部Dock测量面板与回调联动

This commit is contained in:
yuding
2026-03-30 10:53:39 +08:00
parent c11140f967
commit 2574a11284
42 changed files with 18388 additions and 11404 deletions

View 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;
}

View 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';
}
}

View File

@@ -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,
},
});
}
// ---- 边线 ----
/** 启用模型边线显示 */

View File

@@ -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[];
}

View 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;
}
}

View 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);
}
}
}

View 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 ? '已打开' : '已关闭'}`);
}
};
};

View 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 ? '已打开' : '已关闭'}`);
}
};
};

View 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() ? '已打开' : '已关闭'}`);
}
};
};

View 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 ? '已打开' : '已关闭'}`);
}
};
};

View 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%;
}

View 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);
}
}
}

View 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;
}

View File

@@ -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
View 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();
}
}

View File

@@ -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';

View File

@@ -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',

View File

@@ -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';

View File

@@ -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
View 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',
}
};

View 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('漫游面板占位')
});
}
}

View 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 });
}
}

View 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

View File

@@ -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] 打开漫游面板,激活第一人称模式');

View File

@@ -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();

View File

@@ -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': {};