Strip Storm Sewer from plugin-host branch (framework only)
Removes storm_sewer module and stormsewer crate so this branch is suitable for upstream PR. Plugin host, docs, and CadCommand acquisition hooks remain.
17
Cargo.lock
generated
|
|
@ -29,7 +29,6 @@ dependencies = [
|
|||
"rayon",
|
||||
"rfd",
|
||||
"rustc-hash 2.1.2",
|
||||
"stormsewer",
|
||||
"truck-meshalgo",
|
||||
"truck-modeling",
|
||||
"truck-polymesh",
|
||||
|
|
@ -4170,15 +4169,6 @@ 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"
|
||||
|
|
@ -5186,13 +5176,6 @@ version = "1.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||
|
||||
[[package]]
|
||||
name = "stormsewer"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"quick-xml 0.37.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strict-num"
|
||||
version = "0.1.1"
|
||||
|
|
|
|||
|
|
@ -4,9 +4,6 @@ version = "0.5.0"
|
|||
edition = "2021"
|
||||
build = "build.rs"
|
||||
|
||||
[workspace]
|
||||
members = ["crates/stormsewer"]
|
||||
|
||||
[dependencies]
|
||||
# vtkio (pulled in transitively via truck-meshalgo) depends on xz2 → lzma-sys,
|
||||
# which by default links the system liblzma dynamically. On macOS that bakes a
|
||||
|
|
@ -34,9 +31,6 @@ truck-shapeops = "0.4"
|
|||
# Fast non-cryptographic hasher. Handle/u64 keys dominate the hot block,
|
||||
# hatch, draw-depth and wire caches; the default SipHash is overkill there.
|
||||
rustc-hash = "2"
|
||||
# Storm-sewer hydrology/hydraulics engine (Rational + Manning + HGL). Local
|
||||
# crate; powers the `storm_sewer` ribbon module.
|
||||
stormsewer = { path = "crates/stormsewer" }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
|
||||
|
|
|
|||
|
|
@ -1,16 +0,0 @@
|
|||
[package]
|
||||
name = "stormsewer"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Native-Rust storm sewer network hydrology & hydraulics (Rational method, Manning, HGL). Engine core for the Open CAD Studio storm-sewer module and HydroComplete."
|
||||
license = "GPL-3.0-or-later"
|
||||
repository = "https://github.com/mf4633/stormsewer"
|
||||
keywords = ["hydrology", "hydraulics", "stormwater", "civil-engineering", "cad"]
|
||||
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"
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
# Sample storm-sewer network (stormsewer-cli examples/sample.ssn)
|
||||
# A short, properly-sized trunk line: two catch basins + a junction to outfall.
|
||||
|
||||
IDF 60 10 0.8 # i = 60 / (t+10)^0.8 (in/hr, t in minutes) ~ 5 in/hr
|
||||
TAILWATER 100.5 # outfall tailwater elevation (ft)
|
||||
MINTC 10 # minimum time of concentration (min)
|
||||
JUNCTIONK 0.5 # junction loss coefficient
|
||||
|
||||
# id kind x y invert rim area C tc_inlet
|
||||
NODE N1 inlet 0 0 104.0 110.0 1.0 0.70 12
|
||||
NODE N2 inlet 300 0 102.5 108.5 1.0 0.70 10
|
||||
NODE N3 junction 550 0 101.2 107.0 0.5 0.80 8
|
||||
NODE OUT outfall 730 0 100.0 106.0
|
||||
|
||||
# id from to length dia n (dia: 15in=1.25, 18in=1.5, 21in=1.75)
|
||||
PIPE P1 N1 N2 300 1.25 0.013
|
||||
PIPE P2 N2 N3 250 1.50 0.013
|
||||
PIPE P3 N3 OUT 180 1.75 0.013
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<LandXML xmlns="http://www.landxml.org/schema/LandXML-1.2" version="1.2" date="2026-06-09">
|
||||
<Units>
|
||||
<Imperial areaUnit="squareFoot" linearUnit="foot" diameterUnit="inch"/>
|
||||
</Units>
|
||||
<PipeNetworks>
|
||||
<PipeNetwork name="Sample">
|
||||
<Structs>
|
||||
<Struct name="IN1" role="inlet">
|
||||
<Center>0.0 0.0 110.0</Center>
|
||||
<Invert>104.0</Invert>
|
||||
<ElevRim>110.0</ElevRim>
|
||||
</Struct>
|
||||
<Struct name="J1" role="junction">
|
||||
<Center>150.0 0.0 108.0</Center>
|
||||
<Invert>102.0</Invert>
|
||||
<ElevRim>108.0</ElevRim>
|
||||
</Struct>
|
||||
<Struct name="OUT1" role="outfall">
|
||||
<Center>300.0 0.0 106.0</Center>
|
||||
<Invert>100.0</Invert>
|
||||
<ElevRim>106.0</ElevRim>
|
||||
</Struct>
|
||||
</Structs>
|
||||
<Pipes>
|
||||
<Pipe name="P1">
|
||||
<CircPipe diameter="15"/>
|
||||
<StartStruct>IN1</StartStruct>
|
||||
<EndStruct>J1</EndStruct>
|
||||
</Pipe>
|
||||
<Pipe name="P2">
|
||||
<CircPipe diameter="18"/>
|
||||
<StartStruct>J1</StartStruct>
|
||||
<EndStruct>OUT1</EndStruct>
|
||||
</Pipe>
|
||||
</Pipes>
|
||||
</PipeNetwork>
|
||||
</PipeNetworks>
|
||||
</LandXML>
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//! `stormsewer-cli` — run a storm-sewer analysis from a `.ssn` network file.
|
||||
//!
|
||||
//! Usage: stormsewer-cli <network-file>
|
||||
//!
|
||||
//! See `stormsewer::parse` for the file format.
|
||||
|
||||
use std::process::exit;
|
||||
use stormsewer::parse::parse_ssn;
|
||||
use stormsewer::report::format_analysis;
|
||||
|
||||
fn die(msg: &str) -> ! {
|
||||
eprintln!("error: {msg}");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let path = match std::env::args().nth(1) {
|
||||
Some(p) => p,
|
||||
None => die("usage: stormsewer-cli <network-file>"),
|
||||
};
|
||||
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| die(&format!("cannot read {path}: {e}")));
|
||||
let parsed = parse_ssn(&text).unwrap_or_else(|e| die(&e));
|
||||
match parsed.network.analyze(&parsed.idf, &parsed.options) {
|
||||
Ok(a) => print!("{}", format_analysis(&a)),
|
||||
Err(e) => die(&e.to_string()),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
// 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<f64> {
|
||||
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<f64>,
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
// 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::*;
|
||||
|
|
@ -1,287 +0,0 @@
|
|||
// 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<f64>,
|
||||
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<PipeSizeResult> {
|
||||
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<PipeSizeRecommendation> {
|
||||
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::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,288 +0,0 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//! Convert an analyzed network into drawable primitives (CAD-agnostic).
|
||||
//!
|
||||
//! Produces a **plan** view (pipes as lines, structures as markers, flow/HGL
|
||||
//! labels) and a **profile** (HGL long-section of the main stem: ground,
|
||||
//! invert, and hydraulic-grade-line polylines). Coordinates are plain `f64`
|
||||
//! drawing units; the host CAD turns these into its own entities.
|
||||
|
||||
use crate::network::{Analysis, Network, NodeKind};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A pipe drawn in plan.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PlanPipe {
|
||||
pub x1: f64,
|
||||
pub y1: f64,
|
||||
pub x2: f64,
|
||||
pub y2: f64,
|
||||
pub surcharged: bool,
|
||||
}
|
||||
|
||||
/// A structure marker drawn in plan.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PlanNode {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub radius: f64,
|
||||
pub kind: NodeKind,
|
||||
}
|
||||
|
||||
/// A text label placed at a point.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Label {
|
||||
pub x: f64,
|
||||
pub y: f64,
|
||||
pub text: String,
|
||||
pub height: f64,
|
||||
}
|
||||
|
||||
/// Which line of the profile a polyline represents.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ProfileRole {
|
||||
Ground,
|
||||
Invert,
|
||||
Hgl,
|
||||
}
|
||||
|
||||
/// A polyline (sequence of points) with a role.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Polyline {
|
||||
pub pts: Vec<(f64, f64)>,
|
||||
pub role: ProfileRole,
|
||||
}
|
||||
|
||||
/// The full set of primitives for a network drawing.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct NetworkDrawing {
|
||||
pub plan_pipes: Vec<PlanPipe>,
|
||||
pub plan_nodes: Vec<PlanNode>,
|
||||
pub plan_labels: Vec<Label>,
|
||||
pub profile_lines: Vec<Polyline>,
|
||||
pub profile_labels: Vec<Label>,
|
||||
}
|
||||
|
||||
/// Layout / styling knobs for [`draw_network`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DrawConfig {
|
||||
pub text_height: f64,
|
||||
pub node_radius: f64,
|
||||
/// Plan X mapped to profile X=0 (station origin in drawing units).
|
||||
pub profile_origin_x: f64,
|
||||
/// Drawing Y at which the profile datum elevation is plotted.
|
||||
pub profile_origin_y: f64,
|
||||
/// Horizontal scale: station feet → drawing units.
|
||||
pub h_scale: f64,
|
||||
/// Vertical exaggeration applied to elevations in the profile.
|
||||
pub v_exag: f64,
|
||||
}
|
||||
|
||||
impl Default for DrawConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
text_height: 5.0,
|
||||
node_radius: 3.0,
|
||||
profile_origin_x: 0.0,
|
||||
profile_origin_y: -200.0,
|
||||
h_scale: 1.0,
|
||||
v_exag: 10.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build plan + profile primitives for an analyzed network.
|
||||
pub fn draw_network(net: &Network, a: &Analysis, cfg: &DrawConfig) -> NetworkDrawing {
|
||||
let mut d = NetworkDrawing::default();
|
||||
let pos: HashMap<&str, (f64, f64)> =
|
||||
net.nodes.iter().map(|n| (n.id.as_str(), (n.x, n.y))).collect();
|
||||
let hgl: HashMap<&str, f64> = a.nodes.iter().map(|n| (n.id.as_str(), n.hgl)).collect();
|
||||
|
||||
// ── Plan: pipes + labels ────────────────────────────────────────────────
|
||||
for pr in &a.pipes {
|
||||
let (x1, y1) = pos[pr.from.as_str()];
|
||||
let (x2, y2) = pos[pr.to.as_str()];
|
||||
d.plan_pipes.push(PlanPipe { x1, y1, x2, y2, surcharged: pr.surcharged });
|
||||
d.plan_labels.push(Label {
|
||||
x: (x1 + x2) / 2.0,
|
||||
y: (y1 + y2) / 2.0 + cfg.text_height,
|
||||
text: format!("{}: {:.1} cfs {:.0}%", pr.id, pr.design_q, pr.pct_full * 100.0),
|
||||
height: cfg.text_height,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Plan: structure markers + labels ────────────────────────────────────
|
||||
for n in &net.nodes {
|
||||
d.plan_nodes.push(PlanNode { x: n.x, y: n.y, radius: cfg.node_radius, kind: n.kind });
|
||||
let h = hgl.get(n.id.as_str()).copied().unwrap_or(f64::NAN);
|
||||
let label = if h.is_finite() {
|
||||
format!("{} HGL {:.1}", n.id, h)
|
||||
} else {
|
||||
n.id.clone()
|
||||
};
|
||||
d.plan_labels.push(Label {
|
||||
x: n.x + cfg.node_radius,
|
||||
y: n.y + cfg.node_radius,
|
||||
text: label,
|
||||
height: cfg.text_height,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Profile of the main stem ────────────────────────────────────────────
|
||||
let stem = main_stem(net);
|
||||
if stem.len() >= 2 {
|
||||
let nidx: HashMap<&str, usize> =
|
||||
net.nodes.iter().enumerate().map(|(i, n)| (n.id.as_str(), i)).collect();
|
||||
|
||||
// Stations (ft) from the upstream end.
|
||||
let mut stations = vec![0.0f64; stem.len()];
|
||||
for k in 1..stem.len() {
|
||||
let len = pipe_between(net, stem[k - 1], stem[k]).map(|p| p.length).unwrap_or(0.0);
|
||||
stations[k] = stations[k - 1] + len;
|
||||
}
|
||||
let datum = stem.iter().map(|&i| net.nodes[i].invert).fold(f64::INFINITY, f64::min);
|
||||
let px = |st: f64| cfg.profile_origin_x + st * cfg.h_scale;
|
||||
let py = |elev: f64| cfg.profile_origin_y + (elev - datum) * cfg.v_exag;
|
||||
|
||||
let mut ground = Vec::new();
|
||||
let mut invert = Vec::new();
|
||||
let mut hgl_line = Vec::new();
|
||||
for (k, &i) in stem.iter().enumerate() {
|
||||
let n = &net.nodes[i];
|
||||
let st = stations[k];
|
||||
ground.push((px(st), py(n.rim)));
|
||||
invert.push((px(st), py(n.invert)));
|
||||
if let Some(&h) = hgl.get(n.id.as_str()) {
|
||||
if h.is_finite() {
|
||||
hgl_line.push((px(st), py(h)));
|
||||
}
|
||||
}
|
||||
d.profile_labels.push(Label {
|
||||
x: px(st),
|
||||
y: py(n.rim) + cfg.text_height,
|
||||
text: n.id.clone(),
|
||||
height: cfg.text_height,
|
||||
});
|
||||
let _ = nidx; // (reserved for future cross-refs)
|
||||
}
|
||||
d.profile_lines.push(Polyline { pts: ground, role: ProfileRole::Ground });
|
||||
d.profile_lines.push(Polyline { pts: invert, role: ProfileRole::Invert });
|
||||
if hgl_line.len() >= 2 {
|
||||
d.profile_lines.push(Polyline { pts: hgl_line, role: ProfileRole::Hgl });
|
||||
}
|
||||
}
|
||||
|
||||
d
|
||||
}
|
||||
|
||||
/// The main trunk, upstream-first: walk from the outfall up the incoming pipe
|
||||
/// whose upstream node carries the most accumulated drainage area.
|
||||
fn main_stem(net: &Network) -> Vec<usize> {
|
||||
let n = net.nodes.len();
|
||||
if n == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let nidx: HashMap<&str, usize> =
|
||||
net.nodes.iter().enumerate().map(|(i, nd)| (nd.id.as_str(), i)).collect();
|
||||
let mut incoming: Vec<Vec<usize>> = vec![Vec::new(); n]; // upstream node indices
|
||||
let mut has_out = vec![false; n];
|
||||
for p in &net.pipes {
|
||||
if let (Some(&u), Some(&v)) = (nidx.get(p.from.as_str()), nidx.get(p.to.as_str())) {
|
||||
incoming[v].push(u);
|
||||
has_out[u] = true;
|
||||
}
|
||||
}
|
||||
let ca = net.accumulate_ca().unwrap_or_default();
|
||||
let size = |i: usize| ca.get(net.nodes[i].id.as_str()).copied().unwrap_or(0.0);
|
||||
|
||||
// Start at the outfall (kind, else the first node with no outgoing pipe).
|
||||
let start = net
|
||||
.nodes
|
||||
.iter()
|
||||
.position(|nd| nd.kind == NodeKind::Outfall)
|
||||
.or_else(|| (0..n).find(|&i| !has_out[i]))
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut stem = vec![start];
|
||||
let mut cur = start;
|
||||
let mut guard = 0;
|
||||
while guard < n {
|
||||
guard += 1;
|
||||
match incoming[cur].iter().copied().max_by(|&a, &b| {
|
||||
size(a).partial_cmp(&size(b)).unwrap_or(std::cmp::Ordering::Equal)
|
||||
}) {
|
||||
Some(up) => {
|
||||
stem.push(up);
|
||||
cur = up;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
stem.reverse(); // upstream-first
|
||||
stem
|
||||
}
|
||||
|
||||
fn pipe_between<'a>(net: &'a Network, up: usize, dn: usize) -> Option<&'a crate::network::Pipe> {
|
||||
let up_id = net.nodes[up].id.as_str();
|
||||
let dn_id = net.nodes[dn].id.as_str();
|
||||
net.pipes.iter().find(|p| p.from == up_id && p.to == dn_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::idf::IdfCurve;
|
||||
use crate::network::{AnalysisOptions, Node, Pipe};
|
||||
|
||||
fn sample() -> Network {
|
||||
Network {
|
||||
nodes: vec![
|
||||
Node::inlet("N1", 104.0, 110.0, 1.0, 0.70).at(0.0, 0.0),
|
||||
Node::inlet("N2", 102.5, 108.5, 1.0, 0.70).at(300.0, 0.0),
|
||||
Node::junction("N3", 101.2, 107.0, 0.5, 0.80).at(550.0, 0.0),
|
||||
Node::outfall("OUT", 100.0, 106.0).at(730.0, 0.0),
|
||||
],
|
||||
pipes: vec![
|
||||
Pipe::new("P1", "N1", "N2", 300.0, 1.25, 0.013),
|
||||
Pipe::new("P2", "N2", "N3", 250.0, 1.50, 0.013),
|
||||
Pipe::new("P3", "N3", "OUT", 180.0, 1.75, 0.013),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn analyzed() -> (Network, Analysis) {
|
||||
let net = sample();
|
||||
let a = net.analyze(&IdfCurve::new(60.0, 10.0, 0.8), &AnalysisOptions { tailwater: Some(100.5), ..Default::default() }).unwrap();
|
||||
(net, a)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_has_one_line_per_pipe_and_marker_per_node() {
|
||||
let (net, a) = analyzed();
|
||||
let d = draw_network(&net, &a, &DrawConfig::default());
|
||||
assert_eq!(d.plan_pipes.len(), net.pipes.len());
|
||||
assert_eq!(d.plan_nodes.len(), net.nodes.len());
|
||||
assert!(d.plan_labels.len() >= net.pipes.len() + net.nodes.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_stem_is_full_trunk_upstream_first() {
|
||||
let net = sample();
|
||||
let stem = main_stem(&net);
|
||||
let ids: Vec<&str> = stem.iter().map(|&i| net.nodes[i].id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["N1", "N2", "N3", "OUT"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_has_ground_invert_and_hgl() {
|
||||
let (net, a) = analyzed();
|
||||
let d = draw_network(&net, &a, &DrawConfig::default());
|
||||
let roles: Vec<ProfileRole> = d.profile_lines.iter().map(|p| p.role).collect();
|
||||
assert!(roles.contains(&ProfileRole::Ground));
|
||||
assert!(roles.contains(&ProfileRole::Invert));
|
||||
assert!(roles.contains(&ProfileRole::Hgl));
|
||||
for pl in &d.profile_lines {
|
||||
assert!(pl.pts.len() >= 2, "{:?} too short", pl.role);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,208 +0,0 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//! Open-channel & partial-flow hydraulics for circular closed conduits.
|
||||
//!
|
||||
//! US customary units throughout (feet, seconds, cfs). Pass `K_MANNING_SI`
|
||||
//! and `G_SI` for metric. All geometry is exact (no table lookups).
|
||||
|
||||
use std::f64::consts::PI;
|
||||
|
||||
/// Manning conversion factor, US customary (1.486 rounded to 1.49 by convention).
|
||||
pub const K_MANNING_US: f64 = 1.49;
|
||||
/// Manning conversion factor, SI.
|
||||
pub const K_MANNING_SI: f64 = 1.0;
|
||||
/// Gravitational acceleration, US customary (ft/s^2).
|
||||
pub const G_US: f64 = 32.2;
|
||||
/// Gravitational acceleration, SI (m/s^2).
|
||||
pub const G_SI: f64 = 9.81;
|
||||
|
||||
/// Central angle (radians) subtended at the pipe centre by the water surface,
|
||||
/// for flow depth `y` in a circular pipe of diameter `d`.
|
||||
///
|
||||
/// `0` when empty, `2*PI` when full.
|
||||
pub fn circular_angle(y: f64, d: f64) -> f64 {
|
||||
let y = y.clamp(0.0, d);
|
||||
2.0 * (1.0 - 2.0 * y / d).acos()
|
||||
}
|
||||
|
||||
/// Flow geometry at depth `y` in a circular pipe of diameter `d`.
|
||||
///
|
||||
/// Returns `(area, wetted_perimeter, hydraulic_radius, top_width)`.
|
||||
pub fn circular_geometry(y: f64, d: f64) -> (f64, f64, f64, f64) {
|
||||
let theta = circular_angle(y, d);
|
||||
let area = d * d / 8.0 * (theta - theta.sin());
|
||||
let perim = d * theta / 2.0;
|
||||
let radius = if perim > 0.0 { area / perim } else { 0.0 };
|
||||
let top = d * (theta / 2.0).sin();
|
||||
(area, perim, radius, top)
|
||||
}
|
||||
|
||||
/// Full cross-sectional area of a circular pipe.
|
||||
pub fn full_area(d: f64) -> f64 {
|
||||
PI * d * d / 4.0
|
||||
}
|
||||
|
||||
/// Manning discharge from flow area and hydraulic radius.
|
||||
///
|
||||
/// `Q = (k/n) * A * R^(2/3) * S^(1/2)`
|
||||
pub fn manning_q(n: f64, s: f64, area: f64, radius: f64, k: f64) -> f64 {
|
||||
if n <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
k / n * area * radius.powf(2.0 / 3.0) * s.max(0.0).sqrt()
|
||||
}
|
||||
|
||||
/// Just-full (geometrically full, free-surface) capacity of a circular pipe.
|
||||
///
|
||||
/// Note: the *maximum* open-channel discharge actually occurs near `0.94 d`
|
||||
/// and slightly exceeds this value — see [`max_capacity`].
|
||||
pub fn full_flow_capacity(n: f64, s: f64, d: f64, k: f64) -> f64 {
|
||||
manning_q(n, s, full_area(d), d / 4.0, k)
|
||||
}
|
||||
|
||||
/// Discharge at partial depth `y` in a circular pipe.
|
||||
pub fn circular_q(n: f64, s: f64, d: f64, y: f64, k: f64) -> f64 {
|
||||
let (area, _p, radius, _t) = circular_geometry(y, d);
|
||||
manning_q(n, s, area, radius, k)
|
||||
}
|
||||
|
||||
/// Maximum open-channel discharge of a circular pipe and the depth at which it
|
||||
/// occurs (~`0.938 d`). Returns `(q_max, y_at_max)`.
|
||||
pub fn max_capacity(n: f64, s: f64, d: f64, k: f64) -> (f64, f64) {
|
||||
// Q(y) is smooth with a single interior maximum; a fine scan is exact
|
||||
// enough and cheap for the depths involved.
|
||||
let steps = 2000;
|
||||
let mut best_q = 0.0;
|
||||
let mut best_y = 0.0;
|
||||
for i in 1..=steps {
|
||||
let y = d * i as f64 / steps as f64;
|
||||
let q = circular_q(n, s, d, y, k);
|
||||
if q > best_q {
|
||||
best_q = q;
|
||||
best_y = y;
|
||||
}
|
||||
}
|
||||
(best_q, best_y)
|
||||
}
|
||||
|
||||
/// Normal (uniform-flow) depth for a target discharge in a circular pipe.
|
||||
///
|
||||
/// Returns `None` when `q_target` exceeds the pipe's maximum open-channel
|
||||
/// capacity — i.e. the pipe must surcharge / flow under pressure.
|
||||
pub fn normal_depth(q_target: f64, n: f64, s: f64, d: f64, k: f64) -> Option<f64> {
|
||||
if q_target <= 0.0 {
|
||||
return Some(0.0);
|
||||
}
|
||||
let (q_max, y_max) = max_capacity(n, s, d, k);
|
||||
if q_target > q_max {
|
||||
return None;
|
||||
}
|
||||
// Q is monotonic increasing on (0, y_max]; bisect there.
|
||||
let (mut lo, mut hi) = (0.0_f64, y_max);
|
||||
for _ in 0..200 {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
if circular_q(n, s, d, mid, k) > q_target {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid;
|
||||
}
|
||||
if hi - lo < 1e-7 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(0.5 * (lo + hi))
|
||||
}
|
||||
|
||||
/// Critical depth for a target discharge in a circular pipe.
|
||||
///
|
||||
/// Solves `Q^2 * T = g * A^3` (Froude = 1). Valid for `0 < y < d`.
|
||||
pub fn critical_depth(q: f64, d: f64, g: f64) -> f64 {
|
||||
if q <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let resid = |y: f64| {
|
||||
let (area, _p, _r, top) = circular_geometry(y, d);
|
||||
if area <= 0.0 || top <= 0.0 {
|
||||
return -q * q;
|
||||
}
|
||||
q * q * top - g * area * area * area
|
||||
};
|
||||
// resid > 0 for small y, < 0 as y grows: bracket the sign change.
|
||||
let (mut lo, mut hi) = (1e-6 * d, 0.999 * d);
|
||||
for _ in 0..200 {
|
||||
let mid = 0.5 * (lo + hi);
|
||||
if resid(mid) > 0.0 {
|
||||
lo = mid;
|
||||
} else {
|
||||
hi = mid;
|
||||
}
|
||||
if hi - lo < 1e-7 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
0.5 * (lo + hi)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const K: f64 = K_MANNING_US;
|
||||
|
||||
#[test]
|
||||
fn full_area_matches_geometry() {
|
||||
// Geometry at y = d must equal the closed-form full area.
|
||||
let d = 2.0;
|
||||
let (area, perim, r, _t) = circular_geometry(d, d);
|
||||
assert!((area - full_area(d)).abs() < 1e-9);
|
||||
assert!((perim - PI * d).abs() < 1e-9); // full wetted perimeter = pi*d
|
||||
assert!((r - d / 4.0).abs() < 1e-9); // hydraulic radius full = d/4
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn half_depth_is_half_area() {
|
||||
// A circle is exactly half-full at half-diameter depth.
|
||||
let d = 3.0;
|
||||
let (area, _p, _r, _t) = circular_geometry(d / 2.0, d);
|
||||
assert!((area - full_area(d) / 2.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manning_capacity_known_value() {
|
||||
// 2-ft pipe, n=0.013, s=0.005 -> ~16.0 cfs full-flow (hand calc).
|
||||
let q = full_flow_capacity(0.013, 0.005, 2.0, K);
|
||||
assert!((q - 16.0).abs() < 0.2, "got {q}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_depth_round_trip() {
|
||||
// Build a discharge from a depth, then recover the depth.
|
||||
let (n, s, d) = (0.013, 0.01, 2.0);
|
||||
let y0 = 1.2;
|
||||
let q = circular_q(n, s, d, y0, K);
|
||||
let y = normal_depth(q, n, s, d, K).expect("below capacity");
|
||||
assert!((y - y0).abs() < 1e-3, "got {y}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surcharge_returns_none() {
|
||||
// A tiny pipe cannot pass a huge flow as open channel.
|
||||
let y = normal_depth(500.0, 0.013, 0.005, 1.0, K);
|
||||
assert!(y.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_capacity_exceeds_full() {
|
||||
// Peak open-channel flow occurs near 0.94 d and beats just-full.
|
||||
let (qmax, ymax) = max_capacity(0.013, 0.005, 2.0, K);
|
||||
let qfull = full_flow_capacity(0.013, 0.005, 2.0, K);
|
||||
assert!(qmax > qfull);
|
||||
assert!(ymax > 1.8 && ymax < 2.0, "y_at_max = {ymax}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn critical_depth_in_range() {
|
||||
let yc = critical_depth(10.0, 2.0, G_US);
|
||||
assert!(yc > 0.0 && yc < 2.0, "yc = {yc}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,93 +0,0 @@
|
|||
// 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<u32, IdfCurve>,
|
||||
}
|
||||
|
||||
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<u32> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
pub mod idf_set;
|
||||
pub mod tc;
|
||||
|
||||
pub use idf_set::*;
|
||||
pub use tc::*;
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
// 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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//! Rainfall intensity–duration–frequency (IDF) curves.
|
||||
//!
|
||||
//! Uses the common three-parameter form
|
||||
//!
|
||||
//! ```text
|
||||
//! i = a / (t + b)^c (i in in/hr, t in minutes)
|
||||
//! ```
|
||||
//!
|
||||
//! which fits NOAA Atlas-14 partial-duration data well over the 5–180 min
|
||||
//! range used in storm-sewer design.
|
||||
|
||||
/// A three-parameter IDF curve for a single return period.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct IdfCurve {
|
||||
pub a: f64,
|
||||
pub b: f64,
|
||||
pub c: f64,
|
||||
}
|
||||
|
||||
impl IdfCurve {
|
||||
pub fn new(a: f64, b: f64, c: f64) -> Self {
|
||||
Self { a, b, c }
|
||||
}
|
||||
|
||||
/// Rainfall intensity (in/hr) for a storm duration `t_min` (minutes).
|
||||
pub fn intensity(&self, t_min: f64) -> f64 {
|
||||
let t = t_min.max(0.0);
|
||||
self.a / (t + self.b).powf(self.c)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn intensity_decreases_with_duration() {
|
||||
let idf = IdfCurve::new(120.0, 10.0, 0.8);
|
||||
let short = idf.intensity(5.0);
|
||||
let long = idf.intensity(60.0);
|
||||
assert!(short > long, "short {short} long {long}");
|
||||
assert!(long > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intensity_known_value() {
|
||||
// a=120,b=10,c=0.8 ; t=15 -> 120 / 25^0.8 = 9.14 in/hr (hand calc).
|
||||
let idf = IdfCurve::new(120.0, 10.0, 0.8);
|
||||
assert!((idf.intensity(15.0) - 9.14).abs() < 0.05, "{}", idf.intensity(15.0));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,475 +0,0 @@
|
|||
// 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<LandXmlNetwork>,
|
||||
}
|
||||
|
||||
/// A single pipe network from LandXML.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct LandXmlNetwork {
|
||||
pub name: String,
|
||||
pub structures: Vec<LandXmlStruct>,
|
||||
pub pipes: Vec<LandXmlPipe>,
|
||||
}
|
||||
|
||||
/// 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 <PipeNetwork> found".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a LandXML document string into structures and pipes.
|
||||
pub fn parse_landxml(xml: &str) -> Result<LandXmlDocument, String> {
|
||||
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<LandXmlNetwork> = None;
|
||||
let mut in_structs = false;
|
||||
let mut in_pipes = false;
|
||||
|
||||
let mut cur_struct: Option<LandXmlStruct> = None;
|
||||
let mut cur_pipe: Option<LandXmlPipe> = 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::<f64>().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::<f64>().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::<f64>() {
|
||||
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::<f64>() {
|
||||
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::<f64>() {
|
||||
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<String> {
|
||||
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<f64> = 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<crate::network::Network, String> {
|
||||
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<String, String> = 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#"<?xml version="1.0" encoding="UTF-8"?>
|
||||
<LandXML xmlns="http://www.landxml.org/schema/LandXML-1.2" version="1.2" date="2026-06-09">
|
||||
<Units>
|
||||
<Imperial areaUnit="squareFoot" linearUnit="foot" diameterUnit="inch"/>
|
||||
</Units>
|
||||
<PipeNetworks>
|
||||
<PipeNetwork name="Main">
|
||||
<Structs>
|
||||
<Struct name="IN1" role="inlet">
|
||||
<Center>0.0 0.0 110.0</Center>
|
||||
<Invert>104.0</Invert>
|
||||
<ElevRim>110.0</ElevRim>
|
||||
</Struct>
|
||||
<Struct name="OUT1" role="outfall">
|
||||
<Center>300.0 0.0 106.0</Center>
|
||||
<Invert>100.0</Invert>
|
||||
<ElevRim>106.0</ElevRim>
|
||||
</Struct>
|
||||
</Structs>
|
||||
<Pipes>
|
||||
<Pipe name="P1">
|
||||
<CircPipe diameter="18"/>
|
||||
<StartStruct>IN1</StartStruct>
|
||||
<EndStruct>OUT1</EndStruct>
|
||||
</Pipe>
|
||||
</Pipes>
|
||||
</PipeNetwork>
|
||||
</PipeNetworks>
|
||||
</LandXML>"#;
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
pub mod landxml;
|
||||
|
||||
pub use landxml::*;
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//! # stormsewer
|
||||
//!
|
||||
//! Native-Rust storm-sewer network **hydrology & hydraulics** engine.
|
||||
//!
|
||||
//! It implements the standard, public-domain methods used by tools such as
|
||||
//! Autodesk Hydraflow Storm Sewers:
|
||||
//!
|
||||
//! * **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,
|
||||
//! * **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
|
||||
//! a module by an Open CAD Studio fork.
|
||||
//!
|
||||
//! ```
|
||||
//! use stormsewer::{Network, Node, NodeKind, Pipe};
|
||||
//! let net = Network {
|
||||
//! nodes: vec![
|
||||
//! Node::inlet("N1", 100.0, 105.0, 2.0, 0.7),
|
||||
//! Node::outfall("OUT", 99.0, 104.0),
|
||||
//! ],
|
||||
//! pipes: vec![Pipe::new("P1", "N1", "OUT", 100.0, 1.5, 0.013)],
|
||||
//! };
|
||||
//! let results = net.analyze_rational(4.0).unwrap(); // i = 4 in/hr
|
||||
//! assert_eq!(results.len(), 1);
|
||||
//! 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::*;
|
||||
|
|
@ -1,576 +0,0 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//! Storm-sewer network model: nodes (inlets / junctions / outfalls), pipes,
|
||||
//! topology, Rational-method peak-flow accumulation, time-of-concentration /
|
||||
//! IDF intensity, and a hydraulic-grade-line (HGL) backwater pass.
|
||||
//!
|
||||
//! The network is assumed **dendritic** (tree-like, draining to outfalls) as is
|
||||
//! 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.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum NodeKind {
|
||||
Inlet,
|
||||
Junction,
|
||||
Outfall,
|
||||
}
|
||||
|
||||
/// A network node (structure).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Node {
|
||||
pub id: String,
|
||||
pub kind: NodeKind,
|
||||
/// Flowline / invert elevation (ft).
|
||||
pub invert: f64,
|
||||
/// Rim / ground elevation (ft) — used for surcharge-to-surface checks.
|
||||
pub rim: f64,
|
||||
/// Local contributing drainage area (acres).
|
||||
pub area_ac: f64,
|
||||
/// Rational runoff coefficient C for the local area (0–1).
|
||||
pub c: f64,
|
||||
/// Inlet time of concentration for the local catchment (minutes).
|
||||
pub tc_inlet: f64,
|
||||
/// Plan (map) X coordinate of the structure (drawing units).
|
||||
pub x: f64,
|
||||
/// Plan (map) Y coordinate of the structure (drawing units).
|
||||
pub y: f64,
|
||||
}
|
||||
|
||||
impl Node {
|
||||
pub fn inlet(id: &str, invert: f64, rim: f64, area_ac: f64, c: f64) -> Self {
|
||||
Self { id: id.into(), kind: NodeKind::Inlet, invert, rim, area_ac, c, tc_inlet: 10.0, x: 0.0, y: 0.0 }
|
||||
}
|
||||
pub fn junction(id: &str, invert: f64, rim: f64, area_ac: f64, c: f64) -> Self {
|
||||
Self { id: id.into(), kind: NodeKind::Junction, invert, rim, area_ac, c, tc_inlet: 10.0, x: 0.0, y: 0.0 }
|
||||
}
|
||||
pub fn outfall(id: &str, invert: f64, rim: f64) -> Self {
|
||||
Self { id: id.into(), kind: NodeKind::Outfall, invert, rim, area_ac: 0.0, c: 0.0, tc_inlet: 0.0, x: 0.0, y: 0.0 }
|
||||
}
|
||||
/// Builder: set the local inlet time (minutes).
|
||||
pub fn with_tc_inlet(mut self, t_min: f64) -> Self {
|
||||
self.tc_inlet = t_min;
|
||||
self
|
||||
}
|
||||
/// Builder: set the plan (map) coordinates.
|
||||
pub fn at(mut self, x: f64, y: f64) -> Self {
|
||||
self.x = x;
|
||||
self.y = y;
|
||||
self
|
||||
}
|
||||
/// Local C*A product (acres).
|
||||
pub fn ca(&self) -> f64 {
|
||||
self.c * self.area_ac
|
||||
}
|
||||
}
|
||||
|
||||
/// A pipe (network link) carrying flow from `from` (upstream) to `to`.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Pipe {
|
||||
pub id: String,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub length: f64, // ft
|
||||
pub diameter: f64, // ft
|
||||
pub n: f64, // Manning roughness
|
||||
}
|
||||
|
||||
impl Pipe {
|
||||
pub fn new(id: &str, from: &str, to: &str, length: f64, diameter: f64, n: f64) -> Self {
|
||||
Self { id: id.into(), from: from.into(), to: to.into(), length, diameter, n }
|
||||
}
|
||||
}
|
||||
|
||||
/// A storm-sewer network.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Network {
|
||||
pub nodes: Vec<Node>,
|
||||
pub pipes: Vec<Pipe>,
|
||||
}
|
||||
|
||||
/// Per-pipe analysis result.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PipeResult {
|
||||
pub id: String,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
/// Pipe slope from upstream/downstream inverts (ft/ft).
|
||||
pub slope: f64,
|
||||
/// Accumulated C*A draining into this pipe (acres).
|
||||
pub total_ca: f64,
|
||||
/// Design rainfall intensity used (in/hr).
|
||||
pub intensity: f64,
|
||||
/// Time of concentration at the upstream end (minutes).
|
||||
pub tc: f64,
|
||||
/// Flow travel time through this pipe at design flow (minutes).
|
||||
pub travel_time: f64,
|
||||
/// Rational peak design discharge (cfs).
|
||||
pub design_q: f64,
|
||||
/// Just-full capacity (cfs).
|
||||
pub capacity: f64,
|
||||
/// Maximum open-channel capacity (~0.94 d) (cfs).
|
||||
pub max_capacity: f64,
|
||||
/// True if the design flow exceeds open-channel capacity (pipe surcharges).
|
||||
pub surcharged: bool,
|
||||
/// Normal depth (ft), or `None` if surcharged.
|
||||
pub normal_depth: Option<f64>,
|
||||
/// Critical depth (ft).
|
||||
pub critical_depth: f64,
|
||||
/// Actual velocity at design flow (ft/s).
|
||||
pub velocity: f64,
|
||||
/// Full-flow velocity (ft/s).
|
||||
pub velocity_full: f64,
|
||||
/// Design flow as a fraction of just-full capacity.
|
||||
pub pct_full: f64,
|
||||
/// HGL elevation at the upstream structure (ft), if an HGL pass was run.
|
||||
pub hgl_up: Option<f64>,
|
||||
/// HGL elevation at the downstream structure (ft), if an HGL pass was run.
|
||||
pub hgl_dn: Option<f64>,
|
||||
}
|
||||
|
||||
/// Per-node analysis result (populated by the HGL pass).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NodeResult {
|
||||
pub id: String,
|
||||
pub tc: f64,
|
||||
pub hgl: f64,
|
||||
pub rim: f64,
|
||||
/// True if the HGL rises above the rim (flooding).
|
||||
pub surcharge_to_surface: bool,
|
||||
}
|
||||
|
||||
/// Full network analysis (pipes + nodes).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Analysis {
|
||||
pub pipes: Vec<PipeResult>,
|
||||
pub nodes: Vec<NodeResult>,
|
||||
}
|
||||
|
||||
/// Options for [`Network::analyze`].
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AnalysisOptions {
|
||||
/// Minimum time of concentration (minutes) — floors the IDF duration.
|
||||
pub min_tc: f64,
|
||||
/// Tailwater elevation at outfalls (ft). `None` → free outfall at invert.
|
||||
pub tailwater: Option<f64>,
|
||||
/// Junction/structure loss coefficient K in `H = K * V^2 / 2g`.
|
||||
pub junction_k: f64,
|
||||
/// If set, use this constant intensity (in/hr) instead of the IDF curve.
|
||||
pub intensity_override: Option<f64>,
|
||||
}
|
||||
|
||||
impl Default for AnalysisOptions {
|
||||
fn default() -> Self {
|
||||
Self { min_tc: 10.0, tailwater: None, junction_k: 0.5, intensity_override: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors raised during analysis.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum NetworkError {
|
||||
UnknownNode(String),
|
||||
CyclicNetwork,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for NetworkError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
NetworkError::UnknownNode(id) => write!(f, "pipe references unknown node '{id}'"),
|
||||
NetworkError::CyclicNetwork => write!(f, "network contains a cycle"),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::error::Error for NetworkError {}
|
||||
|
||||
/// Conveyance K = (k/n) * A * R^(2/3), so that Q = K * sqrt(S).
|
||||
fn conveyance(n: f64, area: f64, radius: f64, k: f64) -> f64 {
|
||||
if n <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
k / n * area * radius.powf(2.0 / 3.0)
|
||||
}
|
||||
|
||||
impl Network {
|
||||
fn index(&self) -> HashMap<&str, usize> {
|
||||
self.nodes.iter().enumerate().map(|(i, nd)| (nd.id.as_str(), i)).collect()
|
||||
}
|
||||
|
||||
/// Topological node order, most-upstream first (Kahn's algorithm).
|
||||
fn topo_order(&self, idx: &HashMap<&str, usize>) -> Result<Vec<usize>, NetworkError> {
|
||||
let n = self.nodes.len();
|
||||
let mut indeg = vec![0usize; n];
|
||||
let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
|
||||
for p in &self.pipes {
|
||||
let u = *idx.get(p.from.as_str()).ok_or_else(|| NetworkError::UnknownNode(p.from.clone()))?;
|
||||
let v = *idx.get(p.to.as_str()).ok_or_else(|| NetworkError::UnknownNode(p.to.clone()))?;
|
||||
adj[u].push(v);
|
||||
indeg[v] += 1;
|
||||
}
|
||||
let mut queue: Vec<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
|
||||
let mut order = Vec::with_capacity(n);
|
||||
while let Some(u) = queue.pop() {
|
||||
order.push(u);
|
||||
for &v in &adj[u] {
|
||||
indeg[v] -= 1;
|
||||
if indeg[v] == 0 {
|
||||
queue.push(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
if order.len() != n {
|
||||
return Err(NetworkError::CyclicNetwork);
|
||||
}
|
||||
Ok(order)
|
||||
}
|
||||
|
||||
/// Accumulated C*A (acres) reaching each node, keyed by node id.
|
||||
pub fn accumulate_ca(&self) -> Result<HashMap<String, f64>, NetworkError> {
|
||||
let idx = self.index();
|
||||
let order = self.topo_order(&idx)?;
|
||||
let mut feeders: Vec<Vec<usize>> = vec![Vec::new(); self.nodes.len()];
|
||||
for p in &self.pipes {
|
||||
feeders[idx[p.to.as_str()]].push(idx[p.from.as_str()]);
|
||||
}
|
||||
let mut total = vec![0.0f64; self.nodes.len()];
|
||||
for &i in &order {
|
||||
let mut acc = self.nodes[i].ca();
|
||||
for &u in &feeders[i] {
|
||||
acc += total[u];
|
||||
}
|
||||
total[i] = acc;
|
||||
}
|
||||
Ok(self.nodes.iter().enumerate().map(|(i, nd)| (nd.id.clone(), total[i])).collect())
|
||||
}
|
||||
|
||||
/// Simple Rational analysis with one constant intensity (no Tc/HGL).
|
||||
/// Retained for quick checks; [`analyze`](Self::analyze) is the full method.
|
||||
pub fn analyze_rational(&self, intensity: f64) -> Result<Vec<PipeResult>, NetworkError> {
|
||||
let opts = AnalysisOptions { intensity_override: Some(intensity), ..Default::default() };
|
||||
// A flat IDF is unused when intensity_override is set.
|
||||
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<PipeSizeRecommendation>), 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<PipeSizeRecommendation>), 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<Vec<(u32, Analysis)>, 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<Analysis, NetworkError> {
|
||||
let k = K_MANNING_US;
|
||||
let idxm = self.index();
|
||||
let order = self.topo_order(&idxm)?;
|
||||
let n_nodes = self.nodes.len();
|
||||
let n_pipes = self.pipes.len();
|
||||
|
||||
// Resolve pipe endpoints to node indices once.
|
||||
let pe: Vec<(usize, usize)> = self
|
||||
.pipes
|
||||
.iter()
|
||||
.map(|p| (idxm[p.from.as_str()], idxm[p.to.as_str()]))
|
||||
.collect();
|
||||
|
||||
// incoming[node] = (pipe_idx, upstream_node_idx)
|
||||
let mut incoming: Vec<Vec<(usize, usize)>> = vec![Vec::new(); n_nodes];
|
||||
// outgoing[node] = (pipe_idx, downstream_node_idx)
|
||||
let mut outgoing: Vec<Vec<(usize, usize)>> = vec![Vec::new(); n_nodes];
|
||||
for (pi, &(u, v)) in pe.iter().enumerate() {
|
||||
outgoing[u].push((pi, v));
|
||||
incoming[v].push((pi, u));
|
||||
}
|
||||
|
||||
let ca = self.accumulate_ca()?;
|
||||
let total_ca: Vec<f64> = self.nodes.iter().map(|nd| ca[&nd.id]).collect();
|
||||
|
||||
// Per-pipe scratch.
|
||||
let mut p_slope = vec![0.0f64; n_pipes];
|
||||
let mut p_intensity = vec![0.0f64; n_pipes];
|
||||
let mut p_q = vec![0.0f64; n_pipes];
|
||||
let mut p_vel = vec![0.0f64; n_pipes];
|
||||
let mut p_surch = vec![false; n_pipes];
|
||||
let mut p_yn: Vec<Option<f64>> = vec![None; n_pipes];
|
||||
let mut p_travel = vec![0.0f64; n_pipes];
|
||||
let mut tc_node = vec![0.0f64; n_nodes];
|
||||
|
||||
// ── Forward pass (upstream → downstream): Tc, intensity, design Q, vel.
|
||||
for &i in &order {
|
||||
let nd = &self.nodes[i];
|
||||
let mut tc = if nd.kind == NodeKind::Outfall { 0.0 } else { nd.tc_inlet };
|
||||
for &(pi, _u) in &incoming[i] {
|
||||
tc = tc.max(tc_node[/*upstream*/ pe[pi].0] + p_travel[pi]);
|
||||
}
|
||||
tc = tc.max(opts.min_tc);
|
||||
tc_node[i] = tc;
|
||||
|
||||
for &(pi, v) in &outgoing[i] {
|
||||
let p = &self.pipes[pi];
|
||||
let slope = if p.length > 0.0 {
|
||||
(self.nodes[i].invert - self.nodes[v].invert) / p.length
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let intensity = opts.intensity_override.unwrap_or_else(|| idf.intensity(tc));
|
||||
let q = intensity * total_ca[i];
|
||||
let (q_max, _) = max_capacity(p.n, slope, p.diameter, k);
|
||||
let surcharged = q > q_max;
|
||||
let yn = normal_depth(q, p.n, slope, p.diameter, k);
|
||||
let area = if surcharged {
|
||||
full_area(p.diameter)
|
||||
} else {
|
||||
circular_geometry(yn.unwrap_or(p.diameter), p.diameter).0
|
||||
};
|
||||
let vel = if area > 0.0 { q / area } else { 0.0 };
|
||||
let travel = if vel > 0.0 { p.length / vel / 60.0 } else { 0.0 };
|
||||
|
||||
p_slope[pi] = slope;
|
||||
p_intensity[pi] = intensity;
|
||||
p_q[pi] = q;
|
||||
p_vel[pi] = vel;
|
||||
p_surch[pi] = surcharged;
|
||||
p_yn[pi] = yn;
|
||||
p_travel[pi] = travel;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Backward pass (downstream → upstream): HGL with junction losses.
|
||||
let mut hgl = vec![f64::NAN; n_nodes];
|
||||
let mut pipe_hgl_up = vec![None; n_pipes];
|
||||
let mut pipe_hgl_dn = vec![None; n_pipes];
|
||||
|
||||
for &d in order.iter().rev() {
|
||||
// Seed outfall HGL from tailwater (or free outfall at invert).
|
||||
if self.nodes[d].kind == NodeKind::Outfall && hgl[d].is_nan() {
|
||||
hgl[d] = opts.tailwater.unwrap_or(self.nodes[d].invert);
|
||||
}
|
||||
if hgl[d].is_nan() {
|
||||
hgl[d] = self.nodes[d].invert; // disconnected fallback
|
||||
}
|
||||
|
||||
for &(pi, u) in &incoming[d] {
|
||||
let p = &self.pipes[pi];
|
||||
let dia = p.diameter;
|
||||
let inv_d = self.nodes[d].invert;
|
||||
let inv_u = self.nodes[u].invert;
|
||||
let q = p_q[pi];
|
||||
|
||||
let (ws_d, hf) = if p_surch[pi] {
|
||||
let conv_full = conveyance(p.n, full_area(dia), dia / 4.0, k);
|
||||
let sf = if conv_full > 0.0 { (q / conv_full).powi(2) } else { 0.0 };
|
||||
let crown_d = inv_d + dia;
|
||||
(hgl[d].max(crown_d), sf * p.length)
|
||||
} else {
|
||||
let yn = p_yn[pi].unwrap_or(0.0);
|
||||
let (a, _pp, r, _t) = circular_geometry(yn, dia);
|
||||
let conv = conveyance(p.n, a, r, k);
|
||||
let sf = if conv > 0.0 { (q / conv).powi(2) } else { p_slope[pi].max(0.0) };
|
||||
(hgl[d].max(inv_d + yn), sf * p.length)
|
||||
};
|
||||
|
||||
let yn_u = p_yn[pi].unwrap_or(0.0);
|
||||
let hgl_us_pipe = (ws_d + hf).max(inv_u + yn_u);
|
||||
let hj = opts.junction_k * p_vel[pi].powi(2) / (2.0 * G_US);
|
||||
let hgl_u = hgl_us_pipe + hj;
|
||||
|
||||
hgl[u] = if hgl[u].is_nan() { hgl_u } else { hgl[u].max(hgl_u) };
|
||||
pipe_hgl_dn[pi] = Some(hgl[d]);
|
||||
pipe_hgl_up[pi] = Some(hgl_u);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Assemble results.
|
||||
let pipes = self
|
||||
.pipes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(pi, p)| {
|
||||
let capacity = full_flow_capacity(p.n, p_slope[pi], p.diameter, k);
|
||||
let velocity_full = if full_area(p.diameter) > 0.0 {
|
||||
capacity / full_area(p.diameter)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
PipeResult {
|
||||
id: p.id.clone(),
|
||||
from: p.from.clone(),
|
||||
to: p.to.clone(),
|
||||
slope: p_slope[pi],
|
||||
total_ca: total_ca[pe[pi].0],
|
||||
intensity: p_intensity[pi],
|
||||
tc: tc_node[pe[pi].0],
|
||||
travel_time: p_travel[pi],
|
||||
design_q: p_q[pi],
|
||||
capacity,
|
||||
max_capacity: max_capacity(p.n, p_slope[pi], p.diameter, k).0,
|
||||
surcharged: p_surch[pi],
|
||||
normal_depth: p_yn[pi],
|
||||
critical_depth: critical_depth(p_q[pi], p.diameter, G_US),
|
||||
velocity: p_vel[pi],
|
||||
velocity_full,
|
||||
pct_full: if capacity > 0.0 { p_q[pi] / capacity } else { 0.0 },
|
||||
hgl_up: pipe_hgl_up[pi],
|
||||
hgl_dn: pipe_hgl_dn[pi],
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let nodes = self
|
||||
.nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, nd)| NodeResult {
|
||||
id: nd.id.clone(),
|
||||
tc: tc_node[i],
|
||||
hgl: hgl[i],
|
||||
rim: nd.rim,
|
||||
surcharge_to_surface: nd.kind != NodeKind::Outfall && hgl[i] > nd.rim,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Analysis { pipes, nodes })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// N1 (inlet) -> N2 (inlet) -> OUT, two pipes.
|
||||
fn sample() -> Network {
|
||||
Network {
|
||||
nodes: vec![
|
||||
Node::inlet("N1", 100.0, 105.0, 2.0, 0.7), // CA = 1.4
|
||||
Node::inlet("N2", 99.0, 104.0, 3.0, 0.8), // CA = 2.4
|
||||
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 ca_accumulates_downstream() {
|
||||
let ca = sample().accumulate_ca().unwrap();
|
||||
assert!((ca["N1"] - 1.4).abs() < 1e-9);
|
||||
assert!((ca["N2"] - 3.8).abs() < 1e-9);
|
||||
assert!((ca["OUT"] - 3.8).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rational_design_flows() {
|
||||
let r = sample().analyze_rational(4.0).unwrap();
|
||||
let p1 = r.iter().find(|x| x.id == "P1").unwrap();
|
||||
let p2 = r.iter().find(|x| x.id == "P2").unwrap();
|
||||
assert!((p1.design_q - 5.6).abs() < 1e-6, "P1 {}", p1.design_q);
|
||||
assert!((p2.design_q - 15.2).abs() < 1e-6, "P2 {}", p2.design_q);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slope_from_inverts() {
|
||||
let r = sample().analyze_rational(4.0).unwrap();
|
||||
assert!((r[0].slope - 0.01).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_pipe_surcharges_under_heavy_flow() {
|
||||
let r = sample().analyze_rational(4.0).unwrap();
|
||||
let p1 = r.iter().find(|x| x.id == "P1").unwrap();
|
||||
let p2 = r.iter().find(|x| x.id == "P2").unwrap();
|
||||
assert!(!p1.surcharged, "P1 should fit");
|
||||
assert!(p1.normal_depth.is_some());
|
||||
assert!(p2.surcharged, "P2 should surcharge");
|
||||
assert!(p2.normal_depth.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_is_rejected() {
|
||||
let net = Network {
|
||||
nodes: vec![Node::junction("A", 10.0, 20.0, 0.0, 0.0), Node::junction("B", 9.0, 19.0, 0.0, 0.0)],
|
||||
pipes: vec![Pipe::new("AB", "A", "B", 50.0, 1.0, 0.013), Pipe::new("BA", "B", "A", 50.0, 1.0, 0.013)],
|
||||
};
|
||||
assert_eq!(net.accumulate_ca().unwrap_err(), NetworkError::CyclicNetwork);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_node_is_rejected() {
|
||||
let net = Network {
|
||||
nodes: vec![Node::outfall("OUT", 98.0, 103.0)],
|
||||
pipes: vec![Pipe::new("P", "GHOST", "OUT", 100.0, 1.5, 0.013)],
|
||||
};
|
||||
assert!(matches!(net.accumulate_ca().unwrap_err(), NetworkError::UnknownNode(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tc_accumulates_downstream() {
|
||||
// Downstream pipe's Tc must include upstream inlet time + travel time.
|
||||
let idf = IdfCurve::new(120.0, 10.0, 0.8);
|
||||
let a = sample().analyze(&idf, &AnalysisOptions::default()).unwrap();
|
||||
let p1 = a.pipes.iter().find(|x| x.id == "P1").unwrap();
|
||||
let p2 = a.pipes.iter().find(|x| x.id == "P2").unwrap();
|
||||
assert!(p2.tc >= p1.tc, "p2.tc {} p1.tc {}", p2.tc, p1.tc);
|
||||
// Intensity falls as Tc grows downstream.
|
||||
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.
|
||||
let idf = IdfCurve::new(120.0, 10.0, 0.8);
|
||||
let opts = AnalysisOptions { tailwater: Some(100.0), ..Default::default() };
|
||||
let a = sample().analyze(&idf, &opts).unwrap();
|
||||
let h = |id: &str| a.nodes.iter().find(|n| n.id == id).unwrap().hgl;
|
||||
assert!(h("OUT") <= h("N2"), "OUT {} N2 {}", h("OUT"), h("N2"));
|
||||
assert!(h("N2") <= h("N1"), "N2 {} N1 {}", h("N2"), h("N1"));
|
||||
assert!((h("OUT") - 100.0).abs() < 1e-9, "tailwater seeded");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
// 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//! Parser for the `.ssn` storm-sewer network text format.
|
||||
//!
|
||||
//! Whitespace-delimited; `#` starts a comment. Keywords:
|
||||
//!
|
||||
//! ```text
|
||||
//! IDF <a> <b> <c> # IDF curve i = a/(t+b)^c
|
||||
//! INTENSITY <in/hr> # OR a constant intensity (overrides IDF)
|
||||
//! TAILWATER <elev_ft> # outfall tailwater elevation
|
||||
//! MINTC <min> # minimum time of concentration
|
||||
//! JUNCTIONK <k> # junction loss coefficient
|
||||
//! NODE <id> <inlet|junction|outfall> <x> <y> <invert> <rim> [area] [C] [tc_inlet]
|
||||
//! PIPE <id> <from> <to> <length> <dia> <n>
|
||||
//! ```
|
||||
//!
|
||||
//! For `outfall` nodes the trailing `area`, `C`, and `tc_inlet` are omitted.
|
||||
|
||||
use crate::idf::IdfCurve;
|
||||
use crate::network::{AnalysisOptions, Network, Node, Pipe};
|
||||
|
||||
/// Result of parsing a `.ssn` document.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ParsedNetwork {
|
||||
pub network: Network,
|
||||
pub idf: IdfCurve,
|
||||
pub options: AnalysisOptions,
|
||||
}
|
||||
|
||||
/// Parse a `.ssn` document. Returns a human-readable error with the line number.
|
||||
pub fn parse_ssn(text: &str) -> Result<ParsedNetwork, String> {
|
||||
let mut network = Network::default();
|
||||
let mut idf = IdfCurve::new(120.0, 10.0, 0.8);
|
||||
let mut options = AnalysisOptions::default();
|
||||
|
||||
for (lineno, raw) in text.lines().enumerate() {
|
||||
let line = raw.split('#').next().unwrap_or("").trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let t: Vec<&str> = line.split_whitespace().collect();
|
||||
let ln = lineno + 1;
|
||||
let num = |i: usize| -> Result<f64, String> {
|
||||
t.get(i)
|
||||
.ok_or_else(|| format!("line {ln}: missing value #{} in `{line}`", i + 1))?
|
||||
.parse::<f64>()
|
||||
.map_err(|_| format!("line {ln}: `{}` is not a number", t[i]))
|
||||
};
|
||||
|
||||
match t[0].to_ascii_uppercase().as_str() {
|
||||
"IDF" => idf = IdfCurve::new(num(1)?, num(2)?, num(3)?),
|
||||
"INTENSITY" => options.intensity_override = Some(num(1)?),
|
||||
"TAILWATER" => options.tailwater = Some(num(1)?),
|
||||
"MINTC" => options.min_tc = num(1)?,
|
||||
"JUNCTIONK" => options.junction_k = num(1)?,
|
||||
"NODE" => {
|
||||
let id = *t.get(1).ok_or_else(|| format!("line {ln}: NODE needs an id"))?;
|
||||
let kind = t.get(2).ok_or_else(|| format!("line {ln}: NODE needs a kind"))?.to_ascii_lowercase();
|
||||
let (x, y, invert, rim) = (num(3)?, num(4)?, num(5)?, num(6)?);
|
||||
let node = match kind.as_str() {
|
||||
"outfall" => Node::outfall(id, invert, rim),
|
||||
"junction" => Node::junction(id, invert, rim, num(7)?, num(8)?),
|
||||
"inlet" => Node::inlet(id, invert, rim, num(7)?, num(8)?),
|
||||
other => return Err(format!("line {ln}: unknown node kind `{other}`")),
|
||||
}
|
||||
.at(x, y);
|
||||
let node = if t.len() > 9 { node.with_tc_inlet(num(9)?) } else { node };
|
||||
network.nodes.push(node);
|
||||
}
|
||||
"PIPE" => {
|
||||
let id = *t.get(1).ok_or_else(|| format!("line {ln}: PIPE needs an id"))?;
|
||||
let from = *t.get(2).ok_or_else(|| format!("line {ln}: PIPE needs a from-node"))?;
|
||||
let to = *t.get(3).ok_or_else(|| format!("line {ln}: PIPE needs a to-node"))?;
|
||||
network.pipes.push(Pipe::new(id, from, to, num(4)?, num(5)?, num(6)?));
|
||||
}
|
||||
other => return Err(format!("line {ln}: unknown keyword `{other}`")),
|
||||
}
|
||||
}
|
||||
Ok(ParsedNetwork { network, idf, options })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const DOC: &str = "\
|
||||
# demo
|
||||
IDF 60 10 0.8
|
||||
TAILWATER 100.5
|
||||
NODE N1 inlet 0 0 104 110 1.0 0.70 12
|
||||
NODE N2 inlet 300 0 102 108 1.0 0.70
|
||||
NODE OUT outfall 600 0 100 106
|
||||
PIPE P1 N1 N2 300 1.25 0.013
|
||||
PIPE P2 N2 OUT 300 1.50 0.013
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn parses_nodes_pipes_and_params() {
|
||||
let p = parse_ssn(DOC).unwrap();
|
||||
assert_eq!(p.network.nodes.len(), 3);
|
||||
assert_eq!(p.network.pipes.len(), 2);
|
||||
assert_eq!(p.options.tailwater, Some(100.5));
|
||||
let n2 = p.network.nodes.iter().find(|n| n.id == "N2").unwrap();
|
||||
assert_eq!((n2.x, n2.y), (300.0, 0.0));
|
||||
assert_eq!(n2.tc_inlet, 10.0); // defaulted (no trailing value)
|
||||
let n1 = p.network.nodes.iter().find(|n| n.id == "N1").unwrap();
|
||||
assert_eq!(n1.tc_inlet, 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyzes_after_parse() {
|
||||
let p = parse_ssn(DOC).unwrap();
|
||||
let a = p.network.analyze(&p.idf, &p.options).unwrap();
|
||||
assert_eq!(a.pipes.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_bad_line() {
|
||||
let err = parse_ssn("NODE N1 inlet 0 0 oops 110 1 0.7").unwrap_err();
|
||||
assert!(err.contains("line 1"), "{err}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
//! Plain-text report tables for an [`Analysis`] and pipe-sizing output, in the
|
||||
//! spirit of Hydraflow Storm Sewers' pipe and HGL summaries.
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
/// Format the pipe hydraulics table.
|
||||
pub fn pipe_table(a: &Analysis) -> String {
|
||||
let mut s = String::new();
|
||||
s.push_str(
|
||||
"Pipe From To Slope Tc i Q Cap %Full V yn HGLup HGLdn Status\n",
|
||||
);
|
||||
s.push_str(
|
||||
" ft/ft min in/hr cfs cfs % ft/s ft ft ft\n",
|
||||
);
|
||||
s.push_str(&"-".repeat(110));
|
||||
s.push('\n');
|
||||
for p in &a.pipes {
|
||||
let yn = p.normal_depth.map(|y| f(y, 6, 2)).unwrap_or_else(|| " full".into());
|
||||
let hup = p.hgl_up.map(|h| f(h, 8, 2)).unwrap_or_else(|| " --".into());
|
||||
let hdn = p.hgl_dn.map(|h| f(h, 8, 2)).unwrap_or_else(|| " --".into());
|
||||
let status = if p.surcharged { "SURCHARGED" } else { "ok" };
|
||||
s.push_str(&format!(
|
||||
"{:<6} {:<6} {:<6} {} {} {} {} {} {} {} {} {} {} {}\n",
|
||||
p.id,
|
||||
p.from,
|
||||
p.to,
|
||||
f(p.slope, 7, 4),
|
||||
f(p.tc, 5, 1),
|
||||
f(p.intensity, 6, 2),
|
||||
f(p.design_q, 6, 2),
|
||||
f(p.capacity, 6, 2),
|
||||
f(p.pct_full * 100.0, 6, 1),
|
||||
f(p.velocity, 6, 2),
|
||||
yn,
|
||||
hup,
|
||||
hdn,
|
||||
status,
|
||||
));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Format the node / HGL table.
|
||||
pub fn node_table(a: &Analysis) -> String {
|
||||
let mut s = String::new();
|
||||
s.push_str("Node Tc(min) Rim(ft) HGL(ft) Freeboard Status\n");
|
||||
s.push_str(&"-".repeat(60));
|
||||
s.push('\n');
|
||||
for n in &a.nodes {
|
||||
let fb = n.rim - n.hgl;
|
||||
let status = if n.surcharge_to_surface { "FLOODING" } else { "ok" };
|
||||
s.push_str(&format!(
|
||||
"{:<6} {} {} {} {} {}\n",
|
||||
n.id,
|
||||
f(n.tc, 7, 1),
|
||||
f(n.rim, 8, 2),
|
||||
f(n.hgl, 8, 2),
|
||||
f(fb, 9, 2),
|
||||
status,
|
||||
));
|
||||
}
|
||||
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();
|
||||
s.push_str("=== STORM SEWER ANALYSIS ===\n\n");
|
||||
s.push_str(&pipe_table(a));
|
||||
s.push('\n');
|
||||
s.push_str(&node_table(a));
|
||||
// Summary flags.
|
||||
let surcharged: Vec<&str> = a.pipes.iter().filter(|p| p.surcharged).map(|p| p.id.as_str()).collect();
|
||||
let flooding: Vec<&str> = a.nodes.iter().filter(|n| n.surcharge_to_surface).map(|n| n.id.as_str()).collect();
|
||||
s.push('\n');
|
||||
if surcharged.is_empty() && flooding.is_empty() {
|
||||
s.push_str("All pipes flow open-channel; no surface flooding.\n");
|
||||
} else {
|
||||
if !surcharged.is_empty() {
|
||||
s.push_str(&format!("Surcharged pipes: {}\n", surcharged.join(", ")));
|
||||
}
|
||||
if !flooding.is_empty() {
|
||||
s.push_str(&format!("Structures flooding (HGL > rim): {}\n", flooding.join(", ")));
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
|
@ -140,7 +140,6 @@ pub mod insert;
|
|||
pub mod model;
|
||||
pub mod layout;
|
||||
pub mod manage;
|
||||
pub mod storm_sewer;
|
||||
pub mod view;
|
||||
|
||||
// ── Auto-generated module registry ───────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -1,70 +0,0 @@
|
|||
# Storm Sewer module — integration plan
|
||||
|
||||
The ribbon tab (`mod.rs`) and the engine bridge (`analysis.rs`) are in place.
|
||||
What remains is wiring each `SS_*` ribbon command to a handler in the host
|
||||
command system.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
storm_sewer/mod.rs ribbon tab → emits ModuleEvent::Command("SS_*")
|
||||
│
|
||||
host command dispatch (src/app/commands.rs / src/command/)
|
||||
│
|
||||
storm_sewer/analysis.rs builds a stormsewer::Network and runs the engine
|
||||
│
|
||||
stormsewer crate Rational + Manning + HGL → Analysis + report text
|
||||
```
|
||||
|
||||
The network/hydraulics data is carried as **XDATA on the drawing entities**
|
||||
(`data.rs`), so the network round-trips to DWG/DXF and is analyzable directly:
|
||||
|
||||
| Network object | Entity | XDATA record `STORMSEWER_*` |
|
||||
|----------------|--------|------------------------------|
|
||||
| Structure (inlet/junction/outfall) | CIRCLE at the point | `STRUCT`: [kind, invert, rim, area, C] |
|
||||
| Pipe (link) | LINE between two structures | `PIPE`: [diameter, n, from-handle, to-handle] |
|
||||
|
||||
Connectivity is by entity handle: a pipe stores the handles of the two
|
||||
structures it connects, so `data::network_from_entities` rebuilds the
|
||||
`stormsewer::Network` (nodes N1, N2, … by encounter order) from the drawing.
|
||||
|
||||
## Command handlers — status
|
||||
|
||||
All `SS_*` commands are wired in `src/app/commands.rs::dispatch_command`:
|
||||
|
||||
| Command | Status | Action |
|
||||
|-----------|--------|--------|
|
||||
| `SS_INLET`/`SS_JUNCTION`/`SS_OUTFALL` | ✅ done | `PlaceStructure` — enter invert/rim/area/C, then click the location; commits an XDATA-tagged circle |
|
||||
| `SS_PIPE` | ✅ done | `PlacePipe` — enter diameter/n, then click the START and END structures; commits an XDATA-tagged line (endpoints at the clicks, connectivity by structure handle) |
|
||||
| `SS_ANALYZE` | ✅ done | rebuild the network from drawn entities → run engine → add flow/HGL labels + print report |
|
||||
| `SS_REPORT` | ✅ done | rebuild from drawn entities → print `report::format_analysis()` |
|
||||
| `SS_PROFILE` | ✅ done | rebuild from drawn entities → draw the HGL/invert/ground long-section |
|
||||
|
||||
**Interaction order matters.** Both commands collect typed values FIRST (the
|
||||
command line is focused via `focus_cmd_input()` at dispatch and stays focused
|
||||
through text input), then take the viewport click LAST and commit from it. A
|
||||
point-pick result (`NeedPoint`) does not re-focus the command line, so a
|
||||
click-then-type flow would lose focus and route Enter to `on_enter` → cancel.
|
||||
|
||||
The `.ssn` file path is retained in `analysis.rs` (`analyze_text` etc.) for
|
||||
file-based workflows and tests, but the ribbon commands now operate on the
|
||||
**drawn network**.
|
||||
|
||||
Snapping note: pipe endpoints land at the click points (where you click on each
|
||||
structure). Connectivity is exact (by handle) and analysis uses the structure
|
||||
centers, so the analysis is correct; a future refinement could redraw pipes
|
||||
exactly center-to-center.
|
||||
|
||||
## Remaining enhancements
|
||||
|
||||
- **Rainfall parameters UI** — `SS_ANALYZE` uses a default IDF (`60/(t+10)^0.8`)
|
||||
and free outfall; add an `SS_PARAMS` command to set IDF / tailwater / min-Tc.
|
||||
- **Edit command** — `SS_EDIT` to change a placed structure/pipe's values.
|
||||
- **Surcharge styling** — recolor surcharged pipes / flag flooded structures.
|
||||
- **Persistence check** — verify the StormSewer XDATA round-trips through
|
||||
DWG/DXF save+reload (acadrust supports XDATA; confirm end-to-end).
|
||||
|
||||
## Build
|
||||
|
||||
`build.rs` auto-discovers this directory (`storm_sewer/` → `StormSewerModule`)
|
||||
and regenerates `src/modules/registry.rs`, so the tab appears on `cargo build`.
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
# 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`.
|
||||
|
|
@ -1,256 +0,0 @@
|
|||
// Bridge between Open CAD Studio and the `stormsewer` engine crate.
|
||||
//
|
||||
// Runs the hydrology/hydraulics engine on a network and turns the result into
|
||||
// (a) acadrust entities to draw into the document and (b) a text report for the
|
||||
// command line. Networks come from a `.ssn` file (see `stormsewer::parse`); a
|
||||
// built-in sample backs the unit tests and the zero-argument fallbacks.
|
||||
|
||||
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, NodeKind, Pipe};
|
||||
use stormsewer::params::StormAnalysisParams;
|
||||
use stormsewer::parse::parse_ssn;
|
||||
use stormsewer::report::{format_analysis, format_multi_rp};
|
||||
|
||||
use super::data;
|
||||
|
||||
/// 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
|
||||
/// network without re-drawing its geometry.
|
||||
fn build_annotations(net: &Network, a: &Analysis) -> Vec<EntityType> {
|
||||
draw_network(net, a, &DrawConfig::default())
|
||||
.plan_labels
|
||||
.iter()
|
||||
.map(|l| label(l.x, l.y, l.text.clone(), l.height))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── Public API: analyze the network drawn in the active document ────────────
|
||||
|
||||
/// 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<Item = &'a EntityType>,
|
||||
params: &StormAnalysisParams,
|
||||
) -> Result<(Vec<EntityType>, String, Analysis), String> {
|
||||
let net = data::network_from_entities(entities)?;
|
||||
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<Item = &'a EntityType>,
|
||||
params: &StormAnalysisParams,
|
||||
) -> Result<Vec<EntityType>, String> {
|
||||
let net = data::network_from_entities(entities)?;
|
||||
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<Item = &'a EntityType>,
|
||||
params: &StormAnalysisParams,
|
||||
) -> Result<String, String> {
|
||||
let net = data::network_from_entities(entities)?;
|
||||
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<Item = &'a EntityType>,
|
||||
params: &StormAnalysisParams,
|
||||
) -> Result<String, String> {
|
||||
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).
|
||||
fn demo() -> (Network, IdfCurve, AnalysisOptions) {
|
||||
let net = Network {
|
||||
nodes: vec![
|
||||
Node::inlet("N1", 104.0, 110.0, 1.0, 0.70).with_tc_inlet(12.0).at(0.0, 0.0),
|
||||
Node::inlet("N2", 102.5, 108.5, 1.0, 0.70).with_tc_inlet(10.0).at(300.0, 0.0),
|
||||
Node::junction("N3", 101.2, 107.0, 0.5, 0.80).with_tc_inlet(8.0).at(550.0, 0.0),
|
||||
Node::outfall("OUT", 100.0, 106.0).at(730.0, 0.0),
|
||||
],
|
||||
pipes: vec![
|
||||
Pipe::new("P1", "N1", "N2", 300.0, 1.25, 0.013),
|
||||
Pipe::new("P2", "N2", "N3", 250.0, 1.50, 0.013),
|
||||
Pipe::new("P3", "N3", "OUT", 180.0, 1.75, 0.013),
|
||||
],
|
||||
};
|
||||
let idf = IdfCurve::new(60.0, 10.0, 0.8);
|
||||
let opts = AnalysisOptions { tailwater: Some(100.5), ..Default::default() };
|
||||
(net, idf, opts)
|
||||
}
|
||||
|
||||
fn v3(x: f64, y: f64) -> Vector3 {
|
||||
Vector3::new(x, y, 0.0)
|
||||
}
|
||||
|
||||
fn seg(x1: f64, y1: f64, x2: f64, y2: f64) -> EntityType {
|
||||
EntityType::Line(Line::from_points(v3(x1, y1), v3(x2, y2)))
|
||||
}
|
||||
|
||||
fn label(x: f64, y: f64, text: String, height: f64) -> EntityType {
|
||||
EntityType::MText(MText { value: text, insertion_point: v3(x, y), height, ..Default::default() })
|
||||
}
|
||||
|
||||
fn circle(x: f64, y: f64, r: f64) -> EntityType {
|
||||
EntityType::Circle(Circle { center: v3(x, y), radius: r, ..Default::default() })
|
||||
}
|
||||
|
||||
fn run_analysis(net: &Network, idf: &IdfCurve, opts: &AnalysisOptions) -> Result<Analysis, String> {
|
||||
net.analyze(idf, opts).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Plan-view entities (pipes, structure markers, flow/HGL labels) + the report.
|
||||
fn build_plan(net: &Network, a: &Analysis) -> (Vec<EntityType>, String) {
|
||||
let d = draw_network(net, a, &DrawConfig::default());
|
||||
let mut ents = Vec::new();
|
||||
for p in &d.plan_pipes {
|
||||
ents.push(seg(p.x1, p.y1, p.x2, p.y2));
|
||||
}
|
||||
for n in &d.plan_nodes {
|
||||
ents.push(circle(n.x, n.y, n.radius));
|
||||
}
|
||||
for l in &d.plan_labels {
|
||||
ents.push(label(l.x, l.y, l.text.clone(), l.height));
|
||||
}
|
||||
(ents, format_analysis(a))
|
||||
}
|
||||
|
||||
/// HGL long-section entities (ground / invert / HGL polylines + labels).
|
||||
fn build_profile(net: &Network, a: &Analysis) -> Vec<EntityType> {
|
||||
let d = draw_network(net, a, &DrawConfig::default());
|
||||
let mut ents = Vec::new();
|
||||
for pl in &d.profile_lines {
|
||||
for w in pl.pts.windows(2) {
|
||||
ents.push(seg(w[0].0, w[0].1, w[1].0, w[1].1));
|
||||
}
|
||||
}
|
||||
for l in &d.profile_labels {
|
||||
ents.push(label(l.x, l.y, l.text.clone(), l.height));
|
||||
}
|
||||
ents
|
||||
}
|
||||
|
||||
// ── Public API: from a `.ssn` document ──────────────────────────────────────
|
||||
|
||||
/// Open a file dialog and read a `.ssn` network file.
|
||||
///
|
||||
/// Returns `None` if the user cancels, `Some(Ok(text))` on success, or
|
||||
/// `Some(Err(msg))` if the chosen file cannot be read.
|
||||
pub fn pick_ssn_file() -> Option<Result<String, String>> {
|
||||
let path = rfd::FileDialog::new()
|
||||
.add_filter("Storm Sewer Network", &["ssn"])
|
||||
.set_title("Open storm-sewer network (.ssn)")
|
||||
.pick_file()?;
|
||||
Some(std::fs::read_to_string(&path).map_err(|e| format!("cannot read {}: {e}", path.display())))
|
||||
}
|
||||
|
||||
/// Parse a `.ssn` document, analyze it, and return (plan entities, report).
|
||||
pub fn analyze_text(text: &str) -> Result<(Vec<EntityType>, String), String> {
|
||||
let p = parse_ssn(text)?;
|
||||
let a = run_analysis(&p.network, &p.idf, &p.options)?;
|
||||
Ok(build_plan(&p.network, &a))
|
||||
}
|
||||
|
||||
/// Parse a `.ssn` document, analyze it, and return the HGL profile entities.
|
||||
pub fn profile_text(text: &str) -> Result<Vec<EntityType>, String> {
|
||||
let p = parse_ssn(text)?;
|
||||
let a = run_analysis(&p.network, &p.idf, &p.options)?;
|
||||
Ok(build_profile(&p.network, &a))
|
||||
}
|
||||
|
||||
/// Parse a `.ssn` document, analyze it, and return the formatted report.
|
||||
pub fn report_text_from(text: &str) -> Result<String, String> {
|
||||
let p = parse_ssn(text)?;
|
||||
let a = run_analysis(&p.network, &p.idf, &p.options)?;
|
||||
Ok(format_analysis(&a))
|
||||
}
|
||||
|
||||
// ── Built-in sample fallbacks (used by tests) ───────────────────────────────
|
||||
|
||||
/// Plan entities + report for the built-in sample network.
|
||||
pub fn analyze_plan() -> Result<(Vec<EntityType>, String), String> {
|
||||
let (net, idf, opts) = demo();
|
||||
let a = run_analysis(&net, &idf, &opts)?;
|
||||
Ok(build_plan(&net, &a))
|
||||
}
|
||||
|
||||
/// Profile entities for the built-in sample network.
|
||||
pub fn analyze_profile() -> Result<Vec<EntityType>, String> {
|
||||
let (net, idf, opts) = demo();
|
||||
let a = run_analysis(&net, &idf, &opts)?;
|
||||
Ok(build_profile(&net, &a))
|
||||
}
|
||||
|
||||
/// Formatted report for the built-in sample network.
|
||||
pub fn report_text() -> String {
|
||||
let (net, idf, opts) = demo();
|
||||
match run_analysis(&net, &idf, &opts) {
|
||||
Ok(a) => format_analysis(&a),
|
||||
Err(e) => format!("storm-sewer analysis error: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Back-compat alias used by the module's integration test.
|
||||
#[allow(dead_code)]
|
||||
pub fn demo_report() -> String {
|
||||
report_text()
|
||||
}
|
||||
|
|
@ -1,309 +0,0 @@
|
|||
// 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<f64> {
|
||||
text.trim().replace(',', ".").parse::<f64>().ok()
|
||||
}
|
||||
|
||||
enum CStep {
|
||||
PickPolyline,
|
||||
RunoffC,
|
||||
FlowLength,
|
||||
Slope,
|
||||
PickInlet,
|
||||
}
|
||||
|
||||
pub struct TagCatchment {
|
||||
step: CStep,
|
||||
picked: Option<EntityType>,
|
||||
c: f64,
|
||||
flow_length: f64,
|
||||
slope: f64,
|
||||
inlet_handle: Handle,
|
||||
area_ac: f64,
|
||||
acquire_hint: Option<String>,
|
||||
}
|
||||
|
||||
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<ObjectPickHit> {
|
||||
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<WireModel> {
|
||||
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<WireModel> {
|
||||
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<CmdResult> {
|
||||
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<Item = &'a EntityType>,
|
||||
entities_mut: impl Iterator<Item = &'a mut EntityType>,
|
||||
) -> Result<usize, String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,653 +0,0 @@
|
|||
// Storm-sewer data carried on drawing entities via XDATA, and reconstruction
|
||||
// of a `stormsewer::Network` from the drawn entities.
|
||||
//
|
||||
// Structures are circles tagged with the `STORMSEWER_STRUCT` app record
|
||||
// [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<Handle>,
|
||||
/// Pipe entity handles, same order as `network.pipes`.
|
||||
pub pipe_handles: Vec<Handle>,
|
||||
}
|
||||
|
||||
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 {
|
||||
NodeKind::Inlet => "inlet",
|
||||
NodeKind::Junction => "junction",
|
||||
NodeKind::Outfall => "outfall",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_kind(s: &str) -> NodeKind {
|
||||
match s {
|
||||
"outfall" => NodeKind::Outfall,
|
||||
"junction" => NodeKind::Junction,
|
||||
_ => NodeKind::Inlet,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// XDATA record for a pipe, linking the two structures it connects by handle.
|
||||
pub fn pipe_xdata(diameter: f64, n: f64, from: Handle, to: Handle) -> ExtendedDataRecord {
|
||||
let mut r = ExtendedDataRecord::new(APP_PIPE);
|
||||
r.add_value(XDataValue::Real(diameter));
|
||||
r.add_value(XDataValue::Real(n));
|
||||
r.add_value(XDataValue::Handle(from));
|
||||
r.add_value(XDataValue::Handle(to));
|
||||
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<f64> {
|
||||
if let XDataValue::Real(x) = v {
|
||||
Some(*x)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(v: &XDataValue) -> Option<Handle> {
|
||||
if let XDataValue::Handle(h) = v {
|
||||
Some(*h)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
struct StructRec {
|
||||
handle: Handle,
|
||||
kind: NodeKind,
|
||||
invert: f64,
|
||||
rim: f64,
|
||||
area: f64,
|
||||
c: f64,
|
||||
tc_inlet: f64,
|
||||
x: f64,
|
||||
y: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PipeRec {
|
||||
diameter: f64,
|
||||
n: f64,
|
||||
from: Handle,
|
||||
to: Handle,
|
||||
length: f64,
|
||||
}
|
||||
|
||||
struct CatchmentRec {
|
||||
c: f64,
|
||||
flow_length_ft: f64,
|
||||
slope: f64,
|
||||
inlet_handle: Option<Handle>,
|
||||
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<Item = &'a EntityType>,
|
||||
x: f64,
|
||||
y: f64,
|
||||
pick_padding_ft: f64,
|
||||
include_outfalls: bool,
|
||||
) -> Option<StructurePick> {
|
||||
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<Item = &'a EntityType>,
|
||||
x: f64,
|
||||
y: f64,
|
||||
pick_padding_ft: f64,
|
||||
include_outfalls: bool,
|
||||
) -> Option<Handle> {
|
||||
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<Item = &'a EntityType>,
|
||||
x: f64,
|
||||
y: f64,
|
||||
pick_padding_ft: f64,
|
||||
) -> Option<Handle> {
|
||||
nearest_structure_at_point(entities, x, y, pick_padding_ft, false)
|
||||
}
|
||||
|
||||
fn read_structure(e: &EntityType) -> Option<StructRec> {
|
||||
let rec = e.common().extended_data.get_record(APP_STRUCT)?;
|
||||
if rec.values.len() < 5 {
|
||||
return None;
|
||||
}
|
||||
let kind = match &rec.values[0] {
|
||||
XDataValue::String(s) => parse_kind(s),
|
||||
_ => return None,
|
||||
};
|
||||
let (x, y) = match e {
|
||||
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,
|
||||
invert: real(&rec.values[1])?,
|
||||
rim: real(&rec.values[2])?,
|
||||
area: real(&rec.values[3])?,
|
||||
c: real(&rec.values[4])?,
|
||||
tc_inlet,
|
||||
x,
|
||||
y,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_pipe(e: &EntityType) -> Option<PipeRec> {
|
||||
let rec = e.common().extended_data.get_record(APP_PIPE)?;
|
||||
if rec.values.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let length = match e {
|
||||
EntityType::Line(l) => {
|
||||
let dx = l.end.x - l.start.x;
|
||||
let dy = l.end.y - l.start.y;
|
||||
(dx * dx + dy * dy).sqrt()
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(PipeRec {
|
||||
diameter: real(&rec.values[0])?,
|
||||
n: real(&rec.values[1])?,
|
||||
from: handle(&rec.values[2])?,
|
||||
to: handle(&rec.values[3])?,
|
||||
length,
|
||||
})
|
||||
}
|
||||
|
||||
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<CatchmentRec> {
|
||||
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<Item = &'a EntityType>,
|
||||
entities_mut: impl Iterator<Item = &'a mut EntityType>,
|
||||
) -> Result<usize, String> {
|
||||
let drawn = drawn_network_from_entities(entities)?;
|
||||
let mut tc_by_handle: HashMap<Handle, f64> = 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<usize> {
|
||||
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<Item = &'a EntityType>) -> Result<DrawnNetwork, String> {
|
||||
let mut structs: Vec<StructRec> = Vec::new();
|
||||
let mut pipes_raw: Vec<(Handle, PipeRec)> = Vec::new();
|
||||
let mut catchments: Vec<CatchmentRec> = 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((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::<Vec<_>>())?;
|
||||
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<Item = &'a EntityType>) -> Result<Network, String> {
|
||||
Ok(drawn_network_from_entities(entities)?.network)
|
||||
}
|
||||
|
||||
fn assemble_network(structs: &[StructRec], pipes_raw: &[PipeRec]) -> Result<Network, String> {
|
||||
if structs.is_empty() {
|
||||
return Err("No storm-sewer structures in the drawing — place Inlet/Junction/Outfall first.".into());
|
||||
}
|
||||
|
||||
let mut id_of: HashMap<u64, String> = HashMap::new();
|
||||
let mut nodes = Vec::with_capacity(structs.len());
|
||||
for (idx, s) in structs.iter().enumerate() {
|
||||
let id = format!("N{}", idx + 1);
|
||||
id_of.insert(s.handle.value(), id.clone());
|
||||
let node = match s.kind {
|
||||
NodeKind::Inlet => Node::inlet(&id, s.invert, s.rim, s.area, s.c),
|
||||
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);
|
||||
}
|
||||
|
||||
let mut pipes = Vec::new();
|
||||
let mut dropped = 0;
|
||||
for (k, p) in pipes_raw.iter().enumerate() {
|
||||
match (id_of.get(&p.from.value()), id_of.get(&p.to.value())) {
|
||||
(Some(f), Some(t)) => {
|
||||
pipes.push(Pipe::new(&format!("P{}", k + 1), f, t, p.length, p.diameter, p.n));
|
||||
}
|
||||
_ => dropped += 1,
|
||||
}
|
||||
}
|
||||
if pipes.is_empty() {
|
||||
return Err(format!(
|
||||
"No connected storm-sewer pipes ({} structure(s) found, {} dangling pipe(s)). Use Pipe Run to connect structures.",
|
||||
structs.len(), dropped
|
||||
));
|
||||
}
|
||||
Ok(Network { nodes, pipes })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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() });
|
||||
e.common_mut().handle = Handle::new(h);
|
||||
e.common_mut().extended_data.add_record(structure_xdata(kind, invert, invert + 6.0, 1.0, 0.7));
|
||||
e
|
||||
}
|
||||
fn pipe(from: u64, to: u64, x1: f64, x2: 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().extended_data.add_record(pipe_xdata(1.5, 0.013, Handle::new(from), Handle::new(to)));
|
||||
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![
|
||||
structure(1, NodeKind::Inlet, 0.0, 104.0),
|
||||
structure(2, NodeKind::Outfall, 100.0, 100.0),
|
||||
pipe(1, 2, 0.0, 100.0),
|
||||
];
|
||||
let net = network_from_entities(ents.iter()).unwrap();
|
||||
assert_eq!(net.nodes.len(), 2);
|
||||
assert_eq!(net.pipes.len(), 1);
|
||||
assert_eq!(net.pipes[0].from, "N1");
|
||||
assert_eq!(net.pipes[0].to, "N2");
|
||||
assert!((net.pipes[0].length - 100.0).abs() < 1e-6);
|
||||
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<EntityType> = 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![
|
||||
structure(1, NodeKind::Inlet, 0.0, 104.0),
|
||||
pipe(1, 99, 0.0, 100.0), // 99 doesn't exist
|
||||
];
|
||||
assert!(network_from_entities(ents.iter()).is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,172 +0,0 @@
|
|||
// 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,286 +0,0 @@
|
|||
//! 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<path d="M6 38 q8 -14 16 0 t16 0 t16 0" fill="none" stroke="#4FC3F7" stroke-width="3"/>
|
||||
<polygon points="24,16 24,32 40,24" fill="#01579B"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 236 B |
|
|
@ -1,6 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<rect x="12" y="14" width="40" height="36" rx="3" fill="none" stroke="#01579B" stroke-width="3"/>
|
||||
<line x1="12" y1="24" x2="52" y2="24" stroke="#0277BD" stroke-width="3"/>
|
||||
<line x1="12" y1="32" x2="52" y2="32" stroke="#0277BD" stroke-width="3"/>
|
||||
<line x1="12" y1="40" x2="52" y2="40" stroke="#0277BD" stroke-width="3"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 419 B |
|
|
@ -1,4 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<circle cx="32" cy="32" r="18" fill="none" stroke="#01579B" stroke-width="3"/>
|
||||
<circle cx="32" cy="32" r="8" fill="#4FC3F7"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 221 B |
|
|
@ -1,4 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<rect x="10" y="24" width="24" height="16" fill="none" stroke="#01579B" stroke-width="3"/>
|
||||
<path d="M34 22 L54 14 L54 50 L34 42 Z" fill="#4FC3F7" opacity="0.85" stroke="#0277BD" stroke-width="2"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 292 B |
|
|
@ -1,6 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<line x1="12" y1="42" x2="52" y2="18" stroke="#01579B" stroke-width="4"/>
|
||||
<line x1="16" y1="48" x2="56" y2="24" stroke="#4FC3F7" stroke-width="4"/>
|
||||
<circle cx="13" cy="45" r="5" fill="none" stroke="#01579B" stroke-width="3"/>
|
||||
<circle cx="55" cy="21" r="5" fill="none" stroke="#01579B" stroke-width="3"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 403 B |
|
|
@ -1,5 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<polyline points="8,16 24,20 40,30 56,40" fill="none" stroke="#8D6E63" stroke-width="2"/>
|
||||
<polyline points="8,46 24,47 40,49 56,50" fill="none" stroke="#01579B" stroke-width="3"/>
|
||||
<polyline points="8,34 24,37 40,42 56,47" fill="none" stroke="#4FC3F7" stroke-width="2" stroke-dasharray="4 2"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 390 B |
|
|
@ -1,7 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<rect x="16" y="10" width="32" height="44" rx="2" fill="none" stroke="#01579B" stroke-width="3"/>
|
||||
<line x1="22" y1="20" x2="42" y2="20" stroke="#0277BD" stroke-width="2"/>
|
||||
<line x1="22" y1="28" x2="42" y2="28" stroke="#0277BD" stroke-width="2"/>
|
||||
<line x1="22" y1="36" x2="42" y2="36" stroke="#0277BD" stroke-width="2"/>
|
||||
<line x1="22" y1="44" x2="36" y2="44" stroke="#0277BD" stroke-width="2"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 495 B |
|
|
@ -1,118 +0,0 @@
|
|||
// 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<Result<String, String>> {
|
||||
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<String, String> {
|
||||
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<String, Handle> = HashMap::new();
|
||||
let mut coord: HashMap<String, (f64, f64)> = 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
// 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_"],
|
||||
};
|
||||
|
|
@ -1,221 +0,0 @@
|
|||
// Storm Sewer add-on (`opencad.storm_sewer`) — gravity storm-drain design & analysis.
|
||||
//
|
||||
// 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};
|
||||
|
||||
pub struct StormSewerModule;
|
||||
|
||||
// Register the SS_* command names for command-line autocomplete.
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &[
|
||||
"SS_INLET",
|
||||
"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",
|
||||
]
|
||||
});
|
||||
|
||||
const IC_INLET: &[u8] = include_bytes!("icons/inlet.svg");
|
||||
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");
|
||||
|
||||
/// Convenience: an SVG-icon tool that fires a named `SS_*` command.
|
||||
fn tool(id: &'static str, label: &'static str, icon: &'static [u8]) -> ToolDef {
|
||||
ToolDef {
|
||||
id,
|
||||
label,
|
||||
icon: IconKind::Svg(icon),
|
||||
event: ModuleEvent::Command(id.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
impl CadModule for StormSewerModule {
|
||||
fn id(&self) -> &'static str {
|
||||
"storm_sewer"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"Storm Sewer"
|
||||
}
|
||||
|
||||
fn ribbon_groups(&self) -> Vec<RibbonGroup> {
|
||||
vec![
|
||||
// ── Network: place structures and connect them with pipes ───────
|
||||
RibbonGroup {
|
||||
title: "Network",
|
||||
tools: vec![
|
||||
RibbonItem::LargeTool(tool("SS_INLET", "Inlet", IC_INLET)),
|
||||
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 ─────────────────
|
||||
RibbonGroup {
|
||||
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)),
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::plugin::all_ribbon_modules;
|
||||
|
||||
#[test]
|
||||
fn module_is_registered_in_ribbon() {
|
||||
let titles: Vec<&str> = all_ribbon_modules().iter().map(|m| m.title()).collect();
|
||||
assert!(titles.contains(&"Storm Sewer"), "ribbon tabs: {titles:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ribbon_exposes_core_tools() {
|
||||
let mut ids = Vec::new();
|
||||
for g in StormSewerModule.ribbon_groups() {
|
||||
for item in g.tools {
|
||||
if let RibbonItem::Tool(t) | RibbonItem::LargeTool(t) = item {
|
||||
ids.push(t.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_bridge_runs_inside_ocs() {
|
||||
// The stormsewer crate, called from within the OCS binary, produces a
|
||||
// full analysis report for the properly-sized sample network.
|
||||
let report = super::analysis::demo_report();
|
||||
assert!(report.contains("STORM SEWER ANALYSIS"), "no header:\n{report}");
|
||||
assert!(report.contains("P1") && report.contains("HGL"), "no pipe/HGL:\n{report}");
|
||||
assert!(report.contains("no surface flooding"), "expected ok design:\n{report}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_plan_emits_entities_and_report() {
|
||||
let (ents, report) = super::analysis::analyze_plan().expect("analysis runs");
|
||||
// 3 pipes + 4 structures + labels → comfortably more than 7 entities.
|
||||
assert!(ents.len() >= 7, "too few entities: {}", ents.len());
|
||||
assert!(report.contains("STORM SEWER ANALYSIS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_profile_emits_entities() {
|
||||
let ents = super::analysis::analyze_profile().expect("profile runs");
|
||||
// Ground + invert + HGL line segments along a 4-node stem.
|
||||
assert!(ents.len() >= 6, "too few profile entities: {}", ents.len());
|
||||
}
|
||||
|
||||
const SAMPLE_SSN: &str = "\
|
||||
IDF 60 10 0.8
|
||||
TAILWATER 100.5
|
||||
NODE N1 inlet 0 0 104 110 1 0.7 12
|
||||
NODE N2 inlet 300 0 102.5 108.5 1 0.7
|
||||
NODE OUT outfall 600 0 100 106
|
||||
PIPE P1 N1 N2 300 1.25 0.013
|
||||
PIPE P2 N2 OUT 300 1.5 0.013
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn analyze_text_parses_and_emits() {
|
||||
let (ents, report) = super::analysis::analyze_text(SAMPLE_SSN).expect("parse + analyze");
|
||||
assert!(ents.len() >= 5, "too few entities: {}", ents.len());
|
||||
assert!(report.contains("STORM SEWER ANALYSIS"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_text_parses_and_emits() {
|
||||
let ents = super::analysis::profile_text(SAMPLE_SSN).expect("parse + profile");
|
||||
assert!(ents.len() >= 4, "too few profile entities: {}", ents.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_ssn_reports_error() {
|
||||
let err = super::analysis::analyze_text("NODE X inlet 0 0 oops 1 1 1").unwrap_err();
|
||||
assert!(err.contains("line 1"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_doc_runs_on_drawn_network() {
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::{Circle, EntityType, Handle, Line};
|
||||
use stormsewer::network::NodeKind;
|
||||
|
||||
let mk_struct = |h: u64, kind, x: f64, inv: f64| {
|
||||
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(super::data::structure_xdata(kind, inv, inv + 6.0, 1.0, 0.7));
|
||||
e
|
||||
};
|
||||
let mut pipe =
|
||||
EntityType::Line(Line::from_points(Vector3::new(0.0, 0.0, 0.0), Vector3::new(200.0, 0.0, 0.0)));
|
||||
pipe.common_mut()
|
||||
.extended_data
|
||||
.add_record(super::data::pipe_xdata(1.5, 0.013, Handle::new(1), Handle::new(2)));
|
||||
let ents = vec![
|
||||
mk_struct(1, NodeKind::Inlet, 0.0, 104.0),
|
||||
mk_struct(2, NodeKind::Outfall, 200.0, 100.0),
|
||||
pipe,
|
||||
];
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
// 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<f64, String> {
|
||||
s.trim()
|
||||
.replace(',', ".")
|
||||
.parse::<f64>()
|
||||
.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<String, String> {
|
||||
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] <a> <b> <c> (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 <elev_ft> | 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 <minutes>")?)?;
|
||||
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 <k>")?)?;
|
||||
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 <ft/s>")?)?;
|
||||
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 <ft/s>")?)?;
|
||||
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 <percent>")?)?;
|
||||
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 <ft>")?)?;
|
||||
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 <ft>")?)?;
|
||||
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 <ft/ft>")?)?;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
// 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<dyn crate::modules::CadModule> {
|
||||
Box::new(super::StormSewerModule)
|
||||
}
|
||||
|
||||
fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool {
|
||||
dispatch::handle(host, cmd)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
# 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"]
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
// 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<WireModel> {
|
||||
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<WireModel> {
|
||||
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<WireModel> {
|
||||
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<WireModel> {
|
||||
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<StructurePick> {
|
||||
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<WireModel> {
|
||||
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<WireModel> {
|
||||
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)
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
// 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),
|
||||
}
|
||||
}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
// 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<Item = &'a EntityType>,
|
||||
params: &StormAnalysisParams,
|
||||
) -> Result<String, String> {
|
||||
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<Item = &'a EntityType>,
|
||||
params: &StormAnalysisParams,
|
||||
) -> Result<(Vec<PipeDiameterUpdate>, 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<Item = &'a mut EntityType>, 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
// 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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,299 +0,0 @@
|
|||
// Interactive storm-sewer drafting commands.
|
||||
//
|
||||
// Robust placement: a canvas click ALWAYS places a structure (with the values
|
||||
// typed so far, defaults for the rest), so dropping a structure works even if
|
||||
// command-line text entry is unavailable. If typed entry works, enter
|
||||
// invert/rim/area/C first and then click — the click uses those values.
|
||||
//
|
||||
// Pipes are pick-first: click the START structure then the END structure; the
|
||||
// pipe commits on the second click with the current diameter/n.
|
||||
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::{Circle, EntityType, Handle, Line};
|
||||
use glam::Vec3;
|
||||
|
||||
use stormsewer::network::NodeKind;
|
||||
|
||||
use super::data;
|
||||
use super::preview;
|
||||
use crate::command::{CadCommand, CmdResult, ObjectPickHit};
|
||||
use crate::scene::{Scene, WireModel};
|
||||
|
||||
fn parse_num(text: &str) -> Option<f64> {
|
||||
text.trim().replace(',', ".").parse::<f64>().ok()
|
||||
}
|
||||
|
||||
// ── Structure placement ─────────────────────────────────────────────────────
|
||||
|
||||
enum SStep {
|
||||
Invert,
|
||||
Rim,
|
||||
Area,
|
||||
C,
|
||||
Ready,
|
||||
}
|
||||
|
||||
pub struct PlaceStructure {
|
||||
kind: NodeKind,
|
||||
radius: f64,
|
||||
invert: f64,
|
||||
rim: f64,
|
||||
area: f64,
|
||||
c: f64,
|
||||
step: SStep,
|
||||
}
|
||||
|
||||
impl PlaceStructure {
|
||||
pub fn inlet() -> Self {
|
||||
Self::new(NodeKind::Inlet, 3.0)
|
||||
}
|
||||
pub fn junction() -> Self {
|
||||
Self::new(NodeKind::Junction, 4.0)
|
||||
}
|
||||
pub fn outfall() -> Self {
|
||||
Self::new(NodeKind::Outfall, 6.0)
|
||||
}
|
||||
fn new(kind: NodeKind, radius: f64) -> Self {
|
||||
Self { kind, radius, invert: 100.0, rim: 105.0, area: 1.0, c: 0.70, step: SStep::Invert }
|
||||
}
|
||||
fn commit(&self, x: f64, y: f64) -> CmdResult {
|
||||
let circ = Circle { center: Vector3::new(x, y, 0.0), radius: self.radius, ..Default::default() };
|
||||
let mut ent = EntityType::Circle(circ);
|
||||
let (area, c) = if self.kind == NodeKind::Outfall { (0.0, 0.0) } else { (self.area, self.c) };
|
||||
ent.common_mut()
|
||||
.extended_data
|
||||
.add_record(data::structure_xdata(self.kind, self.invert, self.rim, area, c));
|
||||
CmdResult::CommitAndExit(ent)
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for PlaceStructure {
|
||||
fn name(&self) -> &'static str {
|
||||
"SS_STRUCTURE"
|
||||
}
|
||||
fn prompt(&self) -> String {
|
||||
match self.step {
|
||||
SStep::Invert => format!("Storm {}: invert <{:.2}> (or click to place):", data::kind_str(self.kind), self.invert),
|
||||
SStep::Rim => format!("Rim <{:.2}> (or click to place):", self.rim),
|
||||
SStep::Area => format!("Drainage area, ac <{:.2}> (or click to place):", self.area),
|
||||
SStep::C => format!("Runoff C <{:.2}> (or click to place):", self.c),
|
||||
SStep::Ready => "Click to place the structure:".into(),
|
||||
}
|
||||
}
|
||||
fn wants_text_input(&self) -> bool {
|
||||
!matches!(self.step, SStep::Ready)
|
||||
}
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let v = parse_num(text);
|
||||
match self.step {
|
||||
SStep::Invert => {
|
||||
if let Some(x) = v {
|
||||
self.invert = x;
|
||||
}
|
||||
self.step = SStep::Rim;
|
||||
}
|
||||
SStep::Rim => {
|
||||
if let Some(x) = v {
|
||||
self.rim = x;
|
||||
}
|
||||
self.step = if self.kind == NodeKind::Outfall { SStep::Ready } else { SStep::Area };
|
||||
}
|
||||
SStep::Area => {
|
||||
if let Some(x) = v {
|
||||
self.area = x;
|
||||
}
|
||||
self.step = SStep::C;
|
||||
}
|
||||
SStep::C => {
|
||||
if let Some(x) = v {
|
||||
self.c = x;
|
||||
}
|
||||
self.step = SStep::Ready;
|
||||
}
|
||||
SStep::Ready => {}
|
||||
}
|
||||
None
|
||||
}
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
self.commit(pt.x as f64, pt.y as f64)
|
||||
}
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pipe placement (pick-first, so clicks always work) ──────────────────────
|
||||
|
||||
enum PStep {
|
||||
PickStart,
|
||||
PickEnd,
|
||||
}
|
||||
|
||||
pub struct PlacePipe {
|
||||
step: PStep,
|
||||
diameter: f64,
|
||||
n: f64,
|
||||
start_handle: Option<Handle>,
|
||||
start_xy: (f64, f64),
|
||||
start_label: Option<String>,
|
||||
acquire_hint: Option<String>,
|
||||
}
|
||||
|
||||
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),
|
||||
start_label: None,
|
||||
acquire_hint: None,
|
||||
}
|
||||
}
|
||||
fn commit(&self, end_handle: Handle, ex: f64, ey: f64) -> CmdResult {
|
||||
let line = Line::from_points(
|
||||
Vector3::new(self.start_xy.0, self.start_xy.1, 0.0),
|
||||
Vector3::new(ex, ey, 0.0),
|
||||
);
|
||||
let mut ent = EntityType::Line(line);
|
||||
let from = self.start_handle.unwrap_or(Handle::new(0));
|
||||
ent.common_mut().extended_data.add_record(data::pipe_xdata(self.diameter, self.n, from, end_handle));
|
||||
CmdResult::CommitAndExit(ent)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PlacePipe {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for PlacePipe {
|
||||
fn name(&self) -> &'static str {
|
||||
"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 => 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 resolve_object_pick(&self, scene: &Scene, x: f64, y: f64) -> Option<ObjectPickHit> {
|
||||
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<WireModel> {
|
||||
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<WireModel> {
|
||||
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
|
||||
}
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn click_places_structure_with_defaults() {
|
||||
let mut cmd = PlaceStructure::inlet();
|
||||
match cmd.on_point(Vec3::new(10.0, 20.0, 0.0)) {
|
||||
CmdResult::CommitAndExit(EntityType::Circle(c)) => {
|
||||
assert_eq!(c.center.x, 10.0);
|
||||
let e = EntityType::Circle(c);
|
||||
assert!(e.common().extended_data.get_record(data::APP_STRUCT).is_some());
|
||||
}
|
||||
_ => panic!("expected CommitAndExit(Circle)"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_values_are_captured_then_click_places() {
|
||||
let mut cmd = PlaceStructure::inlet();
|
||||
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_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);
|
||||
let e = EntityType::Line(l);
|
||||
assert!(e.common().extended_data.get_record(data::APP_PIPE).is_some());
|
||||
}
|
||||
_ => 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,61 +0,0 @@
|
|||
// 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<Item = &'a mut EntityType>,
|
||||
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<Item = &'a mut EntityType>,
|
||||
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)
|
||||
}
|
||||