feat: Initial release v0.1.0

STP/STEP 3D viewer (WPF, .NET 8) - CADability + HelixToolkit.Wpf:

- Multi-file import (STEP/STL/DXF) with STEP assembly tree
  (per-node visibility/color cascade, zoom-to)
- Measurements: point / distance / edge / face / circle / angle /
  face-to-face distance (B-rep exact where available)
- Assembly verification: two-point align (translate), 3-point align
  (rotate+translate via RigidAlign), axis rotate 90, interference
  check (tri-tri intersection + min gap)
- Section view (CPU mesh clipping, originals preserved)
- mm/inch toggle, CSV export (UTF-8 BOM), 2x PNG screenshot
- Performance: per-file merged edge lines, per-leaf merged mesh in
  browse mode, no BackMaterial on closed solids, edge suspend during
  camera interaction, parallel leaf triangulation with retry
- SmokeTest CLI: import pipeline, --tree, --clip-test,
  --interference-test, --align-test (all passing)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 00:16:10 +08:00
co-authored by Claude Opus 4.8
commit 0e19dbae1b
33 changed files with 4155 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
// 三點對齊剛體變換數學驗證:
// (1) 已知變換還原 (2) Matrix3D ↔ ModOp 兩種表示一致 (3) 共線拒絕
using System.Windows.Media.Media3D;
using STPViewer.Services;
namespace SmokeTest;
internal static class AlignTest
{
public static int Run()
{
int failures = 0;
// ── (1) 已知剛體變換:繞 Z 軸 90° + 平移 (5,-3,2),三點求解後應還原同一變換 ──
var truth = Matrix3D.Identity;
truth.RotateAt(new Quaternion(new Vector3D(0, 0, 1), 90), new Point3D(2, 1, 0));
truth.Translate(new Vector3D(5, -3, 2));
var p1 = new Point3D(0, 0, 0);
var p2 = new Point3D(10, 0, 0);
var p3 = new Point3D(0, 7, 3); // 不共線、不在同平面軸上
Point3D q1 = truth.Transform(p1), q2 = truth.Transform(p2), q3 = truth.Transform(p3);
bool ok = RigidAlign.TryRigidTransform(p1, p2, p3, q1, q2, q3, out Matrix3D m);
failures += Check("三點求解成功", ok, true);
// 三個對應點要精確貼合
failures += CheckPoint("p1→q1", m.Transform(p1), q1);
failures += CheckPoint("p2→q2", m.Transform(p2), q2);
failures += CheckPoint("p3→q3", m.Transform(p3), q3);
// 剛體變換唯一性:任意第 4 點也應該跟著正確變換
var r = new Point3D(-4, 12, 8);
failures += CheckPoint("任意第4點", m.Transform(r), truth.Transform(r));
// ── (2) ToModOpCADability ModOp 與 WPF Matrix3D 對同一點要算出同樣結果 ──
CADability.ModOp op = RigidAlign.ToModOp(m);
foreach (Point3D p in new[] { p1, p2, p3, r, new Point3D(1.5, -2.25, 9) })
{
CADability.GeoPoint gp = op * new CADability.GeoPoint(p.X, p.Y, p.Z);
Point3D wpf = m.Transform(p);
bool same = Dist(new Point3D(gp.x, gp.y, gp.z), wpf) < 1e-9;
if (!same)
{
System.Console.WriteLine($" FAIL: ModOp/Matrix3D 不一致 @ ({p.X},{p.Y},{p.Z})");
failures++;
}
}
System.Console.WriteLine($" {(failures == 0 ? "PASS" : "----")}: ModOp ↔ Matrix3D 一致(5 個測試點)");
// ── (3) 共線拒絕 ──
bool collinearRejected = !RigidAlign.TryRigidTransform(
new Point3D(0, 0, 0), new Point3D(1, 0, 0), new Point3D(2, 0, 0), // 共線
q1, q2, q3, out _);
failures += Check("來源共線 → 拒絕", collinearRejected, true);
failures += Check("Collinear 判定", RigidAlign.Collinear(
new Point3D(0, 0, 0), new Point3D(1, 1, 1), new Point3D(3, 3, 3)), true);
System.Console.WriteLine(failures == 0 ? "AlignTest: 全部通過" : $"AlignTest: {failures} 項失敗");
return failures;
}
private static int Check(string name, bool actual, bool expected)
{
bool ok = actual == expected;
System.Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name}{actual}");
return ok ? 0 : 1;
}
private static int CheckPoint(string name, Point3D actual, Point3D expected)
{
double d = Dist(actual, expected);
bool ok = d < 1e-9;
System.Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name} 誤差 {d:E2}");
return ok ? 0 : 1;
}
private static double Dist(Point3D a, Point3D b) => (b - a).Length;
}
+71
View File
@@ -0,0 +1,71 @@
// 剖面裁切數學驗證:對單位幾何裁切,檢查保留側與面積守恆
using System.Windows.Media;
using System.Windows.Media.Media3D;
using STPViewer.Services;
namespace SmokeTest;
internal static class ClipTest
{
public static int Run()
{
int failures = 0;
// 單一三角形 (0,0,0)(10,0,0)(0,10,0),面積 50
var mesh = new MeshGeometry3D
{
Positions = new Point3DCollection
{
new(0, 0, 0), new(10, 0, 0), new(0, 10, 0),
},
TriangleIndices = new Int32Collection { 0, 1, 2 },
};
mesh.Freeze();
// case 1: 平面 x=20,全保留
var m1 = SectionService.ClipMesh(mesh, new Point3D(20, 0, 0), new Vector3D(1, 0, 0));
failures += Check("全保留", Area(m1), 50.0);
// case 2: 平面 x=-1,全裁掉
var m2 = SectionService.ClipMesh(mesh, new Point3D(-1, 0, 0), new Vector3D(1, 0, 0));
failures += Check("全裁掉", Area(m2), 0.0);
// case 3: 平面 x=5 → 保留 x≤5:梯形面積 = 50 - 右側小三角形(底5高5)/2 = 50 - 12.5 = 37.5
var m3 = SectionService.ClipMesh(mesh, new Point3D(5, 0, 0), new Vector3D(1, 0, 0));
failures += Check("半裁(x≤5)", Area(m3), 37.5);
foreach (Point3D p in m3.Positions)
if (p.X > 5 + 1e-9) { Console.WriteLine($" FAIL: 頂點 x={p.X} 超出保留側"); failures++; }
// case 4: 反向(保留 x≥5)→ 12.5
var m4 = SectionService.ClipMesh(mesh, new Point3D(5, 0, 0), new Vector3D(-1, 0, 0));
failures += Check("半裁(x≥5)", Area(m4), 12.5);
// case 5: 線段裁切
var segs = new Point3DCollection { new(0, 0, 0), new(10, 0, 0) };
Point3DCollection s1 = SectionService.ClipSegments(segs, new Point3D(4, 0, 0), new Vector3D(1, 0, 0));
double segLen = s1.Count == 2 ? (s1[1] - s1[0]).Length : -1;
failures += Check("線段裁切長度", segLen, 4.0);
Console.WriteLine(failures == 0 ? "ClipTest: 全部通過" : $"ClipTest: {failures} 項失敗");
return failures;
}
private static int Check(string name, double actual, double expected)
{
bool ok = Math.Abs(actual - expected) < 1e-9;
Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name} = {actual}(期望 {expected}");
return ok ? 0 : 1;
}
private static double Area(MeshGeometry3D m)
{
double a = 0;
for (int i = 0; i + 2 < m.TriangleIndices.Count; i += 3)
{
Vector3D u = m.Positions[m.TriangleIndices[i + 1]] - m.Positions[m.TriangleIndices[i]];
Vector3D v = m.Positions[m.TriangleIndices[i + 2]] - m.Positions[m.TriangleIndices[i]];
a += Vector3D.CrossProduct(u, v).Length / 2;
}
return a;
}
}
+66
View File
@@ -0,0 +1,66 @@
// 干涉檢查數學驗證:相交 / 分離 / 貼合三種情境
using System.Windows.Media;
using System.Windows.Media.Media3D;
using STPViewer.Services;
namespace SmokeTest;
internal static class InterferenceTest
{
public static int Run()
{
int failures = 0;
// 兩個互穿的四面體(B 比 A 平移 +5,邊長 10 → 必相交)
MeshGeometry3D a = Tetra(new Vector3D(0, 0, 0));
MeshGeometry3D b = Tetra(new Vector3D(5, 0, 0));
InterferenceResult r1 = InterferenceService.Check(new[] { a }, new[] { b });
failures += Check("互穿 → 相交", r1.Intersects, true);
failures += Check("互穿 → 有交線段", r1.Segments.Count > 0, true);
// 分離(B 平移 +30gap 應 ≈ 30 - 10 = 20
MeshGeometry3D c = Tetra(new Vector3D(30, 0, 0));
InterferenceResult r2 = InterferenceService.Check(new[] { a }, new[] { c });
failures += Check("分離 → 不相交", r2.Intersects, false);
bool gapOk = Math.Abs(r2.GapDistance - 20.0) < 0.01;
Console.WriteLine($" {(gapOk ? "PASS" : "FAIL")}: 分離 gap = {r2.GapDistance:F4}(期望 ≈ 20");
if (!gapOk) failures++;
// 貼合(B 平移 +10,頂點剛好接觸 → 不算穿透、gap ≈ 0)
MeshGeometry3D d = Tetra(new Vector3D(10, 0, 0));
InterferenceResult r3 = InterferenceService.Check(new[] { a }, new[] { d });
failures += Check("貼合 → 不算穿透", r3.Intersects, false);
bool touchOk = r3.GapDistance < 0.01;
Console.WriteLine($" {(touchOk ? "PASS" : "FAIL")}: 貼合 gap = {r3.GapDistance:F6}(期望 ≈ 0");
if (!touchOk) failures++;
Console.WriteLine(failures == 0 ? "InterferenceTest: 全部通過" : $"InterferenceTest: {failures} 項失敗");
return failures;
}
private static int Check(string name, bool actual, bool expected)
{
bool ok = actual == expected;
Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name}{actual}");
return ok ? 0 : 1;
}
/// <summary>邊長 10 的四面體,offset 平移</summary>
private static MeshGeometry3D Tetra(Vector3D offset)
{
Point3D P(double x, double y, double z) => new Point3D(x, y, z) + offset;
var m = new MeshGeometry3D
{
Positions = new Point3DCollection
{
P(0, 0, 0), P(10, 0, 0), P(0, 10, 0), P(0, 0, 10),
},
TriangleIndices = new Int32Collection
{
0, 1, 2, 0, 1, 3, 0, 2, 3, 1, 2, 3,
},
};
m.Freeze();
return m;
}
}
+17
View File
@@ -0,0 +1,17 @@
// 測試輔助:用 CADability 內嵌的 netDxf 產生標準 DXF 測試檔
using netDxf;
using netDxf.Entities;
namespace SmokeTest;
internal static class MakeDxf
{
public static void Run(string path)
{
var doc = new DxfDocument();
doc.Entities.Add(new Line(new Vector3(0, 0, 0), new Vector3(100, 50, 0)));
doc.Entities.Add(new Circle(new Vector3(50, 25, 0), 10));
doc.Save(path);
Console.WriteLine($"已產生 {path}");
}
}
+71
View File
@@ -0,0 +1,71 @@
// 無 UI smoke test:驗證 CAD 檔解析 → 三角化 → 裝配樹 整條匯入管線
using System.Diagnostics;
using System.IO;
using STPViewer.Services;
Console.OutputEncoding = System.Text.Encoding.UTF8;
if (args.Length == 0)
{
Console.WriteLine("用法: SmokeTest [--tree] <file.stp|.stl|.dxf> [...]");
return 1;
}
if (args[0] == "--tree")
{
foreach (string p in args.Skip(1)) SmokeTest.TreeDump.Run(p);
return 0;
}
if (args[0] == "--make-dxf")
{
SmokeTest.MakeDxf.Run(args[1]);
return 0;
}
if (args[0] == "--clip-test")
return SmokeTest.ClipTest.Run() == 0 ? 0 : 2;
if (args[0] == "--interference-test")
return SmokeTest.InterferenceTest.Run() == 0 ? 0 : 2;
if (args[0] == "--align-test")
return SmokeTest.AlignTest.Run() == 0 ? 0 : 2;
var service = new StepImportService { Progress = s => Console.WriteLine($" [階段] {s}") };
int failed = 0;
foreach (string path in args)
{
Console.WriteLine($"=== {Path.GetFileName(path)} ===");
var sw = Stopwatch.StartNew();
try
{
ImportedFileData data = service.Import(path);
sw.Stop();
ImportedNode r = data.Root;
Console.WriteLine($" Solids : {r.SolidCount}");
Console.WriteLine($" Faces : {r.FaceCount}");
Console.WriteLine($" Triangles : {r.TriangleCount:N0}");
Console.WriteLine($" Bounds : {r.Bounds.SizeX:F2} x {r.Bounds.SizeY:F2} x {r.Bounds.SizeZ:F2} mm");
Console.WriteLine($" Time : {sw.ElapsedMilliseconds} ms");
Console.WriteLine(" --- 裝配樹 ---");
PrintNode(r, 1);
}
catch (Exception ex)
{
sw.Stop();
failed++;
Console.WriteLine($" FAILED ({sw.ElapsedMilliseconds} ms): {ex.GetType().Name}: {ex.Message}");
}
}
return failed == 0 ? 0 : 2;
static void PrintNode(ImportedNode n, int indent)
{
string seg = n.EdgeSegments is null ? "" : $", edgeSegs={n.EdgeSegments.Count / 2:N0}";
Console.WriteLine($"{new string(' ', indent * 2)}{n.Name} " +
$"[solids={n.SolidCount}, faces={n.FaceCount}, tris={n.TriangleCount:N0}{seg}{(n.HasBrep ? "" : ", B-rep")}]");
foreach (ImportedNode c in n.Children) PrintNode(c, indent + 1);
}
+15
View File
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\STPViewer\STPViewer.csproj" />
</ItemGroup>
</Project>
+32
View File
@@ -0,0 +1,32 @@
// 實驗工具:印出 STEP 匯入後的物件階層(HierarchyToBlocks=true
using CADability;
using CADability.GeoObject;
namespace SmokeTest;
internal static class TreeDump
{
public static void Run(string path)
{
var imp = new ImportStep { HierarchyToBlocks = true };
GeoObjectList list = imp.Read(path);
Console.WriteLine($"top-level objects: {list?.Count}");
if (list is null) return;
foreach (IGeoObject go in list) Dump(go, 0);
}
private static void Dump(IGeoObject go, int indent)
{
string name = go switch
{
Block b => b.Name,
Solid s => s.NameOrEmpty,
Shell sh => sh.NameOrEmpty,
_ => "",
};
Console.WriteLine($"{new string(' ', indent * 2)}{go.GetType().Name} '{name}' children={go.NumChildren}");
if (indent > 6) return;
for (int i = 0; i < go.NumChildren; i++)
if (go.Child(i) is IGeoObject c) Dump(c, indent + 1);
}
}