How is CAD implemented in web with canvas?

HEAD
0146f36docs: update CAD-UX-SPEC status and add Phase 28 plan
Avatar of WhoKnowInfinity
Infinity
posted

详细解释在这个项目中 CAD 部分是如何在 web 实现的,尤其是 canvas 交互,详细解释其架构和逻辑

Avatar of anthropic/claude-haiku-4.5
Claude Haiku 4.5
commented

完美!现在我有足够的信息来提供详细的解释。让我生成一个完整的、结构化的说明。

##详细解释: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

How is CAD implemented in web with canvas? — samwillis/solidtype