
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
🔺 一、德劳内三角剖分:数学之美
📚 1.1 三角剖分基础
三角剖分是将平面点集连接成不重叠三角形集合的过程。德劳内三角剖分是最优三角剖分方法之一,由俄国数学家鲍里斯·德劳内(Boris Delaunay)于1934年提出。
三角剖分定义:
给定平面点集 P = {p₁, p₂, ..., pₙ}
三角剖分 T 是满足以下条件的三角形集合:
1. 三角形顶点都是 P 中的点
2. 任意两个三角形内部不相交
3. 所有三角形的并集是点集的凸包
三角剖分数量:
- n 个点的凸多边形有 C_{n-2} 种三角剖分
- C_{n-2} 是卡特兰数
三角剖分示意:
点集: 一种三角剖分: 另一种三角剖分:
● ● ●───● ● ●
● │╲ ╱│ ╲ ╱
● ● ●─●─● ●───●
│╱ ╲│ ╱╲
●───● ●───●
同一点集可以有多种三角剖分方式
德劳内三角剖分是最优的一种
📐 1.2 德劳内条件
德劳内三角剖分的核心是空圆性质(Empty Circle Property)。
空圆性质:
对于三角剖分中的任意三角形:
其外接圆内部不包含任何其他点
形式化:
对于三角形 T = (pᵢ, pⱼ, pₖ)
设 C(T) 为其外接圆
则对于所有 pₗ ∈ P \ {pᵢ, pⱼ, pₖ}
pₗ 不在 C(T) 内部
外接圆示意:
德劳内三角形: 非德劳内三角形:
● ●
╱ ╲ ╱ ╲
╱ ╲ ╱ ● ╲ ← 点在圆内
╱ ╲ ╱ ╲
●───────● ●───────●
└───────┘ └───────┘
外接圆 外接圆
(空,无点) (不空,有点)
德劳内三角形的外接圆是"空"的
等价条件:
以下条件等价:
1. 空圆性质
每个三角形的外接圆不含其他点
2. 最大化最小角
在所有可能的三角剖分中
德劳内三角剖分的最小角最大
3. 边翻转最优
对于任意四边形,对角线选择使最小角最大
4. Voronoi 对偶
德劳内三角剖分是 Voronoi 图的对偶图
🔬 1.3 Voronoi 图与对偶性
Voronoi 图是德劳内三角剖分的对偶结构,两者密切相关。
Voronoi 图定义:
对于点集 P = {p₁, p₂, ..., pₙ}
点 pᵢ 的 Voronoi 区域:
V(pᵢ) = {x ∈ ℝ² : d(x, pᵢ) ≤ d(x, pⱼ), ∀j ≠ i}
Voronoi 图是所有 Voronoi 区域的边界
性质:
- 每个 Voronoi 区域是凸多边形
- Voronoi 边是两点连线的垂直平分线
- Voronoi 顶点是三个或更多区域的交点
对偶关系:
Voronoi 图 ←→ 德劳内三角剖分
对应关系:
- Voronoi 区域 ←→ 德劳内顶点
- Voronoi 边 ←→ 德劳内边
- Voronoi 顶点 ←→ 德劳内三角形
示意:
Voronoi 图: 德劳内三角剖分:
┌───┬───┐ ●───●
│ ● │ ● │ │╲ ╱│
├───┼───┤ 对偶 ●─●─●
│ ● │ ● │ ────→ │╱ ╲│
└───┴───┘ ●───●
🎯 1.4 德劳内三角剖分的性质
几何性质:
| 性质 | 描述 |
|---|---|
| 空圆性 | 外接圆不含其他点 |
| 最大化最小角 | 最小角最大 |
| 唯一性 | 无四点共圆时唯一 |
| 局部性 | 局部修改不影响全局 |
| 凸包性 | 边界是点集凸包 |
最优性证明:
最大化最小角定理:
在所有可能的三角剖分中
德劳内三角剖分的最小角最大
证明思路:
1. 任何非德劳内边都可以翻转
2. 边翻转会增加最小角
3. 最终达到德劳内条件时最小角最大
边翻转操作:
●───● ●───●
╲ ╱ ╱ ╲
● → ●
╱ ╲ ╲ ╱
●───● ●───●
翻转对角线,使最小角增大
🔧 二、德劳内三角剖分的 Dart 实现
🧮 2.1 基础数据结构
dart
import 'dart:math';
/// 二维点
class Point2D {
final double x;
final double y;
const Point2D(this.x, this.y);
double distanceTo(Point2D other) {
return sqrt((x - other.x) * (x - other.x) + (y - other.y) * (y - other.y));
}
double squaredDistanceTo(Point2D other) {
return (x - other.x) * (x - other.x) + (y - other.y) * (y - other.y);
}
Offset toOffset() => Offset(x, y);
@override
bool operator ==(Object other) =>
other is Point2D && x == other.x && y == other.y;
@override
int get hashCode => Object.hash(x, y);
@override
String toString() => 'Point2D($x, $y)';
}
/// 边
class Edge {
final Point2D p1;
final Point2D p2;
const Edge(this.p1, this.p2);
double get length => p1.distanceTo(p2);
Point2D get midpoint => Point2D((p1.x + p2.x) / 2, (p1.y + p2.y) / 2);
bool contains(Point2D p) => p == p1 || p == p2;
bool sharesVertexWith(Edge other) =>
p1 == other.p1 || p1 == other.p2 || p2 == other.p1 || p2 == other.p2;
@override
bool operator ==(Object other) =>
other is Edge && ((p1 == other.p1 && p2 == other.p2) || (p1 == other.p2 && p2 == other.p1));
@override
int get hashCode => Object.hash(p1, p2) ^ Object.hash(p2, p1);
}
/// 三角形
class Triangle {
final Point2D p1;
final Point2D p2;
final Point2D p3;
const Triangle(this.p1, this.p2, this.p3);
List<Point2D> get vertices => [p1, p2, p3];
List<Edge> get edges => [Edge(p1, p2), Edge(p2, p3), Edge(p3, p1)];
bool containsVertex(Point2D p) => p == p1 || p == p2 || p == p3;
bool containsEdge(Edge e) => edges.contains(e);
Point2D get circumcenter {
final ax = p1.x, ay = p1.y;
final bx = p2.x, by = p2.y;
final cx = p3.x, cy = p3.y;
final d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
if (d.abs() < 1e-10) {
return Point2D(double.infinity, double.infinity);
}
final ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d;
final uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d;
return Point2D(ux, uy);
}
double get circumradius {
final center = circumcenter;
return center.distanceTo(p1);
}
bool isPointInCircumcircle(Point2D p) {
final center = circumcenter;
final radius = circumradius;
return center.distanceTo(p) < radius - 1e-10;
}
bool isPointInside(Point2D p) {
final v0 = p3.x - p1.x, v1 = p3.y - p1.y;
final v2 = p.x - p1.x, v3 = p.y - p1.y;
final v4 = p2.x - p1.x, v5 = p2.y - p1.y;
final denom = v4 * v1 - v5 * v0;
if (denom.abs() < 1e-10) return false;
final a = (v2 * v1 - v3 * v0) / denom;
final b = (v4 * v3 - v5 * v2) / denom;
return a >= 0 && b >= 0 && a + b <= 1;
}
double get area {
return ((p2.x - p1.x) * (p3.y - p1.y) - (p3.x - p1.x) * (p2.y - p1.y)).abs() / 2;
}
double get minAngle {
final a = p2.distanceTo(p3);
final b = p1.distanceTo(p3);
final c = p1.distanceTo(p2);
final angleA = acos((b * b + c * c - a * a) / (2 * b * c));
final angleB = acos((a * a + c * c - b * b) / (2 * a * c));
final angleC = acos((a * a + b * b - c * c) / (2 * a * b));
return [angleA, angleB, angleC].reduce(min);
}
}
⚡ 2.2 Bowyer-Watson 算法
dart
/// 德劳内三角剖分器
class DelaunayTriangulation {
final List<Point2D> points;
List<Triangle> triangles = [];
DelaunayTriangulation(this.points);
void compute() {
if (points.length < 3) return;
double minX = points[0].x, maxX = points[0].x;
double minY = points[0].y, maxY = points[0].y;
for (final p in points) {
if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x;
if (p.y < minY) minY = p.y;
if (p.y > maxY) maxY = p.y;
}
final dx = maxX - minX;
final dy = maxY - minY;
final deltaMax = max(dx, dy) * 2;
final midX = (minX + maxX) / 2;
final midY = (minY + maxY) / 2;
final p1 = Point2D(midX - deltaMax, midY - deltaMax);
final p2 = Point2D(midX, midY + deltaMax * 2);
final p3 = Point2D(midX + deltaMax * 2, midY - deltaMax);
final superTriangle = Triangle(p1, p2, p3);
triangles = [superTriangle];
for (final point in points) {
_addPoint(point);
}
triangles = triangles.where((t) =>
!t.containsVertex(p1) && !t.containsVertex(p2) && !t.containsVertex(p3)
).toList();
}
void _addPoint(Point2D point) {
final badTriangles = <Triangle>[];
for (final triangle in triangles) {
if (triangle.isPointInCircumcircle(point)) {
badTriangles.add(triangle);
}
}
final polygon = <Edge>[];
for (final triangle in badTriangles) {
for (final edge in triangle.edges) {
bool isShared = false;
for (final other in badTriangles) {
if (triangle != other && other.containsEdge(edge)) {
isShared = true;
break;
}
}
if (!isShared) {
polygon.add(edge);
}
}
}
triangles.removeWhere((t) => badTriangles.contains(t));
for (final edge in polygon) {
final newTriangle = Triangle(edge.p1, edge.p2, point);
triangles.add(newTriangle);
}
}
List<Edge> getEdges() {
final edges = <Edge>{};
for (final triangle in triangles) {
edges.addAll(triangle.edges);
}
return edges.toList();
}
double get minAngle {
if (triangles.isEmpty) return 0;
return triangles.map((t) => t.minAngle).reduce(min);
}
double get totalArea {
return triangles.fold(0.0, (sum, t) => sum + t.area);
}
}
🎨 2.3 Voronoi 图生成
dart
/// Voronoi 图生成器
class VoronoiDiagram {
final List<Point2D> points;
final DelaunayTriangulation delaunay;
List<Point2D> vertices = [];
List<VoronoiEdge> edges = [];
VoronoiDiagram(this.points) : delaunay = DelaunayTriangulation(points);
void compute() {
delaunay.compute();
final triangleToVertex = <Triangle, Point2D>{};
for (final triangle in delaunay.triangles) {
final center = triangle.circumcenter;
triangleToVertex[triangle] = center;
if (!vertices.contains(center)) {
vertices.add(center);
}
}
for (final triangle in delaunay.triangles) {
final center = triangleToVertex[triangle]!;
for (final edge in triangle.edges) {
final neighbor = _findNeighborTriangle(triangle, edge);
if (neighbor != null) {
final neighborCenter = triangleToVertex[neighbor]!;
final voronoiEdge = VoronoiEdge(center, neighborCenter, edge);
if (!edges.any((e) => e == voronoiEdge)) {
edges.add(voronoiEdge);
}
}
}
}
}
Triangle? _findNeighborTriangle(Triangle triangle, Edge edge) {
for (final other in delaunay.triangles) {
if (other != triangle && other.containsEdge(edge)) {
return other;
}
}
return null;
}
List<Point2D> getCell(Point2D point) {
final cellVertices = <Point2D>[];
for (final triangle in delaunay.triangles) {
if (triangle.containsVertex(point)) {
cellVertices.add(triangle.circumcenter);
}
}
return _sortVerticesClockwise(cellVertices);
}
List<Point2D> _sortVerticesClockwise(List<Point2D> vertices) {
if (vertices.length < 3) return vertices;
double cx = 0, cy = 0;
for (final v in vertices) {
cx += v.x;
cy += v.y;
}
cx /= vertices.length;
cy /= vertices.length;
vertices.sort((a, b) {
final angleA = atan2(a.y - cy, a.x - cx);
final angleB = atan2(b.y - cy, b.x - cx);
return angleA.compareTo(angleB);
});
return vertices;
}
}
class VoronoiEdge {
final Point2D start;
final Point2D end;
final Edge delaunayEdge;
VoronoiEdge(this.start, this.end, this.delaunayEdge);
@override
bool operator ==(Object other) =>
other is VoronoiEdge && start == other.start && end == other.end;
@override
int get hashCode => Object.hash(start, end);
}
📦 三、完整示例代码
以下是完整的德劳内三角剖分可视化示例代码:
dart
import 'package:flutter/material.dart';
import 'dart:math';
void main() {
runApp(const DelaunayApp());
}
class DelaunayApp extends StatelessWidget {
const DelaunayApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '德劳内三角剖分',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo, brightness: Brightness.dark),
useMaterial3: true,
),
home: const DelaunayHomePage(),
debugShowCheckedModeBanner: false,
);
}
}
class DelaunayHomePage extends StatelessWidget {
const DelaunayHomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('🔺 德劳内三角剖分'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildCard(context, title: '交互式剖分', description: '点击添加点,实时计算', icon: Icons.touch_app, color: Colors.indigo,
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const InteractiveDemo()))),
_buildCard(context, title: '随机点集', description: '生成随机点并剖分', icon: Icons.shuffle, color: Colors.purple,
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const RandomPointsDemo()))),
_buildCard(context, title: 'Voronoi 图', description: '德劳内的对偶图', icon: Icons.grid_on, color: Colors.teal,
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const VoronoiDemo()))),
_buildCard(context, title: '动画演示', description: '算法执行过程', icon: Icons.animation, color: Colors.orange,
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const AnimationDemo()))),
],
),
);
}
Widget _buildCard(BuildContext context, {required String title, required String description, required IconData icon, required Color color, required VoidCallback onTap}) {
return Card(
margin: const EdgeInsets.only(bottom: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
Container(width: 56, height: 56, decoration: BoxDecoration(color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(12)),
child: Icon(icon, color: color, size: 28)),
const SizedBox(width: 16),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(description, style: TextStyle(color: Colors.grey[600], fontSize: 14)),
])),
Icon(Icons.chevron_right, color: Colors.grey[400]),
]),
),
),
);
}
}
/// 点类
class P {
final double x, y;
const P(this.x, this.y);
double dist(P o) => sqrt((x - o.x) * (x - o.x) + (y - o.y) * (y - o.y));
@override bool operator ==(Object o) => o is P && x == o.x && y == o.y;
@override int get hashCode => Object.hash(x, y);
}
/// 三角形类
class Tri {
final P a, b, c;
Tri(this.a, this.b, this.c);
List<P> get verts => [a, b, c];
P circumcenter() {
final d = 2 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y));
if (d.abs() < 1e-10) return P(double.infinity, double.infinity);
final ux = ((a.x*a.x + a.y*a.y) * (b.y - c.y) + (b.x*b.x + b.y*b.y) * (c.y - a.y) + (c.x*c.x + c.y*c.y) * (a.y - b.y)) / d;
final uy = ((a.x*a.x + a.y*a.y) * (c.x - b.x) + (b.x*b.x + b.y*b.y) * (a.x - c.x) + (c.x*c.x + c.y*c.y) * (b.x - a.x)) / d;
return P(ux, uy);
}
double circumradius() => circumcenter().dist(a);
bool inCircumcircle(P p) => circumcenter().dist(p) < circumradius() - 1e-10;
bool hasVert(P p) => a == p || b == p || c == p;
double minAngle() {
final ab = a.dist(b), bc = b.dist(c), ca = c.dist(a);
return [acos((ab*ab + ca*ca - bc*bc)/(2*ab*ca)), acos((ab*ab + bc*bc - ca*ca)/(2*ab*bc)), acos((bc*bc + ca*ca - ab*ab)/(2*bc*ca))].reduce(min);
}
}
/// 德劳内剖分
List<Tri> delaunay(List<P> pts) {
if (pts.length < 3) return [];
double minX = pts[0].x, maxX = pts[0].x, minY = pts[0].y, maxY = pts[0].y;
for (final p in pts) {
if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x;
if (p.y < minY) minY = p.y;
if (p.y > maxY) maxY = p.y;
}
final dx = maxX - minX, dy = maxY - minY;
final dm = max(dx, dy) * 3;
final mx = (minX + maxX) / 2, my = (minY + maxY) / 2;
final sp1 = P(mx - dm, my - dm), sp2 = P(mx, my + dm), sp3 = P(mx + dm, my - dm);
var tris = [Tri(sp1, sp2, sp3)];
for (final pt in pts) {
final bad = <Tri>[];
for (final t in tris) { if (t.inCircumcircle(pt)) bad.add(t); }
final poly = <(P, P)>[];
for (final t in bad) {
for (final e in [(t.a, t.b), (t.b, t.c), (t.c, t.a)]) {
var shared = false;
for (final o in bad) {
if (t != o && ((o.a == e.$1 && o.b == e.$2) || (o.a == e.$2 && o.b == e.$1) ||
(o.b == e.$1 && o.c == e.$2) || (o.b == e.$2 && o.c == e.$1) ||
(o.c == e.$1 && o.a == e.$2) || (o.c == e.$2 && o.a == e.$1))) {
shared = true; break;
}
}
if (!shared) poly.add(e);
}
}
tris.removeWhere((t) => bad.contains(t));
for (final e in poly) { tris.add(Tri(e.$1, e.$2, pt)); }
}
return tris.where((t) => !t.hasVert(sp1) && !t.hasVert(sp2) && !t.hasVert(sp3)).toList();
}
/// 交互式演示
class InteractiveDemo extends StatefulWidget {
const InteractiveDemo({super.key});
@override
State<InteractiveDemo> createState() => _InteractiveDemoState();
}
class _InteractiveDemoState extends State<InteractiveDemo> {
final List<P> _points = [];
List<Tri> _tris = [];
bool _showCircum = false;
void _addPoint(Offset pos) {
setState(() {
_points.add(P(pos.dx, pos.dy));
_tris = delaunay(_points);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('交互式剖分'), actions: [
IconButton(icon: const Icon(Icons.refresh), onPressed: () => setState(() { _points.clear(); _tris.clear(); })),
IconButton(icon: Icon(_showCircum ? Icons.circle : Icons.circle_outlined), onPressed: () => setState(() => _showCircum = !_showCircum)),
]),
body: GestureDetector(
onTapDown: (d) => _addPoint(d.localPosition),
child: CustomPaint(painter: DelaunayPainter(_points, _tris, _showCircum), size: Size.infinite),
),
bottomNavigationBar: Container(
padding: const EdgeInsets.all(12),
color: Colors.grey[900],
child: Text('点数: ${_points.length} 三角形: ${_tris.length} 最小角: ${_tris.isEmpty ? 0 : _tris.map((t) => t.minAngle() * 180 / pi).reduce(min).toStringAsFixed(1)}°',
style: const TextStyle(color: Colors.white70), textAlign: TextAlign.center),
),
);
}
}
class DelaunayPainter extends CustomPainter {
final List<P> pts;
final List<Tri> tris;
final bool showCircum;
DelaunayPainter(this.pts, this.tris, this.showCircum);
@override
void paint(Canvas canvas, Size size) {
canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), Paint()..color = const Color(0xFF0a0a15));
if (showCircum) {
for (final t in tris) {
final c = t.circumcenter();
final r = t.circumradius();
canvas.drawCircle(Offset(c.x, c.y), r, Paint()..color = Colors.indigo.withOpacity(0.1)..style = PaintingStyle.fill);
canvas.drawCircle(Offset(c.x, c.y), r, Paint()..color = Colors.indigo.withOpacity(0.3)..style = PaintingStyle.stroke);
canvas.drawCircle(Offset(c.x, c.y), 3, Paint()..color = Colors.indigo);
}
}
for (final t in tris) {
final path = Path()..moveTo(t.a.x, t.a.y)..lineTo(t.b.x, t.b.y)..lineTo(t.c.x, t.c.y)..close();
final hue = (t.minAngle() * 180 / pi) * 3;
canvas.drawPath(path, Paint()..color = HSVColor.fromAHSV(0.3, hue, 0.6, 0.8).toColor()..style = PaintingStyle.fill);
canvas.drawPath(path, Paint()..color = Colors.white.withOpacity(0.5)..style = PaintingStyle.stroke..strokeWidth = 1);
}
for (final p in pts) {
canvas.drawCircle(Offset(p.x, p.y), 5, Paint()..color = Colors.white);
canvas.drawCircle(Offset(p.x, p.y), 3, Paint()..color = Colors.indigo);
}
}
@override
bool shouldRepaint(covariant DelaunayPainter old) => true;
}
/// 随机点集演示
class RandomPointsDemo extends StatefulWidget {
const RandomPointsDemo({super.key});
@override
State<RandomPointsDemo> createState() => _RandomPointsDemoState();
}
class _RandomPointsDemoState extends State<RandomPointsDemo> {
List<P> _pts = [];
List<Tri> _tris = [];
int _count = 50;
@override
void initState() {
super.initState();
_generate();
}
void _generate() {
final r = Random();
_pts = List.generate(_count, (_) => P(r.nextDouble() * 300 + 50, r.nextDouble() * 400 + 100));
_tris = delaunay(_pts);
setState(() {});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('随机点集')),
body: Column(children: [
Expanded(child: CustomPaint(painter: DelaunayPainter(_pts, _tris, false), size: Size.infinite)),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.grey[900], borderRadius: const BorderRadius.vertical(top: Radius.circular(20))),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Row(children: [
const Text('点数: ', style: TextStyle(color: Colors.white70)),
Expanded(child: Slider(value: _count.toDouble(), min: 10, max: 200, onChanged: (v) => setState(() => _count = v.toInt()))),
Text('$_count', style: const TextStyle(color: Colors.purple)),
]),
ElevatedButton(onPressed: _generate, child: const Text('生成新点集')),
]),
),
]),
);
}
}
/// Voronoi 图演示
class VoronoiDemo extends StatefulWidget {
const VoronoiDemo({super.key});
@override
State<VoronoiDemo> createState() => _VoronoiDemoState();
}
class _VoronoiDemoState extends State<VoronoiDemo> {
final List<P> _pts = [];
List<Tri> _tris = [];
bool _showDelaunay = true;
void _addPoint(Offset pos) {
setState(() {
_pts.add(P(pos.dx, pos.dy));
_tris = delaunay(_pts);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Voronoi 图'), actions: [
IconButton(icon: const Icon(Icons.refresh), onPressed: () => setState(() { _pts.clear(); _tris.clear(); })),
IconButton(icon: Icon(_showDelaunay ? Icons.grid_on : Icons.grid_off), onPressed: () => setState(() => _showDelaunay = !_showDelaunay)),
]),
body: GestureDetector(
onTapDown: (d) => _addPoint(d.localPosition),
child: CustomPaint(painter: VoronoiPainter(_pts, _tris, _showDelaunay), size: Size.infinite),
),
);
}
}
class VoronoiPainter extends CustomPainter {
final List<P> pts;
final List<Tri> tris;
final bool showDelaunay;
VoronoiPainter(this.pts, this.tris, this.showDelaunay);
@override
void paint(Canvas canvas, Size size) {
canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), Paint()..color = const Color(0xFF0a0a15));
final colors = [Colors.red, Colors.green, Colors.blue, Colors.yellow, Colors.purple, Colors.cyan, Colors.orange, Colors.pink];
for (int i = 0; i < pts.length; i++) {
final p = pts[i];
final cellTris = tris.where((t) => t.hasVert(p)).toList();
if (cellTris.isEmpty) continue;
final centers = cellTris.map((t) => t.circumcenter()).toList();
centers.sort((a, b) => atan2(a.y - p.y, a.x - p.x).compareTo(atan2(b.y - p.y, b.x - p.x)));
final path = Path();
for (int j = 0; j < centers.length; j++) {
if (j == 0) path.moveTo(centers[j].x, centers[j].y);
else path.lineTo(centers[j].x, centers[j].y);
}
path.close();
canvas.drawPath(path, Paint()..color = colors[i % colors.length].withOpacity(0.3)..style = PaintingStyle.fill);
}
if (showDelaunay) {
for (final t in tris) {
final path = Path()..moveTo(t.a.x, t.a.y)..lineTo(t.b.x, t.b.y)..lineTo(t.c.x, t.c.y)..close();
canvas.drawPath(path, Paint()..color = Colors.white24..style = PaintingStyle.stroke..strokeWidth = 1);
}
}
for (final p in pts) {
canvas.drawCircle(Offset(p.x, p.y), 4, Paint()..color = Colors.white);
}
}
@override
bool shouldRepaint(covariant VoronoiPainter old) => true;
}
/// 动画演示
class AnimationDemo extends StatefulWidget {
const AnimationDemo({super.key});
@override
State<AnimationDemo> createState() => _AnimationDemoState();
}
class _AnimationDemoState extends State<AnimationDemo> with SingleTickerProviderStateMixin {
late AnimationController _ctrl;
List<P> _pts = [];
List<Tri> _tris = [];
int _step = 0;
double _time = 0;
@override
void initState() {
super.initState();
_generatePoints();
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 16))..repeat();
_ctrl.addListener(_update);
}
void _generatePoints() {
final r = Random();
_pts = List.generate(30, (_) => P(r.nextDouble() * 300 + 50, r.nextDouble() * 400 + 100));
}
void _update() {
_time += 0.016;
if (_time > 0.5) {
_time = 0;
_step++;
if (_step > _pts.length) { _step = 0; _generatePoints(); _tris.clear(); }
else { _tris = delaunay(_pts.sublist(0, _step)); }
setState(() {});
}
}
@override
void dispose() { _ctrl.removeListener(_update); _ctrl.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('动画演示')),
body: Column(children: [
Expanded(child: CustomPaint(painter: AnimationPainter(_pts, _tris, _step), size: Size.infinite)),
Padding(
padding: const EdgeInsets.all(16),
child: Text('添加点: $_step / ${_pts.length}', style: const TextStyle(color: Colors.white70)),
),
]),
);
}
}
class AnimationPainter extends CustomPainter {
final List<P> pts;
final List<Tri> tris;
final int step;
AnimationPainter(this.pts, this.tris, this.step);
@override
void paint(Canvas canvas, Size size) {
canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), Paint()..color = const Color(0xFF0a0a15));
for (final t in tris) {
final path = Path()..moveTo(t.a.x, t.a.y)..lineTo(t.b.x, t.b.y)..lineTo(t.c.x, t.c.y)..close();
canvas.drawPath(path, Paint()..color = Colors.orange.withOpacity(0.2)..style = PaintingStyle.fill);
canvas.drawPath(path, Paint()..color = Colors.orange..style = PaintingStyle.stroke..strokeWidth = 1);
}
for (int i = 0; i < pts.length; i++) {
final p = pts[i];
canvas.drawCircle(Offset(p.x, p.y), i < step ? 5 : 3, Paint()..color = i < step ? Colors.white : Colors.white30);
}
}
@override
bool shouldRepaint(covariant AnimationPainter old) => true;
}
📝 四、数学原理深入解析
📐 4.1 外接圆计算
外接圆公式推导:
给定三角形三点 A(x₁,y₁), B(x₂,y₂), C(x₃,y₃)
外接圆圆心 O(x₀,y₀) 满足:
|AO| = |BO| = |CO|
展开:
(x₀-x₁)² + (y₀-y₁)² = (x₀-x₂)² + (y₀-y₂)²
(x₀-x₁)² + (y₀-y₁)² = (x₀-x₃)² + (y₀-y₃)²
简化得:
2(x₂-x₁)x₀ + 2(y₂-y₁)y₀ = x₂²+y₂²-x₁²-y₁²
2(x₃-x₁)x₀ + 2(y₃-y₁)y₀ = x₃²+y₃²-x₁²-y₁²
用行列式求解:
D = 2(x₁(y₂-y₃) + x₂(y₃-y₁) + x₃(y₁-y₂))
x₀ = ((x₁²+y₁²)(y₂-y₃) + (x₂²+y₂²)(y₃-y₁) + (x₃²+y₃²)(y₁-y₂)) / D
y₀ = ((x₁²+y₁²)(x₃-x₂) + (x₂²+y₂²)(x₁-x₃) + (x₃²+y₃²)(x₂-x₁)) / D
半径:
R = √((x₀-x₁)² + (y₀-y₁)²)
🔄 4.2 边翻转操作
边翻转条件:
对于四边形 ABCD,对角线 AC 和 BD
如果点 D 在三角形 ABC 的外接圆内
则应该翻转边 AC 为 BD
判断条件:
θ₁ + θ₃ > π 时需要翻转
其中:
θ₁ = ∠ABC
θ₃ = ∠ADC
边翻转示意:
A───B A───B
╲ ╱ ╱ ╲
C → C
╱ ╲ ╲ ╱
D───? D───?
翻转后最小角增大
翻转算法:
function flipEdge(quad):
if not isDelaunay(quad):
remove diagonal AC
add diagonal BD
return true
return false
function isDelaunay(quad):
// 检查点 D 是否在三角形 ABC 的外接圆内
return not inCircumcircle(D, A, B, C)
🌸 4.3 复杂度分析
Bowyer-Watson 算法复杂度:
时间复杂度:
- 最坏情况:O(n²)
- 平均情况:O(n log n)
- 随机点集:期望 O(n log n)
空间复杂度:O(n)
优化策略:
1. 空间索引加速点查找
2. 增量插入顺序优化
3. 并行化处理
其他算法比较:
| 算法 | 时间复杂度 | 特点 |
|---|---|---|
| Bowyer-Watson | O(n log n) | 增量式,易实现 |
| 分治法 | O(n log n) | 递归,高效 |
| 平面扫描 | O(n log n) | 顺序处理 |
| 随机增量 | O(n log n) | 期望复杂度 |
🎯 4.4 应用领域
计算机图形学:
- 地形渲染:TIN(不规则三角网)
- 纹理映射:参数化
- 网格简化:LOD 生成
- 表面重建:点云处理
地理信息系统:
- DEM 生成:数字高程模型
- 等高线绘制:插值计算
- 空间分析:邻近查询
- 地形分析:坡度计算
科学计算:
- 有限元分析:网格生成
- 流体模拟:网格划分
- 气象预报:数据插值
- 地震分析:波传播模拟
🔬 五、高级应用场景
🎨 5.1 地形生成
基于德劳内的地形网格:
dart
class TerrainMesh {
final List<Point2D> points;
final List<double> heights;
late List<Tri> triangles;
TerrainMesh(this.points, this.heights) {
triangles = delaunay(points);
}
double getHeightAt(double x, double y) {
for (final t in triangles) {
if (_pointInTriangle(x, y, t)) {
return _interpolateHeight(x, y, t);
}
}
return 0;
}
bool _pointInTriangle(double x, double y, Tri t) {
// 重心坐标判断
return true;
}
double _interpolateHeight(double x, double y, Tri t) {
// 三角形内插值
return 0;
}
}
🌐 5.2 网格简化
边收缩简化:
dart
class MeshSimplification {
List<Tri> simplify(List<Tri> triangles, int targetCount) {
var current = List<Tri>.from(triangles);
while (current.length > targetCount) {
final edge = _findBestEdgeToCollapse(current);
current = _collapseEdge(current, edge);
}
return current;
}
(P, P)? _findBestEdgeToCollapse(List<Tri> tris) {
double minCost = double.infinity;
(P, P)? best;
for (final t in tris) {
for (final edge in [(t.a, t.b), (t.b, t.c), (t.c, t.a)]) {
final cost = _collapseCost(tris, edge);
if (cost < minCost) {
minCost = cost;
best = edge;
}
}
}
return best;
}
double _collapseCost(List<Tri> tris, (P, P) edge) {
// 计算边收缩的代价
return edge.$1.dist(edge.$2);
}
List<Tri> _collapseEdge(List<Tri> tris, (P, P) edge) {
// 执行边收缩
return tris;
}
}
📱 5.3 鸿蒙多端适配
性能优化配置:
dart
class DelaunayConfig {
static int getMaxPointsForDevice(BuildContext context) {
final performance = DevicePerformance.getLevel();
switch (performance) {
case PerformanceLevel.high: return 1000;
case PerformanceLevel.medium: return 500;
case PerformanceLevel.low: return 200;
}
}
static bool shouldUseOptimizedAlgorithm(BuildContext context) {
return DevicePerformance.getLevel() != PerformanceLevel.high;
}
}
📊 六、性能优化策略
⚡ 6.1 空间索引
网格索引加速:
dart
class SpatialGrid {
final double cellSize;
final Map<(int, int), List<P>> grid = {};
SpatialGrid(this.cellSize);
void insert(P p) {
final key = (p.x ~/ cellSize, p.y ~/ cellSize);
grid.putIfAbsent(key, () => []).add(p);
}
List<P> query(P center, double radius) {
final result = <P>[];
final r = radius ~/ cellSize + 1;
final cx = center.x ~/ cellSize;
final cy = center.y ~/ cellSize;
for (int i = -r; i <= r; i++) {
for (int j = -r; j <= r; j++) {
final key = (cx + i, cy + j);
if (grid.containsKey(key)) {
for (final p in grid[key]!) {
if (p.dist(center) <= radius) {
result.add(p);
}
}
}
}
}
return result;
}
}
💾 6.2 增量更新
局部更新优化:
dart
class IncrementalDelaunay {
List<P> points = [];
List<Tri> triangles = [];
void addPoint(P p) {
points.add(p);
// 只更新受影响的三角形
final affected = triangles.where((t) => t.inCircumcircle(p)).toList();
// 局部重剖分
for (final t in affected) {
triangles.remove(t);
}
// 添加新三角形
// ...
}
void removePoint(P p) {
points.remove(p);
// 找到包含该点的所有三角形
final affected = triangles.where((t) => t.hasVert(p)).toList();
// 移除并重剖分
triangles.removeWhere((t) => t.hasVert(p));
// 重新剖分空洞
// ...
}
}
🎓 七、学习资源与拓展
📚 推荐阅读
| 主题 | 资源 | 难度 |
|---|---|---|
| 计算几何 | 《计算几何:算法与应用》 | ⭐⭐⭐ |
| 三角剖分 | 《Triangulations and Applications》 | ⭐⭐⭐ |
| Voronoi 图 | 《Voronoi Diagrams and Delaunay Triangulations》 | ⭐⭐ |
| 网格生成 | 《Mesh Generation》 | ⭐⭐⭐ |
🔗 相关项目
- CGAL:计算几何算法库
- Triangle:二维网格生成器
- Tetgen:三维网格生成器
📝 八、总结
本篇文章深入探讨了德劳内三角剖分的数学原理及其在 Flutter 中的可视化实现。
✅ 核心知识点回顾
| 知识点 | 说明 |
|---|---|
| 🔺 空圆性质 | 外接圆不含其他点 |
| 📐 最大化最小角 | 最优三角剖分 |
| 🔄 Voronoi 对偶 | 两种图的关系 |
| ⚡ Bowyer-Watson | 增量式算法 |
| 🎯 应用领域 | 图形学、GIS、科学计算 |
⭐ 最佳实践要点
- ✅ 使用空间索引加速查找
- ✅ 注意数值精度问题
- ✅ 处理退化情况(四点共圆)
- ✅ 增量更新提高效率
🚀 进阶方向
- 🔮 三维德劳内剖分
- ✨ 约束三角剖分
- 📊 动态网格更新
- 🎨 表面重建应用
💡 提示:本文代码基于 Flutter for Harmony 开发,可在鸿蒙设备上流畅运行。