splitAndSetbackPerimeter 函数实现详解

本文档详细解析 cgaprocessor.jssplitAndSetbackPerimeter 函数的实现。该函数是一个强大的周长分割与退缩工具,沿多边形 footprint 的周长按指定长度和深度进行分割,生成退缩后的面片。

一、函数定位与作用

splitAndSetbackPerimeter 是 CGA 中 split 家族的高级变体。不同于沿直线的 split,它沿多边形周长进行分割,常用于:

二、参数说明

参数类型默认值说明
splitOffsetNumber0周长起始偏移量
firstEdgeIndexNumber0起始边索引,控制从哪条边开始分割
bodyBody必填分割体,格式为 length : depth : operations
body 的语法格式为 length : depth : { operations },其中 length 是沿周长的分段长度,depth 是向内退缩的深度。这与普通 split 不同,后者只有 size : operations

三、核心算法流程

输入:多边形 + splitOffset + body Step 1: 2D 投影,极角排序顶点 Step 2: 按 firstEdgeIndex 旋转顶点顺序 Step 3: 计算各边长度、内法线、周长 Step 4: 解析 body.parts,沿周长分割 Step 5: 每段向内退缩 depth,生成面片 Step 6: 收集 remainder 并生成内部区域 输出:退缩后的分段面片

四、详细步骤解析

Step 1: 2D 投影与顶点排序(第 731~764 行)

roofGable 等函数相同:建立局部坐标系,将 footprint 顶点投影到2D平面,按极角围绕质心逆时针排序。

Step 2: 按 firstEdgeIndex 旋转(第 766~767 行)

firstEdgeIndex = ((Math.floor(firstEdgeIndex) % points.length) + points.length) % points.length;
points = points.slice(firstEdgeIndex).concat(points.slice(0, firstEdgeIndex));

通过数组切片和拼接,将指定的顶点旋转到数组开头。这决定了周长分割的起始位置

Step 3: 构建边信息(第 769~787 行)

var edges = [];
var perimeter = 0;
for (var i=0; i<points.length; i++) {
  var a = points[i];
  var b = points[(i+1) % points.length];
  var dx = b.x-a.x;
  var dy = b.y-a.y;
  var len = Math.sqrt(dx*dx + dy*dy);
  if (len < 0.0001) continue;
  edges.push({
    a: a,
    b: b,
    start: perimeter,
    end: perimeter + len,
    length: len,
    inward: new THREE.Vector2(-dy/len, dx/len),
  });
  perimeter += len;
}

每条边记录以下信息:

属性说明
a, b边的两个端点(2D局部坐标)
start, end该边在周长上的起始和结束位置
length边长
inward指向多边形内部的单位法线向量
inward 的计算:将边向量 (dx, dy) 逆时针旋转 90 度并归一化,得到 (-dy/len, dx/len)。由于顶点已按逆时针排序,这个方向始终指向多边形内部。

Step 4: 解析 body.parts(第 791~804 行)

var sizes = body.parts.map(p => {
  if (p.op != ':' ) throw '...';
  if (p.depth === undefined) throw 'splitAndSetbackPerimeter parts must use length : depth : operations';

  var length = eval_expr(processor, p.head);
  var depth = eval_expr(processor, p.depth);
  var floating = false;

  if (isRelative(length)) length = perimeter*length.value;
  if (isFloating(length)) { length = length.value; floating = true; }
  if (!isNumeric(length) || !isNumeric(depth)) throw '...';

  return { size: length, depth: depth, operations: p.operations, floating: floating };
});

var splits = _compute_splits(sizes, perimeter, body.repeat);

与普通 split 不同,每个 part 包含两个数值:

使用共享的 _compute_splits 函数计算实际分割尺寸(支持 floating、relative、repeat 等特性)。

Step 5: 沿周长定位与退缩(第 809~835 行)

5.1 pointAt 函数

function pointAt(pos) {
  pos = ((pos % perimeter) + perimeter) % perimeter;
  for (var e=0; e<edges.length; e++) {
    if (pos <= edges[e].end || e == edges.length-1) {
      var edge = edges[e];
      var t = Math.max(0, Math.min(1, (pos-edge.start)/edge.length));
      return {
        x: edge.a.x + (edge.b.x-edge.a.x)*t,
        y: edge.a.y + (edge.b.y-edge.a.y)*t,
        inward: edge.inward,
      };
    }
  }
}

给定周长上的位置 pos,返回该点在2D平面上的坐标以及所在边的 inward 方向。

5.2 insetPoint 函数

function insetPoint(p, depth) {
  return {
    x: p.x + p.inward.x*depth,
    y: p.y + p.inward.y*depth,
  };
}

将点沿 inward 方向(向内)移动 depth 距离。

5.3 面片生成

addFace(geom, p0, p1, i1);
addFace(geom, p0, i1, i0);

对每个分割段,生成一个退缩后的四边形,拆分为两个三角形:

原始轮廓边 退缩后的边(深度=depth) p0 p1 i0 i1 inward 两个三角形

图:单个分割段的几何结构。外边缘在原始轮廓上,内边缘向内退缩 depth 距离。

Step 6: Remainder 收集与生成(第 843~897 行)

var remainderPoints = [];
// ... 在每个分割段中 ...
if (!remainderPoints.length || Math.abs(remainderPoints[remainderPoints.length-1].x-i0.x) > 0.0001 || Math.abs(remainderPoints[remainderPoints.length-1].y-i0.y) > 0.0001)
  remainderPoints.push(i0);
remainderPoints.push(i1);

收集所有退缩后的内点 i0, i1,形成一个新的多边形环。然后:

if (body.remainder && remainderPoints.length >= 3) {
  var rem = processor.create();
  for (var j=1; j<remainderPoints.length-1; j++)
    addFace(rem, remainderPoints[0], remainderPoints[j], remainderPoints[j+1]);
  rem.mergeVertices();

  processor.set_attrs(rem);
  processor.stack.push(rem);
  processor.applyOperations(body.remainder);
  processor.stack.pop();
}

如果 body 定义了 remainder 操作,则将退缩后的内轮廓作为新几何体,应用 remainder 操作。这通常用于生成建筑的主体部分,而周长分割段用于生成裙边、装饰等。

五、关键特性

特性说明
跨边分割单个分割段可以跨越多条原始边,自动在边交点处截断
循环周长使用 (pos % perimeter + perimeter) % perimeter 确保周长位置循环
向内退缩通过 inward 法线确保退缩方向始终指向多边形内部
remainder 支持退缩后的内轮廓可单独处理,实现"挖边留心"效果

六、使用示例

// 沿周长每 3 米分割一段,向内退缩 1 米作为裙房
Lot --> splitAndSetbackPerimeter(0, 0) {
  3 : 1 : { facade }
}*

// 带 remainder:裙边 + 主体
Lot --> splitAndSetbackPerimeter(0, 0) {
  2 : 0.5 : { Balcony }
  4 : 1.0 : { Podium }
}* { MainBuilding }

七、源码完整摘录

function func_splitAndSetbackPerimeter(processor) {
  var args = Array.prototype.slice.call(arguments, 1);
  var body = args.pop();
  var splitOffset = args[0] || 0;
  var firstEdgeIndex = args[1] || 0;

  if (!body || !body.parts) throw 'splitAndSetbackPerimeter needs a split body';
  if (!isNumeric(splitOffset)) throw 'splitAndSetbackPerimeter splitOffset must be numeric';
  if (!isNumeric(firstEdgeIndex)) throw 'splitAndSetbackPerimeter firstEdgeIndex must be numeric';

  var top = processor.top;
  if (!top.faces.length) return;

  top.mergeVertices();
  top.computeFaceNormals();

  var normal = top.faces[0].normal.clone().normalize();
  if (!normal.length()) throw 'splitAndSetbackPerimeter needs a non-degenerate face normal';

  var center = new THREE.Vector3();
  top.vertices.forEach(v => center.add(v));
  center.divideScalar(top.vertices.length);

  var projected = top.vertices.map(v => v.clone().sub(center));
  var xaxis = projected[1] ? projected[1].clone().sub(projected[0]) : new THREE.Vector3(1,0,0);
  xaxis.sub(normal.clone().multiplyScalar(xaxis.dot(normal)));
  if (xaxis.length() < 0.0001) xaxis = new THREE.Vector3(1,0,0).cross(normal);
  if (xaxis.length() < 0.0001) xaxis = new THREE.Vector3(0,1,0).cross(normal);
  xaxis.normalize();

  var yaxis = new THREE.Vector3().crossVectors(normal, xaxis).normalize();
  var points = top.vertices.map(v => {
    var d = v.clone().sub(center);
    return {
      vertex: v,
      x: d.dot(xaxis),
      y: d.dot(yaxis),
    };
  });

  var c2 = points.reduce((a, p) => { a.x += p.x; a.y += p.y; return a; }, { x: 0, y: 0 });
  c2.x /= points.length;
  c2.y /= points.length;
  points.sort((a,b) => Math.atan2(a.y-c2.y, a.x-c2.x) - Math.atan2(b.y-c2.y, b.x-c2.x));

  firstEdgeIndex = ((Math.floor(firstEdgeIndex) % points.length) + points.length) % points.length;
  points = points.slice(firstEdgeIndex).concat(points.slice(0, firstEdgeIndex));

  var edges = [];
  var perimeter = 0;
  for (var i=0; i<points.length; i++) {
    var a = points[i];
    var b = points[(i+1) % points.length];
    var dx = b.x-a.x;
    var dy = b.y-a.y;
    var len = Math.sqrt(dx*dx + dy*dy);
    if (len < 0.0001) continue;
    edges.push({
      a: a,
      b: b,
      start: perimeter,
      end: perimeter + len,
      length: len,
      inward: new THREE.Vector2(-dy/len, dx/len),
    });
    perimeter += len;
  }

  if (!edges.length) return;

  var sizes = body.parts.map(p => {
    if (p.op != ':' ) throw 'Illegal splitAndSetbackPerimeter operator, must be : was "{op}"'.format(p);
    if (p.depth === undefined) throw 'splitAndSetbackPerimeter parts must use length : depth : operations';

    var length = eval_expr(processor, p.head);
    var depth = eval_expr(processor, p.depth);
    var floating = false;

    if (isRelative(length)) length = perimeter*length.value;
    if (isFloating(length)) { length = length.value; floating = true; }
    if (!isNumeric(length) || !isNumeric(depth)) throw 'splitAndSetbackPerimeter length and depth must be numeric';

    return { size: length, depth: depth, operations: p.operations, floating: floating };
  });

  var splits = _compute_splits(sizes, perimeter, body.repeat);
  if (!splits.length) return;

  function pointAt(pos) {
    pos = ((pos % perimeter) + perimeter) % perimeter;
    for (var e=0; e<edges.length; e++) {
      if (pos <= edges[e].end || e == edges.length-1) {
        var edge = edges[e];
        var t = Math.max(0, Math.min(1, (pos-edge.start)/edge.length));
        return {
          x: edge.a.x + (edge.b.x-edge.a.x)*t,
          y: edge.a.y + (edge.b.y-edge.a.y)*t,
          inward: edge.inward,
        };
      }
    }
  }

  function fromPlane(p) {
    return center.clone()
      .add(xaxis.clone().multiplyScalar(p.x))
      .add(yaxis.clone().multiplyScalar(p.y));
  }

  function insetPoint(p, depth) {
    return {
      x: p.x + p.inward.x*depth,
      y: p.y + p.inward.y*depth,
    };
  }

  function addFace(g, v1, v2, v3) {
    var l = g.vertices.length;
    g.vertices.push(fromPlane(v1), fromPlane(v2), fromPlane(v3));
    g.faces.push(new THREE.Face3(l, l+1, l+2));
  }

  var remainderPoints = [];
  var current = splitOffset;
  splits.forEach((s,i) => {
    var part = sizes[i%sizes.length];
    var left = current;
    var remaining = s;

    while (remaining > 0.0001) {
      var p0 = pointAt(left);
      var edgeRemaining = perimeter;
      for (var e=0; e<edges.length; e++) {
        if (((left % perimeter) + perimeter) % perimeter <= edges[e].end || e == edges.length-1) {
          edgeRemaining = edges[e].end - (((left % perimeter) + perimeter) % perimeter);
          if (edgeRemaining < 0.0001) edgeRemaining = edges[e].length;
          break;
        }
      }

      var chunk = Math.min(remaining, edgeRemaining);
      var p1 = pointAt(left + chunk);
      var i0 = insetPoint(p0, part.depth);
      var i1 = insetPoint(p1, part.depth);
      var geom = processor.create();

      addFace(geom, p0, p1, i1);
      addFace(geom, p0, i1, i0);
      geom.mergeVertices();

      processor.set_attrs(geom);
      processor.stack.push(geom);
      processor.applyOperations(part.operations);
      processor.stack.pop();

      if (!remainderPoints.length || Math.abs(remainderPoints[remainderPoints.length-1].x-i0.x) > 0.0001 || Math.abs(remainderPoints[remainderPoints.length-1].y-i0.y) > 0.0001)
        remainderPoints.push(i0);
      remainderPoints.push(i1);

      left += chunk;
      remaining -= chunk;
    }

    current += s;
  });

  if (body.remainder && remainderPoints.length >= 3) {
    var rem = processor.create();
    for (var j=1; j<remainderPoints.length-1; j++)
      addFace(rem, remainderPoints[0], remainderPoints[j], remainderPoints[j+1]);
    rem.mergeVertices();

    processor.set_attrs(rem);
    processor.stack.push(rem);
    processor.applyOperations(body.remainder);
    processor.stack.pop();
  }
}
文档生成信息:基于 cgaprocessor.js 第 721~898 行源码分析。生成时间:2026-05-28。