feat: upgrade to v1.2.0 with model param validation and UI improvements

- Upgrade iflow-engine-base to ^2.0.0
- Add sanitizeModelParams for robust model operation validation
- Add try-catch error handling for render mode and model tool calls
- Preserve tree scroll position across tab switches
- Optimize tree node reveal with visibility check and centered scrolling
- Refactor collectModelParams to support multi-model grouping
- Fix tree CSS: remove duplicates, constrain overflow, improve layout
- Move version label to bottom-left
- Rebuild demo libs
This commit is contained in:
yuding
2026-03-02 09:45:59 +08:00
parent 837177f3f2
commit 0ccc891d7c
12 changed files with 4781 additions and 4457 deletions

View File

@@ -588,6 +588,29 @@ const dialog = engine.dialog.create({
- 树节点 ID 生成:当节点 `id` 为空但存在 `ids` 数组时,会对 `ids` 做纯 JS 哈希FNV-1a 32-bit 双哈希拼接),用于生成稳定且较短的节点唯一标识。
- 设计动机:避免依赖 `crypto.subtle`(该 API 在非安全上下文,如 HTTP 站点下可能为 `undefined`),导致运行时报错。
- 注意:该哈希仅用于 UI 树组件的 key/映射,不用于安全加密或鉴权。
- 父级联动规则:树节点勾选/取消勾选时Manager 会递归收集当前节点与全部子孙节点的构件 ID并按模型 URL 分组后再调用引擎显示/隐藏接口,避免父节点跨模型时仅处理单一 URL。
- 选中联动规则:节点选中时同样使用“递归收集 + 按 URL 分组”策略,先清空高亮,再执行高亮与视角定位。
#### 4.1.2 树组件搜索跳转与滚动稳定性
- 相关文件:`src/components/tree/index.ts`、`src/components/tree/index.css`
- 搜索命中节点跳转不再使用 `scrollIntoView({ behavior: 'smooth' })`,改为在树内容容器内计算目标 `scrollTop` 并调用 `contentElement.scrollTo({ behavior: 'auto' })`。
- 跳转时机采用双层 `requestAnimationFrame`,确保父节点展开后的布局完成后再滚动,避免在嵌套滚动容器中出现“跳转后无法滚动/卡死”。
- CSS 约束:树内容区使用 `overflow-y: auto` + `overflow-x: hidden`,节点行宽统一 `width: 100%`,避免 `fit-content + min-width: 100%` 组合导致滚动区域异常。
- Tab 切换显示修正:`src/components/tab/index.ts` 中激活面板不再写入 `display: block`,改为 `display: ''` + `.is-active` 类控制,避免覆盖 `display: flex` 后破坏树容器高度链导致“滑动后回弹”。
- Tab 面板滚动状态保留:`src/components/tab/index.ts` 为每个面板缓存 `.bim-tree-content` 的 `scrollTop`,切走时保存、切回时在 `requestAnimationFrame` 中恢复,避免切换后滚动位置突变。
- 树跳转幂等保护:`src/components/tree/index.ts` 新增 `lastRevealedNodeId` 与可视区判断;同一节点已在可视区时跳过再次自动滚动,减少切换 Tab 后重复重定位造成的“被拉回”。
- Tab 切换行为收敛:`src/managers/construct-tree-manager-btn.ts` 的 `onChange` 不再调用 `resetAllTrees()`,避免每次切换都触发全量勾选/模型显示与树状态重置。
#### 4.1.3 Engine 模型参数防御性校验
- 相关文件:`src/components/engine/index.ts`
- 对 `showModel/hideModels/highlightModel/viewScaleToModel` 增加统一参数清洗:
- 过滤空 URL、非数组 ID、非整数 ID
- 与已加载模型 URL 对齐(支持去除尾部 `/` 后匹配);
- 校验 `nodesMap` 中 ID 是否真实存在且具备可用索引;
- 去重后再下发到底层引擎。
- 目标:避免底层 `modelToolModule.showModel/hideModel` 在异常参数下触发 `forEach` 相关运行时错误。
#### 4.1.1 剖切盒SectionBox对接说明

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

19
package-lock.json generated
View File

@@ -1,15 +1,15 @@
{
"name": "iflow-engine",
"version": "1.1.3",
"version": "1.1.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "iflow-engine",
"version": "1.1.3",
"version": "1.1.8",
"license": "MIT",
"dependencies": {
"iflow-engine-base": "^1.1.5",
"iflow-engine-base": "^2.0.0",
"three": "^0.182.0"
},
"devDependencies": {
@@ -1431,6 +1431,12 @@
"node": ">=0.3.1"
}
},
"node_modules/draco3d": {
"version": "1.5.7",
"resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz",
"integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==",
"license": "Apache-2.0"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1781,13 +1787,14 @@
}
},
"node_modules/iflow-engine-base": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/iflow-engine-base/-/iflow-engine-base-1.1.5.tgz",
"integrity": "sha512-7i5igYpnsmmf9LyuzmyufAB70Gl9eHrjRpuEmsOpVNXovun4NTCeOR7e9hus9liETjGlVKhkyq26qomVq3ytew==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/iflow-engine-base/-/iflow-engine-base-2.0.0.tgz",
"integrity": "sha512-G4GyGikgHLtvf9nCjJD7eG8+2kDFFFavSMLCUOux+2Vw5BMNzj1R3w2/mgasUYY2ou6zL8j59j6xcFm0E+7xRw==",
"license": "ISC",
"dependencies": {
"@types/three": "^0.181.0",
"axios": "^1.13.2",
"draco3d": "^1.5.7",
"jszip": "^3.10.1",
"stats.js": "^0.17.0",
"three": "^0.181.2",

View File

@@ -1,6 +1,6 @@
{
"name": "iflow-engine",
"version": "1.1.7",
"version": "1.2.0",
"description": "iFlow Engine SDK for Vue2, Vue3, React and HTML",
"main": "./dist/iflow-engine.umd.js",
"module": "./dist/iflow-engine.es.js",
@@ -19,7 +19,7 @@
"dev": "vite",
"build": "tsc && vite build",
"copy:demo": "mkdir -p demo/lib && cp dist/iflow-engine.es.js dist/iflow-engine.umd.js dist/iflow-engine.umd.js.map demo/lib/",
"copy:demo-draco": "mkdir -p demo/static/js/draco && cp node_modules/iflow-engine-base/dist/draco/*.js node_modules/iflow-engine-base/dist/draco/*.wasm demo/static/js/draco/",
"copy:demo-draco": "mkdir -p demo/static/js/draco && (cp node_modules/iflow-engine-base/dist/draco/*.js demo/static/js/draco/ 2>/dev/null || true) && (cp node_modules/iflow-engine-base/dist/draco/*.wasm demo/static/js/draco/ 2>/dev/null || true)",
"copy:demo-all": "npm run copy:demo && npm run copy:demo-draco",
"copy:demo-vue": "mkdir -p demo-vue/public/lib && cp dist/iflow-engine.es.js dist/iflow-engine.umd.js dist/iflow-engine.umd.js.map demo-vue/public/lib/",
"dev:demo": "npm run build && npm run copy:demo-all && cd demo && npm run dev",
@@ -59,7 +59,7 @@
"vite-plugin-dts": "^4.5.4"
},
"dependencies": {
"iflow-engine-base": "^1.1.5",
"iflow-engine-base": "^2.0.0",
"three": "^0.182.0"
}
}

View File

@@ -27,7 +27,7 @@
.bim-engine-version {
position: absolute;
bottom: 6px;
right: 10px;
left: 10px;
font-size: 11px;
color: rgba(0, 0, 0, 0.25);
pointer-events: none;

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 };
@@ -579,7 +579,11 @@ export class Engine implements IBimComponent {
console.warn('[Engine] Cannot set render mode: engine not initialized.');
return;
}
try {
this.engine.engineModelModule.setMode(mode);
} catch (e) {
console.warn('[Engine] Failed to set render mode:', e);
}
}
// ==================== 结束:渲染模式 ====================
@@ -935,7 +939,13 @@ export class Engine implements IBimComponent {
console.warn('[Engine] Cannot highlight model: engine not initialized.');
return;
}
this.engine.modelToolModule.highlightModel(models);
const safeModels = this.sanitizeModelParams(models);
if (!safeModels.length) return;
try {
this.engine.modelToolModule.highlightModel(safeModels);
} catch (e) {
console.warn('[Engine] highlightModel failed:', e);
}
}
public unhighlightAllModels(): void {
@@ -951,7 +961,13 @@ export class Engine implements IBimComponent {
console.warn('[Engine] Cannot view scale to model: engine not initialized.');
return;
}
this.engine.modelToolModule.viewScaleToModel(models);
const safeModels = this.sanitizeModelParams(models);
if (!safeModels.length) return;
try {
this.engine.modelToolModule.viewScaleToModel(safeModels);
} catch (e) {
console.warn('[Engine] viewScaleToModel failed:', e);
}
}
public hideModels(models: { url: string; ids: number[] }[]): void {
@@ -959,7 +975,13 @@ export class Engine implements IBimComponent {
console.warn('[Engine] Cannot hide models: engine not initialized.');
return;
}
this.engine.modelToolModule.hideModel(models);
const safeModels = this.sanitizeModelParams(models);
if (!safeModels.length) return;
try {
this.engine.modelToolModule.hideModel(safeModels);
} catch (e) {
console.warn('[Engine] hideModels failed:', e);
}
}
public showModel(models: { url: string; ids: number[] }[]): void {
@@ -967,7 +989,60 @@ export class Engine implements IBimComponent {
console.warn('[Engine] Cannot show model: engine not initialized.');
return;
}
this.engine.modelToolModule.showModel(models);
const safeModels = this.sanitizeModelParams(models);
if (!safeModels.length) return;
try {
this.engine.modelToolModule.showModel(safeModels);
} catch (e) {
console.warn('[Engine] showModel failed:', e);
}
}
private sanitizeModelParams(models: { url: string; ids: number[] }[]): { url: string; ids: number[] }[] {
if (!Array.isArray(models)) return [];
const normalizeUrl = (url: string): string => url.replace(/\/+$/, '');
const loadedModels = Array.isArray((this.engine as any)?.models)
? ((this.engine as any).models as Array<{ url?: string; nodesMap?: Map<number, { indexes?: unknown[] }> }> )
: [];
const modelMap = new Map<string, { nodesMap?: Map<number, { indexes?: unknown[] }> }>();
for (const loadedModel of loadedModels) {
if (loadedModel?.url) {
modelMap.set(normalizeUrl(loadedModel.url), loadedModel);
}
}
const result: { url: string; ids: number[] }[] = [];
for (const model of models) {
if (!model || typeof model.url !== 'string' || !model.url) continue;
if (!Array.isArray(model.ids)) continue;
const loadedModel = modelMap.get(normalizeUrl(model.url));
if (!loadedModel || !(loadedModel.nodesMap instanceof Map)) {
continue;
}
const idsSet = new Set<number>();
for (const rawId of model.ids) {
if (!Number.isFinite(rawId)) continue;
const id = Number(rawId);
if (!Number.isInteger(id)) continue;
const node = loadedModel.nodesMap.get(id);
if (!node || !Array.isArray(node.indexes) || node.indexes.length === 0) {
continue;
}
idsSet.add(id);
}
const ids = Array.from(idsSet);
if (!ids.length) continue;
result.push({ url: model.url, ids });
}
return result;
}
/**
@@ -1115,4 +1190,3 @@ export class Engine implements IBimComponent {
this._isInitialized = false;
}
}

View File

@@ -26,6 +26,7 @@ export class BimTab implements IBimComponent {
private tabMap: Map<string, TabItem> = new Map();
/** id -> 内容容器 */
private panelMap: Map<string, HTMLElement> = new Map();
private panelTreeScrollTopMap: Map<string, number> = new Map();
/** 主题/语言订阅解除函数 */
private unsubscribeLocale: (() => void) | null = null;
private unsubscribeTheme: (() => void) | null = null;
@@ -175,8 +176,14 @@ export class BimTab implements IBimComponent {
// 更新面板显示
this.panelMap.forEach((panel, id) => {
const isActive = id === tabId;
if (!isActive) {
this.savePanelTreeScrollTop(id, panel);
}
panel.classList.toggle('is-active', isActive);
panel.style.display = isActive ? 'block' : 'none';
panel.style.display = isActive ? '' : 'none';
if (isActive) {
this.restorePanelTreeScrollTop(id, panel);
}
});
if (this.options.onChange) {
@@ -234,10 +241,27 @@ export class BimTab implements IBimComponent {
this.unsubscribeTheme = null;
}
this.panelMap.clear();
this.panelTreeScrollTopMap.clear();
this.tabMap.clear();
this.element.remove();
}
private savePanelTreeScrollTop(tabId: string, panel: HTMLElement): void {
const treeContent = panel.querySelector<HTMLElement>('.bim-tree-content');
if (!treeContent) return;
this.panelTreeScrollTopMap.set(tabId, treeContent.scrollTop);
}
private restorePanelTreeScrollTop(tabId: string, panel: HTMLElement): void {
const savedTop = this.panelTreeScrollTopMap.get(tabId);
if (savedTop === undefined) return;
const treeContent = panel.querySelector<HTMLElement>('.bim-tree-content');
if (!treeContent) return;
requestAnimationFrame(() => {
treeContent.scrollTop = savedTop;
});
}
/**
* 工具:解析标题(优先翻译,不存在则回退原值)
*/
@@ -251,4 +275,3 @@ export class BimTab implements IBimComponent {
}
}
}

View File

@@ -109,31 +109,6 @@
display: block;
}
/* 树内容区域 */
.bim-tree-content {
flex: 1;
overflow-y: auto;
padding: 2px 0;
min-height: 0; /* 允许在父容器中正确滚动 */
}
/* 节点行容器 */
.bim-tree-node {
display: flex;
flex-direction: column;
}
/* 节点内容行 (Flex 布局) */
.bim-tree-node-content {
display: flex;
align-items: center;
height: 32px; /* 标准行高 */
cursor: pointer;
transition: background-color 0.2s;
border-radius: 4px;
padding-right: 8px;
}
.bim-tree-node-content:hover {
background-color: var(--bim-component-bg-hover);
}
@@ -286,7 +261,10 @@
/* 树内容区域 */
.bim-tree-content {
flex: 1;
overflow: auto; /* 支持横向和纵向滚动 */
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
scroll-behavior: auto;
padding: 2px 0;
min-height: 0;
}
@@ -295,8 +273,7 @@
.bim-tree-node {
display: flex;
flex-direction: column;
width: fit-content; /* 关键:让节点容器跟随内容宽度 */
min-width: 100%;
width: 100%;
}
/* 节点内容行 (Flex 布局) */
@@ -309,8 +286,6 @@
border-radius: 4px;
padding-right: 8px;
/* 关键:允许宽度根据内容撑开,且至少占满容器 */
width: fit-content;
min-width: 100%;
width: 100%;
box-sizing: border-box;
}

View File

@@ -22,6 +22,7 @@ export class BimTree implements IBimComponent {
private nodeMap: Map<string, BimTreeNode> = new Map();
private rootNodes: BimTreeNode[] = [];
private selectedNode: BimTreeNode | null = null; // 当前选中的高亮节点
private lastRevealedNodeId: string | null = null;
// 订阅清理函数
private unsubscribeLocale: (() => void) | null = null;
@@ -210,6 +211,11 @@ export class BimTree implements IBimComponent {
* 定位到指定节点
*/
public revealNode(node: BimTreeNode) {
if (this.lastRevealedNodeId === node.config.id && this.isNodeFullyVisible(node)) {
return;
}
this.lastRevealedNodeId = node.config.id;
// 1. 关闭搜索下拉
if (this.searchResults) {
this.searchResults.classList.remove('is-visible');
@@ -227,9 +233,39 @@ export class BimTree implements IBimComponent {
this.handleNodeSelect(node);
// 4. 滚动到可视区域
setTimeout(() => {
node.element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, 100);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.scrollNodeIntoContentCenter(node);
});
});
}
private isNodeFullyVisible(node: BimTreeNode): boolean {
const container = this.contentElement;
if (!container || !node?.element?.isConnected) return false;
const containerRect = container.getBoundingClientRect();
const nodeRect = node.element.getBoundingClientRect();
return nodeRect.top >= containerRect.top && nodeRect.bottom <= containerRect.bottom;
}
private scrollNodeIntoContentCenter(node: BimTreeNode): void {
const container = this.contentElement;
if (!container || !node?.element?.isConnected) return;
const containerRect = container.getBoundingClientRect();
const nodeRect = node.element.getBoundingClientRect();
if (nodeRect.top >= containerRect.top && nodeRect.bottom <= containerRect.bottom) {
return;
}
const nodeTopInContainer = nodeRect.top - containerRect.top + container.scrollTop;
const targetTop = Math.max(0, nodeTopInContainer - (container.clientHeight - nodeRect.height) / 2);
container.scrollTo({
top: targetTop,
behavior: 'auto'
});
}
/**
@@ -279,6 +315,7 @@ export class BimTree implements IBimComponent {
this.rootNodes.forEach(node => node.destroy());
this.rootNodes = [];
this.nodeMap.clear();
this.lastRevealedNodeId = null;
this.element.remove();
this.selectedNode = null;
}

View File

@@ -82,6 +82,11 @@ interface TransformedNodeData extends EngineTreeNode {
_modelUrl: string;
}
interface ModelParam {
url: string;
ids: number[];
}
// ============================================================================
// 工具函数
// ============================================================================
@@ -123,14 +128,38 @@ async function hashIds(ids: string[]): Promise<string> {
* 递归收集节点及其所有子孙节点的 ids
* 用于父级节点操作时,获取其下所有叶子节点对应的构件 ids
*/
function collectAllIds(node: BimTreeNode): string[] {
const result: string[] = [];
const data = node.config.data as TransformedNodeData | undefined;
if (data?.ids?.length) {
result.push(...data.ids);
function collectModelParams(node: BimTreeNode): ModelParam[] {
const grouped = new Map<string, Set<number>>();
const collect = (current: BimTreeNode): void => {
const data = current.config.data as TransformedNodeData | undefined;
const modelUrl = data?._modelUrl;
if (modelUrl && data?.ids?.length) {
let idSet = grouped.get(modelUrl);
if (!idSet) {
idSet = new Set<number>();
grouped.set(modelUrl, idSet);
}
for (const rawId of data.ids) {
const id = Number(rawId);
if (Number.isFinite(id)) {
idSet.add(id);
}
}
}
for (const child of current.children || []) {
collect(child);
}
};
collect(node);
const result: ModelParam[] = [];
for (const [url, idSet] of grouped) {
if (idSet.size > 0) {
result.push({ url, ids: Array.from(idSet) });
}
for (const child of node.children || []) {
result.push(...collectAllIds(child));
}
return result;
}
@@ -337,19 +366,8 @@ export class ConstructTreeManagerBtn extends BaseManager {
* 勾选 → showModel取消勾选 → hideModels
*/
onNodeCheck: (node) => {
const nodeData = node.config.data as TransformedNodeData | undefined;
if (!nodeData?._modelUrl) return;
const ids = nodeData.ids?.length
? nodeData.ids
: collectAllIds(node);
if (!ids.length) return;
const modelParam = [{
url: nodeData._modelUrl,
ids: ids.map(Number)
}];
const modelParam = collectModelParams(node);
if (!modelParam.length) return;
if (node.checkState === TreeNodeCheckState.Checked) {
this.registry.engine3d?.showModel(modelParam);
@@ -362,19 +380,8 @@ export class ConstructTreeManagerBtn extends BaseManager {
* 节点选中回调 - 高亮并跳转到模型构件
*/
onNodeSelect: (node) => {
const nodeData = node.config.data as TransformedNodeData | undefined;
if (!nodeData?._modelUrl) return;
const ids = nodeData.ids?.length
? nodeData.ids
: collectAllIds(node);
if (!ids.length) return;
const modelParam = [{
url: nodeData._modelUrl,
ids: ids.map(Number)
}];
const modelParam = collectModelParams(node);
if (!modelParam.length) return;
this.registry.engine3d?.unhighlightAllModels();
this.registry.engine3d?.highlightModel(modelParam);
@@ -438,7 +445,6 @@ export class ConstructTreeManagerBtn extends BaseManager {
],
activeId: 'component',
onChange: () => {
resetAllTrees();
this.dialog?.fitWidth();
}
});