How is CAD implemented in web with canvas?
详细解释在这个项目中 CAD 部分是如何在 web 实现的,尤其是 canvas 交互,详细解释其架构和逻辑
完美!现在我有足够的信息来提供详细的解释。让我生成一个完整的、结构化的说明。
##详细解释:CAD 在 Web 中的实现 - Canvas 交互、架构和逻辑
这是一个非常全面的 CAD 系统,我将从多个维度为你详细解释其架构和实现。
##一、总体架构概览
┌─────────────────────────────────────────┐
│ React UI Layer (@solidtype/app) │
│ - Editor, Viewer, SketchCanvas, etc. │
└─────────────────────┬───────────────────┘
│
┌─────────────┴─────────────┐
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ Kernel Worker │ │ UI Contexts │
│ (OCCT + Solver) │ │ (State Mgmt) │
└──────────────────┘ └──────────────────┘
│ │
└─────────────┬─────────────┘
│
▼
┌──────────────────────┐
│ @solidtype/core │
│ (CAD Kernel API) │
└──────────────────────┘
│
▼
┌──────────────────────┐
│ OpenCascade.js │
│ (WebAssembly BREP) │
└──────────────────────┘
##二、Canvas 交互的详细架构
###2.1 核心 Viewer 组件结构
文件位置: packages/app/src/editor/components/viewer/
Viewer.tsx (主编排器)
├── useSceneSetup (场景初始化)
│ ├── Three.js Scene 创建
│ ├── WebGLRenderer 配置
│ ├── Camera (透视/正交)
│ ├── Post-processing (SSAO)
│ └── 7个场景组 (meshes, edges, sketch等)
│
├── useViewerControls (相机控制与渲染循环)
│ ├── 轨道 (orbit) - 右键拖拽
│ ├── 平移 (pan) - Shift+左键/右键
│ ├── 缩放 (zoom) - 鼠标滚轮
│ ├── resize处理
│ ├── Animation loop (requestAnimationFrame)
│ └── 后处理渲染 (SSAO/直接渲染)
│
├── use3DSelection (3D面/边选择)
│ ├── Face raycast (从mesh数据)
│ ├── Edge raycast (自定义算法)
│ ├── 点击检测 (click start/end)
│ └── 悬停高亮
│
├── useSketchTools (草图工具管理)
│ ├── 草图模式状态 (tool, tempPoints等)
│ ├── 鼠标事件处理 (move, down, up)
│ ├── 预览形状 (line, circle, arc, rect)
│ ├── 吸附目标 (snap point)
│ ├── 拖拽和框选逻辑
│ └── 自动约束
│
└── 6个Renderers (渲染器)
├── useMeshRenderer - 模型面/边线
├── useSketchRenderer - 草图实体+预览
├── useSelectionRenderer - 选择高亮
├── useConstraintRenderer - 约束标签+尺寸标注
├── usePlaneRenderer - 基准平面
└── useOriginRenderer - 原点坐标系
###2.2 Canvas 交互流程 - 以选择为例
// 鼠标事件处理流程
mouseDown → onClickStart()
├─ 记录点击位置和时间
├─ 更新 isMouseDown flag
mouseMouse → onMouseMove()
├─ 跳过超微小移动
└─ 设置 isDragging = true
mouseUp → onClickEnd()
├─ 检查是否有显著移动 (20px阈值)
├─ 如果无移动且无修饰键:
│ └─ 执行 raycast
│ ├─ 面选择: raycast3D()
│ ├─ 边选择: raycastEdges()
│ └─ 更新SelectionContext
│
├─ 如果有修饰键 (Ctrl/Cmd):
│ └─ 追加到现有选择 (addToSelection=true)
│
└─ 更新UI反馈 (高亮, 属性面板)
###2.3 3D Raycast 实现
来源: packages/app/src/editor/components/viewer/Viewer.tsx (L514-548)
const raycast3D = useCallback(
(clientX: number, clientY: number): RaycastHit | null => {
const camera = cameraRef.current;
const container = containerRef.current;
const meshGroup = groupRefs.meshGroup.current;
if (!camera || !container || !meshGroup) return null;
// 屏幕坐标 → 归一化设备坐标 (NDC)
const rect = container.getBoundingClientRect();
const ndcX = ((clientX - rect.left) / rect.width) * 2 - 1;
const ndcY = -((clientY - rect.top) / rect.height) * 2 + 1;
// 使用 Three.js Raycaster
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(new THREE.Vector2(ndcX, ndcY), camera);
// 与所有mesh进行射线求交
const intersects = raycaster.intersectObjects(meshGroup.children, true);
if (intersects.length === 0) return null;
const hit = intersects[0]; // 最近的交点
const mesh = hit.object as THREE.Mesh;
// 从mesh的userData获取拓扑信息
const bodyId = mesh.userData?.bodyId;
const featureId = mesh.userData?.featureId;
const faceMap = mesh.userData?.faceMap as Uint32Array;
// 转换三角形索引 → B-Rep面索引
const triangleIndex = hit.faceIndex ?? 0;
const faceIndex = faceMap ? faceMap[triangleIndex] : triangleIndex;
return {
bodyId,
faceIndex,
featureId,
point: hit.point,
normal: hit.face?.normal ?? null,
};
},
[cameraRef, containerRef, groupRefs.meshGroup]
);
关键点:
- 使用
faceMap维持三角形→B-Rep面的映射 - 每个面由 kernel 生成独特的索引
- 命名系统跟踪面的演变历史
##三、草图编辑系统
###3.1 草图模式架构
interface SketchModeState {
active: boolean; // 是否在草图编辑模式
sketchId: string | null; // 当前编辑的草图ID
planeId: string | null; // 附加平面ID
planeRole: "xy" | "xz" | "yz" | null; // 平面类型 (基准平面)
activeTool: SketchTool; // 当前工具 (line, arc, circle等)
}
type SketchTool =
| "none"
| "select"
| "point"
| "line"
| "arc" // 3点弧 (start→end→bulge)
| "arcCenterpoint" // 中心点弧
| "arcTangent" // 相切弧
| "circle"
| "circle3Point"
| "rectangle"
| "rectangleCenter"
| "rectangle3Point"
###3.2 屏幕→草图坐标转换
最核心的功能之一 - Viewer.tsx (L243-306)
const screenToSketch = useCallback(
(screenX: number, screenY: number, _planeId: string): { x: number; y: number } | null => {
const camera = cameraRef.current;
const container = containerRef.current;
if (!camera || !container) return null;
const sketchId = sketchMode.sketchId;
const kernelTransform = sketchId ? sketchPlaneTransforms[sketchId] : null;
// 1. 确定平面在世界坐标系中的位置和方向
let planePoint: THREE.Vector3; // 平面原点
let xDir: THREE.Vector3; // X轴方向向量
let yDir: THREE.Vector3; // Y轴方向向量
let planeNormal: THREE.Vector3; // 法向量
if (kernelTransform) {
// 使用kernel计算的精确平面变换
planePoint = new THREE.Vector3(...kernelTransform.origin);
xDir = new THREE.Vector3(...kernelTransform.xDir);
yDir = new THREE.Vector3(...kernelTransform.yDir);
planeNormal = new THREE.Vector3(...kernelTransform.normal);
} else if (sketchMode.planeRole) {
// 回退到标准基准平面
switch (sketchMode.planeRole) {
case "xy":
planePoint = new THREE.Vector3(0, 0, 0);
xDir = new THREE.Vector3(1, 0, 0);
yDir = new THREE.Vector3(0, 1, 0);
planeNormal = new THREE.Vector3(0, 0, 1);
break;
// ... 其他情况
}
} else {
return null;
}
// 2. 屏幕坐标 → NDC
const rect = container.getBoundingClientRect();
const ndcX = ((screenX - rect.left) / rect.width) * 2 - 1;
const ndcY = -((screenY - rect.top) / rect.height) * 2 + 1;
// 3. NDC + Camera + 场景→世界坐标射线
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(new THREE.Vector2(ndcX, ndcY), camera);
// 4. 射线与平面的交点
const plane = new THREE.Plane().setFromNormalAndCoplanarPoint(planeNormal, planePoint);
const intersection = new THREE.Vector3();
const hit = raycaster.ray.intersectPlane(plane, intersection);
if (!hit) return null;
// 5. 世界坐标→草图坐标 (使用向量投影)
const offset = intersection.clone().sub(planePoint);
const sketchX = offset.dot(xDir);
const sketchY = offset.dot(yDir);
return { x: sketchX, y: sketchY };
},
[cameraRef, sketchMode.sketchId, sketchMode.planeRole, sketchPlaneTransforms]
);
数学原理:
世界坐标系中的平面:
任意点P在平面上 ⟺ (P - origin) · normal = 0
射线: R(t) = camera.position + t * direction
求交: (R(t) - planePoint) · planeNormal = 0
⟹ t = (planePoint - camera.position) · planeNormal / (direction · planeNormal)
得到交点P后,投影到平面的局部坐标:
sketchX = (P - planePoint) · xDir
sketchY = (P - planePoint) · yDir
###3.3 草图渲染和预览
useSketchRenderer.ts - 渲染现有的草图实体
export function useSketchRenderer(options: SketchRendererOptions): void {
useEffect(() => {
const sketchGroup = sketchGroupRef.current;
if (!sketchGroup || !sceneReady) return;
// 清空旧的草图几何
while (sketchGroup.children.length > 0) {
const child = sketchGroup.children[0];
sketchGroup.remove(child);
// ... 释放资源
}
// 获取活动草图数据
const activeSketch = getActiveSketch();
if (!activeSketch) return;
// 遍历所有实体 (点、线、圆、弧)
for (const point of activeSketch.points) {
// 从草图坐标 → 世界坐标
const worldPos = toWorld([point.x, point.y], sketchMode.sketchId);
// 创建球体表示点
const pointGeom = new THREE.SphereGeometry(2, 8, 8);
const pointMat = new THREE.MeshBasicMaterial({ color: 0x3b82f6 });
const pointMesh = new THREE.Mesh(pointGeom, pointMat);
pointMesh.position.copy(worldPos);
sketchGroup.add(pointMesh);
}
for (const line of activeSketch.entities.filter(e => e.type === 'line')) {
const start = activeSketch.points.find(p => p.id === line.startId);
const end = activeSketch.points.find(p => p.id === line.endId);
const startPos = toWorld([start.x, start.y], sketchMode.sketchId);
const endPos = toWorld([end.x, end.y], sketchMode.sketchId);
// 创建线段 (使用 LineGeometry + LineMaterial 防止透视扭曲)
const lineGeom = new LineGeometry();
lineGeom.setPositions([
startPos.x, startPos.y, startPos.z,
endPos.x, endPos.y, endPos.z,
]);
const lineMat = new LineMaterial({
color: 0x000000,
linewidth: 2.0,
resolution: new THREE.Vector2(width, height),
});
const line3d = new Line2(lineGeom, lineMat);
sketchGroup.add(line3d);
}
// 类似地渲染圆和弧...
}, [/* 依赖项 */]);
}
###3.4 草图工具处理 - useSketchTools
核心的鼠标事件处理 - useSketchTools.ts (最复杂的hook)
当用户在草图模式下移动鼠标时:
const onMouseMove = (e: MouseEvent) => {
// 1. 屏幕→草图坐标转换
const sketchPos = screenToSketch(e.clientX, e.clientY, sketchMode.planeId);
setSketchPos(sketchPos);
setSketchMousePos(sketchPos);
// 2. 检查吸附 (snap to existing points)
const nearby = findNearbyPoint(sketchPos.x, sketchPos.y, SNAP_TOLERANCE);
// 3. 根据当前工具生成预览
if (sketchMode.activeTool === 'line') {
if (tempStartPoint) {
// 从tempStartPoint到当前位置的线预览
setPreviewShapes({
line: {
start: tempStartPoint,
end: nearby || sketchPos // 吸附后的位置
}
});
}
} else if (sketchMode.activeTool === 'circle') {
if (circleCenterPoint) {
const radius = Math.hypot(
sketchPos.x - circleCenterPoint.x,
sketchPos.y - circleCenterPoint.y
);
setPreviewShapes({
circle: { center: circleCenterPoint, radius }
});
}
} else if (sketchMode.activeTool === 'arc') {
// 3点弧的复杂预览逻辑
// 根据已有点数计算圆心和半径
if (arcStartPoint && arcEndPoint && !arcCenterPoint) {
// 从start和end推断center...
const center = calculateCircumcircleCenter(arcStartPoint, arcEndPoint, sketchPos);
setPreviewArc({
start: arcStartPoint,
end: arcEndPoint,
bulge: center
});
}
}
};
const onMouseDown = (e: MouseEvent) => {
if (sketchMode.activeTool === 'line') {
const nearby = findNearbyPoint(sketchPos.x, sketchPos.y, SNAP_TOLERANCE);
if (!tempStartPoint) {
// 第一次点击: 设置起点
const pointId = addPoint(nearby?.x ?? sketchPos.x, nearby?.y ?? sketchPos.y);
setTempStartPoint({ x: nearby?.x ?? sketchPos.x, y: nearby?.y ?? sketchPos.y, id: pointId });
} else {
// 第二次点击: 创建线并继续链接
const endPointId = addPoint(nearby?.x ?? sketchPos.x, nearby?.y ?? sketchPos.y);
addLine(tempStartPoint.id, endPointId);
// 链接模式: 新的起点是刚才的终点
setTempStartPoint({ x: nearby?.x ?? sketchPos.x, y: nearby?.y ?? sketchPos.y, id: endPointId });
// 可选: 应用自动约束
if (autoConstraints) {
applyAutoConstraints(endPointId);
}
}
}
};
const onMouseUp = (e: MouseEvent) => {
if (e.button === 2 || e.key === 'Escape') {
// 右键或ESC: 取消当前工具
setTempStartPoint(null);
setTempSecondPoint(null);
setPreviewShapes({ line: null, circle: null, arc: null });
}
};
###3.5 自动约束系统
当用户绘制时,系统自动检测:
function applyAutoConstraints(pointId: string, fromPointId?: string) {
const point = getPoint(pointId);
// 水平约束检测
if (isNearHorizontal(point.y, nearby.y)) {
addConstraint({
type: 'horizontal',
points: [pointId, nearbyPointId]
});
}
// 竖直约束检测
if (isNearVertical(point.x, nearby.x)) {
addConstraint({
type: 'vertical',
points: [pointId, nearbyPointId]
});
}
// 重合约束检测
if (distance < COINCIDENT_TOLERANCE) {
addConstraint({
type: 'coincident',
points: [pointId, nearbyPointId]
});
}
}
##四、Kernel 和数据流
###4.1 CAD Kernel 工作流
文档 (Yjs)
├─ Features (sketch, extrude, boolean等)
└─ Sketch Data (points, lines, constraints)
│
▼
KernelEngine (Node或Worker)
│
├─ 遍历Feature树
├─ 编译Sketch → Wire (2D → 3D)
├─ 执行建模操作 (extrude, revolve等)
├─ 执行布尔运算
├─ 更新拓扑名称
│
▼
RebuildResult {
bodies: BodyInfo[] // 模型体ID和颜色
meshes: Map<bodyId, Mesh> // 三角化数据
referenceIndex: {} // 面/边 → 源实体映射
featureStatus: {} // 每个feature的错误状态
sketchSolveResults: {} // 草图求解结果
}
│
▼
传回 UI (postMessage 或同步)
│
├─ 更新 KernelContext
├─ 重新渲染所有meshes
├─ 更新选择的面可能不再有效
└─ 刷新UI
###4.2 Kernel 的 Mesh 数据结构
interface MeshData {
positions: Float32Array; // [x,y,z,x,y,z,...] 顶点坐标
normals: Float32Array; // [nx,ny,nz,...] 顶点法向量
indices: Uint32Array; // [i0,i1,i2,...] 三角形顶点索引
// 关键: 拓扑映射
faceMap?: Uint32Array; // triangleIndex → B-Rep face index
edgeMap?: Uint32Array; // edgeIndex → B-Rep edge index
// 可选: 边界线(用于CAD风格的线框)
edges?: Float32Array; // [x,y,z,x,y,z,...] 边的顶点
edgeMap?: Uint32Array; // edge segment → source index
}
###4.3 更新流程中的一致性
当用户编辑sketch时:
// 1. UI更新sketch数据 (Yjs事务)
doc.ydoc.transact(() => {
setSketchData(sketchEl, updatedData);
});
// 2. Yjs 触发 'update' 事件
// 3. KernelEngine 监听并重建
const result = await kernelEngine.rebuild(doc);
// 4. UI 应用结果
setMeshes(result.meshes); // Three.js会重新渲染
setFeatureStatus(result.featureStatus);
setReferenceIndex(result.referenceIndex);
// 5. 如果当前选择的面不再存在,清除选择
if (!result.referenceIndex.bodyIdToFaces[selectedFace.bodyId]) {
clearSelection();
}
##五、选择系统与面跟踪
###5.1 选择数据结构
interface FaceSelection {
bodyId: string; // kernel返回的体ID
faceIndex: number; // B-Rep中的面索引
featureId: string; // 产生该面的feature ID
}
interface EdgeSelection {
bodyId: string;
edgeIndex: number;
featureId: string;
}
###5.2 面索引持久化
关键创新 - faceMap 维护三角化→拓扑的映射:
// Kernel 的 tessellation 过程:
const tessellateBody = (shape: OCCT_Shape) => {
const triangles = [];
const faceMap = []; // triangleIndex → OCCT face index
shape.forEachFace((occtFace, faceIndex) => {
const faceTriangles = tessellateOCCTFace(occtFace);
for (const tri of faceTriangles) {
triangles.push(...tri);
faceMap.push(faceIndex); // 这个三角形来自哪个B-Rep面
}
});
return { triangles, faceMap };
};
// UI 中使用:
const raycast3D = (clientX, clientY) => {
const hit = raycaster.intersectObjects(meshGroup);
const mesh = hit.object;
const triangleIndex = hit.faceIndex;
// 从三角形索引 → B-Rep面索引
const faceIndex = mesh.userData.faceMap[triangleIndex];
return { bodyId, faceIndex, ... };
};
##六、渲染架构
###6.1 场景组织 (7个Three.js Groups)
Scene
├─ meshGroup // 模型surface (MeshStandardMaterial)
├─ edgeGroup // B-Rep边线 (LineSegments2 + LineMaterial)
├─ sketchGroup // 草图实体 (Line2 + 点球)
├─ selectionGroup // 选择高亮线框
├─ faceHighlightGroup // 悬停时的面高亮 (LineSegments2)
├─ constraintLabelsGroup // 约束标签 (CSS2DObject)
├─ planesGroup // 基准平面 (PlaneGeometry)
└─ originGroup // 坐标轴 (3条线)
###6.2 渲染循环 - Animation Loop
const animate = () => {
animationFrameRef.current = requestAnimationFrame(animate);
if (composerRef.current && aoEnabledRef.current) {
// 后处理路径: SSAO + 色调映射
composerRef.current.render();
} else {
// 直接渲染
renderer.render(scene, camera);
}
// 标签渲染器 (CSS2D投影到3D位置)
labelRenderer.render(scene, camera);
};
###6.3 SSAO 后处理
// useSceneSetup中配置
const composer = new EffectComposer(renderer);
const renderPass = new RenderPass(scene, camera);
const normalPass = new NormalPass(scene, camera);
const ssaoEffect = new SSAOEffect(camera, normalPass.renderTarget.texture, {
blueNoise: true,
radius: 10,
intensity: 1.5,
bias: 0.025,
});
composer.addPass(renderPass);
composer.addPass(new EffectPass(camera, ssaoEffect));
效果: 在凹陷处添加细微阴影,增强深度感
##七、多用户交互
###7.1 Awareness 系统 (Yjs)
// 广播相机状态用于跟随
awareness.updateViewerState({
cameraPosition: [x, y, z],
cameraTarget: [tx, ty, tz],
cameraUp: [ux, uy, uz],
zoom: distance,
});
// 广播3D光标
awareness.update3DCursorPosition({
position: [x, y, z],
normal: [nx, ny, nz],
});
// 广播2D草图光标
awareness.update2DCursorPosition({
sketchId,
x, y,
visible: true,
});
###7.2 光标渲染
// UserCursors3D.tsx - 渲染其他用户在3D中的光标
const hitPos = otherUserCursor.position; // 从awareness获取
const sphere = new THREE.Sphere geometry(2);
const cursor3D = new THREE.Mesh(sphere, material);
cursor3D.position.fromArray(hitPos);
scene.add(cursor3D);
// UserCursor2D.tsx - 草图中的光标
// 转换: 草图坐标 → 屏幕坐标
const sketchPos = otherUserCursor.sketchPos;
const screenPos = sketchToScreen(sketchPos); // 调用Viewer的方法
// 在HTML上绝对定位显示光标
##八、配置和优化选项
###8.1 ViewerContext 状态
interface ViewerState {
displayMode: "wireframe" | "shaded";
projectionMode: "perspective" | "orthographic";
currentView: ViewPreset | null;
// 草图选项
snapToGrid: boolean;
gridSize: number; // 毫米
autoConstraints: boolean;
// 渲染选项
ambientOcclusion: boolean; // SSAO开关
showEdges: boolean; // B-Rep边线
}
###8.2 网格吸附
const snapToGrid = (x: number, y: number) => {
if (!viewerState.snapToGrid) return { x, y };
const size = viewerState.gridSize;
return {
x: Math.round(x / size) * size,
y: Math.round(y / size) * size,
};
};
##九、边界情况和坑点
###9.1 坐标系转换
问题: Three.js使用Z-up, 部分CAD系统使用Y-up
解决方案:
- Kernel 明确定义坐标系 (通常Y-up)
- 平面变换显式指定 origin, xDir, yDir, normal
- 永远不假设方向,总是查询planeTransform
###9.2 高DPI屏幕
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
// 限制到2x以避免移动设备过高像素比导致的性能问题
###9.3 浮点精度
所有公差都在 num/tolerance.ts 中集中定义:
const POINT_MERGE_TOLERANCE_MM = 0.01;
const SNAP_TOLERANCE_MM = 5;
const COINCIDENT_TOLERANCE = 0.001;
###9.4 内存泄漏防护
// 每个Renderer中都有清理逻辑
useEffect(() => {
return () => {
// 组件卸载时
if (child.geometry) child.geometry.dispose();
if (child.material) child.material.dispose();
};
}, []);
##十、总结:关键设计原则
| 原则 | 实现 |
|---|---|
| 分离关注点 | Viewer编排 + Hooks处理逻辑 + Renderers渲染 |
| 实时反馈 | 鼠标移动→预览形状、吸附、高亮 |
| 位置独立性 | 通过平面变换处理任意平面上的草图 |
| 协作优先 | Yjs + Awareness 支持实时多用户 |
| CAD准确性 | B-Rep持久化 + faceMap + 拓扑名称跟踪 |
| 性能优化 | 后处理 + 选择性重渲染 + 资源清理 |
这个系统非常成熟,充分利用了现代Web平台和TypeScript的优势,同时保持了CAD应用的严谨性和精度。
END OF POST