diff --git a/Cargo.lock b/Cargo.lock index e341bdd8..39dbc92a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4170,6 +4170,15 @@ dependencies = [ "serde", ] +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr 2.8.1", +] + [[package]] name = "quick-xml" version = "0.39.4" @@ -5180,6 +5189,9 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "stormsewer" version = "0.1.0" +dependencies = [ + "quick-xml 0.37.5", +] [[package]] name = "strict-num" diff --git a/build.rs b/build.rs index d6acb604..baa65b3c 100644 --- a/build.rs +++ b/build.rs @@ -7,6 +7,7 @@ // Rules for a directory to be picked up: // • Located directly in src/modules/ // • Contains a mod.rs file +// • Does NOT contain plugin.toml (add-ons register via BuiltinPlugin::ribbon) // • Defines a pub struct {PascalCase}Module implementing CadModule // (unit struct — no ::new() needed) // @@ -38,7 +39,8 @@ fn main() { } let name = entry.file_name().into_string().ok()?; let mod_file = mods_dir.join(&name).join("mod.rs"); - if mod_file.exists() { + let plugin_toml = mods_dir.join(&name).join("plugin.toml"); + if mod_file.exists() && !plugin_toml.exists() { Some(name) } else { None diff --git a/crates/stormsewer/Cargo.toml b/crates/stormsewer/Cargo.toml index 67dcab4a..8b5b6333 100644 --- a/crates/stormsewer/Cargo.toml +++ b/crates/stormsewer/Cargo.toml @@ -10,6 +10,7 @@ categories = ["science", "simulation"] # Engine is std-only on purpose: dependency-light and WASM-friendly. [dependencies] +quick-xml = { version = "0.37", default-features = false } [lib] name = "stormsewer" diff --git a/crates/stormsewer/examples/sample_landxml.xml b/crates/stormsewer/examples/sample_landxml.xml new file mode 100644 index 00000000..9dc51dac --- /dev/null +++ b/crates/stormsewer/examples/sample_landxml.xml @@ -0,0 +1,39 @@ + + + + + + + + + +
0.0 0.0 110.0
+ 104.0 + 110.0 +
+ +
150.0 0.0 108.0
+ 102.0 + 108.0 +
+ +
300.0 0.0 106.0
+ 100.0 + 106.0 +
+
+ + + + IN1 + J1 + + + + J1 + OUT1 + + +
+
+
\ No newline at end of file diff --git a/crates/stormsewer/src/catchment.rs b/crates/stormsewer/src/catchment.rs new file mode 100644 index 00000000..95f9fddd --- /dev/null +++ b/crates/stormsewer/src/catchment.rs @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Catchment polygon geometry helpers (CAD-agnostic). + +use crate::hydrology::kirpich_minutes; + +/// Shoelace formula area for a closed polygon (square feet). +pub fn shoelace_area_sqft(vertices: &[(f64, f64)]) -> f64 { + let n = vertices.len(); + if n < 3 { + return 0.0; + } + let mut sum = 0.0; + for i in 0..n { + let (x0, y0) = vertices[i]; + let (x1, y1) = vertices[(i + 1) % n]; + sum += x0 * y1 - x1 * y0; + } + (sum / 2.0).abs() +} + +/// Plan centroid of a closed polygon. +pub fn polygon_centroid(vertices: &[(f64, f64)]) -> (f64, f64) { + let n = vertices.len(); + if n < 3 { + let sx: f64 = vertices.iter().map(|v| v.0).sum(); + let sy: f64 = vertices.iter().map(|v| v.1).sum(); + let d = n.max(1) as f64; + return (sx / d, sy / d); + } + let mut a2 = 0.0; + let mut cx = 0.0; + let mut cy = 0.0; + for i in 0..n { + let (x0, y0) = vertices[i]; + let (x1, y1) = vertices[(i + 1) % n]; + let cross = x0 * y1 - x1 * y0; + a2 += cross; + cx += (x0 + x1) * cross; + cy += (y0 + y1) * cross; + } + if a2.abs() < 1e-12 { + let sx: f64 = vertices.iter().map(|v| v.0).sum(); + let sy: f64 = vertices.iter().map(|v| v.1).sum(); + return (sx / n as f64, sy / n as f64); + } + (cx / (3.0 * a2), cy / (3.0 * a2)) +} + +/// Convert square feet to acres. +pub fn sqft_to_acres(area_sqft: f64) -> f64 { + area_sqft / 43_560.0 +} + +/// Default flow-path length: plan distance from catchment centroid to a target point (ft). +pub fn default_flow_length_ft(centroid: (f64, f64), target: (f64, f64)) -> f64 { + let dx = target.0 - centroid.0; + let dy = target.1 - centroid.1; + (dx * dx + dy * dy).sqrt() +} + +/// Kirpich Tc (minutes) for a catchment polygon draining toward a structure. +pub fn catchment_tc_minutes(flow_length_ft: f64, slope: f64) -> f64 { + kirpich_minutes(flow_length_ft, slope) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unit_square_area() { + let verts = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]; + assert!((shoelace_area_sqft(&verts) - 100.0).abs() < 1e-6); + assert!((sqft_to_acres(43_560.0) - 1.0).abs() < 1e-9); + } + + #[test] + fn centroid_of_square() { + let verts = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]; + let (cx, cy) = polygon_centroid(&verts); + assert!((cx - 5.0).abs() < 1e-6 && (cy - 5.0).abs() < 1e-6); + } +} \ No newline at end of file diff --git a/crates/stormsewer/src/design/criteria.rs b/crates/stormsewer/src/design/criteria.rs new file mode 100644 index 00000000..c30f19e1 --- /dev/null +++ b/crates/stormsewer/src/design/criteria.rs @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Design criteria for storm-sewer pipe sizing (velocity, capacity, catalogs). + +/// Standard reinforced-concrete pipe diameters (inches) used in US storm design. +pub const STANDARD_RCP_INCHES: &[u32] = + &[8, 10, 12, 15, 18, 21, 24, 27, 30, 33, 36, 42, 48, 54, 60, 66, 72]; + +/// Convert a catalog diameter from inches to feet. +pub fn inches_to_ft(d_in: u32) -> f64 { + d_in as f64 / 12.0 +} + +/// Default ascending catalog in feet. +pub fn standard_diameters_ft() -> Vec { + STANDARD_RCP_INCHES.iter().map(|&d| inches_to_ft(d)).collect() +} + +/// Agency-style limits used by [`super::sizing::size_pipe_for_flow`]. +#[derive(Clone, Debug, PartialEq)] +pub struct DesignCriteria { + /// Minimum design velocity (ft/s). Pipes below this are rejected. + pub min_velocity: f64, + /// Maximum design velocity (ft/s). Pipes above this are rejected. + pub max_velocity: f64, + /// Maximum design flow as a fraction of just-full Manning capacity. + pub max_pct_full: f64, + /// Ascending catalog of trial diameters (ft). + pub standard_diameters_ft: Vec, + /// When true, reject diameters where design Q exceeds open-channel capacity. + pub require_open_channel: bool, +} + +impl Default for DesignCriteria { + fn default() -> Self { + Self { + min_velocity: 2.0, + max_velocity: 10.0, + max_pct_full: 0.85, + standard_diameters_ft: standard_diameters_ft(), + require_open_channel: true, + } + } +} + +impl DesignCriteria { + /// Typical municipal / DOT storm trunk defaults. + pub fn municipal() -> Self { + Self::default() + } + + /// Slightly relaxed criteria for laterals (allows higher % full). + pub fn lateral() -> Self { + Self { max_pct_full: 0.95, ..Self::default() } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn catalog_is_ascending_in_feet() { + let d = standard_diameters_ft(); + assert!(d.windows(2).all(|w| w[0] < w[1])); + assert!((d[0] - inches_to_ft(8)).abs() < 1e-9); + } +} \ No newline at end of file diff --git a/crates/stormsewer/src/design/inlets.rs b/crates/stormsewer/src/design/inlets.rs new file mode 100644 index 00000000..1719726f --- /dev/null +++ b/crates/stormsewer/src/design/inlets.rs @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Inlet interception capacity (HEC-22 style grate on grade, simplified). + +/// Grate-on-grade interception capacity (cfs). +/// +/// Uses the HEC-22 composite gutter approximation for a depressed grate: +/// `Q = Cw * L * d^1.5 * sqrt(S)` with `Cw ≈ 3.0` (US customary calibration). +pub fn grate_capacity_cfs(grate_length_ft: f64, flow_depth_ft: f64, gutter_slope: f64) -> f64 { + if grate_length_ft <= 0.0 || flow_depth_ft <= 0.0 || gutter_slope <= 0.0 { + return 0.0; + } + const CW: f64 = 3.0; + CW * grate_length_ft * flow_depth_ft.powf(1.5) * gutter_slope.sqrt() +} + +/// Check whether an inlet can capture the approach design flow. +#[derive(Clone, Debug, PartialEq)] +pub struct InletCheck { + pub design_q_cfs: f64, + pub capacity_cfs: f64, + pub ok: bool, +} + +pub fn check_inlet(design_q_cfs: f64, grate_length_ft: f64, flow_depth_ft: f64, gutter_slope: f64) -> InletCheck { + let cap = grate_capacity_cfs(grate_length_ft, flow_depth_ft, gutter_slope); + InletCheck { + design_q_cfs, + capacity_cfs: cap, + ok: cap >= design_q_cfs, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn longer_grate_carries_more() { + let a = grate_capacity_cfs(2.0, 0.15, 0.005); + let b = grate_capacity_cfs(5.0, 0.15, 0.005); + assert!(b > a); + } +} \ No newline at end of file diff --git a/crates/stormsewer/src/design/mod.rs b/crates/stormsewer/src/design/mod.rs new file mode 100644 index 00000000..933b2935 --- /dev/null +++ b/crates/stormsewer/src/design/mod.rs @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Storm-sewer design: criteria catalogs, pipe sizing, and sizing reports. + +pub mod criteria; +pub mod inlets; +pub mod sizing; + +pub use criteria::*; +pub use inlets::*; +pub use sizing::*; \ No newline at end of file diff --git a/crates/stormsewer/src/design/sizing.rs b/crates/stormsewer/src/design/sizing.rs new file mode 100644 index 00000000..07e59da8 --- /dev/null +++ b/crates/stormsewer/src/design/sizing.rs @@ -0,0 +1,287 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Pipe sizing against design criteria — smallest standard pipe that carries +//! the Rational design flow within velocity and capacity limits. + +use crate::hydraulics::{ + circular_geometry, full_area, full_flow_capacity, max_capacity, normal_depth, K_MANNING_US, +}; +use crate::network::{Analysis, Network, Pipe}; + +use super::criteria::DesignCriteria; + +/// Outcome of sizing a single pipe for a known discharge and slope. +#[derive(Clone, Debug, PartialEq)] +pub enum SizeOutcome { + /// Smallest catalog pipe that meets all criteria. + Sized, + /// Current diameter already meets criteria (may equal recommended). + Adequate, + /// No catalog pipe satisfies the criteria at this slope. + NoSolution, +} + +/// Result of sizing one pipe cross-section. +#[derive(Clone, Debug, PartialEq)] +pub struct PipeSizeResult { + pub diameter_ft: f64, + pub velocity: f64, + pub pct_full: f64, + pub normal_depth: Option, + pub surcharged: bool, + pub outcome: SizeOutcome, +} + +/// Per-pipe recommendation for a sized network. +#[derive(Clone, Debug, PartialEq)] +pub struct PipeSizeRecommendation { + pub pipe_id: String, + pub design_q: f64, + pub slope: f64, + pub current_diameter_ft: f64, + pub recommended_diameter_ft: f64, + pub meets_criteria: bool, + pub velocity: f64, + pub pct_full: f64, + pub surcharged: bool, + pub outcome: SizeOutcome, + pub note: String, +} + +/// Evaluate whether diameter `d` carries `q` on slope `s` within criteria. +fn evaluate_diameter(q: f64, slope: f64, n: f64, d: f64, criteria: &DesignCriteria) -> Option { + if d <= 0.0 || q < 0.0 { + return None; + } + let k = K_MANNING_US; + let (q_max, _) = max_capacity(n, slope, d, k); + if criteria.require_open_channel && q > q_max + 1e-9 { + return None; + } + let yn = normal_depth(q, n, slope, d, k); + let surcharged = yn.is_none(); + if criteria.require_open_channel && surcharged { + return None; + } + let area = if surcharged { + full_area(d) + } else { + circular_geometry(yn.unwrap_or(0.0), d).0 + }; + let velocity = if area > 0.0 { q / area } else { 0.0 }; + let capacity = full_flow_capacity(n, slope, d, k); + let pct_full = if capacity > 0.0 { q / capacity } else { 0.0 }; + + if velocity < criteria.min_velocity - 1e-9 { + return None; + } + if velocity > criteria.max_velocity + 1e-9 { + return None; + } + if pct_full > criteria.max_pct_full + 1e-9 { + return None; + } + + Some(PipeSizeResult { + diameter_ft: d, + velocity, + pct_full, + normal_depth: yn, + surcharged, + outcome: SizeOutcome::Sized, + }) +} + +/// Pick the smallest catalog diameter that carries `q` on slope `s`. +pub fn size_pipe_for_flow(q: f64, slope: f64, n: f64, criteria: &DesignCriteria) -> PipeSizeResult { + for &d in &criteria.standard_diameters_ft { + if let Some(r) = evaluate_diameter(q, slope, n, d, criteria) { + return r; + } + } + // No solution — report the largest catalog pipe's hydraulics for diagnostics. + let d = *criteria.standard_diameters_ft.last().unwrap_or(&0.0); + let k = K_MANNING_US; + let yn = normal_depth(q, n, slope, d, k); + let surcharged = yn.is_none(); + let area = if surcharged { + full_area(d) + } else { + circular_geometry(yn.unwrap_or(0.0), d).0 + }; + let velocity = if area > 0.0 { q / area } else { 0.0 }; + let capacity = full_flow_capacity(n, slope, d, k); + PipeSizeResult { + diameter_ft: d, + velocity, + pct_full: if capacity > 0.0 { q / capacity } else { 0.0 }, + normal_depth: yn, + surcharged, + outcome: SizeOutcome::NoSolution, + } +} + +/// Check whether an existing diameter meets criteria (no upsizing). +pub fn check_pipe(q: f64, slope: f64, n: f64, diameter_ft: f64, criteria: &DesignCriteria) -> PipeSizeResult { + if let Some(mut r) = evaluate_diameter(q, slope, n, diameter_ft, criteria) { + r.outcome = SizeOutcome::Adequate; + r + } else { + size_pipe_for_flow(q, slope, n, criteria) + } +} + +fn format_diameter_in(d_ft: f64) -> String { + let inches = (d_ft * 12.0).round() as i32; + format!("{inches}\"") +} + +fn recommend_for_pipe(p: &Pipe, design_q: f64, slope: f64, criteria: &DesignCriteria) -> PipeSizeRecommendation { + let current = p.diameter; + let check = check_pipe(design_q, slope, p.n, current, criteria); + let sized = size_pipe_for_flow(design_q, slope, p.n, criteria); + + let meets = check.outcome == SizeOutcome::Adequate + && (check.diameter_ft - current).abs() < 1e-6 + && !check.surcharged; + + let (recommended, outcome, note) = if meets { + ( + current, + SizeOutcome::Adequate, + format!("{} meets criteria ({:.1}% full, {:.2} ft/s)", p.id, check.pct_full * 100.0, check.velocity), + ) + } else if sized.outcome == SizeOutcome::NoSolution { + ( + sized.diameter_ft, + SizeOutcome::NoSolution, + format!( + "{}: no catalog pipe meets criteria (Q={:.2} cfs, S={:.4}); largest tried {} still surcharged={}", + p.id, + design_q, + slope, + format_diameter_in(sized.diameter_ft), + sized.surcharged + ), + ) + } else { + ( + sized.diameter_ft, + SizeOutcome::Sized, + format!( + "{}: upsize {} → {} ({:.1}% full, {:.2} ft/s)", + p.id, + format_diameter_in(current), + format_diameter_in(sized.diameter_ft), + sized.pct_full * 100.0, + sized.velocity + ), + ) + }; + + PipeSizeRecommendation { + pipe_id: p.id.clone(), + design_q, + slope, + current_diameter_ft: current, + recommended_diameter_ft: recommended, + meets_criteria: meets, + velocity: if meets { check.velocity } else { sized.velocity }, + pct_full: if meets { check.pct_full } else { sized.pct_full }, + surcharged: if meets { check.surcharged } else { sized.surcharged }, + outcome, + note, + } +} + +/// Size every pipe in `net` using flows from a completed [`Analysis`]. +pub fn size_network(net: &Network, analysis: &Analysis, criteria: &DesignCriteria) -> Vec { + net.pipes + .iter() + .map(|p| { + let pr = analysis.pipes.iter().find(|r| r.id == p.id); + let (q, slope) = pr.map(|r| (r.design_q, r.slope)).unwrap_or((0.0, 0.0)); + recommend_for_pipe(p, q, slope, criteria) + }) + .collect() +} + +/// Apply recommended diameters to a network (returns a cloned, sized network). +pub fn apply_sizing(net: &Network, recs: &[PipeSizeRecommendation]) -> Network { + let mut sized = net.clone(); + for (p, r) in sized.pipes.iter_mut().zip(recs.iter()) { + if r.outcome != SizeOutcome::NoSolution { + p.diameter = r.recommended_diameter_ft; + } + } + sized +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::network::{Network, Node}; + + fn heavy_net() -> Network { + Network { + nodes: vec![ + Node::inlet("N1", 100.0, 105.0, 2.0, 0.7), + Node::inlet("N2", 99.0, 104.0, 3.0, 0.8), + Node::outfall("OUT", 98.0, 103.0), + ], + pipes: vec![ + Pipe::new("P1", "N1", "N2", 100.0, 1.5, 0.013), + Pipe::new("P2", "N2", "OUT", 100.0, 1.5, 0.013), + ], + } + } + + #[test] + fn adequately_sized_pipe_is_adequate() { + let net = heavy_net(); + let pipes = net.analyze_rational(2.0).unwrap(); + let p2_q = pipes.iter().find(|x| x.id == "P2").unwrap().design_q; + let criteria = DesignCriteria::default(); + let recs = size_network(&net, &Analysis { pipes, nodes: vec![] }, &criteria); + let p2r = recs.iter().find(|r| r.pipe_id == "P2").unwrap(); + assert!(p2r.meets_criteria || p2r.recommended_diameter_ft >= 1.5); + assert!(p2_q > 0.0); + } + + #[test] + fn undersized_pipe_gets_larger_recommendation() { + let net = heavy_net(); + let pipes = net.analyze_rational(4.0).unwrap(); + assert!(pipes.iter().find(|x| x.id == "P2").unwrap().surcharged); + let criteria = DesignCriteria::default(); + let recs = size_network(&net, &Analysis { pipes, nodes: vec![] }, &criteria); + let p2r = recs.iter().find(|r| r.pipe_id == "P2").unwrap(); + assert!(p2r.recommended_diameter_ft > 1.5, "got {}", p2r.recommended_diameter_ft); + assert_eq!(p2r.outcome, SizeOutcome::Sized); + } + + #[test] + fn apply_sizing_updates_diameters() { + let net = heavy_net(); + let pipes = net.analyze_rational(4.0).unwrap(); + let criteria = DesignCriteria::default(); + let recs = size_network(&net, &Analysis { pipes, nodes: vec![] }, &criteria); + let sized = apply_sizing(&net, &recs); + let p2 = sized.pipes.iter().find(|p| p.id == "P2").unwrap(); + assert!(p2.diameter > 1.5); + } + + #[test] + fn sample_network_has_catalog_solutions() { + let text = include_str!("../../examples/sample.ssn"); + let parsed = crate::parse::parse_ssn(text).unwrap(); + let a = parsed.network.analyze(&parsed.idf, &parsed.options).unwrap(); + assert!(a.pipes.iter().all(|p| !p.surcharged), "sample should not surcharge"); + let recs = size_network(&parsed.network, &a, &DesignCriteria::default()); + assert!( + recs.iter().all(|r| r.outcome != SizeOutcome::NoSolution), + "unexpected: {:?}", + recs.iter().map(|r| (&r.pipe_id, &r.note)).collect::>() + ); + } +} \ No newline at end of file diff --git a/crates/stormsewer/src/hydrology/idf_set.rs b/crates/stormsewer/src/hydrology/idf_set.rs new file mode 100644 index 00000000..20a643da --- /dev/null +++ b/crates/stormsewer/src/hydrology/idf_set.rs @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Multi-return-period IDF curves for storm-sewer design. + +use std::collections::BTreeMap; + +use crate::idf::IdfCurve; + +/// Rainfall IDF curves keyed by return period (years). +#[derive(Clone, Debug, PartialEq)] +pub struct IdfSet { + /// Active design storm return period (years). + pub design_rp: u32, + curves: BTreeMap, +} + +impl Default for IdfSet { + fn default() -> Self { + let mut curves = BTreeMap::new(); + curves.insert(10, IdfCurve::new(60.0, 10.0, 0.8)); + Self { design_rp: 10, curves } + } +} + +impl IdfSet { + pub fn new(design_rp: u32) -> Self { + Self { design_rp, curves: BTreeMap::new() } + } + + /// Municipal default: 10-year curve `i = 60/(t+10)^0.8`. + pub fn municipal_default() -> Self { + Self::default() + } + + pub fn set_curve(&mut self, rp: u32, curve: IdfCurve) { + self.curves.insert(rp, curve); + } + + pub fn curve(&self, rp: u32) -> Option<&IdfCurve> { + self.curves.get(&rp) + } + + pub fn design_curve(&self) -> &IdfCurve { + self.curves + .get(&self.design_rp) + .or_else(|| self.curves.values().next()) + .expect("IdfSet must contain at least one curve") + } + + pub fn set_design_rp(&mut self, rp: u32) { + self.design_rp = rp; + } + + /// Intensity (in/hr) for duration `t_min` at the design return period. + pub fn design_intensity(&self, t_min: f64) -> f64 { + self.design_curve().intensity(t_min) + } + + /// All configured return periods, ascending. + pub fn return_periods(&self) -> Vec { + self.curves.keys().copied().collect() + } + + /// Analyze intensity at every configured return period for one duration. + pub fn intensities_at(&self, t_min: f64) -> Vec<(u32, f64)> { + self.return_periods() + .into_iter() + .map(|rp| (rp, self.curves[&rp].intensity(t_min))) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn design_curve_defaults_to_10yr() { + let set = IdfSet::default(); + assert_eq!(set.design_rp, 10); + assert!(set.design_intensity(15.0) > 0.0); + } + + #[test] + fn multiple_return_periods() { + let mut set = IdfSet::default(); + set.set_curve(25, IdfCurve::new(80.0, 15.0, 0.8)); + set.set_design_rp(25); + let i10 = set.curve(10).unwrap().intensity(20.0); + let i25 = set.design_intensity(20.0); + assert!(i25 > i10); + } +} \ No newline at end of file diff --git a/crates/stormsewer/src/hydrology/mod.rs b/crates/stormsewer/src/hydrology/mod.rs new file mode 100644 index 00000000..86682f57 --- /dev/null +++ b/crates/stormsewer/src/hydrology/mod.rs @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +pub mod idf_set; +pub mod tc; + +pub use idf_set::*; +pub use tc::*; \ No newline at end of file diff --git a/crates/stormsewer/src/hydrology/tc.rs b/crates/stormsewer/src/hydrology/tc.rs new file mode 100644 index 00000000..38b91c5e --- /dev/null +++ b/crates/stormsewer/src/hydrology/tc.rs @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Time-of-concentration estimators (minutes). + +/// Kirpich (1940) — overland flow over unpaved channel. +/// `l` = flow path length (ft), `s` = average slope (ft/ft, positive). +pub fn kirpich_minutes(l: f64, s: f64) -> f64 { + if l <= 0.0 || s <= 0.0 { + return 0.0; + } + 0.0078 * l.powf(0.77) * s.powf(-0.385) +} + +/// FAA / TR-55 style sheet flow on paved surfaces. +/// `l` = flow path (ft), `s` = slope (ft/ft). +pub fn faa_sheet_flow_minutes(l: f64, s: f64) -> f64 { + if l <= 0.0 || s <= 0.0 { + return 0.0; + } + // n=0.02, k=0.007 (US customary) → t = 0.007 * (n*L)^0.8 / (S^0.5 * k^0.2) with n fixed + let n = 0.02_f64; + let k = 0.007_f64; + 0.007_f64 * (n * l).powf(0.8) / (s.sqrt() * k.powf(0.2)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kirpich_increases_with_length() { + let short = kirpich_minutes(200.0, 0.02); + let long = kirpich_minutes(800.0, 0.02); + assert!(long > short); + } + + #[test] + fn faa_reasonable_range() { + let t = faa_sheet_flow_minutes(300.0, 0.01); + assert!(t > 0.5 && t < 30.0, "t={t}"); + } +} \ No newline at end of file diff --git a/crates/stormsewer/src/io/landxml.rs b/crates/stormsewer/src/io/landxml.rs new file mode 100644 index 00000000..7fe8c506 --- /dev/null +++ b/crates/stormsewer/src/io/landxml.rs @@ -0,0 +1,475 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! LandXML 1.2 pipe-network import (Civil 3D / InfraModel compatible subset). + +use crate::network::NodeKind; +use quick_xml::events::Event; +use quick_xml::Reader; +/// Linear units used in the source document. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum LinearUnit { + #[default] + Foot, + Meter, +} + +/// Diameter units for circular pipes. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum DiameterUnit { + #[default] + Inch, + Foot, + Millimeter, + Meter, +} + +/// Parsed LandXML document (one or more pipe networks). +#[derive(Clone, Debug, Default)] +pub struct LandXmlDocument { + pub linear_unit: LinearUnit, + pub diameter_unit: DiameterUnit, + pub networks: Vec, +} + +/// A single pipe network from LandXML. +#[derive(Clone, Debug, Default)] +pub struct LandXmlNetwork { + pub name: String, + pub structures: Vec, + pub pipes: Vec, +} + +/// Structure (manhole / inlet / outfall) from LandXML. +#[derive(Clone, Debug)] +pub struct LandXmlStruct { + pub name: String, + pub kind: NodeKind, + /// Easting / X (ft). + pub x: f64, + /// Northing / Y (ft). + pub y: f64, + pub invert: f64, + pub rim: f64, + pub area_ac: f64, + pub c: f64, +} + +/// Pipe link from LandXML. +#[derive(Clone, Debug)] +pub struct LandXmlPipe { + pub name: String, + pub from: String, + pub to: String, + /// Internal diameter (ft). + pub diameter_ft: f64, + pub n: f64, +} + +impl LandXmlDocument { + /// First network, or an error when the file contains none. + pub fn primary_network(&self) -> Result<&LandXmlNetwork, String> { + self.networks.first().ok_or_else(|| "LandXML: no found".into()) + } +} + +/// Parse a LandXML document string into structures and pipes. +pub fn parse_landxml(xml: &str) -> Result { + let mut reader = Reader::from_str(xml); + reader.config_mut().trim_text(true); + + let mut doc = LandXmlDocument::default(); + let mut buf = Vec::new(); + + let mut in_imperial = false; + let mut in_metric = false; + + let mut cur_network: Option = None; + let mut in_structs = false; + let mut in_pipes = false; + + let mut cur_struct: Option = None; + let mut cur_pipe: Option = None; + let mut text_buf = String::new(); + + loop { + match reader.read_event_into(&mut buf) { + Ok(Event::Empty(e)) => { + let raw = String::from_utf8_lossy(e.name().as_ref()).into_owned(); + let name = local_name(&raw); + if name == "CircPipe" { + if let Some(d) = attr_value(&e, "diameter").and_then(|s| s.parse::().ok()) { + if let Some(p) = cur_pipe.as_mut() { + p.diameter_ft = to_diameter_ft(d, doc.diameter_unit); + } + } + } + } + Ok(Event::Start(e)) => { + let raw = String::from_utf8_lossy(e.name().as_ref()).into_owned(); + let name = local_name(&raw); + text_buf.clear(); + + match name.as_str() { + "Imperial" => { + in_imperial = true; + if let Some(u) = attr_value(&e, "linearUnit") { + doc.linear_unit = parse_linear_unit(&u); + } + if let Some(u) = attr_value(&e, "diameterUnit") { + doc.diameter_unit = parse_diameter_unit(&u); + } + } + "Metric" => { + in_metric = true; + doc.linear_unit = LinearUnit::Meter; + } + "PipeNetwork" => { + let net_name = attr_value(&e, "name").unwrap_or_else(|| "Network".into()); + cur_network = Some(LandXmlNetwork { name: net_name, ..Default::default() }); + } + "Structs" if cur_network.is_some() => in_structs = true, + "Pipes" if cur_network.is_some() => in_pipes = true, + "Struct" if in_structs => { + let sname = attr_value(&e, "name") + .or_else(|| attr_value(&e, "id")) + .unwrap_or_else(|| format!("S{}", cur_network.as_ref().map(|n| n.structures.len()).unwrap_or(0) + 1)); + let role = attr_value(&e, "role").unwrap_or_default(); + let kind = infer_kind(&sname, &role); + cur_struct = Some(LandXmlStruct { + name: sname, + kind, + x: 0.0, + y: 0.0, + invert: 0.0, + rim: 0.0, + area_ac: 0.0, + c: 0.7, + }); + } + "Pipe" if in_pipes => { + let pname = attr_value(&e, "name") + .or_else(|| attr_value(&e, "id")) + .unwrap_or_else(|| format!("P{}", cur_network.as_ref().map(|n| n.pipes.len()).unwrap_or(0) + 1)); + cur_pipe = Some(LandXmlPipe { + name: pname, + from: String::new(), + to: String::new(), + diameter_ft: 1.0, + n: 0.013, + }); + } + "CircPipe" if cur_pipe.is_some() => { + if let Some(d) = attr_value(&e, "diameter").and_then(|s| s.parse::().ok()) { + if let Some(p) = cur_pipe.as_mut() { + p.diameter_ft = to_diameter_ft(d, doc.diameter_unit); + } + } + } + "Center" if cur_struct.is_some() => { + if let (Some(n), Some(ea)) = (attr_value(&e, "north").and_then(|s| s.parse().ok()), attr_value(&e, "east").and_then(|s| s.parse().ok())) { + if let Some(s) = cur_struct.as_mut() { + s.y = to_linear_ft(n, doc.linear_unit); + s.x = to_linear_ft(ea, doc.linear_unit); + if let Some(el) = attr_value(&e, "elev").and_then(|v| v.parse().ok()) { + s.rim = to_linear_ft(el, doc.linear_unit); + } + } + } + } + _ => {} + } + } + Ok(Event::Text(t)) => { + text_buf.push_str(&t.unescape().map_err(|e| e.to_string())?); + } + Ok(Event::End(e)) => { + let raw = String::from_utf8_lossy(e.name().as_ref()).into_owned(); + let name = local_name(&raw); + let text = text_buf.trim().to_string(); + + if !text.is_empty() { + match name.as_str() { + "linearUnit" if in_imperial => doc.linear_unit = parse_linear_unit(&text), + "diameterUnit" if in_imperial => doc.diameter_unit = parse_diameter_unit(&text), + "linearUnit" if in_metric => doc.linear_unit = LinearUnit::Meter, + "diameterUnit" if in_metric => doc.diameter_unit = parse_diameter_unit(&text), + "Center" if let Some(s) = cur_struct.as_mut() => { + if let Some((a, b, c)) = parse_coords(&text) { + // LandXML may be N E Z or E N Z; prefer E N when third is elevation. + s.x = to_linear_ft(a, doc.linear_unit); + s.y = to_linear_ft(b, doc.linear_unit); + if c.abs() > 1e-6 { + s.rim = to_linear_ft(c, doc.linear_unit); + } + } + } + "Invert" | "InvertElev" if let Some(s) = cur_struct.as_mut() => { + if let Ok(v) = text.parse::() { + s.invert = to_linear_ft(v, doc.linear_unit); + } + } + "ElevRim" | "Rim" | "RimElev" if let Some(s) = cur_struct.as_mut() => { + if let Ok(v) = text.parse::() { + s.rim = to_linear_ft(v, doc.linear_unit); + } + } + "StartStruct" | "RefStart" | "BegStruct" if let Some(p) = cur_pipe.as_mut() => { + p.from = text; + } + "EndStruct" | "RefEnd" | "EndStructRef" if let Some(p) = cur_pipe.as_mut() => { + p.to = text; + } + "CircPipe" if let Some(p) = cur_pipe.as_mut() => { + if let Ok(d) = text.parse::() { + p.diameter_ft = to_diameter_ft(d, doc.diameter_unit); + } + } + _ => {} + } + } + + match name.as_str() { + "Imperial" => in_imperial = false, + "Metric" => in_metric = false, + "Struct" => { + if let Some(s) = cur_struct.take() { + if let Some(net) = cur_network.as_mut() { + if s.rim <= s.invert { + let mut s = s; + s.rim = s.invert + 5.0; + net.structures.push(s); + } else { + net.structures.push(s); + } + } + } + } + "Pipe" => { + if let Some(p) = cur_pipe.take() { + if !p.from.is_empty() && !p.to.is_empty() { + if let Some(net) = cur_network.as_mut() { + net.pipes.push(p); + } + } + } + } + "Structs" => in_structs = false, + "Pipes" => in_pipes = false, + "PipeNetwork" => { + if let Some(net) = cur_network.take() { + if !net.structures.is_empty() { + doc.networks.push(net); + } + } + } + _ => {} + } + text_buf.clear(); + } + Ok(Event::Eof) => break, + Err(e) => return Err(format!("LandXML parse error at {}: {e}", reader.error_position())), + _ => {} + } + buf.clear(); + } + + if doc.networks.is_empty() { + return Err("LandXML: no pipe network with structures found".into()); + } + + // Fill missing inverts from rim when needed. + for net in &mut doc.networks { + for s in &mut net.structures { + if s.invert == 0.0 && s.rim != 0.0 { + s.invert = s.rim - 5.0; + } + if s.rim == 0.0 && s.invert != 0.0 { + s.rim = s.invert + 5.0; + } + } + drop_dangling_pipes(net); + } + + Ok(doc) +} + +fn drop_dangling_pipes(net: &mut LandXmlNetwork) { + let names: std::collections::HashSet<_> = net.structures.iter().map(|s| s.name.as_str()).collect(); + net.pipes.retain(|p| names.contains(p.from.as_str()) && names.contains(p.to.as_str())); +} + +fn local_name(tag: &str) -> String { + tag.rsplit(':').next().unwrap_or(tag).to_string() +} + +fn attr_value(e: &quick_xml::events::BytesStart<'_>, key: &str) -> Option { + e.attributes() + .filter_map(|a| a.ok()) + .find(|a| a.key.as_ref() == key.as_bytes()) + .and_then(|a| String::from_utf8(a.value.into_owned()).ok()) +} + +fn parse_coords(text: &str) -> Option<(f64, f64, f64)> { + let nums: Vec = text.split_whitespace().filter_map(|s| s.parse().ok()).collect(); + match nums.len() { + 0 => None, + 1 => Some((nums[0], 0.0, 0.0)), + 2 => Some((nums[0], nums[1], 0.0)), + _ => Some((nums[0], nums[1], nums[2])), + } +} + +fn parse_linear_unit(s: &str) -> LinearUnit { + let l = s.to_ascii_lowercase(); + if l.contains("meter") || l == "m" { + LinearUnit::Meter + } else { + LinearUnit::Foot + } +} + +fn parse_diameter_unit(s: &str) -> DiameterUnit { + let l = s.to_ascii_lowercase(); + if l.contains("milli") { + DiameterUnit::Millimeter + } else if l.contains("meter") || l == "m" { + DiameterUnit::Meter + } else if l.contains("foot") || l == "ft" { + DiameterUnit::Foot + } else { + DiameterUnit::Inch + } +} + +fn to_linear_ft(v: f64, unit: LinearUnit) -> f64 { + match unit { + LinearUnit::Foot => v, + LinearUnit::Meter => v * 3.280_839_895, + } +} + +fn to_diameter_ft(v: f64, unit: DiameterUnit) -> f64 { + match unit { + DiameterUnit::Inch => v / 12.0, + DiameterUnit::Foot => v, + DiameterUnit::Millimeter => v / 304.8, + DiameterUnit::Meter => v * 3.280_839_895, + } +} + +fn infer_kind(name: &str, role: &str) -> NodeKind { + let n = name.to_ascii_lowercase(); + let r = role.to_ascii_lowercase(); + if r.contains("outfall") || n.contains("outfall") || n.starts_with("of") { + NodeKind::Outfall + } else if r.contains("inlet") || n.contains("inlet") || n.starts_with("in") { + NodeKind::Inlet + } else if r.contains("junction") || n.contains("mh") || n.contains("manhole") || n.contains("cb") { + NodeKind::Junction + } else { + NodeKind::Junction + } +} + +/// Convert a parsed LandXML network into a stormsewer [`Network`](crate::network::Network). +pub fn network_from_landxml(net: &LandXmlNetwork) -> Result { + use crate::network::{Network, Node, Pipe}; + use std::collections::HashMap; + + if net.structures.is_empty() { + return Err("LandXML network has no structures".into()); + } + + let mut id_of: HashMap = HashMap::new(); + let mut nodes = Vec::with_capacity(net.structures.len()); + for (i, s) in net.structures.iter().enumerate() { + let id = format!("N{}", i + 1); + id_of.insert(s.name.clone(), id.clone()); + let node = match s.kind { + NodeKind::Inlet => Node::inlet(&id, s.invert, s.rim, s.area_ac, s.c), + NodeKind::Junction => Node::junction(&id, s.invert, s.rim, s.area_ac, s.c), + NodeKind::Outfall => Node::outfall(&id, s.invert, s.rim), + } + .at(s.x, s.y); + nodes.push(node); + } + + let coord: HashMap<_, _> = net.structures.iter().map(|s| (s.name.as_str(), (s.x, s.y))).collect(); + let mut pipes = Vec::new(); + for (k, p) in net.pipes.iter().enumerate() { + let Some(from_id) = id_of.get(&p.from) else { continue }; + let Some(to_id) = id_of.get(&p.to) else { continue }; + let length = match (coord.get(p.from.as_str()), coord.get(p.to.as_str())) { + (Some((x0, y0)), Some((x1, y1))) => ((x1 - x0).powi(2) + (y1 - y0).powi(2)).sqrt(), + _ => 100.0, + }; + pipes.push(Pipe::new( + &format!("P{}", k + 1), + from_id, + to_id, + length, + p.diameter_ft, + p.n, + )); + } + + if pipes.is_empty() { + return Err("LandXML network has no connected pipes".into()); + } + + Ok(Network { nodes, pipes }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SAMPLE: &str = r#" + + + + + + + + +
0.0 0.0 110.0
+ 104.0 + 110.0 +
+ +
300.0 0.0 106.0
+ 100.0 + 106.0 +
+
+ + + + IN1 + OUT1 + + +
+
+
"#; + + #[test] + fn parses_sample_network() { + let doc = parse_landxml(SAMPLE).expect("parse"); + let net = doc.primary_network().unwrap(); + assert_eq!(net.structures.len(), 2); + assert_eq!(net.pipes.len(), 1); + assert!((net.pipes[0].diameter_ft - 1.5).abs() < 1e-6); + } + + #[test] + fn builds_engine_network() { + let doc = parse_landxml(SAMPLE).unwrap(); + let net = doc.primary_network().unwrap(); + let engine = network_from_landxml(net).unwrap(); + assert_eq!(engine.nodes.len(), 2); + assert_eq!(engine.pipes.len(), 1); + assert!((engine.pipes[0].length - 300.0).abs() < 1e-3); + } +} \ No newline at end of file diff --git a/crates/stormsewer/src/io/mod.rs b/crates/stormsewer/src/io/mod.rs new file mode 100644 index 00000000..5a6f9295 --- /dev/null +++ b/crates/stormsewer/src/io/mod.rs @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +pub mod landxml; + +pub use landxml::*; \ No newline at end of file diff --git a/crates/stormsewer/src/lib.rs b/crates/stormsewer/src/lib.rs index 66491412..d8e71347 100644 --- a/crates/stormsewer/src/lib.rs +++ b/crates/stormsewer/src/lib.rs @@ -10,8 +10,9 @@ //! * **Rational method** peak-flow accumulation down a pipe network, //! * **Manning** open-channel / partial-flow hydraulics for circular conduits, //! * normal-depth, critical-depth and full-flow capacity, -//! * *(forthcoming)* **HEC-22** hydraulic-grade-line backwater with junction -//! and structure losses. +//! * **HGL backwater** with junction losses and **standard-pipe sizing** +//! against velocity / capacity criteria (Hydraflow-style design checks). +//! * *(forthcoming)* full **HEC-22** inlet capacity and multi-return-period IDF sets. //! //! This is an **engine only**: no GUI and no CAD dependencies, so it compiles //! to a native library, to WASM (for hydrocomplete.com), and is consumable as @@ -31,15 +32,25 @@ //! assert!((results[0].design_q - 5.6).abs() < 1e-6); // 4 * (0.7*2.0) //! ``` +pub mod catchment; +pub mod design; pub mod drawing; pub mod hydraulics; +pub mod hydrology; pub mod idf; +pub mod io; pub mod network; +pub mod params; pub mod parse; pub mod report; +pub use catchment::*; +pub use design::*; pub use drawing::*; pub use hydraulics::*; +pub use hydrology::*; pub use idf::*; +pub use io::*; pub use network::*; +pub use params::*; pub use parse::*; diff --git a/crates/stormsewer/src/network.rs b/crates/stormsewer/src/network.rs index 3f54f6a8..44f89515 100644 --- a/crates/stormsewer/src/network.rs +++ b/crates/stormsewer/src/network.rs @@ -8,8 +8,11 @@ //! standard for gravity storm sewers. Looped networks are rejected by the //! topological sort. +use crate::design::{size_network, DesignCriteria, PipeSizeRecommendation}; use crate::hydraulics::*; +use crate::hydrology::IdfSet; use crate::idf::IdfCurve; +use crate::params::StormAnalysisParams; use std::collections::HashMap; /// Kind of network node. @@ -151,7 +154,7 @@ pub struct Analysis { } /// Options for [`Network::analyze`]. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct AnalysisOptions { /// Minimum time of concentration (minutes) — floors the IDF duration. pub min_tc: f64, @@ -254,6 +257,40 @@ impl Network { Ok(self.analyze(&IdfCurve::new(0.0, 1.0, 1.0), &opts)?.pipes) } + /// Analyze then recommend standard pipe diameters for each link. + pub fn analyze_and_size( + &self, + idf: &IdfCurve, + opts: &AnalysisOptions, + criteria: &DesignCriteria, + ) -> Result<(Analysis, Vec), NetworkError> { + let a = self.analyze(idf, opts)?; + let recs = size_network(self, &a, criteria); + Ok((a, recs)) + } + + /// Full analyze + size using [`StormAnalysisParams`]. + pub fn analyze_and_size_params( + &self, + params: &StormAnalysisParams, + ) -> Result<(Analysis, Vec), NetworkError> { + self.analyze_and_size(params.idf.design_curve(), ¶ms.hydraulics, ¶ms.sizing) + } + + /// Run analysis at every configured return period. + pub fn analyze_all_rps( + &self, + idf_set: &IdfSet, + opts: &AnalysisOptions, + ) -> Result, NetworkError> { + let mut out = Vec::new(); + for rp in idf_set.return_periods() { + let curve = idf_set.curve(rp).expect("return_periods keys exist"); + out.push((rp, self.analyze(curve, opts)?)); + } + Ok(out) + } + /// Full analysis: Tc accumulation, per-pipe IDF intensity, Rational design /// flows, pipe hydraulics, and an HGL backwater pass with junction losses. pub fn analyze(&self, idf: &IdfCurve, opts: &AnalysisOptions) -> Result { @@ -513,6 +550,18 @@ mod tests { assert!(p2.intensity <= p1.intensity, "i2 {} i1 {}", p2.intensity, p1.intensity); } + #[test] + fn analyze_all_return_periods() { + use crate::hydrology::IdfSet; + let mut idf_set = IdfSet::default(); + idf_set.set_curve(25, IdfCurve::new(90.0, 12.0, 0.8)); + let results = sample().analyze_all_rps(&idf_set, &AnalysisOptions::default()).unwrap(); + assert_eq!(results.len(), 2); + let q10 = results.iter().find(|(rp, _)| *rp == 10).unwrap().1.pipes[1].design_q; + let q25 = results.iter().find(|(rp, _)| *rp == 25).unwrap().1.pipes[1].design_q; + assert!(q25 > q10); + } + #[test] fn hgl_rises_upstream() { // With a tailwater, HGL must be monotonically higher going upstream. diff --git a/crates/stormsewer/src/params.rs b/crates/stormsewer/src/params.rs new file mode 100644 index 00000000..52918e46 --- /dev/null +++ b/crates/stormsewer/src/params.rs @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +//! Global storm-sewer analysis parameters (hydrology, hydraulics, sizing). + +use crate::design::DesignCriteria; +use crate::hydrology::IdfSet; +use crate::network::AnalysisOptions; + +/// Network-level parameters for analyze / size / report passes. +#[derive(Clone, Debug, PartialEq)] +pub struct StormAnalysisParams { + pub idf: IdfSet, + pub hydraulics: AnalysisOptions, + pub sizing: DesignCriteria, + /// Default grate length (ft) for HEC-22 inlet capacity checks at inlets. + pub inlet_grate_length_ft: f64, + /// Assumed gutter flow depth (ft) at the curb for inlet checks. + pub inlet_flow_depth_ft: f64, + /// Assumed gutter longitudinal slope (ft/ft) for inlet checks. + pub inlet_gutter_slope: f64, +} + +impl Default for StormAnalysisParams { + fn default() -> Self { + Self { + idf: IdfSet::municipal_default(), + hydraulics: AnalysisOptions::default(), + sizing: DesignCriteria::municipal(), + inlet_grate_length_ft: 2.0, + inlet_flow_depth_ft: 0.15, + inlet_gutter_slope: 0.005, + } + } +} + +impl StormAnalysisParams { + pub fn municipal() -> Self { + Self::default() + } + + /// Summary for command-line / dialog display. + pub fn summary(&self) -> String { + let c = self.idf.design_curve(); + let tw = self + .hydraulics + .tailwater + .map(|t| format!("{t:.2} ft")) + .unwrap_or_else(|| "free".into()); + format!( + "RP {}yr IDF i=a/(t+b)^c a={:.1} b={:.1} c={:.2} tailwater={tw} minTc={:.0}min junctionK={:.2} V={:.1}-{:.1} ft/s maxFull={:.0}%", + self.idf.design_rp, + c.a, + c.b, + c.c, + self.hydraulics.min_tc, + self.hydraulics.junction_k, + self.sizing.min_velocity, + self.sizing.max_velocity, + self.sizing.max_pct_full * 100.0, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn summary_includes_design_rp() { + let p = StormAnalysisParams::default(); + assert!(p.summary().contains("RP 10yr")); + } +} \ No newline at end of file diff --git a/crates/stormsewer/src/report.rs b/crates/stormsewer/src/report.rs index b05d7524..db85620f 100644 --- a/crates/stormsewer/src/report.rs +++ b/crates/stormsewer/src/report.rs @@ -1,9 +1,11 @@ // SPDX-License-Identifier: GPL-3.0-or-later -//! Plain-text report tables for an [`Analysis`], in the spirit of Hydraflow -//! Storm Sewers' pipe and HGL summaries. +//! Plain-text report tables for an [`Analysis`] and pipe-sizing output, in the +//! spirit of Hydraflow Storm Sewers' pipe and HGL summaries. -use crate::network::Analysis; +use crate::design::PipeSizeRecommendation; +use crate::hydrology::IdfSet; +use crate::network::{Analysis, AnalysisOptions, Network}; fn f(x: f64, w: usize, p: usize) -> String { format!("{:>w$.p$}", x, w = w, p = p) @@ -68,6 +70,92 @@ pub fn node_table(a: &Analysis) -> String { s } +fn dia_in(d_ft: f64) -> String { + format!("{}\"", (d_ft * 12.0).round() as i32) +} + +/// Pipe-sizing recommendations table. +pub fn sizing_table(recs: &[PipeSizeRecommendation]) -> String { + let mut s = String::new(); + s.push_str("Pipe Q(cfs) Slope Current Rec'd %Full V(ft/s) Status\n"); + s.push_str(&"-".repeat(72)); + s.push('\n'); + for r in recs { + let status = match r.outcome { + crate::design::SizeOutcome::Adequate => "ok", + crate::design::SizeOutcome::Sized => "UPSIZE", + crate::design::SizeOutcome::NoSolution => "NO SIZE", + }; + s.push_str(&format!( + "{:<6} {} {} {} {} {} {} {}\n", + r.pipe_id, + f(r.design_q, 6, 2), + f(r.slope, 7, 4), + format!("{:>6}", dia_in(r.current_diameter_ft)), + format!("{:>6}", dia_in(r.recommended_diameter_ft)), + f(r.pct_full * 100.0, 6, 1), + f(r.velocity, 6, 2), + status, + )); + } + s +} + +/// Full sizing report with per-pipe notes. +pub fn format_sizing(recs: &[PipeSizeRecommendation]) -> String { + let mut s = String::new(); + s.push_str("=== STORM SEWER PIPE SIZING ===\n\n"); + s.push_str(&sizing_table(recs)); + s.push('\n'); + for r in recs { + s.push_str(&r.note); + s.push('\n'); + } + let upsized: Vec<&str> = recs + .iter() + .filter(|r| r.outcome == crate::design::SizeOutcome::Sized) + .map(|r| r.pipe_id.as_str()) + .collect(); + let failed: Vec<&str> = recs + .iter() + .filter(|r| r.outcome == crate::design::SizeOutcome::NoSolution) + .map(|r| r.pipe_id.as_str()) + .collect(); + if upsized.is_empty() && failed.is_empty() { + s.push_str("\nAll pipes meet design criteria.\n"); + } else { + if !upsized.is_empty() { + s.push_str(&format!("\nPipes to upsize: {}\n", upsized.join(", "))); + } + if !failed.is_empty() { + s.push_str(&format!("Pipes with no catalog solution: {}\n", failed.join(", "))); + } + } + s +} + +/// Summary of peak design flows at each configured return period. +pub fn format_multi_rp(net: &Network, idf_set: &IdfSet, opts: &AnalysisOptions) -> String { + let mut s = String::new(); + s.push_str("=== MULTI RETURN-PERIOD PEAK FLOWS ===\n\n"); + s.push_str("RP(yr) Pipe Q(cfs) Surcharged\n"); + s.push_str(&"-".repeat(40)); + s.push('\n'); + match net.analyze_all_rps(idf_set, opts) { + Ok(runs) => { + for (rp, a) in runs { + for p in &a.pipes { + let flag = if p.surcharged { "yes" } else { "no" }; + s.push_str(&format!("{rp:<7} {:<6} {} {flag}\n", p.id, f(p.design_q, 6, 2))); + } + s.push('\n'); + } + } + Err(e) => s.push_str(&format!("error: {e}\n")), + } + s +} + /// Full report: pipe table followed by node/HGL table. pub fn format_analysis(a: &Analysis) -> String { let mut s = String::new(); diff --git a/docs/PR-plugin-host.md b/docs/PR-plugin-host.md new file mode 100644 index 00000000..f6137d7e --- /dev/null +++ b/docs/PR-plugin-host.md @@ -0,0 +1,60 @@ +# PR: Add plugin host (Phase 1) + +**Target:** `HakanSeven12/OpenCADStudio` +**Branch:** `feature/plugin-host` on `mf4633/OpenCADStudio` +**Related:** Issue #78 (Storm Sewer interest check / extension architecture) + +## Summary + +Introduces a QGIS-style add-on architecture so discipline-specific tools (storm sewer, sanitary, geotech, …) can ship **outside** the core application. Core ribbon tabs (Home, Model, View, …) are unchanged. Add-on packages register ribbon + commands through a single `PluginRegistration` hook. + +**This PR contains only the generic host** — no Storm Sewer tab, no `stormsewer` engine crate, no civil/hydraulics code in `src/modules/`. + +## What's included + +| Area | Change | +|------|--------| +| `src/plugin/` | Manifest, registry, `BuiltinPlugin` trait, `try_dispatch` | +| `src/app/plugin_host.rs` | `HostSession` adapter (document, undo, tab state, command line) | +| `src/app/document.rs` | Per-tab `plugin_state` map keyed by plugin id | +| `src/app/commands.rs` | Plugin dispatch before legacy command match | +| `src/command/mod.rs` | Generic `ObjectPickHit` + acquisition hooks on `CadCommand` | +| `build.rs` | Skips dirs with `plugin.toml` (add-ons register via plugin host) | +| `src/ui/ribbon/mod.rs` | `all_ribbon_modules()` = core tabs + plugin tabs | +| `docs/plugin-architecture.md` | Accepted architecture spec | +| `docs/plugin-template/` | Scaffold for new add-on authors | + +## Design principles + +1. **`src/plugin/` must not import domain modules** — keeps core mergeable. +2. **One package, one registration** — `plugin.toml` + `register.rs` + `BuiltinPlugin`. +3. **DWG round-trip** — plugins persist domain data on entity XDATA (documented per add-on in `PLUGIN.md`). +4. **Phase 2 ready** — same `plugin.toml` layout for future dynamic `.dll` loading. + +## How to add an add-on (after merge) + +Copy `docs/plugin-template/` → `src/modules//`, implement `BuiltinPlugin`, add `pub mod ;` to `modules/mod.rs`. No edits to `commands.rs`. + +External repos: depend on extracted `ocs_plugin_api` (Phase 1b, follow-up PR). + +## Follow-up (not in this PR) + +- `ocs_plugin_api` workspace crate (semver-stable host surface) +- Dynamic plugin loading (`%APPDATA%/OpenCADStudio/plugins/`) +- Python/scripting bindings over host API (#29) +- Storm Sewer add-on — separate repo/PR: `mf4633` branch `feature/storm-sewer-module` + +## Testing + +```powershell +cargo build +cargo test --lib +``` + +No domain plugin is registered in this branch; existing core tests should pass unchanged. + +## Review questions + +1. OK to add `inventory`-based plugin registration (same pattern as `CommandRegistration`)? +2. Should built-in add-ons ever live in the main repo, or only the host + template? +3. Priority for Phase 1b (`ocs_plugin_api` crate) vs Phase 2 (dynamic loading)? \ No newline at end of file diff --git a/docs/issue-78-reply.md b/docs/issue-78-reply.md new file mode 100644 index 00000000..a684c4bc --- /dev/null +++ b/docs/issue-78-reply.md @@ -0,0 +1,44 @@ +# Draft reply for Issue #78 + +Paste as a GitHub comment on https://github.com/HakanSeven12/OpenCADStudio/issues/78 + +--- + +@HakanSeven12 @schoeller — following up with a concrete architecture proposal and an implementation ready for review. + +### Proposal + +I've drafted a **QGIS-style add-on model** on my fork: + +- **Spec:** [`docs/plugin-architecture.md`](https://github.com/mf4633/OpenCADStudio/blob/feature/plugin-host/docs/plugin-architecture.md) +- **Scaffold:** [`docs/plugin-template/`](https://github.com/mf4633/OpenCADStudio/tree/feature/plugin-host/docs/plugin-template) +- **Framework PR branch:** [`feature/plugin-host`](https://github.com/mf4633/OpenCADStudio/tree/feature/plugin-host) — **host only, no Storm Sewer in core** + +**Three layers:** host core → add-on package (`plugin.toml`, ribbon, commands) → optional headless engine crate. Domain data lives on DWG entities (XDATA), not a proprietary project DB. + +**Phase 1 (in this PR):** in-process plugins via `inventory::submit!(PluginRegistration)`, `HostSession` API, per-document plugin state, command routing without editing `commands.rs`. + +**Phase 2:** user install folder + dynamic `.dll`/`.so` with the same `plugin.toml`. + +### PR ready for review + +I can open a PR against upstream with **only the generic plugin host** — no civil/hydraulics tab in core. Storm Sewer stays on a separate branch/repo as the reference consumer: [`feature/storm-sewer-module`](https://github.com/mf4633/OpenCADStudio/tree/feature/storm-sewer-module). + +### Re: script languages (@schoeller, #29) + +Agree this shouldn't be either/or. Suggested sequencing: + +1. Native Rust add-ons + stable `HostSession` / `ocs_plugin_api` +2. Python (or similar) as **bindings over that same API** — one extension surface, two authoring paths + +Phase 1 defers embedded scripting until the native API is stable. + +### Questions for maintainers + +1. `ocs_plugin_api` as a workspace crate with semver — OK? +2. Should the main repo ship **zero** discipline modules, or optional built-ins for dev? +3. Priority: extract API crate (1b) vs dynamic loading (2)? + +Happy to open the framework PR whenever timing works. Storm Sewer can follow as a separate installable add-on once the host lands. + +— Michael \ No newline at end of file diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md new file mode 100644 index 00000000..192f4110 --- /dev/null +++ b/docs/plugin-architecture.md @@ -0,0 +1,339 @@ +# Open CAD Studio — Plugin Architecture + +**Status:** Accepted (phase 1) +**Author:** Open CAD Studio contributors +**Date:** June 2026 + +This document is the **authoritative spec** for how add-on packages integrate with Open CAD Studio. It follows patterns familiar from [QGIS](https://plugins.qgis.org/) and other open-source extensibility models: a small metadata file, a single entry-point registration, optional separate engine crate, and user-installable packages in a later phase. + +> **Scope:** Generic host runtime only (`src/plugin/`, `src/app/plugin_host.rs`). Domain plugins (Storm Sewer, future sanitary/geotech) live under `src/modules//` and optional `crates//`. They **consume** this API; they are **not** part of the framework source. + +--- + +## Design goals + +| Goal | Rationale | +|------|-----------| +| **One package, one registration** | Ribbon tab, commands, and manifest ship together — no duplicate hooks in `build.rs` and `commands.rs`. | +| **Stable host surface** | Plugin authors target `HostSession` / future `ocs_plugin_api` with semver, not `OpenCADStudio` internals. | +| **Open-source add-on ergonomics** | Separate git repo + workspace crate is supported; in-tree built-ins use the same layout. | +| **DWG round-trip** | Domain data on entities (XDATA), not opaque plugin databases. | +| **Engine reuse** | Headless crates (`stormsewer`, …) run in WASM/CLI without the CAD host. | + +## Non-goals (phase 1) + +- Sandboxed scripting (Python/Lua). +- Replacing the `acadrust` entity model. +- Full Autodesk CUI XML import. + +--- + +## Three layers (do not mix) + +``` +┌────────────────────────────────────────────────────────────────────┐ +│ Layer A — Host core │ +│ iced UI · Scene · Document · Undo · Command line │ +│ Built-in ribbon tabs: Home, Model, View, … (NOT plugins) │ +└───────────────────────────────┬────────────────────────────────────┘ + │ HostSession (stable adapter) +┌───────────────────────────────▼────────────────────────────────────┐ +│ Layer B — Add-on plugin package │ +│ plugin.toml · manifest.rs · register.rs · plugin.rs · dispatch.rs │ +│ Optional ribbon (CadModule) · per-tab state · XDATA schemas │ +└───────────────────────────────┬────────────────────────────────────┘ + │ pure Rust API +┌───────────────────────────────▼────────────────────────────────────┐ +│ Layer C — Domain engine crate (optional) │ +│ crates/stormsewer — hydraulics, IO, no iced/acadrust dependency │ +└────────────────────────────────────────────────────────────────────┘ +``` + +| Layer | Examples | May depend on | +|-------|----------|---------------| +| **A — Host core** | `src/app/`, `src/ui/`, `src/modules/home/` | Everything in the app | +| **B — Plugin package** | `src/modules/storm_sewer/` | Host + optional engine crate | +| **C — Engine** | `crates/stormsewer/` | `std` only (target: WASM/CLI too) | + +**Hard rules** + +1. `src/plugin/` must **not** import any domain module (`storm_sewer`, …). +2. Engine crates must **not** import `iced`, `acadrust`, or `OpenCADStudio`. +3. Add-on plugins must **not** edit `src/app/commands.rs` for new commands. + +--- + +## Comparison to QGIS + +| QGIS | Open CAD Studio | +|------|-----------------| +| `metadata.txt` (name, version, author, …) | `plugin.toml` beside the package | +| `classFactory(iface)` in `__init__.py` | `inventory::submit!(PluginRegistration { construct })` in `register.rs` | +| `iface` stable API | `HostSession` → future `ocs_plugin_api` crate | +| User folder `…/python/plugins//` | Phase 2: `%APPDATA%/OpenCADStudio/plugins//` | +| Plugin repository (plugins.qgis.org) | Future: curated index; today = git + in-tree | +| `qgisMinimumVersion` | `api_version` in manifest (host ABI major) | +| Processing algorithms | Headless engine crates + `SS_ANALYZE`-style commands | +| Vector layer provider | XDATA on DWG entities (`STORMSEWER_*`) | + +QGIS separates **core application** from **Python plugins** loaded at runtime. Open CAD Studio phase 1 compiles add-ons **in-tree** (same ergonomics, static linking). Phase 2 adds dynamic `.dll`/`.so` with the **same** `plugin.toml` and C ABI entry point. + +--- + +## Add-on package layout + +Every add-on — whether in-tree or external — uses this directory shape: + +``` +/ # e.g. storm_sewer or opencad-storm-sewer repo + plugin.toml # human metadata (mirrors manifest.rs) + PLUGIN.md # XDATA schemas, command reference + register.rs # ONLY inventory::submit! — no domain logic + plugin.rs # thin BuiltinPlugin impl + manifest.rs # static PluginManifest (compile-time truth) + dispatch.rs # command routing for this plugin + state.rs # per-document tab state (optional) + mod.rs # CadModule ribbon (if the plugin has a tab) + icons/ # SVG assets + … # domain modules (data.rs, preview.rs, …) + +crates// # optional, separate workspace member + Cargo.toml + src/ +``` + +### `plugin.toml` (metadata file) + +Source of truth for **humans and phase-2 loader**. Values must match `manifest.rs`. + +```toml +[plugin] +id = "opencad.storm_sewer" +name = "Storm Sewer" +version = "0.2.0" +description = "Gravity storm-drain network design and analysis" +author = "Open CAD Studio contributors" +license = "GPL-3.0-only" +homepage = "https://github.com/…/storm-sewer" + +[opencad] +api_version = 1 +ribbon_order = 50 +command_prefixes = ["SS_"] +xdata_apps = ["STORMSEWER_STRUCT", "STORMSEWER_PIPE", "STORMSEWER_CATCHMENT"] +``` + +**Discovery rule:** If `src/modules//plugin.toml` exists, `build.rs` **excludes** that directory from the auto-generated ribbon registry. The tab is registered only via `BuiltinPlugin::ribbon()`. + +--- + +## Host runtime API (phase 1) + +### `PluginManifest` + +```rust +pub struct PluginManifest { + pub id: &'static str, // reverse-DNS: "opencad.storm_sewer" + pub name: &'static str, + pub version: &'static str, + pub description: &'static str, + pub api_version: ApiVersion, // host ABI major; must match host + pub ribbon_order: i32, // sort key among add-on tabs + pub xdata_apps: &'static [&'static str], + pub command_prefixes: &'static [&'static str], +} +``` + +### `BuiltinPlugin` + +```rust +pub trait BuiltinPlugin: Send + Sync { + fn manifest(&self) -> &'static PluginManifest; + fn ribbon(&self) -> Box; + fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool; +} +``` + +### Registration (single entry point) + +```rust +// register.rs — keep this file free of domain logic +inventory::submit! { + crate::plugin::registry::PluginRegistration { + construct: || Box::new(MyPlugin), + } +} +``` + +Host startup: + +1. `inventory::iter::` constructs all plugins. +2. `try_dispatch` routes commands before the legacy `commands.rs` match. +3. `all_ribbon_modules()` = core tabs from `build.rs` + plugin tabs sorted by `ribbon_order`. + +### `HostSession` — plugin-facing surface + +Plugins use `HostSession`, not `OpenCADStudio`: + +| Category | Methods | +|----------|---------| +| Document | `document()`, `document_mut()`, `entities()`, `entities_mut()`, `add_entity()`, `bump_geometry()` | +| Tab state | `plugin_state()`, `plugin_state_mut()`, `ensure_plugin_state()` keyed by `manifest.id` | +| Command line | `push_info`, `push_output`, `push_error`, `set_active_command` | +| Undo / dirty | `push_undo`, `set_dirty` | + +Phase 1b: extract to `crates/ocs_plugin_api` with semver when the surface stabilizes. + +### Command routing + +```rust +// app/commands.rs — plugins run first +if crate::plugin::try_dispatch(self, tab_index, cmd) { + return Task::none(); +} +// … legacy core commands … +``` + +Plugins own: + +- One-shot commands (`SS_ANALYZE`) +- Interactive acquisition (`SS_PIPE` → `PlacePipe`) +- Subcommands (`SS_PARAMS RP 25`) + +Autocomplete: each plugin submits `inventory::submit!(CommandRegistration { names: &[…] })` in `mod.rs` or `register.rs`. + +Interactive acquisition (C3D-style orange ObjectPick) stays in the **host** via generic `CadCommand` hooks — `resolve_object_pick`, `object_pick_hover_previews`, `entity_pick_acquire_previews` — so `app/update.rs` never imports domain modules. + +### Per-document state + +```rust +DocumentTab { + plugin_state: HashMap<&'static str, Box>, +} +``` + +Store under `manifest.id` (e.g. `opencad.storm_sewer`), not ad hoc globals. + +### XDATA contract + +Domain persistence lives on entities. Document schemas in `PLUGIN.md`: + +| App id | Owner | Purpose | +|--------|-------|---------| +| `STORMSEWER_STRUCT` | `opencad.storm_sewer` | Inlet / junction / outfall | +| `STORMSEWER_PIPE` | `opencad.storm_sewer` | Pipe link between structures | +| `STORMSEWER_CATCHMENT` | `opencad.storm_sewer` | Catchment boundary + hydrology | + +Host may add `xdata::read_record` / `write_record` helpers later; plugins use `acadrust` XDATA APIs today. + +--- + +## Core ribbon vs add-on ribbon + +| Kind | Location | Registration | +|------|----------|--------------| +| **Core tab** | `src/modules/home/`, `view/`, … | `build.rs` auto-discovers `mod.rs` (no `plugin.toml`) | +| **Add-on tab** | `src/modules/storm_sewer/`, … | `plugin.toml` + `BuiltinPlugin::ribbon()` | + +This mirrors QGIS: the application ships core menus; plugins add tabs/tools without patching the host binary. + +--- + +## Phased rollout + +### Phase 1 — Built-in add-ons (current) + +- [x] `src/plugin/` runtime + `try_dispatch` +- [x] Per-tab `plugin_state` +- [x] Storm Sewer off `commands.rs` monolith +- [x] Single registration (`plugin.toml` + `BuiltinPlugin::ribbon`) +- [ ] Extract `ocs_plugin_api` crate +- [ ] Plugin manager UI stub (list installed, versions) + +### Phase 2 — Dynamic loading (desktop) + +``` +%APPDATA%/OpenCADStudio/plugins/ + opencad.storm_sewer/ + plugin.toml + opencad_storm_sewer.dll # cdylib +``` + +- `libloading` + `#[no_mangle] extern "C" fn ocs_plugin_register() -> *const PluginVTable` +- `api_version` compatibility gate at load time +- Enable/disable in settings (like QGIS plugin manager) + +### Phase 3 — Interchange & QA + +- LandXML / SWMM export as plugins or engine features +- Golden-file tests per plugin +- Public plugin index (optional) + +### Phase 4 — Live analysis & WASM + +- `on_entity_committed` hooks +- WASM-hosted engines on hydrocomplete.com + +--- + +## Authoring a new add-on (checklist) + +1. Copy `docs/plugin-template/` into `src/modules//`. +2. Fill `plugin.toml` and `manifest.rs` (keep in sync). +3. Implement `CadModule` in `mod.rs` (ribbon). +4. Implement `dispatch.rs` (all commands for your prefixes). +5. Add `plugin.rs` + `register.rs`. +6. Add `pub mod ;` to `src/modules/mod.rs`. +7. Document XDATA in `PLUGIN.md`. +8. Optional: add `crates//` and depend from the plugin package only. +9. `cargo build` — tab appears via plugin registry; `commands.rs` untouched. + +**External repo:** Publish the engine crate to crates.io; depend on `ocs_plugin_api` (when extracted) + ship a `cdylib` for phase 2. In-tree path: add as a git submodule under `src/modules//` or `plugins/`. + +--- + +## Reference implementation: Storm Sewer + +| Piece | Path | +|-------|------| +| Metadata | `storm_sewer/plugin.toml`, `manifest.rs` | +| Registration | `storm_sewer/register.rs` | +| Adapter | `storm_sewer/plugin.rs` | +| Commands | `storm_sewer/dispatch.rs` | +| Ribbon | `storm_sewer/mod.rs` | +| Tab state | `storm_sewer/state.rs` | +| XDATA | `storm_sewer/data.rs`, `PLUGIN.md` | +| Engine | `crates/stormsewer/` | + +--- + +## Workspace layout + +``` +OpenCADStudio/ + docs/ + plugin-architecture.md # this file + plugin-template/ # scaffold for new add-ons + src/ + plugin/ # Layer A runtime (generic) + modules/ + home/ # core ribbon (no plugin.toml) + storm_sewer/ # add-on (has plugin.toml) + crates/ + stormsewer/ # Layer C engine + ocs_plugin_api/ # (phase 1b) stable host API + plugins/ # (phase 2) third-party cdylibs +``` + +--- + +## Appendix: Civil 3D / SSA contrast + +| SSA / Civil 3D | Open CAD Studio add-on | +|----------------|------------------------| +| Proprietary project DB | DWG + XDATA | +| Vendor-only hydraulics | Pluggable `stormsewer` engine | +| Monolithic install | QGIS-style optional packages | +| Closed API | Documented `HostSession` + `PLUGIN.md` | + +This positions Open CAD Studio as an **open, inspectable** civil CAD platform rather than a single-vendor clone. \ No newline at end of file diff --git a/docs/plugin-template/PLUGIN.md b/docs/plugin-template/PLUGIN.md new file mode 100644 index 00000000..f0352edd --- /dev/null +++ b/docs/plugin-template/PLUGIN.md @@ -0,0 +1,11 @@ +# My Plugin (`opencad.my_plugin`) + +## Commands + +| Command | Description | +|---------|-------------| +| `MP_HELLO` | Example command | + +## XDATA + +Document entity record layouts here when your plugin tags geometry. \ No newline at end of file diff --git a/docs/plugin-template/README.md b/docs/plugin-template/README.md new file mode 100644 index 00000000..9a34345f --- /dev/null +++ b/docs/plugin-template/README.md @@ -0,0 +1,31 @@ +# Add-on plugin template + +Copy this folder to `src/modules//` and rename placeholders. + +## Quick start + +1. Copy files into `src/modules/my_plugin/`. +2. Replace `MY_PLUGIN`, `my_plugin`, `opencad.my_plugin`, `MP_` throughout. +3. Add `pub mod my_plugin;` to `src/modules/mod.rs`. +4. `cargo build` — ribbon tab and commands register automatically. + +## Required files + +| File | Purpose | +|------|---------| +| `plugin.toml` | Metadata (QGIS-style); excludes dir from `build.rs` ribbon scan | +| `manifest.rs` | Compile-time `PluginManifest` — keep in sync with `plugin.toml` | +| `register.rs` | `inventory::submit!(PluginRegistration { … })` only | +| `plugin.rs` | Thin `BuiltinPlugin` impl | +| `dispatch.rs` | All command handlers | +| `mod.rs` | `CadModule` ribbon + `CommandRegistration` for autocomplete | +| `PLUGIN.md` | XDATA schemas and command reference | + +## Optional + +| File | Purpose | +|------|---------| +| `state.rs` | Per-document tab state via `host.ensure_plugin_state(PLUGIN_ID, …)` | +| `crates/my_engine/` | Headless domain logic (no iced/acadrust) | + +See `docs/plugin-architecture.md` for the full spec. \ No newline at end of file diff --git a/docs/plugin-template/dispatch.rs b/docs/plugin-template/dispatch.rs new file mode 100644 index 00000000..174f6b94 --- /dev/null +++ b/docs/plugin-template/dispatch.rs @@ -0,0 +1,14 @@ +use crate::plugin::host::HostSession; + +use super::manifest::PLUGIN_ID; + +pub fn handle(host: &mut HostSession<'_>, cmd: &str) -> bool { + let _ = (host, PLUGIN_ID); + match cmd { + "MP_HELLO" => { + host.push_info("Hello from my_plugin"); + true + } + _ => false, + } +} \ No newline at end of file diff --git a/docs/plugin-template/manifest.rs b/docs/plugin-template/manifest.rs new file mode 100644 index 00000000..65cc7444 --- /dev/null +++ b/docs/plugin-template/manifest.rs @@ -0,0 +1,14 @@ +use crate::plugin::manifest::{ApiVersion, PluginManifest}; + +pub const PLUGIN_ID: &str = "opencad.my_plugin"; + +pub static MANIFEST: PluginManifest = PluginManifest { + id: PLUGIN_ID, + name: "My Plugin", + version: "0.1.0", + description: "Short description of what this add-on does", + api_version: ApiVersion::CURRENT, + ribbon_order: 60, + xdata_apps: &["MYPLUGIN_RECORD"], + command_prefixes: &["MP_"], +}; \ No newline at end of file diff --git a/docs/plugin-template/mod.rs b/docs/plugin-template/mod.rs new file mode 100644 index 00000000..4fa749b2 --- /dev/null +++ b/docs/plugin-template/mod.rs @@ -0,0 +1,34 @@ +pub mod dispatch; +pub mod manifest; +pub mod plugin; +pub mod register; + +use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; + +inventory::submit!(crate::command::CommandRegistration { + names: &["MP_HELLO"] +}); + +pub struct MyPluginModule; + +impl CadModule for MyPluginModule { + fn id(&self) -> &'static str { + "my_plugin" + } + + fn title(&self) -> &'static str { + "My Plugin" + } + + fn ribbon_groups(&self) -> Vec { + vec![RibbonGroup { + title: "Tools", + tools: vec![RibbonItem::LargeTool(ToolDef { + id: "MP_HELLO", + label: "Hello", + icon: IconKind::Glyph("★"), + event: ModuleEvent::Command("MP_HELLO".to_string()), + })], + }] + } +} \ No newline at end of file diff --git a/docs/plugin-template/plugin.rs b/docs/plugin-template/plugin.rs new file mode 100644 index 00000000..6230b57d --- /dev/null +++ b/docs/plugin-template/plugin.rs @@ -0,0 +1,21 @@ +use crate::plugin::host::{BuiltinPlugin, HostSession}; +use crate::plugin::manifest::PluginManifest; + +use super::dispatch; +use super::manifest; + +pub struct MyPlugin; + +impl BuiltinPlugin for MyPlugin { + fn manifest(&self) -> &'static PluginManifest { + &manifest::MANIFEST + } + + fn ribbon(&self) -> Box { + Box::new(super::MyPluginModule) + } + + fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool { + dispatch::handle(host, cmd) + } +} \ No newline at end of file diff --git a/docs/plugin-template/plugin.toml b/docs/plugin-template/plugin.toml new file mode 100644 index 00000000..5961f112 --- /dev/null +++ b/docs/plugin-template/plugin.toml @@ -0,0 +1,13 @@ +[plugin] +id = "opencad.my_plugin" +name = "My Plugin" +version = "0.1.0" +description = "Short description of what this add-on does" +author = "Your Name" +license = "GPL-3.0-only" + +[opencad] +api_version = 1 +ribbon_order = 60 +command_prefixes = ["MP_"] +xdata_apps = ["MYPLUGIN_RECORD"] \ No newline at end of file diff --git a/docs/plugin-template/register.rs b/docs/plugin-template/register.rs new file mode 100644 index 00000000..151eb401 --- /dev/null +++ b/docs/plugin-template/register.rs @@ -0,0 +1,7 @@ +use super::plugin::MyPlugin; + +inventory::submit! { + crate::plugin::registry::PluginRegistration { + construct: || Box::new(MyPlugin), + } +} \ No newline at end of file diff --git a/src/app/cmd_result.rs b/src/app/cmd_result.rs index 32964ee8..789fd97d 100644 --- a/src/app/cmd_result.rs +++ b/src/app/cmd_result.rs @@ -251,6 +251,11 @@ impl OpenCADStudio { } CmdResult::ReplaceMany(replacements, additions) => { let label = self.history_label_from_active_cmd(i, "FILLET"); + let was_catchment = self + .tabs[i] + .active_cmd + .as_ref() + .is_some_and(|c| c.name() == "SS_CATCHMENT"); self.push_undo_snapshot(i, label); for (handle, entities) in replacements { self.tabs[i].scene.erase_entities(&[handle]); @@ -265,6 +270,10 @@ impl OpenCADStudio { self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; + if was_catchment { + self.command_line + .push_info("Catchment tagged successfully."); + } self.refresh_properties(); } CmdResult::ReplaceEntity(handle, new_entities) => { diff --git a/src/app/commands.rs b/src/app/commands.rs index 4af04a94..5992e19a 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -28,66 +28,11 @@ impl OpenCADStudio { return Task::done(Message::OpenPathPicked(Some((path, size)))); } + if crate::plugin::try_dispatch(self, i, cmd) { + return Task::none(); + } + match cmd { - // ── Storm Sewer module ────────────────────────────────────────── - "SS_ANALYZE" => { - use crate::modules::storm_sewer::analysis as ss; - let result = ss::analyze_doc(self.tabs[i].scene.document.entities()); - match result { - Ok((ents, report)) => { - for e in ents { - let _ = self.tabs[i].scene.add_entity(e); - } - self.tabs[i].scene.bump_geometry(); - self.command_line - .push_info("Storm sewer: analyzed drawn network (default IDF 60/(t+10)^0.8)."); - for line in report.lines() { - self.command_line.push_output(line); - } - } - Err(e) => self.command_line.push_error(&e), - } - } - "SS_REPORT" => { - use crate::modules::storm_sewer::analysis as ss; - match ss::report_doc(self.tabs[i].scene.document.entities()) { - Ok(report) => { - for line in report.lines() { - self.command_line.push_output(line); - } - } - Err(e) => self.command_line.push_error(&e), - } - } - "SS_INLET" | "SS_JUNCTION" | "SS_OUTFALL" => { - use crate::modules::storm_sewer::structures::PlaceStructure; - let cmd = match cmd { - "SS_INLET" => PlaceStructure::inlet(), - "SS_JUNCTION" => PlaceStructure::junction(), - _ => PlaceStructure::outfall(), - }; - self.command_line.push_info(&cmd.prompt()); - self.tabs[i].active_cmd = Some(Box::new(cmd)); - } - "SS_PIPE" => { - let cmd = crate::modules::storm_sewer::structures::PlacePipe::new(); - self.command_line.push_info(&cmd.prompt()); - self.tabs[i].active_cmd = Some(Box::new(cmd)); - } - "SS_PROFILE" => { - use crate::modules::storm_sewer::analysis as ss; - let result = ss::profile_doc(self.tabs[i].scene.document.entities()); - match result { - Ok(ents) => { - for e in ents { - let _ = self.tabs[i].scene.add_entity(e); - } - self.tabs[i].scene.bump_geometry(); - self.command_line.push_info("Storm sewer HGL profile drawn."); - } - Err(e) => self.command_line.push_error(&e), - } - } "NEW" => return Task::done(Message::TabNew), "OPEN" => return Task::done(Message::OpenFile), "SAVE" | "QSAVE" => return Task::done(Message::SaveFile), diff --git a/src/app/document.rs b/src/app/document.rs index 0667c9e7..7094fb9c 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -9,6 +9,8 @@ use crate::ui::{LayerPanel, PropertiesPanel}; use acadrust::tables::Ucs; use acadrust::{CadDocument, Handle}; use iced; +use std::any::{Any, TypeId}; +use std::collections::HashMap; use std::path::PathBuf; // ── Dynamic input ────────────────────────────────────────────────────────── @@ -96,6 +98,8 @@ pub(super) struct DocumentTab { /// of the model-space shader. The scene is still constructed so the /// rest of the code can treat it as a normal tab when reading. pub(super) is_start: bool, + /// Per-plugin document state (`plugin::BuiltinPlugin` manifest id → state). + pub(super) plugin_state: HashMap<&'static str, Box>, } impl DocumentTab { @@ -142,9 +146,41 @@ impl DocumentTab { active_mleader_style: "Standard".to_string(), last_synced_camera_gen: 0, is_start: false, + plugin_state: HashMap::new(), } } + pub(super) fn plugin_state( + &self, + plugin_id: &'static str, + _type_id: TypeId, + ) -> Option<&T> { + self.plugin_state.get(plugin_id)?.downcast_ref::() + } + + pub(super) fn plugin_state_mut( + &mut self, + plugin_id: &'static str, + _type_id: TypeId, + ) -> Option<&mut T> { + self.plugin_state.get_mut(plugin_id)?.downcast_mut::() + } + + pub(super) fn ensure_plugin_state( + &mut self, + plugin_id: &'static str, + init: impl FnOnce() -> T, + ) -> &mut T { + if !self.plugin_state.contains_key(plugin_id) { + self.plugin_state.insert(plugin_id, Box::new(init())); + } + self.plugin_state + .get_mut(plugin_id) + .expect("just inserted") + .downcast_mut::() + .expect("plugin_id type mismatch") + } + /// Welcome / Start tab. Carries a dummy Scene so the rest of the app /// can read tab state uniformly; the viewport renderer detects /// `is_start` and shows a welcome page instead. diff --git a/src/app/mod.rs b/src/app/mod.rs index 3f79d0e1..6d2ee32f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,5 +1,6 @@ mod cmd_result; mod commands; +pub mod plugin_host; mod document; mod helpers; mod history; diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs new file mode 100644 index 00000000..f5df2517 --- /dev/null +++ b/src/app/plugin_host.rs @@ -0,0 +1,94 @@ +// HostSession — plugin-facing API implemented inside `app` (private field access). + +use std::any::{Any, TypeId}; + +use acadrust::{CadDocument, EntityType, Handle}; + +use super::OpenCADStudio; +use crate::command::CadCommand; + +/// Session adapter: one active document tab, command line, undo. +pub(crate) struct HostSession<'a> { + app: &'a mut OpenCADStudio, + tab: usize, +} + +impl<'a> HostSession<'a> { + pub(crate) fn new(app: &'a mut OpenCADStudio, tab: usize) -> Self { + Self { app, tab } + } + + pub fn tab_index(&self) -> usize { + self.tab + } + + pub fn document(&self) -> &CadDocument { + &self.app.tabs[self.tab].scene.document + } + + pub fn document_mut(&mut self) -> &mut CadDocument { + &mut self.app.tabs[self.tab].scene.document + } + + pub fn entities(&self) -> impl Iterator { + self.document().entities() + } + + pub fn entities_mut(&mut self) -> impl Iterator { + self.document_mut().entities_mut() + } + + pub fn add_entity(&mut self, entity: EntityType) -> Handle { + self.app.tabs[self.tab].scene.add_entity(entity) + } + + pub fn bump_geometry(&mut self) { + self.app.tabs[self.tab].scene.bump_geometry(); + } + + pub fn push_undo(&mut self, label: &str) { + self.app.push_undo_snapshot(self.tab, label); + } + + pub fn set_dirty(&mut self) { + self.app.tabs[self.tab].dirty = true; + } + + pub fn push_info(&mut self, msg: &str) { + self.app.command_line.push_info(msg); + } + + pub fn push_output(&mut self, msg: &str) { + self.app.command_line.push_output(msg); + } + + pub fn push_error(&mut self, msg: &str) { + self.app.command_line.push_error(msg); + } + + pub fn set_active_command(&mut self, cmd: Box) { + self.app.tabs[self.tab].active_cmd = Some(cmd); + } + + pub fn plugin_state( + &self, + plugin_id: &'static str, + ) -> Option<&T> { + self.app.tabs[self.tab].plugin_state(plugin_id, TypeId::of::()) + } + + pub fn plugin_state_mut( + &mut self, + plugin_id: &'static str, + ) -> Option<&mut T> { + self.app.tabs[self.tab].plugin_state_mut(plugin_id, TypeId::of::()) + } + + pub fn ensure_plugin_state( + &mut self, + plugin_id: &'static str, + init: impl FnOnce() -> T, + ) -> &mut T { + self.app.tabs[self.tab].ensure_plugin_state(plugin_id, init) + } +} \ No newline at end of file diff --git a/src/app/update.rs b/src/app/update.rs index ee8f88bd..06095a3e 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -2278,6 +2278,11 @@ impl OpenCADStudio { .as_ref() .map(|c| c.needs_entity_pick()) .unwrap_or(false); + let needs_structure = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.needs_structure_point_pick()) + .unwrap_or(false); let is_gathering = self.tabs[i] .active_cmd .as_ref() @@ -2288,7 +2293,7 @@ impl OpenCADStudio { .as_ref() .map(|c| c.needs_tangent_pick()) .unwrap_or(false); - self.tabs[i].snap_result = if needs_entity || is_gathering { + self.tabs[i].snap_result = if needs_entity || is_gathering || needs_structure { None } else if needs_tan { self.snapper.snap_tangent_only( @@ -2345,6 +2350,39 @@ impl OpenCADStudio { }; self.tabs[i].last_cursor_world = effective; self.tabs[i].last_cursor_screen = p_full; + + // C3D-style orange object snap (plugin commands implement resolve_object_pick). + if needs_structure { + use crate::snap::{SnapResult, SnapType}; + let pick = self.tabs[i].active_cmd.as_ref().and_then(|c| { + c.resolve_object_pick( + &self.tabs[i].scene, + effective.x as f64, + effective.y as f64, + ) + }); + if let Some(pick) = pick { + let world = + glam::Vec3::new(pick.x as f32, pick.y as f32, effective.z); + let ndc = view_proj.project_point3(world); + let screen = iced::Point::new( + (ndc.x + 1.0) * 0.5 * bounds.width, + (1.0 - ndc.y) * 0.5 * bounds.height, + ); + self.tabs[i].snap_result = Some(SnapResult { + world, + screen, + snap_type: SnapType::ObjectPick, + tangent_obj: None, + }); + if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + cmd.set_acquisition_hint(Some(pick.label)); + } + } else if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + cmd.set_acquisition_hint(None); + } + } + // Snap glyph is positioned in canvas space; shift the // tile-local snap screen point back to the full canvas. if let Some(s) = self.tabs[i].snap_result.as_mut() { @@ -2352,16 +2390,42 @@ impl OpenCADStudio { s.screen.y += tile_b.y; } - let mut previews = if needs_entity { + let mut previews = if needs_structure { + let mut p = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.object_pick_hover_previews(&self.tabs[i].scene, effective)) + .unwrap_or_default(); + if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + p.extend(cmd.on_preview_wires(effective)); + } + p + } else if needs_entity { let hover_handle = scene::hit_test::click_hit(p, &all_wires[..], view_proj, bounds) .and_then(|s| Scene::handle_from_wire_name(s)) .unwrap_or(acadrust::Handle::NULL); - self.tabs[i] + let mut p = self.tabs[i] .active_cmd .as_mut() .map(|c| c.on_hover_entity(hover_handle, effective)) - .unwrap_or_default() + .unwrap_or_default(); + if !hover_handle.is_null() { + if let Some(cmd) = self.tabs[i].active_cmd.as_ref() { + p.extend(cmd.entity_pick_acquire_previews( + &self.tabs[i].scene, + hover_handle, + )); + } + if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + if let Some(hint) = + cmd.entity_pick_acquire_hint(hover_handle) + { + cmd.set_acquisition_hint(Some(hint)); + } + } + } + p } else { self.tabs[i] .active_cmd @@ -2763,6 +2827,36 @@ impl OpenCADStudio { }; let result = if self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.needs_structure_point_pick()) + .unwrap_or(false) + { + let pick = self.tabs[i].active_cmd.as_ref().and_then(|c| { + c.resolve_object_pick( + &self.tabs[i].scene, + world_pt.x as f64, + world_pt.y as f64, + ) + }); + if let Some(pick) = pick { + let center = glam::Vec3::new(pick.x as f32, pick.y as f32, world_pt.z); + let result = self.tabs[i].active_cmd.as_mut().map(|c| { + c.on_structure_pick(pick.handle, center) + }); + self.command_line + .push_info(&format!("{} acquired.", pick.label)); + result + } else { + let msg = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.object_pick_miss_message()) + .unwrap_or("No object near click."); + self.command_line.push_error(msg); + None + } + } else if self.tabs[i] .active_cmd .as_ref() .map(|c| c.needs_entity_pick()) @@ -2773,6 +2867,23 @@ impl OpenCADStudio { let hit = scene::hit_test::click_hit(p, &all_wires2[..], vp_mat2, bounds) .and_then(|s| Scene::handle_from_wire_name(s)); if let Some(handle) = hit { + // Some commands (e.g. SS_CATCHMENT) need the entity + // body before `on_entity_pick` can advance. + let inject_first = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.inject_before_entity_pick()) + .unwrap_or(false); + if inject_first { + if let Some(entity) = + self.tabs[i].scene.document.get_entity(handle).cloned() + { + if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + cmd.inject_picked_entity(entity); + } + } + } + let result = self.tabs[i] .active_cmd .as_mut() diff --git a/src/command/mod.rs b/src/command/mod.rs index e73bedf0..5cb72604 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -7,9 +7,19 @@ use crate::scene::hatch_model::HatchModel; use crate::scene::wire_model::WireModel; +use crate::scene::Scene; use acadrust::{EntityType, Handle}; use glam::Vec3; +/// Domain object resolved under the cursor for C3D-style ObjectPick snapping. +#[derive(Clone, Copy, Debug)] +pub struct ObjectPickHit { + pub handle: Handle, + pub x: f64, + pub y: f64, + pub label: &'static str, +} + // ── Transform ───────────────────────────────────────────────────────────── /// A geometric transformation applied to existing entities. @@ -267,6 +277,44 @@ pub trait CadCommand: Send { CmdResult::Cancel } + /// Point-click pick of domain objects (wire hit-test often misses small markers). + fn needs_structure_point_pick(&self) -> bool { + false + } + + /// Resolve a domain object near `(x, y)` while `needs_structure_point_pick()` is active. + fn resolve_object_pick(&self, _scene: &Scene, _x: f64, _y: f64) -> Option { + None + } + + /// Preview wires while hovering during object-point pick. + fn object_pick_hover_previews(&self, _scene: &Scene, _cursor: Vec3) -> Vec { + vec![] + } + + /// Message when `resolve_object_pick` returns none on click. + fn object_pick_miss_message(&self) -> &'static str { + "No object near click." + } + + /// Called when `needs_structure_point_pick()` is true and a structure is found near the click. + fn on_structure_pick(&mut self, _handle: Handle, _pt: Vec3) -> CmdResult { + CmdResult::Cancel + } + + /// Extra acquisition previews during entity pick (besides `on_hover_entity`). + fn entity_pick_acquire_previews(&self, _scene: &Scene, _handle: Handle) -> Vec { + vec![] + } + + /// Acquisition hint label during entity pick hover. + fn entity_pick_acquire_hint(&self, _handle: Handle) -> Option<&'static str> { + None + } + + /// Hover label for C3D-style object acquisition (e.g. "Inlet" under cursor). + fn set_acquisition_hint(&mut self, _hint: Option<&str>) {} + /// Called after `CmdResult::ReplaceEntity` is applied to the document. /// `old` is the erased handle; `new_handles` are the handles assigned to the replacement entities. /// Commands that stay active across replaces should update their internal snapshots here. @@ -367,7 +415,13 @@ pub trait CadCommand: Send { self.on_point(hit) } - /// Called by update.rs after `on_entity_pick` to inject the cloned entity into commands + /// When true, `update.rs` injects the picked entity before calling + /// `on_entity_pick` (required when the pick handler reads injected state). + fn inject_before_entity_pick(&self) -> bool { + false + } + + /// Called by update.rs to inject the cloned entity into commands /// that need to read/modify it (e.g. DIMTEDIT, MLEADERADD, MLEADERREMOVE). /// Default: no-op. fn inject_picked_entity(&mut self, _entity: acadrust::EntityType) {} diff --git a/src/lib.rs b/src/lib.rs index f9164b31..4f3d75c6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,7 @@ pub mod entities; pub mod io; pub mod linetypes; pub mod modules; +pub mod plugin; pub mod patterns; pub mod scene; pub mod snap; diff --git a/src/main.rs b/src/main.rs index d420e6cf..d58a6149 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod entities; mod io; mod linetypes; mod modules; +mod plugin; mod patterns; mod scene; mod snap; diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 3e6b24c4..53044f40 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -1,10 +1,13 @@ // Module system — CadModule, ToolDef, RibbonGroup. // -// To add a new module: -// 1. Create `src/modules/my_name/` directory +// To add a **core** ribbon tab (Home, View, …): +// 1. Create `src/modules/my_name/` directory (no `plugin.toml`) // 2. Add `src/modules/my_name/mod.rs` implementing `CadModule` as `MyNameModule` // 3. Add `pub mod my_name;` below -// 4. `cargo build` — module appears in the ribbon automatically +// 4. `cargo build` — tab appears via build.rs registry +// +// To add an **add-on plugin** (Storm Sewer, …): +// See `docs/plugin-architecture.md` and copy `docs/plugin-template/`. // // Each module folder contains: // - mod.rs : module definition (ribbon groups + tool layout) diff --git a/src/modules/registry.rs b/src/modules/registry.rs index d152bc8b..2530d4fc 100644 --- a/src/modules/registry.rs +++ b/src/modules/registry.rs @@ -14,6 +14,5 @@ pub fn all_modules() -> Vec> { Box::new(super::view::ViewModule), Box::new(super::manage::ManageModule), Box::new(super::layout::LayoutModule), - Box::new(super::storm_sewer::StormSewerModule), ] } diff --git a/src/modules/storm_sewer/PLUGIN.md b/src/modules/storm_sewer/PLUGIN.md new file mode 100644 index 00000000..d04d83b7 --- /dev/null +++ b/src/modules/storm_sewer/PLUGIN.md @@ -0,0 +1,69 @@ +# Storm Sewer (`opencad.storm_sewer`) + +Add-on package for gravity storm-drain network design and analysis. + +- **Engine:** `crates/stormsewer` (headless hydraulics) +- **Host integration:** `plugin.rs`, `dispatch.rs`, `register.rs` +- **Architecture:** `docs/plugin-architecture.md` + +## Commands + +| Command | Description | +|---------|-------------| +| `SS_INLET` | Place inlet structure | +| `SS_JUNCTION` | Place junction structure | +| `SS_OUTFALL` | Place outfall structure | +| `SS_PIPE` | Draw pipe between two structures | +| `SS_CATCHMENT` | Draw catchment boundary polyline | +| `SS_APPLYTC` | Apply time-of-concentration from catchments | +| `SS_LANDXML` / `SS_IMPORTXML` | Import LandXML storm network | +| `SS_ANALYZE` | Run hydraulic analysis on drawn network | +| `SS_SIZE` | Size pipes from analysis | +| `SS_PARAMS` | Set rainfall / analysis parameters | +| `SS_MULTIRP` | Multi return-period analysis | +| `SS_REPORT` | Print analysis report | +| `SS_PROFILE` | Draw HGL profile | + +## XDATA schemas + +All records use application names registered in `manifest.rs`. Values are stored as XDATA on DWG entities so networks round-trip through save/load. + +### `STORMSEWER_STRUCT` (on `CIRCLE`) + +| Index | Field | Type | Notes | +|-------|-------|------|-------| +| 0 | kind | int | 0=inlet, 1=junction, 2=outfall | +| 1 | invert | real | Structure invert elevation | +| 2 | rim | real | Rim elevation | +| 3 | area | real | Contributing area (acres) | +| 4 | C | real | Runoff coefficient | + +### `STORMSEWER_PIPE` (on `LINE`) + +| Index | Field | Type | Notes | +|-------|-------|------|-------| +| 0 | diameter | real | Pipe diameter (inches) | +| 1 | n | real | Manning's n | +| 2 | from_handle | int | Start structure entity handle | +| 3 | to_handle | int | End structure entity handle | + +### `STORMSEWER_CATCHMENT` (on `LWPOLYLINE`) + +| Index | Field | Type | Notes | +|-------|-------|------|-------| +| 0 | area_acres | real | Catchment area | +| 1 | C | real | Runoff coefficient | +| 2 | length_ft | real | Flow path length | +| 3 | slope | real | Average slope | +| 4 | inlet_handle | int | Optional inlet structure handle (0 = none) | + +## Per-document state + +Stored under plugin id `opencad.storm_sewer` as `StormTabState` (`state.rs`): + +- `StormAnalysisParams` — IDF, tailwater, min Tc, return periods, etc. + +## Dependencies + +- `stormsewer` workspace crate — must not depend on CAD host crates. +- This package — must not edit `src/app/commands.rs`; use `dispatch.rs`. \ No newline at end of file diff --git a/src/modules/storm_sewer/analysis.rs b/src/modules/storm_sewer/analysis.rs index 317573ee..da512569 100644 --- a/src/modules/storm_sewer/analysis.rs +++ b/src/modules/storm_sewer/analysis.rs @@ -8,18 +8,19 @@ use acadrust::types::Vector3; use acadrust::{Circle, EntityType, Line, MText}; +use stormsewer::design::check_inlet; use stormsewer::drawing::{draw_network, DrawConfig}; use stormsewer::idf::IdfCurve; -use stormsewer::network::{Analysis, AnalysisOptions, Network, Node, Pipe}; +use stormsewer::network::{Analysis, AnalysisOptions, Network, Node, NodeKind, Pipe}; +use stormsewer::params::StormAnalysisParams; use stormsewer::parse::parse_ssn; -use stormsewer::report::format_analysis; +use stormsewer::report::{format_analysis, format_multi_rp}; use super::data; -/// Default network-level analysis parameters used when analyzing a drawn -/// network (per-entity data carries geometry/area; rainfall is global). -fn default_params() -> (IdfCurve, AnalysisOptions) { - (IdfCurve::new(60.0, 10.0, 0.8), AnalysisOptions::default()) +/// Default network-level analysis parameters (tests / fallbacks). +pub fn default_params() -> StormAnalysisParams { + StormAnalysisParams::municipal() } /// Annotation labels only (flow + HGL), for overlaying on an already-drawn @@ -36,27 +37,85 @@ fn build_annotations(net: &Network, a: &Analysis) -> Vec { /// Reconstruct the network from drawn entities, analyze it, and return /// annotation entities (flow/HGL labels) + the report. -pub fn analyze_doc<'a>(entities: impl Iterator) -> Result<(Vec, String), String> { +pub fn analyze_doc<'a>( + entities: impl Iterator, + params: &StormAnalysisParams, +) -> Result<(Vec, String, Analysis), String> { let net = data::network_from_entities(entities)?; - let (idf, opts) = default_params(); - let a = run_analysis(&net, &idf, &opts)?; - Ok((build_annotations(&net, &a), format_analysis(&a))) + let a = run_analysis(&net, params.idf.design_curve(), ¶ms.hydraulics)?; + let report = full_report(&net, &a, params); + Ok((build_annotations(&net, &a), report, a)) } /// Reconstruct from drawn entities and return the HGL long-section entities. -pub fn profile_doc<'a>(entities: impl Iterator) -> Result, String> { +pub fn profile_doc<'a>( + entities: impl Iterator, + params: &StormAnalysisParams, +) -> Result, String> { let net = data::network_from_entities(entities)?; - let (idf, opts) = default_params(); - let a = run_analysis(&net, &idf, &opts)?; + let a = run_analysis(&net, params.idf.design_curve(), ¶ms.hydraulics)?; Ok(build_profile(&net, &a)) } /// Reconstruct from drawn entities and return the formatted report. -pub fn report_doc<'a>(entities: impl Iterator) -> Result { +pub fn report_doc<'a>( + entities: impl Iterator, + params: &StormAnalysisParams, +) -> Result { let net = data::network_from_entities(entities)?; - let (idf, opts) = default_params(); - let a = run_analysis(&net, &idf, &opts)?; - Ok(format_analysis(&a)) + let a = run_analysis(&net, params.idf.design_curve(), ¶ms.hydraulics)?; + Ok(full_report(&net, &a, params)) +} + +/// Multi-return-period peak-flow comparison table. +pub fn multi_rp_report<'a>( + entities: impl Iterator, + params: &StormAnalysisParams, +) -> Result { + let net = data::network_from_entities(entities)?; + Ok(format_multi_rp(&net, ¶ms.idf, ¶ms.hydraulics)) +} + +fn full_report(net: &Network, a: &Analysis, params: &StormAnalysisParams) -> String { + let mut s = format_analysis(a); + s.push_str(&inlet_section(net, a, params)); + s +} + +fn inlet_section(net: &Network, a: &Analysis, params: &StormAnalysisParams) -> String { + let mut s = String::new(); + let mut any = false; + for nd in &net.nodes { + if nd.kind != NodeKind::Inlet { + continue; + } + let q = a + .pipes + .iter() + .filter(|p| p.from == nd.id) + .map(|p| p.design_q) + .fold(0.0f64, f64::max); + if q <= 0.0 { + continue; + } + if !any { + s.push_str("\n=== INLET CAPACITY (HEC-22 grate, simplified) ===\n"); + s.push_str("Node Q(cfs) Cap(cfs) Status\n"); + any = true; + } + let chk = check_inlet( + q, + params.inlet_grate_length_ft, + params.inlet_flow_depth_ft, + params.inlet_gutter_slope, + ); + let status = if chk.ok { "ok" } else { "BYPASS" }; + s.push_str(&format!( + "{:<6} {:>6.2} {:>8.2} {status}\n", + nd.id, chk.design_q_cfs, chk.capacity_cfs + )); + } + s } /// The built-in demonstration network (properly sized, with plan coordinates). diff --git a/src/modules/storm_sewer/catchment.rs b/src/modules/storm_sewer/catchment.rs new file mode 100644 index 00000000..b90cb2f8 --- /dev/null +++ b/src/modules/storm_sewer/catchment.rs @@ -0,0 +1,309 @@ +// Interactive catchment tagging: closed LwPolyline + STORMSEWER_CATCHMENT XDATA. + +use acadrust::entities::LwPolyline; +use acadrust::{EntityType, Handle}; +use glam::Vec3; + +use stormsewer::catchment::{default_flow_length_ft, polygon_centroid, shoelace_area_sqft, sqft_to_acres}; + +use super::data; +use super::preview; +use crate::command::{CadCommand, CmdResult, ObjectPickHit}; +use crate::scene::{Scene, WireModel}; + +fn parse_num(text: &str) -> Option { + text.trim().replace(',', ".").parse::().ok() +} + +enum CStep { + PickPolyline, + RunoffC, + FlowLength, + Slope, + PickInlet, +} + +pub struct TagCatchment { + step: CStep, + picked: Option, + c: f64, + flow_length: f64, + slope: f64, + inlet_handle: Handle, + area_ac: f64, + acquire_hint: Option, +} + +impl TagCatchment { + pub fn new() -> Self { + Self { + step: CStep::PickPolyline, + picked: None, + c: 0.70, + flow_length: 0.0, + slope: 0.01, + inlet_handle: Handle::NULL, + area_ac: 0.0, + acquire_hint: None, + } + } + + fn commit(&self) -> CmdResult { + let mut ent = self.picked.clone().expect("polyline not picked"); + let EntityType::LwPolyline(pl) = &ent else { + return CmdResult::Cancel; + }; + if !pl.is_closed { + return CmdResult::Cancel; + } + let handle = ent.common().handle; + let xd = &mut ent.common_mut().extended_data; + let kept: Vec<_> = xd + .records() + .iter() + .filter(|r| r.application_name != data::APP_CATCHMENT) + .cloned() + .collect(); + xd.clear(); + for r in kept { + xd.add_record(r); + } + xd.add_record(data::catchment_xdata( + self.c, + self.flow_length, + self.slope, + self.inlet_handle, + )); + CmdResult::ReplaceMany(vec![(handle, vec![ent])], vec![]) + } + + fn assign_inlet(&mut self, handle: Handle, pt: Vec3) { + self.inlet_handle = handle; + if self.flow_length <= 0.0 { + if let Some(ref ent) = self.picked { + if let EntityType::LwPolyline(pl) = ent { + let verts: Vec<_> = pl.vertices.iter().map(|v| (v.location.x, v.location.y)).collect(); + let centroid = polygon_centroid(&verts); + self.flow_length = + default_flow_length_ft(centroid, (pt.x as f64, pt.y as f64)); + } + } + } + } +} + +impl Default for TagCatchment { + fn default() -> Self { + Self::new() + } +} + +impl CadCommand for TagCatchment { + fn name(&self) -> &'static str { + "SS_CATCHMENT" + } + + fn prompt(&self) -> String { + match self.step { + CStep::PickPolyline => { + "Catchment: click closed drainage-area polyline (highlights orange):".into() + } + CStep::RunoffC => format!( + "Catchment area {:.3} ac — runoff C <{:.2}> (Enter to accept):", + self.area_ac, self.c + ), + CStep::FlowLength => format!( + "Flow path length, ft <{:.1}> (0 = auto from centroid to inlet):", + self.flow_length + ), + CStep::Slope => format!("Average slope, ft/ft <{:.4}> (Enter to accept):", self.slope), + CStep::PickInlet => { + let hint = self + .acquire_hint + .as_deref() + .map(|h| format!(" [{h}]")) + .unwrap_or_default(); + format!( + "Catchment: click inlet/junction to drain to (orange snap){hint} — Enter = nearest:" + ) + } + } + } + + fn set_acquisition_hint(&mut self, hint: Option<&str>) { + if matches!(self.step, CStep::PickInlet) { + self.acquire_hint = hint.map(str::to_string); + } + } + + fn wants_text_input(&self) -> bool { + matches!(self.step, CStep::RunoffC | CStep::FlowLength | CStep::Slope) + } + + fn needs_entity_pick(&self) -> bool { + matches!(self.step, CStep::PickPolyline) + } + + fn needs_structure_point_pick(&self) -> bool { + matches!(self.step, CStep::PickInlet) + } + + fn resolve_object_pick(&self, scene: &Scene, x: f64, y: f64) -> Option { + let pick = preview::structure_under_cursor(scene, x, y, true)?; + Some(ObjectPickHit { + handle: pick.handle, + x: pick.x, + y: pick.y, + label: pick.label(), + }) + } + + fn object_pick_hover_previews(&self, scene: &Scene, cursor: Vec3) -> Vec { + preview::structure_acquire_previews(scene, cursor, true) + } + + fn object_pick_miss_message(&self) -> &'static str { + "No storm structure near click — move closer or press Enter for nearest." + } + + fn entity_pick_acquire_previews(&self, scene: &Scene, handle: Handle) -> Vec { + if matches!(self.step, CStep::PickPolyline) { + preview::catchment_poly_under_cursor(scene, handle) + } else { + vec![] + } + } + + fn entity_pick_acquire_hint(&self, _handle: Handle) -> Option<&'static str> { + if matches!(self.step, CStep::PickPolyline) { + Some("Catchment area") + } else { + None + } + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + if !matches!(self.step, CStep::PickPolyline) { + return; + } + if let EntityType::LwPolyline(ref pl) = entity { + if pl.is_closed && pl.vertices.len() >= 3 { + let verts: Vec<_> = pl.vertices.iter().map(|v| (v.location.x, v.location.y)).collect(); + self.area_ac = sqft_to_acres(shoelace_area_sqft(&verts)); + self.picked = Some(entity); + } + } + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn on_entity_pick(&mut self, _handle: Handle, _pt: Vec3) -> CmdResult { + if self.picked.is_none() { + return CmdResult::NeedPoint; + } + self.step = CStep::RunoffC; + CmdResult::NeedPoint + } + + fn on_structure_pick(&mut self, handle: Handle, pt: Vec3) -> CmdResult { + self.assign_inlet(handle, pt); + self.acquire_hint = None; + self.commit() + } + + fn on_text_input(&mut self, text: &str) -> Option { + let v = parse_num(text); + match self.step { + CStep::RunoffC => { + if let Some(x) = v { + self.c = x; + } + self.step = CStep::FlowLength; + } + CStep::FlowLength => { + if let Some(x) = v { + self.flow_length = x; + } + self.step = CStep::Slope; + } + CStep::Slope => { + if let Some(x) = v { + self.slope = x; + } + self.step = CStep::PickInlet; + } + _ => {} + } + None + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + CStep::PickInlet => { + self.acquire_hint = None; + self.commit() + } + CStep::RunoffC => { + self.step = CStep::FlowLength; + CmdResult::NeedPoint + } + CStep::FlowLength => { + self.step = CStep::Slope; + CmdResult::NeedPoint + } + CStep::Slope => { + self.step = CStep::PickInlet; + CmdResult::NeedPoint + } + CStep::PickPolyline => CmdResult::NeedPoint, + } + } + + fn on_point(&mut self, _pt: Vec3) -> CmdResult { + CmdResult::NeedPoint + } +} + +/// Inspect a closed polyline and suggest defaults from geometry. +pub fn catchment_defaults_from_poly(pl: &LwPolyline, target_xy: Option<(f64, f64)>) -> (f64, f64, f64) { + let verts: Vec<_> = pl.vertices.iter().map(|v| (v.location.x, v.location.y)).collect(); + let area_ac = sqft_to_acres(shoelace_area_sqft(&verts)); + let centroid = polygon_centroid(&verts); + let flow_len = target_xy + .map(|t| default_flow_length_ft(centroid, t)) + .unwrap_or(0.0); + (area_ac, flow_len, 0.01) +} + +/// Update structure entities with Tc computed from catchments + network assembly. +pub fn apply_tc_from_network<'a>( + entities: impl Iterator, + entities_mut: impl Iterator, +) -> Result { + data::apply_tc_in_document(entities, entities_mut) +} + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::LwVertex; + use acadrust::types::Vector2; + + #[test] + fn defaults_from_unit_square() { + let mut pl = LwPolyline::default(); + pl.is_closed = true; + pl.vertices = vec![ + LwVertex::new(Vector2::new(0.0, 0.0)), + LwVertex::new(Vector2::new(10.0, 0.0)), + LwVertex::new(Vector2::new(10.0, 10.0)), + LwVertex::new(Vector2::new(0.0, 10.0)), + ]; + let (area, flow, slope) = catchment_defaults_from_poly(&pl, Some((0.0, 0.0))); + assert!((area - sqft_to_acres(100.0)).abs() < 1e-6); + assert!(flow > 0.0); + assert!((slope - 0.01).abs() < 1e-9); + } +} \ No newline at end of file diff --git a/src/modules/storm_sewer/data.rs b/src/modules/storm_sewer/data.rs index d0cbb521..8a501634 100644 --- a/src/modules/storm_sewer/data.rs +++ b/src/modules/storm_sewer/data.rs @@ -2,19 +2,36 @@ // of a `stormsewer::Network` from the drawn entities. // // Structures are circles tagged with the `STORMSEWER_STRUCT` app record -// [kind, invert, rim, area, C]; pipes are lines tagged with `STORMSEWER_PIPE` -// [diameter, n, from-handle, to-handle]. Connectivity is by entity handle, so -// the drawn network round-trips to DWG/DXF and is analyzable directly. +// [kind, invert, rim, area, C, tc_inlet?]; pipes are lines tagged with +// `STORMSEWER_PIPE` [diameter, n, from-handle, to-handle]. Catchments are +// closed LwPolylines tagged with `STORMSEWER_CATCHMENT` +// [C, flow_length_ft, slope, inlet_handle (0 = auto)]. Connectivity is by +// entity handle, so the drawn network round-trips to DWG/DXF and is analyzable +// directly. use std::collections::HashMap; +use acadrust::entities::LwPolyline; use acadrust::xdata::{ExtendedDataRecord, XDataValue}; use acadrust::{EntityType, Handle}; +use stormsewer::catchment::{catchment_tc_minutes, default_flow_length_ft, polygon_centroid, shoelace_area_sqft, sqft_to_acres}; use stormsewer::network::{Network, Node, NodeKind, Pipe}; +/// A storm network reconstructed from drawing entities, with entity handles +/// for round-tripping sizing edits back to the document. +#[derive(Debug)] +pub struct DrawnNetwork { + pub network: Network, + /// Structure entity handles, same order as `network.nodes`. + pub node_handles: Vec, + /// Pipe entity handles, same order as `network.pipes`. + pub pipe_handles: Vec, +} + pub const APP_STRUCT: &str = "STORMSEWER_STRUCT"; pub const APP_PIPE: &str = "STORMSEWER_PIPE"; +pub const APP_CATCHMENT: &str = "STORMSEWER_CATCHMENT"; pub fn kind_str(k: NodeKind) -> &'static str { match k { @@ -34,12 +51,25 @@ fn parse_kind(s: &str) -> NodeKind { /// XDATA record for a structure marker. pub fn structure_xdata(kind: NodeKind, invert: f64, rim: f64, area: f64, c: f64) -> ExtendedDataRecord { + structure_xdata_tc(kind, invert, rim, area, c, 10.0) +} + +/// XDATA record for a structure marker including inlet Tc (minutes). +pub fn structure_xdata_tc( + kind: NodeKind, + invert: f64, + rim: f64, + area: f64, + c: f64, + tc_inlet: f64, +) -> ExtendedDataRecord { let mut r = ExtendedDataRecord::new(APP_STRUCT); r.add_value(XDataValue::String(kind_str(kind).to_string())); r.add_value(XDataValue::Real(invert)); r.add_value(XDataValue::Real(rim)); r.add_value(XDataValue::Real(area)); r.add_value(XDataValue::Real(c)); + r.add_value(XDataValue::Real(tc_inlet)); r } @@ -53,6 +83,17 @@ pub fn pipe_xdata(diameter: f64, n: f64, from: Handle, to: Handle) -> ExtendedDa r } +/// XDATA for a catchment drainage polygon. +/// `inlet_handle` = `Handle::NULL` for auto-assign to nearest inlet/junction. +pub fn catchment_xdata(c: f64, flow_length_ft: f64, slope: f64, inlet_handle: Handle) -> ExtendedDataRecord { + let mut r = ExtendedDataRecord::new(APP_CATCHMENT); + r.add_value(XDataValue::Real(c)); + r.add_value(XDataValue::Real(flow_length_ft)); + r.add_value(XDataValue::Real(slope)); + r.add_value(XDataValue::Handle(inlet_handle)); + r +} + fn real(v: &XDataValue) -> Option { if let XDataValue::Real(x) = v { Some(*x) @@ -76,10 +117,12 @@ struct StructRec { rim: f64, area: f64, c: f64, + tc_inlet: f64, x: f64, y: f64, } +#[derive(Clone)] struct PipeRec { diameter: f64, n: f64, @@ -88,6 +131,99 @@ struct PipeRec { length: f64, } +struct CatchmentRec { + c: f64, + flow_length_ft: f64, + slope: f64, + inlet_handle: Option, + area_ac: f64, + centroid: (f64, f64), +} + +/// True when the entity is a tagged storm-sewer structure circle. +pub fn is_structure_entity(e: &EntityType) -> bool { + read_structure(e).is_some() +} + +/// Storm structure resolved from a plan click (center + kind). +#[derive(Clone, Debug)] +pub struct StructurePick { + pub handle: Handle, + pub kind: NodeKind, + pub x: f64, + pub y: f64, +} + +impl StructurePick { + pub fn label(&self) -> &'static str { + match self.kind { + NodeKind::Inlet => "Inlet", + NodeKind::Junction => "Junction", + NodeKind::Outfall => "Outfall", + } + } +} + +/// Nearest storm structure within click tolerance of `(x, y)` (ft). +/// `pick_padding_ft` is added to each marker circle's radius. +pub fn structure_at_point<'a>( + entities: impl Iterator, + x: f64, + y: f64, + pick_padding_ft: f64, + include_outfalls: bool, +) -> Option { + let mut best: Option<(StructurePick, f64)> = None; + for e in entities { + let s = read_structure(e)?; + if !include_outfalls && s.kind == NodeKind::Outfall { + continue; + } + let radius = match e { + EntityType::Circle(c) => c.radius, + _ => 3.0, + }; + let dx = s.x - x; + let dy = s.y - y; + let dist = (dx * dx + dy * dy).sqrt(); + let limit = radius + pick_padding_ft; + if dist <= limit { + if best.as_ref().map(|(_, d)| dist < *d).unwrap_or(true) { + best = Some(( + StructurePick { + handle: s.handle, + kind: s.kind, + x: s.x, + y: s.y, + }, + dist, + )); + } + } + } + best.map(|(p, _)| p) +} + +pub fn nearest_structure_at_point<'a>( + entities: impl Iterator, + x: f64, + y: f64, + pick_padding_ft: f64, + include_outfalls: bool, +) -> Option { + structure_at_point(entities, x, y, pick_padding_ft, include_outfalls).map(|p| p.handle) +} + +/// Nearest inlet/junction only (for catchment drainage targets). +pub fn nearest_drainage_structure_at_point<'a>( + entities: impl Iterator, + x: f64, + y: f64, + pick_padding_ft: f64, +) -> Option { + nearest_structure_at_point(entities, x, y, pick_padding_ft, false) +} + fn read_structure(e: &EntityType) -> Option { let rec = e.common().extended_data.get_record(APP_STRUCT)?; if rec.values.len() < 5 { @@ -101,6 +237,11 @@ fn read_structure(e: &EntityType) -> Option { EntityType::Circle(c) => (c.center.x, c.center.y), _ => return None, }; + let tc_inlet = if rec.values.len() >= 6 { + real(&rec.values[5]).unwrap_or(10.0) + } else { + 10.0 + }; Some(StructRec { handle: e.common().handle, kind, @@ -108,6 +249,7 @@ fn read_structure(e: &EntityType) -> Option { rim: real(&rec.values[2])?, area: real(&rec.values[3])?, c: real(&rec.values[4])?, + tc_inlet, x, y, }) @@ -135,19 +277,233 @@ fn read_pipe(e: &EntityType) -> Option { }) } -/// Build a `stormsewer::Network` from drawn entities. Structures become nodes -/// (named N1, N2, … in encounter order); pipes become links, mapped to nodes -/// by the handles stored in their XDATA. -pub fn network_from_entities<'a>(entities: impl Iterator) -> Result { +fn polyline_vertices(pl: &LwPolyline) -> Vec<(f64, f64)> { + pl.vertices.iter().map(|v| (v.location.x, v.location.y)).collect() +} + +fn read_catchment(e: &EntityType) -> Option { + let EntityType::LwPolyline(pl) = e else { + return None; + }; + if !pl.is_closed || pl.vertices.len() < 3 { + return None; + } + let rec = e.common().extended_data.get_record(APP_CATCHMENT)?; + if rec.values.len() < 4 { + return None; + } + let verts = polyline_vertices(pl); + let area_ac = sqft_to_acres(shoelace_area_sqft(&verts)); + let inlet = handle(&rec.values[3]).filter(|h| !h.is_null()); + Some(CatchmentRec { + c: real(&rec.values[0])?, + flow_length_ft: real(&rec.values[1])?, + slope: real(&rec.values[2])?, + inlet_handle: inlet, + area_ac, + centroid: polygon_centroid(&verts), + }) +} + +/// Replace the diameter on a storm-sewer pipe line entity. +pub fn set_pipe_diameter(e: &mut EntityType, new_dia: f64) -> bool { + let EntityType::Line(_) = e else { + return false; + }; + let xd = &mut e.common_mut().extended_data; + let Some(old) = xd.records().iter().find(|r| r.application_name == APP_PIPE) else { + return false; + }; + if old.values.len() < 4 { + return false; + }; + let Some(n) = real(&old.values[1]) else { + return false; + }; + let Some(from) = handle(&old.values[2]) else { + return false; + }; + let Some(to) = handle(&old.values[3]) else { + return false; + }; + let kept: Vec<_> = xd + .records() + .iter() + .filter(|r| r.application_name != APP_PIPE) + .cloned() + .collect(); + xd.clear(); + for r in kept { + xd.add_record(r); + } + xd.add_record(pipe_xdata(new_dia, n, from, to)); + true +} + +/// Recompute Tc from catchments and write onto structure entities in the document. +pub fn apply_tc_in_document<'a>( + entities: impl Iterator, + entities_mut: impl Iterator, +) -> Result { + let drawn = drawn_network_from_entities(entities)?; + let mut tc_by_handle: HashMap = HashMap::new(); + for (node, &h) in drawn.network.nodes.iter().zip(drawn.node_handles.iter()) { + if node.kind != NodeKind::Outfall { + tc_by_handle.insert(h, node.tc_inlet); + } + } + let mut updated = 0; + for ent in entities_mut { + let h = ent.common().handle; + let Some(&tc) = tc_by_handle.get(&h) else { + continue; + }; + let EntityType::Circle(_) = ent else { continue }; + let xd = &mut ent.common_mut().extended_data; + let Some(old) = xd.records().iter().find(|r| r.application_name == APP_STRUCT).cloned() else { + continue; + }; + if old.values.len() < 5 { + continue; + } + let kind = match &old.values[0] { + XDataValue::String(s) => parse_kind(s), + _ => continue, + }; + let invert = real(&old.values[1]).unwrap_or(0.0); + let rim = real(&old.values[2]).unwrap_or(0.0); + let area = real(&old.values[3]).unwrap_or(0.0); + let c = real(&old.values[4]).unwrap_or(0.0); + let kept: Vec<_> = xd + .records() + .iter() + .filter(|r| r.application_name != APP_STRUCT) + .cloned() + .collect(); + xd.clear(); + for r in kept { + xd.add_record(r); + } + xd.add_record(structure_xdata_tc(kind, invert, rim, area, c, tc)); + updated += 1; + } + Ok(updated) +} + +/// Write computed inlet Tc back onto structure circle entities (by handle order). +pub fn apply_tc_to_structures(entities: &mut [EntityType], drawn: &DrawnNetwork) -> usize { + let mut updated = 0; + for (node, &h) in drawn.network.nodes.iter().zip(drawn.node_handles.iter()) { + if node.kind == NodeKind::Outfall { + continue; + } + let Some(ent) = entities.iter_mut().find(|e| e.common().handle == h) else { + continue; + }; + let EntityType::Circle(_) = ent else { continue }; + let xd = &mut ent.common_mut().extended_data; + let Some(old) = xd.records().iter().find(|r| r.application_name == APP_STRUCT).cloned() else { + continue; + }; + if old.values.len() < 5 { + continue; + } + let kind = match &old.values[0] { + XDataValue::String(s) => parse_kind(s), + _ => continue, + }; + let invert = real(&old.values[1]).unwrap_or(node.invert); + let rim = real(&old.values[2]).unwrap_or(node.rim); + let area = real(&old.values[3]).unwrap_or(node.area_ac); + let c = real(&old.values[4]).unwrap_or(node.c); + let kept: Vec<_> = xd + .records() + .iter() + .filter(|r| r.application_name != APP_STRUCT) + .cloned() + .collect(); + xd.clear(); + for r in kept { + xd.add_record(r); + } + xd.add_record(structure_xdata_tc(kind, invert, rim, area, c, node.tc_inlet)); + updated += 1; + } + updated +} + +fn nearest_drainage_structure(structs: &[StructRec], point: (f64, f64)) -> Option { + structs + .iter() + .enumerate() + .filter(|(_, s)| s.kind != NodeKind::Outfall) + .min_by(|(_, a), (_, b)| { + let da = (a.x - point.0).powi(2) + (a.y - point.1).powi(2); + let db = (b.x - point.0).powi(2) + (b.y - point.1).powi(2); + da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|(i, _)| i) +} + +fn apply_catchments(structs: &mut [StructRec], catchments: &[CatchmentRec]) { + for cat in catchments { + let target = if let Some(h) = cat.inlet_handle { + structs.iter().position(|s| s.handle == h) + } else { + nearest_drainage_structure(structs, cat.centroid) + }; + let Some(idx) = target else { continue }; + let s = &mut structs[idx]; + let local_ca = s.c * s.area; + let add_ca = cat.c * cat.area_ac; + let total_area = s.area + cat.area_ac; + if total_area > 0.0 { + s.area = total_area; + s.c = (local_ca + add_ca) / total_area; + } + let flow_len = if cat.flow_length_ft > 0.0 { + cat.flow_length_ft + } else { + default_flow_length_ft(cat.centroid, (s.x, s.y)) + }; + let slope = if cat.slope > 0.0 { cat.slope } else { 0.01 }; + let tc = catchment_tc_minutes(flow_len, slope); + s.tc_inlet = s.tc_inlet.max(tc); + } +} + +/// Build a [`DrawnNetwork`] from entities. Structures become nodes (N1, N2, …); +/// pipes become links mapped by structure handles in XDATA. +pub fn drawn_network_from_entities<'a>(entities: impl Iterator) -> Result { let mut structs: Vec = Vec::new(); - let mut pipes_raw: Vec = Vec::new(); + let mut pipes_raw: Vec<(Handle, PipeRec)> = Vec::new(); + let mut catchments: Vec = Vec::new(); for e in entities { if let Some(s) = read_structure(e) { structs.push(s); } else if let Some(p) = read_pipe(e) { - pipes_raw.push(p); + pipes_raw.push((e.common().handle, p)); + } else if let Some(c) = read_catchment(e) { + catchments.push(c); } } + apply_catchments(&mut structs, &catchments); + let network = assemble_network(&structs, &pipes_raw.iter().map(|(_, p)| p.clone()).collect::>())?; + Ok(DrawnNetwork { + network, + node_handles: structs.iter().map(|s| s.handle).collect(), + pipe_handles: pipes_raw.iter().map(|(h, _)| *h).collect(), + }) +} + +/// Build a `stormsewer::Network` from drawn entities. Structures become nodes +/// (named N1, N2, … in encounter order); pipes become links, mapped to nodes +/// by the handles stored in their XDATA. +pub fn network_from_entities<'a>(entities: impl Iterator) -> Result { + Ok(drawn_network_from_entities(entities)?.network) +} + +fn assemble_network(structs: &[StructRec], pipes_raw: &[PipeRec]) -> Result { if structs.is_empty() { return Err("No storm-sewer structures in the drawing — place Inlet/Junction/Outfall first.".into()); } @@ -162,6 +518,7 @@ pub fn network_from_entities<'a>(entities: impl Iterator) NodeKind::Junction => Node::junction(&id, s.invert, s.rim, s.area, s.c), NodeKind::Outfall => Node::outfall(&id, s.invert, s.rim), } + .with_tc_inlet(s.tc_inlet) .at(s.x, s.y); nodes.push(node); } @@ -188,8 +545,9 @@ pub fn network_from_entities<'a>(entities: impl Iterator) #[cfg(test)] mod tests { use super::*; - use acadrust::types::Vector3; - use acadrust::{Circle, Line}; + use acadrust::entities::LwVertex; + use acadrust::types::{Vector2, Vector3}; + use acadrust::{Circle, Line, LwPolyline}; fn structure(h: u64, kind: NodeKind, x: f64, invert: f64) -> EntityType { let mut e = EntityType::Circle(Circle { center: Vector3::new(x, 0.0, 0.0), radius: 3.0, ..Default::default() }); @@ -203,6 +561,26 @@ mod tests { e } + fn catchment_poly(h: u64, c: f64, inlet: u64) -> EntityType { + let mut pl = LwPolyline::default(); + pl.is_closed = true; + pl.vertices = vec![ + LwVertex::new(Vector2::new(40.0, -20.0)), + LwVertex::new(Vector2::new(60.0, -20.0)), + LwVertex::new(Vector2::new(60.0, 20.0)), + LwVertex::new(Vector2::new(40.0, 20.0)), + ]; + let mut e = EntityType::LwPolyline(pl); + e.common_mut().handle = Handle::new(h); + e.common_mut().extended_data.add_record(catchment_xdata( + c, + 2500.0, + 0.02, + if inlet == 0 { Handle::NULL } else { Handle::new(inlet) }, + )); + e + } + #[test] fn reconstructs_network_from_tagged_entities() { let ents = vec![ @@ -219,12 +597,51 @@ mod tests { assert!((net.pipes[0].diameter - 1.5).abs() < 1e-9); } + #[test] + fn catchment_adds_area_and_tc_to_inlet() { + let ents = vec![ + structure(1, NodeKind::Inlet, 0.0, 104.0), + structure(2, NodeKind::Outfall, 100.0, 100.0), + pipe(1, 2, 0.0, 100.0), + catchment_poly(10, 0.8, 1), + ]; + let net = network_from_entities(ents.iter()).unwrap(); + let n1 = &net.nodes[0]; + assert!(n1.area_ac > 1.0, "catchment should add area, got {}", n1.area_ac); + assert!(n1.tc_inlet > 10.0, "Kirpich tc should exceed default 10 min"); + } + #[test] fn errors_when_no_structures() { let ents: Vec = vec![]; assert!(network_from_entities(ents.iter()).is_err()); } + #[test] + fn set_pipe_diameter_updates_xdata() { + let mut e = pipe(1, 2, 0.0, 100.0); + assert!(set_pipe_diameter(&mut e, 2.0)); + let rec = e.common().extended_data.get_record(APP_PIPE).unwrap(); + assert!((real(&rec.values[0]).unwrap() - 2.0).abs() < 1e-9); + } + + #[test] + fn nearest_structure_respects_click_tolerance() { + let ents = vec![ + structure(1, NodeKind::Inlet, 0.0, 104.0), + structure(2, NodeKind::Outfall, 100.0, 100.0), + ]; + assert_eq!( + nearest_structure_at_point(ents.iter(), 1.0, 0.0, 5.0, true), + Some(Handle::new(1)) + ); + assert!(nearest_drainage_structure_at_point(ents.iter(), 100.0, 0.0, 5.0).is_none()); + assert_eq!( + nearest_drainage_structure_at_point(ents.iter(), 1.0, 0.0, 5.0), + Some(Handle::new(1)) + ); + } + #[test] fn dangling_pipe_is_reported() { let ents = vec![ @@ -233,4 +650,4 @@ mod tests { ]; assert!(network_from_entities(ents.iter()).is_err()); } -} +} \ No newline at end of file diff --git a/src/modules/storm_sewer/dispatch.rs b/src/modules/storm_sewer/dispatch.rs new file mode 100644 index 00000000..2bc050e4 --- /dev/null +++ b/src/modules/storm_sewer/dispatch.rs @@ -0,0 +1,172 @@ +// Storm Sewer command handlers — all domain logic lives here, not in `src/plugin/`. + +use crate::command::CadCommand; +use crate::plugin::host::HostSession; + +use super::analysis; +use super::catchment::{apply_tc_from_network, TagCatchment}; +use super::landxml_import; +use super::manifest::PLUGIN_ID; +use super::params_cmd; +use super::sizing; +use super::state::StormTabState; +use super::structures::{PlacePipe, PlaceStructure}; +use super::{data, style}; + +fn tab_params(host: &mut HostSession<'_>) -> stormsewer::params::StormAnalysisParams { + host.ensure_plugin_state(PLUGIN_ID, StormTabState::default) + .params() + .clone() +} + +/// Handle any `SS_*` command. Returns true when consumed. +pub fn handle(host: &mut HostSession<'_>, cmd: &str) -> bool { + if !cmd.starts_with("SS_") { + return false; + } + + match cmd { + "SS_ANALYZE" => { + let params = tab_params(host); + match analysis::analyze_doc(host.entities(), ¶ms) { + Ok((ents, report, analysis)) => { + for e in ents { + let _ = host.add_entity(e); + } + if let Ok(drawn) = data::drawn_network_from_entities(host.entities()) { + host.push_undo("SS_STYLE"); + let (sur, flood) = style::apply_analysis_style(host.entities_mut(), &drawn, &analysis); + if sur > 0 || flood > 0 { + host.set_dirty(); + host.push_info(&format!( + "Styled {sur} surcharged pipe(s), {flood} flooded structure(s)." + )); + } + } + host.bump_geometry(); + host.push_info(&format!("Storm sewer analyzed ({}).", params.summary())); + for line in report.lines() { + host.push_output(line); + } + } + Err(e) => host.push_error(&e), + } + true + } + "SS_REPORT" => { + let params = tab_params(host); + match analysis::report_doc(host.entities(), ¶ms) { + Ok(report) => { + for line in report.lines() { + host.push_output(line); + } + } + Err(e) => host.push_error(&e), + } + true + } + "SS_MULTIRP" => { + let params = tab_params(host); + match analysis::multi_rp_report(host.entities(), ¶ms) { + Ok(report) => { + for line in report.lines() { + host.push_output(line); + } + } + Err(e) => host.push_error(&e), + } + true + } + "SS_PROFILE" => { + let params = tab_params(host); + match analysis::profile_doc(host.entities(), ¶ms) { + Ok(ents) => { + for e in ents { + let _ = host.add_entity(e); + } + host.bump_geometry(); + host.push_info("Storm sewer HGL profile drawn."); + } + Err(e) => host.push_error(&e), + } + true + } + "SS_SIZE" => { + let params = tab_params(host); + match sizing::plan_size_updates(host.entities(), ¶ms) { + Ok((updates, report, pending)) => { + for line in report.lines() { + host.push_output(line); + } + if pending == 0 { + host.push_info("Storm sewer: all pipes already meet sizing criteria."); + } else { + host.push_undo("SS_SIZE"); + let applied = sizing::apply_updates(host.entities_mut(), &updates); + host.bump_geometry(); + host.set_dirty(); + host.push_info(&format!("Storm sewer: applied {applied} pipe diameter update(s).")); + } + } + Err(e) => host.push_error(&e), + } + true + } + "SS_INLET" | "SS_JUNCTION" | "SS_OUTFALL" => { + let c = match cmd { + "SS_INLET" => PlaceStructure::inlet(), + "SS_JUNCTION" => PlaceStructure::junction(), + _ => PlaceStructure::outfall(), + }; + host.push_info(&c.prompt()); + host.set_active_command(Box::new(c)); + true + } + "SS_PIPE" => { + let c = PlacePipe::new(); + host.push_info(&c.prompt()); + host.set_active_command(Box::new(c)); + true + } + "SS_CATCHMENT" => { + let c = TagCatchment::new(); + host.push_info(&c.prompt()); + host.set_active_command(Box::new(c)); + true + } + "SS_APPLYTC" => { + host.push_undo("SS_APPLYTC"); + let snapshot: Vec<_> = host.entities().cloned().collect(); + match apply_tc_from_network(snapshot.iter(), host.entities_mut()) { + Ok(n) => { + host.set_dirty(); + host.bump_geometry(); + host.push_info(&format!("Storm sewer: updated inlet Tc on {n} structure(s).")); + } + Err(e) => host.push_error(&e), + } + true + } + "SS_LANDXML" | "SS_IMPORTXML" => { + match landxml_import::pick_landxml_file() { + None => host.push_info("LandXML import cancelled."), + Some(Ok(xml)) => match landxml_import::import_landxml(host, &xml) { + Ok(msg) => host.push_info(&msg), + Err(e) => host.push_error(&e), + }, + Some(Err(e)) => host.push_error(&e), + } + true + } + cmd if cmd == "SS_PARAMS" || cmd.starts_with("SS_PARAMS ") => { + let rest = cmd.trim_start_matches("SS_PARAMS").trim(); + let state = host.ensure_plugin_state(PLUGIN_ID, StormTabState::default); + match params_cmd::apply_params(state, rest) { + Ok(msg) => host.push_info(&msg), + Err(e) => host.push_error(&e), + } + true + } + _ => false, + } +} \ No newline at end of file diff --git a/src/modules/storm_sewer/headless.rs b/src/modules/storm_sewer/headless.rs new file mode 100644 index 00000000..44d56e8c --- /dev/null +++ b/src/modules/storm_sewer/headless.rs @@ -0,0 +1,286 @@ +//! Headless tests for storm-sewer UX: structure picking, C3D-style acquisition, +//! catchment/pipe command flows (no GUI, no file I/O). + +#[cfg(test)] +mod tests { + use acadrust::entities::LwVertex; + use acadrust::types::{Vector2, Vector3}; + use acadrust::xdata::XDataValue; + use acadrust::{Circle, EntityType, Handle, Line, LwPolyline}; + use glam::Vec3; + + use stormsewer::network::NodeKind; + + use crate::command::{CadCommand, CmdResult}; + use crate::modules::storm_sewer::catchment::TagCatchment; + use crate::modules::storm_sewer::data::{ + self, catchment_xdata, structure_at_point, structure_xdata, APP_CATCHMENT, APP_PIPE, + }; + use crate::modules::storm_sewer::preview; + use crate::modules::storm_sewer::structures::PlacePipe; + use crate::scene::{Scene, WireModel}; + use crate::snap::{SnapType, ALL_SNAP_MODES}; + + fn structure(h: u64, kind: NodeKind, x: f64, y: f64, radius: f64) -> EntityType { + let mut e = EntityType::Circle(Circle { + center: Vector3::new(x, y, 0.0), + radius, + ..Default::default() + }); + e.common_mut().handle = Handle::new(h); + e.common_mut() + .extended_data + .add_record(structure_xdata(kind, 100.0, 106.0, 1.0, 0.7)); + e + } + + fn closed_poly(h: u64) -> EntityType { + let mut pl = LwPolyline::default(); + pl.is_closed = true; + pl.vertices = vec![ + LwVertex::new(Vector2::new(0.0, 0.0)), + LwVertex::new(Vector2::new(100.0, 0.0)), + LwVertex::new(Vector2::new(100.0, 100.0)), + LwVertex::new(Vector2::new(0.0, 100.0)), + ]; + let mut e = EntityType::LwPolyline(pl); + e.common_mut().handle = Handle::new(h); + e + } + + fn advance_catchment_to_inlet(cmd: &mut TagCatchment, poly: EntityType) { + cmd.inject_picked_entity(poly); + let _ = cmd.on_entity_pick(Handle::new(1), Vec3::ZERO); + let _ = cmd.on_enter(); // RunoffC -> FlowLength + let _ = cmd.on_enter(); // FlowLength -> Slope + let _ = cmd.on_enter(); // Slope -> PickInlet + } + + // ── Structure resolution (point pick engine) ──────────────────────────── + + #[test] + fn structure_at_point_returns_kind_label_and_center() { + let ents = vec![structure(1, NodeKind::Junction, 50.0, 25.0, 4.0)]; + let pick = structure_at_point(ents.iter(), 50.0, 25.0, 5.0, true).unwrap(); + assert_eq!(pick.handle, Handle::new(1)); + assert_eq!(pick.label(), "Junction"); + assert!((pick.x - 50.0).abs() < 1e-9); + assert!((pick.y - 25.0).abs() < 1e-9); + } + + #[test] + fn structure_at_point_prefers_nearest_marker() { + let ents = vec![ + structure(1, NodeKind::Inlet, 0.0, 0.0, 3.0), + structure(2, NodeKind::Inlet, 40.0, 0.0, 3.0), + ]; + let pick = structure_at_point(ents.iter(), 38.0, 0.0, 5.0, true).unwrap(); + assert_eq!(pick.handle, Handle::new(2)); + } + + #[test] + fn catchment_pick_excludes_outfall() { + let ents = vec![structure(9, NodeKind::Outfall, 0.0, 0.0, 6.0)]; + assert!(structure_at_point(ents.iter(), 0.0, 0.0, 20.0, false).is_none()); + assert!(data::nearest_drainage_structure_at_point(ents.iter(), 0.0, 0.0, 20.0).is_none()); + } + + #[test] + fn pipe_pick_includes_outfall() { + let ents = vec![structure(9, NodeKind::Outfall, 0.0, 0.0, 6.0)]; + let pick = structure_at_point(ents.iter(), 0.0, 0.0, 20.0, true).unwrap(); + assert_eq!(pick.label(), "Outfall"); + } + + // ── C3D acquisition constants ─────────────────────────────────────────── + + #[test] + fn object_pick_not_in_osnap_palette() { + assert!(!ALL_SNAP_MODES.iter().any(|(t, _, _)| *t == SnapType::ObjectPick)); + } + + #[test] + fn pick_highlight_color_is_orange() { + assert!(WireModel::PICK_HIGHLIGHT[0] > 0.9); + assert!(WireModel::PICK_HIGHLIGHT[1] > 0.4); + assert!(WireModel::PICK_HIGHLIGHT[2] < 0.2); + } + + #[test] + fn pipe_rubber_band_connects_start_to_cursor() { + let w = preview::pipe_run_rubber_band(10.0, 20.0, Vec3::new(50.0, 60.0, 0.0)); + assert_eq!(w.points.len(), 2); + assert!((w.points[0][0] - 10.0).abs() < 1e-6); + assert!((w.points[1][0] - 50.0).abs() < 1e-6); + assert_eq!(w.color, WireModel::CYAN); + } + + // ── Scene-integrated structure under cursor ───────────────────────────── + + #[test] + fn scene_structure_under_cursor_at_marker_center() { + let mut scene = Scene::new(); + let ent = structure(1, NodeKind::Inlet, 200.0, 150.0, 3.0); + scene.add_entity(ent); + let pick = preview::structure_under_cursor(&scene, 200.0, 150.0, true).unwrap(); + assert_eq!(pick.label(), "Inlet"); + let wires = preview::structure_acquire_previews(&scene, Vec3::new(200.0, 150.0, 0.0), true); + assert!(!wires.is_empty()); + assert_eq!(wires[0].color, WireModel::PICK_HIGHLIGHT); + assert!(wires[0].line_weight_px >= 3.0); + } + + // ── SS_CATCHMENT command flow ─────────────────────────────────────────── + + #[test] + fn catchment_uses_structure_pick_only_on_inlet_step() { + let mut cmd = TagCatchment::new(); + assert!(cmd.needs_entity_pick()); + assert!(!cmd.needs_structure_point_pick()); + + advance_catchment_to_inlet(&mut cmd, closed_poly(10)); + assert!(!cmd.needs_entity_pick()); + assert!(cmd.needs_structure_point_pick()); + assert!(cmd.prompt().contains("orange snap")); + } + + #[test] + fn catchment_prompt_includes_acquisition_hint() { + let mut cmd = TagCatchment::new(); + advance_catchment_to_inlet(&mut cmd, closed_poly(10)); + cmd.set_acquisition_hint(Some("Inlet")); + assert!(cmd.prompt().contains("[Inlet]")); + } + + #[test] + fn catchment_explicit_inlet_writes_xdata_and_ends_command() { + let mut cmd = TagCatchment::new(); + let poly = closed_poly(10); + advance_catchment_to_inlet(&mut cmd, poly); + + match cmd.on_structure_pick(Handle::new(42), Vec3::new(10.0, 10.0, 0.0)) { + CmdResult::ReplaceMany(replacements, additions) => { + assert!(additions.is_empty()); + assert_eq!(replacements.len(), 1); + let (_, ents) = &replacements[0]; + let ent = &ents[0]; + let rec = ent.common().extended_data.get_record(APP_CATCHMENT).unwrap(); + assert!(matches!(&rec.values[3], XDataValue::Handle(h) if *h == Handle::new(42))); + } + other => panic!("expected ReplaceMany, got {:?}", std::mem::discriminant(&other)), + } + } + + #[test] + fn catchment_enter_at_inlet_auto_assigns_nearest() { + let mut cmd = TagCatchment::new(); + advance_catchment_to_inlet(&mut cmd, closed_poly(10)); + + match cmd.on_enter() { + CmdResult::ReplaceMany(replacements, _) => { + let ent = &replacements[0].1[0]; + let rec = ent.common().extended_data.get_record(APP_CATCHMENT).unwrap(); + assert!(matches!(&rec.values[3], XDataValue::Handle(h) if h.is_null())); + } + other => panic!("expected ReplaceMany, got {:?}", std::mem::discriminant(&other)), + } + } + + #[test] + fn catchment_structure_pick_sets_flow_length_when_zero() { + let mut cmd = TagCatchment::new(); + advance_catchment_to_inlet(&mut cmd, closed_poly(10)); + match cmd.on_structure_pick(Handle::new(1), Vec3::new(0.0, 0.0, 0.0)) { + CmdResult::ReplaceMany(replacements, _) => { + let rec = replacements[0].1[0] + .common() + .extended_data + .get_record(APP_CATCHMENT) + .unwrap(); + let flow = match &rec.values[1] { + XDataValue::Real(v) => *v, + _ => 0.0, + }; + // Centroid (50,50) → inlet at (0,0) ≈ 70.7 ft + assert!(flow > 50.0, "expected auto flow length, got {flow}"); + } + other => panic!("expected ReplaceMany, got {:?}", std::mem::discriminant(&other)), + } + } + + // ── SS_PIPE command flow ──────────────────────────────────────────────── + + #[test] + fn pipe_uses_structure_point_pick() { + let cmd = PlacePipe::new(); + assert!(!cmd.needs_entity_pick()); + assert!(cmd.needs_structure_point_pick()); + assert!(cmd.prompt().contains("orange snap")); + } + + #[test] + fn pipe_end_prompt_references_start_structure_label() { + let mut cmd = PlacePipe::new(); + cmd.set_acquisition_hint(Some("Inlet")); + cmd.on_structure_pick(Handle::new(1), Vec3::new(0.0, 0.0, 0.0)); + assert!(cmd.prompt().contains("from Inlet")); + } + + #[test] + fn pipe_commit_links_structures_in_xdata() { + let mut cmd = PlacePipe::new(); + cmd.on_structure_pick(Handle::new(1), Vec3::new(0.0, 0.0, 0.0)); + cmd.on_structure_pick(Handle::new(2), Vec3::new(100.0, 0.0, 0.0)); + let mut cmd = PlacePipe::new(); + cmd.on_structure_pick(Handle::new(1), Vec3::new(0.0, 0.0, 0.0)); + if let CmdResult::CommitAndExit(e) = cmd.on_structure_pick(Handle::new(2), Vec3::new(100.0, 0.0, 0.0)) + { + let rec = e.common().extended_data.get_record(APP_PIPE).unwrap(); + assert!(matches!(&rec.values[2], XDataValue::Handle(h) if *h == Handle::new(1))); + assert!(matches!(&rec.values[3], XDataValue::Handle(h) if *h == Handle::new(2))); + } else { + panic!("second pick should commit pipe"); + } + } + + #[test] + fn pipe_preview_only_on_second_pick() { + let mut cmd = PlacePipe::new(); + assert!(cmd.on_preview_wires(Vec3::new(10.0, 0.0, 0.0)).is_empty()); + cmd.on_structure_pick(Handle::new(1), Vec3::ZERO); + assert_eq!(cmd.on_preview_wires(Vec3::new(10.0, 0.0, 0.0)).len(), 1); + } + + // ── End-to-end: catchment + network assembly ──────────────────────────── + + #[test] + fn explicit_catchment_inlet_feeds_network_analysis() { + let mut ents = vec![ + structure(1, NodeKind::Inlet, 0.0, 0.0, 3.0), + structure(2, NodeKind::Outfall, 100.0, 0.0, 6.0), + EntityType::Line(Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(100.0, 0.0, 0.0), + )), + ]; + ents[2].common_mut().handle = Handle::new(3); + ents[2] + .common_mut() + .extended_data + .add_record(data::pipe_xdata(1.5, 0.013, Handle::new(1), Handle::new(2))); + + let mut poly = closed_poly(10); + poly.common_mut() + .extended_data + .add_record(catchment_xdata(0.8, 2500.0, 0.02, Handle::new(1))); + ents.push(poly); + + let net = data::network_from_entities(ents.iter()).unwrap(); + assert!(net.nodes[0].area_ac > 1.0, "catchment area should merge onto inlet"); + assert!( + net.nodes[0].tc_inlet > 10.0, + "Kirpich tc should exceed default, got {}", + net.nodes[0].tc_inlet + ); + } +} \ No newline at end of file diff --git a/src/modules/storm_sewer/landxml_import.rs b/src/modules/storm_sewer/landxml_import.rs new file mode 100644 index 00000000..d5be6a2b --- /dev/null +++ b/src/modules/storm_sewer/landxml_import.rs @@ -0,0 +1,118 @@ +// LandXML pipe-network import → storm-sewer drawing entities. + +use std::collections::HashMap; + +use acadrust::types::Vector3; +use acadrust::{Circle, EntityType, Handle, Line}; + +use stormsewer::io::landxml::{parse_landxml, LandXmlStruct}; + +use super::data; +use crate::plugin::host::HostSession; + +fn structure_entity(s: &LandXmlStruct, radius: f64) -> EntityType { + let mut e = EntityType::Circle(Circle { + center: Vector3::new(s.x, s.y, 0.0), + radius, + ..Default::default() + }); + let (area, c) = if s.kind == stormsewer::network::NodeKind::Outfall { + (0.0, 0.0) + } else { + (s.area_ac, s.c) + }; + e.common_mut() + .extended_data + .add_record(data::structure_xdata(s.kind, s.invert, s.rim, area, c)); + e +} + +fn pipe_entity(diameter: f64, n: f64, from: Handle, to: Handle, x0: f64, y0: f64, x1: f64, y1: f64) -> EntityType { + let mut e = EntityType::Line(Line::from_points( + Vector3::new(x0, y0, 0.0), + Vector3::new(x1, y1, 0.0), + )); + e.common_mut() + .extended_data + .add_record(data::pipe_xdata(diameter, n, from, to)); + e +} + +/// Open a LandXML file dialog and return the file text. +pub fn pick_landxml_file() -> Option> { + let path = rfd::FileDialog::new() + .add_filter("LandXML", &["xml", "landxml"]) + .set_title("Import LandXML pipe network") + .pick_file()?; + Some(std::fs::read_to_string(&path).map_err(|e| format!("cannot read {}: {e}", path.display()))) +} + +/// Import a LandXML document into the active drawing tab. +pub fn import_landxml(host: &mut HostSession<'_>, xml: &str) -> Result { + let doc = parse_landxml(xml)?; + let net = doc.primary_network()?.clone(); + + if net.structures.is_empty() { + return Err("LandXML: no structures to import".into()); + } + + host.push_undo("SS_LANDXML"); + + let mut name_to_handle: HashMap = HashMap::new(); + let mut coord: HashMap = HashMap::new(); + + for s in &net.structures { + let radius = match s.kind { + stormsewer::network::NodeKind::Outfall => 6.0, + stormsewer::network::NodeKind::Inlet => 3.0, + stormsewer::network::NodeKind::Junction => 4.0, + }; + let ent = structure_entity(s, radius); + let h = host.add_entity(ent); + name_to_handle.insert(s.name.clone(), h); + coord.insert(s.name.clone(), (s.x, s.y)); + } + + let mut pipe_count = 0; + for p in &net.pipes { + let Some(&from_h) = name_to_handle.get(&p.from) else { + continue; + }; + let Some(&to_h) = name_to_handle.get(&p.to) else { + continue; + }; + let (x0, y0) = coord.get(&p.from).copied().unwrap_or((0.0, 0.0)); + let (x1, y1) = coord.get(&p.to).copied().unwrap_or((0.0, 0.0)); + host.add_entity(pipe_entity(p.diameter_ft, p.n, from_h, to_h, x0, y0, x1, y1)); + pipe_count += 1; + } + + if pipe_count == 0 { + return Err("LandXML: structures imported but no pipes could be connected — check StartStruct/EndStruct names.".into()); + } + + host.bump_geometry(); + host.set_dirty(); + + Ok(format!( + "Imported LandXML \"{}\": {} structure(s), {} pipe(s).", + net.name, + net.structures.len(), + pipe_count + )) +} + +#[cfg(test)] +mod tests { + use stormsewer::io::landxml::parse_landxml; + + const SAMPLE: &str = include_str!("../../../crates/stormsewer/examples/sample_landxml.xml"); + + #[test] + fn sample_xml_parses_three_structures() { + let doc = parse_landxml(SAMPLE).unwrap(); + let net = doc.primary_network().unwrap(); + assert_eq!(net.structures.len(), 3); + assert_eq!(net.pipes.len(), 2); + } +} \ No newline at end of file diff --git a/src/modules/storm_sewer/manifest.rs b/src/modules/storm_sewer/manifest.rs new file mode 100644 index 00000000..8b6004f3 --- /dev/null +++ b/src/modules/storm_sewer/manifest.rs @@ -0,0 +1,16 @@ +// Storm Sewer plugin identity (domain-specific; not part of `src/plugin/` runtime). + +use crate::plugin::manifest::{ApiVersion, PluginManifest}; + +pub const PLUGIN_ID: &str = "opencad.storm_sewer"; + +pub static MANIFEST: PluginManifest = PluginManifest { + id: PLUGIN_ID, + name: "Storm Sewer", + version: "0.2.0", + description: "Gravity storm-drain network design and analysis", + api_version: ApiVersion::CURRENT, + ribbon_order: 50, + xdata_apps: &["STORMSEWER_STRUCT", "STORMSEWER_PIPE", "STORMSEWER_CATCHMENT"], + command_prefixes: &["SS_"], +}; \ No newline at end of file diff --git a/src/modules/storm_sewer/mod.rs b/src/modules/storm_sewer/mod.rs index e19699d9..cd623fac 100644 --- a/src/modules/storm_sewer/mod.rs +++ b/src/modules/storm_sewer/mod.rs @@ -1,14 +1,24 @@ -// Storm Sewer module — gravity storm-drain network design & analysis. +// Storm Sewer add-on (`opencad.storm_sewer`) — gravity storm-drain design & analysis. // -// Implements the standard public-domain methods (Rational method, Manning, -// HGL backwater) via the external `stormsewer` engine crate. The ribbon tab -// here is the UI surface; the actual command handlers (place structure, draw -// pipe, run analysis) are dispatched by the host command system — see -// INTEGRATION.md for where each `SS_*` command plugs in. +// Package layout follows `docs/plugin-architecture.md` (QGIS-style add-on). +// Ribbon: `CadModule` here; commands: `dispatch.rs` via `BuiltinPlugin`; engine: `stormsewer` crate. pub mod analysis; +pub mod catchment; pub mod data; +pub mod dispatch; +#[cfg(test)] +mod headless; +pub mod landxml_import; +pub mod manifest; +pub mod params_cmd; +pub mod preview; +pub mod plugin; +pub mod register; +pub mod sizing; +pub mod state; pub mod structures; +pub mod style; use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; @@ -21,7 +31,14 @@ inventory::submit!(crate::command::CommandRegistration { "SS_JUNCTION", "SS_OUTFALL", "SS_PIPE", + "SS_CATCHMENT", + "SS_APPLYTC", + "SS_LANDXML", + "SS_IMPORTXML", "SS_ANALYZE", + "SS_SIZE", + "SS_PARAMS", + "SS_MULTIRP", "SS_REPORT", "SS_PROFILE", ] @@ -32,6 +49,7 @@ const IC_JUNCTION: &[u8] = include_bytes!("icons/junction.svg"); const IC_OUTFALL: &[u8] = include_bytes!("icons/outfall.svg"); const IC_PIPE: &[u8] = include_bytes!("icons/pipe.svg"); const IC_ANALYZE: &[u8] = include_bytes!("icons/analyze.svg"); +const IC_SIZE: &[u8] = include_bytes!("icons/pipe.svg"); const IC_REPORT: &[u8] = include_bytes!("icons/report.svg"); const IC_PROFILE: &[u8] = include_bytes!("icons/profile.svg"); @@ -63,6 +81,8 @@ impl CadModule for StormSewerModule { RibbonItem::LargeTool(tool("SS_JUNCTION", "Junction", IC_JUNCTION)), RibbonItem::LargeTool(tool("SS_OUTFALL", "Outfall", IC_OUTFALL)), RibbonItem::LargeTool(tool("SS_PIPE", "Pipe\nRun", IC_PIPE)), + RibbonItem::Tool(tool("SS_CATCHMENT", "Catchment", IC_INLET)), + RibbonItem::Tool(tool("SS_LANDXML", "Import\nLandXML", IC_PIPE)), ], }, // ── Analysis: run the engine and review results ───────────────── @@ -70,6 +90,10 @@ impl CadModule for StormSewerModule { title: "Analysis", tools: vec![ RibbonItem::LargeTool(tool("SS_ANALYZE", "Analyze", IC_ANALYZE)), + RibbonItem::LargeTool(tool("SS_SIZE", "Size\nPipes", IC_SIZE)), + RibbonItem::Tool(tool("SS_PARAMS", "Params", IC_ANALYZE)), + RibbonItem::Tool(tool("SS_APPLYTC", "Apply Tc", IC_ANALYZE)), + RibbonItem::Tool(tool("SS_MULTIRP", "Multi-RP", IC_REPORT)), RibbonItem::Tool(tool("SS_REPORT", "Report", IC_REPORT)), RibbonItem::Tool(tool("SS_PROFILE", "Profile", IC_PROFILE)), ], @@ -81,11 +105,11 @@ impl CadModule for StormSewerModule { #[cfg(test)] mod tests { use super::*; - use crate::modules::registry; + use crate::plugin::all_ribbon_modules; #[test] fn module_is_registered_in_ribbon() { - let titles: Vec<&str> = registry::all_modules().iter().map(|m| m.title()).collect(); + let titles: Vec<&str> = all_ribbon_modules().iter().map(|m| m.title()).collect(); assert!(titles.contains(&"Storm Sewer"), "ribbon tabs: {titles:?}"); } @@ -99,7 +123,9 @@ mod tests { } } } - for needed in ["SS_INLET", "SS_PIPE", "SS_ANALYZE", "SS_REPORT", "SS_PROFILE"] { + for needed in [ + "SS_INLET", "SS_PIPE", "SS_ANALYZE", "SS_SIZE", "SS_PARAMS", "SS_MULTIRP", "SS_REPORT", "SS_PROFILE", + ] { assert!(ids.contains(&needed), "missing {needed}; have {ids:?}"); } } @@ -187,7 +213,8 @@ PIPE P2 N2 OUT 300 1.5 0.013 pipe, ]; - let (annotations, report) = super::analysis::analyze_doc(ents.iter()).expect("analyze drawn net"); + let p = super::analysis::default_params(); + let (annotations, report, _) = super::analysis::analyze_doc(ents.iter(), &p).expect("analyze drawn net"); assert!(!annotations.is_empty(), "expected flow/HGL labels"); assert!(report.contains("STORM SEWER ANALYSIS"), "report:\n{report}"); } diff --git a/src/modules/storm_sewer/params_cmd.rs b/src/modules/storm_sewer/params_cmd.rs new file mode 100644 index 00000000..6be0d383 --- /dev/null +++ b/src/modules/storm_sewer/params_cmd.rs @@ -0,0 +1,137 @@ +// Parse `SS_PARAMS` subcommands and update StormTabState. + +use stormsewer::idf::IdfCurve; +use stormsewer::params::StormAnalysisParams; + +use super::state::StormTabState; + +fn parse_f64(s: &str) -> Result { + s.trim() + .replace(',', ".") + .parse::() + .map_err(|_| format!("`{s}` is not a number")) +} + +/// Apply `SS_PARAMS …` tokens. Empty rest → show summary. +pub fn apply_params(state: &mut StormTabState, rest: &str) -> Result { + let t: Vec<&str> = rest.split_whitespace().collect(); + if t.is_empty() { + return Ok(format!("Storm params: {}", state.params.summary())); + } + let key = t[0].to_ascii_uppercase(); + match key.as_str() { + "RP" | "RETURN" => { + let rp: u32 = t + .get(1) + .ok_or("SS_PARAMS RP needs return period years (e.g. SS_PARAMS RP 25)")? + .parse() + .map_err(|_| "return period must be an integer year")?; + state.params.idf.set_design_rp(rp); + if state.params.idf.curve(rp).is_none() { + // Seed a scaled curve from the design curve if missing. + let base = state.params.idf.design_curve(); + let scale = (rp as f64 / 10.0).sqrt().max(1.0); + state.params.idf.set_curve(rp, IdfCurve::new(base.a * scale, base.b, base.c)); + } + Ok(format!("Design return period set to {rp} yr.")) + } + "IDF" => { + let (rp, ai) = if t.len() == 5 { + let rp: u32 = t[1].parse().map_err(|_| "IDF return period must be integer")?; + (rp, 2) + } else if t.len() == 4 { + (state.params.idf.design_rp, 1) + } else { + return Err("SS_PARAMS IDF [rp] (e.g. SS_PARAMS IDF 60 10 0.8)".into()); + }; + let a = parse_f64(t[ai])?; + let b = parse_f64(t[ai + 1])?; + let c = parse_f64(t[ai + 2])?; + state.params.idf.set_curve(rp, IdfCurve::new(a, b, c)); + state.params.idf.set_design_rp(rp); + Ok(format!("IDF for {rp}-yr set: i = {a}/(t+{b})^{c}")) + } + "TAILWATER" | "TW" => { + let v = t.get(1).map(|s| s.to_ascii_uppercase()); + match v.as_deref() { + None => Err("SS_PARAMS TAILWATER | NONE".into()), + Some("NONE" | "FREE") => { + state.params.hydraulics.tailwater = None; + Ok("Tailwater: free outfall.".into()) + } + Some(s) => { + let elev = parse_f64(s)?; + state.params.hydraulics.tailwater = Some(elev); + Ok(format!("Tailwater elevation set to {elev:.2} ft.")) + } + } + } + "MINTC" => { + let v = parse_f64(t.get(1).ok_or("SS_PARAMS MINTC ")?)?; + state.params.hydraulics.min_tc = v; + Ok(format!("Minimum Tc set to {v:.1} min.")) + } + "JUNCTIONK" | "JK" => { + let v = parse_f64(t.get(1).ok_or("SS_PARAMS JUNCTIONK ")?)?; + state.params.hydraulics.junction_k = v; + Ok(format!("Junction loss K set to {v:.2}.")) + } + "VMIN" => { + let v = parse_f64(t.get(1).ok_or("SS_PARAMS VMIN ")?)?; + state.params.sizing.min_velocity = v; + Ok(format!("Minimum velocity set to {v:.2} ft/s.")) + } + "VMAX" => { + let v = parse_f64(t.get(1).ok_or("SS_PARAMS VMAX ")?)?; + state.params.sizing.max_velocity = v; + Ok(format!("Maximum velocity set to {v:.2} ft/s.")) + } + "MAXFULL" | "PFULL" => { + let v = parse_f64(t.get(1).ok_or("SS_PARAMS MAXFULL ")?)?; + state.params.sizing.max_pct_full = (v / 100.0).clamp(0.1, 1.0); + Ok(format!("Max % full set to {v:.0}%.")) + } + "INLETLEN" | "GRATE" => { + let v = parse_f64(t.get(1).ok_or("SS_PARAMS INLETLEN ")?)?; + state.params.inlet_grate_length_ft = v; + Ok(format!("Inlet grate length set to {v:.2} ft.")) + } + "INLETD" | "CURBDEPTH" => { + let v = parse_f64(t.get(1).ok_or("SS_PARAMS INLETD ")?)?; + state.params.inlet_flow_depth_ft = v; + Ok(format!("Inlet curb flow depth set to {v:.3} ft.")) + } + "INLETS" | "GUTTERS" => { + let v = parse_f64(t.get(1).ok_or("SS_PARAMS INLETS ")?)?; + state.params.inlet_gutter_slope = v; + Ok(format!("Inlet gutter slope set to {v:.4} ft/ft.")) + } + "RESET" => { + state.params = StormAnalysisParams::municipal(); + Ok("Storm params reset to municipal defaults.".into()) + } + _ => Err(format!( + "Unknown SS_PARAMS key `{key}`. Keys: RP, IDF, TAILWATER, MINTC, JUNCTIONK, VMIN, VMAX, MAXFULL, INLETLEN, INLETD, INLETS, RESET" + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sets_return_period() { + let mut s = StormTabState::default(); + apply_params(&mut s, "RP 25").unwrap(); + assert_eq!(s.params.idf.design_rp, 25); + } + + #[test] + fn sets_idf_coefficients() { + let mut s = StormTabState::default(); + apply_params(&mut s, "IDF 70 12 0.75").unwrap(); + let c = s.params.idf.design_curve(); + assert!((c.a - 70.0).abs() < 1e-9); + } +} \ No newline at end of file diff --git a/src/modules/storm_sewer/plugin.rs b/src/modules/storm_sewer/plugin.rs new file mode 100644 index 00000000..a6bff962 --- /dev/null +++ b/src/modules/storm_sewer/plugin.rs @@ -0,0 +1,24 @@ +// Thin adapter: implements the generic `BuiltinPlugin` trait for Storm Sewer. +// Domain logic is in `dispatch.rs`; identity in `manifest.rs`; hook in `register.rs`. + +use crate::plugin::host::{BuiltinPlugin, HostSession}; +use crate::plugin::manifest::PluginManifest; + +use super::dispatch; +use super::manifest; + +pub struct StormSewerPlugin; + +impl BuiltinPlugin for StormSewerPlugin { + fn manifest(&self) -> &'static PluginManifest { + &manifest::MANIFEST + } + + fn ribbon(&self) -> Box { + Box::new(super::StormSewerModule) + } + + fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool { + dispatch::handle(host, cmd) + } +} \ No newline at end of file diff --git a/src/modules/storm_sewer/plugin.toml b/src/modules/storm_sewer/plugin.toml new file mode 100644 index 00000000..c97256d3 --- /dev/null +++ b/src/modules/storm_sewer/plugin.toml @@ -0,0 +1,16 @@ +# Storm Sewer add-on metadata. +# Must stay in sync with manifest.rs (compile-time source of truth for the host). + +[plugin] +id = "opencad.storm_sewer" +name = "Storm Sewer" +version = "0.2.0" +description = "Gravity storm-drain network design and analysis" +author = "Open CAD Studio contributors" +license = "GPL-3.0-only" + +[opencad] +api_version = 1 +ribbon_order = 50 +command_prefixes = ["SS_"] +xdata_apps = ["STORMSEWER_STRUCT", "STORMSEWER_PIPE", "STORMSEWER_CATCHMENT"] \ No newline at end of file diff --git a/src/modules/storm_sewer/preview.rs b/src/modules/storm_sewer/preview.rs new file mode 100644 index 00000000..3fcea801 --- /dev/null +++ b/src/modules/storm_sewer/preview.rs @@ -0,0 +1,96 @@ +// C3D-style acquisition previews: orange structure highlight, pipe rubber-band. + +use acadrust::{EntityType, Handle}; +use glam::Vec3; + +use crate::scene::{Scene, WireModel}; + +use super::data::{self, StructurePick}; + +pub const STRUCTURE_PICK_PAD_FT: f64 = 15.0; + +/// Orange highlight wires for a structure under the cursor. +pub fn highlight_structure(scene: &Scene, pick: &StructurePick) -> Vec { + highlight_handles(scene, &[pick.handle], WireModel::PICK_HIGHLIGHT, 3.0) +} + +/// Highlight any entity (e.g. catchment polyline) in acquisition color. +pub fn highlight_entity(scene: &Scene, handle: Handle) -> Vec { + highlight_handles(scene, &[handle], WireModel::PICK_HIGHLIGHT, 2.5) +} + +/// Dim orange fill on closed catchment while hovering. +pub fn highlight_catchment_poly(scene: &Scene, handle: Handle) -> Vec { + let mut wires = highlight_handles(scene, &[handle], WireModel::PICK_HIGHLIGHT_DIM, 2.0); + for w in &mut wires { + w.line_weight_px = 2.0; + } + wires +} + +pub fn highlight_handles( + scene: &Scene, + handles: &[Handle], + color: [f32; 4], + line_weight_px: f32, +) -> Vec { + let mut out = scene.wire_models_for(handles); + for w in &mut out { + w.color = color; + w.line_weight_px = line_weight_px; + } + out +} + +/// Resolve the structure under the cursor for storm network commands. +pub fn structure_under_cursor( + scene: &Scene, + x: f64, + y: f64, + catchment_inlet_only: bool, +) -> Option { + data::structure_at_point( + scene.document.entities(), + x, + y, + STRUCTURE_PICK_PAD_FT, + !catchment_inlet_only, + ) +} + +/// Cyan rubber-band from a fixed structure center to the cursor (pipe run preview). +pub fn pipe_run_rubber_band(from_x: f64, from_y: f64, to: Vec3) -> WireModel { + WireModel::solid( + "__ss_pipe_preview__".into(), + vec![ + [from_x as f32, from_y as f32, 0.0], + [to.x, to.y, to.z], + ], + WireModel::CYAN, + false, + ) +} + +/// Extra preview wires for storm-sewer structure acquisition at `cursor`. +pub fn structure_acquire_previews( + scene: &Scene, + cursor: Vec3, + catchment_inlet_only: bool, +) -> Vec { + let Some(pick) = structure_under_cursor(scene, cursor.x as f64, cursor.y as f64, catchment_inlet_only) + else { + return vec![]; + }; + highlight_structure(scene, &pick) +} + +/// Closed polyline under cursor (catchment step 1). +pub fn catchment_poly_under_cursor(scene: &Scene, handle: Handle) -> Vec { + let Some(ent) = scene.document.get_entity(handle) else { + return vec![]; + }; + if !matches!(ent, EntityType::LwPolyline(pl) if pl.is_closed) { + return vec![]; + } + highlight_catchment_poly(scene, handle) +} \ No newline at end of file diff --git a/src/modules/storm_sewer/register.rs b/src/modules/storm_sewer/register.rs new file mode 100644 index 00000000..4e899f89 --- /dev/null +++ b/src/modules/storm_sewer/register.rs @@ -0,0 +1,10 @@ +// Compile-time registration with the generic plugin host. +// Keep this file free of storm-sewer logic — only the hook. + +use super::plugin::StormSewerPlugin; + +inventory::submit! { + crate::plugin::registry::PluginRegistration { + construct: || Box::new(StormSewerPlugin), + } +} \ No newline at end of file diff --git a/src/modules/storm_sewer/sizing.rs b/src/modules/storm_sewer/sizing.rs new file mode 100644 index 00000000..0ff3a012 --- /dev/null +++ b/src/modules/storm_sewer/sizing.rs @@ -0,0 +1,132 @@ +// Apply storm-sewer pipe sizing to the drawn network in the active document. + +use acadrust::{EntityType, Handle}; + +use stormsewer::design::SizeOutcome; +use stormsewer::params::StormAnalysisParams; +use stormsewer::report::format_sizing; + +use super::data; + +/// A pipe diameter change to apply in the document. +#[derive(Clone, Debug, PartialEq)] +pub struct PipeDiameterUpdate { + pub handle: Handle, + pub new_diameter_ft: f64, +} + +/// Size pipes from drawn entities without modifying the document. +pub fn size_doc_report<'a>( + entities: impl Iterator, + params: &StormAnalysisParams, +) -> Result { + let (_, report, _) = plan_size_updates(entities, params)?; + Ok(report) +} + +/// Compute sizing recommendations and the list of pipe updates to apply. +pub fn plan_size_updates<'a>( + entities: impl Iterator, + params: &StormAnalysisParams, +) -> Result<(Vec, String, usize), String> { + let drawn = data::drawn_network_from_entities(entities)?; + let (_, recs) = drawn + .network + .analyze_and_size_params(params) + .map_err(|e| e.to_string())?; + + let mut updates = Vec::new(); + for (handle, rec) in drawn.pipe_handles.iter().zip(recs.iter()) { + if rec.outcome == SizeOutcome::NoSolution { + continue; + } + if (rec.recommended_diameter_ft - rec.current_diameter_ft).abs() < 1e-6 { + continue; + } + updates.push(PipeDiameterUpdate { + handle: *handle, + new_diameter_ft: rec.recommended_diameter_ft, + }); + } + + let report = format_sizing(&recs); + let pending = updates.len(); + Ok((updates, report, pending)) +} + +/// Write planned diameter updates onto matching pipe entities. +pub fn apply_updates<'a>(entities: impl Iterator, updates: &[PipeDiameterUpdate]) -> usize { + let mut applied = 0usize; + for e in entities { + let h = e.common().handle; + if let Some(u) = updates.iter().find(|u| u.handle == h) { + if data::set_pipe_diameter(e, u.new_diameter_ft) { + applied += 1; + } + } + } + applied +} + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::types::Vector3; + use acadrust::{Circle, Line}; + use stormsewer::network::NodeKind; + + fn mk_struct(h: u64, kind: NodeKind, x: f64, invert: f64) -> EntityType { + let mut e = EntityType::Circle(Circle { + center: Vector3::new(x, 0.0, 0.0), + radius: 3.0, + ..Default::default() + }); + e.common_mut().handle = Handle::new(h); + e.common_mut() + .extended_data + .add_record(data::structure_xdata(kind, invert, invert + 6.0, 2.0, 0.7)); + e + } + + fn mk_pipe(from: u64, to: u64, x1: f64, x2: f64, dia: f64) -> EntityType { + let mut e = EntityType::Line(Line::from_points( + Vector3::new(x1, 0.0, 0.0), + Vector3::new(x2, 0.0, 0.0), + )); + e.common_mut().handle = Handle::new(from + 100); + e.common_mut() + .extended_data + .add_record(data::pipe_xdata(dia, 0.013, Handle::new(from), Handle::new(to))); + e + } + + #[test] + fn plan_size_finds_undersized_trunk() { + let ents = vec![ + mk_struct(1, NodeKind::Inlet, 0.0, 100.0), + mk_struct(2, NodeKind::Inlet, 100.0, 99.0), + mk_struct(3, NodeKind::Outfall, 200.0, 98.0), + mk_pipe(1, 2, 0.0, 100.0, 1.5), + mk_pipe(2, 3, 100.0, 200.0, 1.5), + ]; + let p = stormsewer::params::StormAnalysisParams::municipal(); + let (updates, report, pending) = plan_size_updates(ents.iter(), &p).expect("size"); + assert!(pending >= 1, "report:\n{report}"); + assert!(!updates.is_empty()); + } + + #[test] + fn apply_updates_writes_xdata() { + let mut ents = vec![ + mk_struct(1, NodeKind::Inlet, 0.0, 100.0), + mk_struct(2, NodeKind::Outfall, 200.0, 98.0), + mk_pipe(1, 2, 0.0, 200.0, 0.5), + ]; + let p = stormsewer::params::StormAnalysisParams::municipal(); + let (updates, _, _) = plan_size_updates(ents.iter(), &p).expect("plan"); + let applied = apply_updates(ents.iter_mut(), &updates); + assert!(applied >= 1); + let drawn = data::drawn_network_from_entities(ents.iter()).unwrap(); + assert!(drawn.network.pipes[0].diameter > 0.5); + } +} \ No newline at end of file diff --git a/src/modules/storm_sewer/state.rs b/src/modules/storm_sewer/state.rs new file mode 100644 index 00000000..7efd572c --- /dev/null +++ b/src/modules/storm_sewer/state.rs @@ -0,0 +1,26 @@ +// Per-document storm-sewer parameters (stored in host tab plugin_state). + +use stormsewer::params::StormAnalysisParams; + +#[derive(Clone, Debug, PartialEq)] +pub struct StormTabState { + pub params: StormAnalysisParams, +} + +impl Default for StormTabState { + fn default() -> Self { + Self { + params: StormAnalysisParams::municipal(), + } + } +} + +impl StormTabState { + pub fn params(&self) -> &StormAnalysisParams { + &self.params + } + + pub fn params_mut(&mut self) -> &mut StormAnalysisParams { + &mut self.params + } +} \ No newline at end of file diff --git a/src/modules/storm_sewer/structures.rs b/src/modules/storm_sewer/structures.rs index 2d9b0748..419fbe49 100644 --- a/src/modules/storm_sewer/structures.rs +++ b/src/modules/storm_sewer/structures.rs @@ -15,7 +15,9 @@ use glam::Vec3; use stormsewer::network::NodeKind; use super::data; -use crate::command::{CadCommand, CmdResult}; +use super::preview; +use crate::command::{CadCommand, CmdResult, ObjectPickHit}; +use crate::scene::{Scene, WireModel}; fn parse_num(text: &str) -> Option { text.trim().replace(',', ".").parse::().ok() @@ -113,8 +115,6 @@ impl CadCommand for PlaceStructure { None } fn on_point(&mut self, pt: Vec3) -> CmdResult { - // A click always places the structure, using whatever values have been - // entered (remaining fields keep their defaults). self.commit(pt.x as f64, pt.y as f64) } fn on_enter(&mut self) -> CmdResult { @@ -135,11 +135,21 @@ pub struct PlacePipe { n: f64, start_handle: Option, start_xy: (f64, f64), + start_label: Option, + acquire_hint: Option, } impl PlacePipe { pub fn new() -> Self { - Self { step: PStep::PickStart, diameter: 1.25, n: 0.013, start_handle: None, start_xy: (0.0, 0.0) } + Self { + step: PStep::PickStart, + diameter: 1.25, + n: 0.013, + start_handle: None, + start_xy: (0.0, 0.0), + start_label: None, + acquire_hint: None, + } } fn commit(&self, end_handle: Handle, ex: f64, ey: f64) -> CmdResult { let line = Line::from_points( @@ -164,25 +174,69 @@ impl CadCommand for PlacePipe { "SS_PIPE" } fn prompt(&self) -> String { + let hint = self + .acquire_hint + .as_deref() + .map(|h| format!(" [{h}]")) + .unwrap_or_default(); match self.step { - PStep::PickStart => "Pipe: click the START structure:".into(), - PStep::PickEnd => format!("Pipe: click the END structure (dia {:.2} ft, n {:.3}):", self.diameter, self.n), + PStep::PickStart => format!("Pipe run: click START structure (orange snap){hint}:"), + PStep::PickEnd => { + let from = self + .start_label + .as_deref() + .unwrap_or("structure"); + format!( + "Pipe run: click END from {from} (dia {:.2} ft, n {:.3}){hint}:", + self.diameter, self.n + ) + } } } fn needs_entity_pick(&self) -> bool { + false + } + fn needs_structure_point_pick(&self) -> bool { true } - fn on_entity_pick(&mut self, handle: Handle, pt: Vec3) -> CmdResult { + fn resolve_object_pick(&self, scene: &Scene, x: f64, y: f64) -> Option { + let pick = preview::structure_under_cursor(scene, x, y, false)?; + Some(ObjectPickHit { + handle: pick.handle, + x: pick.x, + y: pick.y, + label: pick.label(), + }) + } + fn object_pick_hover_previews(&self, scene: &Scene, cursor: Vec3) -> Vec { + preview::structure_acquire_previews(scene, cursor, false) + } + fn object_pick_miss_message(&self) -> &'static str { + "No storm structure near click — move closer or press Enter for nearest." + } + fn set_acquisition_hint(&mut self, hint: Option<&str>) { + self.acquire_hint = hint.map(str::to_string); + } + fn on_structure_pick(&mut self, handle: Handle, pt: Vec3) -> CmdResult { match self.step { PStep::PickStart => { self.start_handle = Some(handle); self.start_xy = (pt.x as f64, pt.y as f64); + self.start_label = self.acquire_hint.clone(); + self.acquire_hint = None; self.step = PStep::PickEnd; CmdResult::NeedPoint } PStep::PickEnd => self.commit(handle, pt.x as f64, pt.y as f64), } } + fn on_preview_wires(&mut self, pt: Vec3) -> Vec { + if matches!(self.step, PStep::PickEnd) { + vec![preview::pipe_run_rubber_band(self.start_xy.0, self.start_xy.1, pt)] + } else { + vec![] + } + } fn on_point(&mut self, _pt: Vec3) -> CmdResult { CmdResult::NeedPoint } @@ -197,7 +251,6 @@ mod tests { #[test] fn click_places_structure_with_defaults() { - // A click commits immediately (no typed values needed). let mut cmd = PlaceStructure::inlet(); match cmd.on_point(Vec3::new(10.0, 20.0, 0.0)) { CmdResult::CommitAndExit(EntityType::Circle(c)) => { @@ -212,20 +265,19 @@ mod tests { #[test] fn typed_values_are_captured_then_click_places() { let mut cmd = PlaceStructure::inlet(); - assert!(cmd.on_text_input("104").is_none()); // invert -> rim - assert!(cmd.on_text_input("110").is_none()); // rim -> area - assert!(cmd.on_text_input("2.0").is_none()); // area -> C - assert!(cmd.on_text_input("0.8").is_none()); // C -> Ready - assert!(matches!(cmd.step, SStep::Ready)); + assert!(cmd.on_text_input("104").is_none()); + assert!(cmd.on_text_input("110").is_none()); + assert!(cmd.on_text_input("2.0").is_none()); + assert!(cmd.on_text_input("0.8").is_none()); assert!(matches!(cmd.on_point(Vec3::ZERO), CmdResult::CommitAndExit(_))); } #[test] fn pipe_connects_two_structures_on_two_clicks() { let mut cmd = PlacePipe::new(); - assert!(cmd.needs_entity_pick()); - assert!(matches!(cmd.on_entity_pick(Handle::new(1), Vec3::new(0.0, 0.0, 0.0)), CmdResult::NeedPoint)); - match cmd.on_entity_pick(Handle::new(2), Vec3::new(100.0, 0.0, 0.0)) { + assert!(cmd.needs_structure_point_pick()); + assert!(matches!(cmd.on_structure_pick(Handle::new(1), Vec3::new(0.0, 0.0, 0.0)), CmdResult::NeedPoint)); + match cmd.on_structure_pick(Handle::new(2), Vec3::new(100.0, 0.0, 0.0)) { CmdResult::CommitAndExit(EntityType::Line(l)) => { assert_eq!(l.start.x, 0.0); assert_eq!(l.end.x, 100.0); @@ -235,4 +287,13 @@ mod tests { _ => panic!("expected CommitAndExit(Line)"), } } -} + + #[test] + fn pipe_preview_rubber_band_on_second_step() { + let mut cmd = PlacePipe::new(); + cmd.on_structure_pick(Handle::new(1), Vec3::new(0.0, 0.0, 0.0)); + let wires = cmd.on_preview_wires(Vec3::new(50.0, 0.0, 0.0)); + assert_eq!(wires.len(), 1); + assert_eq!(wires[0].points.len(), 2); + } +} \ No newline at end of file diff --git a/src/modules/storm_sewer/style.rs b/src/modules/storm_sewer/style.rs new file mode 100644 index 00000000..a1ebd361 --- /dev/null +++ b/src/modules/storm_sewer/style.rs @@ -0,0 +1,61 @@ +// Visual feedback for analysis results (surcharged pipes, flooded structures). + +use acadrust::types::Color; +use acadrust::{EntityType, Handle}; + +use stormsewer::network::Analysis; + +use super::data::DrawnNetwork; + +fn color_surcharged_pipe() -> Color { + Color::from_index(1) +} + +fn color_flooded_struct() -> Color { + Color::from_index(6) +} + +/// Build handle → color assignments from an analysis result. +pub fn style_assignments(drawn: &DrawnNetwork, analysis: &Analysis) -> Vec<(Handle, Color)> { + let mut out = Vec::new(); + for (handle, pr) in drawn.pipe_handles.iter().zip(analysis.pipes.iter()) { + if pr.surcharged { + out.push((*handle, color_surcharged_pipe())); + } + } + for (handle, nr) in drawn.node_handles.iter().zip(analysis.nodes.iter()) { + if nr.surcharge_to_surface { + out.push((*handle, color_flooded_struct())); + } + } + out +} + +/// Apply handle → color assignments to drawing entities. +pub fn apply_colors<'a>( + entities: impl Iterator, + assignments: &[(Handle, Color)], +) -> usize { + let mut applied = 0usize; + for e in entities { + let h = e.common().handle; + if let Some((_, color)) = assignments.iter().find(|(handle, _)| *handle == h) { + e.common_mut().color = *color; + applied += 1; + } + } + applied +} + +/// Color-code pipes and structures from an analysis result. +pub fn apply_analysis_style<'a>( + entities: impl Iterator, + drawn: &DrawnNetwork, + analysis: &Analysis, +) -> (usize, usize) { + let assignments = style_assignments(drawn, analysis); + let surcharged = analysis.pipes.iter().filter(|p| p.surcharged).count(); + let flooded = analysis.nodes.iter().filter(|n| n.surcharge_to_surface).count(); + let _ = apply_colors(entities, &assignments); + (surcharged, flooded) +} \ No newline at end of file diff --git a/src/plugin/host.rs b/src/plugin/host.rs new file mode 100644 index 00000000..da1c53ad --- /dev/null +++ b/src/plugin/host.rs @@ -0,0 +1,17 @@ +// Plugin traits — HostSession lives in `app::plugin_host` (same-crate field access). + +pub(crate) use crate::app::plugin_host::HostSession; + +use crate::modules::CadModule; + +use super::manifest::PluginManifest; + +/// Add-on package entry point (phase 1: in-tree, in-process). +/// +/// One `PluginRegistration` per package — ribbon tab, manifest, and command +/// dispatch are owned here. See `docs/plugin-architecture.md`. +pub trait BuiltinPlugin: Send + Sync { + fn manifest(&self) -> &'static PluginManifest; + fn ribbon(&self) -> Box; + fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool; +} \ No newline at end of file diff --git a/src/plugin/manifest.rs b/src/plugin/manifest.rs new file mode 100644 index 00000000..58f9bbe6 --- /dev/null +++ b/src/plugin/manifest.rs @@ -0,0 +1,32 @@ +// Plugin identity and capability declaration. + +/// Host plugin API version. Bump when HostApi breaks compatibility. +pub const API_VERSION: u32 = 1; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ApiVersion { + pub major: u32, +} + +impl ApiVersion { + pub const CURRENT: Self = Self { major: API_VERSION }; + + pub fn is_compatible_with(host: ApiVersion) -> bool { + Self::CURRENT.major == host.major + } +} + +/// Static metadata every plugin supplies at registration time. +/// Keep fields in sync with `plugin.toml` beside the package. +#[derive(Clone, Copy, Debug)] +pub struct PluginManifest { + pub id: &'static str, + pub name: &'static str, + pub version: &'static str, + pub description: &'static str, + pub api_version: ApiVersion, + /// Sort key for add-on ribbon tabs (lower = further left among plugins). + pub ribbon_order: i32, + pub xdata_apps: &'static [&'static str], + pub command_prefixes: &'static [&'static str], +} \ No newline at end of file diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs new file mode 100644 index 00000000..158ed223 --- /dev/null +++ b/src/plugin/mod.rs @@ -0,0 +1,13 @@ +// Open CAD Studio plugin runtime (phase 1: built-in, in-process). +// +// Generic host only — no domain logic. See `docs/plugin-architecture.md`. +// Domain plugins (e.g. storm_sewer) live under `src/modules//` and +// register here via `inventory::submit!(PluginRegistration { … })`. + +pub mod host; +pub mod manifest; +pub mod registry; + +pub use registry::all_ribbon_modules; +pub(crate) use host::BuiltinPlugin; +pub(crate) use registry::try_dispatch; \ No newline at end of file diff --git a/src/plugin/registry.rs b/src/plugin/registry.rs new file mode 100644 index 00000000..a2f420a2 --- /dev/null +++ b/src/plugin/registry.rs @@ -0,0 +1,42 @@ +// Compile-time plugin registry via `inventory`. + +use super::host::{BuiltinPlugin, HostSession}; +use crate::app::OpenCADStudio; +use crate::modules::{registry as core_registry, CadModule}; + +pub struct PluginRegistration { + pub construct: fn() -> Box, +} + +inventory::collect!(PluginRegistration); + +/// Construct every registered built-in plugin (once per process). +pub fn all_plugins() -> Vec> { + inventory::iter:: + .into_iter() + .map(|r| (r.construct)()) + .collect() +} + +/// Core ribbon tabs plus add-on tabs (sorted by `manifest.ribbon_order`). +pub fn all_ribbon_modules() -> Vec> { + let mut core = core_registry::all_modules(); + let mut addons: Vec<(i32, Box)> = all_plugins() + .into_iter() + .map(|p| (p.manifest().ribbon_order, p.ribbon())) + .collect(); + addons.sort_by_key(|(order, _)| *order); + core.extend(addons.into_iter().map(|(_, ribbon)| ribbon)); + core +} + +/// Try each plugin until one handles `cmd`. Returns true if handled. +pub(crate) fn try_dispatch(app: &mut OpenCADStudio, tab: usize, cmd: &str) -> bool { + let mut host = HostSession::new(app, tab); + for plugin in all_plugins() { + if plugin.dispatch(&mut host, cmd) { + return true; + } + } + false +} \ No newline at end of file diff --git a/src/scene/wire_model.rs b/src/scene/wire_model.rs index dbb35781..a0abd491 100644 --- a/src/scene/wire_model.rs +++ b/src/scene/wire_model.rs @@ -79,6 +79,9 @@ impl WireModel { pub const WHITE: [f32; 4] = [1.00, 1.00, 1.00, 1.0]; pub const CYAN: [f32; 4] = [0.25, 0.85, 1.00, 1.0]; pub const SELECTED: [f32; 4] = [0.15, 0.55, 1.00, 1.0]; + /// Civil 3D-style object acquisition highlight (structure / catchment hover). + pub const PICK_HIGHLIGHT: [f32; 4] = [0.95, 0.50, 0.08, 1.0]; + pub const PICK_HIGHLIGHT_DIM: [f32; 4] = [0.95, 0.50, 0.08, 0.45]; /// Sentinel AABB that never rejects any snap query. pub const UNBOUNDED_AABB: [f32; 4] = [ f32::NEG_INFINITY, diff --git a/src/snap/mod.rs b/src/snap/mod.rs index 548e4ded..c0348ae4 100644 --- a/src/snap/mod.rs +++ b/src/snap/mod.rs @@ -30,6 +30,8 @@ pub enum SnapType { ApparentIntersection, Parallel, Grid, + /// C3D-style object acquisition (storm structure / network pick) — orange marker. + ObjectPick, } /// Ordered list used by the popup and snap engine. diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index a550feeb..5f5735f2 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -500,18 +500,37 @@ impl canvas::Program for SelectionCanvas { // ── Snap marker ─────────────────────────────────────────────────── if let Some((sp, snap_type)) = self.snap { - let yellow = Color { - r: 1.0, - g: 0.9, - b: 0.1, - a: 1.0, + let (r, g, b) = if snap_type == SnapType::ObjectPick { + (0.95_f32, 0.50, 0.08) // C3D-style orange object snap + } else { + (1.0, 0.9, 0.1) // classic yellow OSNAP }; + let marker = Color { r, g, b, a: 1.0 }; let stroke = canvas::Stroke { - width: 1.5, - style: canvas::Style::Solid(yellow), + width: if snap_type == SnapType::ObjectPick { 2.0 } else { 1.5 }, + style: canvas::Style::Solid(marker), ..Default::default() }; match snap_type { + SnapType::ObjectPick => { + // Target box + center dot (Civil 3D acquisition glyph). + let h = 7.0_f32; + let rect = canvas::Path::rectangle( + Point::new(sp.x - h, sp.y - h), + Size::new(h * 2.0, h * 2.0), + ); + frame.stroke(&rect, stroke.clone()); + let r = 3.0_f32; + frame.fill( + &canvas::Path::circle(sp, r), + Color { + r: 0.95, + g: 0.50, + b: 0.08, + a: 0.85, + }, + ); + } SnapType::Endpoint => { let h = 5.0_f32; let rect = canvas::Path::rectangle( @@ -655,7 +674,7 @@ impl canvas::Program for SelectionCanvas { let r = 1.4_f32; for k in [-7.0_f32, 0.0, 7.0] { let dot = canvas::Path::circle(Point::new(sp.x + k, sp.y), r); - frame.fill(&dot, yellow); + frame.fill(&dot, marker); } } SnapType::Parallel => { diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 9c0052ca..adbbc83f 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -13,8 +13,8 @@ use iced::widget::{button, column, container, mouse_area, row, scrollable, svg, use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme}; use crate::app::Message; -use crate::modules::registry; use crate::modules::{CadModule, IconKind, RibbonItem}; +use crate::plugin::all_ribbon_modules; use crate::ui::properties::{color_picker_dropdown, lw_options, LinetypeItem}; mod widgets; @@ -67,7 +67,7 @@ pub struct LayerInfo { impl Ribbon { pub fn new() -> Self { Self { - modules: registry::all_modules(), + modules: all_ribbon_modules(), active: 0, active_tool: None, wireframe: false,