roofGable 函数实现详解

本文档详细解析 cgaprocessor.jsroofGable 函数的实现原理、算法流程及关键代码。该函数用于在 CGA (Computer Generated Architecture) 规则系统中生成人字屋顶(Gable Roof)几何体。

一、函数定位与作用

roofGable 是 CGA 处理器中的核心屋顶生成函数之一,与 roofHiproofPyramidroofShed 等函数并列。它根据当前作用域的平面形状(通常为建筑顶层轮廓),生成一个两面坡的人字屋顶。

人字屋顶(Gable Roof)的特征是:屋顶有两个相对的斜面,在顶部相交形成一条水平的屋脊线(Ridge),另两侧为垂直的山墙面(Gable Wall)

二、函数签名与参数解析

2.1 源码中的参数处理逻辑

// 第 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];
  }

2.2 参数说明

参数 类型 默认值 说明
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),此时默认按角度处理)。

三、核心算法流程总览

输入:平面多边形 + 参数 Step 1: 预处理(合并顶点、算法线) Step 2: 建立局部2D坐标系 Step 3: 确定屋脊方向(最长边) Step 4: 计算各点屋顶高度 输出:3D 屋顶三角网格

四、详细实现步骤

Step 1: 几何体预处理(第 599~606 行)

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';

Step 2: 建立局部2D坐标系(第 608~629 行)

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坐标系中处理:

  1. 计算质心:所有顶点的算术平均值作为局部坐标原点
  2. 投影顶点:每个顶点减去质心,得到以质心为原点的相对向量
  3. 构造 X 轴:取第1个顶点到第0个顶点的方向,并将其投影到法线平面上(减去法线方向的分量)
    xaxis = (v₁ - v₀) - ((v₁ - v₀) · normal) · normal
  4. 退化处理:如果投影后的 xaxis 长度几乎为0,依次尝试用 (1,0,0)×normal 和 (0,1,0)×normal 作为备选
  5. 构造 Y 轴:通过叉乘 yaxis = normal × xaxis 得到,确保 (x, y, normal) 构成右手坐标系
关键技巧:通过将3D顶点投影到2D平面,把复杂的3D屋顶问题转化为简单的2D多边形处理,最后再映射回3D空间。

Step 3: 2D顶点排序与屋脊方向确定(第 631~656 行)

3.1 顶点投影到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 进行逆时针排序。排序后的顶点构成一个有序的凸或凹多边形环。

3.2 确定屋脊方向

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);
    }
  }
}
为什么选最长边? 人字屋顶的屋脊通常是建筑轮廓的最长对称轴,这样生成的屋顶最自然。如果建筑是长方形,最长边就是屋脊的合理选择。

Step 4: 坐标旋转与高度计算(第 658~674 行)

4.1 旋转到屋脊坐标系

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;
});

这里进行了一次坐标系旋转:

4.2 计算高度

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;

核心计算逻辑:

  1. midP:垂直屋脊方向的中心线位置,即屋脊线在2D平面上的投影位置
  2. byAngle 模式:每个顶点的高度 = tan(角度) × |p - midP|,即离屋脊线越远,屋顶越高
  3. byHeight 模式:所有顶点使用统一的高度 value
  4. evenHeight:所有顶点高度的平均值,当 even=true 时使用
屋脊线 (midP) h p 方向(垂直屋脊) q 方向(沿屋脊) 屋脊 屋檐 屋檐

图:屋顶截面示意图。高度 h 与顶点距屋脊线距离 |p - midP| 成正比。

Step 5: 从局部坐标映射回3D(第 676~683 行)

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 的逆变换

  1. 先将 (q, p) 从屋脊坐标系旋转回 (x, y) 局部坐标
  2. 再用 xaxisyaxis 映射回3D世界坐标
  3. 最后沿法线方向叠加高度 h

Step 6: 构建三角网格(第 685~717 行)

6.1 生成底部和屋脊顶点

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]));
});

对每个原始顶点,生成两个3D顶点:

注意 outQ 的悬挑处理有一个条件判断:Math.abs(p.q - midQ) > 0.0001,这是为了避免在屋脊中心线附近的顶点产生错误的悬挑偏移。

6.2 构建底部面(三角形扇)

for (var j=1; j<baseIndices.length-1; j++)
  geometry.faces.push(new THREE.Face3(baseIndices[0], baseIndices[j], baseIndices[j+1]));

将底部多边形三角化为一个三角形扇,以第0个底部顶点为公共顶点,依次与后续顶点构成三角形。

6.3 构建侧面(屋顶坡面)

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个顶点):

Step 7: 合并顶点与更新(第 717~718 行)

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 时自动选择最长边。

七、使用示例

CGA 规则示例

// 按 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。