diff --git a/.gitignore b/.gitignore index 8d032d5..11f19d9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /node_modules/ /public/ /dist/ +/assets/ nul \ No newline at end of file diff --git a/DRAG_USAGE.md b/DRAG_USAGE.md new file mode 100644 index 0000000..550fd84 --- /dev/null +++ b/DRAG_USAGE.md @@ -0,0 +1,169 @@ +# 模型拖拽功能使用说明 + +## 功能概述 + +模型拖拽功能允许用户通过鼠标拖动 3D 模型,支持限制轴向移动,同时只能激活一个轴。 + +## 配置参数 + +在加载或替换模型时,可以添加 `drag` 参数: + +```typescript +drag: { + enable: boolean, // 是否启用拖拽 + axis?: string, // 允许的轴向:'x' | 'y' | 'z' | 'xy' | 'xz' | 'yz' | 'xyz' + step?: number, // 移动步长,默认 0.1 +} +``` + +## 使用示例 + +### 1. 加载模型时配置拖拽 + +```typescript +// 单个模型加载 +await mainApp.appModel.add({ + modelId: 'model1', + modelUrl: 'path/to/model.glb', + drag: { + enable: true, + axis: 'y', // 只允许 Y 轴移动 + step: 0.1, // 每次移动 0.1 单位 + } +}); + +// 批量模型加载 +await mainApp.appModel.add([ + { + modelId: 'model1', + modelUrl: 'path/to/model1.glb', + drag: { + enable: true, + axis: 'x', + step: 0.5, + } + }, + { + modelId: 'model2', + modelUrl: 'path/to/model2.glb', + drag: { + enable: true, + axis: 'xyz', // 允许所有轴向 + step: 0.1, + } + } +]); +``` + +### 2. 替换模型时配置拖拽 + +```typescript +await mainApp.appModel.replaceModel({ + modelId: 'model1', + modelUrl: 'path/to/new-model.glb', + drag: { + enable: true, + axis: 'xz', // 允许 X 和 Z 轴移动 + step: 0.2, + } +}); +``` + +### 3. 动态控制拖拽 + +```typescript +// 启用/禁用拖拽 +mainApp.appModelDrag.setDragEnabled('model1', true); +mainApp.appModelDrag.setDragEnabled('model1', false); + +// 切换激活的轴向 +mainApp.appModelDrag.switchAxis('model1', 'x'); // 切换到 X 轴 +mainApp.appModelDrag.switchAxis('model1', 'y'); // 切换到 Y 轴 +mainApp.appModelDrag.switchAxis('model1', 'z'); // 切换到 Z 轴 + +// 获取拖拽配置 +const config = mainApp.appModelDrag.getDragConfig('model1'); +console.log(config); +``` + +## 轴向说明 + +- `'x'`: 只允许沿 X 轴移动 +- `'y'`: 只允许沿 Y 轴移动 +- `'z'`: 只允许沿 Z 轴移动 +- `'xy'`: 允许沿 X 和 Y 轴移动 +- `'xz'`: 允许沿 X 和 Z 轴移动 +- `'yz'`: 允许沿 Y 和 Z 轴移动 +- `'xyz'`: 允许沿所有轴移动(默认) + +## 注意事项 + +1. **同时只能激活一个轴**:即使配置了多个轴(如 'xyz'),拖拽时也只会沿当前激活的轴移动 +2. **默认激活轴**:拖拽开始时,会自动激活配置中的第一个可用轴 +3. **步长控制**:`step` 参数控制移动的精度,值越小移动越精细 +4. **模型根节点**:拖拽功能作用于模型的根节点,会移动整个模型 +5. **相机控制**:拖拽模型时会自动禁用相机转动,松开鼠标后自动恢复相机控制 + +## 完整示例 + +```typescript +import { MainApp } from './babylonjs/MainApp'; + +// 创建应用 +const mainApp = new MainApp(); + +// 加载配置 +mainApp.loadAConfig({ + container: document.getElementById('canvas'), + modelUrlList: [] +}); + +// 初始化 +await mainApp.Awake(); + +// 添加可拖拽的模型 +await mainApp.appModel.add({ + modelId: 'draggableModel', + modelUrl: 'models/example.glb', + drag: { + enable: true, + axis: 'y', + step: 0.1, + } +}); + +// 监听键盘事件切换轴向 +window.addEventListener('keydown', (e) => { + if (e.key === 'x') { + mainApp.appModelDrag.switchAxis('draggableModel', 'x'); + console.log('切换到 X 轴'); + } else if (e.key === 'y') { + mainApp.appModelDrag.switchAxis('draggableModel', 'y'); + console.log('切换到 Y 轴'); + } else if (e.key === 'z') { + mainApp.appModelDrag.switchAxis('draggableModel', 'z'); + console.log('切换到 Z 轴'); + } +}); +``` + +## API 参考 + +### AppModelDrag 类 + +#### 方法 + +- `configureDrag(modelId: string, config: DragConfig): void` + - 为模型配置拖拽功能 + +- `getDragConfig(modelId: string): DragConfig | undefined` + - 获取模型的拖拽配置 + +- `setDragEnabled(modelId: string, enable: boolean): void` + - 启用/禁用模型拖拽 + +- `switchAxis(modelId: string, axis: 'x' | 'y' | 'z'): void` + - 切换当前激活的轴向 + +- `dispose(): void` + - 清理资源 diff --git a/ScreenShot_2026-05-12_173410_545.png b/ScreenShot_2026-05-12_173410_545.png new file mode 100644 index 0000000..c01117c Binary files /dev/null and b/ScreenShot_2026-05-12_173410_545.png differ diff --git a/ScreenShot_2026-05-12_175052_657.png b/ScreenShot_2026-05-12_175052_657.png new file mode 100644 index 0000000..ab3ad36 Binary files /dev/null and b/ScreenShot_2026-05-12_175052_657.png differ diff --git a/ScreenShot_2026-05-12_175509_225.png b/ScreenShot_2026-05-12_175509_225.png new file mode 100644 index 0000000..ba2ae99 Binary files /dev/null and b/ScreenShot_2026-05-12_175509_225.png differ diff --git a/examples/drag-example.html b/examples/drag-example.html new file mode 100644 index 0000000..0369b22 --- /dev/null +++ b/examples/drag-example.html @@ -0,0 +1,168 @@ + + + + + + 模型拖拽示例 + + + + + +
+

模型拖拽控制

+ +
+ 切换轴向:
+ + + +
+ +
+ 拖拽控制:
+ +
+ +
+ 使用说明:
+ • 点击并拖动模型进行移动
+ • 当前只能沿一个轴移动
+ • 使用按钮切换激活的轴向
+ • 键盘快捷键:X/Y/Z 键切换轴向 +
+ +
+ 当前状态:
+ 拖拽已启用 | 激活轴:Y +
+
+ + + + diff --git a/index copy.html b/index copy.html new file mode 100644 index 0000000..0d052e5 --- /dev/null +++ b/index copy.html @@ -0,0 +1,623 @@ + + + + + + + 3D Model Showcase SDK - TS + + + + +
+ +
+ + +
+ + +
+
选装选配
+ + +
+
+ 棚子尺寸 + +
+
+
+ + + + +
+
+
+ + +
+
+ 棚子类型 + +
+
+
+ + + + +
+
+
+ + +
+
+ 百叶 + +
+
+
+ + + + + +
+
+
+ + +
+
+ 配色 + +
+
+
+ + + + +
+
+
+
+
+ + + + + + + + + \ No newline at end of file diff --git a/index.html b/index.html index caf600a..c611898 100644 --- a/index.html +++ b/index.html @@ -67,6 +67,40 @@ border-bottom: 2px solid rgba(255, 255, 255, 0.2); } + .click-info { + background: rgba(76, 175, 80, 0.2); + border: 1px solid rgba(76, 175, 80, 0.5); + border-radius: 8px; + padding: 12px; + margin-bottom: 15px; + color: #fff; + font-size: 13px; + line-height: 1.6; + } + + .click-info-title { + font-weight: bold; + color: #4caf50; + margin-bottom: 8px; + font-size: 14px; + } + + .click-info-item { + margin-bottom: 4px; + display: flex; + gap: 8px; + } + + .click-info-label { + color: rgba(255, 255, 255, 0.7); + min-width: 70px; + } + + .click-info-value { + color: #fff; + word-break: break-all; + } + .config-category { margin-bottom: 15px; border-radius: 8px; @@ -227,12 +261,34 @@
0%
+ + +
选装选配
+ + +
@@ -273,7 +329,7 @@
- + @@ -290,13 +346,16 @@
- +
+ + +
@@ -390,6 +449,10 @@ diff --git a/index.js b/index.js index ae66de0..036f76b 100644 --- a/index.js +++ b/index.js @@ -15,45 +15,58 @@ const config = { gizmo: { position: true, rotation: true, - scale: false + scale: true }, outline: { enable: true, color: "#2196F3", - thickness:1, - occlusionStrength:0.1, - occlusionThreshold:0.0002 + thickness: 1, + occlusionStrength: 0.1, + occlusionThreshold: 0.0002 } }; kernel.init(config); -kernel.model.add({ - modelId: "框架", - modelUrl: "https://sdk.zguiy.com/resurces/model/框架.glb", - modelControlType: "color" -}); -kernel.model.add({ - modelId: "卷帘大", - modelUrl: "https://sdk.zguiy.com/resurces/model/卷帘大.glb", - modelControlType: "color" -}); -kernel.model.add({ - modelId: "卷帘小", - modelUrl: "https://sdk.zguiy.com/resurces/model/卷帘小.glb", - modelControlType: "color" -}); -kernel.model.add({ - modelId: "小桌", - modelUrl: "https://sdk.zguiy.com/resurces/model/小桌.glb", - modelControlType: "rotation" -}); + + const response = await fetch('http://localhost:3001/api/models/auto-load/list') + const data = await response.json() + const models = data.data // 这就是模型列表 + console.log(models); + + models.forEach(model => { + kernel.model.add({ + modelId: model.id, + modelUrl: model.file_url || `http://localhost:3001${model.file_path}`, + modelControlType: model.model_control_type, + }); + }) + +// kernel.model.add({ +// modelId: "框架", +// modelUrl: "https://sdk.zguiy.com/resurces/model/框架.glb", +// modelControlType: "color" +// }); +// kernel.model.add({ +// modelId: "卷帘大", +// modelUrl: "https://sdk.zguiy.com/resurces/model/卷帘大.glb", +// modelControlType: "color" +// }); +// kernel.model.add({ +// modelId: "配件中", +// modelUrl: "https://sdk.zguiy.com/resurces/model/卷帘小.glb", +// modelControlType: "color" +// }); +// kernel.model.add({ +// modelId: "小桌", +// modelUrl: "https://sdk.zguiy.com/resurces/model/小桌.glb", +// modelControlType: "rotation" +// }); kernel.on('model:load:progress', (data) => { console.log('模型加载事件', data); }); - kernel.on('model:loaded', (data) => { console.log('模型加载完成', data); // 隐藏进度条 @@ -70,19 +83,19 @@ kernel.on('all:ready', (data) => { attribute: 'alpha', value: 0.5, }); - kernel.hotspot.render([ - { - id: "h1", - type: 'hotspot', - name: "卷帘门", - meshName: "Valve_01", - icon: "https://bpic.588ku.com/element_pic/20/06/30/d1046b01afc0b9586844350d131f4daf.jpg!/fw/253/quality/90/unsharp/true/compress/true", - offset: [25, 25, 0], - radius: 20, - color: "#21c7ff", - payload: { type: "valve", code: "A" }, - }, - ]); + // kernel.hotspot.render([ + // { + // id: "h1", + // type: 'hotspot', + // name: "卷帘门", + // meshName: "Valve_01", + // icon: "https://bpic.588ku.com/element_pic/20/06/30/d1046b01afc0b9586844350d131f4daf.jpg!/fw/253/quality/90/unsharp/true/compress/true", + // position: [25, 25, 0], + // radius: 20, + // color: "#21c7ff", + // payload: { type: "valve", code: "A" }, + // }, + // ]); }); @@ -150,17 +163,26 @@ window.getCurrentPickedMesh = () => currentPickedMesh; // 暴露 kernel 到全局,方便调试 -kernel.on('hotspot:click', (data) => { - console.log('热点被点击:', data); - const { id, name } = data - if (name === "卷帘门") { - kernel.door.toggle({ upY: 28, downY: 0, speed: 12 }); +kernel.on('hotspot:click', (event) => { + console.log('热点被点击:', event); - // Y轴剖切,只作用于卷帘门网格,保留下方,剖掉上方 - const clipHeight = 28; // 调整这个值找到合适的剖切高度 - console.log('设置剖切:', clipHeight); - kernel.clipping.setY(clipHeight, true, ['Box005.001', 'Box006.001']); + const { id, name, payload } = event; + + if (payload && payload.skus && payload.skus.length > 0) { + console.log('热点关联的SKU列表:', payload.skus); + // 这里可以根据 SKU 列表做进一步处理,比如显示产品信息 + } else { + console.log('该热点没有关联SKU'); } + + // if (name === "卷帘门") { + // kernel.door.toggle({ upY: 28, downY: 0, speed: 12 }); + + // // Y轴剖切,只作用于卷帘门网格,保留下方,剖掉上方 + // const clipHeight = 28; // 调整这个值找到合适的剖切高度 + // console.log('设置剖切:', clipHeight); + // kernel.clipping.setY(clipHeight, true, ['Box005.001', 'Box006.001']); + // } }); window.kernel = kernel; diff --git a/src/babylonjs/AppCamera.ts b/src/babylonjs/AppCamera.ts index 7e70aee..f227a93 100644 --- a/src/babylonjs/AppCamera.ts +++ b/src/babylonjs/AppCamera.ts @@ -32,7 +32,7 @@ export class AppCamera extends Monobehiver { // this.object.upperBetaLimit = Tools.ToRadians(60); // 最大垂直角(接近90度,避免万向锁) // this.object.lowerBetaLimit = Tools.ToRadians(60); // 最小垂直角 - this.object.position = new Vector3(-0, 100, 0); + this.object.position = new Vector3(-0, 10, 0); this.setTarget(0, 2, 0); } diff --git a/src/babylonjs/AppDropZone.ts b/src/babylonjs/AppDropZone.ts new file mode 100644 index 0000000..b6e54e3 --- /dev/null +++ b/src/babylonjs/AppDropZone.ts @@ -0,0 +1,540 @@ +import { Scene, Mesh, MeshBuilder, StandardMaterial, Color3, Vector3, AbstractMesh, BoundingBoxGizmo } from '@babylonjs/core'; + +export interface DropZoneConfig { + modelName: string; // 目标模型名称 + divisions: number; // 分割块数(每条边分成几块) + color?: string; // 颜色(十六进制) + alpha?: number; // 透明度 + thickness?: number; // 厚度 + offset?: number; // 距离模型的偏移量 + scale?: number; // 整体缩放比例(0-1),用于生成内部放置区域 +} + +export class AppDropZone { + private scene: Scene; + private dropZones: Mesh[] = []; + private dropZoneConfigs: Map = new Map(); // 存储每个模型的放置区域配置 + private boundingBoxLines: Mesh[] = []; // 存储包围盒线框 + + constructor(scene: Scene) { + this.scene = scene; + } + + /** + * 根据模型包围盒生成四周的放置区域 + * @param config 配置参数 + */ + generateDropZones(config: DropZoneConfig): Mesh[] { + const { + modelName, + divisions, + color = '#21c7ff', + alpha = 0.3, + thickness = 2, + offset = 5, + scale = 1.0 + } = config; + + // 查找目标模型(支持 modelId 或 mesh name) + let targetMeshes: AbstractMesh[] | undefined; + + // 先尝试通过 modelId 查找(从 AppModel 的 modelDic) + const mainApp = (this.scene as any).mainApp; + if (mainApp?.appModel) { + targetMeshes = mainApp.appModel.getCachedMeshes(modelName); + } + + // 如果没找到,尝试通过 mesh name 查找 + if (!targetMeshes || targetMeshes.length === 0) { + const mesh = this.scene.getMeshByName(modelName); + if (mesh) { + targetMeshes = [mesh]; + } + } + + if (!targetMeshes || targetMeshes.length === 0) { + console.warn(`模型 ${modelName} 不存在`); + return []; + } + + // 计算所有网格的总包围盒(使用世界坐标) + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + + targetMeshes.forEach(mesh => { + // 强制更新世界矩阵 + mesh.computeWorldMatrix(true); + const boundingInfo = mesh.getBoundingInfo(); + + // 获取世界空间的包围盒 + const worldMin = boundingInfo.boundingBox.minimumWorld; + const worldMax = boundingInfo.boundingBox.maximumWorld; + + minX = Math.min(minX, worldMin.x); + minY = Math.min(minY, worldMin.y); + minZ = Math.min(minZ, worldMin.z); + maxX = Math.max(maxX, worldMax.x); + maxY = Math.max(maxY, worldMax.y); + maxZ = Math.max(maxZ, worldMax.z); + }); + + console.log('包围盒坐标:', { minX, minY, minZ, maxX, maxY, maxZ }); + console.log('包围盒尺寸:', { + width: maxX - minX, + height: maxY - minY, + depth: maxZ - minZ + }); + + const width = maxX - minX; + const height = maxY - minY; + const depth = maxZ - minZ; + + // 应用缩放比例 + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + const centerZ = (minZ + maxZ) / 2; + + const scaledWidth = width * scale; + const scaledHeight = height * scale; + const scaledDepth = depth * scale; + + const scaledMinX = centerX - scaledWidth / 2; + const scaledMaxX = centerX + scaledWidth / 2; + const scaledMinY = centerY - scaledHeight / 2; + const scaledMaxY = centerY + scaledHeight / 2; + const scaledMinZ = centerZ - scaledDepth / 2; + const scaledMaxZ = centerZ + scaledDepth / 2; + + // 计算每块的尺寸 + const blockWidth = scaledWidth / divisions; + const blockDepth = scaledDepth / divisions; + + const zones: Mesh[] = []; + const zoneConfigs: any[] = []; + + // 创建材质 + const material = this.createDropZoneMaterial(color, alpha); + + // 前面(Z轴负方向) + for (let i = 0; i < divisions; i++) { + const x = scaledMinX + blockWidth * i + blockWidth / 2; + const z = scaledMinZ - offset; + const position = new Vector3(x, scaledMinY, z); + const zone = this.createDropZonePlane( + `dropZone_${modelName}_front_${i}`, + blockWidth, + scaledHeight, + position, + 0, + material, + thickness + ); + zones.push(zone); + zoneConfigs.push({ + position: position.clone(), + width: blockWidth, + height: scaledHeight, + rotation: 0, + side: 'front', + index: i + }); + } + + // 后面(Z轴正方向) + for (let i = 0; i < divisions; i++) { + const x = scaledMinX + blockWidth * i + blockWidth / 2; + const z = scaledMaxZ + offset; + const position = new Vector3(x, scaledMinY, z); + const zone = this.createDropZonePlane( + `dropZone_${modelName}_back_${i}`, + blockWidth, + scaledHeight, + position, + 0, + material, + thickness + ); + zones.push(zone); + zoneConfigs.push({ + position: position.clone(), + width: blockWidth, + height: scaledHeight, + rotation: 0, + side: 'back', + index: i + }); + } + + // 左侧(X轴负方向) + for (let i = 0; i < divisions; i++) { + const x = scaledMinX - offset; + const z = scaledMinZ + blockDepth * i + blockDepth / 2; + const position = new Vector3(x, scaledMinY, z); + const zone = this.createDropZonePlane( + `dropZone_${modelName}_left_${i}`, + blockDepth, + scaledHeight, + position, + Math.PI / 2, + material, + thickness + ); + zones.push(zone); + zoneConfigs.push({ + position: position.clone(), + width: blockDepth, + height: scaledHeight, + rotation: Math.PI / 2, + side: 'left', + index: i + }); + } + + // 右侧(X轴正方向) + for (let i = 0; i < divisions; i++) { + const x = scaledMaxX + offset; + const z = scaledMinZ + blockDepth * i + blockDepth / 2; + const position = new Vector3(x, scaledMinY, z); + const zone = this.createDropZonePlane( + `dropZone_${modelName}_right_${i}`, + blockDepth, + scaledHeight, + position, + Math.PI / 2, + material, + thickness + ); + zones.push(zone); + zoneConfigs.push({ + position: position.clone(), + width: blockDepth, + height: scaledHeight, + rotation: Math.PI / 2, + side: 'right', + index: i + }); + } + + // 保存配置 + this.dropZoneConfigs.set(modelName, zoneConfigs); + this.dropZones.push(...zones); + + // 显示包围盒 + this.showBoundingBox(modelName, '#ff0000'); + + // 默认隐藏 + zones.forEach(zone => zone.setEnabled(false)); + + return zones; + } + + /** + * 创建单个放置区域平面 + */ + private createDropZonePlane( + name: string, + width: number, + height: number, + position: Vector3, + rotationY: number, + material: StandardMaterial, + thickness: number + ): Mesh { + // 创建主平面 + const plane = MeshBuilder.CreatePlane(name, { + width: width, + height: height + }, this.scene); + + plane.position = position; + plane.rotation.y = rotationY; + plane.material = material; + plane.isPickable = true; // 可以被拾取,用于检测拖拽 + plane.metadata = { isDropZone: true }; // 标记为放置区域 + + // 创建边框线 + this.createBorder(plane, width, height, thickness); + + return plane; + } + + /** + * 创建边框线 + */ + private createBorder(parent: Mesh, width: number, height: number, thickness: number): void { + const halfWidth = width / 2; + const halfHeight = height / 2; + + const points = [ + new Vector3(-halfWidth, -halfHeight, -0.01), + new Vector3(halfWidth, -halfHeight, -0.01), + new Vector3(halfWidth, halfHeight, -0.01), + new Vector3(-halfWidth, halfHeight, -0.01), + new Vector3(-halfWidth, -halfHeight, -0.01) + ]; + + const border = MeshBuilder.CreateLines(`${parent.name}_border`, { + points: points + }, this.scene); + + border.color = new Color3(1, 1, 1); // 白色边框 + border.parent = parent; + } + + /** + * 创建放置区域材质 + */ + private createDropZoneMaterial(hexColor: string, alpha: number): StandardMaterial { + const material = new StandardMaterial('dropZoneMat_' + Date.now(), this.scene); + const rgb = this.hexToRgb(hexColor); + material.diffuseColor = new Color3(rgb.r, rgb.g, rgb.b); + material.alpha = alpha; + material.backFaceCulling = false; // 双面显示 + return material; + } + + /** + * 十六进制颜色转 RGB + */ + private hexToRgb(hex: string): { r: number; g: number; b: number } { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? { + r: parseInt(result[1], 16) / 255, + g: parseInt(result[2], 16) / 255, + b: parseInt(result[3], 16) / 255 + } : { r: 0.13, g: 0.78, b: 1 }; + } + + /** + * 显示所有放置区域 + */ + showAllDropZones(): void { + this.dropZones.forEach(zone => zone.setEnabled(true)); + // 显示包围盒 + this.boundingBoxLines.forEach(line => line.setEnabled(true)); + } + + /** + * 隐藏所有放置区域 + */ + hideAllDropZones(): void { + this.dropZones.forEach(zone => zone.setEnabled(false)); + // 隐藏包围盒 + this.boundingBoxLines.forEach(line => line.setEnabled(false)); + } + + /** + * 显示指定模型的放置区域 + */ + showDropZonesForModel(modelName: string): void { + this.dropZones + .filter(zone => zone.name.includes(`dropZone_${modelName}_`)) + .forEach(zone => zone.setEnabled(true)); + } + + /** + * 隐藏指定模型的放置区域 + */ + hideDropZonesForModel(modelName: string): void { + this.dropZones + .filter(zone => zone.name.includes(`dropZone_${modelName}_`)) + .forEach(zone => zone.setEnabled(false)); + } + + /** + * 检查某个位置是否在放置区域内 + * @param position 要检查的位置 + * @returns 如果在放置区域内,返回该区域的配置信息,否则返回 null + */ + checkInDropZone(position: Vector3): { zone: Mesh; config: any } | null { + for (const zone of this.dropZones) { + if (!zone.isEnabled()) continue; + + // 简单的距离检测 + const distance = Vector3.Distance( + new Vector3(position.x, 0, position.z), + new Vector3(zone.position.x, 0, zone.position.z) + ); + + // 获取区域的宽度(从 scaling 或原始尺寸计算) + const zoneBounds = zone.getBoundingInfo(); + const zoneSize = zoneBounds.boundingBox.extendSize; + const maxDistance = Math.max(zoneSize.x, zoneSize.z); + + if (distance < maxDistance) { + // 找到对应的配置 + const modelName = zone.name.split('_')[1]; + const configs = this.dropZoneConfigs.get(modelName); + const configIndex = parseInt(zone.name.split('_').pop() || '0'); + const config = configs ? configs.find(c => c.index === configIndex) : null; + + return { zone, config }; + } + } + return null; + } + + /** + * 高亮某个放置区域(鼠标悬停效果) + */ + highlightDropZone(zone: Mesh): void { + const material = zone.material as StandardMaterial; + if (material) { + material.alpha = 0.6; // 增加透明度 + material.emissiveColor = new Color3(0.2, 0.2, 0.2); // 添加发光效果 + } + } + + /** + * 取消高亮 + */ + unhighlightDropZone(zone: Mesh): void { + const material = zone.material as StandardMaterial; + if (material) { + material.alpha = 0.3; // 恢复透明度 + material.emissiveColor = new Color3(0, 0, 0); // 移除发光效果 + } + } + + /** + * 清除所有放置区域 + */ + clearAllDropZones(): void { + this.dropZones.forEach(zone => { + zone.dispose(); + }); + this.dropZones = []; + this.dropZoneConfigs.clear(); + } + + /** + * 清除指定模型的放置区域 + */ + clearDropZonesForModel(modelName: string): void { + const zonesToRemove = this.dropZones.filter(zone => + zone.name.includes(`dropZone_${modelName}_`) + ); + + zonesToRemove.forEach(zone => { + zone.dispose(); + const index = this.dropZones.indexOf(zone); + if (index > -1) { + this.dropZones.splice(index, 1); + } + }); + + this.dropZoneConfigs.delete(modelName); + } + + /** + * 获取所有放置区域 + */ + getAllDropZones(): Mesh[] { + return this.dropZones; + } + + /** + * 获取指定模型的放置区域配置 + */ + getDropZoneConfigsForModel(modelName: string): any[] { + return this.dropZoneConfigs.get(modelName) || []; + } + + /** + * 显示模型的包围盒 + * @param modelName 模型名称 + * @param color 包围盒颜色 + */ + private showBoundingBox(modelName: string, color: string = '#ff0000'): void { + // 查找目标模型 + let targetMeshes: AbstractMesh[] | undefined; + + const mainApp = (this.scene as any).mainApp; + if (mainApp?.appModel) { + targetMeshes = mainApp.appModel.getCachedMeshes(modelName); + } + + if (!targetMeshes || targetMeshes.length === 0) { + const mesh = this.scene.getMeshByName(modelName); + if (mesh) { + targetMeshes = [mesh]; + } + } + + if (!targetMeshes || targetMeshes.length === 0) { + console.warn(`模型 ${modelName} 不存在`); + return; + } + + // 计算总包围盒(使用世界坐标) + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + + targetMeshes.forEach(mesh => { + // 强制更新世界矩阵 + mesh.computeWorldMatrix(true); + const boundingInfo = mesh.getBoundingInfo(); + + // 获取世界空间的包围盒 + const worldMin = boundingInfo.boundingBox.minimumWorld; + const worldMax = boundingInfo.boundingBox.maximumWorld; + + minX = Math.min(minX, worldMin.x); + minY = Math.min(minY, worldMin.y); + minZ = Math.min(minZ, worldMin.z); + maxX = Math.max(maxX, worldMax.x); + maxY = Math.max(maxY, worldMax.y); + maxZ = Math.max(maxZ, worldMax.z); + }); + + // 创建包围盒的8个顶点 + const corners = [ + new Vector3(minX, minY, minZ), + new Vector3(maxX, minY, minZ), + new Vector3(maxX, minY, maxZ), + new Vector3(minX, minY, maxZ), + new Vector3(minX, maxY, minZ), + new Vector3(maxX, maxY, minZ), + new Vector3(maxX, maxY, maxZ), + new Vector3(minX, maxY, maxZ) + ]; + + // 创建12条边 + const edges = [ + // 底面4条边 + [corners[0], corners[1]], + [corners[1], corners[2]], + [corners[2], corners[3]], + [corners[3], corners[0]], + // 顶面4条边 + [corners[4], corners[5]], + [corners[5], corners[6]], + [corners[6], corners[7]], + [corners[7], corners[4]], + // 4条竖边 + [corners[0], corners[4]], + [corners[1], corners[5]], + [corners[2], corners[6]], + [corners[3], corners[7]] + ]; + + const rgb = this.hexToRgb(color); + const lineColor = new Color3(rgb.r, rgb.g, rgb.b); + + edges.forEach((edge, index) => { + const line = MeshBuilder.CreateLines(`boundingBox_${modelName}_${index}`, { + points: edge + }, this.scene); + line.color = lineColor; + this.boundingBoxLines.push(line); + }); + } + + /** + * 隐藏所有包围盒 + */ + private hideBoundingBox(): void { + this.boundingBoxLines.forEach(line => line.dispose()); + this.boundingBoxLines = []; + } +} diff --git a/src/babylonjs/AppModel.ts b/src/babylonjs/AppModel.ts index 8e2c339..89c9a00 100644 --- a/src/babylonjs/AppModel.ts +++ b/src/babylonjs/AppModel.ts @@ -8,6 +8,7 @@ import { Monobehiver } from '../base/Monobehiver'; import { Dictionary } from '../utils/Dictionary'; import { AppConfig } from './AppConfig'; import { EventBridge } from '../event/bridge'; +import { DragConfig } from './AppModelDrag'; type LoadResult = { success: boolean; @@ -27,6 +28,7 @@ type ModelMetadata = { modelId: string; modelUrl: string; modelControlType?: ModelControlType; + drag?: DragConfig; }; /** @@ -221,14 +223,15 @@ export class AppModel extends Monobehiver { return await this.addSingle( modelConfig.modelId, modelConfig.modelUrl, - modelConfig.modelControlType + modelConfig.modelControlType, + modelConfig.drag ); } /** * 添加单个模型 */ - private async addSingle(modelName: string, modelUrl: string, modelControlType?: ModelControlType): Promise { + private async addSingle(modelName: string, modelUrl: string, modelControlType?: ModelControlType, drag?: DragConfig): Promise { // 检查是否已存在 const existingMeshes = this.modelDic.Get(modelName); if (existingMeshes?.length && !existingMeshes[0].isDisposed()) { @@ -250,9 +253,15 @@ export class AppModel extends Monobehiver { this.modelMetadataDic.Set(modelName, { modelId: modelName, modelUrl: modelUrl, - modelControlType: modelControlType + modelControlType: modelControlType, + drag: drag }); + // 配置拖拽功能 + if (drag) { + this.mainApp.appModelDrag?.configureDrag(modelName, drag); + } + // 更新 GameManager 的字典 this.mainApp.gameManager?.updateDictionaries(); @@ -274,7 +283,7 @@ export class AppModel extends Monobehiver { EventBridge.modelLoadProgress({ loaded: 0, total, progress: 0, percentage: 0 }); for (let i = 0; i < models.length; i++) { - const { modelId, modelUrl, modelControlType } = models[i]; + const { modelId, modelUrl, modelControlType, drag } = models[i]; const result = await this.loadSingleModel(modelUrl, (event) => { this.emitProgress(i, total, modelUrl, event); @@ -288,8 +297,14 @@ export class AppModel extends Monobehiver { this.modelMetadataDic.Set(modelId, { modelId: modelId, modelUrl: modelUrl, - modelControlType: modelControlType + modelControlType: modelControlType, + drag: drag }); + + // 配置拖拽功能 + if (drag) { + this.mainApp.appModelDrag?.configureDrag(modelId, drag); + } } results.push(result); @@ -410,7 +425,8 @@ export class AppModel extends Monobehiver { return await this.addSingle( modelConfig.modelId, modelConfig.modelUrl, - modelConfig.modelControlType + modelConfig.modelControlType, + modelConfig.drag ); } diff --git a/src/babylonjs/AppModelDrag.ts b/src/babylonjs/AppModelDrag.ts new file mode 100644 index 0000000..18d24c0 --- /dev/null +++ b/src/babylonjs/AppModelDrag.ts @@ -0,0 +1,257 @@ +import { AbstractMesh } from '@babylonjs/core/Meshes/abstractMesh'; +import { PointerDragBehavior } from '@babylonjs/core/Behaviors/Meshes/pointerDragBehavior'; +import { Vector3 } from '@babylonjs/core/Maths/math.vector'; +import { Scene } from '@babylonjs/core/scene'; +import { Monobehiver } from '../base/Monobehiver'; + +/** + * 拖拽配置接口 + */ +export interface DragConfig { + enable: boolean; + axis?: 'x' | 'y' | 'z' | 'xy' | 'xz' | 'yz' | 'xyz'; + step?: number; +} + +/** + * 模型拖拽信息 + */ +interface ModelDragInfo { + config: DragConfig; + behavior: PointerDragBehavior | null; + currentAxis: 'x' | 'y' | 'z' | null; +} + +/** + * 模型拖拽管理器 - 负责处理模型的拖拽交互 + */ +export class AppModelDrag extends Monobehiver { + private modelDragMap: Map; + private scene: Scene | null; + + constructor(mainApp: any) { + super(mainApp); + this.modelDragMap = new Map(); + this.scene = null; + } + + /** + * 初始化拖拽管理器 + */ + Awake(): void { + this.scene = this.mainApp.appScene.object; + if (!this.scene) { + console.warn('Scene not initialized'); + return; + } + } + + /** + * 为模型配置拖拽 + * @param modelId 模型ID + * @param config 拖拽配置 + */ + configureDrag(modelId: string, config: DragConfig): void { + // 获取模型的根网格 + const meshes = this.mainApp.appModel?.modelDic?.Get(modelId); + if (!meshes || !meshes.length) { + console.warn(`Model not found: ${modelId}`); + return; + } + + const rootMesh = meshes[0]; // 第一个是根节点 + + // 如果已存在,先移除旧的行为 + const existingInfo = this.modelDragMap.get(modelId); + if (existingInfo?.behavior) { + rootMesh.removeBehavior(existingInfo.behavior); + } + + // 创建拖拽信息 + const dragInfo: ModelDragInfo = { + config: { ...config }, + behavior: null, + currentAxis: this.getFirstAvailableAxis(config.axis || 'xyz') + }; + + if (config.enable) { + // 创建并配置拖拽行为 + dragInfo.behavior = this.createDragBehavior(modelId, dragInfo); + rootMesh.addBehavior(dragInfo.behavior); + } + + this.modelDragMap.set(modelId, dragInfo); + } + + /** + * 创建拖拽行为 + */ + private createDragBehavior(modelId: string, dragInfo: ModelDragInfo): PointerDragBehavior { + const axis = dragInfo.currentAxis; + let dragAxis: Vector3; + + // 根据当前激活的轴创建拖拽向量 + switch (axis) { + case 'x': + dragAxis = new Vector3(1, 0, 0); + break; + case 'y': + dragAxis = new Vector3(0, 1, 0); + break; + case 'z': + dragAxis = new Vector3(0, 0, 1); + break; + default: + dragAxis = new Vector3(1, 0, 0); + } + + // 创建拖拽行为 + const pointerDragBehavior = new PointerDragBehavior({ dragAxis: dragAxis }); + + // 使用世界坐标系而不是物体本地坐标系 + pointerDragBehavior.useObjectOrientationForDragging = false; + + // 监听拖拽开始事件 + pointerDragBehavior.onDragStartObservable.add(() => { + // 禁用相机控制 + this.disableCameraControl(); + }); + + // 监听拖拽结束事件 + pointerDragBehavior.onDragEndObservable.add(() => { + // 恢复相机控制 + this.enableCameraControl(); + }); + + return pointerDragBehavior; + } + + /** + * 获取模型的拖拽配置 + * @param modelId 模型ID + */ + getDragConfig(modelId: string): DragConfig | undefined { + return this.modelDragMap.get(modelId)?.config; + } + + /** + * 启用/禁用模型拖拽 + * @param modelId 模型ID + * @param enable 是否启用 + */ + setDragEnabled(modelId: string, enable: boolean): void { + const dragInfo = this.modelDragMap.get(modelId); + if (!dragInfo) return; + + dragInfo.config.enable = enable; + + const meshes = this.mainApp.appModel?.modelDic?.Get(modelId); + if (!meshes || !meshes.length) return; + + const rootMesh = meshes[0]; + + if (enable) { + // 启用:创建并添加行为 + if (!dragInfo.behavior) { + dragInfo.behavior = this.createDragBehavior(modelId, dragInfo); + rootMesh.addBehavior(dragInfo.behavior); + } + } else { + // 禁用:移除行为 + if (dragInfo.behavior) { + rootMesh.removeBehavior(dragInfo.behavior); + dragInfo.behavior = null; + } + } + } + + /** + * 切换激活的轴向 + * @param modelId 模型ID + * @param axis 要激活的轴向 + */ + switchAxis(modelId: string, axis: 'x' | 'y' | 'z'): void { + const dragInfo = this.modelDragMap.get(modelId); + if (!dragInfo) return; + + // 检查该轴是否在允许的轴向中 + if (!this.isAxisAllowed(axis, dragInfo.config.axis || 'xyz')) { + console.warn(`Axis ${axis} is not allowed for model ${modelId}`); + return; + } + + // 更新当前轴 + dragInfo.currentAxis = axis; + + // 重新创建拖拽行为 + const meshes = this.mainApp.appModel?.modelDic?.Get(modelId); + if (!meshes || !meshes.length) return; + + const rootMesh = meshes[0]; + + // 移除旧行为 + if (dragInfo.behavior) { + rootMesh.removeBehavior(dragInfo.behavior); + } + + // 创建新行为 + if (dragInfo.config.enable) { + dragInfo.behavior = this.createDragBehavior(modelId, dragInfo); + rootMesh.addBehavior(dragInfo.behavior); + } + } + + /** + * 获取配置中的第一个可用轴 + */ + private getFirstAvailableAxis(axisConfig: string): 'x' | 'y' | 'z' | null { + if (axisConfig.includes('x')) return 'x'; + if (axisConfig.includes('y')) return 'y'; + if (axisConfig.includes('z')) return 'z'; + return null; + } + + /** + * 检查轴是否在允许的配置中 + */ + private isAxisAllowed(axis: 'x' | 'y' | 'z', axisConfig: string): boolean { + return axisConfig.includes(axis); + } + + /** + * 禁用相机控制 + */ + private disableCameraControl(): void { + const camera = this.mainApp.appCamera?.object; + if (camera) { + camera.detachControl(); + } + } + + /** + * 启用相机控制 + */ + private enableCameraControl(): void { + const camera = this.mainApp.appCamera?.object; + const canvas = this.mainApp.appEngin?.object?.getRenderingCanvas(); + if (camera && canvas) { + camera.attachControl(canvas, true); + } + } + + /** + * 清理资源 + */ + dispose(): void { + // 移除所有拖拽行为 + this.modelDragMap.forEach((dragInfo, modelId) => { + if (dragInfo.behavior) { + const meshes = this.mainApp.appModel?.modelDic?.Get(modelId); + if (meshes && meshes.length) { + meshes[0].removeBehavior(dragInfo.behavior); + } + } + }); + this.modelDragMap.clear(); + } +} diff --git a/src/babylonjs/GameManager.ts b/src/babylonjs/GameManager.ts index 71f6c12..d7cbe52 100644 --- a/src/babylonjs/GameManager.ts +++ b/src/babylonjs/GameManager.ts @@ -746,42 +746,70 @@ export class GameManager extends Monobehiver { } /** - * 应用材质 - * @param target 目标对象 - * @param material 材质路径 + * 应用材质属性 + * @param options 材质配置选项 */ - applyMaterial(target: string, attribute: string, value:string): void { - if (attribute !== 'baseColor' || typeof value !== 'string') return; + applyMaterial(options: { + target: string; + albedoColor?: string; + albedoTexture?: string; + normalMap?: string; + metallicTexture?: string; + roughness?: number; + metallic?: number; + }): void { this.updateDictionaries(); - // 示例实现:根据目标和材质路径应用材质 - // 1. 查找目标网格 + // 1. 查找目标材质 const targetMaterials: PBRMaterial[] = []; this.materialDic.Values().forEach(material => { - if (material.name === target) { + if (material.name === options.target) { targetMaterials.push(material); } }); if (targetMaterials.length === 0) { - console.warn(`Target not found: ${target}`); + console.warn(`Material not found: ${options.target}`); return; } - // 2. 处理材质路径 - // 这里可以根据材质路径加载对应的材质配置 - // 例如:paint/blue 可以映射到特定的材质配置 - - // 3. 应用材质到目标网格 - const color = Color3.FromHexString(value); + // 2. 应用材质属性到目标材质 targetMaterials.forEach(material => { - - // 如果是 baseColor 且值是字符串(16进制颜色),转换为 Color3 + // 应用颜色 + if (options.albedoColor) { + const color = Color3.FromHexString(options.albedoColor); material.albedoColor.copyFrom(color); + } - // 如果有纹理,颜色会作为纹理的乘法因子 - // 强制刷新材质 - material.markDirty(); + // 应用反照率纹理(颜色贴图) + if (options.albedoTexture) { + material.albedoTexture = new Texture(options.albedoTexture); + } + + // 应用法线贴图 + if (options.normalMap) { + material.bumpTexture = new Texture(options.normalMap); + } + + // 应用金属度贴图 + if (options.metallicTexture) { + material.metallicTexture = new Texture(options.metallicTexture); + } + + // 应用粗糙度值 + if (options.roughness !== undefined) { + material.roughness = options.roughness; + } + + // 应用金属度值 + if (options.metallic !== undefined) { + material.metallic = options.metallic; + } + + // 强制刷新材质 + material.markDirty(); }); + + console.log(`Material applied to: ${options.target}`, options); } } diff --git a/src/babylonjs/MainApp.ts b/src/babylonjs/MainApp.ts index 18da372..20fa229 100644 --- a/src/babylonjs/MainApp.ts +++ b/src/babylonjs/MainApp.ts @@ -17,6 +17,8 @@ import { AppHotspot } from './AppHotspot'; import { AppDomTo3D } from './AppDomTo3D'; import { AppSelectionOutline } from './AppSelectionOutline'; import { AppPositionGizmo } from './AppPositionGizmo'; +import { AppModelDrag } from './AppModelDrag'; +import { AppDropZone } from './AppDropZone'; /** * 主应用类 - 3D场景的核心控制器 @@ -34,6 +36,8 @@ export class MainApp { appDomTo3D: AppDomTo3D; appSelectionOutline: AppSelectionOutline; appPositionGizmo: AppPositionGizmo; + appModelDrag: AppModelDrag; + appDropZone: AppDropZone; gameManager: GameManager; @@ -49,6 +53,7 @@ export class MainApp { this.appDomTo3D = new AppDomTo3D(this); this.appSelectionOutline = new AppSelectionOutline(this); this.appPositionGizmo = new AppPositionGizmo(this); + this.appModelDrag = new AppModelDrag(this); this.gameManager = new GameManager(this); window.addEventListener("resize", () => this.appEngin.handleResize()); @@ -85,8 +90,11 @@ export class MainApp { this.appRay.Awake(); this.appSelectionOutline.init(); this.appPositionGizmo.Awake(); + this.appModelDrag.Awake(); this.appDomTo3D.init(); this.appModel.initManagers(); + // 在场景创建后初始化 AppDropZone + this.appDropZone = new AppDropZone(this.appScene.object); this.update(); EventBridge.sceneReady({ scene: this.appScene.object }); } @@ -106,6 +114,7 @@ export class MainApp { this.appModel?.clean(); this.appEnv?.clean(); this.appPositionGizmo?.dispose(); + this.appModelDrag?.dispose(); // this.appHotspot?.clear(); } } diff --git a/src/event/bus.ts b/src/event/bus.ts index 9644937..628354a 100644 --- a/src/event/bus.ts +++ b/src/event/bus.ts @@ -73,7 +73,18 @@ export const once = (eventName: string, callback: (...args: unknown[]) => void, }; export const emit = (eventName: string, ...args: unknown[]): Emitter => { - return eventBus.emit(eventName, ...args); + // 触发内部事件总线 + const result = eventBus.emit(eventName, ...args); + + // 同时触发 window 自定义事件,方便外部监听 + if (typeof window !== 'undefined') { + const customEvent = new CustomEvent(eventName, { + detail: args[0] // 传递第一个参数作为 detail + }); + window.dispatchEvent(customEvent); + } + + return result; }; export const removeAllListeners = (eventName?: string): Emitter => { diff --git a/src/kernel/Adapter.ts b/src/kernel/Adapter.ts index 10614ce..3a5ba9b 100644 --- a/src/kernel/Adapter.ts +++ b/src/kernel/Adapter.ts @@ -66,17 +66,18 @@ export class KernelAdapter { * 应用材质 * @param options 材质应用选项 */ - apply: (options: { target: string; attribute: string,value:string }): void => { - this.mainApp.gameManager.applyMaterial(options.target, options.attribute,options.value); + apply: (options: { + target: string; + albedoColor?: string; + albedoTexture?: string; + normalMap?: string; + metallicTexture?: string; + roughness?: number; + metallic?: number; + }): void => { + this.mainApp.gameManager.applyMaterial(options); }, - /** - * 更换材质颜色 - * @param materialName 材质名称 - * @param hexColor 16进制颜色值,例如 "#FF0000" - */ - color: (materialName: string, hexColor: string): void => { - this.mainApp.gameManager.applyMaterial(materialName, 'baseColor', hexColor); - } + }; /** 卷帘门控�?*/ @@ -214,6 +215,77 @@ export class KernelAdapter { } }; + /** 放置区域管理 */ + dropZone = { + /** + * 根据模型包围盒生成四周的放置区域 + * @param options 配置选项 + * @example + * kernel.dropZone.generate({ + * modelName: "框架", + * divisions: 4, + * color: "#21c7ff", + * alpha: 0.3, + * scale: 0.8 // 缩小到80%,用于内部放置区域 + * }); + */ + generate: (options: { + modelName: string; + divisions: number; + color?: string; + alpha?: number; + thickness?: number; + offset?: number; + scale?: number; + }): any[] => { + return this.mainApp.appDropZone.generateDropZones(options); + }, + /** + * 显示所有放置区域 + */ + showAll: (): void => { + this.mainApp.appDropZone.showAllDropZones(); + }, + /** + * 隐藏所有放置区域 + */ + hideAll: (): void => { + this.mainApp.appDropZone.hideAllDropZones(); + }, + /** + * 显示指定模型的放置区域 + */ + show: (modelName: string): void => { + this.mainApp.appDropZone.showDropZonesForModel(modelName); + }, + /** + * 隐藏指定模型的放置区域 + */ + hide: (modelName: string): void => { + this.mainApp.appDropZone.hideDropZonesForModel(modelName); + }, + /** + * 清除所有放置区域 + */ + clearAll: (): void => { + this.mainApp.appDropZone.clearAllDropZones(); + }, + /** + * 清除指定模型的放置区域 + */ + clear: (modelName: string): void => { + this.mainApp.appDropZone.clearDropZonesForModel(modelName); + }, + /** + * 检查某个位置是否在放置区域内 + */ + checkPosition: (position: [number, number, number]): any => { + const { Vector3 } = require('@babylonjs/core'); + const pos = new Vector3(position[0], position[1], position[2]); + return this.mainApp.appDropZone.checkInDropZone(pos); + } + }; + debug = { /** 列出当前场景网格名称 */ listMeshNames: (): string[] => { diff --git a/微信图片_20260512130148_220_3116.jpg b/微信图片_20260512130148_220_3116.jpg new file mode 100644 index 0000000..4967cb9 Binary files /dev/null and b/微信图片_20260512130148_220_3116.jpg differ diff --git a/微信图片_20260512194218_329_47.jpg b/微信图片_20260512194218_329_47.jpg new file mode 100644 index 0000000..fa9ef60 Binary files /dev/null and b/微信图片_20260512194218_329_47.jpg differ