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,