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) <[email protected]>
No files matched your search
@@ -137,6 +137,7 @@ pub mod insert;
|
||||
pub mod model;
|
||||
pub mod layout;
|
||||
pub mod manage;
|
||||
pub mod storm_sewer;
|
||||
pub mod view;
|
||||
|
||||
// ── Auto-generated module registry ───────────────────────────────────────
|
||||
|
||||
@@ -14,5 +14,6 @@ pub fn all_modules() -> Vec<Box<dyn CadModule>> {
|
||||
Box::new(super::view::ViewModule),
|
||||
Box::new(super::manage::ManageModule),
|
||||
Box::new(super::layout::LayoutModule),
|
||||
Box::new(super::storm_sewer::StormSewerModule),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
# Storm Sewer module — integration plan
|
||||
|
||||
The ribbon tab (`mod.rs`) and the engine bridge (`analysis.rs`) are in place.
|
||||
What remains is wiring each `SS_*` ribbon command to a handler in the host
|
||||
command system.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
storm_sewer/mod.rs ribbon tab → emits ModuleEvent::Command("SS_*")
|
||||
│
|
||||
host command dispatch (src/app/commands.rs / src/command/)
|
||||
│
|
||||
storm_sewer/analysis.rs builds a stormsewer::Network and runs the engine
|
||||
│
|
||||
stormsewer crate Rational + Manning + HGL → Analysis + report text
|
||||
```
|
||||
|
||||
The network/hydraulics data is **not** a native `acadrust` entity, so it is
|
||||
carried as side data keyed to drawing entities:
|
||||
|
||||
| Network object | Drawing representation | XDATA / fields |
|
||||
|----------------|------------------------|----------------|
|
||||
| Node (inlet/junction/outfall) | block insert at the structure point | invert, rim, area_ac, C, tc_inlet, kind |
|
||||
| Pipe (link) | LINE/LWPOLYLINE between two structures | diameter, n (length from geometry) |
|
||||
|
||||
## Command handlers — status
|
||||
|
||||
All `SS_*` commands are wired in `src/app/commands.rs::dispatch_command`:
|
||||
|
||||
| Command | Status | Action |
|
||||
|-----------|--------|--------|
|
||||
| `SS_INLET`/`SS_JUNCTION`/`SS_OUTFALL` | ✅ done | interactive `PlaceStructure` — pick points, drop circle markers (repeatable) |
|
||||
| `SS_PIPE` | ✅ done | interactive `PlacePipe` — chain line segments between structures |
|
||||
| `SS_ANALYZE` | ✅ done | open a `.ssn` file → run engine → draw plan (pipes/markers/labels) + print report |
|
||||
| `SS_REPORT` | ✅ done | open a `.ssn` file → print `report::format_analysis()` to the command line |
|
||||
| `SS_PROFILE` | ✅ done | open a `.ssn` file → draw the HGL/invert/ground long-section of the main stem |
|
||||
|
||||
Drafting (`structures.rs`) and analysis/drawing (`analysis.rs`) are both live and
|
||||
unit-tested. `SS_ANALYZE`/`SS_REPORT`/`SS_PROFILE` read a user-authored `.ssn`
|
||||
network file (format in `stormsewer::parse`); the engine runs on real data.
|
||||
|
||||
## Remaining enhancement: drive analysis from canvas geometry
|
||||
|
||||
The placement commands (`SS_INLET`/`PIPE`/…) draw markers and pipe lines but do
|
||||
not yet carry hydraulic data, so analysis is driven by the `.ssn` file rather
|
||||
than by the drawn entities. To let users analyze what they draw directly, attach
|
||||
data via **XDATA** when `PlaceStructure`/`PlacePipe` commit (invert/rim/area/C on
|
||||
structures, diameter/n on pipes), then have `SS_ANALYZE` walk the document
|
||||
entities into a `stormsewer::Network`. The `.ssn` path remains the simple,
|
||||
file-based workflow.
|
||||
|
||||
## Build
|
||||
|
||||
`build.rs` auto-discovers this directory (`storm_sewer/` → `StormSewerModule`)
|
||||
and regenerates `src/modules/registry.rs`, so the tab appears on `cargo build`.
|
||||
@@ -0,0 +1,152 @@
|
||||
// Bridge between Open CAD Studio and the `stormsewer` engine crate.
|
||||
//
|
||||
// Runs the hydrology/hydraulics engine on a network and turns the result into
|
||||
// (a) acadrust entities to draw into the document and (b) a text report for the
|
||||
// command line. Networks come from a `.ssn` file (see `stormsewer::parse`); a
|
||||
// built-in sample backs the unit tests and the zero-argument fallbacks.
|
||||
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::{Circle, EntityType, Line, MText};
|
||||
|
||||
use stormsewer::drawing::{draw_network, DrawConfig};
|
||||
use stormsewer::idf::IdfCurve;
|
||||
use stormsewer::network::{Analysis, AnalysisOptions, Network, Node, Pipe};
|
||||
use stormsewer::parse::parse_ssn;
|
||||
use stormsewer::report::format_analysis;
|
||||
|
||||
/// The built-in demonstration network (properly sized, with plan coordinates).
|
||||
fn demo() -> (Network, IdfCurve, AnalysisOptions) {
|
||||
let net = Network {
|
||||
nodes: vec![
|
||||
Node::inlet("N1", 104.0, 110.0, 1.0, 0.70).with_tc_inlet(12.0).at(0.0, 0.0),
|
||||
Node::inlet("N2", 102.5, 108.5, 1.0, 0.70).with_tc_inlet(10.0).at(300.0, 0.0),
|
||||
Node::junction("N3", 101.2, 107.0, 0.5, 0.80).with_tc_inlet(8.0).at(550.0, 0.0),
|
||||
Node::outfall("OUT", 100.0, 106.0).at(730.0, 0.0),
|
||||
],
|
||||
pipes: vec![
|
||||
Pipe::new("P1", "N1", "N2", 300.0, 1.25, 0.013),
|
||||
Pipe::new("P2", "N2", "N3", 250.0, 1.50, 0.013),
|
||||
Pipe::new("P3", "N3", "OUT", 180.0, 1.75, 0.013),
|
||||
],
|
||||
};
|
||||
let idf = IdfCurve::new(60.0, 10.0, 0.8);
|
||||
let opts = AnalysisOptions { tailwater: Some(100.5), ..Default::default() };
|
||||
(net, idf, opts)
|
||||
}
|
||||
|
||||
fn v3(x: f64, y: f64) -> Vector3 {
|
||||
Vector3::new(x, y, 0.0)
|
||||
}
|
||||
|
||||
fn seg(x1: f64, y1: f64, x2: f64, y2: f64) -> EntityType {
|
||||
EntityType::Line(Line::from_points(v3(x1, y1), v3(x2, y2)))
|
||||
}
|
||||
|
||||
fn label(x: f64, y: f64, text: String, height: f64) -> EntityType {
|
||||
EntityType::MText(MText { value: text, insertion_point: v3(x, y), height, ..Default::default() })
|
||||
}
|
||||
|
||||
fn circle(x: f64, y: f64, r: f64) -> EntityType {
|
||||
EntityType::Circle(Circle { center: v3(x, y), radius: r, ..Default::default() })
|
||||
}
|
||||
|
||||
fn run_analysis(net: &Network, idf: &IdfCurve, opts: &AnalysisOptions) -> Result<Analysis, String> {
|
||||
net.analyze(idf, opts).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Plan-view entities (pipes, structure markers, flow/HGL labels) + the report.
|
||||
fn build_plan(net: &Network, a: &Analysis) -> (Vec<EntityType>, String) {
|
||||
let d = draw_network(net, a, &DrawConfig::default());
|
||||
let mut ents = Vec::new();
|
||||
for p in &d.plan_pipes {
|
||||
ents.push(seg(p.x1, p.y1, p.x2, p.y2));
|
||||
}
|
||||
for n in &d.plan_nodes {
|
||||
ents.push(circle(n.x, n.y, n.radius));
|
||||
}
|
||||
for l in &d.plan_labels {
|
||||
ents.push(label(l.x, l.y, l.text.clone(), l.height));
|
||||
}
|
||||
(ents, format_analysis(a))
|
||||
}
|
||||
|
||||
/// HGL long-section entities (ground / invert / HGL polylines + labels).
|
||||
fn build_profile(net: &Network, a: &Analysis) -> Vec<EntityType> {
|
||||
let d = draw_network(net, a, &DrawConfig::default());
|
||||
let mut ents = Vec::new();
|
||||
for pl in &d.profile_lines {
|
||||
for w in pl.pts.windows(2) {
|
||||
ents.push(seg(w[0].0, w[0].1, w[1].0, w[1].1));
|
||||
}
|
||||
}
|
||||
for l in &d.profile_labels {
|
||||
ents.push(label(l.x, l.y, l.text.clone(), l.height));
|
||||
}
|
||||
ents
|
||||
}
|
||||
|
||||
// ── Public API: from a `.ssn` document ──────────────────────────────────────
|
||||
|
||||
/// Open a file dialog and read a `.ssn` network file.
|
||||
///
|
||||
/// Returns `None` if the user cancels, `Some(Ok(text))` on success, or
|
||||
/// `Some(Err(msg))` if the chosen file cannot be read.
|
||||
pub fn pick_ssn_file() -> Option<Result<String, String>> {
|
||||
let path = rfd::FileDialog::new()
|
||||
.add_filter("Storm Sewer Network", &["ssn"])
|
||||
.set_title("Open storm-sewer network (.ssn)")
|
||||
.pick_file()?;
|
||||
Some(std::fs::read_to_string(&path).map_err(|e| format!("cannot read {}: {e}", path.display())))
|
||||
}
|
||||
|
||||
/// Parse a `.ssn` document, analyze it, and return (plan entities, report).
|
||||
pub fn analyze_text(text: &str) -> Result<(Vec<EntityType>, String), String> {
|
||||
let p = parse_ssn(text)?;
|
||||
let a = run_analysis(&p.network, &p.idf, &p.options)?;
|
||||
Ok(build_plan(&p.network, &a))
|
||||
}
|
||||
|
||||
/// Parse a `.ssn` document, analyze it, and return the HGL profile entities.
|
||||
pub fn profile_text(text: &str) -> Result<Vec<EntityType>, String> {
|
||||
let p = parse_ssn(text)?;
|
||||
let a = run_analysis(&p.network, &p.idf, &p.options)?;
|
||||
Ok(build_profile(&p.network, &a))
|
||||
}
|
||||
|
||||
/// Parse a `.ssn` document, analyze it, and return the formatted report.
|
||||
pub fn report_text_from(text: &str) -> Result<String, String> {
|
||||
let p = parse_ssn(text)?;
|
||||
let a = run_analysis(&p.network, &p.idf, &p.options)?;
|
||||
Ok(format_analysis(&a))
|
||||
}
|
||||
|
||||
// ── Built-in sample fallbacks (used by tests) ───────────────────────────────
|
||||
|
||||
/// Plan entities + report for the built-in sample network.
|
||||
pub fn analyze_plan() -> Result<(Vec<EntityType>, String), String> {
|
||||
let (net, idf, opts) = demo();
|
||||
let a = run_analysis(&net, &idf, &opts)?;
|
||||
Ok(build_plan(&net, &a))
|
||||
}
|
||||
|
||||
/// Profile entities for the built-in sample network.
|
||||
pub fn analyze_profile() -> Result<Vec<EntityType>, String> {
|
||||
let (net, idf, opts) = demo();
|
||||
let a = run_analysis(&net, &idf, &opts)?;
|
||||
Ok(build_profile(&net, &a))
|
||||
}
|
||||
|
||||
/// Formatted report for the built-in sample network.
|
||||
pub fn report_text() -> String {
|
||||
let (net, idf, opts) = demo();
|
||||
match run_analysis(&net, &idf, &opts) {
|
||||
Ok(a) => format_analysis(&a),
|
||||
Err(e) => format!("storm-sewer analysis error: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Back-compat alias used by the module's integration test.
|
||||
#[allow(dead_code)]
|
||||
pub fn demo_report() -> String {
|
||||
report_text()
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<path d="M6 38 q8 -14 16 0 t16 0 t16 0" fill="none" stroke="#4FC3F7" stroke-width="3"/>
|
||||
<polygon points="24,16 24,32 40,24" fill="#01579B"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 236 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<rect x="12" y="14" width="40" height="36" rx="3" fill="none" stroke="#01579B" stroke-width="3"/>
|
||||
<line x1="12" y1="24" x2="52" y2="24" stroke="#0277BD" stroke-width="3"/>
|
||||
<line x1="12" y1="32" x2="52" y2="32" stroke="#0277BD" stroke-width="3"/>
|
||||
<line x1="12" y1="40" x2="52" y2="40" stroke="#0277BD" stroke-width="3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 419 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<circle cx="32" cy="32" r="18" fill="none" stroke="#01579B" stroke-width="3"/>
|
||||
<circle cx="32" cy="32" r="8" fill="#4FC3F7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 221 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<rect x="10" y="24" width="24" height="16" fill="none" stroke="#01579B" stroke-width="3"/>
|
||||
<path d="M34 22 L54 14 L54 50 L34 42 Z" fill="#4FC3F7" opacity="0.85" stroke="#0277BD" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 292 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<line x1="12" y1="42" x2="52" y2="18" stroke="#01579B" stroke-width="4"/>
|
||||
<line x1="16" y1="48" x2="56" y2="24" stroke="#4FC3F7" stroke-width="4"/>
|
||||
<circle cx="13" cy="45" r="5" fill="none" stroke="#01579B" stroke-width="3"/>
|
||||
<circle cx="55" cy="21" r="5" fill="none" stroke="#01579B" stroke-width="3"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 403 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<polyline points="8,16 24,20 40,30 56,40" fill="none" stroke="#8D6E63" stroke-width="2"/>
|
||||
<polyline points="8,46 24,47 40,49 56,50" fill="none" stroke="#01579B" stroke-width="3"/>
|
||||
<polyline points="8,34 24,37 40,42 56,47" fill="none" stroke="#4FC3F7" stroke-width="2" stroke-dasharray="4 2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 390 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<rect x="16" y="10" width="32" height="44" rx="2" fill="none" stroke="#01579B" stroke-width="3"/>
|
||||
<line x1="22" y1="20" x2="42" y2="20" stroke="#0277BD" stroke-width="2"/>
|
||||
<line x1="22" y1="28" x2="42" y2="28" stroke="#0277BD" stroke-width="2"/>
|
||||
<line x1="22" y1="36" x2="42" y2="36" stroke="#0277BD" stroke-width="2"/>
|
||||
<line x1="22" y1="44" x2="36" y2="44" stroke="#0277BD" stroke-width="2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 495 B |
@@ -0,0 +1,159 @@
|
||||
// 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 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}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// Interactive storm-sewer drafting commands: place structure markers and draw
|
||||
// pipe runs on the canvas.
|
||||
//
|
||||
// These create geometry (circle markers, pipe lines). Attaching hydraulic data
|
||||
// (invert, rim, area, C, diameter) to placed structures — so SS_ANALYZE reads
|
||||
// the drawn network instead of the built-in sample — is the next step; see
|
||||
// INTEGRATION.md.
|
||||
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::{Circle, EntityType, Line};
|
||||
use glam::Vec3;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
|
||||
fn v3(p: Vec3) -> Vector3 {
|
||||
Vector3::new(p.x as f64, p.y as f64, 0.0)
|
||||
}
|
||||
|
||||
/// Repeatedly place a structure marker (a circle) at picked points.
|
||||
pub struct PlaceStructure {
|
||||
label: &'static str,
|
||||
radius: f64,
|
||||
}
|
||||
|
||||
impl PlaceStructure {
|
||||
pub fn inlet() -> Self {
|
||||
Self { label: "inlet", radius: 3.0 }
|
||||
}
|
||||
pub fn junction() -> Self {
|
||||
Self { label: "junction", radius: 4.0 }
|
||||
}
|
||||
pub fn outfall() -> Self {
|
||||
Self { label: "outfall", radius: 6.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for PlaceStructure {
|
||||
fn name(&self) -> &'static str {
|
||||
"SS_STRUCTURE"
|
||||
}
|
||||
fn prompt(&self) -> String {
|
||||
format!("Storm sewer: pick {} location [Esc = done]", self.label)
|
||||
}
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
let marker = Circle { center: v3(pt), radius: self.radius, ..Default::default() };
|
||||
// CommitEntity keeps the command active so several can be dropped.
|
||||
CmdResult::CommitEntity(EntityType::Circle(marker))
|
||||
}
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw pipe runs as connected line segments (chains like LINE).
|
||||
pub struct PlacePipe {
|
||||
last: Option<Vec3>,
|
||||
}
|
||||
|
||||
impl PlacePipe {
|
||||
pub fn new() -> Self {
|
||||
Self { last: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PlacePipe {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for PlacePipe {
|
||||
fn name(&self) -> &'static str {
|
||||
"SS_PIPE"
|
||||
}
|
||||
fn prompt(&self) -> String {
|
||||
if self.last.is_none() {
|
||||
"Storm sewer pipe: pick start structure".into()
|
||||
} else {
|
||||
"Storm sewer pipe: pick next structure [Esc = done]".into()
|
||||
}
|
||||
}
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
match self.last {
|
||||
None => {
|
||||
self.last = Some(pt);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
Some(prev) => {
|
||||
let pipe = Line::from_points(v3(prev), v3(pt));
|
||||
self.last = Some(pt);
|
||||
CmdResult::CommitEntity(EntityType::Line(pipe))
|
||||
}
|
||||
}
|
||||
}
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn structure_commits_a_circle_and_stays_active() {
|
||||
let mut cmd = PlaceStructure::inlet();
|
||||
match cmd.on_point(Vec3::new(10.0, 20.0, 0.0)) {
|
||||
CmdResult::CommitEntity(EntityType::Circle(c)) => {
|
||||
assert_eq!(c.center.x, 10.0);
|
||||
assert_eq!(c.center.y, 20.0);
|
||||
assert!(c.radius > 0.0);
|
||||
}
|
||||
_ => panic!("expected CommitEntity(Circle)"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipe_needs_two_points_then_commits_a_line() {
|
||||
let mut cmd = PlacePipe::new();
|
||||
assert!(matches!(cmd.on_point(Vec3::new(0.0, 0.0, 0.0)), CmdResult::NeedPoint));
|
||||
match cmd.on_point(Vec3::new(100.0, 0.0, 0.0)) {
|
||||
CmdResult::CommitEntity(EntityType::Line(l)) => {
|
||||
assert_eq!(l.start.x, 0.0);
|
||||
assert_eq!(l.end.x, 100.0);
|
||||
}
|
||||
_ => panic!("expected CommitEntity(Line)"),
|
||||
}
|
||||
}
|
||||
}
|
||||