From 7978d1eb2e85c84ddc6de7c8b91fdae662d27005 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Mon, 28 Sep 2026 06:58:47 +0100 Subject: [PATCH] bench: add COMMANDS suite with EXPLODE metrics Shares the benchmark harness between both suites, extracts the plan/batch/apply helpers out of the EXPLODE command arm, and adds the command_benchmarks target. --- Cargo.toml | 4 + benches/README.md | 22 ++- benches/command_benchmarks.rs | 257 ++++++++++++++++++++++++ benches/performance_benchmarks.rs | 303 +---------------------------- benches/support/harness.rs | 291 +++++++++++++++++++++++++++ src/app/commands/dim.rs | 27 +-- src/modules/draw/modify/explode.rs | 46 +++++ 7 files changed, 629 insertions(+), 321 deletions(-) create mode 100644 benches/command_benchmarks.rs create mode 100644 benches/support/harness.rs diff --git a/Cargo.toml b/Cargo.toml index 8cae06e8f..25d637e0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -151,3 +151,7 @@ strip = true [[bench]] name = "performance_benchmarks" harness = false + +[[bench]] +name = "command_benchmarks" +harness = false diff --git a/benches/README.md b/benches/README.md index 92483d0e4..f09c65a6f 100644 --- a/benches/README.md +++ b/benches/README.md @@ -5,12 +5,12 @@ A standardized, statistical performance benchmarking suite measuring throughput, ## Quick Start Run the full benchmark suite with native `--release` optimizations: -```powershell +```bash cargo bench ``` Run a fast validation smoke test (~2 seconds): -```powershell +```bash cargo bench --bench performance_benchmarks -- --quick ``` @@ -66,10 +66,22 @@ cargo bench --bench performance_benchmarks -- --quick --- +## Command Benchmarks + +First benchmarked command: **EXPLODE** (metrics `explode_geometry`, +`explode_scene_apply`). + +```bash +cargo bench --bench command_benchmarks -- --filter explode +cargo bench --bench command_benchmarks -- --quick +cargo bench --bench command_benchmarks -- --filter explode --output target/explode_metrics.json +cargo bench --bench command_benchmarks -- --filter explode --baseline target/explode_before.json +``` + ## Filtering Benchmarks Filter execution by subsystem or name pattern: -```powershell +```bash # Filter by UI cargo bench --bench performance_benchmarks -- ui @@ -85,12 +97,12 @@ cargo bench --bench performance_benchmarks -- hit_test ## Baseline Tracking & Regression Detection ### 1. Save Current Metrics as Baseline -```powershell +```bash cargo bench --bench performance_benchmarks -- --output target/baseline_metrics.json ``` ### 2. Compare PR / Branch Against Baseline -```powershell +```bash cargo bench --bench performance_benchmarks -- --baseline target/baseline_metrics.json ``` diff --git a/benches/command_benchmarks.rs b/benches/command_benchmarks.rs new file mode 100644 index 000000000..df7cfabf3 --- /dev/null +++ b/benches/command_benchmarks.rs @@ -0,0 +1,257 @@ +//! OpenCADStudio COMMANDS benchmark suite (`harness = false`). +//! Currently: EXPLODE (geometry pipeline + scene apply). + +#[path = "support/harness.rs"] +mod harness; + +use std::hint::black_box; +use std::time::Instant; + +use codec::entities::{ + Dimension, DimensionLinear, Insert, Line, LwPolyline, LwVertex, MLine, +}; +use codec::tables::BlockRecord; +use codec::types::{Vector2, Vector3}; +use codec::{CadDocument, EntityType, Handle}; + +use harness::BenchmarkRunner; +use OpenCADStudio::modules::draw::modify::explode::{ + apply_explode_replacements, explode_batch, +}; +use OpenCADStudio::scene::Scene; + +const BLOCK_NAME: &str = "BENCH_BLOCK"; +const VERTS_PER_POLYLINE: usize = 50; +const MEMBERS_PER_BLOCK: usize = 20; + +/// (lwpolylines, inserts, dimensions, mlines) — full = 2,500 compounds. +fn counts(quick: bool) -> (usize, usize, usize, usize) { + if quick { + (1_000, 100, 100, 50) + } else { + (2_000, 200, 200, 100) + } +} + +fn fixture_entities(quick: bool) -> usize { + let (a, b, c, d) = counts(quick); + a + b + c + d +} + +/// Shared 20-member block (10 lines + 10 three-vertex polylines) registered +/// in `block_records` with `entity_handles` set — what explode_from_document +/// needs to materialize inserts. +fn add_bench_block(doc: &mut CadDocument) { + let mut member_handles = Vec::with_capacity(MEMBERS_PER_BLOCK); + for k in 0..MEMBERS_PER_BLOCK { + if k % 2 == 0 { + let mut line = Line::new(); + let x = (k / 2) as f64 * 3.0; + line.start = Vector3::new(x, 0.0, 0.0); + line.end = Vector3::new(x, 4.0, 0.0); + member_handles.push(doc.add_entity(EntityType::Line(line)).unwrap()); + } else { + let mut pl = LwPolyline::new(); + pl.vertices = vec![ + LwVertex::new(Vector2::new(0.0, (k as f64) * 0.5)), + LwVertex::new(Vector2::new(2.0, (k as f64) * 0.5)), + LwVertex::new(Vector2::new(2.0, (k as f64) * 0.5 + 2.0)), + ]; + member_handles.push(doc.add_entity(EntityType::LwPolyline(pl)).unwrap()); + } + } + let mut block = BlockRecord::new(BLOCK_NAME); + block.handle = doc.allocate_handle(); + block.entity_handles = member_handles; + doc.block_records.add(block).unwrap(); +} + +/// Populate the document with the compound fixture; returns the handles of +/// the fixture entities (block members are NOT included). +fn push_fixture_entities(doc: &mut CadDocument, quick: bool) -> Vec { + let (n_pl, n_ins, n_dim, n_ml) = counts(quick); + let mut handles = Vec::with_capacity(fixture_entities(quick)); + + for i in 0..n_pl { + let base = i as f64 * 0.01; + let mut pl = LwPolyline::new(); + pl.vertices = (0..VERTS_PER_POLYLINE) + .map(|k| { + let mut v = LwVertex::new(Vector2::new( + base + k as f64 * 2.0, + (k % 5) as f64 * 0.75, + )); + if k % 2 == 1 { + v.bulge = 0.5; + } + v + }) + .collect(); + handles.push(doc.add_entity(EntityType::LwPolyline(pl)).unwrap()); + } + + for i in 0..n_ins { + let pos = Vector3::new((i % 50) as f64 * 5.0, (i / 50) as f64 * 5.0, 0.0); + let ins = Insert::new(BLOCK_NAME, pos); + handles.push(doc.add_entity(EntityType::Insert(ins)).unwrap()); + } + + for i in 0..n_dim { + let x = (i % 100) as f64 * 12.0; + let y = (i / 100) as f64 * 12.0; + let mut d = DimensionLinear::new( + Vector3::new(x, y, 0.0), + Vector3::new(x + 10.0, y, 0.0), + ); + d.definition_point = Vector3::new(x + 5.0, y + 3.0, 0.0); + d.base.text_middle_point = Vector3::new(x + 5.0, y + 4.0, 0.0); + handles.push(doc.add_entity(EntityType::Dimension(Dimension::Linear(d))).unwrap()); + } + + for i in 0..n_ml { + let x = (i % 100) as f64 * 8.0; + let y = (i / 100) as f64 * 8.0; + let pts = [ + Vector3::new(x, y, 0.0), + Vector3::new(x + 4.0, y, 0.0), + Vector3::new(x + 4.0, y + 4.0, 0.0), + Vector3::new(x + 8.0, y + 4.0, 0.0), + Vector3::new(x + 8.0, y + 8.0, 0.0), + Vector3::new(x + 12.0, y + 8.0, 0.0), + ]; + handles.push(doc.add_entity(EntityType::MLine(MLine::from_points(&pts))).unwrap()); + } + + handles +} + +fn median(times: &[f64]) -> f64 { + let mut sorted = times.to_vec(); + sorted.sort_by(|a, b| a.total_cmp(b)); + let n = sorted.len(); + if n % 2 == 0 { + (sorted[n / 2 - 1] + sorted[n / 2]) * 0.5 + } else { + sorted[n / 2] + } +} + +/// EXPLODE geometry: time `explode_batch` over the whole fixture. +/// Document build + `items` slice construction are untimed; `black_box` on +/// the result keeps the compiler from eliding the work. +fn bench_explode_geometry(runner: &mut BenchmarkRunner) { + let name = "explode_geometry"; + if !runner.should_run(name) { + return; + } + let quick = runner.quick_mode; + let sample_count = if quick { 3 } else { 10 }; + let entity_count = fixture_entities(quick); + let mut times = Vec::with_capacity(sample_count); + + for _ in 0..sample_count { + let mut doc = CadDocument::new(); + add_bench_block(&mut doc); + let handles = push_fixture_entities(&mut doc, quick); + let items: Vec<(Handle, &EntityType)> = handles + .iter() + .filter_map(|&h| doc.get_entity(h).map(|e| (h, e))) + .collect(); + assert_eq!(items.len(), entity_count); + + let t0 = Instant::now(); + let pieces = black_box(explode_batch(&items, &doc)); + times.push(t0.elapsed().as_micros() as f64); + assert_eq!(pieces.len(), entity_count); + assert!( + pieces.iter().any(|p| !p.is_empty()), + "fixture must produce non-empty pieces — block-record/explosion path broken" + ); + } + + let med = median(×); + let throughput = entity_count as f64 / (med / 1_000_000.0); + runner.record( + name, + "EXPLODE geometry pipeline: explode_batch over the compound fixture", + "us", + times, + Some((throughput, "entities/s")), + None, + ); +} + +/// EXPLODE apply: fresh Scene per sample (untimed), replacements computed +/// untimed, `apply_explode_replacements` timed. +fn bench_explode_scene_apply(runner: &mut BenchmarkRunner) { + let name = "explode_scene_apply"; + if !runner.should_run(name) { + return; + } + let quick = runner.quick_mode; + let sample_count = if quick { 3 } else { 10 }; + let entity_count = fixture_entities(quick); + let mut times = Vec::with_capacity(sample_count); + let mut total_pieces = 0usize; + + for _ in 0..sample_count { + let mut scene = Scene::new(); + add_bench_block(&mut scene.document); + let handles = push_fixture_entities(&mut scene.document, quick); + + let (replacements, pieces_count) = { + let items: Vec<(Handle, &EntityType)> = handles + .iter() + .filter_map(|&h| scene.document.get_entity(h).map(|e| (h, e))) + .collect(); + let per_entity = explode_batch(&items, &scene.document); + let reps: Vec<(Handle, Vec)> = handles + .into_iter() + .zip(per_entity) + .filter(|(_, p)| !p.is_empty()) + .collect(); + let count = reps.iter().map(|(_, p)| p.len()).sum(); + (reps, count) + }; // `items` borrow of scene.document ends here + + let t0 = Instant::now(); + black_box(apply_explode_replacements(&mut scene, replacements)); + times.push(t0.elapsed().as_micros() as f64); + assert!( + pieces_count > entity_count, + "fixture pieces ({pieces_count}) must far exceed input count ({entity_count}) — a broken explosion path returns empty lists" + ); + if total_pieces == 0 { + total_pieces = pieces_count; // deterministic — capture once + } + } + + let med = median(×); + let throughput = total_pieces as f64 / (med / 1_000_000.0); + runner.record( + name, + "EXPLODE apply: apply_explode_replacements (erase sources + add pieces)", + "us", + times, + Some((throughput, "pieces/s")), + None, + ); +} + +fn main() { + println!("\nInitializing OpenCADStudio Command Benchmarks..."); + let mut runner = BenchmarkRunner::new( + format!("{:^120}", "OPENCADSTUDIO COMMAND BENCHMARK REPORT"), + "cad_command_metrics.json", + ); + if runner.quick_mode { + println!("Mode: QUICK (halved fixture, 3 samples per metric)"); + } else { + println!("Mode: FULL (2,500 compounds, 10 samples per metric)"); + } + + bench_explode_geometry(&mut runner); + bench_explode_scene_apply(&mut runner); + + runner.finish(); +} diff --git a/benches/performance_benchmarks.rs b/benches/performance_benchmarks.rs index aca20f9b0..f3f076d11 100644 --- a/benches/performance_benchmarks.rs +++ b/benches/performance_benchmarks.rs @@ -33,17 +33,12 @@ #![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 codec::entities::{Arc as AcadArc, Circle, Ellipse, Line, LwPolyline, LwVertex}; use codec::types::{Vector2, Vector3}; use codec::{CadDocument, DxfReader, DxfWriter, EntityType}; @@ -69,297 +64,10 @@ use OpenCADStudio::ui::overlay::{ use OpenCADStudio::ui::properties::LinetypeItem; use OpenCADStudio::ui::ribbon::{LayerInfo, Ribbon}; use OpenCADStudio::ui::style::plotstyle::build_layer_usage; +#[path = "support/harness.rs"] +mod harness; -// ── 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()) -} +use harness::BenchmarkRunner; // ── 1. Scene Entity Ingestion & Batch Creation ─────────────────────────────── @@ -2306,7 +2014,10 @@ fn bench_view_render_viewport_construction(runner: &mut BenchmarkRunner) { fn main() { println!("\nInitializing OpenCADStudio Performance Benchmarks..."); - let mut runner = BenchmarkRunner::new(); + let mut runner = BenchmarkRunner::new( + " OPENCADSTUDIO PERFORMANCE BENCHMARK REPORT ", + "cad_performance_metrics.json", + ); if runner.quick_mode { println!("Mode: QUICK (reduced iteration/entity counts for fast validation)"); diff --git a/benches/support/harness.rs b/benches/support/harness.rs new file mode 100644 index 000000000..4862ab866 --- /dev/null +++ b/benches/support/harness.rs @@ -0,0 +1,291 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::env; +use std::path::{Path, PathBuf}; + +// ── 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 title: String, + pub quick_mode: bool, + pub filter: Option, + pub baseline_path: Option, + pub output_path: PathBuf, + pub results: Vec, +} + +impl BenchmarkRunner { + pub fn new(title: impl Into, default_output: &str) -> 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(default_output) + }); + + Self { + title: title.into(), + 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{}", "=".repeat(120)); + println!("{}", self.title); + println!("{}", "=".repeat(120)); + 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()) +} diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 29fae9203..770007d25 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -1202,40 +1202,27 @@ impl OpenCADStudio { } "EXPLODE" => { - use crate::modules::draw::modify::explode::explode_entity; - let entities: Vec<_> = self.tabs[i] + use crate::modules::draw::modify::explode::{ + apply_explode_replacements, plan_explode, + }; + let selected: Vec<_> = self.tabs[i] .scene .selected_entities() .into_iter() .filter(|(handle, _)| !self.tabs[i].scene.is_layer_locked(*handle)) .collect(); - if entities.is_empty() { + if selected.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; let cmd = SelectObjectsCommand::new("EXPLODE"); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } else { - let replacements: Vec<(codec::Handle, Vec)> = entities - .iter() - .filter_map(|(h, e)| { - let pieces = explode_entity(e, &self.tabs[i].scene.document); - if pieces.is_empty() { - None - } else { - Some((*h, pieces)) - } - }) - .collect(); + let replacements = plan_explode(&selected, &self.tabs[i].scene.document); let exploded = replacements.len(); if exploded > 0 { self.push_undo_snapshot(i, "EXPLODE"); } - for (handle, pieces) in replacements { - self.tabs[i].scene.erase_entities(&[handle]); - for piece in pieces { - self.tabs[i].scene.add_entity(piece); - } - } + apply_explode_replacements(&mut self.tabs[i].scene, replacements); if exploded > 0 { self.tabs[i].dirty = true; self.refresh_properties(); diff --git a/src/modules/draw/modify/explode.rs b/src/modules/draw/modify/explode.rs index f47023fbe..4274418ea 100644 --- a/src/modules/draw/modify/explode.rs +++ b/src/modules/draw/modify/explode.rs @@ -118,6 +118,52 @@ pub fn explode_entity(entity: &EntityType, document: &CadDocument) -> Vec Vec> { + items.iter().map(|(_, e)| explode_entity(e, doc)).collect() +} + +/// Plan the EXPLODE command for an already lock-filtered selection: +/// order-preserving, empty pieces dropped (matching the command arm). +pub fn plan_explode( + selected: &[(Handle, &EntityType)], + doc: &CadDocument, +) -> Vec<(Handle, Vec)> { + selected + .iter() + .filter_map(|(handle, entity)| { + let pieces = explode_entity(entity, doc); + if pieces.is_empty() { + None + } else { + Some((*handle, pieces)) + } + }) + .collect() +} + +/// Apply precomputed EXPLODE replacements. Initial version is a +/// byte-for-byte extraction of the command arm's loop (erase one handle, +/// add pieces one at a time); optimizations change its internals only. +/// Returns the number of exploded sources. +pub fn apply_explode_replacements( + scene: &mut crate::scene::Scene, + replacements: Vec<(Handle, Vec)>, +) -> usize { + let exploded = replacements.len(); + for (handle, pieces) in replacements { + scene.erase_entities(&[handle]); + for piece in pieces { + scene.add_entity(piece); + } + } + exploded +} + fn explode_polyline(p: &Polyline) -> Vec { let n = p.vertices.len(); if n < 2 {