From 22252291793669c9a0cbe39675aa3b9df954d437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kiy=C5=8D=20Jo?= Date: Sun, 6 Sep 2026 13:42:21 +0800 Subject: [PATCH 1/7] fix(rhino): batch Win2D mesh drawing and enforce frame budgets --- .../Controls/ThreeDmViewportControl.xaml.cs | 372 ++++++++++++++---- 1 file changed, 291 insertions(+), 81 deletions(-) diff --git a/src/SpatialViewer.App/Controls/ThreeDmViewportControl.xaml.cs b/src/SpatialViewer.App/Controls/ThreeDmViewportControl.xaml.cs index e2987ff..618e1c8 100644 --- a/src/SpatialViewer.App/Controls/ThreeDmViewportControl.xaml.cs +++ b/src/SpatialViewer.App/Controls/ThreeDmViewportControl.xaml.cs @@ -105,107 +105,281 @@ _session.RenderScene is not { } scene || var basis = CameraBasis.Create(camera); var policies = scene.MeshDrawPolicies.ToDictionary(item => item.GeometryIndex); var geometries = scene.SharedMeshes.Geometries.ToDictionary(item => item.GeometryIndex); - var fills = new List(); + var fillTrianglesRemaining = 80_000; + var wireSegmentsRemaining = 120_000; + var curveSegmentsRemaining = 100_000; + var pointsRemaining = 50_000; + + var batches = new List(scene.SharedMeshes.Instances.Count); foreach (var instance in scene.SharedMeshes.Instances) { - if (!geometries.TryGetValue(instance.GeometryIndex, out var geometry)) continue; + if (!geometries.TryGetValue(instance.GeometryIndex, out var geometry)) + { + continue; + } + policies.TryGetValue(instance.GeometryIndex, out var policy); policy ??= new ThreeDmPreparedMeshDrawPolicy(instance.GeometryIndex, true, false); - var color = ToColor(instance.Appearance.ColorArgb, instance.Appearance.Opacity); + var center = Center(geometry.Bounds); + var worldCenter = TransformPoint(center, instance.Transform); + var depth = Dot(Subtract(worldCenter, camera.Location), basis.Forward); + batches.Add(new ProjectedMeshBatch(instance, geometry, policy, depth)); + } - if (policy.DrawFill) + foreach (var batch in batches.OrderByDescending(item => item.Depth)) + { + var projected = ProjectVertices( + batch.Geometry, + batch.Instance.Transform, + camera, + basis, + aspect, + width, + height); + var triangleCount = batch.Geometry.Indices.Count / 3; + var drawFill = batch.Policy.DrawFill && + triangleCount > 0 && + triangleCount <= fillTrianglesRemaining; + var color = ResolveDisplayColor( + batch.Instance.Appearance.ColorArgb, + batch.Instance.Appearance.Opacity); + + if (drawFill) { - for (var index = 0; index + 2 < geometry.Indices.Count; index += 3) - { - var aIndex = geometry.Indices[index]; - var bIndex = geometry.Indices[index + 1]; - var cIndex = geometry.Indices[index + 2]; - if (!TryVertex(geometry, aIndex, instance.Transform, out var a) || - !TryVertex(geometry, bIndex, instance.Transform, out var b) || - !TryVertex(geometry, cIndex, instance.Transform, out var c) || - !Project(a, camera, basis, aspect, width, height, out var pa) || - !Project(b, camera, basis, aspect, width, height, out var pb) || - !Project(c, camera, basis, aspect, width, height, out var pc)) - { - continue; - } - - fills.Add(new ProjectedTriangle( - pa.Screen, - pb.Screen, - pc.Screen, - (pa.Depth + pb.Depth + pc.Depth) / 3, - color)); - } + DrawFilledMesh( + args.DrawingSession, + batch.Geometry, + projected, + color); + fillTrianglesRemaining -= triangleCount; + } + + if (batch.Policy.DrawWireIndices || !drawFill) + { + DrawWireMesh( + args.DrawingSession, + batch.Geometry, + projected, + ResolveDisplayColor( + batch.Instance.Appearance.ColorArgb, + Math.Max(batch.Instance.Appearance.Opacity, 0.72)), + ref wireSegmentsRemaining); } } - foreach (var triangle in fills.OrderByDescending(item => item.Depth)) + foreach (var curve in scene.Curves) { - FillTriangle(args.DrawingSession, triangle); + if (curveSegmentsRemaining <= 0) + { + break; + } + + DrawCurve( + args.DrawingSession, + curve, + camera, + basis, + aspect, + width, + height, + ResolveDisplayColor(curve.Appearance.ColorArgb, curve.Appearance.Opacity), + ref curveSegmentsRemaining); } - foreach (var instance in scene.SharedMeshes.Instances) + foreach (var pointSet in scene.PointSets) { - if (!geometries.TryGetValue(instance.GeometryIndex, out var geometry)) continue; - if (!policies.TryGetValue(instance.GeometryIndex, out var policy) || !policy.DrawWireIndices) continue; - var color = ToColor(instance.Appearance.ColorArgb, Math.Max(instance.Appearance.Opacity, 0.65)); - foreach (var (aIndex, bIndex) in GetWireEdges(geometry)) + if (pointsRemaining <= 0) { - if (!TryVertex(geometry, aIndex, instance.Transform, out var a) || - !TryVertex(geometry, bIndex, instance.Transform, out var b) || - !Project(a, camera, basis, aspect, width, height, out var pa) || - !Project(b, camera, basis, aspect, width, height, out var pb)) + break; + } + + var color = ResolveDisplayColor(pointSet.Appearance.ColorArgb, pointSet.Appearance.Opacity); + var stride = Math.Max(1, (int)Math.Ceiling((double)pointSet.Points.Count / Math.Max(1, pointsRemaining))); + for (var index = 0; index < pointSet.Points.Count && pointsRemaining > 0; index += stride) + { + if (Project(ToPoint(pointSet.Points[index]), camera, basis, aspect, width, height, out var projected)) { - continue; + args.DrawingSession.FillCircle(projected.Screen, 2.5f, color); + pointsRemaining--; } - - args.DrawingSession.DrawLine(pa.Screen, pb.Screen, color, 1f); } } - foreach (var curve in scene.Curves) + DrawSelectionOverlay(args.DrawingSession, scene, camera, basis, aspect, width, height); + } + + private static ProjectedPoint?[] ProjectVertices( + ThreeDmSharedMeshGeometry geometry, + Transform3d transform, + ThreeDmCameraState camera, + CameraBasis basis, + double aspect, + double width, + double height) + { + var projected = new ProjectedPoint?[geometry.Vertices.Count]; + for (var index = 0; index < geometry.Vertices.Count; index++) { - var color = ToColor(curve.Appearance.ColorArgb, curve.Appearance.Opacity); - for (var index = 1; index < curve.Points.Count; index++) + if (!TryVertex(geometry, index, transform, out var point) || + !Project(point, camera, basis, aspect, width, height, out var screen)) { - var a = ToPoint(curve.Points[index - 1]); - var b = ToPoint(curve.Points[index]); - if (!Project(a, camera, basis, aspect, width, height, out var pa) || - !Project(b, camera, basis, aspect, width, height, out var pb)) - { - continue; - } + continue; + } - args.DrawingSession.DrawLine(pa.Screen, pb.Screen, color, 1f); + projected[index] = screen; + } + + return projected; + } + + private static void DrawFilledMesh( + CanvasDrawingSession drawingSession, + ThreeDmSharedMeshGeometry geometry, + IReadOnlyList projected, + Color color) + { + using var path = new CanvasPathBuilder(drawingSession); + var hasFigures = false; + for (var index = 0; index + 2 < geometry.Indices.Count; index += 3) + { + var aIndex = geometry.Indices[index]; + var bIndex = geometry.Indices[index + 1]; + var cIndex = geometry.Indices[index + 2]; + if ((uint)aIndex >= (uint)projected.Count || + (uint)bIndex >= (uint)projected.Count || + (uint)cIndex >= (uint)projected.Count || + projected[aIndex] is not { } a || + projected[bIndex] is not { } b || + projected[cIndex] is not { } c) + { + continue; } - if (curve.IsClosed && curve.Points.Count > 2) + path.BeginFigure(a.Screen); + path.AddLine(b.Screen); + path.AddLine(c.Screen); + path.EndFigure(CanvasFigureLoop.Closed); + hasFigures = true; + } + + if (!hasFigures) + { + return; + } + + using var geometryPath = CanvasGeometry.CreatePath(path); + drawingSession.FillGeometry(geometryPath, color); + } + + private void DrawWireMesh( + CanvasDrawingSession drawingSession, + ThreeDmSharedMeshGeometry geometry, + IReadOnlyList projected, + Color color, + ref int segmentBudget) + { + if (segmentBudget <= 0 || geometry.Indices.Count < 3) + { + return; + } + + var triangleCount = geometry.Indices.Count / 3; + var desiredTriangles = Math.Max(1, segmentBudget / 3); + var stride = Math.Max(1, (int)Math.Ceiling((double)triangleCount / desiredTriangles)); + using var path = new CanvasPathBuilder(drawingSession); + var hasFigures = false; + + for (var triangle = 0; triangle < triangleCount && segmentBudget >= 3; triangle += stride) + { + var index = triangle * 3; + var aIndex = geometry.Indices[index]; + var bIndex = geometry.Indices[index + 1]; + var cIndex = geometry.Indices[index + 2]; + if ((uint)aIndex >= (uint)projected.Count || + (uint)bIndex >= (uint)projected.Count || + (uint)cIndex >= (uint)projected.Count || + projected[aIndex] is not { } a || + projected[bIndex] is not { } b || + projected[cIndex] is not { } c) { - var a = ToPoint(curve.Points[^1]); - var b = ToPoint(curve.Points[0]); - if (Project(a, camera, basis, aspect, width, height, out var pa) && - Project(b, camera, basis, aspect, width, height, out var pb)) - { - args.DrawingSession.DrawLine(pa.Screen, pb.Screen, color, 1f); - } + continue; } + + AddLineFigure(path, a.Screen, b.Screen); + AddLineFigure(path, b.Screen, c.Screen); + AddLineFigure(path, c.Screen, a.Screen); + hasFigures = true; + segmentBudget -= 3; } - foreach (var pointSet in scene.PointSets) + if (!hasFigures) { - var color = ToColor(pointSet.Appearance.ColorArgb, pointSet.Appearance.Opacity); - foreach (var point in pointSet.Points) + return; + } + + using var geometryPath = CanvasGeometry.CreatePath(path); + drawingSession.DrawGeometry(geometryPath, color, 1f); + } + + private static void DrawCurve( + CanvasDrawingSession drawingSession, + ThreeDmRenderCurve curve, + ThreeDmCameraState camera, + CameraBasis basis, + double aspect, + double width, + double height, + Color color, + ref int segmentBudget) + { + if (curve.Points.Count < 2 || segmentBudget <= 0) + { + return; + } + + var segmentCount = curve.Points.Count - 1 + (curve.IsClosed ? 1 : 0); + var stride = Math.Max(1, (int)Math.Ceiling((double)segmentCount / Math.Max(1, segmentBudget))); + using var path = new CanvasPathBuilder(drawingSession); + var hasFigures = false; + + for (var index = 1; index < curve.Points.Count && segmentBudget > 0; index += stride) + { + var previous = Math.Max(0, index - 1); + if (!Project(ToPoint(curve.Points[previous]), camera, basis, aspect, width, height, out var a) || + !Project(ToPoint(curve.Points[index]), camera, basis, aspect, width, height, out var b)) { - if (Project(ToPoint(point), camera, basis, aspect, width, height, out var projected)) - { - args.DrawingSession.FillCircle(projected.Screen, 2.5f, color); - } + continue; } + + AddLineFigure(path, a.Screen, b.Screen); + hasFigures = true; + segmentBudget--; } - DrawSelectionOverlay(args.DrawingSession, scene, camera, basis, aspect, width, height); + if (curve.IsClosed && curve.Points.Count > 2 && segmentBudget > 0 && + Project(ToPoint(curve.Points[^1]), camera, basis, aspect, width, height, out var last) && + Project(ToPoint(curve.Points[0]), camera, basis, aspect, width, height, out var first)) + { + AddLineFigure(path, last.Screen, first.Screen); + hasFigures = true; + segmentBudget--; + } + + if (!hasFigures) + { + return; + } + + using var geometryPath = CanvasGeometry.CreatePath(path); + drawingSession.DrawGeometry(geometryPath, color, 1f); + } + + private static void AddLineFigure(CanvasPathBuilder path, Vector2 start, Vector2 end) + { + path.BeginFigure(start); + path.AddLine(end); + path.EndFigure(CanvasFigureLoop.Open); } private (int A, int B)[] GetWireEdges(ThreeDmSharedMeshGeometry geometry) @@ -338,17 +512,6 @@ private static bool Project( return true; } - private static void FillTriangle(CanvasDrawingSession session, ProjectedTriangle triangle) - { - using var path = new CanvasPathBuilder(session); - path.BeginFigure(triangle.A); - path.AddLine(triangle.B); - path.AddLine(triangle.C); - path.EndFigure(CanvasFigureLoop.Closed); - using var geometry = CanvasGeometry.CreatePath(path); - session.FillGeometry(geometry, triangle.Color); - } - private void Viewport_SizeChanged(object sender, SizeChangedEventArgs e) => Draw(); private void Viewport_PointerWheelChanged(object sender, PointerRoutedEventArgs e) @@ -364,7 +527,7 @@ private void Viewport_PointerWheelChanged(object sender, PointerRoutedEventArgs SourceFrustum = null, }; } - else if (Mode == ThreeDmViewerMode.Orbit) + else { var offset = Subtract(camera.Location, camera.Target); var factor = delta > 0 ? 0.85 : 1.0 / 0.85; @@ -643,22 +806,65 @@ private static double DistanceToSegment(Vector2 point, Vector2 start, Vector2 en private Color ParseCanvasColor() => _canvasColor == "#FFFFFF" ? Colors.White : Colors.Black; - private static Color ToColor(uint argb, double opacity) + private Color ResolveDisplayColor(uint argb, double opacity) { var alpha = (byte)((argb >> 24) & 0xFF); var red = (byte)((argb >> 16) & 0xFF); var green = (byte)((argb >> 8) & 0xFF); var blue = (byte)(argb & 0xFF); + var luminance = ((0.2126 * red) + (0.7152 * green) + (0.0722 * blue)) / 255.0; + + if (_canvasColor == "#000000" && luminance < 0.18) + { + var amount = luminance < 0.06 ? 0.78 : 0.58; + red = Blend(red, 235, amount); + green = Blend(green, 235, amount); + blue = Blend(blue, 235, amount); + } + else if (_canvasColor == "#FFFFFF" && luminance > 0.86) + { + red = Blend(red, 35, 0.72); + green = Blend(green, 35, 0.72); + blue = Blend(blue, 35, 0.72); + } + var combinedAlpha = (byte)Math.Clamp(alpha * Math.Clamp(opacity, 0, 1), 0, 255); return Color.FromArgb(combinedAlpha, red, green, blue); } + private static byte Blend(byte source, byte target, double amount) => + (byte)Math.Clamp(Math.Round(source + ((target - source) * amount)), 0, 255); + private static Vector3d Subtract(Point3d left, Point3d right) => new(left.X - right.X, left.Y - right.Y, left.Z - right.Z); private static Point3d Add(Point3d point, Vector3d vector) => new(point.X + vector.X, point.Y + vector.Y, point.Z + vector.Z); + private static Point3d Center(BoundingBox3d bounds) => + bounds.IsValid + ? new Point3d( + bounds.Min.X + ((bounds.Max.X - bounds.Min.X) * 0.5), + bounds.Min.Y + ((bounds.Max.Y - bounds.Min.Y) * 0.5), + bounds.Min.Z + ((bounds.Max.Z - bounds.Min.Z) * 0.5)) + : new Point3d(0, 0, 0); + + private static Point3d TransformPoint(Point3d source, Transform3d transform) + { + var x = (transform.M00 * source.X) + (transform.M01 * source.Y) + (transform.M02 * source.Z) + transform.M03; + var y = (transform.M10 * source.X) + (transform.M11 * source.Y) + (transform.M12 * source.Z) + transform.M13; + var z = (transform.M20 * source.X) + (transform.M21 * source.Y) + (transform.M22 * source.Z) + transform.M23; + var w = (transform.M30 * source.X) + (transform.M31 * source.Y) + (transform.M32 * source.Z) + transform.M33; + if (Math.Abs(w) > 1e-15 && Math.Abs(w - 1.0) > 1e-15) + { + x /= w; + y /= w; + z /= w; + } + + return new Point3d(x, y, z); + } + private static Vector3d Add(Vector3d left, Vector3d right) => new(left.X + right.X, left.Y + right.Y, left.Z + right.Z); @@ -704,7 +910,11 @@ public void Dispose() } private readonly record struct ProjectedPoint(Vector2 Screen, double Depth); - private readonly record struct ProjectedTriangle(Vector2 A, Vector2 B, Vector2 C, double Depth, Color Color); + private readonly record struct ProjectedMeshBatch( + ThreeDmSharedMeshInstance Instance, + ThreeDmSharedMeshGeometry Geometry, + ThreeDmPreparedMeshDrawPolicy Policy, + double Depth); private readonly record struct CameraBasis(Vector3d Forward, Vector3d Right, Vector3d Up) { From 34c1f492bf162e52ccb952b47b81705a15fe87c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kiy=C5=8D=20Jo?= Date: Sun, 6 Sep 2026 13:43:51 +0800 Subject: [PATCH 2/7] fix(rhino): build render scenes off the WinUI thread --- .../ThreeDmProductSession.cs | 37 +++++++++++++++---- .../Views/ThreeDmViewerView.xaml.cs | 18 +++++---- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/src/SpatialViewer.App/ThreeDmProductSession.cs b/src/SpatialViewer.App/ThreeDmProductSession.cs index 734bdad..0bc0a09 100644 --- a/src/SpatialViewer.App/ThreeDmProductSession.cs +++ b/src/SpatialViewer.App/ThreeDmProductSession.cs @@ -27,6 +27,8 @@ internal sealed class ThreeDmProductSession : INotifyPropertyChanged, IDisposabl private IReadOnlyList _viewPresets = Array.Empty(); private ThreeDmSelectionId? _selection; private bool _disposed; + private readonly SemaphoreSlim _renderBuildGate = new(1, 1); + private int _renderBuildVersion; public ThreeDmProductSession(string filePath) { @@ -173,7 +175,7 @@ await _session.OpenProgressivelyAsync( }); if (_disposed) return; - RebuildRenderScene(); + await RebuildRenderSceneAsync(); var standard = _session.GetStandardViewPresets(); var named = _session.GetNamedViewPresets(); ViewPresets = standard.Concat(named).ToArray(); @@ -202,19 +204,19 @@ public async Task ReloadAsync() await LoadAsync(); } - public void SetDisplayMode(ThreeDmRenderDisplayMode mode) + public async Task SetDisplayModeAsync(ThreeDmRenderDisplayMode mode) { if (State != ThreeDmProductSessionState.Ready || _disposed) return; DisplayMode = mode; - RebuildRenderScene(); + await RebuildRenderSceneAsync(); } - public void SetLayerVisibility(Guid layerId, bool? visible) + public async Task SetLayerVisibilityAsync(Guid layerId, bool? visible) { if (State != ThreeDmProductSessionState.Ready || _disposed) return; _session.SetLayerVisibility(layerId, visible); - RebuildRenderScene(); OnChanged(nameof(Layers)); + await RebuildRenderSceneAsync(); } public IReadOnlyList GetSelectionIds() => @@ -223,15 +225,36 @@ public IReadOnlyList GetSelectionIds() => public ThreeDmSelectionProperties? GetSelectionProperties(ThreeDmSelectionId selectionId) => State == ThreeDmProductSessionState.Ready ? _session.GetSelectionProperties(selectionId) : null; - private void RebuildRenderScene() + private async Task RebuildRenderSceneAsync() { - RenderScene = _session.BuildPreparedRenderScene(new ThreeDmVisualRenderSettings(DisplayMode)); + var version = Interlocked.Increment(ref _renderBuildVersion); + var displayMode = DisplayMode; + await _renderBuildGate.WaitAsync(); + try + { + if (_disposed || version != Volatile.Read(ref _renderBuildVersion)) + { + return; + } + + var scene = await Task.Run(() => + _session.BuildPreparedRenderScene(new ThreeDmVisualRenderSettings(displayMode))); + if (!_disposed && version == Volatile.Read(ref _renderBuildVersion)) + { + RenderScene = scene; + } + } + finally + { + _renderBuildGate.Release(); + } } public void Dispose() { if (_disposed) return; _disposed = true; + Interlocked.Increment(ref _renderBuildVersion); _session.CancelOpen(); State = ThreeDmProductSessionState.Closed; _ = _session.CloseAsync(); diff --git a/src/SpatialViewer.App/Views/ThreeDmViewerView.xaml.cs b/src/SpatialViewer.App/Views/ThreeDmViewerView.xaml.cs index 69bb1d6..65b5712 100644 --- a/src/SpatialViewer.App/Views/ThreeDmViewerView.xaml.cs +++ b/src/SpatialViewer.App/Views/ThreeDmViewerView.xaml.cs @@ -178,11 +178,12 @@ private static void AddLayerRows(ThreeDmLayerNode node, int depth, List SetLeftPaneMode(showLayers: true); @@ -218,13 +219,16 @@ private void SetMode(ThreeDmViewerMode mode) PanTool.IsChecked = mode == ThreeDmViewerMode.Pan; } - private void ShadedMenuItem_Click(object sender, RoutedEventArgs e) => SetDisplayMode(ThreeDmRenderDisplayMode.Shaded); - private void ShadedEdgesMenuItem_Click(object sender, RoutedEventArgs e) => SetDisplayMode(ThreeDmRenderDisplayMode.ShadedWithEdges); - private void WireframeMenuItem_Click(object sender, RoutedEventArgs e) => SetDisplayMode(ThreeDmRenderDisplayMode.Wireframe); + private async void ShadedMenuItem_Click(object sender, RoutedEventArgs e) => + await SetDisplayModeAsync(ThreeDmRenderDisplayMode.Shaded); + private async void ShadedEdgesMenuItem_Click(object sender, RoutedEventArgs e) => + await SetDisplayModeAsync(ThreeDmRenderDisplayMode.ShadedWithEdges); + private async void WireframeMenuItem_Click(object sender, RoutedEventArgs e) => + await SetDisplayModeAsync(ThreeDmRenderDisplayMode.Wireframe); - private void SetDisplayMode(ThreeDmRenderDisplayMode mode) + private async Task SetDisplayModeAsync(ThreeDmRenderDisplayMode mode) { - _session.SetDisplayMode(mode); + await _session.SetDisplayModeAsync(mode); SyncDisplayModeChecks(); Viewport.Draw(); } From 9fb137395cbb86654a022c4963cc1945145721b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kiy=C5=8D=20Jo?= Date: Sun, 6 Sep 2026 13:46:04 +0800 Subject: [PATCH 3/7] fix(rhino): bound selection highlighting for complex meshes --- .../Controls/ThreeDmViewportControl.xaml.cs | 89 +++++++++---------- 1 file changed, 42 insertions(+), 47 deletions(-) diff --git a/src/SpatialViewer.App/Controls/ThreeDmViewportControl.xaml.cs b/src/SpatialViewer.App/Controls/ThreeDmViewportControl.xaml.cs index 618e1c8..919a0f7 100644 --- a/src/SpatialViewer.App/Controls/ThreeDmViewportControl.xaml.cs +++ b/src/SpatialViewer.App/Controls/ThreeDmViewportControl.xaml.cs @@ -28,7 +28,6 @@ public sealed partial class ThreeDmViewportControl : UserControl, IDisposable private bool _pointerMoved; private bool _disposed; private string _canvasColor = "#000000"; - private readonly Dictionary _wireEdgeCache = []; public ThreeDmViewportControl() { @@ -44,7 +43,6 @@ internal ThreeDmProductSession? Session set { _session = value; - _wireEdgeCache.Clear(); if (_session?.State == ThreeDmProductSessionState.Ready) Fit(); Draw(); } @@ -277,7 +275,8 @@ private void DrawWireMesh( ThreeDmSharedMeshGeometry geometry, IReadOnlyList projected, Color color, - ref int segmentBudget) + ref int segmentBudget, + float strokeWidth = 1f) { if (segmentBudget <= 0 || geometry.Indices.Count < 3) { @@ -319,7 +318,7 @@ projected[bIndex] is not { } b || } using var geometryPath = CanvasGeometry.CreatePath(path); - drawingSession.DrawGeometry(geometryPath, color, 1f); + drawingSession.DrawGeometry(geometryPath, color, strokeWidth); } private static void DrawCurve( @@ -382,27 +381,6 @@ private static void AddLineFigure(CanvasPathBuilder path, Vector2 start, Vector2 path.EndFigure(CanvasFigureLoop.Open); } - private (int A, int B)[] GetWireEdges(ThreeDmSharedMeshGeometry geometry) - { - if (_wireEdgeCache.TryGetValue(geometry.GeometryIndex, out var cached)) return cached; - var edges = new HashSet<(int A, int B)>(); - for (var index = 0; index + 2 < geometry.Indices.Count; index += 3) - { - AddEdge(geometry.Indices[index], geometry.Indices[index + 1], edges); - AddEdge(geometry.Indices[index + 1], geometry.Indices[index + 2], edges); - AddEdge(geometry.Indices[index + 2], geometry.Indices[index], edges); - } - - cached = edges.OrderBy(item => item.A).ThenBy(item => item.B).ToArray(); - _wireEdgeCache[geometry.GeometryIndex] = cached; - return cached; - } - - private static void AddEdge(int left, int right, HashSet<(int A, int B)> edges) - { - edges.Add(left <= right ? (left, right) : (right, left)); - } - private static bool TryVertex( ThreeDmSharedMeshGeometry geometry, int index, @@ -738,47 +716,65 @@ private void DrawSelectionOverlay( if (_session?.Selection is not { } selection) return; var geometries = scene.SharedMeshes.Geometries.ToDictionary(item => item.GeometryIndex); var highlight = Color.FromArgb(255, 0x42, 0xB8, 0xE3); + var selectionWireBudget = 30_000; foreach (var instance in scene.SharedMeshes.Instances) { var id = ThreeDmSelectionId.Create(instance.SourceObjectId, instance.SourceSubobjectIndex, instance.InstancePath); - if (id != selection || !geometries.TryGetValue(instance.GeometryIndex, out var geometry)) continue; - foreach (var (aIndex, bIndex) in GetWireEdges(geometry)) + if (id != selection || + !geometries.TryGetValue(instance.GeometryIndex, out var geometry) || + selectionWireBudget <= 0) { - if (!TryVertex(geometry, aIndex, instance.Transform, out var a) || - !TryVertex(geometry, bIndex, instance.Transform, out var b) || - !Project(a, camera, basis, aspect, width, height, out var pa) || - !Project(b, camera, basis, aspect, width, height, out var pb)) - { - continue; - } - - drawingSession.DrawLine(pa.Screen, pb.Screen, highlight, 2f); + continue; } + + var projected = ProjectVertices( + geometry, + instance.Transform, + camera, + basis, + aspect, + width, + height); + DrawWireMesh( + drawingSession, + geometry, + projected, + highlight, + ref selectionWireBudget, + 2f); } foreach (var curve in scene.Curves) { var id = ThreeDmSelectionId.Create(curve.SourceObjectId, curve.SourceSubobjectIndex, curve.InstancePath); if (id != selection) continue; - for (var index = 1; index < curve.Points.Count; index++) - { - if (Project(ToPoint(curve.Points[index - 1]), camera, basis, aspect, width, height, out var pa) && - Project(ToPoint(curve.Points[index]), camera, basis, aspect, width, height, out var pb)) - { - drawingSession.DrawLine(pa.Screen, pb.Screen, highlight, 2.5f); - } - } + var budget = 20_000; + DrawCurve( + drawingSession, + curve, + camera, + basis, + aspect, + width, + height, + highlight, + ref budget); } + var selectedPointBudget = 10_000; foreach (var pointSet in scene.PointSets) { var id = ThreeDmSelectionId.Create(pointSet.SourceObjectId, null, pointSet.InstancePath); if (id != selection) continue; - foreach (var point in pointSet.Points) + var stride = Math.Max(1, (int)Math.Ceiling((double)pointSet.Points.Count / Math.Max(1, selectedPointBudget))); + for (var index = 0; index < pointSet.Points.Count && selectedPointBudget > 0; index += stride) { - if (Project(ToPoint(point), camera, basis, aspect, width, height, out var projected)) + if (Project(ToPoint(pointSet.Points[index]), camera, basis, aspect, width, height, out var projected)) + { drawingSession.FillCircle(projected.Screen, 4f, highlight); + selectedPointBudget--; + } } } } @@ -906,7 +902,6 @@ public void Dispose() { if (_disposed) return; _disposed = true; - _wireEdgeCache.Clear(); } private readonly record struct ProjectedPoint(Vector2 Screen, double Depth); From 0bad93c788dfd051dd239cda2c269049a23cc00d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kiy=C5=8D=20Jo?= Date: Sun, 6 Sep 2026 13:46:44 +0800 Subject: [PATCH 4/7] fix(rhino): keep batched projection helpers analyzer-clean --- src/SpatialViewer.App/Controls/ThreeDmViewportControl.xaml.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SpatialViewer.App/Controls/ThreeDmViewportControl.xaml.cs b/src/SpatialViewer.App/Controls/ThreeDmViewportControl.xaml.cs index 919a0f7..8f2e44e 100644 --- a/src/SpatialViewer.App/Controls/ThreeDmViewportControl.xaml.cs +++ b/src/SpatialViewer.App/Controls/ThreeDmViewportControl.xaml.cs @@ -234,7 +234,7 @@ _session.RenderScene is not { } scene || private static void DrawFilledMesh( CanvasDrawingSession drawingSession, ThreeDmSharedMeshGeometry geometry, - IReadOnlyList projected, + ProjectedPoint?[] projected, Color color) { using var path = new CanvasPathBuilder(drawingSession); @@ -273,7 +273,7 @@ projected[bIndex] is not { } b || private void DrawWireMesh( CanvasDrawingSession drawingSession, ThreeDmSharedMeshGeometry geometry, - IReadOnlyList projected, + ProjectedPoint?[] projected, Color color, ref int segmentBudget, float strokeWidth = 1f) From e114b99d3ef441c33d90a8d9764c43de1546edc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kiy=C5=8D=20Jo?= Date: Sun, 6 Sep 2026 13:50:20 +0800 Subject: [PATCH 5/7] release(app): prepare SpatialViewer 0.4.1 Rhino rendering hotfix --- CHANGELOG.md | 17 +++++++++++++++++ docs/RELEASE-NOTES-v0.4.1.en.md | 16 ++++++++++++++++ docs/RELEASE-NOTES-v0.4.1.ja.md | 16 ++++++++++++++++ docs/RELEASE-NOTES-v0.4.1.md | 16 ++++++++++++++++ external/SpatialViewer.3DMCore | 2 +- release/release.json | 10 +++++----- src/SpatialViewer.App/AppVersionProvider.cs | 6 +++--- src/SpatialViewer.App/Package.appxmanifest | 2 +- src/SpatialViewer.App/SpatialViewer.App.csproj | 8 ++++---- src/SpatialViewer.App/Views/AboutView.xaml.cs | 2 +- 10 files changed, 80 insertions(+), 15 deletions(-) create mode 100644 docs/RELEASE-NOTES-v0.4.1.en.md create mode 100644 docs/RELEASE-NOTES-v0.4.1.ja.md create mode 100644 docs/RELEASE-NOTES-v0.4.1.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9de469c..c79a372 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## [0.4.1] - 2026-09-06 + +### Fixed + +- Fixed Rhino 3DM models loading layers and object counts but showing a blank viewport when Brep/Extrusion objects had no embedded Rhino render mesh. +- Updated the bundled SpatialViewer.3DMCore to 1.0.2 with semantic Brep and Extrusion fallback tessellation. +- Moved prepared 3DM render-scene construction off the WinUI UI thread so NURBS tessellation no longer freezes the window after loading. +- Replaced per-triangle Win2D CanvasGeometry creation with batched per-instance paths and bounded frame budgets. +- Added dark/light canvas contrast correction so black or near-black Rhino layer colors remain visible on dark backgrounds without mutating source colors. +- Bounded selection highlighting to avoid allocating very large edge hash sets for complex meshes. +- Mouse-wheel zoom now remains available regardless of the active Rhino navigation tool. + +### Preserved + +- CAD viewer rendering, CadCore runtime behavior, CAD toolbar/view controls, title bar, tabs, navigation, Projects, Favorites, and existing shell effects remain unchanged. + + ## [0.4.0] - 2026-09-06 ### Added diff --git a/docs/RELEASE-NOTES-v0.4.1.en.md b/docs/RELEASE-NOTES-v0.4.1.en.md new file mode 100644 index 0000000..3dde255 --- /dev/null +++ b/docs/RELEASE-NOTES-v0.4.1.en.md @@ -0,0 +1,16 @@ +# SpatialViewer v0.4.1 + +[简体中文](RELEASE-NOTES-v0.4.1.md) | [日本語](RELEASE-NOTES-v0.4.1.ja.md) | English + +v0.4.1 is a hotfix for the blank Rhino viewport and UI hangs found during v0.4 field testing. + +## Fixes +- 3DMCore 1.0.2 now generates display meshes from semantic Brep and Extrusion geometry when Rhino render meshes are not stored in the file. +- Prepared 3DM render-scene/NURBS tessellation runs off the WinUI UI thread to prevent the window from becoming unresponsive after loading. +- Win2D rendering now batches triangles per Mesh instance instead of creating a CanvasGeometry for every triangle, with bounded fill/wire/curve/point frame budgets. +- Black and near-black Rhino layer colors receive display-only contrast correction on dark canvases without changing source model colors. +- Complex Mesh selection highlighting uses bounded batched wire drawing instead of allocating huge edge hash sets. +- Mouse-wheel zoom works regardless of the active Select/Orbit/Pan tool. + +## Preserved +This hotfix does not modify the CAD rendering path, CadCore, CAD toolbar behavior, title bar, tabs, navigation, Projects, Favorites, or existing theme/shell effects. diff --git a/docs/RELEASE-NOTES-v0.4.1.ja.md b/docs/RELEASE-NOTES-v0.4.1.ja.md new file mode 100644 index 0000000..32844ef --- /dev/null +++ b/docs/RELEASE-NOTES-v0.4.1.ja.md @@ -0,0 +1,16 @@ +# SpatialViewer v0.4.1 + +[简体中文](RELEASE-NOTES-v0.4.1.md) | 日本語 | [English](RELEASE-NOTES-v0.4.1.en.md) + +v0.4.1 は v0.4 の実機確認で判明した Rhino の黒画面と応答停止を修正するホットフィックスです。 + +## 修正 +- Rhino render mesh が保存されていない 3DM でも、3DMCore 1.0.2 が Brep / Extrusion のセマンティック形状から表示メッシュを生成します。 +- 3DM render scene の NURBS テセレーションを WinUI UI スレッド外へ移し、読み込み後の「応答なし」を防止します。 +- Win2D 描画を三角形ごとの CanvasGeometry 生成から Mesh instance 単位のバッチ描画へ変更し、1 フレームの描画量に上限を設けました。 +- 暗いキャンバス上の黒・暗色 Rhino レイヤー色に表示用コントラスト補正を適用します。元のモデル色は変更しません。 +- 複雑な Mesh の選択ハイライトを上限付きバッチ線画に変更しました。 +- 選択/回転/パンのどのツールでもホイールズームを利用できます。 + +## 維持される機能 +CAD ビューアー、CadCore、CAD ツールバー、タイトルバー、タブ、ナビゲーション、プロジェクト、お気に入り、テーマの既存動作は変更しません。 diff --git a/docs/RELEASE-NOTES-v0.4.1.md b/docs/RELEASE-NOTES-v0.4.1.md new file mode 100644 index 0000000..caa1ff2 --- /dev/null +++ b/docs/RELEASE-NOTES-v0.4.1.md @@ -0,0 +1,16 @@ +# SpatialViewer v0.4.1 + +简体中文 | [日本語](RELEASE-NOTES-v0.4.1.ja.md) | [English](RELEASE-NOTES-v0.4.1.en.md) + +v0.4.1 是针对 v0.4 实机验收中 Rhino 黑屏与未响应问题的热修。 + +## 修复 +- 3DM 文件即使未保存 Rhino render mesh,Brep 与 Extrusion 也会由 3DMCore 1.0.2 从语义几何生成显示网格。 +- 3DM render scene 的曲面离散移出 WinUI UI 线程,避免模型载入完成后窗口长时间“未响应”。 +- Win2D 视口由逐三角创建 CanvasGeometry 改为按 Mesh instance 批量绘制,并设置单帧 fill / wire / curve / point 预算。 +- 黑色或近黑 Rhino 图层颜色在黑色画布上自动做显示对比度修正,不修改原始模型颜色。 +- 复杂 Mesh 的选择高亮改为有预算的批量线框,避免生成巨型边集合。 +- 鼠标滚轮缩放不再受当前选择/旋转/平移工具限制。 + +## 保持不变 +本热修不修改 CAD 看图链、CadCore、CAD 工具条及既有标题栏、标签页、导航、项目、收藏和主题效果。 diff --git a/external/SpatialViewer.3DMCore b/external/SpatialViewer.3DMCore index 51e0c7e..abf40b7 160000 --- a/external/SpatialViewer.3DMCore +++ b/external/SpatialViewer.3DMCore @@ -1 +1 @@ -Subproject commit 51e0c7eb374f54109f02e1888decc1031c62bf7e +Subproject commit abf40b78b4f0b9484d89d9067c55d0a91b12296b diff --git a/release/release.json b/release/release.json index 8599cd2..582df16 100644 --- a/release/release.json +++ b/release/release.json @@ -2,8 +2,8 @@ "schemaVersion": 1, "product": { "name": "SpatialViewer", - "version": "0.4.0", - "packageVersion": "0.4.0.0" + "version": "0.4.1", + "packageVersion": "0.4.1.0" }, "channels": { "github": { @@ -11,8 +11,8 @@ } }, "title": { - "zh-CN": "SpatialViewer v0.4.0 Rhino 3DM 看图接入", - "ja-JP": "SpatialViewer v0.4.0 Rhino 3DM ビューア統合", - "en-US": "SpatialViewer v0.4.0 Rhino 3DM Viewer Integration" + "zh-CN": "SpatialViewer v0.4.1 Rhino 显示与响应性热修", + "ja-JP": "SpatialViewer v0.4.1 Rhino 表示・応答性ホットフィックス", + "en-US": "SpatialViewer v0.4.1 Rhino Rendering and Responsiveness Hotfix" } } diff --git a/src/SpatialViewer.App/AppVersionProvider.cs b/src/SpatialViewer.App/AppVersionProvider.cs index eec392a..a980db5 100644 --- a/src/SpatialViewer.App/AppVersionProvider.cs +++ b/src/SpatialViewer.App/AppVersionProvider.cs @@ -4,8 +4,8 @@ namespace SpatialViewer.Product; internal static class AppVersionProvider { - public const string Version = "0.4.0"; - public const string DisplayVersion = "v0.4.0"; + public const string Version = "0.4.1"; + public const string DisplayVersion = "v0.4.1"; public static Version GetCurrentVersion() { @@ -16,7 +16,7 @@ public static Version GetCurrentVersion() } catch (Exception) when (OperatingSystem.IsWindows()) { - var assemblyVersion = typeof(AppVersionProvider).Assembly.GetName().Version ?? new Version(0, 4, 0, 0); + var assemblyVersion = typeof(AppVersionProvider).Assembly.GetName().Version ?? new Version(0, 4, 1, 0); return new Version(assemblyVersion.Major, assemblyVersion.Minor, Math.Max(0, assemblyVersion.Build), Math.Max(0, assemblyVersion.Revision)); } } diff --git a/src/SpatialViewer.App/Package.appxmanifest b/src/SpatialViewer.App/Package.appxmanifest index a0d194c..4fbd110 100644 --- a/src/SpatialViewer.App/Package.appxmanifest +++ b/src/SpatialViewer.App/Package.appxmanifest @@ -6,7 +6,7 @@ xmlns:uap5="http://schemas.microsoft.com/appx/manifest/uap/windows10/5" xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" IgnorableNamespaces="uap uap5 rescap"> - + Spatial Viewer diff --git a/src/SpatialViewer.App/SpatialViewer.App.csproj b/src/SpatialViewer.App/SpatialViewer.App.csproj index 0a4f58e..07466c4 100644 --- a/src/SpatialViewer.App/SpatialViewer.App.csproj +++ b/src/SpatialViewer.App/SpatialViewer.App.csproj @@ -8,10 +8,10 @@ app.manifest Assets\AppIcon.ico x64 - 0.4.0 - 0.4.0.0 - 0.4.0.0 - 0.4.0 + 0.4.1 + 0.4.1.0 + 0.4.1.0 + 0.4.1 zh-CN Scale|DXFeatureLevel 0.9.0 diff --git a/src/SpatialViewer.App/Views/AboutView.xaml.cs b/src/SpatialViewer.App/Views/AboutView.xaml.cs index 94b0d0f..c80bbb7 100644 --- a/src/SpatialViewer.App/Views/AboutView.xaml.cs +++ b/src/SpatialViewer.App/Views/AboutView.xaml.cs @@ -54,7 +54,7 @@ private void PopulateApplicationInfo() PackageVersionText.Text = AppVersionProvider.GetPackageVersion(); ArchitectureText.Text = RuntimeInformation.ProcessArchitecture.ToString(); CurrentAppVersionText.Text = AppVersionProvider.DisplayVersion; - RhinoCurrentVersionText.Text = "v1.0.1"; + RhinoCurrentVersionText.Text = "v1.0.2"; RhinoCoreStatusText.Text = T("About_RhinoBundled"); } From ec148a8c70556d3344f4a697df3996212e4c9345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kiy=C5=8D=20Jo?= Date: Sun, 6 Sep 2026 13:50:58 +0800 Subject: [PATCH 6/7] fix(rhino): use array length in batched projection bounds checks --- .../Controls/ThreeDmViewportControl.xaml.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/SpatialViewer.App/Controls/ThreeDmViewportControl.xaml.cs b/src/SpatialViewer.App/Controls/ThreeDmViewportControl.xaml.cs index 8f2e44e..189be9f 100644 --- a/src/SpatialViewer.App/Controls/ThreeDmViewportControl.xaml.cs +++ b/src/SpatialViewer.App/Controls/ThreeDmViewportControl.xaml.cs @@ -244,9 +244,9 @@ private static void DrawFilledMesh( var aIndex = geometry.Indices[index]; var bIndex = geometry.Indices[index + 1]; var cIndex = geometry.Indices[index + 2]; - if ((uint)aIndex >= (uint)projected.Count || - (uint)bIndex >= (uint)projected.Count || - (uint)cIndex >= (uint)projected.Count || + if ((uint)aIndex >= (uint)projected.Length || + (uint)bIndex >= (uint)projected.Length || + (uint)cIndex >= (uint)projected.Length || projected[aIndex] is not { } a || projected[bIndex] is not { } b || projected[cIndex] is not { } c) @@ -295,9 +295,9 @@ private void DrawWireMesh( var aIndex = geometry.Indices[index]; var bIndex = geometry.Indices[index + 1]; var cIndex = geometry.Indices[index + 2]; - if ((uint)aIndex >= (uint)projected.Count || - (uint)bIndex >= (uint)projected.Count || - (uint)cIndex >= (uint)projected.Count || + if ((uint)aIndex >= (uint)projected.Length || + (uint)bIndex >= (uint)projected.Length || + (uint)cIndex >= (uint)projected.Length || projected[aIndex] is not { } a || projected[bIndex] is not { } b || projected[cIndex] is not { } c) From f4cacd5cb79f265041fc8c0ddb3a22b13ed8773d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kiy=C5=8D=20Jo?= Date: Sun, 6 Sep 2026 13:54:12 +0800 Subject: [PATCH 7/7] fix(rhino): mark stateless wire batch helper static --- src/SpatialViewer.App/Controls/ThreeDmViewportControl.xaml.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SpatialViewer.App/Controls/ThreeDmViewportControl.xaml.cs b/src/SpatialViewer.App/Controls/ThreeDmViewportControl.xaml.cs index 189be9f..040017b 100644 --- a/src/SpatialViewer.App/Controls/ThreeDmViewportControl.xaml.cs +++ b/src/SpatialViewer.App/Controls/ThreeDmViewportControl.xaml.cs @@ -270,7 +270,7 @@ projected[bIndex] is not { } b || drawingSession.FillGeometry(geometryPath, color); } - private void DrawWireMesh( + private static void DrawWireMesh( CanvasDrawingSession drawingSession, ThreeDmSharedMeshGeometry geometry, ProjectedPoint?[] projected,