cad-editor/src/modules/storm_sewer/mod.rs
Michael Flynn e46701456f 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.
2026-06-09 13:41:21 -04:00

221 lines
7.9 KiB
Rust

// Storm Sewer add-on (`opencad.storm_sewer`) — gravity storm-drain design & analysis.
//
// Package layout follows `docs/plugin-architecture.md` (QGIS-style add-on).
// Ribbon: `CadModule` here; commands: `dispatch.rs` via `BuiltinPlugin`; engine: `stormsewer` crate.
pub mod analysis;
pub mod catchment;
pub mod data;
pub mod dispatch;
#[cfg(test)]
mod headless;
pub mod landxml_import;
pub mod manifest;
pub mod params_cmd;
pub mod preview;
pub mod plugin;
pub mod register;
pub mod sizing;
pub mod state;
pub mod structures;
pub mod style;
use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
pub struct StormSewerModule;
// Register the SS_* command names for command-line autocomplete.
inventory::submit!(crate::command::CommandRegistration {
names: &[
"SS_INLET",
"SS_JUNCTION",
"SS_OUTFALL",
"SS_PIPE",
"SS_CATCHMENT",
"SS_APPLYTC",
"SS_LANDXML",
"SS_IMPORTXML",
"SS_ANALYZE",
"SS_SIZE",
"SS_PARAMS",
"SS_MULTIRP",
"SS_REPORT",
"SS_PROFILE",
]
});
const IC_INLET: &[u8] = include_bytes!("icons/inlet.svg");
const IC_JUNCTION: &[u8] = include_bytes!("icons/junction.svg");
const IC_OUTFALL: &[u8] = include_bytes!("icons/outfall.svg");
const IC_PIPE: &[u8] = include_bytes!("icons/pipe.svg");
const IC_ANALYZE: &[u8] = include_bytes!("icons/analyze.svg");
const IC_SIZE: &[u8] = include_bytes!("icons/pipe.svg");
const IC_REPORT: &[u8] = include_bytes!("icons/report.svg");
const IC_PROFILE: &[u8] = include_bytes!("icons/profile.svg");
/// Convenience: an SVG-icon tool that fires a named `SS_*` command.
fn tool(id: &'static str, label: &'static str, icon: &'static [u8]) -> ToolDef {
ToolDef {
id,
label,
icon: IconKind::Svg(icon),
event: ModuleEvent::Command(id.to_string()),
}
}
impl CadModule for StormSewerModule {
fn id(&self) -> &'static str {
"storm_sewer"
}
fn title(&self) -> &'static str {
"Storm Sewer"
}
fn ribbon_groups(&self) -> Vec<RibbonGroup> {
vec![
// ── Network: place structures and connect them with pipes ───────
RibbonGroup {
title: "Network",
tools: vec![
RibbonItem::LargeTool(tool("SS_INLET", "Inlet", IC_INLET)),
RibbonItem::LargeTool(tool("SS_JUNCTION", "Junction", IC_JUNCTION)),
RibbonItem::LargeTool(tool("SS_OUTFALL", "Outfall", IC_OUTFALL)),
RibbonItem::LargeTool(tool("SS_PIPE", "Pipe\nRun", IC_PIPE)),
RibbonItem::Tool(tool("SS_CATCHMENT", "Catchment", IC_INLET)),
RibbonItem::Tool(tool("SS_LANDXML", "Import\nLandXML", IC_PIPE)),
],
},
// ── Analysis: run the engine and review results ─────────────────
RibbonGroup {
title: "Analysis",
tools: vec![
RibbonItem::LargeTool(tool("SS_ANALYZE", "Analyze", IC_ANALYZE)),
RibbonItem::LargeTool(tool("SS_SIZE", "Size\nPipes", IC_SIZE)),
RibbonItem::Tool(tool("SS_PARAMS", "Params", IC_ANALYZE)),
RibbonItem::Tool(tool("SS_APPLYTC", "Apply Tc", IC_ANALYZE)),
RibbonItem::Tool(tool("SS_MULTIRP", "Multi-RP", IC_REPORT)),
RibbonItem::Tool(tool("SS_REPORT", "Report", IC_REPORT)),
RibbonItem::Tool(tool("SS_PROFILE", "Profile", IC_PROFILE)),
],
},
]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plugin::all_ribbon_modules;
#[test]
fn module_is_registered_in_ribbon() {
let titles: Vec<&str> = all_ribbon_modules().iter().map(|m| m.title()).collect();
assert!(titles.contains(&"Storm Sewer"), "ribbon tabs: {titles:?}");
}
#[test]
fn ribbon_exposes_core_tools() {
let mut ids = Vec::new();
for g in StormSewerModule.ribbon_groups() {
for item in g.tools {
if let RibbonItem::Tool(t) | RibbonItem::LargeTool(t) = item {
ids.push(t.id);
}
}
}
for needed in [
"SS_INLET", "SS_PIPE", "SS_ANALYZE", "SS_SIZE", "SS_PARAMS", "SS_MULTIRP", "SS_REPORT", "SS_PROFILE",
] {
assert!(ids.contains(&needed), "missing {needed}; have {ids:?}");
}
}
#[test]
fn engine_bridge_runs_inside_ocs() {
// The stormsewer crate, called from within the OCS binary, produces a
// full analysis report for the properly-sized sample network.
let report = super::analysis::demo_report();
assert!(report.contains("STORM SEWER ANALYSIS"), "no header:\n{report}");
assert!(report.contains("P1") && report.contains("HGL"), "no pipe/HGL:\n{report}");
assert!(report.contains("no surface flooding"), "expected ok design:\n{report}");
}
#[test]
fn analyze_plan_emits_entities_and_report() {
let (ents, report) = super::analysis::analyze_plan().expect("analysis runs");
// 3 pipes + 4 structures + labels → comfortably more than 7 entities.
assert!(ents.len() >= 7, "too few entities: {}", ents.len());
assert!(report.contains("STORM SEWER ANALYSIS"));
}
#[test]
fn analyze_profile_emits_entities() {
let ents = super::analysis::analyze_profile().expect("profile runs");
// Ground + invert + HGL line segments along a 4-node stem.
assert!(ents.len() >= 6, "too few profile entities: {}", ents.len());
}
const SAMPLE_SSN: &str = "\
IDF 60 10 0.8
TAILWATER 100.5
NODE N1 inlet 0 0 104 110 1 0.7 12
NODE N2 inlet 300 0 102.5 108.5 1 0.7
NODE OUT outfall 600 0 100 106
PIPE P1 N1 N2 300 1.25 0.013
PIPE P2 N2 OUT 300 1.5 0.013
";
#[test]
fn analyze_text_parses_and_emits() {
let (ents, report) = super::analysis::analyze_text(SAMPLE_SSN).expect("parse + analyze");
assert!(ents.len() >= 5, "too few entities: {}", ents.len());
assert!(report.contains("STORM SEWER ANALYSIS"));
}
#[test]
fn profile_text_parses_and_emits() {
let ents = super::analysis::profile_text(SAMPLE_SSN).expect("parse + profile");
assert!(ents.len() >= 4, "too few profile entities: {}", ents.len());
}
#[test]
fn bad_ssn_reports_error() {
let err = super::analysis::analyze_text("NODE X inlet 0 0 oops 1 1 1").unwrap_err();
assert!(err.contains("line 1"), "{err}");
}
#[test]
fn analyze_doc_runs_on_drawn_network() {
use acadrust::types::Vector3;
use acadrust::{Circle, EntityType, Handle, Line};
use stormsewer::network::NodeKind;
let mk_struct = |h: u64, kind, x: f64, inv: f64| {
let mut e = EntityType::Circle(Circle {
center: Vector3::new(x, 0.0, 0.0),
radius: 3.0,
..Default::default()
});
e.common_mut().handle = Handle::new(h);
e.common_mut()
.extended_data
.add_record(super::data::structure_xdata(kind, inv, inv + 6.0, 1.0, 0.7));
e
};
let mut pipe =
EntityType::Line(Line::from_points(Vector3::new(0.0, 0.0, 0.0), Vector3::new(200.0, 0.0, 0.0)));
pipe.common_mut()
.extended_data
.add_record(super::data::pipe_xdata(1.5, 0.013, Handle::new(1), Handle::new(2)));
let ents = vec![
mk_struct(1, NodeKind::Inlet, 0.0, 104.0),
mk_struct(2, NodeKind::Outfall, 200.0, 100.0),
pipe,
];
let p = super::analysis::default_params();
let (annotations, report, _) = super::analysis::analyze_doc(ents.iter(), &p).expect("analyze drawn net");
assert!(!annotations.is_empty(), "expected flow/HGL labels");
assert!(report.contains("STORM SEWER ANALYSIS"), "report:\n{report}");
}
}