案例展示
模型吸附(点 / 线 / 面)
TIP
绿色为模型 A(目标),橙色为模型 B(主动吸附)。球体为吸附点,亮线为吸附边,半透明面为吸附面。B 主动对齐到 A。
<template>
<div style="position: relative">
<div id="TO"></div>
<el-form id="formSnap" label-width="0px">
<div class="form-title">模型吸附(B → A)</div>
<el-form-item>
<el-button type="primary" size="small" @click="doSnap('point')">点吸附</el-button>
<el-button type="primary" size="small" @click="doSnap('line')">线吸附</el-button>
<el-button type="primary" size="small" @click="doSnap('face')">面吸附</el-button>
</el-form-item>
<el-form-item>
<el-button size="small" @click="resetPose()">复位</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup>
import { onMounted } from 'vue'
import { ElForm, ElFormItem, ElButton } from 'element-plus'
let TO = null
let itemA = null
let itemB = null
let poseB = null
onMounted(async () => {
TO = new ThingOrigin('snapScene', document.getElementById('TO'), {
scene: {
background: { type: 'color', color: { alpha: 1, color: '#1f2430' } }
},
camera: { position: { x: 20, y: 40, z: 70 } },
helper: {
axes: { active: true },
grid: { active: true, size: 200, divisions: 40 }
}
})
// A:目标模型(绿);B:主动吸附模型(橙)
itemA = await makeCube({
name: 'modelA',
pos: { x: -18, y: 5, z: 0 },
rot: { x: 0, y: 0, z: 0 },
color: '#57a359',
ptColor: '#ffffff',
lineColor: '#36cfc9',
faceColor: '#95de64'
})
itemB = await makeCube({
name: 'modelB',
pos: { x: 28, y: 8, z: 12 },
rot: { x: 0.7, y: 1.1, z: 0.4 },
color: '#e6a23c',
ptColor: '#ffd666',
lineColor: '#ffc53d',
faceColor: '#ffe58f'
})
// 记录 B 初始位姿,供复位
poseB = {
pos: itemB.cube.position.clone(),
quat: itemB.cube.quaternion.clone()
}
})
// 立方体 + 吸附点(球)+ 吸附线 + 吸附面,均挂在本地坐标
// initCube 默认 1×1×1,initPlane 尺寸在 base.rect 下,需显式指定与辅助几何一致
async function makeCube(cfg) {
const size = 10
const half = size / 2
const cube = TO.model.initCube({
position: cfg.pos,
rotation: cfg.rot,
base: { width: size, height: size, depth: size },
material: { color: cfg.color }
})
cube.name = cfg.name
const pt = TO.model.initSphere({
position: { x: half, y: half, z: half },
base: { radius: 0.8, widthSegments: 12, heightSegments: 12 },
material: { color: cfg.ptColor }
})
cube.add(pt)
// 两点直线,供 snapLine / getLineCenter 使用
const line = TO.line.initLine({
base: {
p1: { x: -half, y: half, z: half + 0.15 },
p2: { x: half, y: half, z: half + 0.15 }
},
material: { color: cfg.lineColor }
})
cube.add(line)
// segments=1,保证前 3 顶点构成有效三角面;尺寸与立方体正面一致
const face = await TO.model.initPlane({
position: { x: 0, y: 0, z: half + 0.01 },
rotation: { x: 0, y: 0, z: 0 },
base: {
type: 'rect',
rect: {
width: size,
height: size,
widthSegments: 1,
heightSegments: 1
}
},
material: {
type: 'color',
color: { color: cfg.faceColor, opacity: 0.4 },
doubleSide: true
}
})
if (face.material) {
face.material.transparent = true
face.material.opacity = 0.4
}
cube.add(face)
TO.scene.add(cube)
return { cube, pt, line, face }
}
// 面法线:优先 TO.tool.getWorldDirection,旧版 bundle 回退 Three.js 原生 API
function getNormal(face) {
return TO.tool.getWorldDirection(face)
}
// 更新世界矩阵
function updateWorld() {
itemA.cube.updateMatrixWorld(true)
itemB.cube.updateMatrixWorld(true)
}
// B 主动吸附 A;每次先复位便于对比
function doSnap(type) {
if (!TO || !itemA || !itemB) return
resetPose()
updateWorld()
if (type === 'point') {
TO.snap.snapPoint(
{ model: itemA.cube, position: TO.tool.getWorldPosition(itemA.pt) },
{ model: itemB.cube, position: TO.tool.getWorldPosition(itemB.pt) }
)
return
}
if (type === 'line') {
TO.snap.snapLine({ model: itemA.cube, line: itemA.line }, { model: itemB.cube, line: itemB.line })
return
}
TO.snap.snapFace(
{ model: itemA.cube, face: itemA.face, normal: getNormal(itemA.face) },
{ model: itemB.cube, face: itemB.face, normal: getNormal(itemB.face) }
)
}
function resetPose() {
if (!itemB || !poseB) return
itemB.cube.position.copy(poseB.pos)
itemB.cube.quaternion.copy(poseB.quat)
itemB.cube.updateMatrixWorld(true)
}
</script>
<style scoped>
#TO {
width: 100%;
height: 400px;
position: relative;
}
#formSnap {
position: absolute;
top: 10px;
right: 10px;
min-width: 240px;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.3);
z-index: 1000;
background: rgba(0, 0, 0, 0.6);
color: #fff;
padding: 12px 16px;
border-radius: 4px;
font-size: 12px;
}
.form-title {
font-size: 13px;
font-weight: 700;
margin-bottom: 8px;
color: #fff;
}
#formSnap :deep(.el-form-item__label) {
color: #fff;
font-size: 12px;
}
</style>API 介绍
snap.snapPoint
| 方法签名 | 返回值 | 描述 |
|---|---|---|
snapPoint(modelA: snapPointParams, modelB: snapPointParams) | - | 点吸附:将 B 的指定点平移到 A 的指定点 |
参数说明:
| 参数名 | 说明 | 类型 | 必填 | 默认值 |
|---|---|---|---|---|
modelA | 模型 A 及点(目标) | snapPointParams | 是 | - |
modelB | 模型 B 及点(主动吸附) | snapPointParams | 是 | - |
snapPointParams
| 属性 | 说明 | 类型 | 必填 | 默认值 |
|---|---|---|---|---|
model | 待吸附的模型对象 | Object3D | 是 | - |
position | 吸附点世界坐标(需支持 clone()) | Vector3 | 是 | - |
snap.snapLine
| 方法签名 | 返回值 | 描述 |
|---|---|---|
snapLine(modelA: snapLineParams, modelB: snapLineParams) | - | 线吸附:将 B 的线段方向对齐到 A,并平移使线段中心重合 |
参数说明:
| 参数名 | 说明 | 类型 | 必填 | 默认值 |
|---|---|---|---|---|
modelA | 模型 A 及线(目标) | snapLineParams | 是 | - |
modelB | 模型 B 及线(主动吸附) | snapLineParams | 是 | - |
snapLineParams
| 属性 | 说明 | 类型 | 必填 | 默认值 |
|---|---|---|---|---|
model | 待吸附的模型对象 | Object3D | 是 | - |
line | 吸附线(Line,几何需为两点直线) | Line | 是 | - |
snap.snapFace
| 方法签名 | 返回值 | 描述 |
|---|---|---|
snapFace(modelA: snapFaceParams, modelB: snapFaceParams) | - | 面吸附:将 B 的面法线对齐到 A,并平移使面中心重合 |
参数说明:
| 参数名 | 说明 | 类型 | 必填 | 默认值 |
|---|---|---|---|---|
modelA | 模型 A 及面(目标) | snapFaceParams | 是 | - |
modelB | 模型 B 及面(主动吸附) | snapFaceParams | 是 | - |
snapFaceParams
| 属性 | 说明 | 类型 | 必填 | 默认值 |
|---|---|---|---|---|
model | 待吸附的模型对象 | Object3D | 是 | - |
face | 吸附面网格(使用几何前 3 个顶点计算面中心) | Mesh | 是 | - |
normal | 面法线(世界坐标,需支持 clone() / normalize()) | Vector3 | 是 | - |
使用要点
- 三个方法均为 B 主动去吸附 A:只变换
modelB.model的位置 / 旋转。 snapPoint计算positionA - positionB作为偏移,加到 B 的position。吸附点世界坐标可用TO.tool.getWorldPosition(obj)获取。snapLine要求line为两点直线(几何position长度为 6)。方向对齐后按线段中心补偿位移,可用TO.line.getLineCenter(line)获取世界坐标中点。snapFace用normal做法线对齐,面中心可用TO.tool.getFaceWorldCenter(face)(取几何前 3 顶点世界坐标均值)。面法线可用TO.tool.getWorldDirection(face)。- 吸附点 / 线 / 面建议作为模型子对象挂在本地坐标,吸附前调用
updateMatrixWorld(true)。