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.
221 lines
7.9 KiB
Rust
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}");
|
|
}
|
|
}
|