本文档详细解析 cgaprocessor.js 中 roofGable 函数的实现原理、算法流程及关键代码。该函数用于在 CGA (Computer Generated Architecture) 规则系统中生成人字屋顶(Gable Roof)几何体。
roofGable 是 CGA 处理器中的核心屋顶生成函数之一,与 roofHip、roofPyramid、roofShed 等函数并列。它根据当前作用域的平面形状(通常为建筑顶层轮廓),生成一个两面坡的人字屋顶。
// 第 576~594 行 function func_roofGable(processor) { var args = Array.prototype.slice.call(arguments, 1); var valueType = 'byAngle'; var value, overhangX = 0, overhangY = 0, even = false, index; if (args[0] == 'byAngle' || args[0] == 'byHeight') { valueType = args[0]; value = args[1]; overhangX = args[2] || 0; overhangY = args[3] || 0; even = args[4] || false; } else { value = args[0]; overhangX = args[1] || 0; overhangY = args[2] || 0; even = args[3] || false; index = args[4]; }
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
valueType |
String | 'byAngle' |
高度计算模式:'byAngle' 按坡角计算,'byHeight' 直接指定屋脊高度 |
value |
Number | 必填 | 坡角度数(byAngle)或屋脊高度(byHeight) |
overhangX |
Number | 0 | 垂直于屋脊方向的悬挑长度(山墙方向) |
overhangY |
Number | 0 | 平行于屋脊方向的悬挑长度(屋檐方向) |
even |
Boolean | false | 是否使用平均高度生成平顶效果 |
index |
Number | undefined | 手动指定哪条边作为屋脊方向(未指定时自动选最长边) |
valueType(如 roofGable('byAngle', 30, 0.5, 0.3)),或省略模式直接传入数值(如 roofGable(30, 0.5, 0.3),此时默认按角度处理)。
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 'roofGable needs a non-degenerate face normal';
mergeVertices():合并位置重合的顶点,确保拓扑 cleancomputeFaceNormals():计算每个面的法线向量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();
这一步的核心是将任意朝向的3D平面多边形,变换到一个局部2D坐标系中处理:
var points = top.vertices.map((v, i) => { var d = v.clone().sub(center); return { index: i, 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));
每个顶点被转换为局部2D坐标 (x, y),然后按极角围绕2D质心 c2 进行逆时针排序。排序后的顶点构成一个有序的凸或凹多边形环。
var ridge = null; if (index !== undefined) { // 手动指定边索引 var edgeIndex = Math.floor(index) % points.length; var a = points[edgeIndex]; var b = points[(edgeIndex+1) % points.length]; ridge = new THREE.Vector2(b.x-a.x, b.y-a.y); } else { // 自动选择最长边作为屋脊 var longest = -1; for (var i=0; i<points.length; i++) { var p1 = points[i]; var p2 = points[(i+1) % points.length]; var len = new THREE.Vector2(p2.x-p1.x, p2.y-p1.y).lengthSq(); if (len > longest) { longest = len; ridge = new THREE.Vector2(p2.x-p1.x, p2.y-p1.y); } } }
index,则按索引取对应边作为屋脊方向ridge 后续会被归一化var across = new THREE.Vector2(-ridge.y, ridge.x); var coords = points.map(p => { p.q = p.x*ridge.x + p.y*ridge.y; // 沿屋脊方向投影 p.p = p.x*across.x + p.y*across.y; // 垂直屋脊方向投影 return p; });
这里进行了一次坐标系旋转:
ridge:沿屋脊方向的单位向量across = (-ridge.y, ridge.x):垂直于屋脊的方向(逆时针旋转90°)q:顶点在屋脊方向上的坐标(类似"纵向")p:顶点在垂直屋脊方向上的坐标(类似"横向")var minP = Math.min.apply(null, coords.map(p => p.p)); var maxP = Math.max.apply(null, coords.map(p => p.p)); var midP = (minP + maxP) / 2; var minQ = Math.min.apply(null, coords.map(p => p.q)); var maxQ = Math.max.apply(null, coords.map(p => p.q)); var midQ = (minQ + maxQ) / 2; var angleHeight = Math.tan(THREE.Math.degToRad(value)); var heights = coords.map(p => valueType == 'byHeight' ? value : angleHeight * Math.abs(p.p - midP)); var evenHeight = heights.reduce((a,h) => a+h, 0) / heights.length;
核心计算逻辑:
midP:垂直屋脊方向的中心线位置,即屋脊线在2D平面上的投影位置valueevenHeight:所有顶点高度的平均值,当 even=true 时使用图:屋顶截面示意图。高度 h 与顶点距屋脊线距离 |p - midP| 成正比。
function fromRoofCoords(q, p, h) { var x = ridge.x*q + across.x*p; var y = ridge.y*q + across.y*p; return center.clone() .add(xaxis.clone().multiplyScalar(x)) .add(yaxis.clone().multiplyScalar(y)) .add(normal.clone().multiplyScalar(h)); }
这个函数是2D→3D 的逆变换:
(q, p) 从屋脊坐标系旋转回 (x, y) 局部坐标xaxis 和 yaxis 映射回3D世界坐标hvar geometry = processor.create(); var baseIndices = []; var ridgeIndices = []; coords.forEach((p, i) => { var outP = p.p + (p.p < midP ? -overhangX : overhangX); var outQ = p.q; if (Math.abs(p.q - midQ) > 0.0001) outQ += p.q < midQ ? -overhangY : overhangY; baseIndices[i] = geometry.vertices.length; geometry.vertices.push(fromRoofCoords(outQ, outP, 0)); ridgeIndices[i] = geometry.vertices.length; geometry.vertices.push(fromRoofCoords(outQ, midP, even ? evenHeight : heights[i])); });
对每个原始顶点,生成两个3D顶点:
p 方向外扩 overhangX 形成悬挑midP,高度根据模式计算。同样沿 q 方向外扩 overhangYoutQ 的悬挑处理有一个条件判断:Math.abs(p.q - midQ) > 0.0001,这是为了避免在屋脊中心线附近的顶点产生错误的悬挑偏移。
for (var j=1; j<baseIndices.length-1; j++) geometry.faces.push(new THREE.Face3(baseIndices[0], baseIndices[j], baseIndices[j+1]));
将底部多边形三角化为一个三角形扇,以第0个底部顶点为公共顶点,依次与后续顶点构成三角形。
for (var k=0; k<coords.length; k++) { var n = (k+1) % coords.length; var r1 = geometry.vertices[ridgeIndices[k]]; var r2 = geometry.vertices[ridgeIndices[n]]; if (r1.distanceTo(r2) < 0.0001) { // 相邻屋脊顶点重合 → 三角形 geometry.faces.push(new THREE.Face3(baseIndices[k], baseIndices[n], ridgeIndices[k])); } else { // 四边形拆分为两个三角形 geometry.faces.push(new THREE.Face3(baseIndices[k], baseIndices[n], ridgeIndices[n])); geometry.faces.push(new THREE.Face3(baseIndices[k], ridgeIndices[n], ridgeIndices[k])); } }
对每个边(从第k个顶点到第k+1个顶点):
geometry.mergeVertices(); processor.update(geometry);
mergeVertices():合并位置相同但索引不同的顶点,优化网格processor.update(geometry):将生成的屋顶几何体替换当前栈顶形状| 步骤 | 数学操作 | 目的 |
|---|---|---|
| 投影到平面 | v' = v - (v·n)n | 消除法线方向分量,确保X轴在平面内 |
| 2D坐标计算 | x = d·xaxis, y = d·yaxis | 将3D向量映射到局部2D坐标 |
| 极角排序 | θ = atan2(y - cy, x - cx) | 获得逆时针有序的多边形顶点环 |
| 坐标旋转 | q = p·ridge, p⊥ = p·across | 转换到以屋脊为参考的坐标系 |
| 高度计算 | h = tan(θ) × |p⊥ - midP| | 按坡角计算屋脊高度 |
| 逆变换 | v_world = center + x·xaxis + y·yaxis + h·normal | 将2D局部坐标映射回3D世界坐标 |
| 函数 | 屋顶类型 | 特点 |
|---|---|---|
roofGable |
人字屋顶 | 两面坡,顶部有水平屋脊,两侧为山墙。自动选最长边为屋脊方向。 |
roofHip |
四坡屋顶 | 四面都有坡度,顶部有屋脊,四角有斜脊(hip)。适用于近似矩形。 |
roofPyramid |
金字塔顶 | 所有面汇聚到顶部一点,无屋脊。 |
roofShed |
单坡屋顶 | 只有一个坡面,一侧高一侧低。 |
roofRidge |
指定屋脊屋顶 | 与 roofGable 算法相同,但允许显式指定哪条边作为屋脊方向。 |
roofRidge(第 1573~1722 行)的算法核心与 roofGable 几乎完全一致,区别在于 roofRidge 强制使用用户传入的 edgeIndex 指定屋脊边,而 roofGable 在没有传入 index 时自动选择最长边。
// 按 30 度坡角生成人字屋顶,悬挑 0.3m Lot --> extrude(3) comp(f) { top: Roof } Roof --> roofGable(30, 0.3, 0.2) // 按高度生成(屋脊高 2.5m),平均高度模式 Roof --> roofGable('byHeight', 2.5, 0.3, 0.2, true) // 手动指定第 2 条边作为屋脊方向 Roof --> roofGable(30, 0.3, 0.2, false, 2)
以下为 cgaprocessor.js 第 576~719 行的完整代码:
function func_roofGable(processor) { var args = Array.prototype.slice.call(arguments, 1); var valueType = 'byAngle'; var value, overhangX = 0, overhangY = 0, even = false, index; if (args[0] == 'byAngle' || args[0] == 'byHeight') { valueType = args[0]; value = args[1]; overhangX = args[2] || 0; overhangY = args[3] || 0; even = args[4] || false; } else { value = args[0]; overhangX = args[1] || 0; overhangY = args[2] || 0; even = args[3] || false; index = args[4]; } if (!isNumeric(value)) throw 'roofGable requires a numeric angle or height'; if (!isNumeric(overhangX) || !isNumeric(overhangY)) throw 'roofGable overhangs 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 'roofGable 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, i) => { var d = v.clone().sub(center); return { index: i, 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)); var ridge = null; if (index !== undefined) { var edgeIndex = Math.floor(index) % points.length; var a = points[edgeIndex]; var b = points[(edgeIndex+1) % points.length]; ridge = new THREE.Vector2(b.x-a.x, b.y-a.y); } else { var longest = -1; for (var i=0; i<points.length; i++) { var p1 = points[i]; var p2 = points[(i+1) % points.length]; var len = new THREE.Vector2(p2.x-p1.x, p2.y-p1.y).lengthSq(); if (len > longest) { longest = len; ridge = new THREE.Vector2(p2.x-p1.x, p2.y-p1.y); } } } if (!ridge || ridge.length() < 0.0001) throw 'roofGable could not determine a ridge direction'; ridge.normalize(); var across = new THREE.Vector2(-ridge.y, ridge.x); var coords = points.map(p => { p.q = p.x*ridge.x + p.y*ridge.y; p.p = p.x*across.x + p.y*across.y; return p; }); var minP = Math.min.apply(null, coords.map(p => p.p)); var maxP = Math.max.apply(null, coords.map(p => p.p)); var midP = (minP + maxP) / 2; var minQ = Math.min.apply(null, coords.map(p => p.q)); var maxQ = Math.max.apply(null, coords.map(p => p.q)); var midQ = (minQ + maxQ) / 2; var angleHeight = Math.tan(THREE.Math.degToRad(value)); var heights = coords.map(p => valueType == 'byHeight' ? value : angleHeight * Math.abs(p.p - midP)); var evenHeight = heights.reduce((a,h) => a+h, 0) / heights.length; function fromRoofCoords(q, p, h) { var x = ridge.x*q + across.x*p; var y = ridge.y*q + across.y*p; return center.clone() .add(xaxis.clone().multiplyScalar(x)) .add(yaxis.clone().multiplyScalar(y)) .add(normal.clone().multiplyScalar(h)); } var geometry = processor.create(); var baseIndices = []; var ridgeIndices = []; coords.forEach((p, i) => { var outP = p.p + (p.p < midP ? -overhangX : overhangX); var outQ = p.q; if (Math.abs(p.q - midQ) > 0.0001) outQ += p.q < midQ ? -overhangY : overhangY; baseIndices[i] = geometry.vertices.length; geometry.vertices.push(fromRoofCoords(outQ, outP, 0)); ridgeIndices[i] = geometry.vertices.length; geometry.vertices.push(fromRoofCoords(outQ, midP, even ? evenHeight : heights[i])); }); for (var j=1; j<baseIndices.length-1; j++) geometry.faces.push(new THREE.Face3(baseIndices[0], baseIndices[j], baseIndices[j+1])); for (var k=0; k<coords.length; k++) { var n = (k+1) % coords.length; var r1 = geometry.vertices[ridgeIndices[k]]; var r2 = geometry.vertices[ridgeIndices[n]]; if (r1.distanceTo(r2) < 0.0001) { geometry.faces.push(new THREE.Face3(baseIndices[k], baseIndices[n], ridgeIndices[k])); } else { geometry.faces.push(new THREE.Face3(baseIndices[k], baseIndices[n], ridgeIndices[n])); geometry.faces.push(new THREE.Face3(baseIndices[k], ridgeIndices[n], ridgeIndices[k])); } } geometry.mergeVertices(); processor.update(geometry); }
cgaprocessor.js 第 576~719 行源码分析生成。生成时间:2026-05-28。