问题描述

extrude中文译作挤压,sweep译作扫掠。前者为将一个shape沿着向量延申,生成一个3D图形。后者为为对一个shape沿着路径延申,生成一个3D图形。
e24d542c7f1b576a3322e4940012e8ea_.jpg
extrude
ff44bee4f8d36eaf70c9da93d4331c47_.jpg
sweep

代码背景

由于是前端开发,我们选择的是前端3D开发代码库three.js,该代码库提供了很多3D开发接口,后面的代码实现介绍中会有体现。
不难发现,在three.js中的geometry中本身就有ExtrudeGeometry这个类,但是这个已经给出的类没有办法实现我们的需求,缺陷有以下几个:

  • 它的输入为shape和一个配置项,首先在three.js中,shape是一个2D图形,它可以有很多种生成方式,但是必须都是2D的点。我们给定的输入是一组三维空间上的坐标数组,无法生成一个所谓的shape。

以下是我们的一组示例给定输入:

  1. "shape": {
  2. "type": "Polygon",
  3. "coordinates": [
  4. [
  5. [0.004999999999999987, 8.971000000000002, 0.33],
  6. [0.012029437251522845, 8.98797056274848, 0.33],
  7. [0.028999999999999988, 8.995000000000001, 0.33],
  8. [0.13, 8.995000000000001, 0.33],
  9. [0.231, 8.995000000000001, 0.33],
  10. [0.2479705627484772, 8.98797056274848, 0.33],
  11. [0.255, 8.971000000000002, 0.33],
  12. [0.2549999999999999, 8.82, 0.33],
  13. [0.25499999999999984, 8.669, 0.33],
  14. [0.24797056274847695, 8.652029437251523, 0.33],
  15. [0.23099999999999982, 8.645000000000001, 0.33],
  16. [0.1299999999999998, 8.645000000000001, 0.33],
  17. [0.028999999999999866, 8.645000000000001, 0.33],
  18. [0.012029437251522876, 8.652029437251523, 0.33],
  19. [0.004999999999999859, 8.669, 0.33],
  20. [0.004999999999999923, 8.82, 0.33],
  21. [0.004999999999999987, 8.971000000000002, 0.33]
  22. ]
  23. ]
  24. },
  25. "path": {
  26. "type": "LineString",
  27. "coordinates": [
  28. [0, 0, 0],
  29. [0, 0, 2.715]
  30. ]
  31. }
  32. }
  • 它生成的3D图形的shape切面必须和给定的路径的切向量空间垂直
    1. // 给定的shape
    2. const shape = new THREE.Shape([
    3. new THREE.Vector2(0, 0),
    4. new THREE.Vector2(1, 0),
    5. new THREE.Vector2(1, 1),
    6. new THREE.Vector2(0, 1),
    7. ]);
    8. // 给定的向量
    9. const line = new THREE.LineCurve3(
    10. new THREE.Vector3(10, 0, 0),
    11. new THREE.Vector3(10, 10, 10)
    12. );
    image.png
    原先 (右边为挪移后的向量,用来进行路径对比,后同)
    image.png
    改进

可以观察出原先的实现中给定的shape会通过翻转使得其初始位置和给定的向量垂直,而不是我们想要的shape沿向量扩展成的3D图形。

  • sweep的转向
  • sweep的转角

image.png
原先
image.png
改进

转向,即需要计算方向转换后的shape方向的旋转。比如上图后转角后shape的方向也需要变化。
sweep的转角的处理,要生成的如框体这类geometry是需要这样的sweep操作,因此对于转角需要做相应的处理。

至此,要实现我们需要的extrude和sweep操作,不考虑代码优化,从算法层面,这三个问题必须要解决,后面我们将会以这些问题作为主要线索来说明算法实现。

具体实现

shape转化

  1. "shape": {
  2. "type": "Polygon",
  3. "coordinates": [
  4. [
  5. [0.004999999999999987, 8.971000000000002, 0.33],
  6. [0.012029437251522845, 8.98797056274848, 0.33],
  7. [0.028999999999999988, 8.995000000000001, 0.33],
  8. [0.13, 8.995000000000001, 0.33],
  9. [0.231, 8.995000000000001, 0.33],
  10. [0.2479705627484772, 8.98797056274848, 0.33],
  11. [0.255, 8.971000000000002, 0.33],
  12. [0.2549999999999999, 8.82, 0.33],
  13. [0.25499999999999984, 8.669, 0.33],
  14. [0.24797056274847695, 8.652029437251523, 0.33],
  15. [0.23099999999999982, 8.645000000000001, 0.33],
  16. [0.1299999999999998, 8.645000000000001, 0.33],
  17. [0.028999999999999866, 8.645000000000001, 0.33],
  18. [0.012029437251522876, 8.652029437251523, 0.33],
  19. [0.004999999999999859, 8.669, 0.33],
  20. [0.004999999999999923, 8.82, 0.33],
  21. [0.004999999999999987, 8.971000000000002, 0.33]
  22. ]
  23. ]
  24. },
  25. "path": {
  26. "type": "LineString",
  27. "coordinates": [
  28. [0, 0, 0],
  29. [0, 0, 2.715]
  30. ]
  31. }
  32. }

正如前述的问题,我们拿到了shape三维点数组,无论是从简化代码还是从复用原先代码的角度上,将这个三维数组转化成一组二维点数组都是非常有道理的,而之后也可以看到这样简化之后代码在实现上很多地方都会变方便。
在说明代码之前,首先我们需要规范下给定输入的类型,即:

  1. export type shapeArrType = {
  2. // 外轮廓(唯一)
  3. outerArr: Array<[number, number, number]>;
  4. // 内轮廓(空洞 数组)
  5. innerArr: Array<Array<[number, number, number]>>;
  6. };
  7. // 路径
  8. pathArr: [number, number, number][];

接着我们需要分析一下该操作的可行性。通过调研得知,给定的shape虽然是一组三维点坐标,但是它们是确定在一个平面上的,基于这一原因,将一个三维坐标阵转化为二维坐标阵是可行的,实际上我们要做两个操作。第一个操作是将shape所在的平面进行rotation操作,使其和xOy平面平行;第二个操作是将平行的平面在z轴方向做translation操作,使其成为xOy平面。此时,我们取所有平面上的点(转化后的)x、y坐标作为我们转化后shape的二维坐标。
这时,当给定的shape被rotation和translation,我们给定的向量也应当受到同样的rotation作用,但是由于是向量,只应该受到rotation作用而不会受到translation作用。
同时,思考一点,即向量的初始位置。由于是向量,从数学角度来说,并没有初始位置这一概念,只有方向和大小两个指标。但是在数据的表现中,向量是以路径的形式表现的,因此我们需要对向量做一个translation使其初始坐标变为(0, 0, 0),否则在向量的作用计算中会产生很大rotation偏移量。这里有点抽象,所以举一个例子。
60d9fa0d886400cc4ddd92bc83015af.jpg
只考虑yOz平面,给定向量translationv(0,1,1)和点 A(0,1,0),然后设定rotation为顺时针90。。如果先作用translation再作用translation会得B、(0,1,-2),但是我们需要的是先做rotation再做translation,得到
B⊥(0,2,-1),这样的差异会在实际的使用被更大的放大。其实从矩阵的角度会更好理解,因为这样的作用实际上都是矩阵作用,而矩阵作用是不满足交换律的,因此我们需要提前将向量放在(0, 0, 0)上,避免出现旋转的偏差。
同时这个向量translation实际上从空间角度是作用在整个geometry上的,所以实际上对所有给定的点坐标都会有这样的一个translation作用。
综上,我们会有三个变换,分别是向量起始点的translation变化,shape通过变换回到xOy平面的变换,向量根据shape的变化进行的相应的旋转变换,这三个变换在代码中以Matrix4类型刻画,对的变量为:

  1. // 根据向量起始点进行的所有点的变换
  2. const mVec = new Matrix4();
  3. // 创建基的转化矩阵(只包含旋转) 目的是让法向量为(0,0,1)可以转换为2D平面
  4. // 对于向量 只用旋转 不能位移
  5. // 前者用于变化向量 后者用于变化点
  6. const mRot = new Matrix4();
  7. const mGen = new Matrix4();

正如上面所说,对于向量起始点的geometry的translation变换为初始步骤,对应实现为局部函数paramSet

  1. const paramSet = () => {
  2. if (!options.pathArr?.length || options.pathArr.length === 0) {
  3. throw new Error('无路径点');
  4. }
  5. const startPoint = new Vector3(options.pathArr[0][0], options.pathArr[0][1], options.pathArr[0][2]);
  6. mVec.setPosition(startPoint).invert();
  7. const numberArrTransform = (arr: [number, number, number]): [number, number, number] => {
  8. return [arr[0] - startPoint.x, arr[1] - startPoint.y, arr[2] - startPoint.z];
  9. };
  10. shapeArr.outerArr = shapeArr.outerArr.map(numberArrTransform);
  11. shapeArr.innerArr.forEach((holeArr, index) => {
  12. shapeArr.innerArr[index] = holeArr.map(numberArrTransform);
  13. });
  14. options.pathArr = options.pathArr.map(numberArrTransform);
  15. };

下一步,计算shape的旋转矩阵,

  1. const getTransformMatrix = () => {
  2. // 找到平面上的三个点 A, B, C
  3. const vertA = outerArr[0];
  4. const vertB = outerArr[1];
  5. let cIndex = 2;
  6. let vertC!: [number, number, number];
  7. let vecAB!: Vector3;
  8. let vecAC!: Vector3;
  9. // 获取不在一条支线上的点C
  10. const getVectors = () => {
  11. vertC = outerArr[cIndex];
  12. // 找到向量 AB AC
  13. vecAB = new Vector3(vertB[0] - vertA[0], vertB[1] - vertA[1], vertB[2] - vertA[2]);
  14. vecAC = new Vector3(vertC[0] - vertA[0], vertC[1] - vertA[1], vertC[2] - vertA[2]);
  15. const vectorCross = new Vector3();
  16. vectorCross.crossVectors(vecAB, vecAC);
  17. // 此时选择了三个在一条直线上的点
  18. if (isEqual(vectorCross.lengthSq(), 0)) {
  19. cIndex++;
  20. if (cIndex >= outerArr.length) {
  21. throw new Error('所有点都是平行点');
  22. }
  23. getVectors();
  24. }
  25. };
  26. getVectors();
  27. // 计算平面法向量
  28. const normal = new Vector3();
  29. normal.crossVectors(vecAB, vecAC);
  30. // 法向量作为新坐标系的基向量z方向
  31. const zb = normal.normalize();
  32. // AB作为基的x方向
  33. const xb = vecAB.normalize();
  34. // 根据两个基算出第三个基
  35. const yb = new Vector3();
  36. yb.crossVectors(zb, xb);
  37. yb.normalize();
  38. // 创建基的转化矩阵(只包含旋转) 目的是让法向量为(0,0,1)可以转换为2D平面
  39. // 对于向量 只用旋转 不能位移
  40. const mRot = new Matrix4();
  41. mRot.makeBasis(xb, yb, zb);
  42. mRot.invert();
  43. // 测试用 实际只用vA
  44. const vA = new Vector3(vertA[0], vertA[1], vertA[2]);
  45. const vB = new Vector3(vertB[0], vertB[1], vertB[2]);
  46. const vC = new Vector3(vertC[0], vertC[1], vertC[2]);
  47. vA.applyMatrix4(mRot);
  48. vB.applyMatrix4(mRot);
  49. vC.applyMatrix4(mRot);
  50. // 生成位移矩阵 目的是将平面的zIndex变为0
  51. const zConstant = vA.z;
  52. const mTrans = new Matrix4();
  53. mTrans.makeTranslation(0, 0, -zConstant);
  54. const mGen = mTrans.clone();
  55. mGen.multiply(mRot);
  56. // 前者用于变化向量 后者用于变化点
  57. return {
  58. mRot: mRot,
  59. mGen: mGen
  60. };
  61. };

上面的函数实际上在做这样的事情,首先需要进行平面的刻画,根据立体几何知识,刻画一个3D平面需要找到该平面上不共线的三个点,12行的getVectors做的就是这样一件事。在得到这样的三个点后,首先计算shape的rotation对应的Matrix4。
要计算rotation的矩阵,我们可以通过计算给定平面和xOy平面在x、y、z轴上的旋转角度,但是这样做很麻烦。我们不妨转换下思维,即如果把xOy平面变成给定shape的所在平面,我们只需要将坐标系进行变基操作即可,本质上这和求出旋转角度进而计算是相同的。而计算我们给定shape平面的基向量是个很简单的事情,所以就很方便。
ce2ba712717ad4226371f6756b892bf.jpg
现在说明下如何根据三个点去求平面的基向量,32-47行代码,我们取三个点分别为A, B, C,然后求法向量,即ABxAC,该方向即平面法向量方向,单位化该向量即得到基向量的z向量。取向量AB的单位化向量作为x方向基向量,可以用z方向基向量和x方向基量的叉积可以得到y方向的基向量。
用得到基向量填充矩阵,即可以得到从默认基向量到shape对应的基向量的Matrix4,即xOy平面到shape平面的rotation矩阵,取该矩阵的反得到将shape平面旋转为和xOy平面平行的rotation矩阵。
将rotation矩阵作用域点A,得到的新的点的z方向坐标即translation矩阵的偏移量。简单处理后,可以返回两个Matrix4。

  1. // 转化所有点为二维向量
  2. const transformInput = () => {
  3. // 3D坐标转化为2D坐标
  4. const transformPoint3DTo2D = (vertex: [number, number, number]) => {
  5. const v = new Vector3(vertex[0], vertex[1], vertex[2]);
  6. v.applyMatrix4(mGen);
  7. if (!isEqual(v.z, 0)) {
  8. throw 'not plate';
  9. }
  10. // 转化为2D坐标
  11. const ret = new Vector2(v.x, v.y);
  12. return ret;
  13. };
  14. const outerArr2Ds = outerArr.map(transformPoint3DTo2D);
  15. const innerHoleArr = innerArr.map(holes => {
  16. // 每一个holes中的点进行转化
  17. return holes.map(transformPoint3DTo2D);
  18. });
  19. return {
  20. outerArr2Ds,
  21. innerHoleArr
  22. };
  23. };

计算出mGen后,将所有点做变换,得到的点就是shape在xOy平面上的对应点(z值都是0),取其x, y值,可以得到对应的二维方向坐标。
到这一步结束后,我们将原来的问题转化为一个2Dshape在空间上的相应的extrude和sweep。这样得到的geometry需要在生成结束后进行逆变换,即:

  1. // 还原
  2. mGen.invert();
  3. mVec.invert();
  4. this.applyMatrix4(mGen);
  5. this.applyMatrix4(mVec);

此时,在空间上得到的geometry即给定参数对应的geometry。

路径分点

在阐述接下来的代码实现前,先说明下具体的思路,
image.png
观察图中的由xOy平面上的shape通过extrude操作得到的geometry,它是由多个基础面连接而成的,因此要实现extrude操作的第一步是绘制这些平面,或者说计算这些平面的点的位置。可以看到图中的蓝框位置,即给定平面在路径上平移后的点的位置,要确定这些点的位置需要两个参数,第一是shape中绘图点的位置,如图中是正方形的4个点,第二是路径上点的位置。比如实例中,我们选择了直线上的三个点(设置参数step=3),可以看到对应的图形出现在向量的对应的三等分点位置。实际计算中,路径可能是曲线,或者有多段路径,因此路径中取点的计算是接下来的重要环节。

  1. // 输入参数 steps 标识拉伸线条中细分线段的点数 默认为1
  2. const getSpacedPointsExtend = (path: CurvePath<Vector3>, divisions = 40) => {
  3. // 总长度
  4. const allLength = path.getLength();
  5. // 有几条曲线 用于生成端点
  6. const curveLengths = path.getCurveLengths();
  7. const pointRate: number[] = [];
  8. const cornerRateList: number[] = [];
  9. if ((path as any).autoClose) {
  10. isClosed = true;
  11. }
  12. curveLengths.forEach((curveLength, index) => {
  13. // 最后一条曲线(存储一点作为最后的连接点)
  14. if (index === curveLengths.length - 1) {
  15. return;
  16. }
  17. // 计算当前分curve的末端位置
  18. const rate = curveLength / allLength;
  19. // 前者对应前曲线末端 后者对应后曲线开端
  20. pointRate.push(rate - 1 / (100 * divisions));
  21. pointRate.push(rate);
  22. pointRate.push(rate + 1 / (100 * divisions));
  23. // 用于查找rate的index列表
  24. cornerRateList.push(rate);
  25. });
  26. // 多余的图形 分别为拐角的前后两个面 以及拐角的对应面
  27. const newDivision = divisions - pointRate.length - (isClosed ? 2 : 0);
  28. if (newDivision <= 0) {
  29. throw '当前参数的step太小,不足以生成';
  30. }
  31. for (let i = 0; i <= newDivision; i++) {
  32. pointRate.push(i / newDivision);
  33. }
  34. // 将所有点进行排序
  35. pointRate.sort((a, b) => a - b);
  36. // 找到所有corner点对应的数值
  37. cornerIndexList = cornerRateList.map(rate => {
  38. return pointRate.findIndex(pr => pr === rate);
  39. });
  40. if (isClosed) {
  41. pointRate.push(1);
  42. pointRate.push(0.01);
  43. cornerIndexList.push(pointRate.length - 2);
  44. }
  45. rateList = pointRate;
  46. // 获取对应点
  47. const points: Vector3[] = pointRate.map(rate => path.getPoint(rate));
  48. return points;
  49. };

此处这里用到three.js中Path类的getPoint方法,该方法输入[0, 1]的一个数字(曲线上的对应点在整条曲线上的位置)返回曲线在该位置上的点的向量。
我们在实现中需要特殊处理一下获取点的rate,因为需要考虑sweep在线与线的转角间的绘制,如果转角的点附近没有shape绘制,最后的图形就会很失真,因此取点时需要取到前一条曲线的最后一个点和下一条曲线的开始点,参考13-26行。
特别的,如果要求绘制的geometry是封闭的,代码中需要为geometry设置封闭点,即需要将最后的点取到开始点,即43-47行做的事情。

  1. // 确定路径上的分割点的位置 但是拐弯的地方和预想并不相同
  2. extrudePts = getSpacedPointsExtend(extrudePath as any, steps);
  3. // 路径向量点 只反转方向 没有位移
  4. extrudePts.forEach(vertex => vertex.applyMatrix4(mRot));

获取路径分点后,需要进行rotation变换,原因见前面叙述。注意,第4行代码,我们只是把路径中的点对应位置算出来了,但是路径是没有经过mRot作用的(没有提供该方法,所以后面考虑路径相关问题时,仍需要考虑mRot的作用)。

Frenet坐标系计算

在说明前,先介绍下Frenet标架,大家可以参考下文档或知乎上的简介。简而言之可以理解为Frenet标架是一个可以描述曲线运动的一种方式。它和直角坐标系描述曲线上点的坐标轨迹不同,它是通过去研究曲线的弧长(这是一个绝对对于曲线有意义的标量),进而研究曲线的运动方向的一种描述方式。
image.png
它的维度体现在曲线运动的切线方向T,法线方向N和副法线方向B这三个向量。考虑二维圆周运动,不难发现在每个运动位置点都有切向变化和法向变化(向心),扩展到三维空间,增加了一个副法向,描述在上下方向上的变化。具体有兴趣可以去看文档研究下,这里就说这么多。

  1. const computeFrenetFramesExtend = (segmentRates: number[], path: CurvePath<Vector3>, closed = false) => {
  2. // see http://www.cs.indiana.edu/pub/techreports/TR425.pdf
  3. const normal = new Vector3();
  4. const segments = segmentRates.length;
  5. const tangents: Vector3[] = [];
  6. const normals: Vector3[] = [];
  7. const binormals: Vector3[] = [];
  8. const vec = new Vector3();
  9. const mat = new Matrix4();
  10. // compute the tangent vectors for each segment on the curve
  11. segmentRates.forEach((rate, index) => {
  12. tangents[index] = path.getTangentAt(rate, new Vector3());
  13. // 正规化
  14. tangents[index].normalize();
  15. });
  16. // select an initial normal vector perpendicular to the first tangent vector,
  17. // and in the direction of the minimum tangent xyz component
  18. normals[0] = new Vector3();
  19. binormals[0] = new Vector3();
  20. let min = Number.MAX_VALUE;
  21. const tx = Math.abs(tangents[0].x);
  22. const ty = Math.abs(tangents[0].y);
  23. const tz = Math.abs(tangents[0].z);
  24. if (tx <= min) {
  25. min = tx;
  26. normal.set(1, 0, 0);
  27. }
  28. if (ty <= min) {
  29. min = ty;
  30. normal.set(0, 1, 0);
  31. }
  32. if (tz <= min) {
  33. normal.set(0, 0, 1);
  34. }
  35. vec.crossVectors(tangents[0], normal).normalize();
  36. normals[0].crossVectors(tangents[0], vec);
  37. binormals[0].crossVectors(tangents[0], normals[0]);
  38. // compute the slowly-varying normal and binormal vectors for each segment on the curve
  39. // 计算法向量和副法向量 该算法依赖于曲率中的基向量关系
  40. for (let i = 1; i < segments; i++) {
  41. normals[i] = normals[i - 1].clone();
  42. binormals[i] = binormals[i - 1].clone();
  43. vec.crossVectors(tangents[i - 1], tangents[i]);
  44. if (vec.length() > Number.EPSILON) {
  45. vec.normalize();
  46. const theta = Math.acos(clamp(tangents[i - 1].dot(tangents[i]), -1, 1)); // clamp for floating pt errors
  47. normals[i].applyMatrix4(mat.makeRotationAxis(vec, theta));
  48. }
  49. binormals[i].crossVectors(tangents[i], normals[i]);
  50. }
  51. // if the curve is closed, postprocess the vectors so the first and last normal vectors are the same
  52. if (closed === true) {
  53. let theta = Math.acos(clamp(normals[0].dot(normals[segments]), -1, 1));
  54. theta /= segments;
  55. if (tangents[0].dot(vec.crossVectors(normals[0], normals[segments])) > 0) {
  56. theta = -theta;
  57. }
  58. for (let i = 1; i < segments; i++) {
  59. // twist a little...
  60. normals[i].applyMatrix4(mat.makeRotationAxis(tangents[i], theta * i));
  61. binormals[i].crossVectors(tangents[i], normals[i]);
  62. }
  63. }
  64. // 切线 法向量 副法向量
  65. return {
  66. tangents: tangents,
  67. normals: normals,
  68. binormals: binormals
  69. };
  70. };

这里的代码实现原理,可以参考文档去进行详细研究。但是作为工具化函数,只需要知道它返回曲线上给定的rate位置的切线方向T,法线方向N和副法线方向B这三个单位向量即可。

构建基平面

  1. const shapePoints = shape.extractPoints(curveSegments);
  2. let vertices = shapePoints.shape;
  3. const holes = shapePoints.holes;
  4. const reverse = !ShapeUtils.isClockWise(vertices);
  5. // 如果面积为负 反转所有点 和洞的所有点
  6. if (reverse) {
  7. vertices = vertices.reverse();
  8. // Maybe we should also check if holes are in the opposite direction, just to be safe ...
  9. for (let h = 0, hl = holes.length; h < hl; h++) {
  10. const ahole = holes[h];
  11. if (ShapeUtils.isClockWise(ahole)) {
  12. holes[h] = ahole.reverse();
  13. }
  14. }
  15. }
  16. // 生成给定shape的faces
  17. const faces = ShapeUtils.triangulateShape(vertices, holes);

这一部分都是调用的Shape类的相关方法,根据给定的shape的2维转换点,计算出输入的对应shape的绘画点。

侧面计算

  1. for (let i = 0; i < vlen; i++) {
  2. const vert = bevelEnabled ? scalePt2(vertices[i], verticesMovements[i], bs) : vertices[i];
  3. if (!extrudeByPath) {
  4. v(vert.x, vert.y, 0);
  5. } else {
  6. const vertice3D = new Vector3(vert.x, vert.y, 0);
  7. position2.copy(vertice3D).add(extrudePts[0]);
  8. v(position2.x, position2.y, position2.z);
  9. }
  10. }
  11. for (let s = 1; s <= steps; s++) {
  12. for (let i = 0; i < vlen; i++) {
  13. // bevelEnabled现在只考虑false情况,即对应的shape的相对绘制点
  14. const vert = bevelEnabled ? scalePt2(vertices[i], verticesMovements[i], bs) : vertices[i];
  15. if (!extrudeByPath) {
  16. // 如果没有path 直接延申即可
  17. v(vert.x, vert.y, (depth / steps) * s);
  18. } else {
  19. // 这里为了计算当前step位置的外部边缘的一圈vertex坐标
  20. // 增加对应的向量数值
  21. const vertice3D = new Vector3(vert.x, vert.y, 0);
  22. position2.copy(vertice3D);
  23. // // 做相应的旋转变换
  24. position2.applyMatrix4(matrixList[s]).add(extrudePts[s]);
  25. v(position2.x, position2.y, position2.z);
  26. }
  27. }
  28. }

首先1-12行,绘制首个shape面,其实就是将shape的所有绘制点挪到向量的起始点,这里是(0,0,0)。重点说明14-32行:循环变量s标识侧面绘制拉伸线条中细分线段的点的index,i标识shape的对应绘制点的index。由于extrudeByPath在我们的应用场景下总是有值,所以只用看23-30行代码。在这里,需要做一个rotation变换,用来处理sweep中出现向量方向偏转后的对应点的rotation。

  1. const getMatrix = () => {
  2. if (splineTube.tangents.length === 0) {
  3. return [];
  4. }
  5. // 原始矩阵 后面矩阵都和该矩阵进行比较变换
  6. const m0 = new Matrix4();
  7. m0.makeBasis(splineTube.tangents[0], splineTube.normals[0], splineTube.binormals[0]);
  8. // compareMatrix = (mRot.m0)-1
  9. const compareMatrix = new Matrix4();
  10. compareMatrix.multiplyMatrices(mRot, m0).invert();
  11. const matrixList = splineTube.tangents.map((tangent, i) => {
  12. const normal = splineTube.normals[i];
  13. const binormal = splineTube.binormals[i];
  14. // 对应该点的Frenet坐标系变换
  15. const mi = new Matrix4();
  16. mi.makeBasis(tangent, normal, binormal);
  17. // 计算从m1->mi的变换 (mRot.mi).(mRot.m1)-1
  18. const retMatrix = new Matrix4();
  19. retMatrix.multiplyMatrices(mRot, mi).multiply(compareMatrix);
  20. return retMatrix;
  21. });
  22. return matrixList;
  23. };
  24. // 这个矩阵需要用于后面外围平面的变化计算
  25. matrixList = getMatrix();
  26. }

计算曲线上对应点的rotation变换矩阵,仍然需要用到坐标系变换的知识。 对于曲线路径上的任意一点,我们需要根据这个点的切向量的位置去确认它和起始点的切向量的偏移关系。但是考虑到所有的路径点都会受到mRot矩阵的作用,所以这个基的变换需要剔除掉mRot的影响。
这里的基变换很简单,首先初始点A基矩阵我们设为M1,然后路径中取任意点B其基矩阵为Mi,现在计算路径中的旋转矩阵。考虑A点在实际坐标系中受到mRot作用,mRot·M1,同理B点作用矩阵为mRot·Mi,这样要从A点切向量向量旋转到B点切向量需要矩阵作用为mRot·Mi(mRot·M1)-1,然后依次算出每个点对应的Matrix4,就可以获取所有路径上点的rotation对应的Matrix4。

现在回到计算侧面点的代码上来:

  1. const vertice3D = new Vector3(vert.x, vert.y, 0);
  2. position2.copy(vertice3D);
  3. // // 做相应的旋转变换
  4. position2.applyMatrix4(matrixList[s]).add(extrudePts[s]);
  5. v(position2.x, position2.y, position2.z);
  1. 实际上行1获取了shape的对应绘图点,然后行5将计算出的向量点位置获取到,然后和绘图点相加,实际上是将shaperotation变换后平移到对应的向量位置。

处理转角

在路径分点时,对于每个转角代码中都会预留3个step,

  1. curveLengths.forEach((curveLength, index) => {
  2. // 最后一条曲线(存储一点作为最后的连接点)
  3. if (index === curveLengths.length - 1) {
  4. return;
  5. }
  6. // 计算当前分curve的末端位置
  7. const rate = curveLength / allLength;
  8. // 前者对应前曲线末端 后者对应后曲线开端
  9. pointRate.push(rate - 1 / (100 * divisions));
  10. pointRate.push(rate);
  11. pointRate.push(rate + 1 / (100 * divisions));
  12. // 用于查找rate的index列表
  13. cornerRateList.push(rate);
  14. });

现在代码将通过这3个step绘制连线。

当平面发生翻转后,如果不进行连接,就会产生不自然的连接,
image.png
如果想要得到一个自然的连接,一个思路就是将反转的平面的对应点的延长线进行连接,
image.png

  1. for (const cornerIndex of cornerIndexList) {
  2. // 前一个和后一个step位置 用于定位点
  3. const preStep = cornerIndex - 1;
  4. const nextStep = cornerIndex + 1;
  5. const preVector = splineTube.tangents[preStep].clone();
  6. const nextVector = splineTube.tangents[nextStep].clone();
  7. // 向量平行
  8. const dotVectorLen = new Vector3().copy(preVector).dot(nextVector);
  9. if (isEqual(dotVectorLen, 1)) {
  10. // 向量平行 这里跳过
  11. continue;
  12. }
  13. // 判断两个向量的相等 要求是差异点不会太大
  14. const isVertexEqual = (v1: Vector3, v2: Vector3) => {
  15. return Math.abs(v1.x - v2.x) < 0.00001 && Math.abs(v1.y - v2.y) < 0.00001 && Math.abs(v1.z - v2.z) < 0.00001;
  16. };
  17. // p1对应点1 v1对应线1向量 p2对应点2 v2对应线2向量
  18. // 具体原理查看 https://blog.csdn.net/xdedzl/article/details/86009147
  19. const getLineIntersection = (p1: Vector3, p2: Vector3, v1: Vector3, v2: Vector3) => {
  20. const startPointSeg = new Vector3().copy(p2).sub(p1);
  21. // 有向面积1,2
  22. const vecS1 = new Vector3().copy(v1).cross(v2);
  23. const vecS2 = new Vector3().copy(startPointSeg).cross(v2);
  24. const num = new Vector3().copy(startPointSeg).dot(vecS1);
  25. if (Math.abs(num) > Number.EPSILON) {
  26. console.log('直线不共面');
  27. // throw '直线不共面';
  28. }
  29. const rate = vecS2.dot(vecS1) / vecS1.lengthSq();
  30. const interSection = new Vector3().copy(v1).multiplyScalar(rate).add(p1);
  31. return interSection;
  32. };
  33. // 计算这些点中每两个点的交点
  34. for (let i = 0; i < vlen; i++) {
  35. // 拐点的前后两个面的对应点计算
  36. const preVertexIndex = vlen * preStep + i;
  37. const preVertex = new Vector3(
  38. placeholder[3 * preVertexIndex],
  39. placeholder[3 * preVertexIndex + 1],
  40. placeholder[3 * preVertexIndex + 2]
  41. );
  42. const nextVertexIndex = vlen * nextStep + i;
  43. const nextVertex = new Vector3(
  44. placeholder[3 * nextVertexIndex],
  45. placeholder[3 * nextVertexIndex + 1],
  46. placeholder[3 * nextVertexIndex + 2]
  47. );
  48. // 获取两个向量交点
  49. // 两个点实际上是一个点 不用计算
  50. if (isVertexEqual(preVertex, nextVertex)) {
  51. continue;
  52. }
  53. // 交点坐标计算
  54. const newPoint = getLineIntersection(preVertex, nextVertex, preVector, nextVector);
  55. // 更新坐标
  56. const vertexIndex = vlen * cornerIndex + i;
  57. placeholder[vertexIndex * 3] = newPoint.x;
  58. placeholder[vertexIndex * 3 + 1] = newPoint.y;
  59. placeholder[vertexIndex * 3 + 2] = newPoint.z;
  60. }
  61. }
  1. 此处代码获取转角的前后平面,然后计算前后平面对应的位置的交点。然后替换掉原先计算好的侧面点。这里计算空间上直线的交点的算法可以参考[blog](https://blog.csdn.net/xdedzl/article/details/86009147)。

收尾

调用three.js的绘画侧面和地面的代码,完成geometry的创建,接着还原开始时的旋转,

  1. // 还原
  2. mGen.invert();
  3. mVec.invert();
  4. this.applyMatrix4(mGen);
  5. this.applyMatrix4(mVec);

这样得到的geometry即给定参数对应的空间几何体。

优化

在设置参数时,有一个非常重要的参数steps,它决定了绘画geometry的粒度,steps越大绘画越精细。
image.png
step=30
image.png
step=120
如果是extrude即使steps=1也可以完成绘画,
image.png
而sweep正如上面所述,其由于有转角等处理,它的steps至少要满足一定条件。
绘制一个模型,经常需要成百上千的geometry,如果steps设置过大会让画面渲染变得非常卡顿,因此在类的实现中自动设置steps是一个很有必要的行为。

  1. const createPath = () => {
  2. const path = new Path();
  3. const pathCoordinates = options.pathArr;
  4. if (pathCoordinates.length < 2) {
  5. throw new Error('path 太短');
  6. }
  7. let size = 3;
  8. let i = 0;
  9. while (i < pathCoordinates.length) {
  10. const startPoint = new Vector3(...pathCoordinates[i]);
  11. let j = i + 1;
  12. // 计算起始点到结束点的向量
  13. let subVector!: Vector3;
  14. // 合并连续的同方向向量
  15. while (j < pathCoordinates.length) {
  16. const endPoint = new Vector3(...pathCoordinates[j]);
  17. if (subVector === undefined) {
  18. // 最后两个点 成为一条直线
  19. if (j === pathCoordinates.length - 1) {
  20. const line = new LineCurve3(startPoint, endPoint);
  21. path.curves.push(line as any);
  22. size += 5;
  23. // 旧的点变为新的点
  24. i = j;
  25. // 最后一个点执行后直接结束
  26. i++;
  27. break;
  28. }
  29. subVector = new Vector3().subVectors(endPoint, startPoint);
  30. j++;
  31. } else {
  32. const newSubVector = new Vector3().subVectors(endPoint, startPoint);
  33. // 判断新老向量是否平行
  34. const crossVectorLenSq = new Vector3().crossVectors(subVector, newSubVector).lengthSq();
  35. if (isEqual(crossVectorLenSq, 0)) {
  36. // 最后一条直线
  37. if (j === pathCoordinates.length - 1) {
  38. const line = new LineCurve3(startPoint, endPoint);
  39. path.curves.push(line as any);
  40. size += 5;
  41. // 旧的点变为新的点
  42. i = j;
  43. // 最后一个点执行后直接结束
  44. i++;
  45. break;
  46. }
  47. // 向量平行继续
  48. j++;
  49. } else {
  50. // 向量不平行 增加path
  51. const line = new LineCurve3(startPoint, new Vector3(...pathCoordinates[j - 1]));
  52. path.curves.push(line as any);
  53. size += 5;
  54. // 旧的点变为新的点
  55. i = j - 1;
  56. break;
  57. }
  58. }
  59. }
  60. }
  61. options.extrudePath = path as any;
  62. options.steps = options.type === 'extrude' ? 3 : size;
  63. };

createPath函数,其目的是根据输入的pathArr生成一个Path实例。
首先,代码根据路径中的向量点判断新的点是否在老的向量上,如果是那么新的点作为输入路径向量的终点。举个例子:

  1. const pathArr = [
  2. [0, 0, 0],
  3. [0, 1, 0],
  4. [0, 2, 0],
  5. [0, 2, 1]
  6. ];

首先取(0,0,0)作为起点,然后(0,1,0)作为终点,得到当前向量为(0,1,0)。不难发现(0,2,0)也在向量方向上,这时用(0,2,0)替换(0,1,0)作为向量终点,当前向量变为(0,2,0)。直到看到下一个点
(0,2,1),不在该向量上,此时开始进入新的向量循环,同时把之前的向量(0,2,0)添加到路径中。
由于添加路径时产生了转角,对sizes进行定量增加。如果是曲线路径后续还需要进行其他的优化处理。
另外extrude需要的steps很少因此可以直接设置其steps为3。

TODO

对于曲线路径的处理,或者很多corner case的测试还没有完善,需要后续继续更新代码。