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>
194 lines
7.1 KiB
Rust
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}");
|
|
}
|
|
}
|