This commit is contained in:
2026-05-13 10:43:06 +08:00
parent 6cefd063f2
commit 223fa5dd4e
19 changed files with 2282 additions and 104 deletions

1
.gitignore vendored
View File

@ -1,4 +1,5 @@
/node_modules/
/public/
/dist/
/assets/
nul

169
DRAG_USAGE.md Normal file
View File

@ -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`
- 清理资源

Binary file not shown.

After

Width:  |  Height:  |  Size: 481 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 550 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

168
examples/drag-example.html Normal file
View File

@ -0,0 +1,168 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>模型拖拽示例</title>
<style>
body {
margin: 0;
padding: 0;
overflow: hidden;
font-family: Arial, sans-serif;
}
#canvas {
width: 100vw;
height: 100vh;
display: block;
}
.controls {
position: absolute;
top: 20px;
left: 20px;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 20px;
border-radius: 8px;
max-width: 300px;
}
.controls h3 {
margin-top: 0;
}
.controls button {
margin: 5px;
padding: 8px 15px;
cursor: pointer;
border: none;
border-radius: 4px;
background: #4CAF50;
color: white;
font-size: 14px;
}
.controls button:hover {
background: #45a049;
}
.controls button.active {
background: #2196F3;
}
.info {
margin-top: 15px;
font-size: 12px;
line-height: 1.6;
}
</style>
</head>
<body>
<canvas id="canvas"></canvas>
<div class="controls">
<h3>模型拖拽控制</h3>
<div>
<strong>切换轴向:</strong><br>
<button id="axisX" onclick="switchAxis('x')">X 轴</button>
<button id="axisY" onclick="switchAxis('y')" class="active">Y 轴</button>
<button id="axisZ" onclick="switchAxis('z')">Z 轴</button>
</div>
<div style="margin-top: 15px;">
<strong>拖拽控制:</strong><br>
<button onclick="toggleDrag()">启用/禁用拖拽</button>
</div>
<div class="info">
<strong>使用说明:</strong><br>
• 点击并拖动模型进行移动<br>
• 当前只能沿一个轴移动<br>
• 使用按钮切换激活的轴向<br>
• 键盘快捷键X/Y/Z 键切换轴向
</div>
<div class="info" style="margin-top: 10px;">
<strong>当前状态:</strong><br>
<span id="status">拖拽已启用 | 激活轴Y</span>
</div>
</div>
<script type="module">
import { MainApp } from '../dist/assets/index.js';
let mainApp;
let currentAxis = 'y';
let dragEnabled = true;
// 初始化应用
async function init() {
mainApp = new MainApp();
// 加载配置
mainApp.loadAConfig({
container: document.getElementById('canvas'),
modelUrlList: []
});
// 初始化场景
await mainApp.Awake();
// 添加可拖拽的模型
await mainApp.appModel.add({
modelId: 'draggableModel',
modelUrl: 'path/to/your/model.glb', // 替换为实际模型路径
drag: {
enable: true,
axis: 'y',
step: 0.1,
}
});
console.log('场景初始化完成');
}
// 切换轴向
window.switchAxis = function(axis) {
if (!mainApp) return;
currentAxis = axis;
mainApp.appModelDrag.switchAxis('draggableModel', axis);
// 更新按钮状态
document.querySelectorAll('.controls button').forEach(btn => {
btn.classList.remove('active');
});
document.getElementById('axis' + axis.toUpperCase()).classList.add('active');
// 更新状态显示
updateStatus();
console.log(`切换到 ${axis.toUpperCase()}`);
};
// 切换拖拽启用状态
window.toggleDrag = function() {
if (!mainApp) return;
dragEnabled = !dragEnabled;
mainApp.appModelDrag.setDragEnabled('draggableModel', dragEnabled);
updateStatus();
console.log(`拖拽${dragEnabled ? '已启用' : '已禁用'}`);
};
// 更新状态显示
function updateStatus() {
const status = document.getElementById('status');
status.textContent = `拖拽${dragEnabled ? '已启用' : '已禁用'} | 激活轴:${currentAxis.toUpperCase()}`;
}
// 键盘快捷键
window.addEventListener('keydown', (e) => {
const key = e.key.toLowerCase();
if (key === 'x' || key === 'y' || key === 'z') {
switchAxis(key);
}
});
// 启动应用
init().catch(console.error);
</script>
</body>
</html>

623
index copy.html Normal file
View File

@ -0,0 +1,623 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>3D Model Showcase SDK - TS</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
overflow: hidden;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
}
#app {
width: 100vw;
height: 100vh;
display: flex;
position: relative;
}
#canvas-container {
flex: 1;
position: relative;
}
#renderDom {
width: 100%;
height: 100%;
display: block;
}
#config-panel {
width: 320px;
background: rgba(30, 30, 45, 0.95);
backdrop-filter: blur(10px);
overflow-y: auto;
padding: 20px;
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.3);
}
#config-panel::-webkit-scrollbar {
width: 6px;
}
#config-panel::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
}
#config-panel::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 3px;
}
.config-title {
color: #fff;
font-size: 18px;
font-weight: bold;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid rgba(255, 255, 255, 0.2);
}
.config-category {
margin-bottom: 15px;
border-radius: 8px;
overflow: hidden;
background: rgba(255, 255, 255, 0.05);
}
.category-header {
padding: 12px 15px;
background: rgba(255, 255, 255, 0.1);
color: #fff;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: background 0.3s;
user-select: none;
}
.category-header:hover {
background: rgba(255, 255, 255, 0.15);
}
.category-header.active {
background: rgba(76, 175, 80, 0.3);
}
.category-title {
font-size: 14px;
font-weight: 600;
}
.category-arrow {
transition: transform 0.3s;
font-size: 12px;
}
.category-arrow.expanded {
transform: rotate(180deg);
}
.category-content {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
transition: grid-template-rows 0.3s ease, padding 0.3s ease;
padding: 0 15px;
}
.category-content.expanded {
grid-template-rows: 1fr;
padding: 15px;
}
.category-content>* {
min-height: 0;
}
.option-item {
margin-bottom: 12px;
}
.option-label {
color: rgba(255, 255, 255, 0.8);
font-size: 13px;
margin-bottom: 8px;
display: block;
}
.option-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.option-btn {
padding: 8px 16px;
border: 1px solid rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.05);
color: #fff;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.3s;
}
.option-btn:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.5);
}
.option-btn.selected {
background: rgba(76, 175, 80, 0.6);
border-color: #4CAF50;
}
.option-checkbox {
display: flex;
align-items: center;
padding: 8px;
border-radius: 4px;
cursor: pointer;
transition: background 0.3s;
}
.option-checkbox:hover {
background: rgba(255, 255, 255, 0.05);
}
.option-checkbox input[type="checkbox"] {
margin-right: 8px;
cursor: pointer;
}
.option-checkbox label {
color: rgba(255, 255, 255, 0.8);
font-size: 12px;
cursor: pointer;
}
/* 进度条样式 */
#progress-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80%;
max-width: 500px;
background: rgba(255, 255, 255, 0.2);
border-radius: 10px;
padding: 10px;
z-index: 1000;
}
#progress-bar {
width: 0%;
height: 10px;
background: linear-gradient(90deg, #4CAF50, #45a049);
border-radius: 5px;
transition: width 0.1s ease;
}
#progress-text {
color: white;
text-align: center;
margin-top: 5px;
font-size: 14px;
}
</style>
</head>
<body>
<div id="app">
<!-- 画布区域 -->
<div id="canvas-container">
<canvas id="renderDom"></canvas>
<div id="progress-container" style="display: none;">
<div id="progress-bar"></div>
<div id="progress-text">0%</div>
</div>
</div>
<!-- 配置面板 -->
<div id="config-panel">
<div class="config-title">选装选配</div>
<!-- 棚子尺寸 -->
<div class="config-category">
<div class="category-header" data-category="size">
<span class="category-title">棚子尺寸</span>
<span class="category-arrow"></span>
</div>
<div class="category-content">
<div class="option-group">
<button class="option-btn" data-option="size-1">3x3米</button>
<button class="option-btn" data-option="size-2">4x4米</button>
<button class="option-btn" data-option="size-3">5x5米</button>
<button class="option-btn" data-option="size-4">6x6米</button>
</div>
</div>
</div>
<!-- 棚子类型 -->
<div class="config-category">
<div class="category-header" data-category="type">
<span class="category-title">棚子类型</span>
<span class="category-arrow"></span>
</div>
<div class="category-content">
<div class="option-group">
<button class="option-btn" data-option="type-1">平顶</button>
<button class="option-btn" data-option="type-2">尖顶</button>
<button class="option-btn" data-option="type-3">弧形</button>
<button class="option-btn" data-option="type-4">异形</button>
</div>
</div>
</div>
<!-- 百叶 (单选) -->
<div class="config-category">
<div class="category-header" data-category="louver">
<span class="category-title">百叶</span>
<span class="category-arrow"></span>
</div>
<div class="category-content">
<div class="option-group">
<button class="option-btn" data-option="louver-1">百叶1</button>
<button class="option-btn" data-option="louver-2">百叶2</button>
<button class="option-btn" data-option="louver-3">百叶3</button>
<button class="option-btn" data-option="louver-4">百叶4</button>
<button class="option-btn" data-option="louver-4">卷帘小</button>
</div>
</div>
</div>
<!-- 配色 -->
<div class="config-category">
<div class="category-header" data-category="color">
<span class="category-title">配色</span>
<span class="category-arrow"></span>
</div>
<div class="category-content">
<div class="option-group">
<button class="option-btn" data-option="color-1">白色</button>
<button class="option-btn" data-option="color-2">灰色</button>
<button class="option-btn" data-option="color-3">黑色</button>
<button class="option-btn" data-option="color-4">木色</button>
</div>
</div>
</div>
</div>
</div>
<!-- 模型信息框用于2D转3D显示 -->
<div id="model-info-box" style="display: none;">
<div style="
background: rgba(255, 255, 255, 0.95);
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
font-size: 14px;
color: #333;
min-width: 200px;
">
<div style="font-weight: bold; margin-bottom: 8px; color: #4CAF50;">模型信息</div>
<div id="info-name" style="margin-bottom: 5px;">名称: -</div>
<div id="info-position" style="margin-bottom: 10px; font-size: 12px; color: #666;">坐标: -</div>
<!-- 颜色按钮 -->
<div id="color-buttons" style="display: none; gap: 8px; margin-bottom: 8px;">
<button id="color-btn-1" style="
flex: 1;
padding: 8px;
background: #FFFFFF;
color: #333;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
">白色</button>
<button id="color-btn-2" style="
flex: 1;
padding: 8px;
background: #000000;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
">黑色</button>
</div>
<!-- 旋转按钮 -->
<div id="rotation-buttons" style="display: none; gap: 8px; margin-bottom: 8px;">
<button id="rotation-btn-90" style="
flex: 1;
padding: 8px;
background: #2196F3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
">旋转90°</button>
<button id="rotation-btn-180" style="
flex: 1;
padding: 8px;
background: #2196F3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
">旋转180°</button>
</div>
<div style="display: flex; gap: 8px;">
<button id="remove-model-btn" style="
flex: 1;
padding: 8px;
background: #f44336;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
">移除</button>
<button id="close-info-btn" style="
flex: 1;
padding: 8px;
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
">关闭</button>
</div>
</div>
</div>
<script type="module" src="./index.js"></script>
<script type="module">
import { kernel } from './src/main.ts';
// ========== UI 交互逻辑 ==========
// 折叠面板切换
document.querySelectorAll('.category-header').forEach(header => {
header.addEventListener('click', function () {
const content = this.nextElementSibling;
const arrow = this.querySelector('.category-arrow');
// 切换展开/收起状态
content.classList.toggle('expanded');
arrow.classList.toggle('expanded');
this.classList.toggle('active');
});
});
// 单选按钮逻辑
document.querySelectorAll('.option-btn').forEach(btn => {
btn.addEventListener('click', async function () {
const optionGroup = this.parentElement;
const category = this.closest('.config-category');
const categoryName = category.querySelector('.category-header').dataset.category;
// 同一组内取消其他选中状态
optionGroup.querySelectorAll('.option-btn').forEach(b => {
b.classList.remove('selected');
});
// 选中当前按钮
this.classList.add('selected');
// 触发自定义事件
const event = new CustomEvent('config:change', {
detail: {
category: categoryName,
value: this.dataset.option,
text: this.textContent
}
});
document.dispatchEvent(event);
console.log('配置变更:', {
category: categoryName,
value: this.dataset.option,
text: this.textContent
});
// 百叶模型替换逻辑
if (categoryName === "louver") {
const currentText = this.textContent;
// 根据 SKU 查询配置和事件
try {
const response = await fetch(`http://localhost:3000/api/product-configs/by-sku/${currentText}`);
const result = await response.json();
if (result.code === 200 && result.data) {
console.log('SKU配置数据:', result.data);
console.log('关联事件:', result.data.events);
// 使用配置数据中的模型路径(如果有)
const modelUrl = result.data.model_id
? `https://sdk.zguiy.com/resurces/model/${result.data.model_id}.glb`
: `https://sdk.zguiy.com/resurces/model/${currentText}.glb`;
console.log('替换百叶模型:', modelUrl);
await kernel.model.replace({
modelId: '卷帘小',
modelUrl: modelUrl,
modelControlType: 'color'
});
console.log(`百叶模型已替换为 ${currentText}`);
} else {
console.warn('未找到SKU配置使用默认模型路径');
const modelUrl = `https://sdk.zguiy.com/resurces/model/${currentText}.glb`;
console.log('替换百叶模型:', modelUrl);
await kernel.model.replace({
modelId: '卷帘小',
modelUrl: modelUrl,
modelControlType: 'color'
});
console.log(`百叶模型已替换为 ${currentText}`);
}
} catch (error) {
console.error(`查询SKU配置或替换模型失败:`, error);
}
}
});
});
// 多选复选框逻辑
document.querySelectorAll('.option-checkbox input[type="checkbox"]').forEach(checkbox => {
checkbox.addEventListener('change', async function () {
const category = this.closest('.config-category');
const categoryName = category.querySelector('.category-header').dataset.category;
const optionGroup = this.closest('.option-group');
const checked = this.checked;
// 获取当前组所有选中的值
const selectedValues = Array.from(
optionGroup.querySelectorAll('input[type="checkbox"]:checked')
).map(cb => ({
value: cb.dataset.option,
text: cb.nextElementSibling.textContent
}));
// 触发自定义事件
const event = new CustomEvent('config:change', {
detail: {
category: categoryName,
values: selectedValues,
checked: this.checked,
currentValue: this.dataset.option
}
});
document.dispatchEvent(event);
console.log('配置变更(多选):', {
category: categoryName,
selectedValues: selectedValues,
checked: this.checked,
currentValue: this.dataset.option
});
});
});
// 监听配置变更事件(供外部使用)
document.addEventListener('config:change', function (e) {
// 这里可以根据配置变更来操作 3D 模型
// 例如:
// if (e.detail.category === 'size') {
// kernel.model.replace({ modelId: 'shed', modelUrl: `/models/shed-${e.detail.value}.glb`, modelControlType: 'rotation' });
// }
// if (e.detail.category === 'color') {
// kernel.material.apply({
// target: 'ShedMaterial',
// attribute: 'baseColor',
// value: getColorValue(e.detail.value)
// });
// }
});
// ========== 模型信息框按钮事件 ==========
// 关闭按钮事件
document.getElementById('close-info-btn').addEventListener('click', () => {
kernel.domTo3D.detach('model-info');
});
// 白色按钮事件
document.getElementById('color-btn-1').addEventListener('click', () => {
const materialName = window.getCurrentMaterialName();
if (materialName) {
console.log('切换为白色,材质名:', materialName);
kernel.material.color(materialName, '#FFFFFF');
} else {
console.log('没有选中材质');
}
});
// 黑色按钮事件
document.getElementById('color-btn-2').addEventListener('click', () => {
const materialName = window.getCurrentMaterialName();
if (materialName) {
console.log('切换为黑色,材质名:', materialName);
kernel.material.color(materialName, '#000000');
} else {
console.log('没有选中材质');
}
});
// 旋转90度按钮事件
document.getElementById('rotation-btn-90').addEventListener('click', () => {
const pickedMesh = window.getCurrentPickedMesh();
if (pickedMesh) {
const modelName = kernel.model.findModelNameByMesh?.(pickedMesh);
if (modelName) {
console.log('旋转90度模型名:', modelName);
kernel.transform.rotation({
modelId: modelName,
vector3: { x: 0, y: 90, z: 0 }
});
} else {
console.log('未找到模型名称');
}
} else {
console.log('没有选中的网格');
}
});
// 旋转180度按钮事件
document.getElementById('rotation-btn-180').addEventListener('click', () => {
const pickedMesh = window.getCurrentPickedMesh();
if (pickedMesh) {
const modelName = kernel.model.findModelNameByMesh?.(pickedMesh);
if (modelName) {
console.log('旋转180度模型名:', modelName);
kernel.transform.rotation({
modelId: modelName,
vector3: { x: 0, y: 30, z: 0 }
});
} else {
console.log('未找到模型名称');
}
} else {
console.log('没有选中的网格');
}
});
// 移除按钮事件
document.getElementById('remove-model-btn').addEventListener('click', () => {
const pickedMesh = window.getCurrentPickedMesh();
if (pickedMesh) {
const meshName = pickedMesh.name;
const success = kernel.model.remove(meshName);
if (success) {
console.log('模型已移除');
// 关闭信息框
kernel.domTo3D.detach('model-info');
} else {
console.log('移除失败:未找到该网格所属的模型');
}
} else {
console.log('没有选中的网格');
}
});
</script>
</body>
</html>

View File

@ -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 @@
<div id="progress-bar"></div>
<div id="progress-text">0%</div>
</div>
<!-- 生成放置区域按钮 -->
<button id="dropzone-btn" style="
position: absolute;
top: 20px;
left: 20px;
padding: 10px 20px;
background: #21c7ff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
z-index: 100;
">生成放置区域</button>
</div>
<!-- 配置面板 -->
<div id="config-panel">
<div class="config-title">选装选配</div>
<!-- 点击信息显示区域 -->
<div id="click-info" class="click-info" style="display: none;">
<div class="click-info-title">点击信息</div>
<div id="click-info-content"></div>
</div>
<!-- 棚子尺寸 -->
<div class="config-category">
<div class="category-header" data-category="size">
@ -273,7 +329,7 @@
</div>
<div class="category-content">
<div class="option-group">
<button class="option-btn" data-option="louver-1">百叶1</button>
<button class="option-btn" data-option="louver-1">111</button>
<button class="option-btn" data-option="louver-2">百叶2</button>
<button class="option-btn" data-option="louver-3">百叶3</button>
<button class="option-btn" data-option="louver-4">百叶4</button>
@ -290,13 +346,16 @@
</div>
<div class="category-content">
<div class="option-group">
<button class="option-btn" data-option="color-1">白色</button>
<button class="option-btn" data-option="color-1">222222</button>
<button class="option-btn" data-option="color-2">灰色</button>
<button class="option-btn" data-option="color-3">黑色</button>
<button class="option-btn" data-option="color-4">木色</button>
</div>
</div>
</div>
<button id="hotspot-btn">生成热点</button>
<button id="prevent-btn">生成防止区域</button>
</div>
</div>
@ -390,6 +449,10 @@
<script type="module" src="./index.js"></script>
<script type="module">
import { kernel } from './src/main.ts';
// ========== UI 交互逻辑 ==========
@ -439,24 +502,178 @@
});
// 百叶模型替换逻辑
if (categoryName === "louver") {
// if (categoryName === "louver") {
const currentText = this.textContent;
const modelUrl = `https://sdk.zguiy.com/resurces/model/${currentText}.glb`;
console.log('替换百叶模型:', modelUrl);
console.log(currentText);
skuToFunc(currentText);
// }
});
});
document.querySelector('#hotspot-btn').addEventListener('click', async function () {
console.log(11111111111111);
await hotspotRequest();
})
const skuToFunc = async (currentText) => {
// 根据 SKU 查询配置和事件
try {
const response = await fetch(`http://localhost:3000/api/product-configs/by-sku/${currentText}`);
const result = await response.json();
if (result.code === 200 && result.data) {
console.log('SKU配置数据:', result.data);
console.log('关联事件:', result.data.events);
// 使用 for...of 循环以支持 await
for (const event of result.data.events) {
if (event.event_type === 'change_model') {
const { file_url, model_control_type, category } = event.target_data;
console.log('替换百叶模型:', event);
await kernel.model.replace({
modelId: '卷帘小',
modelUrl: modelUrl,
modelControlType: 'color'
modelId: category,
modelUrl: file_url,
modelControlType: model_control_type,
drag: {
enable: true,
axis: 'x',
step: 0.1,
},
});
console.log(`百叶模型已替换为 ${currentText}`);
}
if (event.event_type === 'change_color') {
const materialName = event.material_name;
const { color, color_map_url, normal_map_url } = event.target_data;
console.log('替换百叶模型颜色:', event.target_data);
kernel.material.apply({
target: materialName,
albedoColor: color,
albedoTexture: color_map_url,
normalMap: normal_map_url,
});
console.log(`百叶模型颜色已替换为 ${color}`);
}
}
} else {
console.log(`未查询到数据`);
}
} catch (error) {
console.error(`百叶模型替换失败:`, error);
console.error(`查询SKU配置或替换模型失败:`, error);
}
}
const hotspotRequest = async () => {
try {
// 从后端获取激活状态的热点列表
const response = await fetch('http://localhost:3001/api/hotspots?status=active&page=1&pageSize=100');
const result = await response.json();
if (result.code === 200 && result.data.list.length > 0) {
// 将后端数据转换为 SDK 需要的格式
const hotspots = result.data.list.map(item => ({
id: item.id,
type: 'hotspot',
name: item.name,
meshName: item.name, // 可以根据实际情况调整
icon: item.image_url,
position: [item.position_x, item.position_y, item.position_z],
radius: item.radius,
color: "#000000",
payload: {
skus: item.skus || [],
},
}));
// 渲染热点
kernel.hotspot.render(hotspots);
console.log('热点渲染成功:', hotspots);
} else {
console.log('没有可用的热点数据');
}
} catch (error) {
console.error('获取热点数据失败:', error);
}
}
// 监听热点点击事件
window.addEventListener('hotspot:click', (event) => {
console.log('热点被点击:', event.detail);
const { id, name, payload } = event.detail;
const clickInfoDiv = document.getElementById('click-info');
const clickInfoContent = document.getElementById('click-info-content');
let html = `<div class="click-info-item">
<span class="click-info-label">类型:</span>
<span class="click-info-value">热点</span>
</div>
<div class="click-info-item">
<span class="click-info-label">名称:</span>
<span class="click-info-value">${name}</span>
</div>`;
if (payload && payload.skus && payload.skus.length > 0) {
html += `<div class="click-info-item">
<span class="click-info-label">关联SKU:</span>
<span class="click-info-value">${payload.skus.join(', ')}</span>
</div>`;
} else {
html += `<div class="click-info-item">
<span class="click-info-label">关联SKU:</span>
<span class="click-info-value">无</span>
</div>`;
}
clickInfoContent.innerHTML = html;
clickInfoDiv.style.display = 'block';
});
// 监听模型点击事件
window.addEventListener('model:click', (event) => {
console.log('模型被点击:', event.detail);
const { meshName, materialName, modelControlType } = event.detail;
const clickInfoDiv = document.getElementById('click-info');
const clickInfoContent = document.getElementById('click-info-content');
let html = `<div class="click-info-item">
<span class="click-info-label">类型:</span>
<span class="click-info-value">模型</span>
</div>
<div class="click-info-item">
<span class="click-info-label">网格名称:</span>
<span class="click-info-value">${meshName}</span>
</div>`;
if (materialName) {
html += `<div class="click-info-item">
<span class="click-info-label">材质名称:</span>
<span class="click-info-value">${materialName}</span>
</div>`;
}
if (modelControlType) {
html += `<div class="click-info-item">
<span class="click-info-label">控制类型:</span>
<span class="click-info-value">${modelControlType}</span>
</div>`;
}
clickInfoContent.innerHTML = html;
clickInfoDiv.style.display = 'block';
});
// 多选复选框逻辑
document.querySelectorAll('.option-checkbox input[type="checkbox"]').forEach(checkbox => {
checkbox.addEventListener('change', async function () {
@ -521,7 +738,10 @@
const materialName = window.getCurrentMaterialName();
if (materialName) {
console.log('切换为白色,材质名:', materialName);
kernel.material.color(materialName, '#FFFFFF');
kernel.material.apply({
target: materialName,
albedoColor: '#FFFFFF',
});
} else {
console.log('没有选中材质');
}
@ -532,7 +752,10 @@
const materialName = window.getCurrentMaterialName();
if (materialName) {
console.log('切换为黑色,材质名:', materialName);
kernel.material.color(materialName, '#000000');
kernel.material.apply({
target: materialName,
albedoColor: '#000000',
});
} else {
console.log('没有选中材质');
}
@ -593,6 +816,45 @@
console.log('没有选中的网格');
}
});
// 生成放置区域按钮事件
let dropZoneVisible = false;
document.getElementById('dropzone-btn').addEventListener('click', () => {
if (!dropZoneVisible) {
// 生成放置区域(假设有一个名为"框架"的模型)
const modelName = "pergola_80s_3x3"; // 可以改成你实际的模型名称
// 先清除旧的放置区域
kernel.dropZone.clearAll();
// 生成新的放置区域
kernel.dropZone.generate({
modelName: modelName,
divisions: 4,
color: "#21c7ff",
alpha: 0.3,
thickness: 2,
offset: 0, // 改小偏移量,从 5 改为 0.5
scale: 0.9 // 缩小到80%,生成内部放置区域
});
// 显示放置区域
kernel.dropZone.showAll();
dropZoneVisible = true;
// 更新按钮文字
document.getElementById('dropzone-btn').textContent = '隐藏放置区域';
console.log('已生成并显示放置区域');
} else {
// 隐藏放置区域
kernel.dropZone.hideAll();
dropZoneVisible = false;
// 更新按钮文字
document.getElementById('dropzone-btn').textContent = '生成放置区域';
console.log('已隐藏放置区域');
}
});
</script>
</body>

106
index.js
View File

@ -15,7 +15,7 @@ const config = {
gizmo: {
position: true,
rotation: true,
scale: false
scale: true
},
outline: {
enable: true,
@ -27,33 +27,46 @@ const config = {
};
kernel.init(config);
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: "框架",
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"
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;

View File

@ -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);
}

View File

@ -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<string, any[]> = 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 = [];
}
}

View File

@ -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<LoadResult> {
private async addSingle(modelName: string, modelUrl: string, modelControlType?: ModelControlType, drag?: DragConfig): Promise<LoadResult> {
// 检查是否已存在
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
);
}

View File

@ -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<string, ModelDragInfo>;
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();
}
}

View File

@ -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);
}
// 应用反照率纹理(颜色贴图)
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);
}
}

View File

@ -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();
}
}

View File

@ -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 => {

View File

@ -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);
}
};
/** 卷帘门控<E997A8>?*/
@ -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[] => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB