diff --git a/Cargo.toml b/Cargo.toml index 0b8d05a61..e4d968f87 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -136,3 +136,7 @@ iced = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00 [profile.release] strip = true + +[[bench]] +name = "performance_benchmarks" +harness = false diff --git a/benches/README.md b/benches/README.md new file mode 100644 index 000000000..a9af5c970 --- /dev/null +++ b/benches/README.md @@ -0,0 +1,91 @@ +# OpenCADStudio Performance Benchmarks + +A standardized, statistical performance benchmarking suite measuring throughput, latency, and memory metrics across core CAD subsystems and the user interface. + +## Quick Start + +Run the full benchmark suite with native `--release` optimizations: +```powershell +cargo bench +``` + +Run a fast validation smoke test (~2 seconds): +```powershell +cargo bench --bench performance_benchmarks -- --quick +``` + +> [!NOTE] +> Benchmarks are registered under `[[bench]]` with `harness = false`. They are **never** executed by regular `cargo test` runs, preventing CI slowdowns. + +--- + +## Benchmark Suite Catalog (25 Metrics) + +### 1. Scene & Entity Management +- `scene_entity_ingestion_10k`: Ingestion throughput for 10,000 mixed 2D entities (Lines, Circles, Arcs, Polylines). +- `batch_entity_transform_mutation`: Translating 1,000 entities, invoking `bump_entities`, and updating the spatial acceleration structure. +- `undo_delta_recording_cycle`: Capturing before-images and transactional delta recordings for 500 entity mutations. + +### 2. Geometry Tessellation & Viewport Navigation +- `analytical_curved_tessellation`: Initial wire generation for 14,000 curved entities (circles, arcs, ellipses, polylines). +- `wire_partitioning_per_frame`: Partitioning 14,000 wires into GPU analytical instances vs regular lines. +- `analytical_curved_zoom_100x`: 100 camera zoom navigations measuring frame overhead and FPS. +- `wide_and_tapered_arc_tessellation`: Tessellating 7,500 thick polyline arcs, tapered arcs, and donuts. +- `zoom_extents_bounding_box_calc`: Cold axis-aligned bounding box (AABB) calculation across 20,000 mixed entities (`ZOOM EXTENTS`). + +### 3. Spatial Indexing, Snapping & Picking +- `spatial_interaction_index_build`: Building the spatial `InteractionIndex` and `prepare_screen` for 10,000 wires. +- `hit_test_click_pick_latency`: Single-point cursor click selection across 5,000 entities in screen space. +- `hit_test_box_window_enclosed`: Window selection (`box_hit` enclosed) across 5,000 entities. +- `hit_test_box_crossing_boundary`: Crossing selection (`box_hit` crossing boundary) across 5,000 entities. +- `osnap_cursor_tracking_latency`: Real-time cursor snapping across Endpoint, Midpoint, Center, Intersection, and Nearest modes. + +### 4. Parametrics & DXF I/O +- `constraint_solver_dogleg_system`: Solving coupled 2D geometric constraints (Perpendicular, Parallel, Length) via the Dogleg solver. +- `dxf_export_write_throughput`: Serializing 5,000 entities to DXF binary stream. +- `dxf_import_parse_throughput`: Parsing DXF stream of 5,000 entities into `CadDocument`. + +### 5. UI & Viewport Overlay Performance +- `ui_ribbon_view_construction`: Per-frame ribbon widget tree construction. +- `ui_grid_geometry_uncached`: Multi-pane tiled viewport grid projection and segment generation. +- `ui_grid_cache_hit_evaluation`: Grid overlay cache key construction and reuse decision. +- `ui_icon_handle_cached`: Themed SVG icon cache hit (FxHashMap lookup + Handle clone). +- `ui_icon_handle_uncached`: Uncached SVG icon memory parse and Handle allocation. + +### 6. State Management & Draworder +- `selection_state_deep_clone`: Deep cloning of `SelectionState` (with 64 polygon points). +- `selection_state_arc_clone`: `Arc::clone` (atomic reference bump). +- `draworder_depth_map_full_build`: Full draworder depth map generation across 10,000 ordered entities. +- `draworder_depth_map_incremental_patch`: Fast-path incremental depth map patch on entity modification. + +--- + +## Filtering Benchmarks + +Filter execution by subsystem or name pattern: +```powershell +# Filter by UI +cargo bench --bench performance_benchmarks -- ui + +# Filter by tessellation +cargo bench --bench performance_benchmarks -- tessellation + +# Filter by hit testing +cargo bench --bench performance_benchmarks -- hit_test +``` + +--- + +## Baseline Tracking & Regression Detection + +### 1. Save Current Metrics as Baseline +```powershell +cargo bench --bench performance_benchmarks -- --output target/baseline_metrics.json +``` + +### 2. Compare PR / Branch Against Baseline +```powershell +cargo bench --bench performance_benchmarks -- --baseline target/baseline_metrics.json +``` + +The benchmark runner will output statistical deltas (`% faster` / `% slower`) for each metric and flag regressions exceeding the defined thresholds. diff --git a/benches/performance_benchmarks.rs b/benches/performance_benchmarks.rs new file mode 100644 index 000000000..3339e0c06 --- /dev/null +++ b/benches/performance_benchmarks.rs @@ -0,0 +1,1667 @@ +//! OpenCADStudio Performance Benchmarking Suite +//! +//! Measures latency, percentiles, throughput, and memory metrics across core CAD subsystems: +//! 1. Scene Entity Ingestion & Population +//! 2. Analytical & Curved Entity Tessellation (incorporating existing 14,000-entity benchmark) +//! 3. Wide & Tapered Arc Tessellation (incorporating existing 7,500-entity benchmark) +//! 4. Spatial Broadphase Index Construction (InteractionIndex) +//! 5. Interactive Hit-Testing & Picking (click_hit, window box_hit, crossing box_hit, click_hits_all) +//! 6. Real-Time Object Snapping (Endpoint, Midpoint, Center, Intersection, Nearest) +//! 7. 2D Parametric Geometric Constraint Solving (Dogleg solver) +//! 8. DXF File I/O Serialization & Parsing Throughput +//! 9. Selection State Deep vs Arc Cloning +//! 10. UI Ribbon View Widget Tree Construction +//! 11. UI Viewport Grid Geometry Projection & Overlay Cache Key Evaluation +//! 12. UI Themed SVG Icon Lookup Caching vs Uncached Parse +//! 13. Wide & Tapered Arc + Donut Tessellation (7.5k offset polyline curves) +//! 14. Model Space Extents & Bounding Box Calculation (ZOOM EXTENTS) +//! 15. Batch Entity Transformation & Incremental Dirty-Tracking +//! 16. Draworder Depth Map Full Build & Incremental Patch +//! 17. Delta-Undo Transaction Before-Image Recording +//! +//! Output: +//! - Human-readable ASCII / Markdown summary table on stdout +//! - Machine-readable JSON metrics saved to `target/cad_performance_metrics.json` +//! - Baseline regression comparison via `--baseline ` or `CAD_BENCH_BASELINE=` +//! +//! Run with: +//! `cargo bench` (runs in --release mode, does not run on cargo test) +//! `cargo run --release --bench performance_benchmarks` +//! `cargo run --release --bench performance_benchmarks -- --quick` (quick smoke test) + +#![allow(clippy::field_reassign_with_default, clippy::manual_is_multiple_of)] + +use std::collections::HashMap; +use std::env; +use std::f64::consts::{PI, TAU}; +use std::hint::black_box; +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Instant; + +use serde::{Deserialize, Serialize}; + +use acadrust::entities::{Arc as AcadArc, Circle, Ellipse, Line, LwPolyline, LwVertex}; +use acadrust::types::{Vector2, Vector3}; +use acadrust::{CadDocument, DxfReader, DxfWriter, EntityType}; +use glam::{DVec3, Mat4, Vec3}; +use iced::{Color, Point, Rectangle}; + +use OpenCADStudio::scene::pick::hit_test::{box_hit, click_hit}; +use OpenCADStudio::scene::pick::interaction_index::InteractionIndex; +use OpenCADStudio::scene::pick::selection_state::SelectionState; +use OpenCADStudio::scene::pipeline::wire_arena::partition_wires; +use OpenCADStudio::scene::sketch_constraints::{ConstraintKind, SketchRef, SketchScope}; +use OpenCADStudio::scene::view::camera::Camera; +use OpenCADStudio::scene::{ChangeKind, Scene}; +use OpenCADStudio::snap::Snapper; +use OpenCADStudio::ui::icons::{self, CHECK}; +use OpenCADStudio::ui::overlay::{grid_segments, GridCanvasState, GridKey, GridParams, GridStyle, should_reuse}; +use OpenCADStudio::ui::properties::LinetypeItem; +use OpenCADStudio::ui::ribbon::{LayerInfo, Ribbon}; + +// ── Metric Structures ─────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BenchmarkMetric { + pub name: String, + pub description: String, + pub unit: String, + pub samples: usize, + pub min: f64, + pub max: f64, + pub mean: f64, + pub median: f64, + pub p95: f64, + pub p99: f64, + pub std_dev: f64, + pub throughput: Option, + pub throughput_unit: Option, + pub target_threshold: Option, + pub passed_target: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BenchmarkSuiteReport { + pub timestamp: String, + pub os: String, + pub arch: String, + pub profile: String, + pub is_quick_mode: bool, + pub metrics: Vec, +} + +pub struct BenchmarkRunner { + pub quick_mode: bool, + pub filter: Option, + pub baseline_path: Option, + pub output_path: PathBuf, + pub results: Vec, +} + +impl Default for BenchmarkRunner { + fn default() -> Self { + Self::new() + } +} + +impl BenchmarkRunner { + pub fn new() -> Self { + let args: Vec = env::args().collect(); + let quick_mode = args.iter().any(|a| a == "--quick" || a == "-q") + || env::var("CAD_BENCH_QUICK").is_ok(); + + let filter = args + .windows(2) + .find(|w| w[0] == "--filter") + .map(|w| w[1].clone()) + .or_else(|| env::var("CAD_BENCH_FILTER").ok()); + + let baseline_path = args + .windows(2) + .find(|w| w[0] == "--baseline") + .map(|w| PathBuf::from(&w[1])) + .or_else(|| env::var("CAD_BENCH_BASELINE").ok().map(PathBuf::from)); + + let output_path = args + .windows(2) + .find(|w| w[0] == "--output") + .map(|w| PathBuf::from(&w[1])) + .or_else(|| env::var("CAD_BENCH_OUTPUT").ok().map(PathBuf::from)) + .unwrap_or_else(|| { + let target_dir = Path::new("target"); + if !target_dir.exists() { + let _ = std::fs::create_dir_all(target_dir); + } + target_dir.join("cad_performance_metrics.json") + }); + + Self { + quick_mode, + filter, + baseline_path, + output_path, + results: Vec::new(), + } + } + + pub fn should_run(&self, name: &str) -> bool { + if let Some(ref filter) = self.filter { + name.to_lowercase().contains(&filter.to_lowercase()) + } else { + true + } + } + + /// Record a measured benchmark with multiple timing samples. + pub fn record( + &mut self, + name: &str, + description: &str, + unit: &str, + mut samples: Vec, + throughput: Option<(f64, &str)>, + target_threshold: Option, + ) { + if samples.is_empty() { + return; + } + samples.sort_by(|a, b| a.total_cmp(b)); + + let n = samples.len(); + let min = samples[0]; + let max = samples[n - 1]; + let sum: f64 = samples.iter().sum(); + let mean = sum / (n as f64); + let median = if n % 2 == 0 { + (samples[n / 2 - 1] + samples[n / 2]) * 0.5 + } else { + samples[n / 2] + }; + + let p95_idx = ((n as f64) * 0.95).floor() as usize; + let p95 = samples[p95_idx.min(n - 1)]; + + let p99_idx = ((n as f64) * 0.99).floor() as usize; + let p99 = samples[p99_idx.min(n - 1)]; + + let variance: f64 = samples.iter().map(|&x| (x - mean).powi(2)).sum::() / (n as f64); + let std_dev = variance.sqrt(); + + let passed_target = match target_threshold { + Some(thresh) => median <= thresh, + None => true, + }; + + let (thru_val, thru_unit) = match throughput { + Some((val, u)) => (Some(val), Some(u.to_string())), + None => (None, None), + }; + + self.results.push(BenchmarkMetric { + name: name.to_string(), + description: description.to_string(), + unit: unit.to_string(), + samples: n, + min, + max, + mean, + median, + p95, + p99, + std_dev, + throughput: thru_val, + throughput_unit: thru_unit, + target_threshold, + passed_target, + }); + } + + /// Print summary table to stdout and persist JSON report. + pub fn finish(&self) { + let baseline_map: HashMap = if let Some(ref path) = self.baseline_path { + if let Ok(content) = std::fs::read_to_string(path) { + if let Ok(report) = serde_json::from_str::(&content) { + println!("\nLoaded baseline from '{}' ({} metrics)", path.display(), report.metrics.len()); + report.metrics.into_iter().map(|m| (m.name.clone(), m)).collect() + } else { + eprintln!("Warning: Failed to parse baseline JSON at '{}'", path.display()); + HashMap::new() + } + } else { + eprintln!("Warning: Could not read baseline file at '{}'", path.display()); + HashMap::new() + } + } else { + HashMap::new() + }; + + println!("\n========================================================================================================================"); + println!(" OPENCADSTUDIO PERFORMANCE BENCHMARK REPORT "); + println!("========================================================================================================================"); + println!( + "{:<38} | {:>8} | {:>8} | {:>8} | {:>8} | {:>16} | {:>10} | {:^6}", + "Benchmark Name", "Median", "Mean", "P95", "Min", "Throughput", "Baseline", "Status" + ); + println!("------------------------------------------------------------------------------------------------------------------------"); + + for m in &self.results { + let throughput_str = match (&m.throughput, &m.throughput_unit) { + (Some(val), Some(unit)) => { + if *val >= 1_000_000.0 { + format!("{:.2}M {}", val / 1_000_000.0, unit) + } else if *val >= 1_000.0 { + format!("{:.1}k {}", val / 1_000.0, unit) + } else { + format!("{:.1} {}", val, unit) + } + } + _ => "-".to_string(), + }; + + let baseline_col = if let Some(base) = baseline_map.get(&m.name) { + if base.median > 0.0 { + let delta_pct = ((m.median - base.median) / base.median) * 100.0; + if delta_pct < -1.0 { + format!("{:+.1}% (fast)", delta_pct) + } else if delta_pct > 1.0 { + format!("{:+.1}% (slow)", delta_pct) + } else { + "~0.0%".to_string() + } + } else { + "-".to_string() + } + } else { + "-".to_string() + }; + + let status = if m.passed_target { "PASS" } else { "FAIL" }; + + println!( + "{:<38} | {:>6.2} {:<1} | {:>6.2} {:<1} | {:>6.2} {:<1} | {:>6.2} {:<1} | {:>16} | {:>10} | {:^6}", + m.name, + m.median, + m.unit, + m.mean, + m.unit, + m.p95, + m.unit, + m.min, + m.unit, + throughput_str, + baseline_col, + status, + ); + } + println!("========================================================================================================================\n"); + + let report = BenchmarkSuiteReport { + timestamp: chrono_now(), + os: env::consts::OS.to_string(), + arch: env::consts::ARCH.to_string(), + profile: if cfg!(debug_assertions) { "debug".to_string() } else { "release".to_string() }, + is_quick_mode: self.quick_mode, + metrics: self.results.clone(), + }; + + if let Ok(json) = serde_json::to_string_pretty(&report) { + if let Some(parent) = self.output_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Err(e) = std::fs::write(&self.output_path, json) { + eprintln!("Error saving metrics to '{}': {}", self.output_path.display(), e); + } else { + println!("Saved complete performance metrics JSON to: {}\n", self.output_path.display()); + } + } + } +} + +fn chrono_now() -> String { + let d = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + format!("unix_{}", d.as_secs()) +} + +// ── 1. Scene Entity Ingestion & Batch Creation ─────────────────────────────── + +fn bench_scene_entity_ingestion(runner: &mut BenchmarkRunner) { + let name = "scene_entity_ingestion_10k"; + if !runner.should_run(name) { + return; + } + + let n_entities = if runner.quick_mode { 2_000 } else { 10_000 }; + let runs = if runner.quick_mode { 3 } else { 10 }; + let mut samples = Vec::with_capacity(runs); + + for _ in 0..runs { + let mut scene = Scene::new(); + let t0 = Instant::now(); + + let n_lines = (n_entities * 4) / 10; + let n_circles = (n_entities * 3) / 10; + let n_arcs = (n_entities * 2) / 10; + let n_polylines = n_entities - n_lines - n_circles - n_arcs; + + for i in 0..n_lines { + let x = (i % 100) as f64 * 20.0; + let y = (i / 100) as f64 * 20.0; + let mut line = Line::new(); + line.start = Vector3::new(x, y, 0.0); + line.end = Vector3::new(x + 15.0, y + 15.0, 0.0); + scene.add_entity(EntityType::Line(line)); + } + + for i in 0..n_circles { + let x = (i % 100) as f64 * 30.0; + let y = (i / 100) as f64 * 30.0 + 2000.0; + let mut circle = Circle::default(); + circle.center = Vector3::new(x, y, 0.0); + circle.radius = 12.0; + scene.add_entity(EntityType::Circle(circle)); + } + + for i in 0..n_arcs { + let x = (i % 100) as f64 * 30.0; + let y = (i / 100) as f64 * 30.0 + 4000.0; + let mut arc = AcadArc::default(); + arc.center = Vector3::new(x, y, 0.0); + arc.radius = 10.0; + arc.start_angle = 0.0; + arc.end_angle = PI * 0.75; + scene.add_entity(EntityType::Arc(arc)); + } + + for i in 0..n_polylines { + let x = (i % 50) as f64 * 50.0; + let y = (i / 50) as f64 * 50.0 + 6000.0; + let mut pl = LwPolyline::new(); + pl.vertices = vec![ + LwVertex { location: Vector2::new(x, y), bulge: 0.0, start_width: 0.0, end_width: 0.0, vertex_id: 0 }, + LwVertex { location: Vector2::new(x + 20.0, y), bulge: 0.5, start_width: 0.0, end_width: 0.0, vertex_id: 1 }, + LwVertex { location: Vector2::new(x + 20.0, y + 20.0), bulge: 0.0, start_width: 0.0, end_width: 0.0, vertex_id: 2 }, + ]; + scene.add_entity(EntityType::LwPolyline(pl)); + } + + let elapsed_ms = t0.elapsed().as_secs_f64() * 1000.0; + samples.push(elapsed_ms); + black_box(&scene); + } + + let median_sec = samples[samples.len() / 2] / 1000.0; + let throughput = (n_entities as f64) / median_sec; + + runner.record( + name, + "Ingesting 10,000 mixed entities (Lines, Circles, Arcs, Polylines) into Scene", + "ms", + samples, + Some((throughput, "entities/s")), + Some(150.0), + ); +} + +// ── 2. Analytical & Curved Entity Tessellation & Zoom ──────────────────────── +// Incorporates the existing `bench_analytical_rendering` (14,000 curved entities) + +fn bench_analytical_tessellation_zoom(runner: &mut BenchmarkRunner) { + let name_tess = "analytical_curved_tessellation"; + let name_zoom = "analytical_curved_zoom_100x"; + if !runner.should_run("analytical") { + return; + } + + let (n_circles, n_arcs, n_ellipses, n_polylines) = if runner.quick_mode { + (1000, 1000, 400, 400) + } else { + (5000, 5000, 2000, 2000) + }; + let total_entities = n_circles + n_arcs + n_ellipses + n_polylines; + + let mut scene = Scene::new(); + for i in 0..n_circles { + let x = (i % 100) as f64 * 50.0; + let y = (i / 100) as f64 * 50.0; + let mut circle = Circle::default(); + circle.center = Vector3::new(x, y, 0.0); + circle.radius = 10.0 + (i % 20) as f64; + scene.add_entity(EntityType::Circle(circle)); + } + + for i in 0..n_arcs { + let x = (i % 100) as f64 * 50.0 + 25.0; + let y = (i / 100) as f64 * 50.0; + let mut arc = AcadArc::default(); + arc.center = Vector3::new(x, y, 0.0); + arc.radius = 15.0; + arc.start_angle = ((i % 8) as f64) * 0.25 * PI; + arc.end_angle = arc.start_angle + 0.75 * PI; + scene.add_entity(EntityType::Arc(arc)); + } + + for i in 0..n_ellipses { + let x = (i % 50) as f64 * 100.0; + let y = (i / 50) as f64 * 100.0 + 5000.0; + let mut el = Ellipse::default(); + el.center = Vector3::new(x, y, 0.0); + el.major_axis = Vector3::new(20.0, 0.0, 0.0); + el.minor_axis_ratio = 0.5; + el.start_parameter = 0.0; + el.end_parameter = TAU; + scene.add_entity(EntityType::Ellipse(el)); + } + + for i in 0..n_polylines { + let x = (i % 50) as f64 * 100.0 + 50.0; + let y = (i / 50) as f64 * 100.0 + 5000.0; + let mut pline = LwPolyline::new(); + pline.is_closed = true; + pline.vertices = vec![ + LwVertex { location: Vector2::new(x, y), bulge: 1.0, start_width: 0.0, end_width: 0.0, vertex_id: 0 }, + LwVertex { location: Vector2::new(x + 30.0, y), bulge: 1.0, start_width: 0.0, end_width: 0.0, vertex_id: 1 }, + ]; + scene.add_entity(EntityType::LwPolyline(pline)); + } + + let cam = Camera::default(); + let tess_runs = if runner.quick_mode { 3 } else { 5 }; + let mut tess_samples = Vec::with_capacity(tess_runs); + let mut wires_opt = None; + + for _ in 0..tess_runs { + scene.bump_geometry(); + let t0 = Instant::now(); + let wires = scene.model_tile_wires_arc(0, &cam, 1.0, 1000.0); + tess_samples.push(t0.elapsed().as_secs_f64() * 1000.0); + wires_opt = Some(wires); + } + + let wires = wires_opt.unwrap(); + let median_tess_sec = tess_samples[tess_samples.len() / 2] / 1000.0; + let tess_throughput = (total_entities as f64) / median_tess_sec; + + runner.record( + name_tess, + "Initial wire generation for 14,000 curved entities (circles, arcs, ellipses, polylines)", + "ms", + tess_samples, + Some((tess_throughput, "entities/s")), + Some(120.0), + ); + + let depths = rustc_hash::FxHashMap::default(); + let part_runs = if runner.quick_mode { 20 } else { 50 }; + let mut part_samples = Vec::with_capacity(part_runs); + for _ in 0..part_runs { + let t0 = Instant::now(); + let p = partition_wires(&wires, &depths); + part_samples.push(t0.elapsed().as_secs_f64() * 1000.0); + black_box(p); + } + runner.record( + "wire_partitioning_per_frame", + "Partitioning 14k wires into GPU circles/ellipses and regular lines", + "ms", + part_samples, + None, + Some(2.0), + ); + + let zoom_levels = [0.1f32, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 50.0, 100.0, 1000.0]; + let zoom_runs = if runner.quick_mode { 2 } else { 5 }; + let mut zoom_samples = Vec::with_capacity(zoom_runs); + + for _ in 0..zoom_runs { + let t0 = Instant::now(); + let mut dynamic_cam = Camera::default(); + for &zoom in zoom_levels.iter().cycle().take(100) { + dynamic_cam.distance = 1000.0 / zoom; + let _w = scene.model_tile_wires_arc(0, &dynamic_cam, 1.0, 1000.0); + } + zoom_samples.push(t0.elapsed().as_secs_f64() * 1000.0); + } + + let median_zoom_ms = zoom_samples[zoom_samples.len() / 2]; + let fps = 100.0 / (median_zoom_ms / 1000.0); + + runner.record( + name_zoom, + "100 camera zoom navigations over 14,000 curved entities", + "ms", + zoom_samples, + Some((fps, "FPS")), + Some(50.0), + ); +} + +// ── 3. Spatial Broadphase Index Construction ──────────────────────────────── + +fn bench_spatial_index_construction(runner: &mut BenchmarkRunner) { + let name = "spatial_interaction_index_build"; + if !runner.should_run(name) { + return; + } + + let n_lines = if runner.quick_mode { 2_000 } else { 10_000 }; + let mut scene = Scene::new(); + for i in 0..n_lines { + let x = (i % 100) as f64 * 30.0; + let y = (i / 100) as f64 * 30.0; + let mut line = Line::new(); + line.start = Vector3::new(x, y, 0.0); + line.end = Vector3::new(x + 25.0, y + 25.0, 0.0); + scene.add_entity(EntityType::Line(line)); + } + + let cam = Camera::default(); + let wires = scene.model_tile_wires_arc(0, &cam, 1.0, 1000.0); + + let runs = if runner.quick_mode { 5 } else { 15 }; + let mut samples = Vec::with_capacity(runs); + + for _ in 0..runs { + let t0 = Instant::now(); + let index = InteractionIndex::build(&wires); + index.prepare_screen(); + samples.push(t0.elapsed().as_secs_f64() * 1000.0); + black_box(index); + } + + let median_sec = samples[samples.len() / 2] / 1000.0; + let throughput = (wires.len() as f64) / median_sec; + + runner.record( + name, + "Constructing spatial InteractionIndex & prepare_screen for 10k wires", + "ms", + samples, + Some((throughput, "wires/s")), + Some(35.0), + ); +} + +// ── 4. Interactive Picking & Hit-Testing ───────────────────────────────────── + +fn bench_hit_test_picking(runner: &mut BenchmarkRunner) { + if !runner.should_run("hit_test") && !runner.should_run("pick") { + return; + } + + let n_wires = if runner.quick_mode { 1_000 } else { 5_000 }; + let mut scene = Scene::new(); + for i in 0..n_wires { + let x = (i % 100) as f64 * 20.0; + let y = (i / 100) as f64 * 20.0; + let mut line = Line::new(); + line.start = Vector3::new(x, y, 0.0); + line.end = Vector3::new(x + 18.0, y + 18.0, 0.0); + scene.add_entity(EntityType::Line(line)); + } + + let cam = Camera::default(); + let wires = scene.model_tile_wires_arc(0, &cam, 1.0, 1000.0); + let index = InteractionIndex::build(&wires); + index.prepare_screen(); + + let view_rot = Mat4::IDENTITY; + let eye = DVec3::new(1000.0, 1000.0, 2000.0); + let bounds = Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 1080.0 }; + + let n_queries = if runner.quick_mode { 200 } else { 1_000 }; + let mut click_samples = Vec::with_capacity(5); + + for _ in 0..5 { + let t0 = Instant::now(); + for q in 0..n_queries { + let cx = ((q * 37) % 1920) as f32; + let cy = ((q * 53) % 1080) as f32; + let hit = click_hit(Point::new(cx, cy), wires.as_slice(), view_rot, eye, bounds, false, 8.0); + black_box(hit); + } + let per_query_us = (t0.elapsed().as_secs_f64() * 1_000_000.0) / (n_queries as f64); + click_samples.push(per_query_us); + } + + let median_click_us = click_samples[click_samples.len() / 2]; + runner.record( + "hit_test_click_pick_latency", + "Single-point click_hit query across 5,000 entities in screen space", + "µs", + click_samples, + Some((1_000_000.0 / median_click_us, "queries/s")), + Some(100.0), + ); + + let mut box_win_samples = Vec::with_capacity(5); + for _ in 0..5 { + let t0 = Instant::now(); + for q in 0..n_queries { + let cx = ((q * 41) % 1500) as f32; + let cy = ((q * 47) % 800) as f32; + let p1 = Point::new(cx, cy); + let p2 = Point::new(cx + 150.0, cy + 150.0); + let hits = box_hit(p1, p2, false, wires.as_slice(), view_rot, eye, bounds); + black_box(hits); + } + let per_query_us = (t0.elapsed().as_secs_f64() * 1_000_000.0) / (n_queries as f64); + box_win_samples.push(per_query_us); + } + let median_win_us = box_win_samples[box_win_samples.len() / 2]; + runner.record( + "hit_test_box_window_enclosed", + "Window selection box_hit (enclosed) across 5,000 entities", + "µs", + box_win_samples, + Some((1_000_000.0 / median_win_us, "queries/s")), + Some(250.0), + ); + + let mut box_cross_samples = Vec::with_capacity(5); + for _ in 0..5 { + let t0 = Instant::now(); + for q in 0..n_queries { + let cx = ((q * 41) % 1500) as f32; + let cy = ((q * 47) % 800) as f32; + let p1 = Point::new(cx, cy); + let p2 = Point::new(cx + 150.0, cy + 150.0); + let hits = box_hit(p1, p2, true, wires.as_slice(), view_rot, eye, bounds); + black_box(hits); + } + let per_query_us = (t0.elapsed().as_secs_f64() * 1_000_000.0) / (n_queries as f64); + box_cross_samples.push(per_query_us); + } + let median_cross_us = box_cross_samples[box_cross_samples.len() / 2]; + runner.record( + "hit_test_box_crossing_boundary", + "Crossing selection box_hit (boundary intersection) across 5,000 entities", + "µs", + box_cross_samples, + Some((1_000_000.0 / median_cross_us, "queries/s")), + Some(500.0), + ); +} + +// ── 5. Real-Time Object Snapping (OSNAP) ───────────────────────────────────── + +fn bench_osnap_evaluation(runner: &mut BenchmarkRunner) { + let name = "osnap_cursor_tracking_latency"; + if !runner.should_run("snap") && !runner.should_run(name) { + return; + } + + let n_entities = if runner.quick_mode { 1_000 } else { 5_000 }; + let mut scene = Scene::new(); + for i in 0..n_entities { + let x = (i % 100) as f64 * 30.0; + let y = (i / 100) as f64 * 30.0; + let mut line = Line::new(); + line.start = Vector3::new(x, y, 0.0); + line.end = Vector3::new(x + 25.0, y + 25.0, 0.0); + scene.add_entity(EntityType::Line(line)); + } + + let cam = Camera::default(); + let wires = scene.model_tile_wires_arc(0, &cam, 1.0, 1000.0); + let index = InteractionIndex::build(&wires); + index.prepare_screen(); + + let mut snapper = Snapper::default(); + snapper.toggle_global(); + snapper.enable_all(); + + let view_rot = Mat4::IDENTITY; + let eye = DVec3::new(500.0, 500.0, 1000.0); + let bounds = Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 1080.0 }; + let grid_origin = Vec3::ZERO; + let grid_axes = (Vec3::X, Vec3::Y, Vec3::Z); + + let n_evals = if runner.quick_mode { 300 } else { 1_500 }; + let mut samples = Vec::with_capacity(5); + + for _ in 0..5 { + let t0 = Instant::now(); + for q in 0..n_evals { + let wx = (q as f64 * 7.3) % 2500.0; + let wy = (q as f64 * 11.7) % 2500.0; + let cursor_world = DVec3::new(wx, wy, 0.0); + let cursor_screen = Point::new( + ((q * 23) % 1920) as f32, + ((q * 31) % 1080) as f32, + ); + + let hit = snapper.snap( + cursor_world, + cursor_screen, + wires.as_slice(), + view_rot, + eye, + bounds, + grid_origin, + grid_axes, + None, + ); + black_box(hit); + } + let per_snap_us = (t0.elapsed().as_secs_f64() * 1_000_000.0) / (n_evals as f64); + samples.push(per_snap_us); + } + + let median_us = samples[samples.len() / 2]; + runner.record( + name, + "Real-time OSNAP evaluation (Endpoint, Mid, Center, Intersect, Nearest) across 5k entities", + "µs", + samples, + Some((1_000_000.0 / median_us, "snaps/s")), + Some(350.0), + ); +} + +// ── 6. 2D Parametric Geometric Constraint Solving ─────────────────────────── + +fn bench_geometric_constraint_solving(runner: &mut BenchmarkRunner) { + let name = "constraint_solver_dogleg_system"; + if !runner.should_run("constraint") && !runner.should_run("solve") { + return; + } + + let n_chains = if runner.quick_mode { 5 } else { 15 }; + let runs = if runner.quick_mode { 5 } else { 15 }; + let mut samples = Vec::with_capacity(runs); + + for run_idx in 0..runs { + let mut scene = Scene::new(); + let mut lines = Vec::new(); + + for i in 0..n_chains { + let y = i as f64 * 30.0; + let mut l1 = Line::new(); + l1.start = Vector3::new(0.0, y, 0.0); + l1.end = Vector3::new(50.0, y, 0.0); + let h1 = scene.add_entity(EntityType::Line(l1)); + + let mut l2 = Line::new(); + l2.start = Vector3::new(50.0, y, 0.0); + l2.end = Vector3::new(50.0, y + 20.0, 0.0); + let h2 = scene.add_entity(EntityType::Line(l2)); + + let mut l3 = Line::new(); + l3.start = Vector3::new(50.0, y + 20.0, 0.0); + l3.end = Vector3::new(0.0, y + 20.0, 0.0); + let h3 = scene.add_entity(EntityType::Line(l3)); + + lines.push((h1, h2, h3)); + } + + let cs = scene.sketch_constraint_set_mut(SketchScope::ModelSpace); + for &(h1, h2, h3) in &lines { + cs.add(ConstraintKind::Perpendicular, vec![SketchRef::whole(h1), SketchRef::whole(h2)], None); + cs.add(ConstraintKind::Perpendicular, vec![SketchRef::whole(h2), SketchRef::whole(h3)], None); + cs.add(ConstraintKind::Parallel, vec![SketchRef::whole(h1), SketchRef::whole(h3)], None); + } + + let target = lines[run_idx % lines.len()].0; + if let Some(EntityType::Line(ref mut l)) = scene.document.get_entity_mut(target) { + l.end.y += 10.0; + } + + let t0 = Instant::now(); + scene.bump_entities(&[(target, ChangeKind::Modified)]); + samples.push(t0.elapsed().as_secs_f64() * 1000.0); + } + + let median_ms = samples[samples.len() / 2]; + runner.record( + name, + "Solving coupled nonlinear 2D constraints (Perp, Parallel, Length) via Dogleg solver", + "ms", + samples, + Some((1000.0 / median_ms, "solves/s")), + Some(10.0), + ); +} + +// ── 7. DXF File I/O Serialization & Parsing Throughput ────────────────────── + +fn bench_dxf_io_throughput(runner: &mut BenchmarkRunner) { + if !runner.should_run("dxf") && !runner.should_run("io") { + return; + } + + let n_entities = if runner.quick_mode { 1_000 } else { 5_000 }; + let mut doc = CadDocument::new(); + + for i in 0..n_entities { + let x = (i % 100) as f64 * 20.0; + let y = (i / 100) as f64 * 20.0; + if i % 2 == 0 { + let mut line = Line::new(); + line.start = Vector3::new(x, y, 0.0); + line.end = Vector3::new(x + 10.0, y + 10.0, 0.0); + let _ = doc.add_entity(EntityType::Line(line)); + } else { + let mut circle = Circle::default(); + circle.center = Vector3::new(x, y, 0.0); + circle.radius = 8.0; + let _ = doc.add_entity(EntityType::Circle(circle)); + } + } + + let runs = if runner.quick_mode { 3 } else { 8 }; + let mut write_samples = Vec::with_capacity(runs); + let mut dxf_bytes = Vec::new(); + + for _ in 0..runs { + let t0 = Instant::now(); + let bytes = DxfWriter::new(&doc).write_to_vec().expect("DXF write succeeds"); + write_samples.push(t0.elapsed().as_secs_f64() * 1000.0); + dxf_bytes = bytes; + } + + let mb_size = (dxf_bytes.len() as f64) / (1024.0 * 1024.0); + let median_write_sec = write_samples[write_samples.len() / 2] / 1000.0; + let write_throughput_mb = mb_size / median_write_sec; + + runner.record( + "dxf_export_write_throughput", + "Serializing 5,000 entities to DXF binary stream", + "ms", + write_samples, + Some((write_throughput_mb, "MB/s")), + Some(100.0), + ); + + let mut read_samples = Vec::with_capacity(runs); + for _ in 0..runs { + let t0 = Instant::now(); + let parsed_doc = DxfReader::from_reader(Cursor::new(dxf_bytes.clone())) + .expect("DXF reader created") + .read() + .expect("DXF parse succeeds"); + read_samples.push(t0.elapsed().as_secs_f64() * 1000.0); + black_box(parsed_doc); + } + + let median_read_sec = read_samples[read_samples.len() / 2] / 1000.0; + let read_throughput_mb = mb_size / median_read_sec; + + runner.record( + "dxf_import_parse_throughput", + "Parsing DXF stream of 5,000 entities into CadDocument", + "ms", + read_samples, + Some((read_throughput_mb, "MB/s")), + Some(80.0), + ); +} + +// ── 8. Selection State Deep vs Arc Cloning ─────────────────────────────────── +// Incorporates the existing `bench_selection_state_clone` + +fn bench_selection_cloning(runner: &mut BenchmarkRunner) { + if !runner.should_run("selection") && !runner.should_run("clone") { + return; + } + + let mut state = SelectionState { + vp_size: (1920.0, 1080.0), + poly_points: vec![Point::new(10.0, 10.0); 64], + ..Default::default() + }; + state.box_anchor = Some(Point::new(0.0, 0.0)); + state.box_current = Some(Point::new(100.0, 100.0)); + + let n = if runner.quick_mode { 1_000 } else { 5_000 }; + let runs = 5; + + let mut deep_samples = Vec::with_capacity(runs); + for _ in 0..runs { + let t0 = Instant::now(); + for _ in 0..n { + black_box(state.clone()); + } + let per_ns = (t0.elapsed().as_nanos() as f64) / (n as f64); + deep_samples.push(per_ns); + } + let median_deep_ns = deep_samples[deep_samples.len() / 2]; + runner.record( + "selection_state_deep_clone", + "SelectionState::clone (deep clone with 64 polygon vertices)", + "ns", + deep_samples, + Some((1_000_000_000.0 / median_deep_ns, "clones/s")), + Some(1000.0), + ); + + let arc_state = Arc::new(state); + let mut arc_samples = Vec::with_capacity(runs); + for _ in 0..runs { + let t0 = Instant::now(); + for _ in 0..n { + black_box(Arc::clone(&arc_state)); + } + let per_ns = (t0.elapsed().as_nanos() as f64) / (n as f64); + arc_samples.push(per_ns); + } + let median_arc_ns = arc_samples[arc_samples.len() / 2]; + runner.record( + "selection_state_arc_clone", + "Arc::clone (atomic reference count increment)", + "ns", + arc_samples, + Some((1_000_000_000.0 / median_arc_ns, "clones/s")), + Some(20.0), + ); +} + +// ── 10. UI Ribbon View Construction Benchmark ────────────────────────────── + +fn bench_ui_ribbon_view_construction(runner: &mut BenchmarkRunner) { + if !runner.should_run("ui_ribbon_view_construction") { + return; + } + + let mut ribbon = Ribbon::new(); + ribbon.set_styles( + vec![ + "Standard".to_string(), + "Title".to_string(), + "Annotative".to_string(), + ], + "Standard", + vec!["Standard".to_string()], + "Standard", + vec!["Standard".to_string()], + "Standard", + vec!["Standard".to_string()], + "Standard", + ); + ribbon.set_layers( + vec![ + LayerInfo { + name: "0".to_string(), + color: Color::TRANSPARENT, + visible: true, + frozen: false, + locked: false, + }, + LayerInfo { + name: "DRAWING".to_string(), + color: Color::TRANSPARENT, + visible: true, + frozen: false, + locked: false, + }, + LayerInfo { + name: "DIMENSIONS".to_string(), + color: Color::TRANSPARENT, + visible: true, + frozen: false, + locked: false, + }, + LayerInfo { + name: "ANNOTATIONS".to_string(), + color: Color::TRANSPARENT, + visible: true, + frozen: false, + locked: false, + }, + ], + "0", + ); + ribbon.set_available_linetypes(vec![ + LinetypeItem { + name: "Continuous".to_string(), + art: String::new(), + }, + LinetypeItem { + name: "DASHED".to_string(), + art: String::new(), + }, + LinetypeItem { + name: "CENTER".to_string(), + art: String::new(), + }, + LinetypeItem { + name: "HIDDEN".to_string(), + art: String::new(), + }, + ]); + + // Warm-up for allocator and tree settling + for _ in 0..10 { + let _ = black_box(ribbon.view(false, false, 0, 0, false)); + } + + let n = if runner.quick_mode { 20 } else { 100 }; + let runs = 5; + let mut samples = Vec::with_capacity(runs); + + for _ in 0..runs { + let t0 = Instant::now(); + for _ in 0..n { + let elem = ribbon.view(false, false, 0, 0, false); + black_box(elem); + } + let per_us = (t0.elapsed().as_micros() as f64) / (n as f64); + samples.push(per_us); + } + + let median_us = samples[samples.len() / 2]; + runner.record( + "ui_ribbon_view_construction", + "Ribbon::view widget tree construction per frame (populated tabs, styles, layers)", + "µs", + samples, + Some((1_000_000.0 / median_us, "views/s")), + Some(3000.0), // Target threshold < 3.0 ms + ); +} + +// ── 11. UI Grid Geometry Generation & Cache Hit Benchmark ────────────────── + +fn bench_ui_grid_geometry(runner: &mut BenchmarkRunner) { + let view_rot1 = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05); + let eye1 = glam::DVec3::new(4.0, 3.5, 9.0); + let bounds1 = Rectangle { + x: 0.0, + y: 0.0, + width: 1280.0, + height: 720.0, + }; + let step1 = 80.0_f32; + let origin1 = glam::DVec3::new(0.0, 0.0, 0.0); + let axes1 = (Vec3::X, Vec3::Y, Vec3::Z); + let limits1: Option<(glam::DVec2, glam::DVec2)> = None; + + let view_rot2 = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05); + let eye2 = glam::DVec3::new(4.0, 3.5, 9.0); + let bounds2 = Rectangle { + x: 1280.0, + y: 0.0, + width: 640.0, + height: 720.0, + }; + let step2 = 160.0_f32; + let origin2 = glam::DVec3::new(0.0, 0.0, 0.0); + let axes2 = (Vec3::X, Vec3::Y, Vec3::Z); + let limits2: Option<(glam::DVec2, glam::DVec2)> = None; + + // ── 1. Uncached grid geometry generation ── + if runner.should_run("ui_grid_geometry_uncached") { + for _ in 0..10 { + let _ = black_box(grid_segments(view_rot1, eye1, bounds1, step1, origin1, axes1, limits1)); + let _ = black_box(grid_segments(view_rot2, eye2, bounds2, step2, origin2, axes2, limits2)); + } + + let n = if runner.quick_mode { 20 } else { 100 }; + let runs = 5; + let mut samples = Vec::with_capacity(runs); + + for _ in 0..runs { + let t0 = Instant::now(); + for _ in 0..n { + let g1 = grid_segments(view_rot1, eye1, bounds1, step1, origin1, axes1, limits1); + let g2 = grid_segments(view_rot2, eye2, bounds2, step2, origin2, axes2, limits2); + black_box(g1); + black_box(g2); + } + let per_us = (t0.elapsed().as_micros() as f64) / (n as f64); + samples.push(per_us); + } + + let median_us = samples[samples.len() / 2]; + runner.record( + "ui_grid_geometry_uncached", + "Viewport 2-pane tiled grid projection & segment generation (uncached)", + "µs", + samples, + Some((1_000_000.0 / median_us, "frames/s")), + Some(800.0), // Target threshold < 800 µs + ); + } + + // ── 2. Cached grid key hit evaluation ── + if runner.should_run("ui_grid_cache_hit_evaluation") { + let params1 = GridParams { + view_rot: view_rot1, + eye: eye1, + bounds: bounds1, + step: step1, + origin: origin1, + axes: axes1, + limits: limits1, + }; + let params2 = GridParams { + view_rot: view_rot2, + eye: eye2, + bounds: bounds2, + step: step2, + origin: origin2, + axes: axes2, + limits: limits2, + }; + let grids = vec![params1, params2]; + let canvas_bounds = Rectangle { + x: 0.0, + y: 0.0, + width: 1920.0, + height: 720.0, + }; + + let state = GridCanvasState::default(); + let stored_key = GridKey::from_grids(&grids, canvas_bounds, GridStyle::default()); + *state.key.borrow_mut() = Some(stored_key); + + for _ in 0..50 { + let key = GridKey::from_grids(&grids, canvas_bounds, GridStyle::default()); + let hit = should_reuse(state.key.borrow().as_ref(), &key); + black_box(hit); + } + + let n = if runner.quick_mode { 1_000 } else { 10_000 }; + let runs = 5; + let mut samples = Vec::with_capacity(runs); + + for _ in 0..runs { + let t0 = Instant::now(); + let mut hit_count = 0u32; + for _ in 0..n { + let key = GridKey::from_grids(black_box(&grids), black_box(canvas_bounds), GridStyle::default()); + if should_reuse(state.key.borrow().as_ref(), &key) { + hit_count += 1; + } + } + assert_eq!(hit_count as usize, n); + let per_ns = (t0.elapsed().as_nanos() as f64) / (n as f64); + samples.push(per_ns); + } + + let median_ns = samples[samples.len() / 2]; + runner.record( + "ui_grid_cache_hit_evaluation", + "Grid overlay cache key construction + reuse hit decision", + "ns", + samples, + Some((1_000_000_000.0 / median_ns, "evals/s")), + Some(500.0), // Target threshold < 500 ns + ); + } +} + +// ── 12. UI Themed SVG Icon Caching Benchmark ─────────────────────────────── + +fn bench_ui_icon_caching(runner: &mut BenchmarkRunner) { + let bytes = CHECK; + + // Warm up the themed handle cache + let _ = icons::themed_handle(bytes); + + if runner.should_run("ui_icon_handle_cached") { + let n = if runner.quick_mode { 5_000 } else { 50_000 }; + let runs = 5; + let mut samples = Vec::with_capacity(runs); + + for _ in 0..runs { + let t0 = Instant::now(); + for _ in 0..n { + let handle = icons::themed_handle(black_box(bytes)); + black_box(handle); + } + let per_ns = (t0.elapsed().as_nanos() as f64) / (n as f64); + samples.push(per_ns); + } + + let median_ns = samples[samples.len() / 2]; + runner.record( + "ui_icon_handle_cached", + "Themed SVG icon cache hit (FxHashMap lookup + Handle clone)", + "ns", + samples, + Some((1_000_000_000.0 / median_ns, "lookups/s")), + Some(100.0), // Target threshold < 100 ns + ); + } + + if runner.should_run("ui_icon_handle_uncached") { + let n = if runner.quick_mode { 200 } else { 2_000 }; + let runs = 5; + let mut samples = Vec::with_capacity(runs); + + for _ in 0..runs { + let t0 = Instant::now(); + for _ in 0..n { + let handle = iced::widget::svg::Handle::from_memory(black_box(bytes)); + black_box(handle); + } + let per_ns = (t0.elapsed().as_nanos() as f64) / (n as f64); + samples.push(per_ns); + } + + let median_ns = samples[samples.len() / 2]; + runner.record( + "ui_icon_handle_uncached", + "Uncached SVG icon memory parse + Handle allocation", + "ns", + samples, + Some((1_000_000_000.0 / median_ns, "parses/s")), + None, // Reference comparison + ); + } +} + +// ── 13. Wide & Tapered Arc + Donut Tessellation ───────────────────────────── + +fn bench_wide_and_tapered_arc_tessellation(runner: &mut BenchmarkRunner) { + let name = "wide_and_tapered_arc_tessellation"; + if !runner.should_run(name) { + return; + } + + let (n_wide, n_taper, n_donut) = if runner.quick_mode { + (500, 500, 500) + } else { + (2500, 2500, 2500) + }; + let total_entities = n_wide + n_taper + n_donut; + + let runs = if runner.quick_mode { 3 } else { 5 }; + let mut samples = Vec::with_capacity(runs); + + for _ in 0..runs { + let mut scene = Scene::new(); + + // 1. Wide polyline arcs (constant width 6.0) + for i in 0..n_wide { + let x = (i % 50) as f64 * 80.0; + let y = (i / 50) as f64 * 80.0; + let mut pline = LwPolyline::new(); + pline.constant_width = 6.0; + pline.vertices = vec![ + LwVertex { + location: Vector2::new(x, y), + bulge: 0.5, + start_width: 0.0, + end_width: 0.0, + vertex_id: 0, + }, + LwVertex { + location: Vector2::new(x + 40.0, y), + bulge: 0.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 1, + }, + ]; + scene.add_entity(EntityType::LwPolyline(pline)); + } + + // 2. Tapered arcs (width: 2.0 -> 12.0) + for i in 0..n_taper { + let x = (i % 50) as f64 * 80.0; + let y = (i / 50) as f64 * 80.0 + 4500.0; + let mut pline = LwPolyline::new(); + pline.vertices = vec![ + LwVertex { + location: Vector2::new(x, y), + bulge: 0.7, + start_width: 2.0, + end_width: 12.0, + vertex_id: 0, + }, + LwVertex { + location: Vector2::new(x + 35.0, y + 10.0), + bulge: 0.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 1, + }, + ]; + scene.add_entity(EntityType::LwPolyline(pline)); + } + + // 3. Donuts (2 closed arc segments, inner 10.0, outer 26.0) + for i in 0..n_donut { + let cx = (i % 50) as f64 * 80.0; + let cy = (i / 50) as f64 * 80.0 + 9000.0; + let inner_r = 10.0; + let outer_r = 26.0; + let r_avg = (inner_r + outer_r) * 0.5; + let width = outer_r - inner_r; + let mut pline = LwPolyline::new(); + pline.is_closed = true; + pline.constant_width = width; + pline.vertices = vec![ + LwVertex { + location: Vector2::new(cx - r_avg, cy), + bulge: 1.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 0, + }, + LwVertex { + location: Vector2::new(cx + r_avg, cy), + bulge: 1.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 1, + }, + ]; + scene.add_entity(EntityType::LwPolyline(pline)); + } + + let cam = Camera::default(); + let t0 = Instant::now(); + let wires = scene.model_tile_wires_arc(0, &cam, 1.0, 1000.0); + black_box(wires); + let elapsed_ms = t0.elapsed().as_secs_f64() * 1000.0; + samples.push(elapsed_ms); + } + + let median_ms = samples[samples.len() / 2]; + let throughput = (total_entities as f64) / (median_ms / 1000.0); + runner.record( + name, + "Tessellating 7.5k wide arcs, tapered arcs & donuts (mesh offset curves)", + "ms", + samples, + Some((throughput, "entities/s")), + Some(100.0), + ); +} + +// ── 14. Model Space Extents Calculation (ZOOM EXTENTS) ────────────────────── + +fn bench_zoom_extents_calculation(runner: &mut BenchmarkRunner) { + let name = "zoom_extents_bounding_box_calc"; + if !runner.should_run(name) { + return; + } + + let n = if runner.quick_mode { 4_000 } else { 20_000 }; + let mut scene = Scene::new(); + for i in 0..n { + let x = (i % 200) as f64 * 50.0; + let y = (i / 200) as f64 * 50.0; + match i % 4 { + 0 => { + let mut line = Line::new(); + line.start = Vector3::new(x, y, 0.0); + line.end = Vector3::new(x + 40.0, y + 40.0, 0.0); + scene.add_entity(EntityType::Line(line)); + } + 1 => { + let mut c = Circle::new(); + c.center = Vector3::new(x + 20.0, y + 20.0, 0.0); + c.radius = 15.0; + scene.add_entity(EntityType::Circle(c)); + } + 2 => { + let mut a = AcadArc::new(); + a.center = Vector3::new(x + 20.0, y + 20.0, 0.0); + a.radius = 15.0; + a.start_angle = 0.5; + a.end_angle = 3.5; + scene.add_entity(EntityType::Arc(a)); + } + _ => { + let mut pl = LwPolyline::new(); + pl.vertices = vec![ + LwVertex::new(Vector2::new(x, y)), + LwVertex::new(Vector2::new(x + 20.0, y + 10.0)), + LwVertex::new(Vector2::new(x + 40.0, y)), + ]; + scene.add_entity(EntityType::LwPolyline(pl)); + } + } + } + + let runs = if runner.quick_mode { 5 } else { 15 }; + let mut samples = Vec::with_capacity(runs); + + for _ in 0..runs { + // Invalidate extents cache to measure cold AABB traversal across all entities + scene.invalidate_draw_depth(); + scene.bump_entities(&[]); + + let t0 = Instant::now(); + let ext = scene.model_space_extents(); + black_box(ext); + let elapsed_us = t0.elapsed().as_micros() as f64; + samples.push(elapsed_us); + } + + let median_us = samples[samples.len() / 2]; + let throughput = (n as f64) / (median_us / 1_000_000.0); + runner.record( + name, + "Computing model space bounding box across 20k mixed entities (ZOOM EXTENTS)", + "µs", + samples, + Some((throughput, "entities/s")), + Some(10000.0), // Target threshold < 10.0 ms + ); +} + +// ── 15. Batch Entity Transformation & Mutation ────────────────────────────── + +fn bench_batch_entity_mutation(runner: &mut BenchmarkRunner) { + let name = "batch_entity_transform_mutation"; + if !runner.should_run(name) { + return; + } + + let total_entities = if runner.quick_mode { 2_000 } else { 10_000 }; + let mutate_count = if runner.quick_mode { 200 } else { 1_000 }; + + let mut scene = Scene::new(); + let mut handles = Vec::with_capacity(total_entities); + for i in 0..total_entities { + let x = (i % 100) as f64 * 30.0; + let y = (i / 100) as f64 * 30.0; + let mut line = Line::new(); + line.start = Vector3::new(x, y, 0.0); + line.end = Vector3::new(x + 20.0, y + 20.0, 0.0); + let h = scene.add_entity(EntityType::Line(line)); + handles.push(h); + } + let cam = Camera::default(); + let wires = scene.model_tile_wires_arc(0, &cam, 1.0, 1000.0); + let index = InteractionIndex::build(&wires); + index.prepare_screen(); + + let runs = if runner.quick_mode { 5 } else { 10 }; + let mut samples = Vec::with_capacity(runs); + + for run_idx in 0..runs { + let offset = (run_idx as f64 + 1.0) * 2.0; + let mut modified = Vec::with_capacity(mutate_count); + + for &h in handles.iter().take(mutate_count) { + if let Some(EntityType::Line(ref mut line)) = scene.document.get_entity_mut(h) { + line.start.x += offset; + line.end.x += offset; + } + modified.push((h, ChangeKind::Modified)); + } + + let t0 = Instant::now(); + scene.bump_entities(&modified); + let wires = scene.model_tile_wires_arc(0, &cam, 1.0, 1000.0); + let idx = InteractionIndex::build(&wires); + idx.prepare_screen(); + let elapsed_ms = t0.elapsed().as_secs_f64() * 1000.0; + black_box(idx); + samples.push(elapsed_ms); + } + + let median_ms = samples[samples.len() / 2]; + let throughput = (mutate_count as f64) / (median_ms / 1000.0); + runner.record( + name, + "Incremental dirty propagation & spatial index update for 1,000 modified entities", + "ms", + samples, + Some((throughput, "entities/s")), + Some(10.0), // Target threshold < 10.0 ms + ); +} + +// ── 16. Draworder Evaluation (Full Build & Incremental Patch) ─────────────── + +fn bench_draworder_evaluation(runner: &mut BenchmarkRunner) { + let total_entities = if runner.quick_mode { 2_000 } else { 10_000 }; + let mut scene = Scene::new(); + let mut handles = Vec::with_capacity(total_entities); + for i in 0..total_entities { + let x = (i % 100) as f64 * 30.0; + let y = (i / 100) as f64 * 30.0; + let mut line = Line::new(); + line.start = Vector3::new(x, y, 0.0); + line.end = Vector3::new(x + 20.0, y + 20.0, 0.0); + let h = scene.add_entity(EntityType::Line(line)); + handles.push(h); + } + + // ── Full build ── + if runner.should_run("draworder_depth_map_full_build") { + let runs = if runner.quick_mode { 5 } else { 10 }; + let mut samples = Vec::with_capacity(runs); + for _ in 0..runs { + scene.invalidate_draw_depth(); + let t0 = Instant::now(); + let depths = scene.draw_depth_map(); + black_box(depths); + let elapsed_ms = t0.elapsed().as_secs_f64() * 1000.0; + samples.push(elapsed_ms); + } + let median_ms = samples[samples.len() / 2]; + let throughput = (total_entities as f64) / (median_ms / 1000.0); + runner.record( + "draworder_depth_map_full_build", + "Initial draworder depth map construction across 10,000 entities", + "ms", + samples, + Some((throughput, "entities/s")), + Some(15.0), + ); + } + + // ── Incremental patch ── + if runner.should_run("draworder_depth_map_incremental_patch") { + let _ = scene.draw_depth_map(); + + let n_patch = if runner.quick_mode { 100 } else { 1_000 }; + let runs = 5; + let mut samples = Vec::with_capacity(runs); + + for _ in 0..runs { + let t0 = Instant::now(); + for j in 0..n_patch { + let h = handles[j % handles.len()]; + scene.bump_entities(&[(h, ChangeKind::Modified)]); + let depths = scene.draw_depth_map(); + black_box(depths); + } + let per_us = (t0.elapsed().as_micros() as f64) / (n_patch as f64); + samples.push(per_us); + } + let median_us = samples[samples.len() / 2]; + runner.record( + "draworder_depth_map_incremental_patch", + "Draworder depth map fast-path retrieval under entity modifications", + "µs", + samples, + Some((1_000_000.0 / median_us, "patches/s")), + Some(50.0), // Target threshold < 50 µs + ); + } +} + +// ── 17. Delta-Undo Transaction Before-Image Recording ─────────────────────── + +fn bench_undo_delta_recording(runner: &mut BenchmarkRunner) { + let name = "undo_delta_recording_cycle"; + if !runner.should_run(name) { + return; + } + + let total = if runner.quick_mode { 1_000 } else { 5_000 }; + let mutate_count = if runner.quick_mode { 100 } else { 500 }; + + let mut scene = Scene::new(); + let mut handles = Vec::with_capacity(total); + for i in 0..total { + let x = (i % 100) as f64 * 30.0; + let y = (i / 100) as f64 * 30.0; + let mut line = Line::new(); + line.start = Vector3::new(x, y, 0.0); + line.end = Vector3::new(x + 20.0, y + 20.0, 0.0); + let h = scene.add_entity(EntityType::Line(line)); + handles.push(h); + } + + let runs = if runner.quick_mode { 5 } else { 15 }; + let mut samples = Vec::with_capacity(runs); + + for _ in 0..runs { + let t0 = Instant::now(); + scene.begin_undo_recording(); + for &h in handles.iter().take(mutate_count) { + let before = scene.document.get_entity(h).map(|e| Arc::new(e.clone())); + scene.record_undo_before(h, before); + } + let rec = scene.take_undo_recording(); + black_box(rec); + let elapsed_us = t0.elapsed().as_micros() as f64; + samples.push(elapsed_us); + } + + let median_us = samples[samples.len() / 2]; + let throughput = (mutate_count as f64) / (median_us / 1_000_000.0); + runner.record( + name, + "Capturing delta-undo transaction before-images for 500 entity mutations", + "µs", + samples, + Some((throughput, "records/s")), + Some(1000.0), // Target threshold < 1000 µs (1 ms) + ); +} + +// ── Main Entrypoint ───────────────────────────────────────────────────────── + +fn main() { + println!("\nInitializing OpenCADStudio Performance Benchmarks..."); + let mut runner = BenchmarkRunner::new(); + + if runner.quick_mode { + println!("Mode: QUICK (reduced iteration/entity counts for fast validation)"); + } else { + println!("Mode: FULL (production scale benchmarks for baseline metrics)"); + } + + bench_scene_entity_ingestion(&mut runner); + bench_analytical_tessellation_zoom(&mut runner); + bench_spatial_index_construction(&mut runner); + bench_hit_test_picking(&mut runner); + bench_osnap_evaluation(&mut runner); + bench_geometric_constraint_solving(&mut runner); + bench_dxf_io_throughput(&mut runner); + bench_selection_cloning(&mut runner); + bench_ui_ribbon_view_construction(&mut runner); + bench_ui_grid_geometry(&mut runner); + bench_ui_icon_caching(&mut runner); + bench_wide_and_tapered_arc_tessellation(&mut runner); + bench_zoom_extents_calculation(&mut runner); + bench_batch_entity_mutation(&mut runner); + bench_draworder_evaluation(&mut runner); + bench_undo_delta_recording(&mut runner); + + runner.finish(); +} diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index 073b674b0..c43f1983a 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -1538,73 +1538,7 @@ mod tests { use acadrust::objects::ObjectType; use acadrust::EntityType; - // Benchmark, not a correctness test: measures end-to-end latency of - // HATCHTOBACK and interactive DRAWORDER BACK on a large synthetic drawing. - // Ignored by default; A/B across commits with: - // BENCH_LINES=100000 BENCH_HATCHES=350 BENCH_RUNS=5 \ - // cargo test --release --lib bench_draworder_large_drawing -- --ignored --nocapture - #[test] - #[ignore] - fn bench_draworder_large_drawing() { - let lines_n: usize = std::env::var("BENCH_LINES").ok().and_then(|v| v.parse().ok()).unwrap_or(100_000); - let hatches_n: usize = std::env::var("BENCH_HATCHES").ok().and_then(|v| v.parse().ok()).unwrap_or(350); - let runs: usize = std::env::var("BENCH_RUNS").ok().and_then(|v| v.parse().ok()).unwrap_or(5); - let sel_n: usize = std::env::var("BENCH_SEL").ok().and_then(|v| v.parse().ok()).unwrap_or(100); - fn median(v: &mut [f64]) -> f64 { - v.sort_by(|a, b| a.partial_cmp(b).unwrap()); - v[v.len() / 2] - } - - // --- HATCHTOBACK --- - let mut hb_ms: Vec = Vec::new(); - for _ in 0..runs { - let mut app = OpenCADStudio::new_for_test(); - app.automation_op(r#"{"op":"new"}"#); - let i = app.active_tab; - let t0 = std::time::Instant::now(); - let lines: Vec = - (0..lines_n).map(|_| EntityType::Line(Default::default())).collect(); - let _ = app.tabs[i].scene.add_entities(lines); - let hatches: Vec = - (0..hatches_n).map(|_| EntityType::Hatch(Default::default())).collect(); - app.tabs[i].scene.add_entities(hatches); - eprintln!( - "[bench] setup ({} lines + {} hatches): {:.1} ms", - lines_n, - hatches_n, - t0.elapsed().as_secs_f64() * 1e3 - ); - let t1 = std::time::Instant::now(); - let _ = app.run_command_line("HATCHTOBACK"); - hb_ms.push(t1.elapsed().as_secs_f64() * 1e3); - } - eprintln!("[bench] HATCHTOBACK median: {:.3} ms over {} runs", median(&mut hb_ms), runs); - - // --- DRAWORDER BACK (interactive path) --- - let mut db_ms: Vec = Vec::new(); - for _ in 0..runs { - let mut app = OpenCADStudio::new_for_test(); - app.automation_op(r#"{"op":"new"}"#); - let i = app.active_tab; - let lines: Vec = - (0..lines_n).map(|_| EntityType::Line(Default::default())).collect(); - let _ = app.tabs[i].scene.add_entities(lines); - let hatches: Vec = - (0..hatches_n).map(|_| EntityType::Hatch(Default::default())).collect(); - let handles = app.tabs[i].scene.add_entities(hatches); - app.tabs[i] - .scene - .replace_selection(handles.into_iter().take(sel_n).collect()); - let t1 = std::time::Instant::now(); - let _ = app.run_command_line("DRAWORDER BACK"); - db_ms.push(t1.elapsed().as_secs_f64() * 1e3); - } - eprintln!( - "[bench] DRAWORDER BACK ({} selected) median: {:.3} ms over {} runs", - sel_n, median(&mut db_ms), runs - ); - } fn fresh_app() -> OpenCADStudio { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 4bfdd0864..7155f6317 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2661,7 +2661,8 @@ impl Scene { /// is `None` for a freshly added entity. No-op when not recording — the /// callers guard with [`Scene::is_recording_undo`] so the clone is skipped /// entirely on the common (no-recording) path. - pub(crate) fn record_undo_before(&mut self, handle: Handle, before: Option>) { + #[doc(hidden)] + pub fn record_undo_before(&mut self, handle: Handle, before: Option>) { if let Some(rec) = self.undo_recording.as_mut() { if !rec.before.contains_key(&handle) { rec.order.push(handle); @@ -5377,7 +5378,8 @@ impl Scene { /// frustum cull, no zoom LOD, and no re-tessellation/re-upload on camera /// moves. The per-tile camera args are unused (kept for call-site symmetry /// with the paper-space wire sources). - pub(super) fn model_tile_wires_arc( + #[doc(hidden)] + pub fn model_tile_wires_arc( &self, _tile_idx: usize, _cam: &Camera, @@ -6226,7 +6228,8 @@ impl Scene { self.draw_depth_generation.get() } - pub(super) fn draw_depth_map(&self) -> Arc> { + #[doc(hidden)] + pub fn draw_depth_map(&self) -> Arc> { { let cache = self.draw_depth_cache.borrow(); if let Some(cache) = cache.as_ref() { @@ -12535,260 +12538,6 @@ mod layout_cache_tests { 2, "Mixed polyline should have its 2 bulge arcs routed to analytical CircleGpu" ); - assert_eq!( - partitioned.regular.len(), - 1, - "Mixed polyline straight lines should form 1 regular wire" - ); - } - - #[test] - #[ignore = "benchmark"] - fn bench_analytical_rendering() { - use std::time::Instant; - use acadrust::entities::{Arc as AcadArc, Circle, Ellipse, LwPolyline, LwVertex}; - use acadrust::types::{Vector2, Vector3}; - - let n_circles = 5000; - let n_arcs = 5000; - let n_ellipses = 2000; - let n_polylines = 2000; - let total_entities = n_circles + n_arcs + n_ellipses + n_polylines; - - let mut scene = Scene::new(); - for i in 0..n_circles { - let x = (i % 100) as f64 * 50.0; - let y = (i / 100) as f64 * 50.0; - let mut circle = Circle::default(); - circle.center = Vector3::new(x, y, 0.0); - circle.radius = 10.0 + (i % 20) as f64; - scene.add_entity(EntityType::Circle(circle)); - } - - for i in 0..n_arcs { - let x = (i % 100) as f64 * 50.0 + 25.0; - let y = (i / 100) as f64 * 50.0; - let mut arc = AcadArc::default(); - arc.center = Vector3::new(x, y, 0.0); - arc.radius = 15.0; - arc.start_angle = ((i % 8) as f64) * 0.25 * std::f64::consts::PI; - arc.end_angle = arc.start_angle + 0.75 * std::f64::consts::PI; - scene.add_entity(EntityType::Arc(arc)); - } - - for i in 0..n_ellipses { - let x = (i % 50) as f64 * 100.0; - let y = (i / 50) as f64 * 100.0 + 5000.0; - let mut el = Ellipse::default(); - el.center = Vector3::new(x, y, 0.0); - el.major_axis = Vector3::new(20.0, 0.0, 0.0); - el.minor_axis_ratio = 0.5; - el.start_parameter = 0.0; - el.end_parameter = std::f64::consts::TAU; - scene.add_entity(EntityType::Ellipse(el)); - } - - for i in 0..n_polylines { - let x = (i % 50) as f64 * 100.0 + 50.0; - let y = (i / 50) as f64 * 100.0 + 5000.0; - let mut pline = LwPolyline::new(); - pline.is_closed = true; - pline.vertices = vec![ - LwVertex { - location: Vector2::new(x, y), - bulge: 1.0, - start_width: 0.0, - end_width: 0.0, - vertex_id: 0, - }, - LwVertex { - location: Vector2::new(x + 30.0, y), - bulge: 1.0, - start_width: 0.0, - end_width: 0.0, - vertex_id: 1, - }, - ]; - scene.add_entity(EntityType::LwPolyline(pline)); - } - - let cam = Camera::default(); - let t_tess_start = Instant::now(); - let wires = scene.model_tile_wires_arc(0, &cam, 1.0, 1000.0); - let tess_duration = t_tess_start.elapsed(); - - let total_wires = wires.len(); - let mut chord_points = 0usize; - let mut analytical_tangents = 0usize; - for w in wires.iter() { - chord_points += w.points.len(); - analytical_tangents += w.tangent_geoms.len(); - } - - let depths = rustc_hash::FxHashMap::default(); - let t_part_start = Instant::now(); - let iters = 100; - let mut part = None; - for _ in 0..iters { - part = Some(crate::scene::pipeline::wire_arena::partition_wires(&wires, &depths)); - } - let part_duration = t_part_start.elapsed() / (iters as u32); - let p = part.unwrap(); - - // Zoom simulation - let zoom_levels = [0.1f32, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 50.0, 100.0, 1000.0]; - let t_zoom_start = Instant::now(); - let mut dynamic_cam = Camera::default(); - for &zoom in zoom_levels.iter().cycle().take(100) { - dynamic_cam.distance = 1000.0 / zoom; - let _w = scene.model_tile_wires_arc(0, &dynamic_cam, 1.0, 1000.0); - } - let zoom_100_duration = t_zoom_start.elapsed(); - let per_zoom_ms = zoom_100_duration.as_secs_f64() * 1000.0 / 100.0; - - let line_vram_mb = (chord_points * 36) as f64 / (1024.0 * 1024.0); - let inst_vram_mb = (p.circle_instances.len() * 128 + p.ellipse_instances.len() * 144) as f64 / (1024.0 * 1024.0); - - println!("\n=== BENCHMARK REPORT: CURRENT HEAD (WITH GPU ANALYTICAL & OPTIMIZATIONS) ==="); - println!("Curved Entities Tested: {total_entities}"); - println!("Initial Wire Build Time: {:.2?}", tess_duration); - println!("Total Wires: {total_wires}"); - println!("Tessellated Chord Vertices: {chord_points}"); - println!("Analytical Tangent Geometries: {analytical_tangents}"); - println!("Partitioning Time (per frame): {:.3?}", part_duration); - println!("Regular Line Wires: {}", p.regular.len()); - println!("GPU Circle/Arc Instances: {}", p.circle_instances.len()); - println!("GPU Ellipse Instances: {}", p.ellipse_instances.len()); - println!("Line Arena VRAM (chord lines): {:.2} MB", line_vram_mb); - println!("GPU Analytical VRAM: {:.2} MB", inst_vram_mb); - println!("VRAM Reduction Ratio: {:.1}x", if inst_vram_mb > 0.0 { line_vram_mb / inst_vram_mb } else { 0.0 }); - println!("100 Camera Zoom Navigations: {:.2?}", zoom_100_duration); - println!("Zoom Frame Overhead: {:.3} ms ({:.0} FPS)", per_zoom_ms, 1000.0 / per_zoom_ms.max(0.001)); - println!("============================================================================\n"); - } - - #[test] - #[ignore = "benchmark"] - fn bench_wide_and_tapered_arc_rendering() { - use std::time::Instant; - use acadrust::entities::LwPolyline; - use acadrust::types::Vector2; - - let n_wide_arcs = 2500; - let n_tapered_arcs = 2500; - let n_donuts = 2500; - let total_entities = n_wide_arcs + n_tapered_arcs + n_donuts; - - let mut scene = Scene::new(); - - // 1. Wide polyline arcs (constant width 6.0) - for i in 0..n_wide_arcs { - let x = (i % 50) as f64 * 80.0; - let y = (i / 50) as f64 * 80.0; - let mut pline = LwPolyline::new(); - pline.constant_width = 6.0; - pline.vertices = vec![ - acadrust::entities::LwVertex { - location: Vector2::new(x, y), - bulge: 0.5, - start_width: 0.0, - end_width: 0.0, - vertex_id: 0, - }, - acadrust::entities::LwVertex { - location: Vector2::new(x + 40.0, y), - bulge: 0.0, - start_width: 0.0, - end_width: 0.0, - vertex_id: 1, - }, - ]; - scene.add_entity(EntityType::LwPolyline(pline)); - } - - // 2. Tapered arcs (width: 2.0 -> 12.0) - for i in 0..n_tapered_arcs { - let x = (i % 50) as f64 * 80.0; - let y = (i / 50) as f64 * 80.0 + 4500.0; - let mut pline = LwPolyline::new(); - pline.vertices = vec![ - acadrust::entities::LwVertex { - location: Vector2::new(x, y), - bulge: 0.7, - start_width: 2.0, - end_width: 12.0, - vertex_id: 0, - }, - acadrust::entities::LwVertex { - location: Vector2::new(x + 35.0, y + 10.0), - bulge: 0.0, - start_width: 0.0, - end_width: 0.0, - vertex_id: 1, - }, - ]; - scene.add_entity(EntityType::LwPolyline(pline)); - } - - // 3. Donuts (2 semicircular arc segments, constant width 16.0) - for i in 0..n_donuts { - let cx = (i % 50) as f64 * 80.0; - let cy = (i / 50) as f64 * 80.0 + 9000.0; - let donut = crate::modules::draw::draw::donut::make_donut(cx, cy, 0.0, 10.0, 26.0); - scene.add_entity(donut); - } - - let cam = Camera::default(); - let t_tess_start = Instant::now(); - let wires = scene.model_tile_wires_arc(0, &cam, 1.0, 1000.0); - let tess_duration = t_tess_start.elapsed(); - - let total_wires = wires.len(); - let mut chord_points = 0usize; - let mut analytical_tangents = 0usize; - for w in wires.iter() { - chord_points += w.points.len(); - analytical_tangents += w.tangent_geoms.len(); - } - - let depths = rustc_hash::FxHashMap::default(); - let t_part_start = Instant::now(); - let iters = 100; - let mut part = None; - for _ in 0..iters { - part = Some(crate::scene::pipeline::wire_arena::partition_wires(&wires, &depths)); - } - let part_duration = t_part_start.elapsed() / (iters as u32); - let p = part.unwrap(); - - // Zoom simulation - let zoom_levels = [0.1f32, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 50.0, 100.0, 1000.0]; - let t_zoom_start = Instant::now(); - let mut dynamic_cam = Camera::default(); - for &zoom in zoom_levels.iter().cycle().take(100) { - dynamic_cam.distance = 1000.0 / zoom; - let _w = scene.model_tile_wires_arc(0, &dynamic_cam, 1.0, 1000.0); - } - let zoom_100_duration = t_zoom_start.elapsed(); - let per_zoom_ms = zoom_100_duration.as_secs_f64() * 1000.0 / 100.0; - - let line_vram_mb = (chord_points * 36) as f64 / (1024.0 * 1024.0); - let inst_vram_mb = (p.circle_instances.len() * 128) as f64 / (1024.0 * 1024.0); - - println!("\n=== BENCHMARK REPORT: THICK & TAPERED ANALYTICAL ARCS ==="); - println!("Thick & Tapered Entities: {total_entities}"); - println!("Initial Wire Build Time: {:.2?}", tess_duration); - println!("Total Wires: {total_wires}"); - println!("Tessellated Chord Vertices: {chord_points}"); - println!("Analytical Tangent Geometries: {analytical_tangents}"); - println!("Partitioning Time (per frame): {:.3?}", part_duration); - println!("Regular Line Wires: {}", p.regular.len()); - println!("GPU Circle/Arc Instances: {}", p.circle_instances.len()); - println!("Line Arena VRAM (chord lines): {:.2} MB", line_vram_mb); - println!("GPU Analytical VRAM: {:.2} MB", inst_vram_mb); - println!("VRAM Reduction Ratio: {:.1}x", if inst_vram_mb > 0.0 { line_vram_mb / inst_vram_mb } else { 0.0 }); - println!("100 Camera Zoom Navigations: {:.2?}", zoom_100_duration); - println!("Zoom Frame Overhead: {:.3} ms ({:.0} FPS)", per_zoom_ms, 1000.0 / per_zoom_ms.max(0.001)); - println!("========================================================\n"); } } + diff --git a/src/scene/pick/selection_state.rs b/src/scene/pick/selection_state.rs index 666a85082..067a9c2c2 100644 --- a/src/scene/pick/selection_state.rs +++ b/src/scene/pick/selection_state.rs @@ -78,85 +78,3 @@ impl SelectionState { } } -#[cfg(test)] -mod bench_selection_clone_tests { - use super::SelectionState; - use iced::Point; - use std::hint::black_box; - use std::sync::Arc; - use std::time::Instant; - - fn make_state(poly_len: usize) -> SelectionState { - let mut s = SelectionState { - vp_size: (1920.0, 1080.0), - poly_points: vec![Point::new(10.0, 10.0); poly_len], - ..Default::default() - }; - s.box_anchor = Some(Point::new(0.0, 0.0)); - s.box_current = Some(Point::new(100.0, 100.0)); - s - } - - #[test] - #[ignore] - fn bench_selection_state_clone() { - let state = make_state(64); - for _ in 0..20 { - black_box(state.clone()); - } - let n = 5000u32; - let start = Instant::now(); - for _ in 0..n { - black_box(state.clone()); - } - let elapsed = start.elapsed(); - let per = elapsed / n; - println!( - "SelectionState::clone (deep, 64 pts): {:?} per clone (n={}, total {:?})", - per, n, elapsed - ); - assert!(per.as_secs_f64() > 0.0); - } - - #[test] - #[ignore] - fn bench_selection_arc_clone() { - let state = Arc::new(make_state(64)); - for _ in 0..20 { - black_box(Arc::clone(&state)); - } - let n = 5000u32; - let start = Instant::now(); - for _ in 0..n { - black_box(Arc::clone(&state)); - } - let elapsed = start.elapsed(); - let per = elapsed / n; - println!( - "Arc::clone (Arc bump, 64 pts): {:?} per clone (n={}, total {:?})", - per, n, elapsed - ); - assert!(per.as_secs_f64() > 0.0); - } - - #[test] - #[ignore] - fn bench_selection_state_clone_empty() { - let state = make_state(0); - for _ in 0..20 { - black_box(state.clone()); - } - let n = 5000u32; - let start = Instant::now(); - for _ in 0..n { - black_box(state.clone()); - } - let elapsed = start.elapsed(); - let per = elapsed / n; - println!( - "SelectionState::clone (deep, 0 pts): {:?} per clone (n={}, total {:?})", - per, n, elapsed - ); - assert!(per.as_secs_f64() > 0.0); - } -} diff --git a/src/ui/icons.rs b/src/ui/icons.rs index a9d4227da..59469855a 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -159,7 +159,8 @@ thread_local! { /// trigger a `RefCell` panic on the second `borrow_mut`, because /// no `borrow_mut` is held while parsing runs. (The same /// `thread_local!` + `RefCell` shape is used by `SEMANTIC_CACHE`.) -fn themed_handle(bytes: &'static [u8]) -> svg::Handle { +#[doc(hidden)] +pub fn themed_handle(bytes: &'static [u8]) -> svg::Handle { let key = (bytes.as_ptr() as usize, bytes.len()); if let Some(handle) = THEMED_CACHE.with(|cache| cache.borrow().get(&key).cloned()) { return handle; diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 05e88b46a..32d03653d 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -380,7 +380,8 @@ pub struct GridParams { /// uses to size the coloured UCS axes overlay. Returned from `grid_segments` so /// the renderer-free geometry construction can be unit-tested and benchmarked /// without an iced `Renderer` (Mission #1, 2026-08-26 bench-first plan). -pub(crate) struct GridGeometry { +#[doc(hidden)] +pub struct GridGeometry { pub segments: Vec<(Point, Point)>, pub axis_extent: f32, } @@ -390,7 +391,8 @@ impl GridGeometry { /// early-exit branches of `grid_segments` (zero-sized bounds, no visible /// samples, non-finite step) so the caller never needs to special-case /// the `None` path. - fn empty() -> Self { + #[doc(hidden)] + pub fn empty() -> Self { Self { segments: Vec::new(), axis_extent: 0.0 } } } @@ -405,8 +407,9 @@ impl GridGeometry { /// `GridParams` set is required for correctness. /// /// Added 2026-08-26 by Mission #1 (grid overlay cache, Tier 1 #1). +#[doc(hidden)] #[derive(Clone, Debug, PartialEq)] -pub(crate) struct GridKey { +pub struct GridKey { pub grids: Vec, pub bounds: iced::Rectangle, pub style: GridStyle, @@ -414,7 +417,8 @@ pub(crate) struct GridKey { impl GridKey { /// Build a key from the per-pane `GridParams`, overlay bounds, and grid style. - pub(crate) fn from_grids(grids: &[GridParams], bounds: iced::Rectangle, style: GridStyle) -> Self { + #[doc(hidden)] + pub fn from_grids(grids: &[GridParams], bounds: iced::Rectangle, style: GridStyle) -> Self { Self { grids: grids.to_vec(), bounds, style } } } @@ -423,7 +427,8 @@ impl GridKey { /// `new`. Reference-based to avoid moving the (potentially large) `Vec` of /// per-pane params; the caller borrows from `RefCell>` on /// both sides. -pub(crate) fn should_reuse(cached: Option<&GridKey>, new: &GridKey) -> bool { +#[doc(hidden)] +pub fn should_reuse(cached: Option<&GridKey>, new: &GridKey) -> bool { match cached { Some(old) => old == new, None => false, @@ -440,8 +445,9 @@ pub(crate) fn should_reuse(cached: Option<&GridKey>, new: &GridKey) -> bool { /// bounds happen to be unchanged (e.g. a pan within the same canvas size). /// /// Added 2026-08-26 by Mission #1 (grid overlay cache, Tier 1 #1). +#[doc(hidden)] #[derive(Default)] -pub(crate) struct GridCanvasState { +pub struct GridCanvasState { pub key: RefCell>, pub cache: canvas::Cache, } @@ -1761,8 +1767,9 @@ fn draw_grid( /// Extracted from `draw_grid` (2026-08-26, Mission #1 step 1) so the geometry /// construction can be unit-tested and benchmarked without an iced /// `Renderer`. Behaviour is identical to the inlined version that preceded it. +#[doc(hidden)] #[allow(clippy::too_many_arguments)] -pub(crate) fn grid_segments( +pub fn grid_segments( view_rot: Mat4, eye: glam::DVec3, bounds: iced::Rectangle, @@ -3306,171 +3313,6 @@ mod clip_tests { } } -#[cfg(test)] -mod bench_grid_geometry_tests { - use super::*; - use std::hint::black_box; - use std::time::Instant; - - /// Benchmarks the pure grid geometry construction (uncached). - /// Represents a 2-pane tiled Model layout: pane 1 at x=0..1280, pane 2 at - /// x=1280..1920. Slight tilt, typical eye, step 80 (pane 1) / 160 (pane 2). - /// RED: requires `grid_segments(...)` which does not exist yet — compilation - /// must fail with E0425 "cannot find function `grid_segments`". The bench - /// becomes meaningful at Step 1 once the helper is extracted. - #[test] - #[ignore] - fn bench_grid_geometry_uncached() { - let view_rot1 = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05); - let eye1 = glam::DVec3::new(4.0, 3.5, 9.0); - let bounds1 = iced::Rectangle { - x: 0.0, - y: 0.0, - width: 1280.0, - height: 720.0, - }; - let step1 = 80.0_f32; - let grid_origin1 = glam::DVec3::new(0.0, 0.0, 0.0); - let grid_axes1 = (Vec3::X, Vec3::Y, Vec3::Z); - let limits1: Option<(glam::DVec2, glam::DVec2)> = None; - - let view_rot2 = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05); - let eye2 = glam::DVec3::new(4.0, 3.5, 9.0); - let bounds2 = iced::Rectangle { - x: 1280.0, - y: 0.0, - width: 640.0, - height: 720.0, - }; - let step2 = 160.0_f32; - let grid_origin2 = glam::DVec3::new(0.0, 0.0, 0.0); - let grid_axes2 = (Vec3::X, Vec3::Y, Vec3::Z); - let limits2: Option<(glam::DVec2, glam::DVec2)> = None; - - for _ in 0..20 { - let _ = black_box(grid_segments( - black_box(view_rot1), - black_box(eye1), - black_box(bounds1), - black_box(step1), - black_box(grid_origin1), - black_box(grid_axes1), - black_box(limits1), - )); - let _ = black_box(grid_segments( - black_box(view_rot2), - black_box(eye2), - black_box(bounds2), - black_box(step2), - black_box(grid_origin2), - black_box(grid_axes2), - black_box(limits2), - )); - } - - let n = 200u32; - let start = Instant::now(); - for _ in 0..n { - let _ = black_box(grid_segments( - black_box(view_rot1), - black_box(eye1), - black_box(bounds1), - black_box(step1), - black_box(grid_origin1), - black_box(grid_axes1), - black_box(limits1), - )); - let _ = black_box(grid_segments( - black_box(view_rot2), - black_box(eye2), - black_box(bounds2), - black_box(step2), - black_box(grid_origin2), - black_box(grid_axes2), - black_box(limits2), - )); - } - let elapsed = start.elapsed(); - let per_frame = elapsed / n; - println!( - "grid_segments uncached: {:?} per frame (n = {}, total {:?})", - per_frame, n, elapsed - ); - assert!(per_frame.as_secs_f64() > 0.0, "per-frame time must be positive"); - } - - /// A/B partner of `bench_grid_geometry_uncached` (Mission #1, step 6). - /// Times the hit-path decision only: build `GridKey` from the current - /// pane params + canvas bounds, borrow the stored key, call - /// `should_reuse`. Mirrors the body of the hit branch in - /// `GridCanvas::draw`. Excludes the iced `canvas::Cache` internals - /// (Arc-clone + draw_with_bounds fast path) because they live in the - /// fork and are not what we added; measures only the cost we own. - #[test] - #[ignore] - fn bench_grid_geometry_cached() { - let view_rot1 = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05); - let eye1 = glam::DVec3::new(4.0, 3.5, 9.0); - let bounds1 = iced::Rectangle { x: 0.0, y: 0.0, width: 1280.0, height: 720.0 }; - let step1 = 80.0_f32; - let origin1 = glam::DVec3::new(0.0, 0.0, 0.0); - let axes1 = (Vec3::X, Vec3::Y, Vec3::Z); - let limits1: Option<(glam::DVec2, glam::DVec2)> = None; - - let view_rot2 = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05); - let eye2 = glam::DVec3::new(4.0, 3.5, 9.0); - let bounds2 = iced::Rectangle { x: 1280.0, y: 0.0, width: 640.0, height: 720.0 }; - let step2 = 160.0_f32; - let origin2 = glam::DVec3::new(0.0, 0.0, 0.0); - let axes2 = (Vec3::X, Vec3::Y, Vec3::Z); - let limits2: Option<(glam::DVec2, glam::DVec2)> = None; - - let params1 = GridParams { - view_rot: view_rot1, eye: eye1, bounds: bounds1, step: step1, - origin: origin1, axes: axes1, limits: limits1, - }; - let params2 = GridParams { - view_rot: view_rot2, eye: eye2, bounds: bounds2, step: step2, - origin: origin2, axes: axes2, limits: limits2, - }; - let grids = vec![params1, params2]; - // Overall canvas bounds — what `GridCanvas::draw` receives and - // passes to `GridKey::from_grids`. The 1920×720 covers the two - // tiled panes (1280 + 640). - let canvas_bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 }; - - // Pre-seed a `GridCanvasState` with the same key the bench will - // build each iteration — guaranteed hit path. - let state = GridCanvasState::default(); - let stored_key = GridKey::from_grids(&grids, canvas_bounds, GridStyle::default()); - *state.key.borrow_mut() = Some(stored_key); - - for _ in 0..20 { - let key = GridKey::from_grids(black_box(&grids), black_box(canvas_bounds), GridStyle::default()); - let hit = should_reuse(state.key.borrow().as_ref(), &key); - black_box(hit); - } - - let n = 200u32; - let start = Instant::now(); - let mut hit_count = 0u32; - for _ in 0..n { - let key = GridKey::from_grids(black_box(&grids), black_box(canvas_bounds), GridStyle::default()); - if should_reuse(state.key.borrow().as_ref(), &key) { - hit_count += 1; - } - } - let elapsed = start.elapsed(); - let per_frame = elapsed / n; - assert_eq!(hit_count, n, "bench should always hit (sanity)"); - println!( - "grid key + should_reuse (hit path): {:?} per frame (n = {}, total {:?})", - per_frame, n, elapsed - ); - assert!(per_frame.as_secs_f64() > 0.0, "per-frame time must be positive"); - } -} - #[cfg(test)] mod grid_key_tests { use super::*; diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 5dc37ad6b..badc52b84 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -1610,76 +1610,5 @@ mod tests { assert!(ribbon.escape_extension()); assert_eq!(ribbon.open_dropdown, None); } - - /// Reproducible element-construction benchmark for the ribbon view. Run with: - /// `cargo test --lib --release -- --ignored --nocapture bench_ribbon_view_construction` - /// - /// Times `Ribbon::view()` element construction — the dominant cost in the - /// cheap (non-render) part of a ribbon frame, and the thing Mission #4 - /// reworked. `#[ignore]`d so normal runs stay silent; state is populated so - /// Auto-mode panels actually have four densities to build. - #[test] - #[ignore] - fn bench_ribbon_view_construction() { - use std::time::Instant; - use super::*; - - let mut ribbon = Ribbon::new(); - ribbon.set_styles( - vec![ - "Standard".to_string(), - "Title".to_string(), - "Annotative".to_string(), - ], - "Standard", - vec!["Standard".to_string()], - "Standard", - vec!["Standard".to_string()], - "Standard", - vec!["Standard".to_string()], - "Standard", - ); - ribbon.set_layers( - vec![ - LayerInfo { - name: "0".to_string(), - color: Color::TRANSPARENT, - visible: true, - frozen: false, - locked: false, - }, - LayerInfo { - name: "DRAWING".to_string(), - color: Color::TRANSPARENT, - visible: true, - frozen: false, - locked: false, - }, - ], - "0", - ); - ribbon.set_available_linetypes(vec![ - LinetypeItem { - name: "Continuous".to_string(), - art: String::new(), - }, - LinetypeItem { - name: "DASHED".to_string(), - art: String::new(), - }, - ]); - - let n = 200u32; - // Warm-up for allocator/tree-slot settling before timing begins. - let _ = ribbon.view(false, false, 0, 0, false); - let start = Instant::now(); - for _ in 0..n { - let _ = ribbon.view(false, false, 0, 0, false); - } - let per_frame = start.elapsed() / n; - println!( - "bench_ribbon_view_construction: {per_frame:?} per `Ribbon::view()` \ - element build (n = {n}, release measurement recommended)" - ); - } } +