feat(i18n): Add runtime language packs and language switching (#1)
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
// 三點對齊剛體變換數學驗證:
|
||||
// (1) 已知變換還原 (2) Matrix3D ↔ ModOp 兩種表示一致 (3) 共線拒絕
|
||||
// Three-point alignment checks: known transform, Matrix3D/ModOp agreement, and collinear rejection.
|
||||
using System.Windows.Media.Media3D;
|
||||
using STPViewer.Services;
|
||||
|
||||
@@ -11,29 +10,29 @@ internal static class AlignTest
|
||||
{
|
||||
int failures = 0;
|
||||
|
||||
// ── (1) 已知剛體變換:繞 Z 軸 90° + 平移 (5,-3,2),三點求解後應還原同一變換 ──
|
||||
// Recover a known 90-degree Z rotation followed by translation (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); // 不共線、不在同平面軸上
|
||||
var p3 = new Point3D(0, 7, 3); // Not collinear with the first two points.
|
||||
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 += Check("three-point transform solved", ok, true);
|
||||
|
||||
// 三個對應點要精確貼合
|
||||
// Corresponding points must coincide.
|
||||
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 點也應該跟著正確變換
|
||||
// An independent fourth point checks the full rigid transform.
|
||||
var r = new Point3D(-4, 12, 8);
|
||||
failures += CheckPoint("任意第4點", m.Transform(r), truth.Transform(r));
|
||||
failures += CheckPoint("fourth point", m.Transform(r), truth.Transform(r));
|
||||
|
||||
// ── (2) ToModOp:CADability ModOp 與 WPF Matrix3D 對同一點要算出同樣結果 ──
|
||||
// CADability ModOp and WPF Matrix3D must transform points identically.
|
||||
CADability.ModOp op = RigidAlign.ToModOp(m);
|
||||
foreach (Point3D p in new[] { p1, p2, p3, r, new Point3D(1.5, -2.25, 9) })
|
||||
{
|
||||
@@ -42,28 +41,28 @@ internal static class AlignTest
|
||||
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})");
|
||||
System.Console.WriteLine($" FAIL: ModOp/Matrix3D mismatch at ({p.X},{p.Y},{p.Z})");
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
System.Console.WriteLine($" {(failures == 0 ? "PASS" : "----")}: ModOp ↔ Matrix3D 一致(5 個測試點)");
|
||||
System.Console.WriteLine($" {(failures == 0 ? "PASS" : "----")}: ModOp/Matrix3D agree at five points");
|
||||
|
||||
// ── (3) 共線拒絕 ──
|
||||
// Collinear source points must be rejected.
|
||||
bool collinearRejected = !RigidAlign.TryRigidTransform(
|
||||
new Point3D(0, 0, 0), new Point3D(1, 0, 0), new Point3D(2, 0, 0), // 共線
|
||||
new Point3D(0, 0, 0), new Point3D(1, 0, 0), new Point3D(2, 0, 0), // Collinear.
|
||||
q1, q2, q3, out _);
|
||||
failures += Check("來源共線 → 拒絕", collinearRejected, true);
|
||||
failures += Check("Collinear 判定", RigidAlign.Collinear(
|
||||
failures += Check("collinear source rejected", collinearRejected, true);
|
||||
failures += Check("collinearity detection", 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} 項失敗");
|
||||
System.Console.WriteLine(failures == 0 ? "AlignTest: all passed" : $"AlignTest: {failures} failed");
|
||||
return failures;
|
||||
}
|
||||
|
||||
private static int Check(string name, bool actual, bool expected)
|
||||
{
|
||||
bool ok = actual == expected;
|
||||
System.Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name}({actual})");
|
||||
System.Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name} ({actual})");
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
@@ -71,7 +70,7 @@ internal static class AlignTest
|
||||
{
|
||||
double d = Dist(actual, expected);
|
||||
bool ok = d < 1e-9;
|
||||
System.Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name} 誤差 {d:E2}");
|
||||
System.Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name} error {d:E2}");
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
|
||||
+15
-15
@@ -1,4 +1,4 @@
|
||||
// 剖面裁切數學驗證:對單位幾何裁切,檢查保留側與面積守恆
|
||||
// Section clipping checks the retained side and triangle area.
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Media3D;
|
||||
using STPViewer.Services;
|
||||
@@ -11,7 +11,7 @@ internal static class ClipTest
|
||||
{
|
||||
int failures = 0;
|
||||
|
||||
// 單一三角形 (0,0,0)(10,0,0)(0,10,0),面積 50
|
||||
// A single triangle with area 50.
|
||||
var mesh = new MeshGeometry3D
|
||||
{
|
||||
Positions = new Point3DCollection
|
||||
@@ -22,38 +22,38 @@ internal static class ClipTest
|
||||
};
|
||||
mesh.Freeze();
|
||||
|
||||
// case 1: 平面 x=20,全保留
|
||||
// A plane at x=20 retains the full triangle.
|
||||
var m1 = SectionService.ClipMesh(mesh, new Point3D(20, 0, 0), new Vector3D(1, 0, 0));
|
||||
failures += Check("全保留", Area(m1), 50.0);
|
||||
failures += Check("fully retained", Area(m1), 50.0);
|
||||
|
||||
// case 2: 平面 x=-1,全裁掉
|
||||
// A plane at x=-1 removes the full triangle.
|
||||
var m2 = SectionService.ClipMesh(mesh, new Point3D(-1, 0, 0), new Vector3D(1, 0, 0));
|
||||
failures += Check("全裁掉", Area(m2), 0.0);
|
||||
failures += Check("fully removed", Area(m2), 0.0);
|
||||
|
||||
// case 3: 平面 x=5 → 保留 x≤5:梯形面積 = 50 - 右側小三角形(底5高5)/2 = 50 - 12.5 = 37.5
|
||||
// At x=5, the retained trapezoid has area 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);
|
||||
failures += Check("half clipped (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++; }
|
||||
if (p.X > 5 + 1e-9) { Console.WriteLine($" FAIL: vertex x={p.X} is outside retained side"); failures++; }
|
||||
|
||||
// case 4: 反向(保留 x≥5)→ 12.5
|
||||
// Reverse the plane normal to retain the smaller triangle.
|
||||
var m4 = SectionService.ClipMesh(mesh, new Point3D(5, 0, 0), new Vector3D(-1, 0, 0));
|
||||
failures += Check("半裁(x≥5)", Area(m4), 12.5);
|
||||
failures += Check("half clipped (x>=5)", Area(m4), 12.5);
|
||||
|
||||
// case 5: 線段裁切
|
||||
// Clip a line segment.
|
||||
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);
|
||||
failures += Check("clipped segment length", segLen, 4.0);
|
||||
|
||||
Console.WriteLine(failures == 0 ? "ClipTest: 全部通過" : $"ClipTest: {failures} 項失敗");
|
||||
Console.WriteLine(failures == 0 ? "ClipTest: all passed" : $"ClipTest: {failures} failed");
|
||||
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})");
|
||||
Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name} = {actual} (expected {expected})");
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,13 @@ using System.Linq;
|
||||
using CADability;
|
||||
using CADability.GeoObject;
|
||||
using STPViewer.Services;
|
||||
using IOPath = System.IO.Path; // CADability.GeoObject.Path(曲線)撞名
|
||||
using IOPath = System.IO.Path; // Avoid the CADability.GeoObject.Path name collision.
|
||||
|
||||
namespace SmokeTest;
|
||||
|
||||
/// <summary>
|
||||
/// STEP 匯出往返驗證(v0.5.0 匯出 STEP 功能的無 UI 驗證):
|
||||
/// 匯入來源 STEP → 走與 MainViewModel.ExportStepFile 相同路徑寫出新檔 → 重新匯入比對實體數。
|
||||
/// Headless STEP export round trip: import, export through the same CADability path,
|
||||
/// then import the result and compare solid counts.
|
||||
/// </summary>
|
||||
public static class ExportTest
|
||||
{
|
||||
@@ -20,26 +20,26 @@ public static class ExportTest
|
||||
Console.WriteLine($"=== ExportTest: {IOPath.GetFileName(sourcePath)} → {IOPath.GetFileName(outPath)} ===");
|
||||
ImportedFileData data = new StepImportService().Import(sourcePath);
|
||||
var geos = Collect(data.Root).Where(g => g is Solid or Shell).ToList();
|
||||
Console.WriteLine($" 來源 Solid/Shell 物件 = {geos.Count}(Solids = {data.Root.SolidCount})");
|
||||
Console.WriteLine($" Source Solid/Shell objects = {geos.Count} (solids = {data.Root.SolidCount})");
|
||||
|
||||
Project project = Project.CreateSimpleProject();
|
||||
Model model = project.GetActiveModel();
|
||||
foreach (IGeoObject g in geos) model.Add(g);
|
||||
new ExportStep().WriteToFile(outPath, project);
|
||||
var fi = new FileInfo(outPath);
|
||||
Console.WriteLine($" 已寫出({fi.Length:N0} bytes)");
|
||||
Console.WriteLine($" Written ({fi.Length:N0} bytes)");
|
||||
if (fi.Length < 100)
|
||||
{
|
||||
Console.WriteLine("ExportTest: FAIL(輸出檔近乎空白)");
|
||||
Console.WriteLine("ExportTest: FAIL (output is nearly empty)");
|
||||
return 1;
|
||||
}
|
||||
|
||||
ImportedFileData back = new StepImportService().Import(outPath);
|
||||
Console.WriteLine($" 回讀 Solids = {back.Root.SolidCount}, Faces = {back.Root.FaceCount}");
|
||||
Console.WriteLine($" Reimported solids = {back.Root.SolidCount}, faces = {back.Root.FaceCount}");
|
||||
bool ok = back.Root.SolidCount == data.Root.SolidCount && back.Root.FaceCount > 0;
|
||||
Console.WriteLine(ok
|
||||
? "ExportTest: PASS(實體數一致)"
|
||||
: $"ExportTest: FAIL(來源 Solids {data.Root.SolidCount} ↔ 回讀 {back.Root.SolidCount})");
|
||||
? "ExportTest: PASS (solid counts match)"
|
||||
: $"ExportTest: FAIL (source solids {data.Root.SolidCount} vs reimported {back.Root.SolidCount})");
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 干涉檢查數學驗證:相交 / 分離 / 貼合三種情境
|
||||
// Interference checks for intersecting, separated, and touching meshes.
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Media3D;
|
||||
using STPViewer.Services;
|
||||
@@ -11,41 +11,41 @@ internal static class InterferenceTest
|
||||
{
|
||||
int failures = 0;
|
||||
|
||||
// 兩個互穿的四面體(B 比 A 平移 +5,邊長 10 → 必相交)
|
||||
// Two tetrahedra overlap when B is offset by 5 along X.
|
||||
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);
|
||||
failures += Check("overlap intersects", r1.Intersects, true);
|
||||
failures += Check("overlap has intersection segments", r1.Segments.Count > 0, true);
|
||||
|
||||
// 分離(B 平移 +30,gap 應 ≈ 30 - 10 = 20)
|
||||
// B at X=30 leaves a gap of about 20.
|
||||
MeshGeometry3D c = Tetra(new Vector3D(30, 0, 0));
|
||||
InterferenceResult r2 = InterferenceService.Check(new[] { a }, new[] { c });
|
||||
failures += Check("分離 → 不相交", r2.Intersects, false);
|
||||
failures += Check("separated meshes do not intersect", r2.Intersects, false);
|
||||
bool gapOk = Math.Abs(r2.GapDistance - 20.0) < 0.01;
|
||||
Console.WriteLine($" {(gapOk ? "PASS" : "FAIL")}: 分離 gap = {r2.GapDistance:F4}(期望 ≈ 20)");
|
||||
Console.WriteLine($" {(gapOk ? "PASS" : "FAIL")}: separated gap = {r2.GapDistance:F4} (expected ~20)");
|
||||
if (!gapOk) failures++;
|
||||
|
||||
// 貼合(B 平移 +10,頂點剛好接觸 → 不算穿透、gap ≈ 0)
|
||||
// B at X=10 touches a vertex without penetrating A.
|
||||
MeshGeometry3D d = Tetra(new Vector3D(10, 0, 0));
|
||||
InterferenceResult r3 = InterferenceService.Check(new[] { a }, new[] { d });
|
||||
failures += Check("貼合 → 不算穿透", r3.Intersects, false);
|
||||
failures += Check("touching is not penetration", r3.Intersects, false);
|
||||
bool touchOk = r3.GapDistance < 0.01;
|
||||
Console.WriteLine($" {(touchOk ? "PASS" : "FAIL")}: 貼合 gap = {r3.GapDistance:F6}(期望 ≈ 0)");
|
||||
Console.WriteLine($" {(touchOk ? "PASS" : "FAIL")}: touching gap = {r3.GapDistance:F6} (expected ~0)");
|
||||
if (!touchOk) failures++;
|
||||
|
||||
Console.WriteLine(failures == 0 ? "InterferenceTest: 全部通過" : $"InterferenceTest: {failures} 項失敗");
|
||||
Console.WriteLine(failures == 0 ? "InterferenceTest: all passed" : $"InterferenceTest: {failures} failed");
|
||||
return failures;
|
||||
}
|
||||
|
||||
private static int Check(string name, bool actual, bool expected)
|
||||
{
|
||||
bool ok = actual == expected;
|
||||
Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name}({actual})");
|
||||
Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name} ({actual})");
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
/// <summary>邊長 10 的四面體,offset 平移</summary>
|
||||
/// <summary>A tetrahedron with side length 10, translated by offset.</summary>
|
||||
private static MeshGeometry3D Tetra(Vector3D offset)
|
||||
{
|
||||
Point3D P(double x, double y, double z) => new Point3D(x, y, z) + offset;
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Windows.Media.Media3D;
|
||||
using CADability.GeoObject;
|
||||
using STPViewer.Models;
|
||||
using STPViewer.Services;
|
||||
using STPViewer.ViewModels;
|
||||
using IOPath = System.IO.Path;
|
||||
|
||||
namespace SmokeTest;
|
||||
|
||||
internal static class LocalizationTest
|
||||
{
|
||||
private static readonly Regex FormatIndex = new(@"(?<!\{)\{(\d+)(?:[^{}]*)\}(?!\})", RegexOptions.Compiled);
|
||||
|
||||
public static int Run()
|
||||
{
|
||||
int failures = 0;
|
||||
Exception? error = null;
|
||||
var thread = new Thread(() =>
|
||||
{
|
||||
try { failures = RunCore(); }
|
||||
catch (Exception ex) { error = ex; }
|
||||
});
|
||||
thread.SetApartmentState(ApartmentState.STA);
|
||||
thread.Start();
|
||||
thread.Join();
|
||||
if (error is not null)
|
||||
{
|
||||
Console.WriteLine($"LocalizationTest: {error}");
|
||||
return 1;
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
private static int RunCore()
|
||||
{
|
||||
int failures = CheckTemporaryPacks() + CheckBundledPacks() + CheckViewModel() + CheckSettings();
|
||||
Console.WriteLine(failures == 0 ? "LocalizationTest: all passed" : $"LocalizationTest: {failures} failed");
|
||||
return failures;
|
||||
}
|
||||
|
||||
private static int CheckTemporaryPacks()
|
||||
{
|
||||
int failures = 0;
|
||||
string directory = IOPath.Combine(IOPath.GetTempPath(), "stpviewer_i18n_" + Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
failures += Check("missing directory loads without languages", new LocalizationService(directory).Languages.Count == 0);
|
||||
Directory.CreateDirectory(directory);
|
||||
failures += Check("empty directory loads without languages", new LocalizationService(directory).Languages.Count == 0);
|
||||
WritePack(directory, "en", "English", new()
|
||||
{
|
||||
["Ui.Sample"] = "View", ["Ui.Fallback"] = "Fallback {0}",
|
||||
["Ui.BadBrace"] = "English {0}", ["Ui.BadIndex"] = "English {0}",
|
||||
["Ui.BothBad"] = "English {"
|
||||
});
|
||||
WritePack(directory, "zh_cn", "Simplified", new()
|
||||
{
|
||||
["Ui.Sample"] = "CN_VIEW", ["Ui.BadBrace"] = "Chinese {",
|
||||
["Ui.BadIndex"] = "Chinese {1}", ["Ui.BothBad"] = "Chinese {"
|
||||
});
|
||||
WritePack(directory, "zh_tw", "Traditional", new() { ["Ui.Sample"] = "TW_VIEW" });
|
||||
WritePack(directory, "pirate_dialect", "Pirate", new() { ["Ui.Sample"] = "ARR_VIEW" });
|
||||
File.WriteAllText(IOPath.Combine(directory, "broken.json"), "{invalid json");
|
||||
File.WriteAllText(IOPath.Combine(directory, "no_key.json"), "{\"data\":{\"Ui.Sample\":\"unused\"}}");
|
||||
File.WriteAllText(IOPath.Combine(directory, "no_data.json"), "{\"key\":\"Missing data\"}");
|
||||
File.WriteAllText(IOPath.Combine(directory, "non_string.json"), "{\"key\":\"Invalid\",\"data\":{\"Ui.Sample\":42}}");
|
||||
|
||||
CultureInfo oldUi = CultureInfo.CurrentUICulture;
|
||||
LocalizationService service;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("en-US");
|
||||
service = new LocalizationService(directory);
|
||||
}
|
||||
finally { CultureInfo.CurrentUICulture = oldUi; }
|
||||
|
||||
failures += Check("valid packs isolated from malformed files", service.Languages.Count == 4 &&
|
||||
service.Languages.Select(l => l.Id).Order().SequenceEqual(new[] { "en", "pirate_dialect", "zh_cn", "zh_tw" }));
|
||||
failures += Check("display names and data loaded", service.Languages.Any(l =>
|
||||
l.Id == "pirate_dialect" && l.Key == "Pirate" && l.Data["Ui.Sample"] == "ARR_VIEW"));
|
||||
failures += Check("English selected from system culture", service.Language == "en");
|
||||
failures += Check("legacy Chinese IDs resolved", service.ResolveLanguage("zh-CN") == "zh_cn" &&
|
||||
service.ResolveLanguage("zh-TW") == "zh_tw");
|
||||
failures += Check("Chinese script and region aliases resolved", service.ResolveLanguage("zh-Hans") == "zh_cn" &&
|
||||
service.ResolveLanguage("zh-SG") == "zh_cn" && service.ResolveLanguage("zh-Hant") == "zh_tw" &&
|
||||
service.ResolveLanguage("zh-HK") == "zh_tw" && service.ResolveLanguage("zh-MO") == "zh_tw");
|
||||
failures += Check("custom language ID resolved", service.ResolveLanguage("pirate-dialect") == "pirate_dialect");
|
||||
failures += Check("unknown language falls back", service.ResolveLanguage("unknown") == "en");
|
||||
failures += Check("invalid custom ID falls back", service.ResolveLanguage("pirate_other") == "en");
|
||||
|
||||
CultureInfo beforeNull = CultureInfo.CurrentUICulture;
|
||||
try
|
||||
{
|
||||
CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo("zh-HK");
|
||||
failures += Check("null language uses system UI culture", service.ResolveLanguage(null) == "zh_tw");
|
||||
}
|
||||
finally { CultureInfo.CurrentUICulture = beforeNull; }
|
||||
|
||||
LanguageDefinition chinese = service.Languages.Single(l => l.Id == "zh_cn");
|
||||
bool selectedNotified = false, indexerNotified = false;
|
||||
chinese.PropertyChanged += (_, e) => selectedNotified |= e.PropertyName == nameof(LanguageDefinition.IsSelected);
|
||||
((INotifyPropertyChanged)service).PropertyChanged += (_, e) => indexerNotified |= e.PropertyName == "Item[]";
|
||||
service.SetLanguage("zh-CN");
|
||||
failures += Check("selection and indexer notify", service.Language == "zh_cn" &&
|
||||
chinese.IsSelected && selectedNotified && indexerNotified);
|
||||
failures += Check("selected data and fallback", service["Ui.Sample"] == "CN_VIEW" &&
|
||||
service["Ui.Fallback"] == "Fallback {0}" && service["Ui.Missing"] == "Ui.Missing");
|
||||
failures += Check("malformed brace uses English format", service.FormatText("Ui.BadBrace", "value") == "English value");
|
||||
failures += Check("missing format argument uses English format", service.FormatText("Ui.BadIndex", "value") == "English value");
|
||||
failures += Check("invalid English format returns key", service.FormatText("Ui.BothBad", "value") == "Ui.BothBad");
|
||||
service.SetLanguage("pirate_dialect");
|
||||
failures += Check("custom language selected", service.Language == "pirate_dialect" &&
|
||||
service["Ui.Sample"] == "ARR_VIEW");
|
||||
|
||||
WritePack(directory, "new_language", "New language", new() { ["Ui.Sample"] = "NEW_VIEW" });
|
||||
WritePack(directory, "en", "English", new() { ["Ui.Sample"] = "Updated view", ["Ui.Fallback"] = "Fallback {0}" });
|
||||
failures += Check("existing instance does not hot reload", service.Languages.Count == 4 &&
|
||||
service.Languages.Single(l => l.Id == "en").Data["Ui.Sample"] == "View");
|
||||
var reloaded = new LocalizationService(directory);
|
||||
failures += Check("new instance rescans files and values", reloaded.Languages.Count == 5 &&
|
||||
reloaded.Languages.Single(l => l.Id == "en").Data["Ui.Sample"] == "Updated view");
|
||||
|
||||
File.Delete(IOPath.Combine(directory, "zh_tw.json"));
|
||||
var withoutTraditional = new LocalizationService(directory);
|
||||
failures += Check("missing language not listed", withoutTraditional.Languages.All(l => l.Id != "zh_tw"));
|
||||
failures += Check("missing legacy ID falls back", withoutTraditional.ResolveLanguage("zh-TW") == "en");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(directory)) Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
private static void WritePack(string directory, string id, string name, Dictionary<string, string> data) =>
|
||||
File.WriteAllText(IOPath.Combine(directory, id + ".json"), JsonSerializer.Serialize(new { key = name, data }));
|
||||
|
||||
private static int CheckBundledPacks()
|
||||
{
|
||||
int failures = 0;
|
||||
LocalizationService service = LocalizationService.Instance;
|
||||
failures += Check("bundled languages available", new[] { "en", "zh_cn", "zh_tw" }
|
||||
.All(id => service.Languages.Any(l => l.Id == id)));
|
||||
LanguageDefinition? english = service.Languages.FirstOrDefault(l => l.Id == "en");
|
||||
if (english is null) return failures;
|
||||
failures += Check("bundled English data nonempty", english.Data.Count > 0 &&
|
||||
english.Data.Values.All(v => !string.IsNullOrWhiteSpace(v)));
|
||||
int formatFailures = 0;
|
||||
foreach (LanguageDefinition language in service.Languages)
|
||||
foreach ((string key, string value) in language.Data)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) formatFailures++;
|
||||
int[] indices = Indices(value);
|
||||
if (english.Data.TryGetValue(key, out string? fallback) && !Indices(fallback).SequenceEqual(indices))
|
||||
{
|
||||
Console.WriteLine($" FAIL: {language.Id} format arguments differ for {key}");
|
||||
formatFailures++;
|
||||
}
|
||||
object[] args = Enumerable.Range(0, indices.DefaultIfEmpty(-1).Max() + 1)
|
||||
.Select(i => (object)i).ToArray();
|
||||
try { _ = string.Format(CultureInfo.InvariantCulture, value, args); }
|
||||
catch (FormatException)
|
||||
{
|
||||
Console.WriteLine($" FAIL: {language.Id} format syntax for {key}");
|
||||
formatFailures++;
|
||||
}
|
||||
}
|
||||
failures += Check("bundled data and format strings valid", formatFailures == 0);
|
||||
failures += Check("missing key returns key", service["__missing_localization_key__"] == "__missing_localization_key__");
|
||||
return failures;
|
||||
}
|
||||
|
||||
private static int CheckViewModel()
|
||||
{
|
||||
int failures = 0;
|
||||
LocalizationService service = LocalizationService.Instance;
|
||||
string originalLanguage = service.Language;
|
||||
CultureInfo numericCulture = CultureInfo.CurrentCulture;
|
||||
try
|
||||
{
|
||||
service.SetLanguage("en");
|
||||
var vm = new MainViewModel();
|
||||
vm.InitializeLocalization();
|
||||
try
|
||||
{
|
||||
string oldLabel = vm.QuickBar["Import"].Label;
|
||||
bool labelChanged = false;
|
||||
vm.QuickBar["Import"].PropertyChanged += (_, e) =>
|
||||
labelChanged |= e.PropertyName == nameof(QuickBarItemViewModel.Label);
|
||||
|
||||
MeasurementResult result = new MeasurementService().MeasureDistance(
|
||||
new Point3D(0, 0, 0), new Point3D(25.4, 0, 0), "D1", 0.2);
|
||||
vm.Measurements.Add(result);
|
||||
vm.UseInch = true;
|
||||
string oneInch = 1d.ToString("F4", numericCulture) + " in";
|
||||
string beforeTitle = result.Title, beforeDetail = result.Detail;
|
||||
string beforeLabel = result.DynamicLabels.Single().Label.Text;
|
||||
var (distanceLabel, distanceTextFor) = result.DynamicLabels.Single();
|
||||
int labelRefreshes = 0;
|
||||
result.DynamicLabels[0] = (distanceLabel, u =>
|
||||
{
|
||||
labelRefreshes++;
|
||||
return distanceTextFor(u);
|
||||
});
|
||||
bool titleChanged = false, detailChanged = false;
|
||||
result.PropertyChanged += (_, e) =>
|
||||
{
|
||||
titleChanged |= e.PropertyName == nameof(MeasurementResult.Title);
|
||||
detailChanged |= e.PropertyName == nameof(MeasurementResult.Detail);
|
||||
};
|
||||
|
||||
vm.ChangeLanguageCommand.Execute("zh_tw");
|
||||
failures += Check("VM selects language", service.Language == "zh_tw" &&
|
||||
service.Languages.Single(l => l.Id == "zh_tw").IsSelected);
|
||||
failures += Check("existing distance detail translated", beforeDetail != result.Detail &&
|
||||
titleChanged && detailChanged && result.Detail.Contains(oneInch, StringComparison.Ordinal));
|
||||
failures += Check("distance and number culture preserved", vm.UseInch &&
|
||||
CultureInfo.CurrentCulture.Equals(numericCulture) && beforeTitle == result.Title &&
|
||||
result.Title.Contains(oneInch, StringComparison.Ordinal));
|
||||
failures += Check("3D distance label refreshed", labelRefreshes == 1 &&
|
||||
beforeLabel == oneInch && result.DynamicLabels.Single().Label.Text == beforeLabel);
|
||||
failures += Check("quick bar label refreshed", labelChanged && vm.QuickBar["Import"].Label != oldLabel);
|
||||
|
||||
LanguageDefinition selected = service.Languages.Single(l => l.Id == "zh_tw");
|
||||
bool reselected = false;
|
||||
selected.PropertyChanged += (_, e) => reselected |= e.PropertyName == nameof(LanguageDefinition.IsSelected);
|
||||
vm.ChangeLanguageCommand.Execute("zh_tw");
|
||||
failures += Check("reselecting current language restores check", reselected && selected.IsSelected);
|
||||
}
|
||||
finally { vm.DisposeLocalization(); }
|
||||
failures += CheckImportedNames(service);
|
||||
}
|
||||
finally { service.SetLanguage(originalLanguage); }
|
||||
return failures;
|
||||
}
|
||||
|
||||
private static int CheckImportedNames(LocalizationService service)
|
||||
{
|
||||
int failures = 0;
|
||||
MethodInfo blockNode = typeof(StepImportService).GetMethod("BlockNode", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
var importer = new StepImportService();
|
||||
service.SetLanguage("en");
|
||||
|
||||
ImportedNode Import(Block block) => (ImportedNode)blockNode.Invoke(importer, [block])!;
|
||||
var namedSolidBlock = Block.Construct();
|
||||
namedSolidBlock.Name = "Solid";
|
||||
namedSolidBlock.Add(Solid.Construct());
|
||||
ImportedNode namedSolid = Import(namedSolidBlock);
|
||||
var namedShellBlock = Block.Construct();
|
||||
namedShellBlock.Name = "Shell";
|
||||
namedShellBlock.Add(Shell.Construct());
|
||||
ImportedNode namedShell = Import(namedShellBlock);
|
||||
var defaultSolidBlock = Block.Construct();
|
||||
defaultSolidBlock.Add(Solid.Construct());
|
||||
ImportedNode defaultSolid = Import(defaultSolidBlock);
|
||||
var defaultShellBlock = Block.Construct();
|
||||
defaultShellBlock.Add(Shell.Construct());
|
||||
ImportedNode defaultShellLeaf = Import(defaultShellBlock).Children.Single();
|
||||
var collisionBlock = Block.Construct();
|
||||
var realComponentShell = Shell.Construct();
|
||||
realComponentShell.Name = "Component";
|
||||
collisionBlock.Add(realComponentShell);
|
||||
ImportedNode collision = Import(collisionBlock);
|
||||
|
||||
ModelNodeViewModel ViewModel(ImportedNode node) => new(0)
|
||||
{
|
||||
Name = node.Name,
|
||||
NameKey = node.NameKey,
|
||||
NameNumber = node.NameNumber
|
||||
};
|
||||
var namedSolidVm = ViewModel(namedSolid);
|
||||
var namedShellVm = ViewModel(namedShell);
|
||||
var defaultSolidVm = ViewModel(defaultSolid);
|
||||
var defaultShellVm = ViewModel(defaultShellLeaf);
|
||||
var collisionVm = ViewModel(collision);
|
||||
service.SetLanguage("zh_cn");
|
||||
failures += Check("explicit solid name retained", namedSolid.NameKey is null && namedSolidVm.Name == "Solid");
|
||||
failures += Check("real block name retained", namedShell.NameKey is null && namedShellVm.Name == "Shell");
|
||||
failures += Check("default solid name translated", defaultSolid.NameKey == "Runtime.SolidName" &&
|
||||
defaultSolidVm.Name == service["Runtime.SolidName"]);
|
||||
failures += Check("default shell name translated", defaultShellLeaf.NameKey == "Runtime.ShellName" &&
|
||||
defaultShellVm.Name == service["Runtime.ShellName"]);
|
||||
failures += Check("real child name wins collision", collision.NameKey is null && collisionVm.Name == "Component");
|
||||
return failures;
|
||||
}
|
||||
|
||||
private static int CheckSettings()
|
||||
{
|
||||
var settings = new AppSettings
|
||||
{
|
||||
Language = "zh-CN", WindowWidth = 1200, WindowHeight = 800, WindowLeft = 100, WindowTop = 100
|
||||
};
|
||||
AppSettings? restored = JsonSerializer.Deserialize<AppSettings>(JsonSerializer.Serialize(settings));
|
||||
return Check("legacy ID round trips through settings JSON", restored?.Language == "zh-CN" &&
|
||||
LocalizationService.Instance.ResolveLanguage(restored.Language) == "zh_cn");
|
||||
}
|
||||
|
||||
private static int[] Indices(string format) => FormatIndex.Matches(format)
|
||||
.Select(m => int.Parse(m.Groups[1].Value, CultureInfo.InvariantCulture))
|
||||
.Distinct().Order().ToArray();
|
||||
|
||||
private static int Check(string name, bool ok)
|
||||
{
|
||||
Console.WriteLine($" {(ok ? "PASS" : "FAIL")}: {name}");
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// 測試輔助:用 CADability 內嵌的 netDxf 產生標準 DXF 測試檔
|
||||
// Generate a small standard DXF test file with the bundled netDxf library.
|
||||
using netDxf;
|
||||
using netDxf.Entities;
|
||||
|
||||
@@ -12,6 +12,6 @@ internal static class MakeDxf
|
||||
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}");
|
||||
Console.WriteLine($"Generated {path}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,12 @@ using System.IO;
|
||||
|
||||
namespace SmokeTest;
|
||||
|
||||
/// <summary>產生測試用 binary STL:一片 gridN×gridN 的網格,每格 2 個三角形(波浪起伏,避免全共面)。</summary>
|
||||
/// <summary>Generate a binary STL grid with two triangles per cell and noncoplanar vertices.</summary>
|
||||
internal static class MakeStl
|
||||
{
|
||||
public static void Run(string path, int triCount)
|
||||
{
|
||||
// 每格 2 三角,格數 = triCount/2;邊長 grid = ceil(sqrt(cells))
|
||||
// Two triangles per cell; use a square grid large enough for the requested count.
|
||||
int cells = System.Math.Max(1, triCount / 2);
|
||||
int grid = (int)System.Math.Ceiling(System.Math.Sqrt(cells));
|
||||
int tris = grid * grid * 2;
|
||||
@@ -36,6 +36,6 @@ internal static class MakeStl
|
||||
WriteTri(bw, x0, y0, Z(x, y), x1, y1, Z(x + 1, y + 1), x0, y1, Z(x, y + 1));
|
||||
}
|
||||
|
||||
Console.WriteLine($"寫出 {tris:N0} 三角形 → {path} ({new FileInfo(path).Length:N0} bytes)");
|
||||
Console.WriteLine($"Wrote {tris:N0} triangles to {path} ({new FileInfo(path).Length:N0} bytes)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 無 UI smoke test:驗證 CAD 檔解析 → 三角化 → 裝配樹 整條匯入管線
|
||||
// Headless smoke tests for CAD import, triangulation, and the assembly tree.
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using STPViewer.Services;
|
||||
@@ -7,7 +7,7 @@ Console.OutputEncoding = System.Text.Encoding.UTF8;
|
||||
|
||||
if (args.Length == 0)
|
||||
{
|
||||
Console.WriteLine("用法: SmokeTest [--tree] <file.stp|.stl|.dxf> [...]");
|
||||
Console.WriteLine("Usage: SmokeTest [--tree] <file.stp|.stl|.dxf> [...]");
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ if (args[0] == "--interference-test")
|
||||
if (args[0] == "--align-test")
|
||||
return SmokeTest.AlignTest.Run() == 0 ? 0 : 2;
|
||||
|
||||
if (args[0] == "--localization-test")
|
||||
return SmokeTest.LocalizationTest.Run() == 0 ? 0 : 2;
|
||||
|
||||
if (args[0] == "--bbox")
|
||||
return SmokeTest.BBoxDump.Run(args[1]);
|
||||
|
||||
@@ -47,7 +50,7 @@ if (args[0] == "--export-test")
|
||||
if (args[0] == "--stl-export-test")
|
||||
return SmokeTest.StlExportTest.Run(args.Length > 1 ? args[1] : null) == 0 ? 0 : 2;
|
||||
|
||||
var service = new StepImportService { Progress = s => Console.WriteLine($" [階段] {s}") };
|
||||
var service = new StepImportService { Progress = s => Console.WriteLine($" [stage] {s}") };
|
||||
int failed = 0;
|
||||
|
||||
foreach (string path in args)
|
||||
@@ -64,7 +67,7 @@ foreach (string path in args)
|
||||
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(" --- 裝配樹 ---");
|
||||
Console.WriteLine(" --- assembly tree ---");
|
||||
PrintNode(r, 1);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -81,6 +84,6 @@ 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")}]");
|
||||
$"[solids={n.SolidCount}, faces={n.FaceCount}, tris={n.TriangleCount:N0}{seg}{(n.HasBrep ? "" : ", no B-rep")}]");
|
||||
foreach (ImportedNode c in n.Children) PrintNode(c, indent + 1);
|
||||
}
|
||||
|
||||
@@ -5,15 +5,13 @@ using System.Text;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Media3D;
|
||||
using STPViewer.Services;
|
||||
using IOPath = System.IO.Path; // CADability.GeoObject.Path(曲線)撞名
|
||||
using IOPath = System.IO.Path; // Avoid the CADability.GeoObject.Path name collision.
|
||||
|
||||
namespace SmokeTest;
|
||||
|
||||
/// <summary>
|
||||
/// STL 匯出驗證(無 UI):
|
||||
/// 1) binary — 已知立方體網格寫出 → 解析 header/三角形數/座標範圍比對(含縮放)
|
||||
/// 2) ASCII — facet 數與 solid/endsolid 結構
|
||||
/// 3) 退化三角形(零面積)自動濾除
|
||||
/// Headless STL export checks:
|
||||
/// binary round trip, ASCII facet structure, and degenerate triangle filtering.
|
||||
/// </summary>
|
||||
public static class StlExportTest
|
||||
{
|
||||
@@ -24,20 +22,20 @@ public static class StlExportTest
|
||||
string dir = IOPath.Combine(IOPath.GetTempPath(), "stpviewer_stl_test");
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
MeshGeometry3D cube = BuildCube(10); // 10×10×10 mm、12 三角形
|
||||
MeshGeometry3D cube = BuildCube(10); // 10x10x10 mm, 12 triangles.
|
||||
|
||||
// 1) binary:三角形數 + 縮放後座標範圍
|
||||
// Binary triangle count and scaled coordinate range.
|
||||
{
|
||||
string f = IOPath.Combine(dir, "cube_bin.stl");
|
||||
double scale = 1.0 / 25.4; // mm → inch
|
||||
double scale = 1.0 / 25.4; // mm to inches.
|
||||
int written = StlExportService.Write(f, new[] { cube }, ascii: false, scale);
|
||||
(uint count, float min, float max) = ReadBinary(f);
|
||||
bool ok = written == 12 && count == 12
|
||||
&& Math.Abs(min - 0f) < 1e-5 && Math.Abs(max - 10f / 25.4f) < 1e-4;
|
||||
Report(ref failed, ok, $"binary 寫出/回讀 12 三角形、inch 縮放範圍 [{min:F4}, {max:F4}]");
|
||||
Report(ref failed, ok, $"binary round trip: 12 triangles, inch range [{min:F4}, {max:F4}]");
|
||||
}
|
||||
|
||||
// 2) ASCII:facet 數 + 結構
|
||||
// ASCII facet count and structure.
|
||||
{
|
||||
string f = IOPath.Combine(dir, "cube_ascii.stl");
|
||||
int written = StlExportService.Write(f, new[] { cube }, ascii: true, scale: 1.0);
|
||||
@@ -46,15 +44,15 @@ public static class StlExportTest
|
||||
bool ok = written == 12 && facets == 12
|
||||
&& text.StartsWith("solid ", StringComparison.Ordinal)
|
||||
&& text.Contains("endsolid");
|
||||
Report(ref failed, ok, $"ASCII 結構完整、facet = {facets}");
|
||||
Report(ref failed, ok, $"ASCII structure valid, facets = {facets}");
|
||||
}
|
||||
|
||||
// 3) 退化三角形濾除:加一個零面積三角形 → 仍是 12
|
||||
// A zero-area triangle must be filtered from the export.
|
||||
{
|
||||
var degenerate = new MeshGeometry3D();
|
||||
degenerate.Positions.Add(new Point3D(0, 0, 0));
|
||||
degenerate.Positions.Add(new Point3D(1, 1, 1));
|
||||
degenerate.Positions.Add(new Point3D(2, 2, 2)); // 共線 → 面積 0
|
||||
degenerate.Positions.Add(new Point3D(2, 2, 2)); // Collinear: zero area.
|
||||
degenerate.TriangleIndices.Add(0);
|
||||
degenerate.TriangleIndices.Add(1);
|
||||
degenerate.TriangleIndices.Add(2);
|
||||
@@ -64,17 +62,16 @@ public static class StlExportTest
|
||||
int written = StlExportService.Write(f, new[] { cube, degenerate }, ascii: false, scale: 1.0);
|
||||
(uint count, _, _) = ReadBinary(f);
|
||||
bool ok = written == 12 && count == 12;
|
||||
Report(ref failed, ok, $"退化三角形濾除(寫出 {written})");
|
||||
Report(ref failed, ok, $"degenerate triangle filtered ({written} written)");
|
||||
}
|
||||
|
||||
// 4)(可選)真實 STEP:B-rep 精細重新三角化 → 寫 binary STL 驗證
|
||||
//(不測「較粗」:CADability GetTriangulation 對比快取粗的精度回傳既有快取,粗化無效 → 功能已移除)
|
||||
// Optional real STEP input: retessellate B-rep geometry at fine precision.
|
||||
if (stepPath is not null)
|
||||
{
|
||||
var data = new StepImportService().Import(stepPath);
|
||||
var geos = Collect(data.Root).ToList();
|
||||
int importTris = data.Root.TriangleCount;
|
||||
Console.WriteLine($" 來源 {IOPath.GetFileName(stepPath)}:{geos.Count} 個 B-rep 物件、匯入網格 {importTris:N0} 三角形");
|
||||
Console.WriteLine($" Source {IOPath.GetFileName(stepPath)}: {geos.Count} B-rep objects, {importTris:N0} imported triangles");
|
||||
|
||||
var meshes = StlExportService.Tessellate(
|
||||
geos, StlExportService.PrecisionFactor(StlMeshQuality.Fine));
|
||||
@@ -82,13 +79,13 @@ public static class StlExportTest
|
||||
int written = StlExportService.Write(f, meshes, ascii: false, scale: 1.0);
|
||||
(uint count, _, _) = ReadBinary(f);
|
||||
Report(ref failed, meshes.Count > 0 && written > 0 && count == written,
|
||||
$"精細重新三角化 {meshes.Count} 面 → {written:N0} 三角形");
|
||||
// CADability 三角形數對精度非單調(0.4× 反而比 1× 少),Fine=0.15× 實測明顯更細
|
||||
$"fine retessellation: {meshes.Count} faces, {written:N0} triangles");
|
||||
// CADability's triangle count is not monotonic in precision; 0.15x is measurably finer.
|
||||
Report(ref failed, written > importTris,
|
||||
$"精細({written:N0})> 匯入預設({importTris:N0})");
|
||||
$"fine ({written:N0}) > imported default ({importTris:N0})");
|
||||
}
|
||||
|
||||
Console.WriteLine(failed == 0 ? "StlExportTest: PASS" : $"StlExportTest: FAIL({failed} 項)");
|
||||
Console.WriteLine(failed == 0 ? "StlExportTest: PASS" : $"StlExportTest: FAIL ({failed} checks)");
|
||||
return failed;
|
||||
}
|
||||
|
||||
@@ -106,7 +103,7 @@ public static class StlExportTest
|
||||
if (!ok) failed++;
|
||||
}
|
||||
|
||||
/// <summary>0..size 的軸對齊立方體,12 個三角形(法向不講究,STL 寫出時自算)</summary>
|
||||
/// <summary>Axis-aligned cube from 0 to size, with 12 triangles.</summary>
|
||||
private static MeshGeometry3D BuildCube(double s)
|
||||
{
|
||||
var m = new MeshGeometry3D();
|
||||
@@ -131,7 +128,7 @@ public static class StlExportTest
|
||||
using var br = new BinaryReader(File.OpenRead(path));
|
||||
byte[] header = br.ReadBytes(80);
|
||||
if (Encoding.ASCII.GetString(header, 0, 5) == "solid")
|
||||
throw new InvalidDataException("binary STL header 不可以 solid 開頭");
|
||||
throw new InvalidDataException("Binary STL header must not begin with solid");
|
||||
uint count = br.ReadUInt32();
|
||||
float min = float.MaxValue, max = float.MinValue;
|
||||
for (uint t = 0; t < count; t++)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 實驗工具:印出 STEP 匯入後的物件階層(HierarchyToBlocks=true)
|
||||
// Print the STEP object hierarchy after import with HierarchyToBlocks enabled.
|
||||
using CADability;
|
||||
using CADability.GeoObject;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user