feat: add camera switch, fix click event/dialog resize/map state sync

- fix(engine): adapt click handler to base engine array format data[0].url/ids
- feat(toolbar): add perspective/orthographic camera switch button with dynamic icon
- fix(dialog): clamp resize to container bounds to prevent overflow
- feat(demo): add auto-combine feature with robust URL parsing and validation
- fix(walk): close minimap on walk exit and sync map state between toolbar and walk panel
- fix(engine): correct MiniMap getstate() casing to match base engine API
- build: rebuild demo libs
This commit is contained in:
yuding
2026-03-05 17:43:50 +08:00
parent b96e5f3262
commit 507112fcf9
18 changed files with 6890 additions and 6501 deletions

View File

@@ -200,6 +200,7 @@
<button class="primary" onclick="loadModel()">加载模型</button>
<button onclick="switchModel()">切换模型</button>
<button onclick="loadCombinedModel()">组合模型</button>
<button onclick="openCombineDialog()">自动组合</button>
</div>
<div class="btn-container" style="margin-top: 8px;">
<button onclick="pauseRendering()">暂停渲染</button>
@@ -216,6 +217,19 @@
<div id="app"></div>
</main>
<!-- 自动组合弹窗 -->
<div id="combine-overlay" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,.45); z-index:9999; display:none; justify-content:center; align-items:center;">
<div style="background:#fff; border-radius:10px; padding:24px; width:520px; max-width:90vw; box-shadow:0 8px 32px rgba(0,0,0,.18);">
<h3 style="margin:0 0 12px; font-size:1.1rem; color:#333;">自动组合加载</h3>
<p style="margin:0 0 10px; font-size:.85rem; color:#888;">请输入模型 URL每行一个或用英文逗号分隔</p>
<textarea id="combine-urls" rows="6" style="width:100%; padding:10px; font-size:.85rem; border:1px solid #ddd; border-radius:6px; resize:vertical; font-family:monospace;" placeholder="https://example.com/model1/&#10;https://example.com/model2/"></textarea>
<div style="display:flex; justify-content:flex-end; gap:8px; margin-top:14px;">
<button onclick="closeCombineDialog()" style="min-width:72px;">取消</button>
<button class="primary" onclick="confirmCombineLoad()" style="min-width:72px;">确定加载</button>
</div>
</div>
</div>
<script>
let engine = null;
let isToolbarVisible = true;
@@ -577,6 +591,84 @@
}
}
}
// --- 自动组合加载 ---
function openCombineDialog() {
if (!engine || !engine.engine || !engine.engine.isInitialized()) {
alert('请先初始化 3D 引擎!');
return;
}
const overlay = document.getElementById('combine-overlay');
overlay.style.display = 'flex';
}
function closeCombineDialog() {
const overlay = document.getElementById('combine-overlay');
overlay.style.display = 'none';
}
function confirmCombineLoad() {
let raw = document.getElementById('combine-urls').value.trim();
if (!raw) {
alert('请输入至少一个模型 URL');
return;
}
// 1) 统一清洗去除智能引号、零宽字符、BOM 等不可见字符
raw = raw
.replace(/[\u2018\u2019]/g, "'") // “” → '
.replace(/[\u201C\u201D]/g, '"') // “” → "
.replace(/[\u200B\uFEFF\u00A0]/g, '') // 零宽空格、BOM、不换行空格
.trim();
// 2) 尝试当 JSON 数组解析
let urls = [];
try {
const jsonStr = raw.replace(/'/g, '"');
const parsed = JSON.parse(jsonStr);
if (Array.isArray(parsed)) {
urls = parsed.map(s => String(s).trim()).filter(Boolean);
}
} catch(e) {
// 3) JSON 失败,走分割逻辑
urls = raw.split(/[,;\n\r]+/)
.map(s => s.trim().replace(/^['"|\[\]\s]+|['"|\[\]\s]+$/g, ''))
.filter(Boolean);
}
// 4) 只保留 http/https 开头的合法 URL
urls = urls.filter(s => /^https?:\/\//i.test(s));
if (urls.length === 0) {
alert('未解析到有效的 URL请确保以 http:// 或 https:// 开头');
return;
}
// 5) 逐个校验 URL 是否合法
for (let i = 0; i < urls.length; i++) {
try {
new URL(urls[i]);
} catch(e) {
alert('第 ' + (i+1) + ' 个 URL 无效:\n' + urls[i] + '\n\n请检查是否包含特殊字符');
console.error('❌ 无效 URL原始字符:', JSON.stringify(urls[i]));
return;
}
}
try {
engine.engine.loadModel(urls, {
position: [0, 0, 0],
rotation: [0, 0, 0],
scale: [1, 1, 1]
});
console.log('✅ 自动组合加载已发送:', urls);
closeCombineDialog();
document.getElementById('combine-urls').value = '';
} catch (error) {
console.error('❌ 自动组合加载错误:', error);
}
}
</script>
</body>

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

8
package-lock.json generated
View File

@@ -9,7 +9,7 @@
"version": "1.3.2",
"license": "MIT",
"dependencies": {
"iflow-engine-base": "^2.0.5",
"iflow-engine-base": "^2.0.6",
"three": "^0.182.0"
},
"devDependencies": {
@@ -1787,9 +1787,9 @@
}
},
"node_modules/iflow-engine-base": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/iflow-engine-base/-/iflow-engine-base-2.0.5.tgz",
"integrity": "sha512-/YM5f5l0DfvhYR75QNeeSqgW/OAXYmGfQbH+jNqTbACg604ki+BHwmBmXh/CPrugfdBj1OT5qXWHD76iPOriag==",
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/iflow-engine-base/-/iflow-engine-base-2.0.6.tgz",
"integrity": "sha512-ogC1miovUIAfC1XwlATl/y1UmZfs5RneUUxKRwWwLQ1Juz6VgWMHts8dyhmu3FRkAuUAhnknn1Gr/2MdLwxRvA==",
"license": "ISC",
"dependencies": {
"@types/three": "^0.181.0",

View File

@@ -1,6 +1,6 @@
{
"name": "iflow-engine",
"version": "1.3.2",
"version": "1.3.3",
"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": "^2.0.5",
"iflow-engine-base": "^2.0.6",
"three": "^0.182.0"
}
}

View File

@@ -739,6 +739,25 @@ export class BimButtonGroup implements IBimComponent {
private getIcon(icon?: string): string { return icon || this.DEFAULT_ICON; }
/**
* 更新指定按钮的图标
* @param id 按钮 ID
* @param icon 新的 SVG 图标字符串
*/
public updateButtonIcon(id: string, icon: string): void {
const btnEl = this.btnRefs.get(id);
if (!btnEl) return;
const iconEl = btnEl.querySelector('.opt-btn-icon');
if (iconEl) {
iconEl.innerHTML = this.getIcon(icon);
}
// 同步更新 button 对象的 icon 属性
const button = this.findButtonById(id);
if (button) {
button.icon = icon;
}
}
public updateButtonVisibility(id: string, visible: boolean): void {
if (!this.options.visibility) this.options.visibility = {};
this.options.visibility[id] = visible;

View File

@@ -0,0 +1,25 @@
import type { ButtonConfig } from '../../../index.type';
import { getIcon } from '../../../../../utils/icon-manager';
import type { ManagerRegistry } from '../../../../../core/manager-registry';
export const createCameraSwitchButton = (registry: ManagerRegistry): ButtonConfig => {
return {
id: 'camera-switch',
groupId: 'group-1',
type: 'button',
label: 'toolbar.cameraSwitch',
icon: getIcon('透视相机'),
keepActive: false,
onClick: () => {
const engineComponent = registry.engine3d?.getEngineComponent();
if (!engineComponent) return;
engineComponent.switchCamera();
// 切换后更新图标
const newType = engineComponent.getCameraType();
const newIcon = getIcon(newType === 'orthographic' ? '正交相机' : '透视相机');
registry.toolbar?.updateButtonIcon('camera-switch', newIcon);
}
};
};

View File

@@ -15,6 +15,9 @@ export const createMapButton = (registry: ManagerRegistry): ButtonConfig => {
icon: getIcon('地图'),
onClick: () => {
registry.engine3d?.getEngineComponent()?.toggleMiniMap();
// 同步漫游面板的小地图按钮状态
const mapState = registry.engine3d?.getEngineComponent()?.getMiniMapState() ?? false;
registry.walkControl?.panel?.setPlanViewActive(mapState);
}
};
};

View File

@@ -21,10 +21,12 @@ export class Toolbar extends BimButtonGroup {
const { createSectionAxisButton } = await import('./buttons/section/section-axis');
const { createSectionBoxButton } = await import('./buttons/section/section-box');
const { createAiChatButton } = await import('./buttons/ai-chat');
const { createCameraSwitchButton } = await import('./buttons/camera-switch');
this.addGroup('group-1');
this.addButton(createHomeButton(registry));
this.addButton(createCameraSwitchButton(registry));
this.addButton(createZoomBoxButton(registry));
this.addButton(createMeasureButton(registry));
this.addButton(createSectionMenuButton(registry));

View File

@@ -425,6 +425,10 @@ export class BimDialog implements IBimComponent {
let startY = 0;
let startW = 0;
let startH = 0;
let containerW = 0;
let containerH = 0;
let elLeft = 0;
let elTop = 0;
const onMouseDown = (e: MouseEvent) => {
e.preventDefault();
@@ -434,6 +438,12 @@ export class BimDialog implements IBimComponent {
startW = this.element.offsetWidth;
startH = this.element.offsetHeight;
// 缓存容器尺寸和弹窗位置,用于计算最大可缩放范围
containerW = this.container.clientWidth;
containerH = this.container.clientHeight;
elLeft = this.element.offsetLeft;
elTop = this.element.offsetTop;
// 关键:使用 capture: true
document.addEventListener('mousemove', onMouseMove, { capture: true });
document.addEventListener('mouseup', onMouseUp, { capture: true });
@@ -449,8 +459,12 @@ export class BimDialog implements IBimComponent {
const dx = e.clientX - startX;
const dy = e.clientY - startY;
const newW = Math.max(this.options.minWidth || 100, startW + dx);
const newH = Math.max(this.options.minHeight || 50, startH + dy);
// 最大宽高:不超出容器右边界/下边界
const maxW = containerW - elLeft;
const maxH = containerH - elTop;
const newW = Math.min(maxW, Math.max(this.options.minWidth || 100, startW + dx));
const newH = Math.min(maxH, Math.max(this.options.minHeight || 50, startH + dy));
this.element.style.width = `${newW}px`;
this.element.style.height = `${newH}px`;

View File

@@ -7,8 +7,8 @@ import type { MeasureUnit, MeasurePrecision } from '../measure-panel/types';
import type { SectionBoxRange } from '../section-box-panel/types';
import type { ManagerRegistry } from '../../core/manager-registry';
// 导入第三方 SDK 的 createEngine 函数(从 npm 包引入)
//import { createEngine as createEngineSDK } from 'iflow-engine-base';
import { createEngine as createEngineSDK } from '../../../../bim_engine_base/dist/bim-engine-sdk.es';
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 };
@@ -131,12 +131,13 @@ export class Engine implements IBimComponent {
this.setTheme(themeManager.getTheme());
// 监听构件点击事件
this.engine.events.on('click', (hit: any) => {
// 底层 interactionModule trigger 格式: [{url: string, ids: string[]}]
this.engine.events.on('click', (data: any) => {
const registry = this.registry;
if (hit && hit.object) {
if (data && Array.isArray(data) && data.length > 0 && data[0].url) {
this.selectedComponent = {
url: hit.object.url,
id: hit.object.name
url: data[0].url,
id: data[0].ids?.[0]
};
console.log('[Engine] 构件选中:', this.selectedComponent);
registry.emit('component:selected', this.selectedComponent);
@@ -593,6 +594,34 @@ export class Engine implements IBimComponent {
// ==================== 结束:剖切功能 ====================
// ==================== 相机切换 ====================
/**
* 切换相机类型(透视/正交)
* @remarks 底层调用 cameraModule.switchCurrentCamera(),保留当前相机位置
*/
public switchCamera(): void {
if (!this._isInitialized || !this.engine?.cameraModule) {
console.warn('[Engine] Cannot switch camera: engine not initialized.');
return;
}
this.engine.cameraModule.switchCurrentCamera();
}
/**
* 获取当前相机类型
* @returns 'perspective' | 'orthographic'
*/
public getCameraType(): 'perspective' | 'orthographic' {
if (!this._isInitialized || !this.engine?.cameraModule) {
return 'perspective';
}
// 底层 CameraType enum: PERSPECTIVE = 0, ORTHOGRAPHIC = 1
const type = this.engine.cameraModule.getCameraType();
return type === 1 ? 'orthographic' : 'perspective';
}
// ==================== 结束:相机切换 ====================
// ==================== 渲染模式 ====================
/**
@@ -943,7 +972,7 @@ export class Engine implements IBimComponent {
if (!this._isInitialized || !this.engine) {
return false;
}
return this.engine.minMap?.getState() ?? false;
return this.engine.minMap?.getstate() ?? false;
}
/**

View File

@@ -25,7 +25,8 @@ export const enUS: TranslationDictionary = {
section: 'Section',
sectionPlane: 'Plane Section',
sectionAxis: 'Axis Section',
sectionBox: 'Section Box'
sectionBox: 'Section Box',
cameraSwitch: 'Camera',
},
dialog: {
testTitle: 'Test Dialog',

View File

@@ -29,6 +29,7 @@ export interface TranslationDictionary {
sectionPlane: string;
sectionAxis: string;
sectionBox: string;
cameraSwitch: string;
};
panel: {
property: {

View File

@@ -25,7 +25,8 @@ export const zhCN: TranslationDictionary = {
section: '剖切',
sectionPlane: '拾取面剖切',
sectionAxis: '轴向剖切',
sectionBox: '剖切盒'
sectionBox: '剖切盒',
cameraSwitch: '相机切换',
},
dialog: {
testTitle: '测试弹窗',

View File

@@ -112,6 +112,15 @@ export class ToolbarManager extends BaseManager {
this.toolbar?.setBtnActive(id, active);
}
/**
* 更新按钮图标
* @param id 按钮 ID
* @param icon 新的 SVG 图标字符串
*/
public updateButtonIcon(id: string, icon: string) {
this.toolbar?.updateButtonIcon(id, icon);
}
/**
* 设置工具栏可见性
* @param visible 是否可见

View File

@@ -44,6 +44,8 @@ export class WalkControlManager extends BaseManager {
onPlanViewToggle: (isActive) => {
console.log('[WalkControl] 小地图:', isActive);
this.engineComponent?.toggleMiniMap();
// 同步工具栏地图按钮的激活状态
this.registry.toolbar?.setBtnActive('map', isActive);
this.emit('walk:plan-view-toggle', { isActive });
},
onPathModeToggle: (isActive) => {
@@ -91,7 +93,9 @@ export class WalkControlManager extends BaseManager {
});
this.panel.init();
// 同步当前地图状态到漫游面板
const mapState = this.engineComponent?.getMiniMapState() ?? false;
this.panel.setPlanViewActive(mapState);
if (this.registry.container) {
this.panel.element.style.position = 'absolute';
@@ -110,6 +114,11 @@ export class WalkControlManager extends BaseManager {
public hide(): void {
this.pathManager?.hide();
// 如果小地图开着,先关闭它
if (this.engineComponent?.getMiniMapState()) {
this.engineComponent.toggleMiniMap();
}
console.log('[WalkControl] 关闭漫游面板,退出第一人称模式');
this.engineComponent?.deactivateFirstPersonMode();
@@ -118,6 +127,9 @@ export class WalkControlManager extends BaseManager {
this.panel = null;
}
// 同步工具栏地图按钮的激活状态
this.registry.toolbar?.setBtnActive('map', false);
if (this.registry.toolbar) {
this.registry.toolbar.show();
}

View File

@@ -65,6 +65,10 @@ const ICONS: Record<string, string> = {
loader: '<svg width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 4V2A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8Z"><animateTransform attributeName="transform" dur="0.75s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/></path></svg>',
send: '<svg width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M2 21l21-9L2 3v7l15 2l-15 2v7z"/></svg>',
// ========== 相机切换图标 (48x48) ==========
: '<svg width="48" height="48" viewBox="0 0 48 48"><path fill="currentColor" d="M24 4L4 14v20l20 10l20-10V14L24 4zm0 4.5l14 7v14l-14 7l-14-7v-14l14-7zM24 18a6 6 0 100 12a6 6 0 000-12z"/></svg>',
: '<svg width="48" height="48" viewBox="0 0 48 48"><path fill="currentColor" d="M6 6h36v36H6V6zm4 4v28h28V10H10zm4 4h20v20H14V14z"/></svg>',
// ========== 默认图标 ==========
default: '<svg width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10s10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8s8 3.59 8 8s-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z"/></svg>',
};