Add plugin host architecture and Storm Sewer add-on package
Generic plugin runtime (src/plugin/, HostSession, per-tab plugin_state, command router). QGIS-style add-on layout with plugin.toml and single PluginRegistration. Storm Sewer refactored as reference consumer with dispatch.rs, catchments, LandXML, headless tests (43 passing). Core update.rs decoupled from storm_sewer via CadCommand object-pick hooks. Docs: plugin-architecture.md, plugin-template, PR and issue-78 reply drafts.
This commit is contained in:
parent
1199895f0e
commit
e46701456f
66 changed files with 4538 additions and 141 deletions
12
Cargo.lock
generated
12
Cargo.lock
generated
|
|
@ -4170,6 +4170,15 @@ dependencies = [
|
||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.39.4"
|
version = "0.39.4"
|
||||||
|
|
@ -5180,6 +5189,9 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stormsewer"
|
name = "stormsewer"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"quick-xml 0.37.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "strict-num"
|
name = "strict-num"
|
||||||
|
|
|
||||||
4
build.rs
4
build.rs
|
|
@ -7,6 +7,7 @@
|
||||||
// Rules for a directory to be picked up:
|
// Rules for a directory to be picked up:
|
||||||
// • Located directly in src/modules/
|
// • Located directly in src/modules/
|
||||||
// • Contains a mod.rs file
|
// • Contains a mod.rs file
|
||||||
|
// • Does NOT contain plugin.toml (add-ons register via BuiltinPlugin::ribbon)
|
||||||
// • Defines a pub struct {PascalCase}Module implementing CadModule
|
// • Defines a pub struct {PascalCase}Module implementing CadModule
|
||||||
// (unit struct — no ::new() needed)
|
// (unit struct — no ::new() needed)
|
||||||
//
|
//
|
||||||
|
|
@ -38,7 +39,8 @@ fn main() {
|
||||||
}
|
}
|
||||||
let name = entry.file_name().into_string().ok()?;
|
let name = entry.file_name().into_string().ok()?;
|
||||||
let mod_file = mods_dir.join(&name).join("mod.rs");
|
let mod_file = mods_dir.join(&name).join("mod.rs");
|
||||||
if mod_file.exists() {
|
let plugin_toml = mods_dir.join(&name).join("plugin.toml");
|
||||||
|
if mod_file.exists() && !plugin_toml.exists() {
|
||||||
Some(name)
|
Some(name)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ categories = ["science", "simulation"]
|
||||||
|
|
||||||
# Engine is std-only on purpose: dependency-light and WASM-friendly.
|
# Engine is std-only on purpose: dependency-light and WASM-friendly.
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
quick-xml = { version = "0.37", default-features = false }
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "stormsewer"
|
name = "stormsewer"
|
||||||
|
|
|
||||||
39
crates/stormsewer/examples/sample_landxml.xml
Normal file
39
crates/stormsewer/examples/sample_landxml.xml
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?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>
|
||||||
84
crates/stormsewer/src/catchment.rs
Normal file
84
crates/stormsewer/src/catchment.rs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
//! Catchment polygon geometry helpers (CAD-agnostic).
|
||||||
|
|
||||||
|
use crate::hydrology::kirpich_minutes;
|
||||||
|
|
||||||
|
/// Shoelace formula area for a closed polygon (square feet).
|
||||||
|
pub fn shoelace_area_sqft(vertices: &[(f64, f64)]) -> f64 {
|
||||||
|
let n = vertices.len();
|
||||||
|
if n < 3 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let mut sum = 0.0;
|
||||||
|
for i in 0..n {
|
||||||
|
let (x0, y0) = vertices[i];
|
||||||
|
let (x1, y1) = vertices[(i + 1) % n];
|
||||||
|
sum += x0 * y1 - x1 * y0;
|
||||||
|
}
|
||||||
|
(sum / 2.0).abs()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plan centroid of a closed polygon.
|
||||||
|
pub fn polygon_centroid(vertices: &[(f64, f64)]) -> (f64, f64) {
|
||||||
|
let n = vertices.len();
|
||||||
|
if n < 3 {
|
||||||
|
let sx: f64 = vertices.iter().map(|v| v.0).sum();
|
||||||
|
let sy: f64 = vertices.iter().map(|v| v.1).sum();
|
||||||
|
let d = n.max(1) as f64;
|
||||||
|
return (sx / d, sy / d);
|
||||||
|
}
|
||||||
|
let mut a2 = 0.0;
|
||||||
|
let mut cx = 0.0;
|
||||||
|
let mut cy = 0.0;
|
||||||
|
for i in 0..n {
|
||||||
|
let (x0, y0) = vertices[i];
|
||||||
|
let (x1, y1) = vertices[(i + 1) % n];
|
||||||
|
let cross = x0 * y1 - x1 * y0;
|
||||||
|
a2 += cross;
|
||||||
|
cx += (x0 + x1) * cross;
|
||||||
|
cy += (y0 + y1) * cross;
|
||||||
|
}
|
||||||
|
if a2.abs() < 1e-12 {
|
||||||
|
let sx: f64 = vertices.iter().map(|v| v.0).sum();
|
||||||
|
let sy: f64 = vertices.iter().map(|v| v.1).sum();
|
||||||
|
return (sx / n as f64, sy / n as f64);
|
||||||
|
}
|
||||||
|
(cx / (3.0 * a2), cy / (3.0 * a2))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert square feet to acres.
|
||||||
|
pub fn sqft_to_acres(area_sqft: f64) -> f64 {
|
||||||
|
area_sqft / 43_560.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default flow-path length: plan distance from catchment centroid to a target point (ft).
|
||||||
|
pub fn default_flow_length_ft(centroid: (f64, f64), target: (f64, f64)) -> f64 {
|
||||||
|
let dx = target.0 - centroid.0;
|
||||||
|
let dy = target.1 - centroid.1;
|
||||||
|
(dx * dx + dy * dy).sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kirpich Tc (minutes) for a catchment polygon draining toward a structure.
|
||||||
|
pub fn catchment_tc_minutes(flow_length_ft: f64, slope: f64) -> f64 {
|
||||||
|
kirpich_minutes(flow_length_ft, slope)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unit_square_area() {
|
||||||
|
let verts = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
|
||||||
|
assert!((shoelace_area_sqft(&verts) - 100.0).abs() < 1e-6);
|
||||||
|
assert!((sqft_to_acres(43_560.0) - 1.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn centroid_of_square() {
|
||||||
|
let verts = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)];
|
||||||
|
let (cx, cy) = polygon_centroid(&verts);
|
||||||
|
assert!((cx - 5.0).abs() < 1e-6 && (cy - 5.0).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
68
crates/stormsewer/src/design/criteria.rs
Normal file
68
crates/stormsewer/src/design/criteria.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
//! Design criteria for storm-sewer pipe sizing (velocity, capacity, catalogs).
|
||||||
|
|
||||||
|
/// Standard reinforced-concrete pipe diameters (inches) used in US storm design.
|
||||||
|
pub const STANDARD_RCP_INCHES: &[u32] =
|
||||||
|
&[8, 10, 12, 15, 18, 21, 24, 27, 30, 33, 36, 42, 48, 54, 60, 66, 72];
|
||||||
|
|
||||||
|
/// Convert a catalog diameter from inches to feet.
|
||||||
|
pub fn inches_to_ft(d_in: u32) -> f64 {
|
||||||
|
d_in as f64 / 12.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default ascending catalog in feet.
|
||||||
|
pub fn standard_diameters_ft() -> Vec<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
crates/stormsewer/src/design/inlets.rs
Normal file
44
crates/stormsewer/src/design/inlets.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
//! Inlet interception capacity (HEC-22 style grate on grade, simplified).
|
||||||
|
|
||||||
|
/// Grate-on-grade interception capacity (cfs).
|
||||||
|
///
|
||||||
|
/// Uses the HEC-22 composite gutter approximation for a depressed grate:
|
||||||
|
/// `Q = Cw * L * d^1.5 * sqrt(S)` with `Cw ≈ 3.0` (US customary calibration).
|
||||||
|
pub fn grate_capacity_cfs(grate_length_ft: f64, flow_depth_ft: f64, gutter_slope: f64) -> f64 {
|
||||||
|
if grate_length_ft <= 0.0 || flow_depth_ft <= 0.0 || gutter_slope <= 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
const CW: f64 = 3.0;
|
||||||
|
CW * grate_length_ft * flow_depth_ft.powf(1.5) * gutter_slope.sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check whether an inlet can capture the approach design flow.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct InletCheck {
|
||||||
|
pub design_q_cfs: f64,
|
||||||
|
pub capacity_cfs: f64,
|
||||||
|
pub ok: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_inlet(design_q_cfs: f64, grate_length_ft: f64, flow_depth_ft: f64, gutter_slope: f64) -> InletCheck {
|
||||||
|
let cap = grate_capacity_cfs(grate_length_ft, flow_depth_ft, gutter_slope);
|
||||||
|
InletCheck {
|
||||||
|
design_q_cfs,
|
||||||
|
capacity_cfs: cap,
|
||||||
|
ok: cap >= design_q_cfs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn longer_grate_carries_more() {
|
||||||
|
let a = grate_capacity_cfs(2.0, 0.15, 0.005);
|
||||||
|
let b = grate_capacity_cfs(5.0, 0.15, 0.005);
|
||||||
|
assert!(b > a);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
crates/stormsewer/src/design/mod.rs
Normal file
11
crates/stormsewer/src/design/mod.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
//! Storm-sewer design: criteria catalogs, pipe sizing, and sizing reports.
|
||||||
|
|
||||||
|
pub mod criteria;
|
||||||
|
pub mod inlets;
|
||||||
|
pub mod sizing;
|
||||||
|
|
||||||
|
pub use criteria::*;
|
||||||
|
pub use inlets::*;
|
||||||
|
pub use sizing::*;
|
||||||
287
crates/stormsewer/src/design/sizing.rs
Normal file
287
crates/stormsewer/src/design/sizing.rs
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
//! Pipe sizing against design criteria — smallest standard pipe that carries
|
||||||
|
//! the Rational design flow within velocity and capacity limits.
|
||||||
|
|
||||||
|
use crate::hydraulics::{
|
||||||
|
circular_geometry, full_area, full_flow_capacity, max_capacity, normal_depth, K_MANNING_US,
|
||||||
|
};
|
||||||
|
use crate::network::{Analysis, Network, Pipe};
|
||||||
|
|
||||||
|
use super::criteria::DesignCriteria;
|
||||||
|
|
||||||
|
/// Outcome of sizing a single pipe for a known discharge and slope.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum SizeOutcome {
|
||||||
|
/// Smallest catalog pipe that meets all criteria.
|
||||||
|
Sized,
|
||||||
|
/// Current diameter already meets criteria (may equal recommended).
|
||||||
|
Adequate,
|
||||||
|
/// No catalog pipe satisfies the criteria at this slope.
|
||||||
|
NoSolution,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of sizing one pipe cross-section.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct PipeSizeResult {
|
||||||
|
pub diameter_ft: f64,
|
||||||
|
pub velocity: f64,
|
||||||
|
pub pct_full: f64,
|
||||||
|
pub normal_depth: Option<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<_>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
crates/stormsewer/src/hydrology/idf_set.rs
Normal file
93
crates/stormsewer/src/hydrology/idf_set.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
//! Multi-return-period IDF curves for storm-sewer design.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use crate::idf::IdfCurve;
|
||||||
|
|
||||||
|
/// Rainfall IDF curves keyed by return period (years).
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct IdfSet {
|
||||||
|
/// Active design storm return period (years).
|
||||||
|
pub design_rp: u32,
|
||||||
|
curves: BTreeMap<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
7
crates/stormsewer/src/hydrology/mod.rs
Normal file
7
crates/stormsewer/src/hydrology/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
pub mod idf_set;
|
||||||
|
pub mod tc;
|
||||||
|
|
||||||
|
pub use idf_set::*;
|
||||||
|
pub use tc::*;
|
||||||
42
crates/stormsewer/src/hydrology/tc.rs
Normal file
42
crates/stormsewer/src/hydrology/tc.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
//! Time-of-concentration estimators (minutes).
|
||||||
|
|
||||||
|
/// Kirpich (1940) — overland flow over unpaved channel.
|
||||||
|
/// `l` = flow path length (ft), `s` = average slope (ft/ft, positive).
|
||||||
|
pub fn kirpich_minutes(l: f64, s: f64) -> f64 {
|
||||||
|
if l <= 0.0 || s <= 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
0.0078 * l.powf(0.77) * s.powf(-0.385)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FAA / TR-55 style sheet flow on paved surfaces.
|
||||||
|
/// `l` = flow path (ft), `s` = slope (ft/ft).
|
||||||
|
pub fn faa_sheet_flow_minutes(l: f64, s: f64) -> f64 {
|
||||||
|
if l <= 0.0 || s <= 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
// n=0.02, k=0.007 (US customary) → t = 0.007 * (n*L)^0.8 / (S^0.5 * k^0.2) with n fixed
|
||||||
|
let n = 0.02_f64;
|
||||||
|
let k = 0.007_f64;
|
||||||
|
0.007_f64 * (n * l).powf(0.8) / (s.sqrt() * k.powf(0.2))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kirpich_increases_with_length() {
|
||||||
|
let short = kirpich_minutes(200.0, 0.02);
|
||||||
|
let long = kirpich_minutes(800.0, 0.02);
|
||||||
|
assert!(long > short);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn faa_reasonable_range() {
|
||||||
|
let t = faa_sheet_flow_minutes(300.0, 0.01);
|
||||||
|
assert!(t > 0.5 && t < 30.0, "t={t}");
|
||||||
|
}
|
||||||
|
}
|
||||||
475
crates/stormsewer/src/io/landxml.rs
Normal file
475
crates/stormsewer/src/io/landxml.rs
Normal file
|
|
@ -0,0 +1,475 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
//! LandXML 1.2 pipe-network import (Civil 3D / InfraModel compatible subset).
|
||||||
|
|
||||||
|
use crate::network::NodeKind;
|
||||||
|
use quick_xml::events::Event;
|
||||||
|
use quick_xml::Reader;
|
||||||
|
/// Linear units used in the source document.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||||
|
pub enum LinearUnit {
|
||||||
|
#[default]
|
||||||
|
Foot,
|
||||||
|
Meter,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Diameter units for circular pipes.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
|
||||||
|
pub enum DiameterUnit {
|
||||||
|
#[default]
|
||||||
|
Inch,
|
||||||
|
Foot,
|
||||||
|
Millimeter,
|
||||||
|
Meter,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parsed LandXML document (one or more pipe networks).
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct LandXmlDocument {
|
||||||
|
pub linear_unit: LinearUnit,
|
||||||
|
pub diameter_unit: DiameterUnit,
|
||||||
|
pub networks: Vec<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
crates/stormsewer/src/io/mod.rs
Normal file
5
crates/stormsewer/src/io/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
pub mod landxml;
|
||||||
|
|
||||||
|
pub use landxml::*;
|
||||||
|
|
@ -10,8 +10,9 @@
|
||||||
//! * **Rational method** peak-flow accumulation down a pipe network,
|
//! * **Rational method** peak-flow accumulation down a pipe network,
|
||||||
//! * **Manning** open-channel / partial-flow hydraulics for circular conduits,
|
//! * **Manning** open-channel / partial-flow hydraulics for circular conduits,
|
||||||
//! * normal-depth, critical-depth and full-flow capacity,
|
//! * normal-depth, critical-depth and full-flow capacity,
|
||||||
//! * *(forthcoming)* **HEC-22** hydraulic-grade-line backwater with junction
|
//! * **HGL backwater** with junction losses and **standard-pipe sizing**
|
||||||
//! and structure losses.
|
//! 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
|
//! 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
|
//! to a native library, to WASM (for hydrocomplete.com), and is consumable as
|
||||||
|
|
@ -31,15 +32,25 @@
|
||||||
//! assert!((results[0].design_q - 5.6).abs() < 1e-6); // 4 * (0.7*2.0)
|
//! 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 drawing;
|
||||||
pub mod hydraulics;
|
pub mod hydraulics;
|
||||||
|
pub mod hydrology;
|
||||||
pub mod idf;
|
pub mod idf;
|
||||||
|
pub mod io;
|
||||||
pub mod network;
|
pub mod network;
|
||||||
|
pub mod params;
|
||||||
pub mod parse;
|
pub mod parse;
|
||||||
pub mod report;
|
pub mod report;
|
||||||
|
|
||||||
|
pub use catchment::*;
|
||||||
|
pub use design::*;
|
||||||
pub use drawing::*;
|
pub use drawing::*;
|
||||||
pub use hydraulics::*;
|
pub use hydraulics::*;
|
||||||
|
pub use hydrology::*;
|
||||||
pub use idf::*;
|
pub use idf::*;
|
||||||
|
pub use io::*;
|
||||||
pub use network::*;
|
pub use network::*;
|
||||||
|
pub use params::*;
|
||||||
pub use parse::*;
|
pub use parse::*;
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,11 @@
|
||||||
//! standard for gravity storm sewers. Looped networks are rejected by the
|
//! standard for gravity storm sewers. Looped networks are rejected by the
|
||||||
//! topological sort.
|
//! topological sort.
|
||||||
|
|
||||||
|
use crate::design::{size_network, DesignCriteria, PipeSizeRecommendation};
|
||||||
use crate::hydraulics::*;
|
use crate::hydraulics::*;
|
||||||
|
use crate::hydrology::IdfSet;
|
||||||
use crate::idf::IdfCurve;
|
use crate::idf::IdfCurve;
|
||||||
|
use crate::params::StormAnalysisParams;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
/// Kind of network node.
|
/// Kind of network node.
|
||||||
|
|
@ -151,7 +154,7 @@ pub struct Analysis {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Options for [`Network::analyze`].
|
/// Options for [`Network::analyze`].
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct AnalysisOptions {
|
pub struct AnalysisOptions {
|
||||||
/// Minimum time of concentration (minutes) — floors the IDF duration.
|
/// Minimum time of concentration (minutes) — floors the IDF duration.
|
||||||
pub min_tc: f64,
|
pub min_tc: f64,
|
||||||
|
|
@ -254,6 +257,40 @@ impl Network {
|
||||||
Ok(self.analyze(&IdfCurve::new(0.0, 1.0, 1.0), &opts)?.pipes)
|
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
|
/// Full analysis: Tc accumulation, per-pipe IDF intensity, Rational design
|
||||||
/// flows, pipe hydraulics, and an HGL backwater pass with junction losses.
|
/// flows, pipe hydraulics, and an HGL backwater pass with junction losses.
|
||||||
pub fn analyze(&self, idf: &IdfCurve, opts: &AnalysisOptions) -> Result<Analysis, NetworkError> {
|
pub fn analyze(&self, idf: &IdfCurve, opts: &AnalysisOptions) -> Result<Analysis, NetworkError> {
|
||||||
|
|
@ -513,6 +550,18 @@ mod tests {
|
||||||
assert!(p2.intensity <= p1.intensity, "i2 {} i1 {}", p2.intensity, p1.intensity);
|
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]
|
#[test]
|
||||||
fn hgl_rises_upstream() {
|
fn hgl_rises_upstream() {
|
||||||
// With a tailwater, HGL must be monotonically higher going upstream.
|
// With a tailwater, HGL must be monotonically higher going upstream.
|
||||||
|
|
|
||||||
73
crates/stormsewer/src/params.rs
Normal file
73
crates/stormsewer/src/params.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
//! Global storm-sewer analysis parameters (hydrology, hydraulics, sizing).
|
||||||
|
|
||||||
|
use crate::design::DesignCriteria;
|
||||||
|
use crate::hydrology::IdfSet;
|
||||||
|
use crate::network::AnalysisOptions;
|
||||||
|
|
||||||
|
/// Network-level parameters for analyze / size / report passes.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct StormAnalysisParams {
|
||||||
|
pub idf: IdfSet,
|
||||||
|
pub hydraulics: AnalysisOptions,
|
||||||
|
pub sizing: DesignCriteria,
|
||||||
|
/// Default grate length (ft) for HEC-22 inlet capacity checks at inlets.
|
||||||
|
pub inlet_grate_length_ft: f64,
|
||||||
|
/// Assumed gutter flow depth (ft) at the curb for inlet checks.
|
||||||
|
pub inlet_flow_depth_ft: f64,
|
||||||
|
/// Assumed gutter longitudinal slope (ft/ft) for inlet checks.
|
||||||
|
pub inlet_gutter_slope: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for StormAnalysisParams {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
idf: IdfSet::municipal_default(),
|
||||||
|
hydraulics: AnalysisOptions::default(),
|
||||||
|
sizing: DesignCriteria::municipal(),
|
||||||
|
inlet_grate_length_ft: 2.0,
|
||||||
|
inlet_flow_depth_ft: 0.15,
|
||||||
|
inlet_gutter_slope: 0.005,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StormAnalysisParams {
|
||||||
|
pub fn municipal() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Summary for command-line / dialog display.
|
||||||
|
pub fn summary(&self) -> String {
|
||||||
|
let c = self.idf.design_curve();
|
||||||
|
let tw = self
|
||||||
|
.hydraulics
|
||||||
|
.tailwater
|
||||||
|
.map(|t| format!("{t:.2} ft"))
|
||||||
|
.unwrap_or_else(|| "free".into());
|
||||||
|
format!(
|
||||||
|
"RP {}yr IDF i=a/(t+b)^c a={:.1} b={:.1} c={:.2} tailwater={tw} minTc={:.0}min junctionK={:.2} V={:.1}-{:.1} ft/s maxFull={:.0}%",
|
||||||
|
self.idf.design_rp,
|
||||||
|
c.a,
|
||||||
|
c.b,
|
||||||
|
c.c,
|
||||||
|
self.hydraulics.min_tc,
|
||||||
|
self.hydraulics.junction_k,
|
||||||
|
self.sizing.min_velocity,
|
||||||
|
self.sizing.max_velocity,
|
||||||
|
self.sizing.max_pct_full * 100.0,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn summary_includes_design_rp() {
|
||||||
|
let p = StormAnalysisParams::default();
|
||||||
|
assert!(p.summary().contains("RP 10yr"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
//! Plain-text report tables for an [`Analysis`], in the spirit of Hydraflow
|
//! Plain-text report tables for an [`Analysis`] and pipe-sizing output, in the
|
||||||
//! Storm Sewers' pipe and HGL summaries.
|
//! spirit of Hydraflow Storm Sewers' pipe and HGL summaries.
|
||||||
|
|
||||||
use crate::network::Analysis;
|
use crate::design::PipeSizeRecommendation;
|
||||||
|
use crate::hydrology::IdfSet;
|
||||||
|
use crate::network::{Analysis, AnalysisOptions, Network};
|
||||||
|
|
||||||
fn f(x: f64, w: usize, p: usize) -> String {
|
fn f(x: f64, w: usize, p: usize) -> String {
|
||||||
format!("{:>w$.p$}", x, w = w, p = p)
|
format!("{:>w$.p$}", x, w = w, p = p)
|
||||||
|
|
@ -68,6 +70,92 @@ pub fn node_table(a: &Analysis) -> String {
|
||||||
s
|
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.
|
/// Full report: pipe table followed by node/HGL table.
|
||||||
pub fn format_analysis(a: &Analysis) -> String {
|
pub fn format_analysis(a: &Analysis) -> String {
|
||||||
let mut s = String::new();
|
let mut s = String::new();
|
||||||
|
|
|
||||||
60
docs/PR-plugin-host.md
Normal file
60
docs/PR-plugin-host.md
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
# PR: Add plugin host (Phase 1)
|
||||||
|
|
||||||
|
**Target:** `HakanSeven12/OpenCADStudio`
|
||||||
|
**Branch:** `feature/plugin-host` on `mf4633/OpenCADStudio`
|
||||||
|
**Related:** Issue #78 (Storm Sewer interest check / extension architecture)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Introduces a QGIS-style add-on architecture so discipline-specific tools (storm sewer, sanitary, geotech, …) can ship **outside** the core application. Core ribbon tabs (Home, Model, View, …) are unchanged. Add-on packages register ribbon + commands through a single `PluginRegistration` hook.
|
||||||
|
|
||||||
|
**This PR contains only the generic host** — no Storm Sewer tab, no `stormsewer` engine crate, no civil/hydraulics code in `src/modules/`.
|
||||||
|
|
||||||
|
## What's included
|
||||||
|
|
||||||
|
| Area | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `src/plugin/` | Manifest, registry, `BuiltinPlugin` trait, `try_dispatch` |
|
||||||
|
| `src/app/plugin_host.rs` | `HostSession` adapter (document, undo, tab state, command line) |
|
||||||
|
| `src/app/document.rs` | Per-tab `plugin_state` map keyed by plugin id |
|
||||||
|
| `src/app/commands.rs` | Plugin dispatch before legacy command match |
|
||||||
|
| `src/command/mod.rs` | Generic `ObjectPickHit` + acquisition hooks on `CadCommand` |
|
||||||
|
| `build.rs` | Skips dirs with `plugin.toml` (add-ons register via plugin host) |
|
||||||
|
| `src/ui/ribbon/mod.rs` | `all_ribbon_modules()` = core tabs + plugin tabs |
|
||||||
|
| `docs/plugin-architecture.md` | Accepted architecture spec |
|
||||||
|
| `docs/plugin-template/` | Scaffold for new add-on authors |
|
||||||
|
|
||||||
|
## Design principles
|
||||||
|
|
||||||
|
1. **`src/plugin/` must not import domain modules** — keeps core mergeable.
|
||||||
|
2. **One package, one registration** — `plugin.toml` + `register.rs` + `BuiltinPlugin`.
|
||||||
|
3. **DWG round-trip** — plugins persist domain data on entity XDATA (documented per add-on in `PLUGIN.md`).
|
||||||
|
4. **Phase 2 ready** — same `plugin.toml` layout for future dynamic `.dll` loading.
|
||||||
|
|
||||||
|
## How to add an add-on (after merge)
|
||||||
|
|
||||||
|
Copy `docs/plugin-template/` → `src/modules/<name>/`, implement `BuiltinPlugin`, add `pub mod <name>;` to `modules/mod.rs`. No edits to `commands.rs`.
|
||||||
|
|
||||||
|
External repos: depend on extracted `ocs_plugin_api` (Phase 1b, follow-up PR).
|
||||||
|
|
||||||
|
## Follow-up (not in this PR)
|
||||||
|
|
||||||
|
- `ocs_plugin_api` workspace crate (semver-stable host surface)
|
||||||
|
- Dynamic plugin loading (`%APPDATA%/OpenCADStudio/plugins/`)
|
||||||
|
- Python/scripting bindings over host API (#29)
|
||||||
|
- Storm Sewer add-on — separate repo/PR: `mf4633` branch `feature/storm-sewer-module`
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cargo build
|
||||||
|
cargo test --lib
|
||||||
|
```
|
||||||
|
|
||||||
|
No domain plugin is registered in this branch; existing core tests should pass unchanged.
|
||||||
|
|
||||||
|
## Review questions
|
||||||
|
|
||||||
|
1. OK to add `inventory`-based plugin registration (same pattern as `CommandRegistration`)?
|
||||||
|
2. Should built-in add-ons ever live in the main repo, or only the host + template?
|
||||||
|
3. Priority for Phase 1b (`ocs_plugin_api` crate) vs Phase 2 (dynamic loading)?
|
||||||
44
docs/issue-78-reply.md
Normal file
44
docs/issue-78-reply.md
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# Draft reply for Issue #78
|
||||||
|
|
||||||
|
Paste as a GitHub comment on https://github.com/HakanSeven12/OpenCADStudio/issues/78
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
@HakanSeven12 @schoeller — following up with a concrete architecture proposal and an implementation ready for review.
|
||||||
|
|
||||||
|
### Proposal
|
||||||
|
|
||||||
|
I've drafted a **QGIS-style add-on model** on my fork:
|
||||||
|
|
||||||
|
- **Spec:** [`docs/plugin-architecture.md`](https://github.com/mf4633/OpenCADStudio/blob/feature/plugin-host/docs/plugin-architecture.md)
|
||||||
|
- **Scaffold:** [`docs/plugin-template/`](https://github.com/mf4633/OpenCADStudio/tree/feature/plugin-host/docs/plugin-template)
|
||||||
|
- **Framework PR branch:** [`feature/plugin-host`](https://github.com/mf4633/OpenCADStudio/tree/feature/plugin-host) — **host only, no Storm Sewer in core**
|
||||||
|
|
||||||
|
**Three layers:** host core → add-on package (`plugin.toml`, ribbon, commands) → optional headless engine crate. Domain data lives on DWG entities (XDATA), not a proprietary project DB.
|
||||||
|
|
||||||
|
**Phase 1 (in this PR):** in-process plugins via `inventory::submit!(PluginRegistration)`, `HostSession` API, per-document plugin state, command routing without editing `commands.rs`.
|
||||||
|
|
||||||
|
**Phase 2:** user install folder + dynamic `.dll`/`.so` with the same `plugin.toml`.
|
||||||
|
|
||||||
|
### PR ready for review
|
||||||
|
|
||||||
|
I can open a PR against upstream with **only the generic plugin host** — no civil/hydraulics tab in core. Storm Sewer stays on a separate branch/repo as the reference consumer: [`feature/storm-sewer-module`](https://github.com/mf4633/OpenCADStudio/tree/feature/storm-sewer-module).
|
||||||
|
|
||||||
|
### Re: script languages (@schoeller, #29)
|
||||||
|
|
||||||
|
Agree this shouldn't be either/or. Suggested sequencing:
|
||||||
|
|
||||||
|
1. Native Rust add-ons + stable `HostSession` / `ocs_plugin_api`
|
||||||
|
2. Python (or similar) as **bindings over that same API** — one extension surface, two authoring paths
|
||||||
|
|
||||||
|
Phase 1 defers embedded scripting until the native API is stable.
|
||||||
|
|
||||||
|
### Questions for maintainers
|
||||||
|
|
||||||
|
1. `ocs_plugin_api` as a workspace crate with semver — OK?
|
||||||
|
2. Should the main repo ship **zero** discipline modules, or optional built-ins for dev?
|
||||||
|
3. Priority: extract API crate (1b) vs dynamic loading (2)?
|
||||||
|
|
||||||
|
Happy to open the framework PR whenever timing works. Storm Sewer can follow as a separate installable add-on once the host lands.
|
||||||
|
|
||||||
|
— Michael
|
||||||
339
docs/plugin-architecture.md
Normal file
339
docs/plugin-architecture.md
Normal file
|
|
@ -0,0 +1,339 @@
|
||||||
|
# Open CAD Studio — Plugin Architecture
|
||||||
|
|
||||||
|
**Status:** Accepted (phase 1)
|
||||||
|
**Author:** Open CAD Studio contributors
|
||||||
|
**Date:** June 2026
|
||||||
|
|
||||||
|
This document is the **authoritative spec** for how add-on packages integrate with Open CAD Studio. It follows patterns familiar from [QGIS](https://plugins.qgis.org/) and other open-source extensibility models: a small metadata file, a single entry-point registration, optional separate engine crate, and user-installable packages in a later phase.
|
||||||
|
|
||||||
|
> **Scope:** Generic host runtime only (`src/plugin/`, `src/app/plugin_host.rs`). Domain plugins (Storm Sewer, future sanitary/geotech) live under `src/modules/<name>/` and optional `crates/<engine>/`. They **consume** this API; they are **not** part of the framework source.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design goals
|
||||||
|
|
||||||
|
| Goal | Rationale |
|
||||||
|
|------|-----------|
|
||||||
|
| **One package, one registration** | Ribbon tab, commands, and manifest ship together — no duplicate hooks in `build.rs` and `commands.rs`. |
|
||||||
|
| **Stable host surface** | Plugin authors target `HostSession` / future `ocs_plugin_api` with semver, not `OpenCADStudio` internals. |
|
||||||
|
| **Open-source add-on ergonomics** | Separate git repo + workspace crate is supported; in-tree built-ins use the same layout. |
|
||||||
|
| **DWG round-trip** | Domain data on entities (XDATA), not opaque plugin databases. |
|
||||||
|
| **Engine reuse** | Headless crates (`stormsewer`, …) run in WASM/CLI without the CAD host. |
|
||||||
|
|
||||||
|
## Non-goals (phase 1)
|
||||||
|
|
||||||
|
- Sandboxed scripting (Python/Lua).
|
||||||
|
- Replacing the `acadrust` entity model.
|
||||||
|
- Full Autodesk CUI XML import.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Three layers (do not mix)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Layer A — Host core │
|
||||||
|
│ iced UI · Scene · Document · Undo · Command line │
|
||||||
|
│ Built-in ribbon tabs: Home, Model, View, … (NOT plugins) │
|
||||||
|
└───────────────────────────────┬────────────────────────────────────┘
|
||||||
|
│ HostSession (stable adapter)
|
||||||
|
┌───────────────────────────────▼────────────────────────────────────┐
|
||||||
|
│ Layer B — Add-on plugin package │
|
||||||
|
│ plugin.toml · manifest.rs · register.rs · plugin.rs · dispatch.rs │
|
||||||
|
│ Optional ribbon (CadModule) · per-tab state · XDATA schemas │
|
||||||
|
└───────────────────────────────┬────────────────────────────────────┘
|
||||||
|
│ pure Rust API
|
||||||
|
┌───────────────────────────────▼────────────────────────────────────┐
|
||||||
|
│ Layer C — Domain engine crate (optional) │
|
||||||
|
│ crates/stormsewer — hydraulics, IO, no iced/acadrust dependency │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| Layer | Examples | May depend on |
|
||||||
|
|-------|----------|---------------|
|
||||||
|
| **A — Host core** | `src/app/`, `src/ui/`, `src/modules/home/` | Everything in the app |
|
||||||
|
| **B — Plugin package** | `src/modules/storm_sewer/` | Host + optional engine crate |
|
||||||
|
| **C — Engine** | `crates/stormsewer/` | `std` only (target: WASM/CLI too) |
|
||||||
|
|
||||||
|
**Hard rules**
|
||||||
|
|
||||||
|
1. `src/plugin/` must **not** import any domain module (`storm_sewer`, …).
|
||||||
|
2. Engine crates must **not** import `iced`, `acadrust`, or `OpenCADStudio`.
|
||||||
|
3. Add-on plugins must **not** edit `src/app/commands.rs` for new commands.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comparison to QGIS
|
||||||
|
|
||||||
|
| QGIS | Open CAD Studio |
|
||||||
|
|------|-----------------|
|
||||||
|
| `metadata.txt` (name, version, author, …) | `plugin.toml` beside the package |
|
||||||
|
| `classFactory(iface)` in `__init__.py` | `inventory::submit!(PluginRegistration { construct })` in `register.rs` |
|
||||||
|
| `iface` stable API | `HostSession` → future `ocs_plugin_api` crate |
|
||||||
|
| User folder `…/python/plugins/<id>/` | Phase 2: `%APPDATA%/OpenCADStudio/plugins/<id>/` |
|
||||||
|
| Plugin repository (plugins.qgis.org) | Future: curated index; today = git + in-tree |
|
||||||
|
| `qgisMinimumVersion` | `api_version` in manifest (host ABI major) |
|
||||||
|
| Processing algorithms | Headless engine crates + `SS_ANALYZE`-style commands |
|
||||||
|
| Vector layer provider | XDATA on DWG entities (`STORMSEWER_*`) |
|
||||||
|
|
||||||
|
QGIS separates **core application** from **Python plugins** loaded at runtime. Open CAD Studio phase 1 compiles add-ons **in-tree** (same ergonomics, static linking). Phase 2 adds dynamic `.dll`/`.so` with the **same** `plugin.toml` and C ABI entry point.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Add-on package layout
|
||||||
|
|
||||||
|
Every add-on — whether in-tree or external — uses this directory shape:
|
||||||
|
|
||||||
|
```
|
||||||
|
<plugin_id>/ # e.g. storm_sewer or opencad-storm-sewer repo
|
||||||
|
plugin.toml # human metadata (mirrors manifest.rs)
|
||||||
|
PLUGIN.md # XDATA schemas, command reference
|
||||||
|
register.rs # ONLY inventory::submit! — no domain logic
|
||||||
|
plugin.rs # thin BuiltinPlugin impl
|
||||||
|
manifest.rs # static PluginManifest (compile-time truth)
|
||||||
|
dispatch.rs # command routing for this plugin
|
||||||
|
state.rs # per-document tab state (optional)
|
||||||
|
mod.rs # CadModule ribbon (if the plugin has a tab)
|
||||||
|
icons/ # SVG assets
|
||||||
|
… # domain modules (data.rs, preview.rs, …)
|
||||||
|
|
||||||
|
crates/<engine>/ # optional, separate workspace member
|
||||||
|
Cargo.toml
|
||||||
|
src/
|
||||||
|
```
|
||||||
|
|
||||||
|
### `plugin.toml` (metadata file)
|
||||||
|
|
||||||
|
Source of truth for **humans and phase-2 loader**. Values must match `manifest.rs`.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[plugin]
|
||||||
|
id = "opencad.storm_sewer"
|
||||||
|
name = "Storm Sewer"
|
||||||
|
version = "0.2.0"
|
||||||
|
description = "Gravity storm-drain network design and analysis"
|
||||||
|
author = "Open CAD Studio contributors"
|
||||||
|
license = "GPL-3.0-only"
|
||||||
|
homepage = "https://github.com/…/storm-sewer"
|
||||||
|
|
||||||
|
[opencad]
|
||||||
|
api_version = 1
|
||||||
|
ribbon_order = 50
|
||||||
|
command_prefixes = ["SS_"]
|
||||||
|
xdata_apps = ["STORMSEWER_STRUCT", "STORMSEWER_PIPE", "STORMSEWER_CATCHMENT"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Discovery rule:** If `src/modules/<dir>/plugin.toml` exists, `build.rs` **excludes** that directory from the auto-generated ribbon registry. The tab is registered only via `BuiltinPlugin::ribbon()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Host runtime API (phase 1)
|
||||||
|
|
||||||
|
### `PluginManifest`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct PluginManifest {
|
||||||
|
pub id: &'static str, // reverse-DNS: "opencad.storm_sewer"
|
||||||
|
pub name: &'static str,
|
||||||
|
pub version: &'static str,
|
||||||
|
pub description: &'static str,
|
||||||
|
pub api_version: ApiVersion, // host ABI major; must match host
|
||||||
|
pub ribbon_order: i32, // sort key among add-on tabs
|
||||||
|
pub xdata_apps: &'static [&'static str],
|
||||||
|
pub command_prefixes: &'static [&'static str],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `BuiltinPlugin`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait BuiltinPlugin: Send + Sync {
|
||||||
|
fn manifest(&self) -> &'static PluginManifest;
|
||||||
|
fn ribbon(&self) -> Box<dyn CadModule>;
|
||||||
|
fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Registration (single entry point)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// register.rs — keep this file free of domain logic
|
||||||
|
inventory::submit! {
|
||||||
|
crate::plugin::registry::PluginRegistration {
|
||||||
|
construct: || Box::new(MyPlugin),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Host startup:
|
||||||
|
|
||||||
|
1. `inventory::iter::<PluginRegistration>` constructs all plugins.
|
||||||
|
2. `try_dispatch` routes commands before the legacy `commands.rs` match.
|
||||||
|
3. `all_ribbon_modules()` = core tabs from `build.rs` + plugin tabs sorted by `ribbon_order`.
|
||||||
|
|
||||||
|
### `HostSession` — plugin-facing surface
|
||||||
|
|
||||||
|
Plugins use `HostSession`, not `OpenCADStudio`:
|
||||||
|
|
||||||
|
| Category | Methods |
|
||||||
|
|----------|---------|
|
||||||
|
| Document | `document()`, `document_mut()`, `entities()`, `entities_mut()`, `add_entity()`, `bump_geometry()` |
|
||||||
|
| Tab state | `plugin_state()`, `plugin_state_mut()`, `ensure_plugin_state()` keyed by `manifest.id` |
|
||||||
|
| Command line | `push_info`, `push_output`, `push_error`, `set_active_command` |
|
||||||
|
| Undo / dirty | `push_undo`, `set_dirty` |
|
||||||
|
|
||||||
|
Phase 1b: extract to `crates/ocs_plugin_api` with semver when the surface stabilizes.
|
||||||
|
|
||||||
|
### Command routing
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// app/commands.rs — plugins run first
|
||||||
|
if crate::plugin::try_dispatch(self, tab_index, cmd) {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
// … legacy core commands …
|
||||||
|
```
|
||||||
|
|
||||||
|
Plugins own:
|
||||||
|
|
||||||
|
- One-shot commands (`SS_ANALYZE`)
|
||||||
|
- Interactive acquisition (`SS_PIPE` → `PlacePipe`)
|
||||||
|
- Subcommands (`SS_PARAMS RP 25`)
|
||||||
|
|
||||||
|
Autocomplete: each plugin submits `inventory::submit!(CommandRegistration { names: &[…] })` in `mod.rs` or `register.rs`.
|
||||||
|
|
||||||
|
Interactive acquisition (C3D-style orange ObjectPick) stays in the **host** via generic `CadCommand` hooks — `resolve_object_pick`, `object_pick_hover_previews`, `entity_pick_acquire_previews` — so `app/update.rs` never imports domain modules.
|
||||||
|
|
||||||
|
### Per-document state
|
||||||
|
|
||||||
|
```rust
|
||||||
|
DocumentTab {
|
||||||
|
plugin_state: HashMap<&'static str, Box<dyn Any + Send + Sync>>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Store under `manifest.id` (e.g. `opencad.storm_sewer`), not ad hoc globals.
|
||||||
|
|
||||||
|
### XDATA contract
|
||||||
|
|
||||||
|
Domain persistence lives on entities. Document schemas in `PLUGIN.md`:
|
||||||
|
|
||||||
|
| App id | Owner | Purpose |
|
||||||
|
|--------|-------|---------|
|
||||||
|
| `STORMSEWER_STRUCT` | `opencad.storm_sewer` | Inlet / junction / outfall |
|
||||||
|
| `STORMSEWER_PIPE` | `opencad.storm_sewer` | Pipe link between structures |
|
||||||
|
| `STORMSEWER_CATCHMENT` | `opencad.storm_sewer` | Catchment boundary + hydrology |
|
||||||
|
|
||||||
|
Host may add `xdata::read_record` / `write_record` helpers later; plugins use `acadrust` XDATA APIs today.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core ribbon vs add-on ribbon
|
||||||
|
|
||||||
|
| Kind | Location | Registration |
|
||||||
|
|------|----------|--------------|
|
||||||
|
| **Core tab** | `src/modules/home/`, `view/`, … | `build.rs` auto-discovers `mod.rs` (no `plugin.toml`) |
|
||||||
|
| **Add-on tab** | `src/modules/storm_sewer/`, … | `plugin.toml` + `BuiltinPlugin::ribbon()` |
|
||||||
|
|
||||||
|
This mirrors QGIS: the application ships core menus; plugins add tabs/tools without patching the host binary.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phased rollout
|
||||||
|
|
||||||
|
### Phase 1 — Built-in add-ons (current)
|
||||||
|
|
||||||
|
- [x] `src/plugin/` runtime + `try_dispatch`
|
||||||
|
- [x] Per-tab `plugin_state`
|
||||||
|
- [x] Storm Sewer off `commands.rs` monolith
|
||||||
|
- [x] Single registration (`plugin.toml` + `BuiltinPlugin::ribbon`)
|
||||||
|
- [ ] Extract `ocs_plugin_api` crate
|
||||||
|
- [ ] Plugin manager UI stub (list installed, versions)
|
||||||
|
|
||||||
|
### Phase 2 — Dynamic loading (desktop)
|
||||||
|
|
||||||
|
```
|
||||||
|
%APPDATA%/OpenCADStudio/plugins/
|
||||||
|
opencad.storm_sewer/
|
||||||
|
plugin.toml
|
||||||
|
opencad_storm_sewer.dll # cdylib
|
||||||
|
```
|
||||||
|
|
||||||
|
- `libloading` + `#[no_mangle] extern "C" fn ocs_plugin_register() -> *const PluginVTable`
|
||||||
|
- `api_version` compatibility gate at load time
|
||||||
|
- Enable/disable in settings (like QGIS plugin manager)
|
||||||
|
|
||||||
|
### Phase 3 — Interchange & QA
|
||||||
|
|
||||||
|
- LandXML / SWMM export as plugins or engine features
|
||||||
|
- Golden-file tests per plugin
|
||||||
|
- Public plugin index (optional)
|
||||||
|
|
||||||
|
### Phase 4 — Live analysis & WASM
|
||||||
|
|
||||||
|
- `on_entity_committed` hooks
|
||||||
|
- WASM-hosted engines on hydrocomplete.com
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authoring a new add-on (checklist)
|
||||||
|
|
||||||
|
1. Copy `docs/plugin-template/` into `src/modules/<name>/`.
|
||||||
|
2. Fill `plugin.toml` and `manifest.rs` (keep in sync).
|
||||||
|
3. Implement `CadModule` in `mod.rs` (ribbon).
|
||||||
|
4. Implement `dispatch.rs` (all commands for your prefixes).
|
||||||
|
5. Add `plugin.rs` + `register.rs`.
|
||||||
|
6. Add `pub mod <name>;` to `src/modules/mod.rs`.
|
||||||
|
7. Document XDATA in `PLUGIN.md`.
|
||||||
|
8. Optional: add `crates/<engine>/` and depend from the plugin package only.
|
||||||
|
9. `cargo build` — tab appears via plugin registry; `commands.rs` untouched.
|
||||||
|
|
||||||
|
**External repo:** Publish the engine crate to crates.io; depend on `ocs_plugin_api` (when extracted) + ship a `cdylib` for phase 2. In-tree path: add as a git submodule under `src/modules/<name>/` or `plugins/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reference implementation: Storm Sewer
|
||||||
|
|
||||||
|
| Piece | Path |
|
||||||
|
|-------|------|
|
||||||
|
| Metadata | `storm_sewer/plugin.toml`, `manifest.rs` |
|
||||||
|
| Registration | `storm_sewer/register.rs` |
|
||||||
|
| Adapter | `storm_sewer/plugin.rs` |
|
||||||
|
| Commands | `storm_sewer/dispatch.rs` |
|
||||||
|
| Ribbon | `storm_sewer/mod.rs` |
|
||||||
|
| Tab state | `storm_sewer/state.rs` |
|
||||||
|
| XDATA | `storm_sewer/data.rs`, `PLUGIN.md` |
|
||||||
|
| Engine | `crates/stormsewer/` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Workspace layout
|
||||||
|
|
||||||
|
```
|
||||||
|
OpenCADStudio/
|
||||||
|
docs/
|
||||||
|
plugin-architecture.md # this file
|
||||||
|
plugin-template/ # scaffold for new add-ons
|
||||||
|
src/
|
||||||
|
plugin/ # Layer A runtime (generic)
|
||||||
|
modules/
|
||||||
|
home/ # core ribbon (no plugin.toml)
|
||||||
|
storm_sewer/ # add-on (has plugin.toml)
|
||||||
|
crates/
|
||||||
|
stormsewer/ # Layer C engine
|
||||||
|
ocs_plugin_api/ # (phase 1b) stable host API
|
||||||
|
plugins/ # (phase 2) third-party cdylibs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: Civil 3D / SSA contrast
|
||||||
|
|
||||||
|
| SSA / Civil 3D | Open CAD Studio add-on |
|
||||||
|
|----------------|------------------------|
|
||||||
|
| Proprietary project DB | DWG + XDATA |
|
||||||
|
| Vendor-only hydraulics | Pluggable `stormsewer` engine |
|
||||||
|
| Monolithic install | QGIS-style optional packages |
|
||||||
|
| Closed API | Documented `HostSession` + `PLUGIN.md` |
|
||||||
|
|
||||||
|
This positions Open CAD Studio as an **open, inspectable** civil CAD platform rather than a single-vendor clone.
|
||||||
11
docs/plugin-template/PLUGIN.md
Normal file
11
docs/plugin-template/PLUGIN.md
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# My Plugin (`opencad.my_plugin`)
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `MP_HELLO` | Example command |
|
||||||
|
|
||||||
|
## XDATA
|
||||||
|
|
||||||
|
Document entity record layouts here when your plugin tags geometry.
|
||||||
31
docs/plugin-template/README.md
Normal file
31
docs/plugin-template/README.md
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# Add-on plugin template
|
||||||
|
|
||||||
|
Copy this folder to `src/modules/<your_module>/` and rename placeholders.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
1. Copy files into `src/modules/my_plugin/`.
|
||||||
|
2. Replace `MY_PLUGIN`, `my_plugin`, `opencad.my_plugin`, `MP_` throughout.
|
||||||
|
3. Add `pub mod my_plugin;` to `src/modules/mod.rs`.
|
||||||
|
4. `cargo build` — ribbon tab and commands register automatically.
|
||||||
|
|
||||||
|
## Required files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `plugin.toml` | Metadata (QGIS-style); excludes dir from `build.rs` ribbon scan |
|
||||||
|
| `manifest.rs` | Compile-time `PluginManifest` — keep in sync with `plugin.toml` |
|
||||||
|
| `register.rs` | `inventory::submit!(PluginRegistration { … })` only |
|
||||||
|
| `plugin.rs` | Thin `BuiltinPlugin` impl |
|
||||||
|
| `dispatch.rs` | All command handlers |
|
||||||
|
| `mod.rs` | `CadModule` ribbon + `CommandRegistration` for autocomplete |
|
||||||
|
| `PLUGIN.md` | XDATA schemas and command reference |
|
||||||
|
|
||||||
|
## Optional
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `state.rs` | Per-document tab state via `host.ensure_plugin_state(PLUGIN_ID, …)` |
|
||||||
|
| `crates/my_engine/` | Headless domain logic (no iced/acadrust) |
|
||||||
|
|
||||||
|
See `docs/plugin-architecture.md` for the full spec.
|
||||||
14
docs/plugin-template/dispatch.rs
Normal file
14
docs/plugin-template/dispatch.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
use crate::plugin::host::HostSession;
|
||||||
|
|
||||||
|
use super::manifest::PLUGIN_ID;
|
||||||
|
|
||||||
|
pub fn handle(host: &mut HostSession<'_>, cmd: &str) -> bool {
|
||||||
|
let _ = (host, PLUGIN_ID);
|
||||||
|
match cmd {
|
||||||
|
"MP_HELLO" => {
|
||||||
|
host.push_info("Hello from my_plugin");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
14
docs/plugin-template/manifest.rs
Normal file
14
docs/plugin-template/manifest.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
use crate::plugin::manifest::{ApiVersion, PluginManifest};
|
||||||
|
|
||||||
|
pub const PLUGIN_ID: &str = "opencad.my_plugin";
|
||||||
|
|
||||||
|
pub static MANIFEST: PluginManifest = PluginManifest {
|
||||||
|
id: PLUGIN_ID,
|
||||||
|
name: "My Plugin",
|
||||||
|
version: "0.1.0",
|
||||||
|
description: "Short description of what this add-on does",
|
||||||
|
api_version: ApiVersion::CURRENT,
|
||||||
|
ribbon_order: 60,
|
||||||
|
xdata_apps: &["MYPLUGIN_RECORD"],
|
||||||
|
command_prefixes: &["MP_"],
|
||||||
|
};
|
||||||
34
docs/plugin-template/mod.rs
Normal file
34
docs/plugin-template/mod.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
pub mod dispatch;
|
||||||
|
pub mod manifest;
|
||||||
|
pub mod plugin;
|
||||||
|
pub mod register;
|
||||||
|
|
||||||
|
use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
|
||||||
|
|
||||||
|
inventory::submit!(crate::command::CommandRegistration {
|
||||||
|
names: &["MP_HELLO"]
|
||||||
|
});
|
||||||
|
|
||||||
|
pub struct MyPluginModule;
|
||||||
|
|
||||||
|
impl CadModule for MyPluginModule {
|
||||||
|
fn id(&self) -> &'static str {
|
||||||
|
"my_plugin"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn title(&self) -> &'static str {
|
||||||
|
"My Plugin"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ribbon_groups(&self) -> Vec<RibbonGroup> {
|
||||||
|
vec![RibbonGroup {
|
||||||
|
title: "Tools",
|
||||||
|
tools: vec![RibbonItem::LargeTool(ToolDef {
|
||||||
|
id: "MP_HELLO",
|
||||||
|
label: "Hello",
|
||||||
|
icon: IconKind::Glyph("★"),
|
||||||
|
event: ModuleEvent::Command("MP_HELLO".to_string()),
|
||||||
|
})],
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
21
docs/plugin-template/plugin.rs
Normal file
21
docs/plugin-template/plugin.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
use crate::plugin::host::{BuiltinPlugin, HostSession};
|
||||||
|
use crate::plugin::manifest::PluginManifest;
|
||||||
|
|
||||||
|
use super::dispatch;
|
||||||
|
use super::manifest;
|
||||||
|
|
||||||
|
pub struct MyPlugin;
|
||||||
|
|
||||||
|
impl BuiltinPlugin for MyPlugin {
|
||||||
|
fn manifest(&self) -> &'static PluginManifest {
|
||||||
|
&manifest::MANIFEST
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ribbon(&self) -> Box<dyn crate::modules::CadModule> {
|
||||||
|
Box::new(super::MyPluginModule)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool {
|
||||||
|
dispatch::handle(host, cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
docs/plugin-template/plugin.toml
Normal file
13
docs/plugin-template/plugin.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
[plugin]
|
||||||
|
id = "opencad.my_plugin"
|
||||||
|
name = "My Plugin"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Short description of what this add-on does"
|
||||||
|
author = "Your Name"
|
||||||
|
license = "GPL-3.0-only"
|
||||||
|
|
||||||
|
[opencad]
|
||||||
|
api_version = 1
|
||||||
|
ribbon_order = 60
|
||||||
|
command_prefixes = ["MP_"]
|
||||||
|
xdata_apps = ["MYPLUGIN_RECORD"]
|
||||||
7
docs/plugin-template/register.rs
Normal file
7
docs/plugin-template/register.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
use super::plugin::MyPlugin;
|
||||||
|
|
||||||
|
inventory::submit! {
|
||||||
|
crate::plugin::registry::PluginRegistration {
|
||||||
|
construct: || Box::new(MyPlugin),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -251,6 +251,11 @@ impl OpenCADStudio {
|
||||||
}
|
}
|
||||||
CmdResult::ReplaceMany(replacements, additions) => {
|
CmdResult::ReplaceMany(replacements, additions) => {
|
||||||
let label = self.history_label_from_active_cmd(i, "FILLET");
|
let label = self.history_label_from_active_cmd(i, "FILLET");
|
||||||
|
let was_catchment = self
|
||||||
|
.tabs[i]
|
||||||
|
.active_cmd
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|c| c.name() == "SS_CATCHMENT");
|
||||||
self.push_undo_snapshot(i, label);
|
self.push_undo_snapshot(i, label);
|
||||||
for (handle, entities) in replacements {
|
for (handle, entities) in replacements {
|
||||||
self.tabs[i].scene.erase_entities(&[handle]);
|
self.tabs[i].scene.erase_entities(&[handle]);
|
||||||
|
|
@ -265,6 +270,10 @@ impl OpenCADStudio {
|
||||||
self.tabs[i].scene.clear_preview_wire();
|
self.tabs[i].scene.clear_preview_wire();
|
||||||
self.tabs[i].active_cmd = None;
|
self.tabs[i].active_cmd = None;
|
||||||
self.tabs[i].snap_result = None;
|
self.tabs[i].snap_result = None;
|
||||||
|
if was_catchment {
|
||||||
|
self.command_line
|
||||||
|
.push_info("Catchment tagged successfully.");
|
||||||
|
}
|
||||||
self.refresh_properties();
|
self.refresh_properties();
|
||||||
}
|
}
|
||||||
CmdResult::ReplaceEntity(handle, new_entities) => {
|
CmdResult::ReplaceEntity(handle, new_entities) => {
|
||||||
|
|
|
||||||
|
|
@ -28,66 +28,11 @@ impl OpenCADStudio {
|
||||||
return Task::done(Message::OpenPathPicked(Some((path, size))));
|
return Task::done(Message::OpenPathPicked(Some((path, size))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if crate::plugin::try_dispatch(self, i, cmd) {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
|
||||||
match cmd {
|
match cmd {
|
||||||
// ── Storm Sewer module ──────────────────────────────────────────
|
|
||||||
"SS_ANALYZE" => {
|
|
||||||
use crate::modules::storm_sewer::analysis as ss;
|
|
||||||
let result = ss::analyze_doc(self.tabs[i].scene.document.entities());
|
|
||||||
match result {
|
|
||||||
Ok((ents, report)) => {
|
|
||||||
for e in ents {
|
|
||||||
let _ = self.tabs[i].scene.add_entity(e);
|
|
||||||
}
|
|
||||||
self.tabs[i].scene.bump_geometry();
|
|
||||||
self.command_line
|
|
||||||
.push_info("Storm sewer: analyzed drawn network (default IDF 60/(t+10)^0.8).");
|
|
||||||
for line in report.lines() {
|
|
||||||
self.command_line.push_output(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => self.command_line.push_error(&e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"SS_REPORT" => {
|
|
||||||
use crate::modules::storm_sewer::analysis as ss;
|
|
||||||
match ss::report_doc(self.tabs[i].scene.document.entities()) {
|
|
||||||
Ok(report) => {
|
|
||||||
for line in report.lines() {
|
|
||||||
self.command_line.push_output(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => self.command_line.push_error(&e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"SS_INLET" | "SS_JUNCTION" | "SS_OUTFALL" => {
|
|
||||||
use crate::modules::storm_sewer::structures::PlaceStructure;
|
|
||||||
let cmd = match cmd {
|
|
||||||
"SS_INLET" => PlaceStructure::inlet(),
|
|
||||||
"SS_JUNCTION" => PlaceStructure::junction(),
|
|
||||||
_ => PlaceStructure::outfall(),
|
|
||||||
};
|
|
||||||
self.command_line.push_info(&cmd.prompt());
|
|
||||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
|
||||||
}
|
|
||||||
"SS_PIPE" => {
|
|
||||||
let cmd = crate::modules::storm_sewer::structures::PlacePipe::new();
|
|
||||||
self.command_line.push_info(&cmd.prompt());
|
|
||||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
|
||||||
}
|
|
||||||
"SS_PROFILE" => {
|
|
||||||
use crate::modules::storm_sewer::analysis as ss;
|
|
||||||
let result = ss::profile_doc(self.tabs[i].scene.document.entities());
|
|
||||||
match result {
|
|
||||||
Ok(ents) => {
|
|
||||||
for e in ents {
|
|
||||||
let _ = self.tabs[i].scene.add_entity(e);
|
|
||||||
}
|
|
||||||
self.tabs[i].scene.bump_geometry();
|
|
||||||
self.command_line.push_info("Storm sewer HGL profile drawn.");
|
|
||||||
}
|
|
||||||
Err(e) => self.command_line.push_error(&e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"NEW" => return Task::done(Message::TabNew),
|
"NEW" => return Task::done(Message::TabNew),
|
||||||
"OPEN" => return Task::done(Message::OpenFile),
|
"OPEN" => return Task::done(Message::OpenFile),
|
||||||
"SAVE" | "QSAVE" => return Task::done(Message::SaveFile),
|
"SAVE" | "QSAVE" => return Task::done(Message::SaveFile),
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ use crate::ui::{LayerPanel, PropertiesPanel};
|
||||||
use acadrust::tables::Ucs;
|
use acadrust::tables::Ucs;
|
||||||
use acadrust::{CadDocument, Handle};
|
use acadrust::{CadDocument, Handle};
|
||||||
use iced;
|
use iced;
|
||||||
|
use std::any::{Any, TypeId};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
// ── Dynamic input ──────────────────────────────────────────────────────────
|
// ── Dynamic input ──────────────────────────────────────────────────────────
|
||||||
|
|
@ -96,6 +98,8 @@ pub(super) struct DocumentTab {
|
||||||
/// of the model-space shader. The scene is still constructed so the
|
/// of the model-space shader. The scene is still constructed so the
|
||||||
/// rest of the code can treat it as a normal tab when reading.
|
/// rest of the code can treat it as a normal tab when reading.
|
||||||
pub(super) is_start: bool,
|
pub(super) is_start: bool,
|
||||||
|
/// Per-plugin document state (`plugin::BuiltinPlugin` manifest id → state).
|
||||||
|
pub(super) plugin_state: HashMap<&'static str, Box<dyn Any + Send + Sync>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DocumentTab {
|
impl DocumentTab {
|
||||||
|
|
@ -142,9 +146,41 @@ impl DocumentTab {
|
||||||
active_mleader_style: "Standard".to_string(),
|
active_mleader_style: "Standard".to_string(),
|
||||||
last_synced_camera_gen: 0,
|
last_synced_camera_gen: 0,
|
||||||
is_start: false,
|
is_start: false,
|
||||||
|
plugin_state: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn plugin_state<T: Any + Send + Sync + 'static>(
|
||||||
|
&self,
|
||||||
|
plugin_id: &'static str,
|
||||||
|
_type_id: TypeId,
|
||||||
|
) -> Option<&T> {
|
||||||
|
self.plugin_state.get(plugin_id)?.downcast_ref::<T>()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn plugin_state_mut<T: Any + Send + Sync + 'static>(
|
||||||
|
&mut self,
|
||||||
|
plugin_id: &'static str,
|
||||||
|
_type_id: TypeId,
|
||||||
|
) -> Option<&mut T> {
|
||||||
|
self.plugin_state.get_mut(plugin_id)?.downcast_mut::<T>()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn ensure_plugin_state<T: Any + Send + Sync + 'static>(
|
||||||
|
&mut self,
|
||||||
|
plugin_id: &'static str,
|
||||||
|
init: impl FnOnce() -> T,
|
||||||
|
) -> &mut T {
|
||||||
|
if !self.plugin_state.contains_key(plugin_id) {
|
||||||
|
self.plugin_state.insert(plugin_id, Box::new(init()));
|
||||||
|
}
|
||||||
|
self.plugin_state
|
||||||
|
.get_mut(plugin_id)
|
||||||
|
.expect("just inserted")
|
||||||
|
.downcast_mut::<T>()
|
||||||
|
.expect("plugin_id type mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
/// Welcome / Start tab. Carries a dummy Scene so the rest of the app
|
/// Welcome / Start tab. Carries a dummy Scene so the rest of the app
|
||||||
/// can read tab state uniformly; the viewport renderer detects
|
/// can read tab state uniformly; the viewport renderer detects
|
||||||
/// `is_start` and shows a welcome page instead.
|
/// `is_start` and shows a welcome page instead.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
mod cmd_result;
|
mod cmd_result;
|
||||||
mod commands;
|
mod commands;
|
||||||
|
pub mod plugin_host;
|
||||||
mod document;
|
mod document;
|
||||||
mod helpers;
|
mod helpers;
|
||||||
mod history;
|
mod history;
|
||||||
|
|
|
||||||
94
src/app/plugin_host.rs
Normal file
94
src/app/plugin_host.rs
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
// HostSession — plugin-facing API implemented inside `app` (private field access).
|
||||||
|
|
||||||
|
use std::any::{Any, TypeId};
|
||||||
|
|
||||||
|
use acadrust::{CadDocument, EntityType, Handle};
|
||||||
|
|
||||||
|
use super::OpenCADStudio;
|
||||||
|
use crate::command::CadCommand;
|
||||||
|
|
||||||
|
/// Session adapter: one active document tab, command line, undo.
|
||||||
|
pub(crate) struct HostSession<'a> {
|
||||||
|
app: &'a mut OpenCADStudio,
|
||||||
|
tab: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> HostSession<'a> {
|
||||||
|
pub(crate) fn new(app: &'a mut OpenCADStudio, tab: usize) -> Self {
|
||||||
|
Self { app, tab }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tab_index(&self) -> usize {
|
||||||
|
self.tab
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn document(&self) -> &CadDocument {
|
||||||
|
&self.app.tabs[self.tab].scene.document
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn document_mut(&mut self) -> &mut CadDocument {
|
||||||
|
&mut self.app.tabs[self.tab].scene.document
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entities(&self) -> impl Iterator<Item = &EntityType> {
|
||||||
|
self.document().entities()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entities_mut(&mut self) -> impl Iterator<Item = &mut EntityType> {
|
||||||
|
self.document_mut().entities_mut()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_entity(&mut self, entity: EntityType) -> Handle {
|
||||||
|
self.app.tabs[self.tab].scene.add_entity(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bump_geometry(&mut self) {
|
||||||
|
self.app.tabs[self.tab].scene.bump_geometry();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_undo(&mut self, label: &str) {
|
||||||
|
self.app.push_undo_snapshot(self.tab, label);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_dirty(&mut self) {
|
||||||
|
self.app.tabs[self.tab].dirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_info(&mut self, msg: &str) {
|
||||||
|
self.app.command_line.push_info(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_output(&mut self, msg: &str) {
|
||||||
|
self.app.command_line.push_output(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push_error(&mut self, msg: &str) {
|
||||||
|
self.app.command_line.push_error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_active_command(&mut self, cmd: Box<dyn CadCommand>) {
|
||||||
|
self.app.tabs[self.tab].active_cmd = Some(cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn plugin_state<T: Any + Send + Sync + 'static>(
|
||||||
|
&self,
|
||||||
|
plugin_id: &'static str,
|
||||||
|
) -> Option<&T> {
|
||||||
|
self.app.tabs[self.tab].plugin_state(plugin_id, TypeId::of::<T>())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn plugin_state_mut<T: Any + Send + Sync + 'static>(
|
||||||
|
&mut self,
|
||||||
|
plugin_id: &'static str,
|
||||||
|
) -> Option<&mut T> {
|
||||||
|
self.app.tabs[self.tab].plugin_state_mut(plugin_id, TypeId::of::<T>())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_plugin_state<T: Any + Send + Sync + 'static>(
|
||||||
|
&mut self,
|
||||||
|
plugin_id: &'static str,
|
||||||
|
init: impl FnOnce() -> T,
|
||||||
|
) -> &mut T {
|
||||||
|
self.app.tabs[self.tab].ensure_plugin_state(plugin_id, init)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2278,6 +2278,11 @@ impl OpenCADStudio {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|c| c.needs_entity_pick())
|
.map(|c| c.needs_entity_pick())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
|
let needs_structure = self.tabs[i]
|
||||||
|
.active_cmd
|
||||||
|
.as_ref()
|
||||||
|
.map(|c| c.needs_structure_point_pick())
|
||||||
|
.unwrap_or(false);
|
||||||
let is_gathering = self.tabs[i]
|
let is_gathering = self.tabs[i]
|
||||||
.active_cmd
|
.active_cmd
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|
@ -2288,7 +2293,7 @@ impl OpenCADStudio {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|c| c.needs_tangent_pick())
|
.map(|c| c.needs_tangent_pick())
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
self.tabs[i].snap_result = if needs_entity || is_gathering {
|
self.tabs[i].snap_result = if needs_entity || is_gathering || needs_structure {
|
||||||
None
|
None
|
||||||
} else if needs_tan {
|
} else if needs_tan {
|
||||||
self.snapper.snap_tangent_only(
|
self.snapper.snap_tangent_only(
|
||||||
|
|
@ -2345,6 +2350,39 @@ impl OpenCADStudio {
|
||||||
};
|
};
|
||||||
self.tabs[i].last_cursor_world = effective;
|
self.tabs[i].last_cursor_world = effective;
|
||||||
self.tabs[i].last_cursor_screen = p_full;
|
self.tabs[i].last_cursor_screen = p_full;
|
||||||
|
|
||||||
|
// C3D-style orange object snap (plugin commands implement resolve_object_pick).
|
||||||
|
if needs_structure {
|
||||||
|
use crate::snap::{SnapResult, SnapType};
|
||||||
|
let pick = self.tabs[i].active_cmd.as_ref().and_then(|c| {
|
||||||
|
c.resolve_object_pick(
|
||||||
|
&self.tabs[i].scene,
|
||||||
|
effective.x as f64,
|
||||||
|
effective.y as f64,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if let Some(pick) = pick {
|
||||||
|
let world =
|
||||||
|
glam::Vec3::new(pick.x as f32, pick.y as f32, effective.z);
|
||||||
|
let ndc = view_proj.project_point3(world);
|
||||||
|
let screen = iced::Point::new(
|
||||||
|
(ndc.x + 1.0) * 0.5 * bounds.width,
|
||||||
|
(1.0 - ndc.y) * 0.5 * bounds.height,
|
||||||
|
);
|
||||||
|
self.tabs[i].snap_result = Some(SnapResult {
|
||||||
|
world,
|
||||||
|
screen,
|
||||||
|
snap_type: SnapType::ObjectPick,
|
||||||
|
tangent_obj: None,
|
||||||
|
});
|
||||||
|
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
|
||||||
|
cmd.set_acquisition_hint(Some(pick.label));
|
||||||
|
}
|
||||||
|
} else if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
|
||||||
|
cmd.set_acquisition_hint(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Snap glyph is positioned in canvas space; shift the
|
// Snap glyph is positioned in canvas space; shift the
|
||||||
// tile-local snap screen point back to the full canvas.
|
// tile-local snap screen point back to the full canvas.
|
||||||
if let Some(s) = self.tabs[i].snap_result.as_mut() {
|
if let Some(s) = self.tabs[i].snap_result.as_mut() {
|
||||||
|
|
@ -2352,16 +2390,42 @@ impl OpenCADStudio {
|
||||||
s.screen.y += tile_b.y;
|
s.screen.y += tile_b.y;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut previews = if needs_entity {
|
let mut previews = if needs_structure {
|
||||||
|
let mut p = self.tabs[i]
|
||||||
|
.active_cmd
|
||||||
|
.as_ref()
|
||||||
|
.map(|c| c.object_pick_hover_previews(&self.tabs[i].scene, effective))
|
||||||
|
.unwrap_or_default();
|
||||||
|
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
|
||||||
|
p.extend(cmd.on_preview_wires(effective));
|
||||||
|
}
|
||||||
|
p
|
||||||
|
} else if needs_entity {
|
||||||
let hover_handle =
|
let hover_handle =
|
||||||
scene::hit_test::click_hit(p, &all_wires[..], view_proj, bounds)
|
scene::hit_test::click_hit(p, &all_wires[..], view_proj, bounds)
|
||||||
.and_then(|s| Scene::handle_from_wire_name(s))
|
.and_then(|s| Scene::handle_from_wire_name(s))
|
||||||
.unwrap_or(acadrust::Handle::NULL);
|
.unwrap_or(acadrust::Handle::NULL);
|
||||||
self.tabs[i]
|
let mut p = self.tabs[i]
|
||||||
.active_cmd
|
.active_cmd
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.map(|c| c.on_hover_entity(hover_handle, effective))
|
.map(|c| c.on_hover_entity(hover_handle, effective))
|
||||||
.unwrap_or_default()
|
.unwrap_or_default();
|
||||||
|
if !hover_handle.is_null() {
|
||||||
|
if let Some(cmd) = self.tabs[i].active_cmd.as_ref() {
|
||||||
|
p.extend(cmd.entity_pick_acquire_previews(
|
||||||
|
&self.tabs[i].scene,
|
||||||
|
hover_handle,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
|
||||||
|
if let Some(hint) =
|
||||||
|
cmd.entity_pick_acquire_hint(hover_handle)
|
||||||
|
{
|
||||||
|
cmd.set_acquisition_hint(Some(hint));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p
|
||||||
} else {
|
} else {
|
||||||
self.tabs[i]
|
self.tabs[i]
|
||||||
.active_cmd
|
.active_cmd
|
||||||
|
|
@ -2763,6 +2827,36 @@ impl OpenCADStudio {
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = if self.tabs[i]
|
let result = if self.tabs[i]
|
||||||
|
.active_cmd
|
||||||
|
.as_ref()
|
||||||
|
.map(|c| c.needs_structure_point_pick())
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
let pick = self.tabs[i].active_cmd.as_ref().and_then(|c| {
|
||||||
|
c.resolve_object_pick(
|
||||||
|
&self.tabs[i].scene,
|
||||||
|
world_pt.x as f64,
|
||||||
|
world_pt.y as f64,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if let Some(pick) = pick {
|
||||||
|
let center = glam::Vec3::new(pick.x as f32, pick.y as f32, world_pt.z);
|
||||||
|
let result = self.tabs[i].active_cmd.as_mut().map(|c| {
|
||||||
|
c.on_structure_pick(pick.handle, center)
|
||||||
|
});
|
||||||
|
self.command_line
|
||||||
|
.push_info(&format!("{} acquired.", pick.label));
|
||||||
|
result
|
||||||
|
} else {
|
||||||
|
let msg = self.tabs[i]
|
||||||
|
.active_cmd
|
||||||
|
.as_ref()
|
||||||
|
.map(|c| c.object_pick_miss_message())
|
||||||
|
.unwrap_or("No object near click.");
|
||||||
|
self.command_line.push_error(msg);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
} else if self.tabs[i]
|
||||||
.active_cmd
|
.active_cmd
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|c| c.needs_entity_pick())
|
.map(|c| c.needs_entity_pick())
|
||||||
|
|
@ -2773,6 +2867,23 @@ impl OpenCADStudio {
|
||||||
let hit = scene::hit_test::click_hit(p, &all_wires2[..], vp_mat2, bounds)
|
let hit = scene::hit_test::click_hit(p, &all_wires2[..], vp_mat2, bounds)
|
||||||
.and_then(|s| Scene::handle_from_wire_name(s));
|
.and_then(|s| Scene::handle_from_wire_name(s));
|
||||||
if let Some(handle) = hit {
|
if let Some(handle) = hit {
|
||||||
|
// Some commands (e.g. SS_CATCHMENT) need the entity
|
||||||
|
// body before `on_entity_pick` can advance.
|
||||||
|
let inject_first = self.tabs[i]
|
||||||
|
.active_cmd
|
||||||
|
.as_ref()
|
||||||
|
.map(|c| c.inject_before_entity_pick())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if inject_first {
|
||||||
|
if let Some(entity) =
|
||||||
|
self.tabs[i].scene.document.get_entity(handle).cloned()
|
||||||
|
{
|
||||||
|
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
|
||||||
|
cmd.inject_picked_entity(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let result = self.tabs[i]
|
let result = self.tabs[i]
|
||||||
.active_cmd
|
.active_cmd
|
||||||
.as_mut()
|
.as_mut()
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,19 @@
|
||||||
|
|
||||||
use crate::scene::hatch_model::HatchModel;
|
use crate::scene::hatch_model::HatchModel;
|
||||||
use crate::scene::wire_model::WireModel;
|
use crate::scene::wire_model::WireModel;
|
||||||
|
use crate::scene::Scene;
|
||||||
use acadrust::{EntityType, Handle};
|
use acadrust::{EntityType, Handle};
|
||||||
use glam::Vec3;
|
use glam::Vec3;
|
||||||
|
|
||||||
|
/// Domain object resolved under the cursor for C3D-style ObjectPick snapping.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct ObjectPickHit {
|
||||||
|
pub handle: Handle,
|
||||||
|
pub x: f64,
|
||||||
|
pub y: f64,
|
||||||
|
pub label: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
// ── Transform ─────────────────────────────────────────────────────────────
|
// ── Transform ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// A geometric transformation applied to existing entities.
|
/// A geometric transformation applied to existing entities.
|
||||||
|
|
@ -267,6 +277,44 @@ pub trait CadCommand: Send {
|
||||||
CmdResult::Cancel
|
CmdResult::Cancel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Point-click pick of domain objects (wire hit-test often misses small markers).
|
||||||
|
fn needs_structure_point_pick(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a domain object near `(x, y)` while `needs_structure_point_pick()` is active.
|
||||||
|
fn resolve_object_pick(&self, _scene: &Scene, _x: f64, _y: f64) -> Option<ObjectPickHit> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Preview wires while hovering during object-point pick.
|
||||||
|
fn object_pick_hover_previews(&self, _scene: &Scene, _cursor: Vec3) -> Vec<WireModel> {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Message when `resolve_object_pick` returns none on click.
|
||||||
|
fn object_pick_miss_message(&self) -> &'static str {
|
||||||
|
"No object near click."
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called when `needs_structure_point_pick()` is true and a structure is found near the click.
|
||||||
|
fn on_structure_pick(&mut self, _handle: Handle, _pt: Vec3) -> CmdResult {
|
||||||
|
CmdResult::Cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extra acquisition previews during entity pick (besides `on_hover_entity`).
|
||||||
|
fn entity_pick_acquire_previews(&self, _scene: &Scene, _handle: Handle) -> Vec<WireModel> {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Acquisition hint label during entity pick hover.
|
||||||
|
fn entity_pick_acquire_hint(&self, _handle: Handle) -> Option<&'static str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hover label for C3D-style object acquisition (e.g. "Inlet" under cursor).
|
||||||
|
fn set_acquisition_hint(&mut self, _hint: Option<&str>) {}
|
||||||
|
|
||||||
/// Called after `CmdResult::ReplaceEntity` is applied to the document.
|
/// Called after `CmdResult::ReplaceEntity` is applied to the document.
|
||||||
/// `old` is the erased handle; `new_handles` are the handles assigned to the replacement entities.
|
/// `old` is the erased handle; `new_handles` are the handles assigned to the replacement entities.
|
||||||
/// Commands that stay active across replaces should update their internal snapshots here.
|
/// Commands that stay active across replaces should update their internal snapshots here.
|
||||||
|
|
@ -367,7 +415,13 @@ pub trait CadCommand: Send {
|
||||||
self.on_point(hit)
|
self.on_point(hit)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called by update.rs after `on_entity_pick` to inject the cloned entity into commands
|
/// When true, `update.rs` injects the picked entity before calling
|
||||||
|
/// `on_entity_pick` (required when the pick handler reads injected state).
|
||||||
|
fn inject_before_entity_pick(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called by update.rs to inject the cloned entity into commands
|
||||||
/// that need to read/modify it (e.g. DIMTEDIT, MLEADERADD, MLEADERREMOVE).
|
/// that need to read/modify it (e.g. DIMTEDIT, MLEADERADD, MLEADERREMOVE).
|
||||||
/// Default: no-op.
|
/// Default: no-op.
|
||||||
fn inject_picked_entity(&mut self, _entity: acadrust::EntityType) {}
|
fn inject_picked_entity(&mut self, _entity: acadrust::EntityType) {}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ pub mod entities;
|
||||||
pub mod io;
|
pub mod io;
|
||||||
pub mod linetypes;
|
pub mod linetypes;
|
||||||
pub mod modules;
|
pub mod modules;
|
||||||
|
pub mod plugin;
|
||||||
pub mod patterns;
|
pub mod patterns;
|
||||||
pub mod scene;
|
pub mod scene;
|
||||||
pub mod snap;
|
pub mod snap;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ mod entities;
|
||||||
mod io;
|
mod io;
|
||||||
mod linetypes;
|
mod linetypes;
|
||||||
mod modules;
|
mod modules;
|
||||||
|
mod plugin;
|
||||||
mod patterns;
|
mod patterns;
|
||||||
mod scene;
|
mod scene;
|
||||||
mod snap;
|
mod snap;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
// Module system — CadModule, ToolDef, RibbonGroup.
|
// Module system — CadModule, ToolDef, RibbonGroup.
|
||||||
//
|
//
|
||||||
// To add a new module:
|
// To add a **core** ribbon tab (Home, View, …):
|
||||||
// 1. Create `src/modules/my_name/` directory
|
// 1. Create `src/modules/my_name/` directory (no `plugin.toml`)
|
||||||
// 2. Add `src/modules/my_name/mod.rs` implementing `CadModule` as `MyNameModule`
|
// 2. Add `src/modules/my_name/mod.rs` implementing `CadModule` as `MyNameModule`
|
||||||
// 3. Add `pub mod my_name;` below
|
// 3. Add `pub mod my_name;` below
|
||||||
// 4. `cargo build` — module appears in the ribbon automatically
|
// 4. `cargo build` — tab appears via build.rs registry
|
||||||
|
//
|
||||||
|
// To add an **add-on plugin** (Storm Sewer, …):
|
||||||
|
// See `docs/plugin-architecture.md` and copy `docs/plugin-template/`.
|
||||||
//
|
//
|
||||||
// Each module folder contains:
|
// Each module folder contains:
|
||||||
// - mod.rs : module definition (ribbon groups + tool layout)
|
// - mod.rs : module definition (ribbon groups + tool layout)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,5 @@ pub fn all_modules() -> Vec<Box<dyn CadModule>> {
|
||||||
Box::new(super::view::ViewModule),
|
Box::new(super::view::ViewModule),
|
||||||
Box::new(super::manage::ManageModule),
|
Box::new(super::manage::ManageModule),
|
||||||
Box::new(super::layout::LayoutModule),
|
Box::new(super::layout::LayoutModule),
|
||||||
Box::new(super::storm_sewer::StormSewerModule),
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
69
src/modules/storm_sewer/PLUGIN.md
Normal file
69
src/modules/storm_sewer/PLUGIN.md
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
# Storm Sewer (`opencad.storm_sewer`)
|
||||||
|
|
||||||
|
Add-on package for gravity storm-drain network design and analysis.
|
||||||
|
|
||||||
|
- **Engine:** `crates/stormsewer` (headless hydraulics)
|
||||||
|
- **Host integration:** `plugin.rs`, `dispatch.rs`, `register.rs`
|
||||||
|
- **Architecture:** `docs/plugin-architecture.md`
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `SS_INLET` | Place inlet structure |
|
||||||
|
| `SS_JUNCTION` | Place junction structure |
|
||||||
|
| `SS_OUTFALL` | Place outfall structure |
|
||||||
|
| `SS_PIPE` | Draw pipe between two structures |
|
||||||
|
| `SS_CATCHMENT` | Draw catchment boundary polyline |
|
||||||
|
| `SS_APPLYTC` | Apply time-of-concentration from catchments |
|
||||||
|
| `SS_LANDXML` / `SS_IMPORTXML` | Import LandXML storm network |
|
||||||
|
| `SS_ANALYZE` | Run hydraulic analysis on drawn network |
|
||||||
|
| `SS_SIZE` | Size pipes from analysis |
|
||||||
|
| `SS_PARAMS` | Set rainfall / analysis parameters |
|
||||||
|
| `SS_MULTIRP` | Multi return-period analysis |
|
||||||
|
| `SS_REPORT` | Print analysis report |
|
||||||
|
| `SS_PROFILE` | Draw HGL profile |
|
||||||
|
|
||||||
|
## XDATA schemas
|
||||||
|
|
||||||
|
All records use application names registered in `manifest.rs`. Values are stored as XDATA on DWG entities so networks round-trip through save/load.
|
||||||
|
|
||||||
|
### `STORMSEWER_STRUCT` (on `CIRCLE`)
|
||||||
|
|
||||||
|
| Index | Field | Type | Notes |
|
||||||
|
|-------|-------|------|-------|
|
||||||
|
| 0 | kind | int | 0=inlet, 1=junction, 2=outfall |
|
||||||
|
| 1 | invert | real | Structure invert elevation |
|
||||||
|
| 2 | rim | real | Rim elevation |
|
||||||
|
| 3 | area | real | Contributing area (acres) |
|
||||||
|
| 4 | C | real | Runoff coefficient |
|
||||||
|
|
||||||
|
### `STORMSEWER_PIPE` (on `LINE`)
|
||||||
|
|
||||||
|
| Index | Field | Type | Notes |
|
||||||
|
|-------|-------|------|-------|
|
||||||
|
| 0 | diameter | real | Pipe diameter (inches) |
|
||||||
|
| 1 | n | real | Manning's n |
|
||||||
|
| 2 | from_handle | int | Start structure entity handle |
|
||||||
|
| 3 | to_handle | int | End structure entity handle |
|
||||||
|
|
||||||
|
### `STORMSEWER_CATCHMENT` (on `LWPOLYLINE`)
|
||||||
|
|
||||||
|
| Index | Field | Type | Notes |
|
||||||
|
|-------|-------|------|-------|
|
||||||
|
| 0 | area_acres | real | Catchment area |
|
||||||
|
| 1 | C | real | Runoff coefficient |
|
||||||
|
| 2 | length_ft | real | Flow path length |
|
||||||
|
| 3 | slope | real | Average slope |
|
||||||
|
| 4 | inlet_handle | int | Optional inlet structure handle (0 = none) |
|
||||||
|
|
||||||
|
## Per-document state
|
||||||
|
|
||||||
|
Stored under plugin id `opencad.storm_sewer` as `StormTabState` (`state.rs`):
|
||||||
|
|
||||||
|
- `StormAnalysisParams` — IDF, tailwater, min Tc, return periods, etc.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `stormsewer` workspace crate — must not depend on CAD host crates.
|
||||||
|
- This package — must not edit `src/app/commands.rs`; use `dispatch.rs`.
|
||||||
|
|
@ -8,18 +8,19 @@
|
||||||
use acadrust::types::Vector3;
|
use acadrust::types::Vector3;
|
||||||
use acadrust::{Circle, EntityType, Line, MText};
|
use acadrust::{Circle, EntityType, Line, MText};
|
||||||
|
|
||||||
|
use stormsewer::design::check_inlet;
|
||||||
use stormsewer::drawing::{draw_network, DrawConfig};
|
use stormsewer::drawing::{draw_network, DrawConfig};
|
||||||
use stormsewer::idf::IdfCurve;
|
use stormsewer::idf::IdfCurve;
|
||||||
use stormsewer::network::{Analysis, AnalysisOptions, Network, Node, Pipe};
|
use stormsewer::network::{Analysis, AnalysisOptions, Network, Node, NodeKind, Pipe};
|
||||||
|
use stormsewer::params::StormAnalysisParams;
|
||||||
use stormsewer::parse::parse_ssn;
|
use stormsewer::parse::parse_ssn;
|
||||||
use stormsewer::report::format_analysis;
|
use stormsewer::report::{format_analysis, format_multi_rp};
|
||||||
|
|
||||||
use super::data;
|
use super::data;
|
||||||
|
|
||||||
/// Default network-level analysis parameters used when analyzing a drawn
|
/// Default network-level analysis parameters (tests / fallbacks).
|
||||||
/// network (per-entity data carries geometry/area; rainfall is global).
|
pub fn default_params() -> StormAnalysisParams {
|
||||||
fn default_params() -> (IdfCurve, AnalysisOptions) {
|
StormAnalysisParams::municipal()
|
||||||
(IdfCurve::new(60.0, 10.0, 0.8), AnalysisOptions::default())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Annotation labels only (flow + HGL), for overlaying on an already-drawn
|
/// Annotation labels only (flow + HGL), for overlaying on an already-drawn
|
||||||
|
|
@ -36,27 +37,85 @@ fn build_annotations(net: &Network, a: &Analysis) -> Vec<EntityType> {
|
||||||
|
|
||||||
/// Reconstruct the network from drawn entities, analyze it, and return
|
/// Reconstruct the network from drawn entities, analyze it, and return
|
||||||
/// annotation entities (flow/HGL labels) + the report.
|
/// annotation entities (flow/HGL labels) + the report.
|
||||||
pub fn analyze_doc<'a>(entities: impl Iterator<Item = &'a EntityType>) -> Result<(Vec<EntityType>, String), String> {
|
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 net = data::network_from_entities(entities)?;
|
||||||
let (idf, opts) = default_params();
|
let a = run_analysis(&net, params.idf.design_curve(), ¶ms.hydraulics)?;
|
||||||
let a = run_analysis(&net, &idf, &opts)?;
|
let report = full_report(&net, &a, params);
|
||||||
Ok((build_annotations(&net, &a), format_analysis(&a)))
|
Ok((build_annotations(&net, &a), report, a))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reconstruct from drawn entities and return the HGL long-section entities.
|
/// Reconstruct from drawn entities and return the HGL long-section entities.
|
||||||
pub fn profile_doc<'a>(entities: impl Iterator<Item = &'a EntityType>) -> Result<Vec<EntityType>, String> {
|
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 net = data::network_from_entities(entities)?;
|
||||||
let (idf, opts) = default_params();
|
let a = run_analysis(&net, params.idf.design_curve(), ¶ms.hydraulics)?;
|
||||||
let a = run_analysis(&net, &idf, &opts)?;
|
|
||||||
Ok(build_profile(&net, &a))
|
Ok(build_profile(&net, &a))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reconstruct from drawn entities and return the formatted report.
|
/// Reconstruct from drawn entities and return the formatted report.
|
||||||
pub fn report_doc<'a>(entities: impl Iterator<Item = &'a EntityType>) -> Result<String, String> {
|
pub fn report_doc<'a>(
|
||||||
|
entities: impl Iterator<Item = &'a EntityType>,
|
||||||
|
params: &StormAnalysisParams,
|
||||||
|
) -> Result<String, String> {
|
||||||
let net = data::network_from_entities(entities)?;
|
let net = data::network_from_entities(entities)?;
|
||||||
let (idf, opts) = default_params();
|
let a = run_analysis(&net, params.idf.design_curve(), ¶ms.hydraulics)?;
|
||||||
let a = run_analysis(&net, &idf, &opts)?;
|
Ok(full_report(&net, &a, params))
|
||||||
Ok(format_analysis(&a))
|
}
|
||||||
|
|
||||||
|
/// 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).
|
/// The built-in demonstration network (properly sized, with plan coordinates).
|
||||||
|
|
|
||||||
309
src/modules/storm_sewer/catchment.rs
Normal file
309
src/modules/storm_sewer/catchment.rs
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
// Interactive catchment tagging: closed LwPolyline + STORMSEWER_CATCHMENT XDATA.
|
||||||
|
|
||||||
|
use acadrust::entities::LwPolyline;
|
||||||
|
use acadrust::{EntityType, Handle};
|
||||||
|
use glam::Vec3;
|
||||||
|
|
||||||
|
use stormsewer::catchment::{default_flow_length_ft, polygon_centroid, shoelace_area_sqft, sqft_to_acres};
|
||||||
|
|
||||||
|
use super::data;
|
||||||
|
use super::preview;
|
||||||
|
use crate::command::{CadCommand, CmdResult, ObjectPickHit};
|
||||||
|
use crate::scene::{Scene, WireModel};
|
||||||
|
|
||||||
|
fn parse_num(text: &str) -> Option<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,19 +2,36 @@
|
||||||
// of a `stormsewer::Network` from the drawn entities.
|
// of a `stormsewer::Network` from the drawn entities.
|
||||||
//
|
//
|
||||||
// Structures are circles tagged with the `STORMSEWER_STRUCT` app record
|
// Structures are circles tagged with the `STORMSEWER_STRUCT` app record
|
||||||
// [kind, invert, rim, area, C]; pipes are lines tagged with `STORMSEWER_PIPE`
|
// [kind, invert, rim, area, C, tc_inlet?]; pipes are lines tagged with
|
||||||
// [diameter, n, from-handle, to-handle]. Connectivity is by entity handle, so
|
// `STORMSEWER_PIPE` [diameter, n, from-handle, to-handle]. Catchments are
|
||||||
// the drawn network round-trips to DWG/DXF and is analyzable directly.
|
// 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 std::collections::HashMap;
|
||||||
|
|
||||||
|
use acadrust::entities::LwPolyline;
|
||||||
use acadrust::xdata::{ExtendedDataRecord, XDataValue};
|
use acadrust::xdata::{ExtendedDataRecord, XDataValue};
|
||||||
use acadrust::{EntityType, Handle};
|
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};
|
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_STRUCT: &str = "STORMSEWER_STRUCT";
|
||||||
pub const APP_PIPE: &str = "STORMSEWER_PIPE";
|
pub const APP_PIPE: &str = "STORMSEWER_PIPE";
|
||||||
|
pub const APP_CATCHMENT: &str = "STORMSEWER_CATCHMENT";
|
||||||
|
|
||||||
pub fn kind_str(k: NodeKind) -> &'static str {
|
pub fn kind_str(k: NodeKind) -> &'static str {
|
||||||
match k {
|
match k {
|
||||||
|
|
@ -34,12 +51,25 @@ fn parse_kind(s: &str) -> NodeKind {
|
||||||
|
|
||||||
/// XDATA record for a structure marker.
|
/// XDATA record for a structure marker.
|
||||||
pub fn structure_xdata(kind: NodeKind, invert: f64, rim: f64, area: f64, c: f64) -> ExtendedDataRecord {
|
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);
|
let mut r = ExtendedDataRecord::new(APP_STRUCT);
|
||||||
r.add_value(XDataValue::String(kind_str(kind).to_string()));
|
r.add_value(XDataValue::String(kind_str(kind).to_string()));
|
||||||
r.add_value(XDataValue::Real(invert));
|
r.add_value(XDataValue::Real(invert));
|
||||||
r.add_value(XDataValue::Real(rim));
|
r.add_value(XDataValue::Real(rim));
|
||||||
r.add_value(XDataValue::Real(area));
|
r.add_value(XDataValue::Real(area));
|
||||||
r.add_value(XDataValue::Real(c));
|
r.add_value(XDataValue::Real(c));
|
||||||
|
r.add_value(XDataValue::Real(tc_inlet));
|
||||||
r
|
r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,6 +83,17 @@ pub fn pipe_xdata(diameter: f64, n: f64, from: Handle, to: Handle) -> ExtendedDa
|
||||||
r
|
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> {
|
fn real(v: &XDataValue) -> Option<f64> {
|
||||||
if let XDataValue::Real(x) = v {
|
if let XDataValue::Real(x) = v {
|
||||||
Some(*x)
|
Some(*x)
|
||||||
|
|
@ -76,10 +117,12 @@ struct StructRec {
|
||||||
rim: f64,
|
rim: f64,
|
||||||
area: f64,
|
area: f64,
|
||||||
c: f64,
|
c: f64,
|
||||||
|
tc_inlet: f64,
|
||||||
x: f64,
|
x: f64,
|
||||||
y: f64,
|
y: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
struct PipeRec {
|
struct PipeRec {
|
||||||
diameter: f64,
|
diameter: f64,
|
||||||
n: f64,
|
n: f64,
|
||||||
|
|
@ -88,6 +131,99 @@ struct PipeRec {
|
||||||
length: f64,
|
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> {
|
fn read_structure(e: &EntityType) -> Option<StructRec> {
|
||||||
let rec = e.common().extended_data.get_record(APP_STRUCT)?;
|
let rec = e.common().extended_data.get_record(APP_STRUCT)?;
|
||||||
if rec.values.len() < 5 {
|
if rec.values.len() < 5 {
|
||||||
|
|
@ -101,6 +237,11 @@ fn read_structure(e: &EntityType) -> Option<StructRec> {
|
||||||
EntityType::Circle(c) => (c.center.x, c.center.y),
|
EntityType::Circle(c) => (c.center.x, c.center.y),
|
||||||
_ => return None,
|
_ => return None,
|
||||||
};
|
};
|
||||||
|
let tc_inlet = if rec.values.len() >= 6 {
|
||||||
|
real(&rec.values[5]).unwrap_or(10.0)
|
||||||
|
} else {
|
||||||
|
10.0
|
||||||
|
};
|
||||||
Some(StructRec {
|
Some(StructRec {
|
||||||
handle: e.common().handle,
|
handle: e.common().handle,
|
||||||
kind,
|
kind,
|
||||||
|
|
@ -108,6 +249,7 @@ fn read_structure(e: &EntityType) -> Option<StructRec> {
|
||||||
rim: real(&rec.values[2])?,
|
rim: real(&rec.values[2])?,
|
||||||
area: real(&rec.values[3])?,
|
area: real(&rec.values[3])?,
|
||||||
c: real(&rec.values[4])?,
|
c: real(&rec.values[4])?,
|
||||||
|
tc_inlet,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
})
|
})
|
||||||
|
|
@ -135,19 +277,233 @@ fn read_pipe(e: &EntityType) -> Option<PipeRec> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a `stormsewer::Network` from drawn entities. Structures become nodes
|
fn polyline_vertices(pl: &LwPolyline) -> Vec<(f64, f64)> {
|
||||||
/// (named N1, N2, … in encounter order); pipes become links, mapped to nodes
|
pl.vertices.iter().map(|v| (v.location.x, v.location.y)).collect()
|
||||||
/// by the handles stored in their XDATA.
|
}
|
||||||
pub fn network_from_entities<'a>(entities: impl Iterator<Item = &'a EntityType>) -> Result<Network, String> {
|
|
||||||
|
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 structs: Vec<StructRec> = Vec::new();
|
||||||
let mut pipes_raw: Vec<PipeRec> = Vec::new();
|
let mut pipes_raw: Vec<(Handle, PipeRec)> = Vec::new();
|
||||||
|
let mut catchments: Vec<CatchmentRec> = Vec::new();
|
||||||
for e in entities {
|
for e in entities {
|
||||||
if let Some(s) = read_structure(e) {
|
if let Some(s) = read_structure(e) {
|
||||||
structs.push(s);
|
structs.push(s);
|
||||||
} else if let Some(p) = read_pipe(e) {
|
} else if let Some(p) = read_pipe(e) {
|
||||||
pipes_raw.push(p);
|
pipes_raw.push((e.common().handle, p));
|
||||||
|
} else if let Some(c) = read_catchment(e) {
|
||||||
|
catchments.push(c);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
apply_catchments(&mut structs, &catchments);
|
||||||
|
let network = assemble_network(&structs, &pipes_raw.iter().map(|(_, p)| p.clone()).collect::<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() {
|
if structs.is_empty() {
|
||||||
return Err("No storm-sewer structures in the drawing — place Inlet/Junction/Outfall first.".into());
|
return Err("No storm-sewer structures in the drawing — place Inlet/Junction/Outfall first.".into());
|
||||||
}
|
}
|
||||||
|
|
@ -162,6 +518,7 @@ pub fn network_from_entities<'a>(entities: impl Iterator<Item = &'a EntityType>)
|
||||||
NodeKind::Junction => Node::junction(&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),
|
NodeKind::Outfall => Node::outfall(&id, s.invert, s.rim),
|
||||||
}
|
}
|
||||||
|
.with_tc_inlet(s.tc_inlet)
|
||||||
.at(s.x, s.y);
|
.at(s.x, s.y);
|
||||||
nodes.push(node);
|
nodes.push(node);
|
||||||
}
|
}
|
||||||
|
|
@ -188,8 +545,9 @@ pub fn network_from_entities<'a>(entities: impl Iterator<Item = &'a EntityType>)
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use acadrust::types::Vector3;
|
use acadrust::entities::LwVertex;
|
||||||
use acadrust::{Circle, Line};
|
use acadrust::types::{Vector2, Vector3};
|
||||||
|
use acadrust::{Circle, Line, LwPolyline};
|
||||||
|
|
||||||
fn structure(h: u64, kind: NodeKind, x: f64, invert: f64) -> EntityType {
|
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() });
|
let mut e = EntityType::Circle(Circle { center: Vector3::new(x, 0.0, 0.0), radius: 3.0, ..Default::default() });
|
||||||
|
|
@ -203,6 +561,26 @@ mod tests {
|
||||||
e
|
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]
|
#[test]
|
||||||
fn reconstructs_network_from_tagged_entities() {
|
fn reconstructs_network_from_tagged_entities() {
|
||||||
let ents = vec![
|
let ents = vec![
|
||||||
|
|
@ -219,12 +597,51 @@ mod tests {
|
||||||
assert!((net.pipes[0].diameter - 1.5).abs() < 1e-9);
|
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]
|
#[test]
|
||||||
fn errors_when_no_structures() {
|
fn errors_when_no_structures() {
|
||||||
let ents: Vec<EntityType> = vec![];
|
let ents: Vec<EntityType> = vec![];
|
||||||
assert!(network_from_entities(ents.iter()).is_err());
|
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]
|
#[test]
|
||||||
fn dangling_pipe_is_reported() {
|
fn dangling_pipe_is_reported() {
|
||||||
let ents = vec![
|
let ents = vec![
|
||||||
|
|
|
||||||
172
src/modules/storm_sewer/dispatch.rs
Normal file
172
src/modules/storm_sewer/dispatch.rs
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
// Storm Sewer command handlers — all domain logic lives here, not in `src/plugin/`.
|
||||||
|
|
||||||
|
use crate::command::CadCommand;
|
||||||
|
use crate::plugin::host::HostSession;
|
||||||
|
|
||||||
|
use super::analysis;
|
||||||
|
use super::catchment::{apply_tc_from_network, TagCatchment};
|
||||||
|
use super::landxml_import;
|
||||||
|
use super::manifest::PLUGIN_ID;
|
||||||
|
use super::params_cmd;
|
||||||
|
use super::sizing;
|
||||||
|
use super::state::StormTabState;
|
||||||
|
use super::structures::{PlacePipe, PlaceStructure};
|
||||||
|
use super::{data, style};
|
||||||
|
|
||||||
|
fn tab_params(host: &mut HostSession<'_>) -> stormsewer::params::StormAnalysisParams {
|
||||||
|
host.ensure_plugin_state(PLUGIN_ID, StormTabState::default)
|
||||||
|
.params()
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle any `SS_*` command. Returns true when consumed.
|
||||||
|
pub fn handle(host: &mut HostSession<'_>, cmd: &str) -> bool {
|
||||||
|
if !cmd.starts_with("SS_") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
match cmd {
|
||||||
|
"SS_ANALYZE" => {
|
||||||
|
let params = tab_params(host);
|
||||||
|
match analysis::analyze_doc(host.entities(), ¶ms) {
|
||||||
|
Ok((ents, report, analysis)) => {
|
||||||
|
for e in ents {
|
||||||
|
let _ = host.add_entity(e);
|
||||||
|
}
|
||||||
|
if let Ok(drawn) = data::drawn_network_from_entities(host.entities()) {
|
||||||
|
host.push_undo("SS_STYLE");
|
||||||
|
let (sur, flood) = style::apply_analysis_style(host.entities_mut(), &drawn, &analysis);
|
||||||
|
if sur > 0 || flood > 0 {
|
||||||
|
host.set_dirty();
|
||||||
|
host.push_info(&format!(
|
||||||
|
"Styled {sur} surcharged pipe(s), {flood} flooded structure(s)."
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
host.bump_geometry();
|
||||||
|
host.push_info(&format!("Storm sewer analyzed ({}).", params.summary()));
|
||||||
|
for line in report.lines() {
|
||||||
|
host.push_output(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => host.push_error(&e),
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"SS_REPORT" => {
|
||||||
|
let params = tab_params(host);
|
||||||
|
match analysis::report_doc(host.entities(), ¶ms) {
|
||||||
|
Ok(report) => {
|
||||||
|
for line in report.lines() {
|
||||||
|
host.push_output(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => host.push_error(&e),
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"SS_MULTIRP" => {
|
||||||
|
let params = tab_params(host);
|
||||||
|
match analysis::multi_rp_report(host.entities(), ¶ms) {
|
||||||
|
Ok(report) => {
|
||||||
|
for line in report.lines() {
|
||||||
|
host.push_output(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => host.push_error(&e),
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"SS_PROFILE" => {
|
||||||
|
let params = tab_params(host);
|
||||||
|
match analysis::profile_doc(host.entities(), ¶ms) {
|
||||||
|
Ok(ents) => {
|
||||||
|
for e in ents {
|
||||||
|
let _ = host.add_entity(e);
|
||||||
|
}
|
||||||
|
host.bump_geometry();
|
||||||
|
host.push_info("Storm sewer HGL profile drawn.");
|
||||||
|
}
|
||||||
|
Err(e) => host.push_error(&e),
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"SS_SIZE" => {
|
||||||
|
let params = tab_params(host);
|
||||||
|
match sizing::plan_size_updates(host.entities(), ¶ms) {
|
||||||
|
Ok((updates, report, pending)) => {
|
||||||
|
for line in report.lines() {
|
||||||
|
host.push_output(line);
|
||||||
|
}
|
||||||
|
if pending == 0 {
|
||||||
|
host.push_info("Storm sewer: all pipes already meet sizing criteria.");
|
||||||
|
} else {
|
||||||
|
host.push_undo("SS_SIZE");
|
||||||
|
let applied = sizing::apply_updates(host.entities_mut(), &updates);
|
||||||
|
host.bump_geometry();
|
||||||
|
host.set_dirty();
|
||||||
|
host.push_info(&format!("Storm sewer: applied {applied} pipe diameter update(s)."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => host.push_error(&e),
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"SS_INLET" | "SS_JUNCTION" | "SS_OUTFALL" => {
|
||||||
|
let c = match cmd {
|
||||||
|
"SS_INLET" => PlaceStructure::inlet(),
|
||||||
|
"SS_JUNCTION" => PlaceStructure::junction(),
|
||||||
|
_ => PlaceStructure::outfall(),
|
||||||
|
};
|
||||||
|
host.push_info(&c.prompt());
|
||||||
|
host.set_active_command(Box::new(c));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"SS_PIPE" => {
|
||||||
|
let c = PlacePipe::new();
|
||||||
|
host.push_info(&c.prompt());
|
||||||
|
host.set_active_command(Box::new(c));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"SS_CATCHMENT" => {
|
||||||
|
let c = TagCatchment::new();
|
||||||
|
host.push_info(&c.prompt());
|
||||||
|
host.set_active_command(Box::new(c));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"SS_APPLYTC" => {
|
||||||
|
host.push_undo("SS_APPLYTC");
|
||||||
|
let snapshot: Vec<_> = host.entities().cloned().collect();
|
||||||
|
match apply_tc_from_network(snapshot.iter(), host.entities_mut()) {
|
||||||
|
Ok(n) => {
|
||||||
|
host.set_dirty();
|
||||||
|
host.bump_geometry();
|
||||||
|
host.push_info(&format!("Storm sewer: updated inlet Tc on {n} structure(s)."));
|
||||||
|
}
|
||||||
|
Err(e) => host.push_error(&e),
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
"SS_LANDXML" | "SS_IMPORTXML" => {
|
||||||
|
match landxml_import::pick_landxml_file() {
|
||||||
|
None => host.push_info("LandXML import cancelled."),
|
||||||
|
Some(Ok(xml)) => match landxml_import::import_landxml(host, &xml) {
|
||||||
|
Ok(msg) => host.push_info(&msg),
|
||||||
|
Err(e) => host.push_error(&e),
|
||||||
|
},
|
||||||
|
Some(Err(e)) => host.push_error(&e),
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
cmd if cmd == "SS_PARAMS" || cmd.starts_with("SS_PARAMS ") => {
|
||||||
|
let rest = cmd.trim_start_matches("SS_PARAMS").trim();
|
||||||
|
let state = host.ensure_plugin_state(PLUGIN_ID, StormTabState::default);
|
||||||
|
match params_cmd::apply_params(state, rest) {
|
||||||
|
Ok(msg) => host.push_info(&msg),
|
||||||
|
Err(e) => host.push_error(&e),
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
286
src/modules/storm_sewer/headless.rs
Normal file
286
src/modules/storm_sewer/headless.rs
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
//! Headless tests for storm-sewer UX: structure picking, C3D-style acquisition,
|
||||||
|
//! catchment/pipe command flows (no GUI, no file I/O).
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use acadrust::entities::LwVertex;
|
||||||
|
use acadrust::types::{Vector2, Vector3};
|
||||||
|
use acadrust::xdata::XDataValue;
|
||||||
|
use acadrust::{Circle, EntityType, Handle, Line, LwPolyline};
|
||||||
|
use glam::Vec3;
|
||||||
|
|
||||||
|
use stormsewer::network::NodeKind;
|
||||||
|
|
||||||
|
use crate::command::{CadCommand, CmdResult};
|
||||||
|
use crate::modules::storm_sewer::catchment::TagCatchment;
|
||||||
|
use crate::modules::storm_sewer::data::{
|
||||||
|
self, catchment_xdata, structure_at_point, structure_xdata, APP_CATCHMENT, APP_PIPE,
|
||||||
|
};
|
||||||
|
use crate::modules::storm_sewer::preview;
|
||||||
|
use crate::modules::storm_sewer::structures::PlacePipe;
|
||||||
|
use crate::scene::{Scene, WireModel};
|
||||||
|
use crate::snap::{SnapType, ALL_SNAP_MODES};
|
||||||
|
|
||||||
|
fn structure(h: u64, kind: NodeKind, x: f64, y: f64, radius: f64) -> EntityType {
|
||||||
|
let mut e = EntityType::Circle(Circle {
|
||||||
|
center: Vector3::new(x, y, 0.0),
|
||||||
|
radius,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
e.common_mut().handle = Handle::new(h);
|
||||||
|
e.common_mut()
|
||||||
|
.extended_data
|
||||||
|
.add_record(structure_xdata(kind, 100.0, 106.0, 1.0, 0.7));
|
||||||
|
e
|
||||||
|
}
|
||||||
|
|
||||||
|
fn closed_poly(h: u64) -> EntityType {
|
||||||
|
let mut pl = LwPolyline::default();
|
||||||
|
pl.is_closed = true;
|
||||||
|
pl.vertices = vec![
|
||||||
|
LwVertex::new(Vector2::new(0.0, 0.0)),
|
||||||
|
LwVertex::new(Vector2::new(100.0, 0.0)),
|
||||||
|
LwVertex::new(Vector2::new(100.0, 100.0)),
|
||||||
|
LwVertex::new(Vector2::new(0.0, 100.0)),
|
||||||
|
];
|
||||||
|
let mut e = EntityType::LwPolyline(pl);
|
||||||
|
e.common_mut().handle = Handle::new(h);
|
||||||
|
e
|
||||||
|
}
|
||||||
|
|
||||||
|
fn advance_catchment_to_inlet(cmd: &mut TagCatchment, poly: EntityType) {
|
||||||
|
cmd.inject_picked_entity(poly);
|
||||||
|
let _ = cmd.on_entity_pick(Handle::new(1), Vec3::ZERO);
|
||||||
|
let _ = cmd.on_enter(); // RunoffC -> FlowLength
|
||||||
|
let _ = cmd.on_enter(); // FlowLength -> Slope
|
||||||
|
let _ = cmd.on_enter(); // Slope -> PickInlet
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Structure resolution (point pick engine) ────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structure_at_point_returns_kind_label_and_center() {
|
||||||
|
let ents = vec![structure(1, NodeKind::Junction, 50.0, 25.0, 4.0)];
|
||||||
|
let pick = structure_at_point(ents.iter(), 50.0, 25.0, 5.0, true).unwrap();
|
||||||
|
assert_eq!(pick.handle, Handle::new(1));
|
||||||
|
assert_eq!(pick.label(), "Junction");
|
||||||
|
assert!((pick.x - 50.0).abs() < 1e-9);
|
||||||
|
assert!((pick.y - 25.0).abs() < 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn structure_at_point_prefers_nearest_marker() {
|
||||||
|
let ents = vec![
|
||||||
|
structure(1, NodeKind::Inlet, 0.0, 0.0, 3.0),
|
||||||
|
structure(2, NodeKind::Inlet, 40.0, 0.0, 3.0),
|
||||||
|
];
|
||||||
|
let pick = structure_at_point(ents.iter(), 38.0, 0.0, 5.0, true).unwrap();
|
||||||
|
assert_eq!(pick.handle, Handle::new(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catchment_pick_excludes_outfall() {
|
||||||
|
let ents = vec![structure(9, NodeKind::Outfall, 0.0, 0.0, 6.0)];
|
||||||
|
assert!(structure_at_point(ents.iter(), 0.0, 0.0, 20.0, false).is_none());
|
||||||
|
assert!(data::nearest_drainage_structure_at_point(ents.iter(), 0.0, 0.0, 20.0).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pipe_pick_includes_outfall() {
|
||||||
|
let ents = vec![structure(9, NodeKind::Outfall, 0.0, 0.0, 6.0)];
|
||||||
|
let pick = structure_at_point(ents.iter(), 0.0, 0.0, 20.0, true).unwrap();
|
||||||
|
assert_eq!(pick.label(), "Outfall");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── C3D acquisition constants ───────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn object_pick_not_in_osnap_palette() {
|
||||||
|
assert!(!ALL_SNAP_MODES.iter().any(|(t, _, _)| *t == SnapType::ObjectPick));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pick_highlight_color_is_orange() {
|
||||||
|
assert!(WireModel::PICK_HIGHLIGHT[0] > 0.9);
|
||||||
|
assert!(WireModel::PICK_HIGHLIGHT[1] > 0.4);
|
||||||
|
assert!(WireModel::PICK_HIGHLIGHT[2] < 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pipe_rubber_band_connects_start_to_cursor() {
|
||||||
|
let w = preview::pipe_run_rubber_band(10.0, 20.0, Vec3::new(50.0, 60.0, 0.0));
|
||||||
|
assert_eq!(w.points.len(), 2);
|
||||||
|
assert!((w.points[0][0] - 10.0).abs() < 1e-6);
|
||||||
|
assert!((w.points[1][0] - 50.0).abs() < 1e-6);
|
||||||
|
assert_eq!(w.color, WireModel::CYAN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scene-integrated structure under cursor ─────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scene_structure_under_cursor_at_marker_center() {
|
||||||
|
let mut scene = Scene::new();
|
||||||
|
let ent = structure(1, NodeKind::Inlet, 200.0, 150.0, 3.0);
|
||||||
|
scene.add_entity(ent);
|
||||||
|
let pick = preview::structure_under_cursor(&scene, 200.0, 150.0, true).unwrap();
|
||||||
|
assert_eq!(pick.label(), "Inlet");
|
||||||
|
let wires = preview::structure_acquire_previews(&scene, Vec3::new(200.0, 150.0, 0.0), true);
|
||||||
|
assert!(!wires.is_empty());
|
||||||
|
assert_eq!(wires[0].color, WireModel::PICK_HIGHLIGHT);
|
||||||
|
assert!(wires[0].line_weight_px >= 3.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SS_CATCHMENT command flow ───────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catchment_uses_structure_pick_only_on_inlet_step() {
|
||||||
|
let mut cmd = TagCatchment::new();
|
||||||
|
assert!(cmd.needs_entity_pick());
|
||||||
|
assert!(!cmd.needs_structure_point_pick());
|
||||||
|
|
||||||
|
advance_catchment_to_inlet(&mut cmd, closed_poly(10));
|
||||||
|
assert!(!cmd.needs_entity_pick());
|
||||||
|
assert!(cmd.needs_structure_point_pick());
|
||||||
|
assert!(cmd.prompt().contains("orange snap"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catchment_prompt_includes_acquisition_hint() {
|
||||||
|
let mut cmd = TagCatchment::new();
|
||||||
|
advance_catchment_to_inlet(&mut cmd, closed_poly(10));
|
||||||
|
cmd.set_acquisition_hint(Some("Inlet"));
|
||||||
|
assert!(cmd.prompt().contains("[Inlet]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catchment_explicit_inlet_writes_xdata_and_ends_command() {
|
||||||
|
let mut cmd = TagCatchment::new();
|
||||||
|
let poly = closed_poly(10);
|
||||||
|
advance_catchment_to_inlet(&mut cmd, poly);
|
||||||
|
|
||||||
|
match cmd.on_structure_pick(Handle::new(42), Vec3::new(10.0, 10.0, 0.0)) {
|
||||||
|
CmdResult::ReplaceMany(replacements, additions) => {
|
||||||
|
assert!(additions.is_empty());
|
||||||
|
assert_eq!(replacements.len(), 1);
|
||||||
|
let (_, ents) = &replacements[0];
|
||||||
|
let ent = &ents[0];
|
||||||
|
let rec = ent.common().extended_data.get_record(APP_CATCHMENT).unwrap();
|
||||||
|
assert!(matches!(&rec.values[3], XDataValue::Handle(h) if *h == Handle::new(42)));
|
||||||
|
}
|
||||||
|
other => panic!("expected ReplaceMany, got {:?}", std::mem::discriminant(&other)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catchment_enter_at_inlet_auto_assigns_nearest() {
|
||||||
|
let mut cmd = TagCatchment::new();
|
||||||
|
advance_catchment_to_inlet(&mut cmd, closed_poly(10));
|
||||||
|
|
||||||
|
match cmd.on_enter() {
|
||||||
|
CmdResult::ReplaceMany(replacements, _) => {
|
||||||
|
let ent = &replacements[0].1[0];
|
||||||
|
let rec = ent.common().extended_data.get_record(APP_CATCHMENT).unwrap();
|
||||||
|
assert!(matches!(&rec.values[3], XDataValue::Handle(h) if h.is_null()));
|
||||||
|
}
|
||||||
|
other => panic!("expected ReplaceMany, got {:?}", std::mem::discriminant(&other)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn catchment_structure_pick_sets_flow_length_when_zero() {
|
||||||
|
let mut cmd = TagCatchment::new();
|
||||||
|
advance_catchment_to_inlet(&mut cmd, closed_poly(10));
|
||||||
|
match cmd.on_structure_pick(Handle::new(1), Vec3::new(0.0, 0.0, 0.0)) {
|
||||||
|
CmdResult::ReplaceMany(replacements, _) => {
|
||||||
|
let rec = replacements[0].1[0]
|
||||||
|
.common()
|
||||||
|
.extended_data
|
||||||
|
.get_record(APP_CATCHMENT)
|
||||||
|
.unwrap();
|
||||||
|
let flow = match &rec.values[1] {
|
||||||
|
XDataValue::Real(v) => *v,
|
||||||
|
_ => 0.0,
|
||||||
|
};
|
||||||
|
// Centroid (50,50) → inlet at (0,0) ≈ 70.7 ft
|
||||||
|
assert!(flow > 50.0, "expected auto flow length, got {flow}");
|
||||||
|
}
|
||||||
|
other => panic!("expected ReplaceMany, got {:?}", std::mem::discriminant(&other)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SS_PIPE command flow ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pipe_uses_structure_point_pick() {
|
||||||
|
let cmd = PlacePipe::new();
|
||||||
|
assert!(!cmd.needs_entity_pick());
|
||||||
|
assert!(cmd.needs_structure_point_pick());
|
||||||
|
assert!(cmd.prompt().contains("orange snap"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pipe_end_prompt_references_start_structure_label() {
|
||||||
|
let mut cmd = PlacePipe::new();
|
||||||
|
cmd.set_acquisition_hint(Some("Inlet"));
|
||||||
|
cmd.on_structure_pick(Handle::new(1), Vec3::new(0.0, 0.0, 0.0));
|
||||||
|
assert!(cmd.prompt().contains("from Inlet"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pipe_commit_links_structures_in_xdata() {
|
||||||
|
let mut cmd = PlacePipe::new();
|
||||||
|
cmd.on_structure_pick(Handle::new(1), Vec3::new(0.0, 0.0, 0.0));
|
||||||
|
cmd.on_structure_pick(Handle::new(2), Vec3::new(100.0, 0.0, 0.0));
|
||||||
|
let mut cmd = PlacePipe::new();
|
||||||
|
cmd.on_structure_pick(Handle::new(1), Vec3::new(0.0, 0.0, 0.0));
|
||||||
|
if let CmdResult::CommitAndExit(e) = cmd.on_structure_pick(Handle::new(2), Vec3::new(100.0, 0.0, 0.0))
|
||||||
|
{
|
||||||
|
let rec = e.common().extended_data.get_record(APP_PIPE).unwrap();
|
||||||
|
assert!(matches!(&rec.values[2], XDataValue::Handle(h) if *h == Handle::new(1)));
|
||||||
|
assert!(matches!(&rec.values[3], XDataValue::Handle(h) if *h == Handle::new(2)));
|
||||||
|
} else {
|
||||||
|
panic!("second pick should commit pipe");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pipe_preview_only_on_second_pick() {
|
||||||
|
let mut cmd = PlacePipe::new();
|
||||||
|
assert!(cmd.on_preview_wires(Vec3::new(10.0, 0.0, 0.0)).is_empty());
|
||||||
|
cmd.on_structure_pick(Handle::new(1), Vec3::ZERO);
|
||||||
|
assert_eq!(cmd.on_preview_wires(Vec3::new(10.0, 0.0, 0.0)).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── End-to-end: catchment + network assembly ────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_catchment_inlet_feeds_network_analysis() {
|
||||||
|
let mut ents = vec![
|
||||||
|
structure(1, NodeKind::Inlet, 0.0, 0.0, 3.0),
|
||||||
|
structure(2, NodeKind::Outfall, 100.0, 0.0, 6.0),
|
||||||
|
EntityType::Line(Line::from_points(
|
||||||
|
Vector3::new(0.0, 0.0, 0.0),
|
||||||
|
Vector3::new(100.0, 0.0, 0.0),
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
ents[2].common_mut().handle = Handle::new(3);
|
||||||
|
ents[2]
|
||||||
|
.common_mut()
|
||||||
|
.extended_data
|
||||||
|
.add_record(data::pipe_xdata(1.5, 0.013, Handle::new(1), Handle::new(2)));
|
||||||
|
|
||||||
|
let mut poly = closed_poly(10);
|
||||||
|
poly.common_mut()
|
||||||
|
.extended_data
|
||||||
|
.add_record(catchment_xdata(0.8, 2500.0, 0.02, Handle::new(1)));
|
||||||
|
ents.push(poly);
|
||||||
|
|
||||||
|
let net = data::network_from_entities(ents.iter()).unwrap();
|
||||||
|
assert!(net.nodes[0].area_ac > 1.0, "catchment area should merge onto inlet");
|
||||||
|
assert!(
|
||||||
|
net.nodes[0].tc_inlet > 10.0,
|
||||||
|
"Kirpich tc should exceed default, got {}",
|
||||||
|
net.nodes[0].tc_inlet
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
118
src/modules/storm_sewer/landxml_import.rs
Normal file
118
src/modules/storm_sewer/landxml_import.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
// LandXML pipe-network import → storm-sewer drawing entities.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use acadrust::types::Vector3;
|
||||||
|
use acadrust::{Circle, EntityType, Handle, Line};
|
||||||
|
|
||||||
|
use stormsewer::io::landxml::{parse_landxml, LandXmlStruct};
|
||||||
|
|
||||||
|
use super::data;
|
||||||
|
use crate::plugin::host::HostSession;
|
||||||
|
|
||||||
|
fn structure_entity(s: &LandXmlStruct, radius: f64) -> EntityType {
|
||||||
|
let mut e = EntityType::Circle(Circle {
|
||||||
|
center: Vector3::new(s.x, s.y, 0.0),
|
||||||
|
radius,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let (area, c) = if s.kind == stormsewer::network::NodeKind::Outfall {
|
||||||
|
(0.0, 0.0)
|
||||||
|
} else {
|
||||||
|
(s.area_ac, s.c)
|
||||||
|
};
|
||||||
|
e.common_mut()
|
||||||
|
.extended_data
|
||||||
|
.add_record(data::structure_xdata(s.kind, s.invert, s.rim, area, c));
|
||||||
|
e
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pipe_entity(diameter: f64, n: f64, from: Handle, to: Handle, x0: f64, y0: f64, x1: f64, y1: f64) -> EntityType {
|
||||||
|
let mut e = EntityType::Line(Line::from_points(
|
||||||
|
Vector3::new(x0, y0, 0.0),
|
||||||
|
Vector3::new(x1, y1, 0.0),
|
||||||
|
));
|
||||||
|
e.common_mut()
|
||||||
|
.extended_data
|
||||||
|
.add_record(data::pipe_xdata(diameter, n, from, to));
|
||||||
|
e
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a LandXML file dialog and return the file text.
|
||||||
|
pub fn pick_landxml_file() -> Option<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/modules/storm_sewer/manifest.rs
Normal file
16
src/modules/storm_sewer/manifest.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
// Storm Sewer plugin identity (domain-specific; not part of `src/plugin/` runtime).
|
||||||
|
|
||||||
|
use crate::plugin::manifest::{ApiVersion, PluginManifest};
|
||||||
|
|
||||||
|
pub const PLUGIN_ID: &str = "opencad.storm_sewer";
|
||||||
|
|
||||||
|
pub static MANIFEST: PluginManifest = PluginManifest {
|
||||||
|
id: PLUGIN_ID,
|
||||||
|
name: "Storm Sewer",
|
||||||
|
version: "0.2.0",
|
||||||
|
description: "Gravity storm-drain network design and analysis",
|
||||||
|
api_version: ApiVersion::CURRENT,
|
||||||
|
ribbon_order: 50,
|
||||||
|
xdata_apps: &["STORMSEWER_STRUCT", "STORMSEWER_PIPE", "STORMSEWER_CATCHMENT"],
|
||||||
|
command_prefixes: &["SS_"],
|
||||||
|
};
|
||||||
|
|
@ -1,14 +1,24 @@
|
||||||
// Storm Sewer module — gravity storm-drain network design & analysis.
|
// Storm Sewer add-on (`opencad.storm_sewer`) — gravity storm-drain design & analysis.
|
||||||
//
|
//
|
||||||
// Implements the standard public-domain methods (Rational method, Manning,
|
// Package layout follows `docs/plugin-architecture.md` (QGIS-style add-on).
|
||||||
// HGL backwater) via the external `stormsewer` engine crate. The ribbon tab
|
// Ribbon: `CadModule` here; commands: `dispatch.rs` via `BuiltinPlugin`; engine: `stormsewer` crate.
|
||||||
// here is the UI surface; the actual command handlers (place structure, draw
|
|
||||||
// pipe, run analysis) are dispatched by the host command system — see
|
|
||||||
// INTEGRATION.md for where each `SS_*` command plugs in.
|
|
||||||
|
|
||||||
pub mod analysis;
|
pub mod analysis;
|
||||||
|
pub mod catchment;
|
||||||
pub mod data;
|
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 structures;
|
||||||
|
pub mod style;
|
||||||
|
|
||||||
use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
|
use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
|
||||||
|
|
||||||
|
|
@ -21,7 +31,14 @@ inventory::submit!(crate::command::CommandRegistration {
|
||||||
"SS_JUNCTION",
|
"SS_JUNCTION",
|
||||||
"SS_OUTFALL",
|
"SS_OUTFALL",
|
||||||
"SS_PIPE",
|
"SS_PIPE",
|
||||||
|
"SS_CATCHMENT",
|
||||||
|
"SS_APPLYTC",
|
||||||
|
"SS_LANDXML",
|
||||||
|
"SS_IMPORTXML",
|
||||||
"SS_ANALYZE",
|
"SS_ANALYZE",
|
||||||
|
"SS_SIZE",
|
||||||
|
"SS_PARAMS",
|
||||||
|
"SS_MULTIRP",
|
||||||
"SS_REPORT",
|
"SS_REPORT",
|
||||||
"SS_PROFILE",
|
"SS_PROFILE",
|
||||||
]
|
]
|
||||||
|
|
@ -32,6 +49,7 @@ const IC_JUNCTION: &[u8] = include_bytes!("icons/junction.svg");
|
||||||
const IC_OUTFALL: &[u8] = include_bytes!("icons/outfall.svg");
|
const IC_OUTFALL: &[u8] = include_bytes!("icons/outfall.svg");
|
||||||
const IC_PIPE: &[u8] = include_bytes!("icons/pipe.svg");
|
const IC_PIPE: &[u8] = include_bytes!("icons/pipe.svg");
|
||||||
const IC_ANALYZE: &[u8] = include_bytes!("icons/analyze.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_REPORT: &[u8] = include_bytes!("icons/report.svg");
|
||||||
const IC_PROFILE: &[u8] = include_bytes!("icons/profile.svg");
|
const IC_PROFILE: &[u8] = include_bytes!("icons/profile.svg");
|
||||||
|
|
||||||
|
|
@ -63,6 +81,8 @@ impl CadModule for StormSewerModule {
|
||||||
RibbonItem::LargeTool(tool("SS_JUNCTION", "Junction", IC_JUNCTION)),
|
RibbonItem::LargeTool(tool("SS_JUNCTION", "Junction", IC_JUNCTION)),
|
||||||
RibbonItem::LargeTool(tool("SS_OUTFALL", "Outfall", IC_OUTFALL)),
|
RibbonItem::LargeTool(tool("SS_OUTFALL", "Outfall", IC_OUTFALL)),
|
||||||
RibbonItem::LargeTool(tool("SS_PIPE", "Pipe\nRun", IC_PIPE)),
|
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 ─────────────────
|
// ── Analysis: run the engine and review results ─────────────────
|
||||||
|
|
@ -70,6 +90,10 @@ impl CadModule for StormSewerModule {
|
||||||
title: "Analysis",
|
title: "Analysis",
|
||||||
tools: vec![
|
tools: vec![
|
||||||
RibbonItem::LargeTool(tool("SS_ANALYZE", "Analyze", IC_ANALYZE)),
|
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_REPORT", "Report", IC_REPORT)),
|
||||||
RibbonItem::Tool(tool("SS_PROFILE", "Profile", IC_PROFILE)),
|
RibbonItem::Tool(tool("SS_PROFILE", "Profile", IC_PROFILE)),
|
||||||
],
|
],
|
||||||
|
|
@ -81,11 +105,11 @@ impl CadModule for StormSewerModule {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::modules::registry;
|
use crate::plugin::all_ribbon_modules;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn module_is_registered_in_ribbon() {
|
fn module_is_registered_in_ribbon() {
|
||||||
let titles: Vec<&str> = registry::all_modules().iter().map(|m| m.title()).collect();
|
let titles: Vec<&str> = all_ribbon_modules().iter().map(|m| m.title()).collect();
|
||||||
assert!(titles.contains(&"Storm Sewer"), "ribbon tabs: {titles:?}");
|
assert!(titles.contains(&"Storm Sewer"), "ribbon tabs: {titles:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,7 +123,9 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for needed in ["SS_INLET", "SS_PIPE", "SS_ANALYZE", "SS_REPORT", "SS_PROFILE"] {
|
for needed in [
|
||||||
|
"SS_INLET", "SS_PIPE", "SS_ANALYZE", "SS_SIZE", "SS_PARAMS", "SS_MULTIRP", "SS_REPORT", "SS_PROFILE",
|
||||||
|
] {
|
||||||
assert!(ids.contains(&needed), "missing {needed}; have {ids:?}");
|
assert!(ids.contains(&needed), "missing {needed}; have {ids:?}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -187,7 +213,8 @@ PIPE P2 N2 OUT 300 1.5 0.013
|
||||||
pipe,
|
pipe,
|
||||||
];
|
];
|
||||||
|
|
||||||
let (annotations, report) = super::analysis::analyze_doc(ents.iter()).expect("analyze drawn net");
|
let p = super::analysis::default_params();
|
||||||
|
let (annotations, report, _) = super::analysis::analyze_doc(ents.iter(), &p).expect("analyze drawn net");
|
||||||
assert!(!annotations.is_empty(), "expected flow/HGL labels");
|
assert!(!annotations.is_empty(), "expected flow/HGL labels");
|
||||||
assert!(report.contains("STORM SEWER ANALYSIS"), "report:\n{report}");
|
assert!(report.contains("STORM SEWER ANALYSIS"), "report:\n{report}");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
137
src/modules/storm_sewer/params_cmd.rs
Normal file
137
src/modules/storm_sewer/params_cmd.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
// Parse `SS_PARAMS` subcommands and update StormTabState.
|
||||||
|
|
||||||
|
use stormsewer::idf::IdfCurve;
|
||||||
|
use stormsewer::params::StormAnalysisParams;
|
||||||
|
|
||||||
|
use super::state::StormTabState;
|
||||||
|
|
||||||
|
fn parse_f64(s: &str) -> Result<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/modules/storm_sewer/plugin.rs
Normal file
24
src/modules/storm_sewer/plugin.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// Thin adapter: implements the generic `BuiltinPlugin` trait for Storm Sewer.
|
||||||
|
// Domain logic is in `dispatch.rs`; identity in `manifest.rs`; hook in `register.rs`.
|
||||||
|
|
||||||
|
use crate::plugin::host::{BuiltinPlugin, HostSession};
|
||||||
|
use crate::plugin::manifest::PluginManifest;
|
||||||
|
|
||||||
|
use super::dispatch;
|
||||||
|
use super::manifest;
|
||||||
|
|
||||||
|
pub struct StormSewerPlugin;
|
||||||
|
|
||||||
|
impl BuiltinPlugin for StormSewerPlugin {
|
||||||
|
fn manifest(&self) -> &'static PluginManifest {
|
||||||
|
&manifest::MANIFEST
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ribbon(&self) -> Box<dyn crate::modules::CadModule> {
|
||||||
|
Box::new(super::StormSewerModule)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool {
|
||||||
|
dispatch::handle(host, cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/modules/storm_sewer/plugin.toml
Normal file
16
src/modules/storm_sewer/plugin.toml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Storm Sewer add-on metadata.
|
||||||
|
# Must stay in sync with manifest.rs (compile-time source of truth for the host).
|
||||||
|
|
||||||
|
[plugin]
|
||||||
|
id = "opencad.storm_sewer"
|
||||||
|
name = "Storm Sewer"
|
||||||
|
version = "0.2.0"
|
||||||
|
description = "Gravity storm-drain network design and analysis"
|
||||||
|
author = "Open CAD Studio contributors"
|
||||||
|
license = "GPL-3.0-only"
|
||||||
|
|
||||||
|
[opencad]
|
||||||
|
api_version = 1
|
||||||
|
ribbon_order = 50
|
||||||
|
command_prefixes = ["SS_"]
|
||||||
|
xdata_apps = ["STORMSEWER_STRUCT", "STORMSEWER_PIPE", "STORMSEWER_CATCHMENT"]
|
||||||
96
src/modules/storm_sewer/preview.rs
Normal file
96
src/modules/storm_sewer/preview.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
// C3D-style acquisition previews: orange structure highlight, pipe rubber-band.
|
||||||
|
|
||||||
|
use acadrust::{EntityType, Handle};
|
||||||
|
use glam::Vec3;
|
||||||
|
|
||||||
|
use crate::scene::{Scene, WireModel};
|
||||||
|
|
||||||
|
use super::data::{self, StructurePick};
|
||||||
|
|
||||||
|
pub const STRUCTURE_PICK_PAD_FT: f64 = 15.0;
|
||||||
|
|
||||||
|
/// Orange highlight wires for a structure under the cursor.
|
||||||
|
pub fn highlight_structure(scene: &Scene, pick: &StructurePick) -> Vec<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)
|
||||||
|
}
|
||||||
10
src/modules/storm_sewer/register.rs
Normal file
10
src/modules/storm_sewer/register.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
// Compile-time registration with the generic plugin host.
|
||||||
|
// Keep this file free of storm-sewer logic — only the hook.
|
||||||
|
|
||||||
|
use super::plugin::StormSewerPlugin;
|
||||||
|
|
||||||
|
inventory::submit! {
|
||||||
|
crate::plugin::registry::PluginRegistration {
|
||||||
|
construct: || Box::new(StormSewerPlugin),
|
||||||
|
}
|
||||||
|
}
|
||||||
132
src/modules/storm_sewer/sizing.rs
Normal file
132
src/modules/storm_sewer/sizing.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
// Apply storm-sewer pipe sizing to the drawn network in the active document.
|
||||||
|
|
||||||
|
use acadrust::{EntityType, Handle};
|
||||||
|
|
||||||
|
use stormsewer::design::SizeOutcome;
|
||||||
|
use stormsewer::params::StormAnalysisParams;
|
||||||
|
use stormsewer::report::format_sizing;
|
||||||
|
|
||||||
|
use super::data;
|
||||||
|
|
||||||
|
/// A pipe diameter change to apply in the document.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct PipeDiameterUpdate {
|
||||||
|
pub handle: Handle,
|
||||||
|
pub new_diameter_ft: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Size pipes from drawn entities without modifying the document.
|
||||||
|
pub fn size_doc_report<'a>(
|
||||||
|
entities: impl Iterator<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/modules/storm_sewer/state.rs
Normal file
26
src/modules/storm_sewer/state.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
// Per-document storm-sewer parameters (stored in host tab plugin_state).
|
||||||
|
|
||||||
|
use stormsewer::params::StormAnalysisParams;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct StormTabState {
|
||||||
|
pub params: StormAnalysisParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for StormTabState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
params: StormAnalysisParams::municipal(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StormTabState {
|
||||||
|
pub fn params(&self) -> &StormAnalysisParams {
|
||||||
|
&self.params
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn params_mut(&mut self) -> &mut StormAnalysisParams {
|
||||||
|
&mut self.params
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,9 @@ use glam::Vec3;
|
||||||
use stormsewer::network::NodeKind;
|
use stormsewer::network::NodeKind;
|
||||||
|
|
||||||
use super::data;
|
use super::data;
|
||||||
use crate::command::{CadCommand, CmdResult};
|
use super::preview;
|
||||||
|
use crate::command::{CadCommand, CmdResult, ObjectPickHit};
|
||||||
|
use crate::scene::{Scene, WireModel};
|
||||||
|
|
||||||
fn parse_num(text: &str) -> Option<f64> {
|
fn parse_num(text: &str) -> Option<f64> {
|
||||||
text.trim().replace(',', ".").parse::<f64>().ok()
|
text.trim().replace(',', ".").parse::<f64>().ok()
|
||||||
|
|
@ -113,8 +115,6 @@ impl CadCommand for PlaceStructure {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||||
// A click always places the structure, using whatever values have been
|
|
||||||
// entered (remaining fields keep their defaults).
|
|
||||||
self.commit(pt.x as f64, pt.y as f64)
|
self.commit(pt.x as f64, pt.y as f64)
|
||||||
}
|
}
|
||||||
fn on_enter(&mut self) -> CmdResult {
|
fn on_enter(&mut self) -> CmdResult {
|
||||||
|
|
@ -135,11 +135,21 @@ pub struct PlacePipe {
|
||||||
n: f64,
|
n: f64,
|
||||||
start_handle: Option<Handle>,
|
start_handle: Option<Handle>,
|
||||||
start_xy: (f64, f64),
|
start_xy: (f64, f64),
|
||||||
|
start_label: Option<String>,
|
||||||
|
acquire_hint: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlacePipe {
|
impl PlacePipe {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self { step: PStep::PickStart, diameter: 1.25, n: 0.013, start_handle: None, start_xy: (0.0, 0.0) }
|
Self {
|
||||||
|
step: PStep::PickStart,
|
||||||
|
diameter: 1.25,
|
||||||
|
n: 0.013,
|
||||||
|
start_handle: None,
|
||||||
|
start_xy: (0.0, 0.0),
|
||||||
|
start_label: None,
|
||||||
|
acquire_hint: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fn commit(&self, end_handle: Handle, ex: f64, ey: f64) -> CmdResult {
|
fn commit(&self, end_handle: Handle, ex: f64, ey: f64) -> CmdResult {
|
||||||
let line = Line::from_points(
|
let line = Line::from_points(
|
||||||
|
|
@ -164,25 +174,69 @@ impl CadCommand for PlacePipe {
|
||||||
"SS_PIPE"
|
"SS_PIPE"
|
||||||
}
|
}
|
||||||
fn prompt(&self) -> String {
|
fn prompt(&self) -> String {
|
||||||
|
let hint = self
|
||||||
|
.acquire_hint
|
||||||
|
.as_deref()
|
||||||
|
.map(|h| format!(" [{h}]"))
|
||||||
|
.unwrap_or_default();
|
||||||
match self.step {
|
match self.step {
|
||||||
PStep::PickStart => "Pipe: click the START structure:".into(),
|
PStep::PickStart => format!("Pipe run: click START structure (orange snap){hint}:"),
|
||||||
PStep::PickEnd => format!("Pipe: click the END structure (dia {:.2} ft, n {:.3}):", self.diameter, self.n),
|
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 {
|
fn needs_entity_pick(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
fn needs_structure_point_pick(&self) -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
fn on_entity_pick(&mut self, handle: Handle, pt: Vec3) -> CmdResult {
|
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 {
|
match self.step {
|
||||||
PStep::PickStart => {
|
PStep::PickStart => {
|
||||||
self.start_handle = Some(handle);
|
self.start_handle = Some(handle);
|
||||||
self.start_xy = (pt.x as f64, pt.y as f64);
|
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;
|
self.step = PStep::PickEnd;
|
||||||
CmdResult::NeedPoint
|
CmdResult::NeedPoint
|
||||||
}
|
}
|
||||||
PStep::PickEnd => self.commit(handle, pt.x as f64, pt.y as f64),
|
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 {
|
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
|
||||||
CmdResult::NeedPoint
|
CmdResult::NeedPoint
|
||||||
}
|
}
|
||||||
|
|
@ -197,7 +251,6 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn click_places_structure_with_defaults() {
|
fn click_places_structure_with_defaults() {
|
||||||
// A click commits immediately (no typed values needed).
|
|
||||||
let mut cmd = PlaceStructure::inlet();
|
let mut cmd = PlaceStructure::inlet();
|
||||||
match cmd.on_point(Vec3::new(10.0, 20.0, 0.0)) {
|
match cmd.on_point(Vec3::new(10.0, 20.0, 0.0)) {
|
||||||
CmdResult::CommitAndExit(EntityType::Circle(c)) => {
|
CmdResult::CommitAndExit(EntityType::Circle(c)) => {
|
||||||
|
|
@ -212,20 +265,19 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn typed_values_are_captured_then_click_places() {
|
fn typed_values_are_captured_then_click_places() {
|
||||||
let mut cmd = PlaceStructure::inlet();
|
let mut cmd = PlaceStructure::inlet();
|
||||||
assert!(cmd.on_text_input("104").is_none()); // invert -> rim
|
assert!(cmd.on_text_input("104").is_none());
|
||||||
assert!(cmd.on_text_input("110").is_none()); // rim -> area
|
assert!(cmd.on_text_input("110").is_none());
|
||||||
assert!(cmd.on_text_input("2.0").is_none()); // area -> C
|
assert!(cmd.on_text_input("2.0").is_none());
|
||||||
assert!(cmd.on_text_input("0.8").is_none()); // C -> Ready
|
assert!(cmd.on_text_input("0.8").is_none());
|
||||||
assert!(matches!(cmd.step, SStep::Ready));
|
|
||||||
assert!(matches!(cmd.on_point(Vec3::ZERO), CmdResult::CommitAndExit(_)));
|
assert!(matches!(cmd.on_point(Vec3::ZERO), CmdResult::CommitAndExit(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pipe_connects_two_structures_on_two_clicks() {
|
fn pipe_connects_two_structures_on_two_clicks() {
|
||||||
let mut cmd = PlacePipe::new();
|
let mut cmd = PlacePipe::new();
|
||||||
assert!(cmd.needs_entity_pick());
|
assert!(cmd.needs_structure_point_pick());
|
||||||
assert!(matches!(cmd.on_entity_pick(Handle::new(1), Vec3::new(0.0, 0.0, 0.0)), CmdResult::NeedPoint));
|
assert!(matches!(cmd.on_structure_pick(Handle::new(1), Vec3::new(0.0, 0.0, 0.0)), CmdResult::NeedPoint));
|
||||||
match cmd.on_entity_pick(Handle::new(2), Vec3::new(100.0, 0.0, 0.0)) {
|
match cmd.on_structure_pick(Handle::new(2), Vec3::new(100.0, 0.0, 0.0)) {
|
||||||
CmdResult::CommitAndExit(EntityType::Line(l)) => {
|
CmdResult::CommitAndExit(EntityType::Line(l)) => {
|
||||||
assert_eq!(l.start.x, 0.0);
|
assert_eq!(l.start.x, 0.0);
|
||||||
assert_eq!(l.end.x, 100.0);
|
assert_eq!(l.end.x, 100.0);
|
||||||
|
|
@ -235,4 +287,13 @@ mod tests {
|
||||||
_ => panic!("expected CommitAndExit(Line)"),
|
_ => 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
61
src/modules/storm_sewer/style.rs
Normal file
61
src/modules/storm_sewer/style.rs
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
// Visual feedback for analysis results (surcharged pipes, flooded structures).
|
||||||
|
|
||||||
|
use acadrust::types::Color;
|
||||||
|
use acadrust::{EntityType, Handle};
|
||||||
|
|
||||||
|
use stormsewer::network::Analysis;
|
||||||
|
|
||||||
|
use super::data::DrawnNetwork;
|
||||||
|
|
||||||
|
fn color_surcharged_pipe() -> Color {
|
||||||
|
Color::from_index(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn color_flooded_struct() -> Color {
|
||||||
|
Color::from_index(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build handle → color assignments from an analysis result.
|
||||||
|
pub fn style_assignments(drawn: &DrawnNetwork, analysis: &Analysis) -> Vec<(Handle, Color)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (handle, pr) in drawn.pipe_handles.iter().zip(analysis.pipes.iter()) {
|
||||||
|
if pr.surcharged {
|
||||||
|
out.push((*handle, color_surcharged_pipe()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (handle, nr) in drawn.node_handles.iter().zip(analysis.nodes.iter()) {
|
||||||
|
if nr.surcharge_to_surface {
|
||||||
|
out.push((*handle, color_flooded_struct()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply handle → color assignments to drawing entities.
|
||||||
|
pub fn apply_colors<'a>(
|
||||||
|
entities: impl Iterator<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)
|
||||||
|
}
|
||||||
17
src/plugin/host.rs
Normal file
17
src/plugin/host.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
// Plugin traits — HostSession lives in `app::plugin_host` (same-crate field access).
|
||||||
|
|
||||||
|
pub(crate) use crate::app::plugin_host::HostSession;
|
||||||
|
|
||||||
|
use crate::modules::CadModule;
|
||||||
|
|
||||||
|
use super::manifest::PluginManifest;
|
||||||
|
|
||||||
|
/// Add-on package entry point (phase 1: in-tree, in-process).
|
||||||
|
///
|
||||||
|
/// One `PluginRegistration` per package — ribbon tab, manifest, and command
|
||||||
|
/// dispatch are owned here. See `docs/plugin-architecture.md`.
|
||||||
|
pub trait BuiltinPlugin: Send + Sync {
|
||||||
|
fn manifest(&self) -> &'static PluginManifest;
|
||||||
|
fn ribbon(&self) -> Box<dyn CadModule>;
|
||||||
|
fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool;
|
||||||
|
}
|
||||||
32
src/plugin/manifest.rs
Normal file
32
src/plugin/manifest.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
// Plugin identity and capability declaration.
|
||||||
|
|
||||||
|
/// Host plugin API version. Bump when HostApi breaks compatibility.
|
||||||
|
pub const API_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub struct ApiVersion {
|
||||||
|
pub major: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiVersion {
|
||||||
|
pub const CURRENT: Self = Self { major: API_VERSION };
|
||||||
|
|
||||||
|
pub fn is_compatible_with(host: ApiVersion) -> bool {
|
||||||
|
Self::CURRENT.major == host.major
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Static metadata every plugin supplies at registration time.
|
||||||
|
/// Keep fields in sync with `plugin.toml` beside the package.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct PluginManifest {
|
||||||
|
pub id: &'static str,
|
||||||
|
pub name: &'static str,
|
||||||
|
pub version: &'static str,
|
||||||
|
pub description: &'static str,
|
||||||
|
pub api_version: ApiVersion,
|
||||||
|
/// Sort key for add-on ribbon tabs (lower = further left among plugins).
|
||||||
|
pub ribbon_order: i32,
|
||||||
|
pub xdata_apps: &'static [&'static str],
|
||||||
|
pub command_prefixes: &'static [&'static str],
|
||||||
|
}
|
||||||
13
src/plugin/mod.rs
Normal file
13
src/plugin/mod.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
// Open CAD Studio plugin runtime (phase 1: built-in, in-process).
|
||||||
|
//
|
||||||
|
// Generic host only — no domain logic. See `docs/plugin-architecture.md`.
|
||||||
|
// Domain plugins (e.g. storm_sewer) live under `src/modules/<name>/` and
|
||||||
|
// register here via `inventory::submit!(PluginRegistration { … })`.
|
||||||
|
|
||||||
|
pub mod host;
|
||||||
|
pub mod manifest;
|
||||||
|
pub mod registry;
|
||||||
|
|
||||||
|
pub use registry::all_ribbon_modules;
|
||||||
|
pub(crate) use host::BuiltinPlugin;
|
||||||
|
pub(crate) use registry::try_dispatch;
|
||||||
42
src/plugin/registry.rs
Normal file
42
src/plugin/registry.rs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
// Compile-time plugin registry via `inventory`.
|
||||||
|
|
||||||
|
use super::host::{BuiltinPlugin, HostSession};
|
||||||
|
use crate::app::OpenCADStudio;
|
||||||
|
use crate::modules::{registry as core_registry, CadModule};
|
||||||
|
|
||||||
|
pub struct PluginRegistration {
|
||||||
|
pub construct: fn() -> Box<dyn BuiltinPlugin>,
|
||||||
|
}
|
||||||
|
|
||||||
|
inventory::collect!(PluginRegistration);
|
||||||
|
|
||||||
|
/// Construct every registered built-in plugin (once per process).
|
||||||
|
pub fn all_plugins() -> Vec<Box<dyn BuiltinPlugin>> {
|
||||||
|
inventory::iter::<PluginRegistration>
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| (r.construct)())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Core ribbon tabs plus add-on tabs (sorted by `manifest.ribbon_order`).
|
||||||
|
pub fn all_ribbon_modules() -> Vec<Box<dyn CadModule>> {
|
||||||
|
let mut core = core_registry::all_modules();
|
||||||
|
let mut addons: Vec<(i32, Box<dyn CadModule>)> = all_plugins()
|
||||||
|
.into_iter()
|
||||||
|
.map(|p| (p.manifest().ribbon_order, p.ribbon()))
|
||||||
|
.collect();
|
||||||
|
addons.sort_by_key(|(order, _)| *order);
|
||||||
|
core.extend(addons.into_iter().map(|(_, ribbon)| ribbon));
|
||||||
|
core
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try each plugin until one handles `cmd`. Returns true if handled.
|
||||||
|
pub(crate) fn try_dispatch(app: &mut OpenCADStudio, tab: usize, cmd: &str) -> bool {
|
||||||
|
let mut host = HostSession::new(app, tab);
|
||||||
|
for plugin in all_plugins() {
|
||||||
|
if plugin.dispatch(&mut host, cmd) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
@ -79,6 +79,9 @@ impl WireModel {
|
||||||
pub const WHITE: [f32; 4] = [1.00, 1.00, 1.00, 1.0];
|
pub const WHITE: [f32; 4] = [1.00, 1.00, 1.00, 1.0];
|
||||||
pub const CYAN: [f32; 4] = [0.25, 0.85, 1.00, 1.0];
|
pub const CYAN: [f32; 4] = [0.25, 0.85, 1.00, 1.0];
|
||||||
pub const SELECTED: [f32; 4] = [0.15, 0.55, 1.00, 1.0];
|
pub const SELECTED: [f32; 4] = [0.15, 0.55, 1.00, 1.0];
|
||||||
|
/// Civil 3D-style object acquisition highlight (structure / catchment hover).
|
||||||
|
pub const PICK_HIGHLIGHT: [f32; 4] = [0.95, 0.50, 0.08, 1.0];
|
||||||
|
pub const PICK_HIGHLIGHT_DIM: [f32; 4] = [0.95, 0.50, 0.08, 0.45];
|
||||||
/// Sentinel AABB that never rejects any snap query.
|
/// Sentinel AABB that never rejects any snap query.
|
||||||
pub const UNBOUNDED_AABB: [f32; 4] = [
|
pub const UNBOUNDED_AABB: [f32; 4] = [
|
||||||
f32::NEG_INFINITY,
|
f32::NEG_INFINITY,
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ pub enum SnapType {
|
||||||
ApparentIntersection,
|
ApparentIntersection,
|
||||||
Parallel,
|
Parallel,
|
||||||
Grid,
|
Grid,
|
||||||
|
/// C3D-style object acquisition (storm structure / network pick) — orange marker.
|
||||||
|
ObjectPick,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ordered list used by the popup and snap engine.
|
/// Ordered list used by the popup and snap engine.
|
||||||
|
|
|
||||||
|
|
@ -500,18 +500,37 @@ impl canvas::Program<Message> for SelectionCanvas {
|
||||||
|
|
||||||
// ── Snap marker ───────────────────────────────────────────────────
|
// ── Snap marker ───────────────────────────────────────────────────
|
||||||
if let Some((sp, snap_type)) = self.snap {
|
if let Some((sp, snap_type)) = self.snap {
|
||||||
let yellow = Color {
|
let (r, g, b) = if snap_type == SnapType::ObjectPick {
|
||||||
r: 1.0,
|
(0.95_f32, 0.50, 0.08) // C3D-style orange object snap
|
||||||
g: 0.9,
|
} else {
|
||||||
b: 0.1,
|
(1.0, 0.9, 0.1) // classic yellow OSNAP
|
||||||
a: 1.0,
|
|
||||||
};
|
};
|
||||||
|
let marker = Color { r, g, b, a: 1.0 };
|
||||||
let stroke = canvas::Stroke {
|
let stroke = canvas::Stroke {
|
||||||
width: 1.5,
|
width: if snap_type == SnapType::ObjectPick { 2.0 } else { 1.5 },
|
||||||
style: canvas::Style::Solid(yellow),
|
style: canvas::Style::Solid(marker),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
match snap_type {
|
match snap_type {
|
||||||
|
SnapType::ObjectPick => {
|
||||||
|
// Target box + center dot (Civil 3D acquisition glyph).
|
||||||
|
let h = 7.0_f32;
|
||||||
|
let rect = canvas::Path::rectangle(
|
||||||
|
Point::new(sp.x - h, sp.y - h),
|
||||||
|
Size::new(h * 2.0, h * 2.0),
|
||||||
|
);
|
||||||
|
frame.stroke(&rect, stroke.clone());
|
||||||
|
let r = 3.0_f32;
|
||||||
|
frame.fill(
|
||||||
|
&canvas::Path::circle(sp, r),
|
||||||
|
Color {
|
||||||
|
r: 0.95,
|
||||||
|
g: 0.50,
|
||||||
|
b: 0.08,
|
||||||
|
a: 0.85,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
SnapType::Endpoint => {
|
SnapType::Endpoint => {
|
||||||
let h = 5.0_f32;
|
let h = 5.0_f32;
|
||||||
let rect = canvas::Path::rectangle(
|
let rect = canvas::Path::rectangle(
|
||||||
|
|
@ -655,7 +674,7 @@ impl canvas::Program<Message> for SelectionCanvas {
|
||||||
let r = 1.4_f32;
|
let r = 1.4_f32;
|
||||||
for k in [-7.0_f32, 0.0, 7.0] {
|
for k in [-7.0_f32, 0.0, 7.0] {
|
||||||
let dot = canvas::Path::circle(Point::new(sp.x + k, sp.y), r);
|
let dot = canvas::Path::circle(Point::new(sp.x + k, sp.y), r);
|
||||||
frame.fill(&dot, yellow);
|
frame.fill(&dot, marker);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SnapType::Parallel => {
|
SnapType::Parallel => {
|
||||||
|
|
|
||||||
|
|
@ -13,8 +13,8 @@ use iced::widget::{button, column, container, mouse_area, row, scrollable, svg,
|
||||||
use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme};
|
use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme};
|
||||||
|
|
||||||
use crate::app::Message;
|
use crate::app::Message;
|
||||||
use crate::modules::registry;
|
|
||||||
use crate::modules::{CadModule, IconKind, RibbonItem};
|
use crate::modules::{CadModule, IconKind, RibbonItem};
|
||||||
|
use crate::plugin::all_ribbon_modules;
|
||||||
use crate::ui::properties::{color_picker_dropdown, lw_options, LinetypeItem};
|
use crate::ui::properties::{color_picker_dropdown, lw_options, LinetypeItem};
|
||||||
|
|
||||||
mod widgets;
|
mod widgets;
|
||||||
|
|
@ -67,7 +67,7 @@ pub struct LayerInfo {
|
||||||
impl Ribbon {
|
impl Ribbon {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
modules: registry::all_modules(),
|
modules: all_ribbon_modules(),
|
||||||
active: 0,
|
active: 0,
|
||||||
active_tool: None,
|
active_tool: None,
|
||||||
wireframe: false,
|
wireframe: false,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue