Add Storm Sewer module: storm-drain network analysis (Rational + Manning + HGL)

Adds a "Storm Sewer" ribbon tab backed by a new in-repo `stormsewer` engine
crate (vendored under crates/). The engine implements standard public-domain
methods — Rational-method peak-flow accumulation, Manning circular-pipe
hydraulics, time-of-concentration / IDF intensity, and an HGL backwater pass
with junction losses — with 24 unit tests and no external dependencies.

Module (src/modules/storm_sewer/):
- Drafting: SS_INLET / SS_JUNCTION / SS_OUTFALL place structure markers and
  SS_PIPE chains pipe runs (interactive CadCommands).
- Analysis: SS_ANALYZE / SS_REPORT / SS_PROFILE open a .ssn network file, run
  the engine, draw the plan and HGL long-section, and print a report.
- SVG icons; SS_* commands registered for command-line autocomplete.

Integration is additive: build.rs auto-discovers the module and one dispatch
block is added to src/app/commands.rs. 7 module integration tests.

See src/modules/storm_sewer/INTEGRATION.md for the design and the optional
follow-up (drive analysis from canvas geometry via XDATA).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michael Flynn 2026-06-08 17:16:30 -04:00
commit 9b44eb6400
26 changed files with 2007 additions and 0 deletions

View file

@ -29,6 +29,70 @@ impl OpenCADStudio {
}
match cmd {
// ── Storm Sewer module ──────────────────────────────────────────
"SS_ANALYZE" => {
use crate::modules::storm_sewer::analysis as ss;
match ss::pick_ssn_file() {
Some(read) => match read.and_then(|t| ss::analyze_text(&t)) {
Ok((ents, report)) => {
for e in ents {
let _ = self.tabs[i].scene.add_entity(e);
}
self.tabs[i].scene.bump_geometry();
for line in report.lines() {
self.command_line.push_output(line);
}
}
Err(e) => self.command_line.push_error(&e),
},
None => self.command_line.push_info("Storm sewer: open cancelled."),
}
}
"SS_REPORT" => {
use crate::modules::storm_sewer::analysis as ss;
match ss::pick_ssn_file() {
Some(read) => match read.and_then(|t| ss::report_text_from(&t)) {
Ok(report) => {
for line in report.lines() {
self.command_line.push_output(line);
}
}
Err(e) => self.command_line.push_error(&e),
},
None => self.command_line.push_info("Storm sewer: open cancelled."),
}
}
"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;
match ss::pick_ssn_file() {
Some(read) => match read.and_then(|t| ss::profile_text(&t)) {
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),
},
None => self.command_line.push_info("Storm sewer: open cancelled."),
}
}
"NEW" => return Task::done(Message::TabNew),
"OPEN" => return Task::done(Message::OpenFile),
"SAVE" | "QSAVE" => return Task::done(Message::SaveFile),