本文档详细解析 cgaprocessor.js 中 splitAndSetbackPerimeter 函数的实现。该函数是一个强大的周长分割与退缩工具,沿多边形 footprint 的周长按指定长度和深度进行分割,生成退缩后的面片。
splitAndSetbackPerimeter 是 CGA 中 split 家族的高级变体。不同于沿直线的 split,它沿多边形周长进行分割,常用于:
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
splitOffset | Number | 0 | 周长起始偏移量 |
firstEdgeIndex | Number | 0 | 起始边索引,控制从哪条边开始分割 |
body | Body | 必填 | 分割体,格式为 length : depth : operations |
length : depth : { operations },其中 length 是沿周长的分段长度,depth 是向内退缩的深度。这与普通 split 不同,后者只有 size : operations。
与 roofGable 等函数相同:建立局部坐标系,将 footprint 顶点投影到2D平面,按极角围绕质心逆时针排序。
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;
}
每条边记录以下信息:
| 属性 | 说明 |
|---|---|
a, b | 边的两个端点(2D局部坐标) |
start, end | 该边在周长上的起始和结束位置 |
length | 边长 |
inward | 指向多边形内部的单位法线向量 |
(dx, dy) 逆时针旋转 90 度并归一化,得到 (-dy/len, dx/len)。由于顶点已按逆时针排序,这个方向始终指向多边形内部。
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 包含两个数值:
length:沿周长的分割长度depth:向内退缩的深度使用共享的 _compute_splits 函数计算实际分割尺寸(支持 floating、relative、repeat 等特性)。
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 方向。
function insetPoint(p, depth) {
return {
x: p.x + p.inward.x*depth,
y: p.y + p.inward.y*depth,
};
}
将点沿 inward 方向(向内)移动 depth 距离。
addFace(geom, p0, p1, i1); addFace(geom, p0, i1, i0);
对每个分割段,生成一个退缩后的四边形,拆分为两个三角形:
p0, p1:沿周长的两个外点(原始轮廓上)i0, i1:向内退缩后的两个内点图:单个分割段的几何结构。外边缘在原始轮廓上,内边缘向内退缩 depth 距离。
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。