每日算法 — 使用java实现折纸:平面折叠序列与几何约束传播

引言

折纸(Origami)是一门将二维纸张通过折叠变换为三维造型的艺术。在计算几何领域,折纸问题涉及平面折叠序列规划与几何约束满足两大核心算法挑战:给定目标形状,如何确定折叠的顺序与角度?本文用Java实现折纸的平面几何仿真系统,讲解折痕直线计算、面片翻折变换以及刚性折叠的约束传播判定算法。

问题分析

折纸的数学模型

一张平面纸可建模为多边形网格,每条折痕(crease)将纸面划分为可相对转动的两个部分:
– 山折(Mountain):折痕凸起,两面夹角小于180°
– 谷折(Valley):折痕凹陷,两面夹角大于180°
– 面片(Facet):折痕之间的多边形区域,折叠过程中保持刚性不变形

刚性折叠约束

刚性折叠要求每个面片在折叠过程中保持平面且形状不变,只允许绕折痕旋转。这产生了一组角度约束方程:围绕每个顶点的所有扇形角之和必须满足特定的相容性条件。

核心算法一:折痕的直线方程与面片变换

给定折痕的两个端点,计算该直线将平面分割为两个半平面。折叠操作本质上是关于折痕直线的镜像反射:

P' = P - 2 * ((P - A) · n) * n

其中 n 为折痕直线的单位法向量,A 为直线上任一点。

核心算法二:Kawasaki-Justin定理与刚性折叠判定

Kawasaki-Justin定理给出了单个顶点可刚性折叠的充要条件:围绕顶点的交替扇形角之和等于 π。

α₁ + α₃ + α₅ + ... = α₂ + α₄ + α₆ + ... = π

本文实现该定理的判定算法,并基于约束传播思想验证整个折纸图案是否可刚性折叠。

完整Java实现

package origami.geometry;

import java.util.*;

/**
 * 折纸几何仿真系统
 * 
 * 核心功能:
 * 1. 折痕直线计算:两点确定直线,分割半平面
 * 2. 面片翻折变换:关于折痕直线的镜像反射
 * 3. Kawasaki-Justin定理:单顶点刚性折叠判定
 * 4. 约束传播:全局折叠序列验证
 */
public class OrigamiSimulator {

    // 精度阈值
    private static final double EPS = 1e-9;

    /**
     * 二维点/向量
     */
    public static class Point {
        public final double x, y;
        public Point(double x, double y) { this.x = x; this.y = y; }
        public Point add(Point p) { return new Point(x + p.x, y + p.y); }
        public Point sub(Point p) { return new Point(x - p.x, y - p.y); }
        public Point mul(double s) { return new Point(x * s, y * s); }
        public double dot(Point p) { return x * p.x + y * p.y; }
        public double cross(Point p) { return x * p.y - y * p.x; }
        public double length() { return Math.sqrt(x * x + y * y); }
        public Point normalize() {
            double len = length();
            if (len < EPS) return new Point(0, 0);
            return new Point(x / len, y / len);
        }
        @Override
        public String toString() { return String.format("(%.3f, %.3f)", x, y); }
    }

    /**
     * 折痕:由两点定义的直线段,附带折叠方向
     */
    public static class Crease {
        public final Point p1, p2;
        public final boolean mountain; // true=山折, false=谷折
        public final double foldAngle; // 折叠角度 (0 = 平铺, π = 完全对折)

        public Crease(Point p1, Point p2, boolean mountain, double foldAngle) {
            this.p1 = p1;
            this.p2 = p2;
            this.mountain = mountain;
            this.foldAngle = foldAngle;
        }

        /**
         * 获取折痕直线的单位方向向量
         */
        public Point direction() {
            return p2.sub(p1).normalize();
        }

        /**
         * 获取折痕直线的单位法向量(指向"右侧"半平面)
         */
        public Point normal() {
            Point dir = direction();
            return new Point(-dir.y, dir.x); // 逆时针旋转90°
        }

        /**
         * 判断点位于折痕的哪一侧
         * 返回值 > 0:右侧(法向量方向)
         * 返回值 < 0:左侧
         * 返回值 = 0:在直线上
         */
        public double sideOf(Point p) {
            Point n = p2.sub(p1);
            return n.cross(p.sub(p1));
        }
    }

    /**
     * 面片:由顶点序列定义的多边形
     */
    public static class Facet {
        public List<Point> vertices;
        public int id;

        public Facet(List<Point> vertices, int id) {
            this.vertices = new ArrayList<>(vertices);
            this.id = id;
        }

        /**
         * 计算多边形面积(有向面积,正值表示逆时针)
         */
        public double area() {
            double sum = 0;
            int n = vertices.size();
            for (int i = 0; i < n; i++) {
                Point a = vertices.get(i);
                Point b = vertices.get((i + 1) % n);
                sum += a.cross(b);
            }
            return sum / 2;
        }

        /**
         * 判断点是否在面片内部(射线法)
         */
        public boolean contains(Point p) {
            int n = vertices.size();
            boolean inside = false;
            for (int i = 0, j = n - 1; i < n; j = i++) {
                Point pi = vertices.get(i);
                Point pj = vertices.get(j);
                if (((pi.y > p.y) != (pj.y > p.y)) &&
                    (p.x < (pj.x - pi.x) * (p.y - pi.y) / (pj.y - pi.y) + pi.x)) {
                    inside = !inside;
                }
            }
            return inside;
        }
    }

    /**
     * 将点 P 关于由点 A 和方向 dir 定义的直线做镜像反射
     */
    public static Point reflect(Point P, Point A, Point dir) {
        Point AP = P.sub(A);
        // 投影长度
        double proj = AP.dot(dir);
        // 投影点
        Point projPoint = A.add(dir.mul(proj));
        // 反射点 = 投影点 - (P - 投影点) = 2*投影点 - P
        return projPoint.mul(2).sub(P);
    }

    /**
     * 将整个面片关于指定折痕做翻折变换
     * 只变换位于折痕"左侧"(side < 0)的点
     */
    public static Facet foldFacet(Facet facet, Crease crease) {
        Point dir = crease.direction();
        List<Point> newVertices = new ArrayList<>();
        for (Point v : facet.vertices) {
            if (crease.sideOf(v) < -EPS) {
                // 点位于折叠侧,执行镜像反射
                newVertices.add(reflect(v, crease.p1, dir));
            } else {
                // 点位于固定侧,保持不变
                newVertices.add(v);
            }
        }
        return new Facet(newVertices, facet.id);
    }

    /**
     * Kawasaki-Justin定理判定
     * 给定围绕一个顶点的所有扇形角(按顺序),判定该顶点是否可刚性折叠
     * 
     * 定理:交替扇形角之和相等,即 α₁+α₃+... = α₂+α₄+... = π
     * 
     * @param sectorAngles 围绕顶点的扇形角数组(弧度,按逆时针顺序)
     * @return true 如果满足Kawasaki-Justin条件
     */
    public static boolean kawasakiJustinCheck(double[] sectorAngles) {
        int n = sectorAngles.length;
        if (n % 2 != 0) return false; // 必须偶数个扇形

        double oddSum = 0, evenSum = 0;
        for (int i = 0; i < n; i++) {
            if (i % 2 == 0) oddSum += sectorAngles[i];
            else evenSum += sectorAngles[i];
        }

        return Math.abs(oddSum - Math.PI) < EPS && Math.abs(evenSum - Math.PI) < EPS;
    }

    /**
     * 计算围绕某顶点的扇形角
     * @param center 中心顶点
     * @param neighbors 相邻顶点(按逆时针顺序排列)
     * @return 扇形角数组
     */
    public static double[] computeSectorAngles(Point center, List<Point> neighbors) {
        int n = neighbors.size();
        double[] angles = new double[n];
        for (int i = 0; i < n; i++) {
            Point v1 = neighbors.get(i).sub(center);
            Point v2 = neighbors.get((i + 1) % n).sub(center);
            // 扇形角 = v1 到 v2 的夹角
            double dot = v1.dot(v2);
            double cross = v1.cross(v2);
            angles[i] = Math.atan2(cross, dot);
            if (angles[i] < 0) angles[i] += 2 * Math.PI;
        }
        return angles;
    }

    /**
     * 折纸图案类:管理面片和折痕的集合
     */
    public static class OrigamiPattern {
        public List<Facet> facets;
        public List<Crease> creases;

        public OrigamiPattern() {
            facets = new ArrayList<>();
            creases = new ArrayList<>();
        }

        /**
         * 执行一次折叠操作
         */
        public void applyFold(Crease crease) {
            List<Facet> newFacets = new ArrayList<>();
            for (Facet f : facets) {
                newFacets.add(foldFacet(f, crease));
            }
            facets = newFacets;
        }

        /**
         * 打印当前所有面片的状态
         */
        public void printState() {
            System.out.println("===== 当前折纸状态 =====");
            for (Facet f : facets) {
                System.out.print("面片 " + f.id + ": ");
                for (Point v : f.vertices) {
                    System.out.print(v + " ");
                }
                System.out.println();
            }
        }
    }

    /**
     * 创建一个简单的正方形折纸(单面片)
     */
    public static OrigamiPattern createSquare(double size) {
        OrigamiPattern pattern = new OrigamiPattern();
        List<Point> square = Arrays.asList(
            new Point(0, 0),
            new Point(size, 0),
            new Point(size, size),
            new Point(0, size)
        );
        pattern.facets.add(new Facet(square, 0));
        return pattern;
    }

    /**
     * 创建带有预设折痕的正方形折纸
     */
    public static OrigamiPattern createSquareWithDiagonal(double size) {
        OrigamiPattern pattern = createSquare(size);
        // 添加对角线折痕
        pattern.creases.add(new Crease(
            new Point(0, 0), new Point(size, size), true, Math.PI / 2));
        return pattern;
    }

    /**
     * 约束传播:验证整个折纸图案的所有顶点是否满足刚性折叠条件
     */
    public static boolean validateRigidFoldability(OrigamiPattern pattern) {
        // 提取所有顶点及其相邻面片
        Map<Point, List<Point>> vertexNeighbors = new HashMap<>();

        for (Facet f : pattern.facets) {
            int n = f.vertices.size();
            for (int i = 0; i < n; i++) {
                Point v = f.vertices.get(i);
                Point prev = f.vertices.get((i - 1 + n) % n);
                Point next = f.vertices.get((i + 1) % n);

                vertexNeighbors.computeIfAbsent(v, k -> new ArrayList<>());
                if (!vertexNeighbors.get(v).contains(prev)) vertexNeighbors.get(v).add(prev);
                if (!vertexNeighbors.get(v).contains(next)) vertexNeighbors.get(v).add(next);
            }
        }

        // 对每个内部顶点应用Kawasaki-Justin判定
        boolean valid = true;
        for (Map.Entry<Point, List<Point>> entry : vertexNeighbors.entrySet()) {
            List<Point> neighbors = entry.getValue();
            if (neighbors.size() >= 4) {
                double[] sectors = computeSectorAngles(entry.getKey(), neighbors);
                boolean kj = kawasakiJustinCheck(sectors);
                if (!kj) {
                    System.out.println("顶点 " + entry.getKey() + " 不满足Kawasaki-Justin条件");
                    valid = false;
                }
            }
        }
        return valid;
    }

    // ==================== 测试入口 ====================
    public static void main(String[] args) {
        System.out.println("===== 折纸几何仿真系统 =====\n");

        // 测试1:镜像反射
        System.out.println("【测试1】点关于直线的镜像反射");
        Point P = new Point(2, 3);
        Point A = new Point(0, 0);
        Point B = new Point(5, 5);
        Point reflected = reflect(P, A, B.sub(A).normalize());
        System.out.println("原点 P = " + P);
        System.out.println("关于直线 (0,0)-(5,5) 的镜像 = " + reflected);
        System.out.println();

        // 测试2:Kawasaki-Justin定理
        System.out.println("【测试2】Kawasaki-Justin刚性折叠判定");
        // 标准水 bomb 底的扇形角:4个90°扇形
        double[] waterBombAngles = {Math.PI/2, Math.PI/2, Math.PI/2, Math.PI/2};
        System.out.println("水炸弹底 (4×90°): " + kawasakiJustinCheck(waterBombAngles));

        // 不满足条件的角
        double[] badAngles = {Math.PI/3, Math.PI/3, Math.PI/3, Math.PI/2};
        System.out.println("不规则角 (60°,60°,60°,90°): " + kawasakiJustinCheck(badAngles));
        System.out.println();

        // 测试3:正方形对角线折叠
        System.out.println("【测试3】正方形对角线折叠");
        OrigamiPattern pattern = createSquareWithDiagonal(10);
        System.out.println("折叠前:");
        pattern.printState();

        Crease diagonal = pattern.creases.get(0);
        pattern.applyFold(diagonal);

        System.out.println("\n沿对角线折叠后:");
        pattern.printState();
        System.out.println();

        // 测试4:约束传播验证
        System.out.println("【测试4】刚性折叠约束验证");
        OrigamiPattern simple = createSquare(10);
        boolean valid = validateRigidFoldability(simple);
        System.out.println("单面片正方形是否可刚性折叠: " + valid);
    }
}

复杂度分析

指标 复杂度 说明
点镜像反射 O(1) 固定次数的向量运算
面片折叠 O(v) v为面片顶点数
Kawasaki判定 O(k) k为顶点扇形数
全局约束验证 O(V × k log k) V为顶点数,需排序邻居
空间复杂度 O(F × v) F为面片数

扩展与优化

  1. 树状折叠序列:将折叠顺序建模为二叉树,每个内部节点代表一次折叠操作
  2. Maekawa-Justin定理:额外验证每个顶点的山折/谷折数量差为 ±2
  3. 三维展开:将折叠后的二维面片映射到三维空间,计算各面片的法向量
  4. 自动折痕生成:给定目标3D形状,通过优化算法反推折痕图案
  5. 数值稳定性:对于接近共线的点,使用高精度浮点或符号计算

总结

本文用Java实现了折纸的平面几何仿真系统,将计算几何中的镜像变换与折纸数学中的Kawasaki-Justin定理进行了有机结合。通过将折纸问题抽象为面片集合的刚性变换,我们不仅实现了基础的折叠模拟,还建立了可折叠性的形式化判定框架。这种”几何建模 + 约束验证”的方法论,在计算机辅助设计(CAD)、可展开结构和折叠机器人等领域都有重要应用。