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

@@ -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;
@@ -35,4 +35,4 @@
z-index: 1;
font-family: system-ui, -apple-system, sans-serif;
letter-spacing: 0.3px;
}
}

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;
}
this.engine.engineModelModule.setMode(mode);
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);
}
for (const child of node.children || []) {
result.push(...collectAllIds(child));
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) });
}
}
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();
}
});