一个用于可视化 3D 几何模型的工具(第一部分)
一篇关于使用 WPF 可视化 3D 几何模型的工具的文章

引言
在阅读Charles Petzold所著的《Windows 3D编程》一书时,我想我应该使用WPF 3D开发一些东西。我一直没有想法,直到有人问我是否能帮助解决一个3D几何问题:如果学生能够轻松地可视化几何模型中的点和线的定位,他们就能轻松地解决这个问题。
因此,我决定开发一个工具,允许用户:
- 通过定义点和线来创建3D几何模型
- 通过围绕3个轴旋转来检查模型
- 修改模型,以及
- 持久化模型以备后用
背景
该工具是使用Charles Petzold开发的Petzold.Media3D
库开发的。使用这个库,您可以轻松地在3D空间中创建网格、线条、曲线、轴和文本。下图显示了此工具中使用的类。
WireBase
类继承自ModelVisual3D
,提供了在3D空间中绘制一条或多条线的基本功能。它注册了一些依赖属性,例如Color
、Thickness
等。
Axes
类派生自WireBase
,在3D空间中绘制X、Y和Z轴。您可以通过Extent
属性指定三个轴的范围,该属性适用于所有三个轴。原始类每单位(可以视为一英寸)绘制一个大刻度,每大刻度绘制10个小刻度。如果Extent
很大,例如80英寸,那么轴上的刻度将过多,不仅使轴上的刻度和单位标签过于拥挤,而且还会影响性能。因此,我在该类中添加了两个新属性,UnitPerBigTick
和SmallTicksPerBigPick
,您可以使用它们来指定每大刻度的英寸数,每大刻度的小刻度数。
WireLine
类也派生自WireBase
,是该工具的核心,用于在3D空间中绘制一条线。您可以通过指定两个端点来创建一条线。
另一个WireBase
派生类WireText
用于绘制3D几何模型中点的标签。
设计
该工具的设计可以分为4个部分:模型、视图、对话框和持久化机制。
我将分两部分介绍程序的設計:第一部分介紹模型、視圖和對話框,第二部分介紹持久化機制。
模型
G3DModel
类表示一个3D几何模型,该模型包含G3DElement
,可以是G3DPoint
或G3DLine
。您可以向模型添加/删除G3DPoint
或G3DLine
。您可以分别通过Points
或Lines
属性检索所有G3DPoint
或G3DLine
。Extent
属性返回模型在X、Y或Z轴上的范围,以最大者为准。IsDirty
属性指示模型自上次保存以来是否已更改。如果模型中没有点或线,IsEmpty
属性将返回true
。
G3DModel
类的CreateVisual3D()
方法返回一个表示3D几何模型的ModelVisual3D
,然后该模型可以被添加到Viewport3D
以进行显示。
G3DElement
类是G3DPoint
和G3DLine
的基类,分别表示一个点或一条线。每个G3DElement
对象都有一个唯一的标识,由ID
属性表示。G3DElement
对象可以有一个Label
,该标签将与点或线一起绘制。
G3DPoint
类定义了3D空间中的一个点。类型为Point3D
的Position
属性指定了点的位置。
G3DLine
类定义了一条线,以两个G3DPoint
对象作为端点。G3DLine
可以有一个颜色,默认值为Black
。所有线的端点(G3DPoint
对象)必须在作为线的端点使用之前,通过调用G3DModel
的某个AddPoint()
方法添加到模型中。
将来,我们可能会添加更多G3DElement
派生类的类型,例如表示3D空间中向量的G3DVector
。
以下代码显示了G3DModel
类中CreateVisual3D
方法的实现。
/// <summary>
/// Creates and returns a ModelVisual3D that represents the model.
/// </summary>
/// <param name="ratio">The ratio to be used to display the labels.
/// </param>
/// <returns>The ModelVisual3D that represents the model</returns>
public ModelVisual3D CreateVisual3D(double ratio)
{
ModelVisual3D visual = new ModelVisual3D();
AddLines(visual);
AddPoints(visual, ratio);
return visual;
}
/// <summary>
/// Add all lines of the model to the ModelVisual3D object.
/// </summary>
/// <param name="visual">The ModelVisual3D to which the lines are
/// added.</param>
private void AddLines(ModelVisual3D visual)
{
foreach (var line in m_linesByName.Values)
{
var wl = new WireLine()
{
Point1 = line.StartPoint.Position,
Point2 = line.EndPoint.Position,
Color = line.Color,
Thickness = LINE_THICKNESS
};
visual.Children.Add(wl);
}
}
/// <summary>
/// Add points and labels to the ModelVisual3D object.
/// </summary>
/// <param name="visual">The ModelVisual3D to which the points
/// and labels are added</param>
/// <param name="ratio">The ratio to scale the labels</param>
private void AddPoints(ModelVisual3D visual, double ratio)
{
foreach (var p in m_pointsByName.Values)
{
if (!IsPointUsedInLines(p))
{
var point = 1.0/96.0;
var wl = new WireLine()
{
Point1 = p.Position,
Point2 = p.Position + new Vector3D(point, point, point),
Thickness = 2
};
visual.Children.Add(wl);
}
if (!string.IsNullOrEmpty(p.Label))
{
var wt = new WireText()
{
Origin = p.Position,
Text = p.Label,
FontSize = POINT_LABEL_SIZE * ratio,
Thickness = 2
};
visual.Children.Add(wt);
}
}
}
/// <summary>
/// Check if the given point is used in any line.
/// </summary>
/// <param name="p">The point to be checked</param>
/// <returns>Returns true if the point is used in a line.</returns>
private bool IsPointUsedInLines(G3DPoint p)
{
foreach (var l in m_lines)
{
if (l.StartPoint.ID == p.ID || l.EndPoint.ID == p.ID)
{
return true;
}
}
return false;
}
View
视图由两个窗口组成:MainWindow
和ControlPanel
。MainWindow
显示G3DModel
,而ControlPanel
用于旋转模型和进行缩放。
MainWindow
包含一个G3DViewport
,该G3DViewport
又包含一个ViewPort3D
。ViewPort3D
具有AmbientLight
、DirectionalLight
和PerspectiveCamera
。三个RotateTransform3D
对象被分配给PerspectiveCamera
,以便围绕三个轴旋转相机。
ControlPanel
有四个滑块。三个轴滑块分别用于围绕三个轴旋转相机。距离滑块通过改变相机到轴原点的距离来实现放大和缩小。
滑块的位置通过数据绑定绑定到Viewport3D
的4个属性。轴滑块绑定到分配给相机的相应AxisAngleRotation3D
对象的AngleProperty
,范围从-180到180。当我们改变其中一个轴滑块的位置时,相机将围绕相应的轴旋转,从而使我们能够从不同角度查看模型。
距离滑块绑定到G3DViewport
的Distance
属性,该属性在值改变时调整相机的Position
。距离滑块的范围是从G3DViewport
的Extent
的8倍到8分之1。
为了支持数据绑定,我们注册了一个名为DistanceProperty
、类型为int
的DependencyProperty
。当属性值改变时,会调用DistancePropertyChanged()
方法,该方法会调整相机的位置,从而实现放大或缩小的效果。
public static readonly DependencyProperty DistanceProperty =
DependencyProperty.Register("Distance",
typeof(int),
typeof(G3DViewport),
new PropertyMetadata(0, DistancePropertyChanged));
public int Distance
{
set { SetValue(DistanceProperty, value); }
get { return (int)GetValue(DistanceProperty); }
}
protected static void DistancePropertyChanged
(
DependencyObject obj,
DependencyPropertyChangedEventArgs args
)
{
if (obj != null)
{
(obj as G3DViewport).DistancePropertyChanged(args);
}
}
protected void DistancePropertyChanged(DependencyPropertyChangedEventArgs args)
{
var p = m_camera.Position;
var currentDistance = Math.Sqrt(p.X * p.X + p.Y * p.Y + p.Z * p.Z);
var ratio = Math.Sqrt(Distance / currentDistance);
m_camera.Position = new Point3D(p.X * ratio, p.Y * ratio, p.Z * ratio);
}
ControlPanel
的两个Bind()
方法将DependencyObject
的DependencyProperty
绑定到滑块。
public void Bind(DependencyObject target, DependencyProperty property, SliderId id)
{
if (target == null || property == null)
{
throw new ArgumentNullException
("The target and property parameters should not be null");
}
switch (id)
{
case SliderId.AxisX:
Bind(target, property, sliderX);
break;
case SliderId.AxisY:
Bind(target, property, sliderY);
break;
case SliderId.AxisZ:
Bind(target, property, sliderZ);
break;
case SliderId.Distance:
Bind(target, property, sliderDistance);
break;
default:
throw new ArgumentException("Invalid SliderId");
}
}
private void Bind(DependencyObject target, DependencyProperty property, Slider slider)
{
Binding binding = new Binding();
binding.Source = slider; ;
binding.Path = new PropertyPath("Value");
binding.Mode = BindingMode.TwoWay;
BindingOperations.SetBinding(target, property, binding);
}
请注意,绑定是双向的,因此属性值和滑块的位置将始终同步。
MainWindow.Window_Loaded()
方法通过调用ControlPanel
的Bind()
方法将属性绑定到ControlPanel
的滑块。
private void Window_Loaded(object sender, RoutedEventArgs e)
{
…
m_controlPanel.Bind(viewport.AxisX,
AxisAngleRotation3D.AngleProperty, ControlPanel.SliderId.AxisX);
m_controlPanel.Bind(viewport.AxisY,
AxisAngleRotation3D.AngleProperty, ControlPanel.SliderId.AxisY);
m_controlPanel.Bind(viewport.AxisZ,
AxisAngleRotation3D.AngleProperty, ControlPanel.SliderId.AxisZ);
m_controlPanel.Bind(viewport,
G3DViewport.DistanceProperty, ControlPanel.SliderId.Distance);
}
对话框
有几个对话框允许您操作模型。
您可以通过AddPointDialog
添加一个或多个点,方法是指定点的名称、坐标和/或标签。当您单击“添加”按钮时,该点将被添加到模型中。您可以使用同一个对话框连续添加新点,并在完成添加点后单击“关闭”按钮。
您可以使用AddLineDialog
向模型添加线,该对话框允许您选择线的两个端点,指定线的名称,或者选择线的颜色。该对话框列出了模型中的所有点供您选择端点。
您可以使用EditModelDialog
创建新模型或编辑现有模型,可以使用该对话框添加点和线,编辑值。
请注意,此对话框不是WPF窗口,而是Windows Forms Form,其中包含两个DataGridView
控件。
单元测试
单元测试使用NUnit Framework完成。我已经为一些模型类创建了单元测试,例如G3DPoint
、G3DLine
和G3DModel
。请参阅UnitTests项目中的文件G3DPointTester.cs、G3DLineTester.cs和G3DModelTester.cs。虽然本节篇幅很短,但这并不意味着单元测试不重要。相反,单元测试对于确保系统正常工作并使我们有勇气重构代码至关重要。
结论
在第一部分中,我们介绍了模型、视图和对话框的设计。正如您所见,使用Petzold.Media3D
库绘制3D几何模型非常容易,而且我们只使用了该库的一小部分。我们可以向模型添加其他类型的视觉元素,例如曲线。
在第二部分,我将介绍该工具的持久化机制。
我在CodePlex上创建了一个项目。请从那里下载最新的代码。
历史
- 2009年10月11日:初始帖子