cad-editor/src/modules/storm_sewer/mod.rs
Michael Flynn 1e68219b43 Storm Sewer: interactive in-app network — snapping, tracked objects, analyze the drawing
Replaces the .ssn-file workflow with an interactive one driven by the canvas:

- Structures (SS_INLET / SS_JUNCTION / SS_OUTFALL) prompt invert/rim/area/C on
  the command line and commit a CIRCLE tagged with STORMSEWER_STRUCT XDATA
  [kind, invert, rim, area, C]. Outfalls skip area/C.
- SS_PIPE picks a START and END structure (entity-pick), snapping its endpoints
  to their centers, prompts diameter/n, and stores connectivity (the two
  structure handles) in STORMSEWER_PIPE XDATA [diameter, n, from, to].
- SS_ANALYZE / SS_REPORT / SS_PROFILE rebuild the network from the drawn
  entities (data.rs::network_from_entities) and analyze it directly; SS_ANALYZE
  overlays flow/HGL labels without redrawing the geometry.

Because the data lives on the entities as XDATA, the network round-trips to
DWG/DXF. One additive line in src/app/update.rs adds SS_PIPE to the
inject_picked_entity allow-list so the command can read the picked structure's
center for snapping.

15 module tests cover the XDATA round-trip, analyze-from-drawing, and the
command step machines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 07:29:14 -04:00

194 lines
7.1 KiB
Rust

// Storm Sewer module — gravity storm-drain network design & analysis.
//
// Implements the standard public-domain methods (Rational method, Manning,
// HGL backwater) via the external `stormsewer` engine crate. The ribbon tab
// here is the UI surface; the actual command handlers (place structure, draw
// pipe, run analysis) are dispatched by the host command system — see
// INTEGRATION.md for where each `SS_*` command plugs in.
pub mod analysis;
pub mod data;
pub mod structures;
use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
pub struct StormSewerModule;
// Register the SS_* command names for command-line autocomplete.
inventory::submit!(crate::command::CommandRegistration {
names: &[
"SS_INLET",
"SS_JUNCTION",
"SS_OUTFALL",
"SS_PIPE",
"SS_ANALYZE",
"SS_REPORT",
"SS_PROFILE",
]
});
const IC_INLET: &[u8] = include_bytes!("icons/inlet.svg");
const IC_JUNCTION: &[u8] = include_bytes!("icons/junction.svg");
const IC_OUTFALL: &[u8] = include_bytes!("icons/outfall.svg");
const IC_PIPE: &[u8] = include_bytes!("icons/pipe.svg");
const IC_ANALYZE: &[u8] = include_bytes!("icons/analyze.svg");
const IC_REPORT: &[u8] = include_bytes!("icons/report.svg");
const IC_PROFILE: &[u8] = include_bytes!("icons/profile.svg");
/// Convenience: an SVG-icon tool that fires a named `SS_*` command.
fn tool(id: &'static str, label: &'static str, icon: &'static [u8]) -> ToolDef {
ToolDef {
id,
label,
icon: IconKind::Svg(icon),
event: ModuleEvent::Command(id.to_string()),
}
}
impl CadModule for StormSewerModule {
fn id(&self) -> &'static str {
"storm_sewer"
}
fn title(&self) -> &'static str {
"Storm Sewer"
}
fn ribbon_groups(&self) -> Vec<RibbonGroup> {
vec![
// ── Network: place structures and connect them with pipes ───────
RibbonGroup {
title: "Network",
tools: vec![
RibbonItem::LargeTool(tool("SS_INLET", "Inlet", IC_INLET)),
RibbonItem::LargeTool(tool("SS_JUNCTION", "Junction", IC_JUNCTION)),
RibbonItem::LargeTool(tool("SS_OUTFALL", "Outfall", IC_OUTFALL)),
RibbonItem::LargeTool(tool("SS_PIPE", "Pipe\nRun", IC_PIPE)),
],
},
// ── Analysis: run the engine and review results ─────────────────
RibbonGroup {
title: "Analysis",
tools: vec![
RibbonItem::LargeTool(tool("SS_ANALYZE", "Analyze", IC_ANALYZE)),
RibbonItem::Tool(tool("SS_REPORT", "Report", IC_REPORT)),
RibbonItem::Tool(tool("SS_PROFILE", "Profile", IC_PROFILE)),
],
},
]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::modules::registry;
#[test]
fn module_is_registered_in_ribbon() {
let titles: Vec<&str> = registry::all_modules().iter().map(|m| m.title()).collect();
assert!(titles.contains(&"Storm Sewer"), "ribbon tabs: {titles:?}");
}
#[test]
fn ribbon_exposes_core_tools() {
let mut ids = Vec::new();
for g in StormSewerModule.ribbon_groups() {
for item in g.tools {
if let RibbonItem::Tool(t) | RibbonItem::LargeTool(t) = item {
ids.push(t.id);
}
}
}
for needed in ["SS_INLET", "SS_PIPE", "SS_ANALYZE", "SS_REPORT", "SS_PROFILE"] {
assert!(ids.contains(&needed), "missing {needed}; have {ids:?}");
}
}
#[test]
fn engine_bridge_runs_inside_ocs() {
// The stormsewer crate, called from within the OCS binary, produces a
// full analysis report for the properly-sized sample network.
let report = super::analysis::demo_report();
assert!(report.contains("STORM SEWER ANALYSIS"), "no header:\n{report}");
assert!(report.contains("P1") && report.contains("HGL"), "no pipe/HGL:\n{report}");
assert!(report.contains("no surface flooding"), "expected ok design:\n{report}");
}
#[test]
fn analyze_plan_emits_entities_and_report() {
let (ents, report) = super::analysis::analyze_plan().expect("analysis runs");
// 3 pipes + 4 structures + labels → comfortably more than 7 entities.
assert!(ents.len() >= 7, "too few entities: {}", ents.len());
assert!(report.contains("STORM SEWER ANALYSIS"));
}
#[test]
fn analyze_profile_emits_entities() {
let ents = super::analysis::analyze_profile().expect("profile runs");
// Ground + invert + HGL line segments along a 4-node stem.
assert!(ents.len() >= 6, "too few profile entities: {}", ents.len());
}
const SAMPLE_SSN: &str = "\
IDF 60 10 0.8
TAILWATER 100.5
NODE N1 inlet 0 0 104 110 1 0.7 12
NODE N2 inlet 300 0 102.5 108.5 1 0.7
NODE OUT outfall 600 0 100 106
PIPE P1 N1 N2 300 1.25 0.013
PIPE P2 N2 OUT 300 1.5 0.013
";
#[test]
fn analyze_text_parses_and_emits() {
let (ents, report) = super::analysis::analyze_text(SAMPLE_SSN).expect("parse + analyze");
assert!(ents.len() >= 5, "too few entities: {}", ents.len());
assert!(report.contains("STORM SEWER ANALYSIS"));
}
#[test]
fn profile_text_parses_and_emits() {
let ents = super::analysis::profile_text(SAMPLE_SSN).expect("parse + profile");
assert!(ents.len() >= 4, "too few profile entities: {}", ents.len());
}
#[test]
fn bad_ssn_reports_error() {
let err = super::analysis::analyze_text("NODE X inlet 0 0 oops 1 1 1").unwrap_err();
assert!(err.contains("line 1"), "{err}");
}
#[test]
fn analyze_doc_runs_on_drawn_network() {
use acadrust::types::Vector3;
use acadrust::{Circle, EntityType, Handle, Line};
use stormsewer::network::NodeKind;
let mk_struct = |h: u64, kind, x: f64, inv: f64| {
let mut e = EntityType::Circle(Circle {
center: Vector3::new(x, 0.0, 0.0),
radius: 3.0,
..Default::default()
});
e.common_mut().handle = Handle::new(h);
e.common_mut()
.extended_data
.add_record(super::data::structure_xdata(kind, inv, inv + 6.0, 1.0, 0.7));
e
};
let mut pipe =
EntityType::Line(Line::from_points(Vector3::new(0.0, 0.0, 0.0), Vector3::new(200.0, 0.0, 0.0)));
pipe.common_mut()
.extended_data
.add_record(super::data::pipe_xdata(1.5, 0.013, Handle::new(1), Handle::new(2)));
let ents = vec![
mk_struct(1, NodeKind::Inlet, 0.0, 104.0),
mk_struct(2, NodeKind::Outfall, 200.0, 100.0),
pipe,
];
let (annotations, report) = super::analysis::analyze_doc(ents.iter()).expect("analyze drawn net");
assert!(!annotations.is_empty(), "expected flow/HGL labels");
assert!(report.contains("STORM SEWER ANALYSIS"), "report:\n{report}");
}
}