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

@@ -11,7 +11,7 @@
- 🎯 **框架无关**: 支持 Vue 2/3、React 和原生 HTML
- 📦 **开箱即用**: 提供完整的 BIM 功能组件
- 🎨 **主题系统**: 内置暗色/亮色主题,支持自定义
- 🌍 **国际化**: 支持英文切换
- 🌍 **国际化**: 支持简体中文、繁体中文与英文切换
- 📐 **测量工具**: 标高、距离、角度、坡度、体积等
- ✂️ **剖切功能**: 拾取面剖切、轴向剖切、剖切盒
- 🚶 **漫游控制**: 第一人称漫游、路径漫游、平面图导航
@@ -51,7 +51,7 @@ onMounted(() => {
if (containerRef.value) {
// 初始化 BIM 引擎
bimEngine = new BimEngine(containerRef.value, {
locale: 'zh-CN', // 语言: 'zh-CN' | 'en-US'
locale: 'zh-CN', // 语言: 'zh-CN' | 'zh-TW' | 'en-US'
theme: 'dark' // 主题: 'dark' | 'light'
});
@@ -303,7 +303,7 @@ class BimEngine {
constructor(
container: HTMLElement | string,
options?: {
locale?: 'zh-CN' | 'en-US';
locale?: 'zh-CN' | 'zh-TW' | 'en-US';
theme?: 'dark' | 'light';
}
);
@@ -325,7 +325,7 @@ class BimEngine {
// 主题和国际化
setTheme(theme: 'dark' | 'light', config?: ThemeConfig): void;
setLocale(locale: 'zh-CN' | 'en-US'): void;
setLocale(locale: 'zh-CN' | 'zh-TW' | 'en-US'): void;
// 事件系统
on(event: string, handler: Function): void;
@@ -411,7 +411,7 @@ SDK 使用 CSS 变量实现主题系统,所有组件自动响应主题变化:
```typescript
const bimEngine = new BimEngine(container, {
locale: 'zh-CN' // 'zh-CN' | 'en-US'
locale: 'zh-CN' // 'zh-CN' | 'zh-TW' | 'en-US'
});
// 运行时切换语言
@@ -421,6 +421,7 @@ bimEngine.setLocale('en-US');
### 支持的语言
- `zh-CN`: 简体中文
- `zh-TW`: 繁体中文
- `en-US`: English
## 🔧 开发指南

View File

@@ -121,6 +121,7 @@
<h2>🌍 语言 (Language)</h2>
<div class="btn-container">
<button class="primary" onclick="setLang('zh-CN')">中文</button>
<button class="primary" onclick="setLang('zh-TW')">繁體中文</button>
<button class="primary" onclick="setLang('en-US')">English</button>
</div>
</div>
@@ -135,21 +136,24 @@
</div>
</div>
<!-- 3. 工具栏操作 -->
<div class="control-group">
<h2>🛠️ 工具栏 (Toolbar)</h2>
<!-- 3. 工具栏操作 (CusBimEngine 中已移除 ToolbarManager) -->
<div class="control-group" style="opacity: 0.6;">
<h2>🛠️ 工具栏 (Toolbar) <span style="color: #999; font-size: 0.75rem;">[CusBimEngine 已移除]</span></h2>
<div style="font-size: 0.8rem; color: #888; margin-bottom: 8px;">
当前使用 CusBimEngine不包含工具栏功能
</div>
<div class="btn-container">
<button onclick="toggleToolbar()">显隐工具栏</button>
<button onclick="toggleLabel()">显隐标签</button>
<button onclick="toggleLocationBtn()">显隐定位按钮</button>
<button disabled>显隐工具栏</button>
<button disabled>显隐标签</button>
<button disabled>显隐定位按钮</button>
</div>
<div class="btn-container" style="margin-top: 8px;">
<button onclick="addCustomGroup()">加组</button>
<button onclick="addCustomButton()">加按钮</button>
<button disabled>加组</button>
<button disabled>加按钮</button>
</div>
<div class="btn-container" style="margin-top: 8px;">
<button onclick="setToolbarType('default')">默认样式</button>
<button onclick="setToolbarType('glass-pill')">胶囊样式</button>
<button disabled>默认样式</button>
<button disabled>胶囊样式</button>
</div>
</div>
@@ -168,6 +172,7 @@
<h2>📑 功能面板 (Panels)</h2>
<div class="btn-container">
<button onclick="openPropertyPanel()">属性面板</button>
<button onclick="viewPresetCacheData()">查看预设缓存</button>
</div>
</div>
@@ -324,11 +329,119 @@
let isLabelVisible = true;
let isLocationVisible = true;
let customGroupAdded = false;
let currentLocale = 'zh-CN';
let unsubscribePresetSaved = null;
let unsubscribePresetChanged = null;
let unsubscribePresetDeleted = null;
const PRESET_CACHE_KEY = 'iflow-demo-setting-presets-v1';
function injectSettingPresetsFromCache() {
if (!engine || !engine.setting || typeof engine.setting.setPresetList !== 'function') {
return;
}
engine.setting.setPresetList(buildPresetListFromCache());
console.log('✅ 已注入缓存预设列表');
}
function safeClone(data) {
return JSON.parse(JSON.stringify(data));
}
function loadPresetCache() {
try {
const raw = localStorage.getItem(PRESET_CACHE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (error) {
console.warn('读取预设缓存失败,已忽略:', error);
return [];
}
}
function savePresetCache(list) {
localStorage.setItem(PRESET_CACHE_KEY, JSON.stringify(list));
}
function buildPresetListFromCache() {
return loadPresetCache().filter((item) => item && typeof item.id === 'string');
}
function upsertPresetCache(preset) {
const list = loadPresetCache();
const index = list.findIndex((item) => item.id === preset.id);
const normalizedPreset = { ...preset, isDefault: Boolean(preset.isDefault) };
if (index >= 0) {
list[index] = normalizedPreset;
} else {
list.push(normalizedPreset);
}
savePresetCache(list);
}
function deletePresetCache(preset) {
const list = loadPresetCache();
const next = list.filter((item) => item && item.id !== preset.id);
savePresetCache(next);
}
function bindPresetEvents() {
if (!engine || typeof engine.on !== 'function') return;
if (unsubscribePresetSaved) {
unsubscribePresetSaved();
unsubscribePresetSaved = null;
}
if (unsubscribePresetChanged) {
unsubscribePresetChanged();
unsubscribePresetChanged = null;
}
if (unsubscribePresetDeleted) {
unsubscribePresetDeleted();
unsubscribePresetDeleted = null;
}
unsubscribePresetSaved = engine.on('setting:preset-saved', ({ preset }) => {
upsertPresetCache(safeClone(preset));
injectSettingPresetsFromCache();
console.log('💾 已写入预设缓存:', preset);
});
unsubscribePresetChanged = engine.on('setting:preset-changed', ({ preset }) => {
console.log('🔁 已切换预设:', preset);
});
unsubscribePresetDeleted = engine.on('setting:preset-deleted', (preset) => {
deletePresetCache(safeClone(preset));
injectSettingPresetsFromCache();
console.log('🗑️ 已删除预设缓存:', preset);
});
}
function viewPresetCacheData() {
const data = loadPresetCache();
console.log('📦 当前预设缓存数据:', data);
window.alert(data.length === 0 ? '当前没有预设缓存数据' : JSON.stringify(data, null, 2));
}
/**
* 销毁所有引擎实例
*/
function destroyAllEngines() {
if (unsubscribePresetSaved) {
unsubscribePresetSaved();
unsubscribePresetSaved = null;
}
if (unsubscribePresetChanged) {
unsubscribePresetChanged();
unsubscribePresetChanged = null;
}
if (unsubscribePresetDeleted) {
unsubscribePresetDeleted();
unsubscribePresetDeleted = null;
}
if (engine) { engine.destroy(); engine = null; }
if (engine2d) { engine2d.destroy(); engine2d = null; }
if (engine720) { engine720.destroy(); engine720 = null; }
@@ -349,7 +462,10 @@
// --- 语言设置 ---
function setLang(lang) {
currentLocale = lang;
if (engine) engine.setLocale(lang);
if (engine2d) engine2d.setLocale(lang);
if (engine720) engine720.setLocale(lang);
}
// --- 弹窗测试 ---
@@ -469,6 +585,7 @@
/**
* 初始化 3D 引擎(独立实例,销毁其他引擎类型)
* 使用 CusBimEngine移除了按钮组和构件树
*/
function initEngine3D() {
destroyAllEngines();
@@ -479,7 +596,7 @@
document.getElementById('btn-load720').disabled = true;
try {
engine = new IflowEngine.BimEngine('app', { locale: 'zh-CN' });
engine = new IflowEngine.CusBimEngine('app', { locale: currentLocale });
var success = engine.engine.initialize({
backgroundColor: 0x333333,
version: 'v2',
@@ -488,6 +605,8 @@
});
if (success) {
injectSettingPresetsFromCache();
bindPresetEvents();
updateEngineStatus('已初始化');
console.log('✅ 3D 引擎初始化成功');
loadModel();
@@ -835,6 +954,7 @@
try {
engine2d = new IflowEngine.BimEngine2d('app', {
locale: currentLocale,
backgroundColor: 0x1a1a1a,
gridEnabled: true,
axesEnabled: true,
@@ -927,6 +1047,7 @@
try {
engine720 = new IflowEngine.BimEngine720('app', {
locale: currentLocale,
fov: 75,
enableZoom: true,
enableRotate: true,
@@ -980,4 +1101,4 @@
</script>
</body>
</html>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -43,7 +43,7 @@ constructor(container: HTMLElement | string, options?: BimEngine2dOptions)
```typescript
interface BimEngine2dOptions {
/** 语言 */
locale?: 'zh-CN' | 'en-US';
locale?: 'zh-CN' | 'zh-TW' | 'en-US';
/** 主题 */
theme?: 'dark' | 'light';
/** 背景颜色(十六进制数值,如 0xffffff */

View File

@@ -43,7 +43,7 @@ constructor(container: HTMLElement | string, options?: BimEngine720Options)
```typescript
interface BimEngine720Options {
/** 语言 */
locale?: 'zh-CN' | 'en-US';
locale?: 'zh-CN' | 'zh-TW' | 'en-US';
/** 主题 */
theme?: 'dark' | 'light';
/** 视场角(默认 75 */

View File

@@ -18,7 +18,8 @@ src/
│ └── theme.ts # 主题服务
├── locales/
│ ├── types.ts # 翻译类型定义
│ ├── zh-CN.ts # 中文翻译
│ ├── zh-CN.ts # 简体中文翻译
│ ├── zh-TW.ts # 繁体中文翻译
│ └── en-US.ts # 英文翻译
└── themes/
├── types.ts # 主题类型定义
@@ -43,7 +44,7 @@ class LocaleManager {
subscribe(listener: (locale: LocaleType) => void): () => void;
}
type LocaleType = 'zh-CN' | 'en-US';
type LocaleType = 'zh-CN' | 'zh-TW' | 'en-US';
// 单例导出
export const localeManager: LocaleManager;

View File

@@ -8,6 +8,7 @@
| **managers** | `src/managers/` | 15 个管理器,处理业务逻辑和组件协调(仅 3D | [管理器模块.md](管理器模块.md) |
| **components** | `src/components/` | 3 个引擎组件 + 20+ UI 组件 | [组件模块.md](组件模块.md) |
| **services** | `src/services/` | 全局服务:国际化、主题管理 | [服务模块.md](服务模块.md) |
| **settings** | `src/managers/setting-dialog-manager.ts` | 设置系统:渲染、显示、环境配置 | [设置系统.md](设置系统.md) |
## Core 模块
@@ -38,18 +39,19 @@
### 功能管理器
| 管理器 | 职责 |
|--------|------|
| ComponentDetailManager | 构件详情弹窗管理 |
| ConstructTreeManagerBtn | 构件树管理 |
| MeasureDialogManager | 测量对话框管理 |
| SectionPlaneDialogManager | 平面剖切管理 |
| SectionAxisDialogManager | 轴向剖切管理 |
| SectionBoxDialogManager | 剖切盒管理 |
| WalkControlManager | 漫游控制管理 |
| WalkPathDialogManager | 漫游路径管理 |
| WalkPlanViewDialogManager | 漫游平面图管理 |
| MapDialogManager | 图管理 |
| 管理器 | 职责 | 文档 |
|--------|------|------|
| ComponentDetailManager | 构件详情弹窗管理 | |
| ConstructTreeManagerBtn | 构件树管理 | |
| MeasureDialogManager | 测量对话框管理 | |
| SectionPlaneDialogManager | 平面剖切管理 | |
| SectionAxisDialogManager | 轴向剖切管理 | |
| SectionBoxDialogManager | 剖切盒管理 | |
| SettingDialogManager | 设置系统管理 | [设置系统.md](设置系统.md) |
| WalkControlManager | 漫游控制管理 | |
| WalkPathDialogManager | 漫游路径管理 | |
| WalkPlanViewDialogManager | 漫游平面图管理 | |
| MapDialogManager | 地图管理 | |
[查看详情 →](管理器模块.md)

View File

@@ -0,0 +1,926 @@
# 设置系统模块文档
> 本文档基于 `iflow-engine` SDK 真实代码实现说明设置系统的架构、API 和第三方预设接入方式。
## 目录
- [1. 架构概览](#1-架构概览)
- [2. 核心类型](#2-核心类型)
- [3. 设置面板UI](#3-设置面板ui)
- [4. API 参考](#4-api-参考)
- [5. 第三方预设接入](#5-第三方预设接入)
- [6. 事件系统](#6-事件系统)
---
## 1. 架构概览
设置系统采用分层架构:
```
┌─────────────────────────────────────────────────────────┐
│ UI 层: Toolbar Setting Button │
│ -> registry.setting.toggle() │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Manager 层: SettingDialogManager │
│ - 管理设置面板生命周期 │
│ - 维护预设列表 │
│ - 编排设置应用流程 │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 组件层: Engine (src/components/engine/index.ts) │
│ - 封装底层引擎设置能力 │
│ - 提供统一 getSettings/setSettings API │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 底层: iflow-engine-base │
│ - 实际 WebGL/Three.js 渲染设置 │
│ - Setting 模块 (shadow, lighting, env, etc.) │
└─────────────────────────────────────────────────────────┘
```
### 关键文件
| 文件 | 职责 |
|------|------|
| `src/managers/setting-dialog-manager.ts` | 设置对话框管理器UI 和预设管理 |
| `src/components/engine/index.ts` | 引擎组件,设置 API 实现 |
| `src/components/engine/types.ts` | 设置相关类型定义 |
| `src/types/events.ts` | 设置相关事件定义 |
| `src/locales/zh-CN.ts` | 设置面板文案国际化 |
---
## 2. 核心类型
### 2.1 EngineSettings - 完整设置
```typescript
interface EngineSettings {
render: RenderSettings;
display: DisplaySettings;
environment: EnvironmentSettings;
}
interface RenderSettings {
mode: 'simple' | 'balance' | 'advanced'; // 渲染模式
contrast: number; // 对比度 0-100
saturation: number; // 饱和度 0-100
shadowIntensity: number; // 阴影强度 0-100
lightIntensity: number; // 光照强度 0-100
gtaoIntensity: number; // GTAO 强度 0-100
}
interface DisplaySettings {
showEdge: boolean; // 显示边线
edgeOpacity: number; // 边线透明度 0-100
showGrid: boolean; // 显示轴网
showLevel: boolean; // 显示标高
showGround: boolean; // 显示地面
groundId: string; // 地面类型 ID
groundHeight: number; // 地面高度 (米)
}
interface EnvironmentSettings {
type: 'none' | 'hdr' | 'sky'; // 环境类型
hdrId: string; // HDR 背景 ID
hdrIntensity: number; // HDR 强度 0-100
skyPreset: string; // 天空预设
skyParams: SkyParams; // 天空高级参数
skyIntensity: number; // 天空强度 0-100
}
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;
}
```
### 2.2 EngineSettingPreset - 预设定义
```typescript
interface EngineSettingPreset {
id: string; // 唯一标识
presetName: string; // 预设显示名称
isDefault: boolean; // 是否为默认预设
settings: EngineSettings; // 预设包含的设置
readonly?: boolean; // 是否只读
source?: 'sdk-default' | 'external' | 'user'; // 预设来源
}
```
**source 字段说明:**
- `'sdk-default'`: SDK 内置的默认预设
- `'external'`: 第三方注入的预设
- `'user'`: 用户自行保存的预设
### 2.3 设置补丁 (局部更新)
```typescript
interface EngineSettingsPatch {
render?: Partial<RenderSettings>;
display?: Partial<DisplaySettings>;
environment?: Partial<EnvironmentSettings>;
}
```
### 2.4 预设资源列表
```typescript
interface SettingPresetLists {
ground: PresetListItem[]; // 地面类型列表
hdr: PresetListItem[]; // HDR 背景列表
sky: PresetListItem[]; // 天空预设列表
}
interface PresetListItem {
id: string;
names: string[]; // [中文名, 繁体名, 英文名]
}
```
---
## 3. 设置面板UI
### 3.1 面板结构
设置对话框宽度 `420px`,包含以下区域:
```
┌───────────────────────────────────────────┐
│ 设置 [×] │ ← 标题栏
├───────────────────────────────────────────┤
│ │
│ ▓▓ 渲染设置 ▓▓ │
│ 渲染模式: [性能] [平衡] [效果] │
│ 阴影强度: [══════════════●══] 75 │
│ 光照强度: [════════●═══════] 50 │
│ 对比度: [════════●═══════] 50 (效果模式可见)
│ 饱和度: [════════●═══════] 50 (效果模式可见)
│ GTAO: [════════●═══════] 50 (效果模式可见)
│ │
│ ▓▓ 显示设置 ▓▓ │
│ 边线 [◉] 透明度 [═══●═══] 50 │
│ 轴网 [◉] │
│ 标高 [◉] │
│ 地面 [◉] 类型 [地面一 ▼] 高度 [0] │
│ │
│ ▓▓ 环境背景 ▓▓ │
│ [无] [HDR背景] [天空盒] │
│ HDR: [hdr-001 ▼] 强度 [══●═══] 40 │
│ 或 │
│ 天空: [晴朗 ▼] 强度 [══●═══] 40 │
│ │
├───────────────────────────────────────────┤
│ [撤销修改] │ ← 修改时显示
│ [预设选择 ▼] [🗑] [保存预设] │ ← 底部操作栏
└───────────────────────────────────────────┘
```
### 3.2 UI 交互规则
1. **渲染模式条件显示**
- `contrast`, `saturation`, `gtaoIntensity` 仅在 `advanced` 模式下显示
- 切换模式时面板不抖动,控件平滑显示/隐藏
2. **环境类型互斥**
- `none`: 无环境背景
- `hdr`: 显示 HDR 选择器和强度滑块
- `sky`: 显示天空预设选择器和强度滑块
3. **显示设置联动**
- 边线开启后才显示透明度滑块
- 地面开启后才显示地面类型和高度输入
---
## 4. API 参考
### 4.1 SettingDialogManager
文件: `src/managers/setting-dialog-manager.ts`
#### 生命周期
```typescript
// 初始化设置管理器
init(): void
// 销毁设置管理器
destroy(): void
```
#### 对话框控制
```typescript
// 显示设置对话框
show(): void
// 隐藏设置对话框
hide(): void
// 切换显示状态
toggle(): void
// 获取对话框是否打开
isOpen(): boolean
```
#### 预设管理
```typescript
/**
* 设置/注入预设列表
* @param presets 预设数组,会合并到现有预设列表
*/
setPresetList(presets: EngineSettingPreset[]): void
// 获取当前预设列表
getPresetList(): EngineSettingPreset[]
// 获取当前选中的预设
getSelectedPreset(): EngineSettingPreset | null
// 应用指定 ID 的预设
applyPresetById(id: string): Promise<void>
```
### 4.2 Engine 组件设置 API
文件: `src/components/engine/index.ts`
#### 批量设置
```typescript
/**
* 获取当前完整设置
* @returns 当前 EngineSettings 的深拷贝
*/
getSettings(): EngineSettings
/**
* 应用设置补丁
* @param patch 部分设置,会合并到当前设置
* @returns Promise设置应用完成后 resolve
*/
setSettings(patch: EngineSettingsPatch): Promise<void>
/**
* 重置为默认设置
* @returns Promise
*/
resetToDefault(): Promise<void>
```
#### 预设资源
```typescript
/**
* 获取预设资源列表(地面/HDR/天空)
* @returns 各类资源的可用选项
*/
getPresetLists(): SettingPresetLists
```
#### 渲染模式
```typescript
// 获取当前渲染模式
getRenderMode(): 'simple' | 'balance' | 'advanced'
// 设置渲染模式
setRenderMode(mode: 'simple' | 'balance' | 'advanced'): void
```
#### 光照与渲染
```typescript
// 环境光强度
setAmbientLightIntensity(intensity: number): void // 0-100
getAmbientLightIntensity(): number
// 对比度
setSceneContrast(contrast: number): void // 0-100
getSceneContrast(): number
// 饱和度
setSceneSaturation(saturation: number): void // 0-100
getSceneSaturation(): number
// 阴影强度
setShadowIntensity(intensity: number): void // 0-100
```
#### 边线
```typescript
// 启用边线
activeModelEdge(): void
// 禁用边线
disActiveModelEdge(): void
// 获取边线是否启用
getModelEdgeActive(): boolean
// 设置边线透明度
setEdgeOpacity(opacity: number): void // 0-100
```
#### HDR 背景
```typescript
// 获取 HDR 列表
getHDRBackgroundList(): { name: string; id: string }[]
// 设置 HDR ID
setHDRBackgroundId(id: string): void
// 获取当前 HDR ID
getHDRBackgroundId(): string
// 设置 HDR 可见性
setHDRBackgroundVisibility(visible: boolean): void
// 设置 HDR 强度
setHDRIntensity(intensity: number): void // 0-100
```
#### 地面
```typescript
// 获取地面列表
getGroundList(): { name: string; id: string }[]
// 设置地面 ID
setGroundId(id: string): void
// 获取当前地面 ID
getGroundId(): string
// 设置地面高度
setGroundElevation(elevation: number): void // 米
// 获取地面高度
getGroundElevation(): number
// 设置地面可见性
setGroundVisible(visible: boolean): void
```
#### 天空
```typescript
// 获取天空预设列表
getSkyPresetList(): { name: string; id: string }[]
// 设置天空预设
setSkyPreset(presetId: string): void
// 设置天空强度
setSkyIntensity(intensity: number): void // 0-100
// 设置天空参数
setSkyParams(params: Partial<SkyParams>): void
```
### 4.3 BimEngine 快捷访问
```typescript
const bimEngine = new BimEngine(container, options);
// 初始化设置管理器
bimEngine.initSetting();
// 访问设置管理器
bimEngine.setting?.show();
bimEngine.setting?.hide();
bimEngine.setting?.toggle();
// 通过引擎组件访问设置 API
bimEngine.engine?.getSettings();
bimEngine.engine?.setSettings({ render: { mode: 'advanced' } });
```
---
## 5. 第三方预设接入
### 5.1 注入预设
第三方可通过 `setPresetList` 方法注入自定义预设:
```typescript
import { BimEngine } from 'iflow-engine';
// 创建引擎
const bimEngine = new BimEngine(container, {
locale: 'zh-CN',
theme: 'dark'
});
// 初始化必要组件
bimEngine.initToolbar();
bimEngine.initButtonGroup();
bimEngine.initDialog();
bimEngine.initEngine();
bimEngine.initSetting(); // 初始化设置管理器
// 注入第三方预设
const externalPresets = [
{
id: 'vendor-indoor',
presetName: '室内展厅',
isDefault: false,
source: 'external', // 标记为外部来源
settings: {
render: {
mode: 'advanced',
contrast: 55,
saturation: 60,
shadowIntensity: 70,
lightIntensity: 80,
gtaoIntensity: 60,
},
display: {
showEdge: true,
edgeOpacity: 40,
showGrid: false,
showLevel: true,
showGround: true,
groundId: 'marble-01',
groundHeight: 0,
},
environment: {
type: 'hdr',
hdrId: 'interior-01',
hdrIntensity: 30,
skyPreset: 'sunrise_clear',
skyParams: {},
skyIntensity: 20,
},
},
},
{
id: 'vendor-outdoor',
presetName: '室外建筑',
isDefault: true, // 设为默认预设
source: 'external',
settings: {
render: {
mode: 'balance',
contrast: 50,
saturation: 50,
shadowIntensity: 50,
lightIntensity: 50,
gtaoIntensity: 50,
},
display: {
showEdge: false,
edgeOpacity: 30,
showGrid: true,
showLevel: false,
showGround: true,
groundId: 'grass-01',
groundHeight: 0,
},
environment: {
type: 'sky',
hdrId: '0',
hdrIntensity: 20,
skyPreset: 'sunny_clear',
skyParams: {
turbidity: 3,
cloudCoverage: 0.2,
},
skyIntensity: 25,
},
},
},
];
bimEngine.setting?.setPresetList(externalPresets);
```
### 5.2 预设合并规则
调用 `setPresetList` 时的处理逻辑:
1. **保留内置预设**`__internal-default-preset__` 始终保留
2. **去重**:相同 `id` 的预设会被新传入的替换
3. **来源标记**:建议第三方预设标记 `source: 'external'`
4. **默认预设**
- 若传入的预设中有 `isDefault: true`,则选中该预设
- 若有多个 `isDefault: true`,取第一个
- 若没有指定默认,选中内置默认预设
### 5.3 动态更新预设
```typescript
// 随时可以更新预设列表
bimEngine.setting?.setPresetList([
{
id: 'dynamic-preset',
presetName: '动态预设',
isDefault: false,
source: 'external',
settings: { /* ... */ },
},
]);
// 获取当前预设列表
const presets = bimEngine.setting?.getPresetList();
```
### 5.4 完整示例
```typescript
import { BimEngine } from 'iflow-engine';
import type { EngineSettingPreset } from 'iflow-engine';
class VendorPresetManager {
private engine: BimEngine;
constructor(engine: BimEngine) {
this.engine = engine;
}
// 注册供应商预设
registerVendorPresets() {
const presets: EngineSettingPreset[] = [
this.createReviewPreset(),
this.createPresentationPreset(),
this.createPerformancePreset(),
];
this.engine.setting?.setPresetList(presets);
}
// 评审模式 - 高对比度,显示边线
private createReviewPreset(): EngineSettingPreset {
return {
id: 'vendor-review',
presetName: '评审模式',
isDefault: false,
source: 'external',
settings: {
render: {
mode: 'advanced',
contrast: 70,
saturation: 55,
shadowIntensity: 65,
lightIntensity: 55,
gtaoIntensity: 50,
},
display: {
showEdge: true,
edgeOpacity: 50,
showGrid: true,
showLevel: true,
showGround: true,
groundId: 'grid-01',
groundHeight: 0,
},
environment: {
type: 'hdr',
hdrId: 'studio-01',
hdrIntensity: 35,
skyPreset: 'sunrise_clear',
skyParams: {},
skyIntensity: 20,
},
},
};
}
// 展示模式 - 高饱和度HDR环境
private createPresentationPreset(): EngineSettingPreset {
return {
id: 'vendor-presentation',
presetName: '展示模式',
isDefault: true, // 设为默认
source: 'external',
settings: {
render: {
mode: 'advanced',
contrast: 55,
saturation: 75,
shadowIntensity: 80,
lightIntensity: 70,
gtaoIntensity: 60,
},
display: {
showEdge: false,
edgeOpacity: 30,
showGrid: false,
showLevel: false,
showGround: true,
groundId: 'pavement-01',
groundHeight: -0.5,
},
environment: {
type: 'hdr',
hdrId: 'outdoor-afternoon',
hdrIntensity: 45,
skyPreset: 'sunset_glow',
skyParams: {},
skyIntensity: 25,
},
},
};
}
// 性能模式 - 低画质,高帧率
private createPerformancePreset(): EngineSettingPreset {
return {
id: 'vendor-performance',
presetName: '性能模式',
isDefault: false,
source: 'external',
settings: {
render: {
mode: 'simple',
contrast: 50,
saturation: 50,
shadowIntensity: 20,
lightIntensity: 50,
gtaoIntensity: 0,
},
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,
},
},
};
}
}
// 使用
const bimEngine = new BimEngine(container);
bimEngine.initToolbar();
bimEngine.initButtonGroup();
bimEngine.initDialog();
bimEngine.initEngine();
bimEngine.initSetting();
const presetManager = new VendorPresetManager(bimEngine);
presetManager.registerVendorPresets();
// 监听预设变更
bimEngine.on('setting:preset-changed', ({ preset }) => {
console.log(`已切换到: ${preset.presetName}`);
// 可以在这里做自定义逻辑
if (preset.source === 'external') {
console.log('使用第三方预设');
}
});
```
---
## 6. 事件系统
### 6.1 设置相关事件
文件: `src/types/events.ts`
```typescript
// 预设保存事件
'setting:preset-saved': {
preset: EngineSettingPreset; // 被保存的预设
currentSettings: EngineSettings; // 当前完整设置
timestamp: number;
}
// 预设切换事件
'setting:preset-changed': {
preset: EngineSettingPreset; // 切换后的目标预设
timestamp: number;
}
// 预设删除事件
'setting:preset-deleted': EngineSettingPreset; // 被删除的预设
```
### 6.2 事件监听示例
```typescript
const bimEngine = new BimEngine(container);
// 监听预设保存
const offSaved = bimEngine.on('setting:preset-saved', ({ preset, currentSettings, timestamp }) => {
console.log('用户保存预设:', preset.presetName);
console.log('预设来源:', preset.source);
console.log('保存时间:', new Date(timestamp));
// 可以持久化到本地存储或后端
localStorage.setItem(`preset_${preset.id}`, JSON.stringify(preset));
});
// 监听预设切换
const offChanged = bimEngine.on('setting:preset-changed', ({ preset, timestamp }) => {
console.log('预设已切换:', preset.presetName);
// 根据来源做不同处理
switch (preset.source) {
case 'sdk-default':
console.log('使用 SDK 默认预设');
break;
case 'external':
console.log('使用第三方预设');
break;
case 'user':
console.log('使用用户自定义预设');
break;
}
});
// 监听预设删除
const offDeleted = bimEngine.on('setting:preset-deleted', (preset) => {
console.log('预设已删除:', preset.presetName);
// 清理本地存储
localStorage.removeItem(`preset_${preset.id}`);
});
// 取消监听
// offSaved();
// offChanged();
// offDeleted();
```
### 6.3 与第三方系统集成
```typescript
// 示例:将用户保存的预设同步到后端
class PresetSyncService {
constructor(private engine: BimEngine) {
this.setupListeners();
}
private setupListeners() {
// 保存时同步到后端
this.engine.on('setting:preset-saved', async ({ preset }) => {
if (preset.source === 'user') {
await this.saveToServer(preset);
}
});
// 切换时上报埋点
this.engine.on('setting:preset-changed', ({ preset }) => {
this.trackPresetUsage(preset);
});
}
private async saveToServer(preset: EngineSettingPreset) {
try {
await fetch('/api/presets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(preset),
});
} catch (error) {
console.error('同步预设失败:', error);
}
}
private trackPresetUsage(preset: EngineSettingPreset) {
// 埋点上报
analytics.track('Preset Changed', {
presetId: preset.id,
presetName: preset.presetName,
source: preset.source,
});
}
}
```
---
## 7. 默认值参考
```typescript
const DEFAULT_SETTINGS: EngineSettings = {
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,
},
};
```
---
## 8. 常见问题
### Q1: 如何获取当前生效的设置?
```typescript
const currentSettings = bimEngine.engine?.getSettings();
console.log(currentSettings.render.mode);
```
### Q2: 如何只修改部分设置?
```typescript
// 只修改渲染模式,其他保持不变
await bimEngine.engine?.setSettings({
render: { mode: 'simple' }
});
```
### Q3: 第三方预设会被用户删除吗?
不会。`readonly: true` 的预设不会显示删除按钮。建议第三方预设设置 `readonly: true`
```typescript
{
id: 'vendor-preset',
presetName: '供应商预设',
source: 'external',
readonly: true, // 用户不可删除
settings: { ... }
}
```
### Q4: 如何清空所有第三方预设?
```typescript
// 获取当前列表
const currentList = bimEngine.setting?.getPresetList() || [];
// 过滤掉外部预设
const filteredList = currentList.filter(p => p.source !== 'external');
// 重新设置
bimEngine.setting?.setPresetList(filteredList);
```
### Q5: 设置修改后立即生效吗?
`setSettings` 返回 Promise设置应用完成后 resolve
```typescript
await bimEngine.engine?.setSettings({
environment: { type: 'hdr', hdrId: 'hdr-01' }
});
// 到这里设置已生效
```
---
**文档版本**: 1.0.0
**更新时间**: 2026-03-30
**适用 SDK 版本**: iflow-engine >= 2.2.0

View File

@@ -162,6 +162,101 @@ registry.engine3d?.getEngineComponent()?.activateXx();
---
---
## H. 设置系统对接
设置系统通过 `SettingDialogManager``Engine` 组件协作实现。
### H.1 架构
```text
SettingDialogManager (src/managers/setting-dialog-manager.ts)
├── UI 管理: 设置面板生命周期、预设列表展示
├── 预设管理: setPresetList() / applyPresetById()
└── 设置应用: 调用 engineComponent.setSettings()
Engine 组件 (src/components/engine/index.ts)
├── getSettings(): 获取当前完整设置
├── setSettings(patch): 应用设置补丁
└── 各类单项设置方法 (setRenderMode, setHDRBackgroundId, etc.)
```
### H.2 第三方注入预设
```typescript
// 初始化设置管理器
bimEngine.initSetting();
// 注入第三方预设
bimEngine.setting?.setPresetList([
{
id: 'vendor-preset',
presetName: '供应商预设',
isDefault: false,
source: 'external',
settings: {
render: { mode: 'advanced', contrast: 60, ... },
display: { showEdge: true, ... },
environment: { type: 'hdr', hdrId: 'hdr-01', ... }
}
}
]);
```
### H.3 设置 API 调用链
```text
// 用户点击设置按钮
Toolbar Setting Button
-> registry.setting.toggle()
-> SettingDialogManager.show()
-> createContent() 创建面板 UI
-> 用户修改设置
-> this.engineComponent?.setSettings(patch)
-> Engine.setSettings()
-> 调用底层引擎 setting 模块
// 用户切换预设
Preset Select Change
-> SettingDialogManager.applyPresetById(id)
-> 找到对应 preset
-> this.engineComponent?.setSettings(preset.settings)
-> emit('setting:preset-changed', { preset, timestamp })
// 用户保存预设
Save Preset Button
-> 弹出命名输入框
-> SettingDialogManager.saveAsPresetWithName(name)
-> 组装 EngineSettingPreset
-> emit('setting:preset-saved', { preset, currentSettings, timestamp })
```
### H.4 设置相关事件
```typescript
// 预设保存
bimEngine.on('setting:preset-saved', ({ preset, currentSettings, timestamp }) => {
// 可持久化到 localStorage 或后端
});
// 预设切换
bimEngine.on('setting:preset-changed', ({ preset, timestamp }) => {
// 可埋点上报
});
// 预设删除
bimEngine.on('setting:preset-deleted', (preset) => {
// 清理存储
});
```
### H.5 详细文档
- [设置系统模块文档](MODULES/设置系统.md) - 完整 API 和接入指南
---
## G. 验收清单
重构或新增功能后,至少检查:

View File

@@ -62,7 +62,7 @@ import { BimEngine, BimEngine2d, BimEngine720 } from 'iflow-engine';
import { BimEngine } from 'iflow-engine';
const engine = new BimEngine('container', {
locale: 'zh-CN', // 'zh-CN' | 'en-US'
locale: 'zh-CN', // 'zh-CN' | 'zh-TW' | 'en-US'
theme: 'dark' // 'dark' | 'light'
});
@@ -85,7 +85,7 @@ engine.engine?.loadModel(['https://example.com/model/'], {
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `container` | `HTMLElement \| string` | ✅ | DOM 元素或元素 ID |
| `options.locale` | `'zh-CN' \| 'en-US'` | | 界面语言,默认 `'zh-CN'` |
| `options.locale` | `'zh-CN' \| 'zh-TW' \| 'en-US'` | | 界面语言,默认 `'zh-CN'` |
| `options.theme` | `'dark' \| 'light'` | | 主题,默认 `'dark'` |
### 管理器
@@ -200,7 +200,7 @@ engine2d.destroy();
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `container` | `HTMLElement \| string` | ✅ | DOM 元素或元素 ID |
| `options.locale` | `'zh-CN' \| 'en-US'` | | 界面语言 |
| `options.locale` | `'zh-CN' \| 'zh-TW' \| 'en-US'` | | 界面语言 |
| `options.theme` | `'dark' \| 'light'` | | 主题 |
| `options.backgroundColor` | `number` | | 背景色,如 `0xffffff` |
| `options.gridEnabled` | `boolean` | | 是否显示网格 |
@@ -324,7 +324,7 @@ engine720.destroy();
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `container` | `HTMLElement \| string` | ✅ | DOM 元素或元素 ID |
| `options.locale` | `'zh-CN' \| 'en-US'` | | 界面语言 |
| `options.locale` | `'zh-CN' \| 'zh-TW' \| 'en-US'` | | 界面语言 |
| `options.theme` | `'dark' \| 'light'` | | 主题 |
| `options.fov` | `number` | | 视场角,默认 `75` |
| `options.enableZoom` | `boolean` | | 是否启用缩放,默认 `true` |

View File

@@ -26,6 +26,7 @@
| [贡献指南.md](贡献指南.md) | 开发协作、脚本说明、最小验证集 | 项目贡献者 |
| [运维手册.md](运维手册.md) | 构建/发布/回滚流程与常见问题 | 维护者 |
| [API调用链.md](API调用链.md) | API 调用链(用户交互 → Manager → Engine → 底层引擎) | SDK 维护者 |
| [MODULES/设置系统.md](MODULES/设置系统.md) | 设置系统架构、API、第三方预设接入 | 所有开发者 |
## 模块概览
```

12
package-lock.json generated
View File

@@ -1,15 +1,15 @@
{
"name": "iflow-engine",
"version": "2.4.4",
"version": "2.4.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "iflow-engine",
"version": "2.4.4",
"version": "2.4.8",
"license": "MIT",
"dependencies": {
"iflow-engine-base": "^3.4.4",
"iflow-engine-base": "^3.4.7",
"three": "^0.182.0"
},
"devDependencies": {
@@ -1829,9 +1829,9 @@
}
},
"node_modules/iflow-engine-base": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/iflow-engine-base/-/iflow-engine-base-3.4.4.tgz",
"integrity": "sha512-iM+c5y6TKdgZhBSgVK503CAMV0kNUHD82tHo+bakd7HbNxMX48LN+sLLd6WyLUiwsblHw/PpJqNc5YZkwvovMg==",
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/iflow-engine-base/-/iflow-engine-base-3.4.7.tgz",
"integrity": "sha512-SHVtyRpyPz1U9u73nULZUV6AQSCMOBovyPb6ROOToog1I/FowTuWNVM3zMLMNN88sJ/0XTWBHZbZEifwtMCIkg==",
"license": "ISC",
"dependencies": {
"@types/three": "^0.181.0",

View File

@@ -1,6 +1,6 @@
{
"name": "iflow-engine",
"version": "2.4.4",
"version": "2.4.8",
"description": "iFlow Engine SDK for Vue2, Vue3, React and HTML",
"main": "./dist/iflow-engine.umd.js",
"module": "./dist/iflow-engine.es.js",
@@ -59,7 +59,7 @@
"vite-plugin-dts": "^4.5.4"
},
"dependencies": {
"iflow-engine-base": "^3.4.4",
"iflow-engine-base": "^3.4.7",
"three": "^0.182.0"
}
}

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