forked from 1b-agent/opencadstudio-upstream
Storm Sewer: interactive in-app network — snapping, tracked objects, analyze the drawing
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>
This commit is contained in:
1 parent
9b44eb6400
commit
1e68219b43
7 files changed
+608
-103
No files matched your search
+28
-33
@@ -32,34 +32,31 @@ impl OpenCADStudio {
|
||||
// ── 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);
|
||||
}
|
||||
let result = ss::analyze_doc(self.tabs[i].scene.document.entities());
|
||||
match result {
|
||||
Ok((ents, report)) => {
|
||||
for e in ents {
|
||||
let _ = self.tabs[i].scene.add_entity(e);
|
||||
}
|
||||
Err(e) => self.command_line.push_error(&e),
|
||||
},
|
||||
None => self.command_line.push_info("Storm sewer: open cancelled."),
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.command_line
|
||||
.push_info("Storm sewer: analyzed drawn network (default IDF 60/(t+10)^0.8).");
|
||||
for line in report.lines() {
|
||||
self.command_line.push_output(line);
|
||||
}
|
||||
}
|
||||
Err(e) => self.command_line.push_error(&e),
|
||||
}
|
||||
}
|
||||
"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);
|
||||
}
|
||||
match ss::report_doc(self.tabs[i].scene.document.entities()) {
|
||||
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."),
|
||||
}
|
||||
Err(e) => self.command_line.push_error(&e),
|
||||
}
|
||||
}
|
||||
"SS_INLET" | "SS_JUNCTION" | "SS_OUTFALL" => {
|
||||
@@ -79,18 +76,16 @@ impl OpenCADStudio {
|
||||
}
|
||||
"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.");
|
||||
let result = ss::profile_doc(self.tabs[i].scene.document.entities());
|
||||
match result {
|
||||
Ok(ents) => {
|
||||
for e in ents {
|
||||
let _ = self.tabs[i].scene.add_entity(e);
|
||||
}
|
||||
Err(e) => self.command_line.push_error(&e),
|
||||
},
|
||||
None => self.command_line.push_info("Storm sewer: open cancelled."),
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.command_line.push_info("Storm sewer HGL profile drawn.");
|
||||
}
|
||||
Err(e) => self.command_line.push_error(&e),
|
||||
}
|
||||
}
|
||||
"NEW" => return Task::done(Message::TabNew),
|
||||
|
||||
+1
-1
@@ -2812,7 +2812,7 @@ impl OpenCADStudio {
|
||||
.map(|c| {
|
||||
matches!(
|
||||
c.name(),
|
||||
"DIMTEDIT" | "MLEADERADD" | "MLEADERREMOVE"
|
||||
"DIMTEDIT" | "MLEADERADD" | "MLEADERREMOVE" | "SS_PIPE"
|
||||
)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
@@ -16,13 +16,17 @@ 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:
|
||||
The network/hydraulics data is carried as **XDATA on the drawing entities**
|
||||
(`data.rs`), so the network round-trips to DWG/DXF and is analyzable directly:
|
||||
|
||||
| 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) |
|
||||
| Network object | Entity | XDATA record `STORMSEWER_*` |
|
||||
|----------------|--------|------------------------------|
|
||||
| Structure (inlet/junction/outfall) | CIRCLE at the point | `STRUCT`: [kind, invert, rim, area, C] |
|
||||
| Pipe (link) | LINE between two structures | `PIPE`: [diameter, n, from-handle, to-handle] |
|
||||
|
||||
Connectivity is by entity handle: a pipe stores the handles of the two
|
||||
structures it connects, so `data::network_from_entities` rebuilds the
|
||||
`stormsewer::Network` (nodes N1, N2, … by encounter order) from the drawing.
|
||||
|
||||
## Command handlers — status
|
||||
|
||||
@@ -30,25 +34,27 @@ 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 |
|
||||
| `SS_INLET`/`SS_JUNCTION`/`SS_OUTFALL` | ✅ done | `PlaceStructure` — pick a point, then prompt invert/rim/area/C; commits an XDATA-tagged circle |
|
||||
| `SS_PIPE` | ✅ done | `PlacePipe` — pick START + END structures (snaps to their centers via entity-pick), prompt diameter/n; commits an XDATA-tagged line with connectivity |
|
||||
| `SS_ANALYZE` | ✅ done | rebuild the network from drawn entities → run engine → add flow/HGL labels + print report |
|
||||
| `SS_REPORT` | ✅ done | rebuild from drawn entities → print `report::format_analysis()` |
|
||||
| `SS_PROFILE` | ✅ done | rebuild from drawn entities → draw the HGL/invert/ground long-section |
|
||||
|
||||
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.
|
||||
`SS_PIPE` requires its name in the `inject_picked_entity` allow-list in
|
||||
`src/app/update.rs` (so it receives the picked structure to read its center).
|
||||
|
||||
## Remaining enhancement: drive analysis from canvas geometry
|
||||
The `.ssn` file path is retained in `analysis.rs` (`analyze_text` etc.) for
|
||||
file-based workflows and tests, but the ribbon commands now operate on the
|
||||
**drawn network**.
|
||||
|
||||
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.
|
||||
## Remaining enhancements
|
||||
|
||||
- **Rainfall parameters UI** — `SS_ANALYZE` uses a default IDF (`60/(t+10)^0.8`)
|
||||
and free outfall; add an `SS_PARAMS` command to set IDF / tailwater / min-Tc.
|
||||
- **Edit command** — `SS_EDIT` to change a placed structure/pipe's values.
|
||||
- **Surcharge styling** — recolor surcharged pipes / flag flooded structures.
|
||||
- **Persistence check** — verify the StormSewer XDATA round-trips through
|
||||
DWG/DXF save+reload (acadrust supports XDATA; confirm end-to-end).
|
||||
|
||||
## Build
|
||||
|
||||
|
||||
@@ -14,6 +14,51 @@ use stormsewer::network::{Analysis, AnalysisOptions, Network, Node, Pipe};
|
||||
use stormsewer::parse::parse_ssn;
|
||||
use stormsewer::report::format_analysis;
|
||||
|
||||
use super::data;
|
||||
|
||||
/// Default network-level analysis parameters used when analyzing a drawn
|
||||
/// network (per-entity data carries geometry/area; rainfall is global).
|
||||
fn default_params() -> (IdfCurve, AnalysisOptions) {
|
||||
(IdfCurve::new(60.0, 10.0, 0.8), AnalysisOptions::default())
|
||||
}
|
||||
|
||||
/// Annotation labels only (flow + HGL), for overlaying on an already-drawn
|
||||
/// network without re-drawing its geometry.
|
||||
fn build_annotations(net: &Network, a: &Analysis) -> Vec<EntityType> {
|
||||
draw_network(net, a, &DrawConfig::default())
|
||||
.plan_labels
|
||||
.iter()
|
||||
.map(|l| label(l.x, l.y, l.text.clone(), l.height))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── Public API: analyze the network drawn in the active document ────────────
|
||||
|
||||
/// Reconstruct the network from drawn entities, analyze it, and return
|
||||
/// annotation entities (flow/HGL labels) + the report.
|
||||
pub fn analyze_doc<'a>(entities: impl Iterator<Item = &'a EntityType>) -> Result<(Vec<EntityType>, String), String> {
|
||||
let net = data::network_from_entities(entities)?;
|
||||
let (idf, opts) = default_params();
|
||||
let a = run_analysis(&net, &idf, &opts)?;
|
||||
Ok((build_annotations(&net, &a), format_analysis(&a)))
|
||||
}
|
||||
|
||||
/// Reconstruct from drawn entities and return the HGL long-section entities.
|
||||
pub fn profile_doc<'a>(entities: impl Iterator<Item = &'a EntityType>) -> Result<Vec<EntityType>, String> {
|
||||
let net = data::network_from_entities(entities)?;
|
||||
let (idf, opts) = default_params();
|
||||
let a = run_analysis(&net, &idf, &opts)?;
|
||||
Ok(build_profile(&net, &a))
|
||||
}
|
||||
|
||||
/// Reconstruct from drawn entities and return the formatted report.
|
||||
pub fn report_doc<'a>(entities: impl Iterator<Item = &'a EntityType>) -> Result<String, String> {
|
||||
let net = data::network_from_entities(entities)?;
|
||||
let (idf, opts) = default_params();
|
||||
let a = run_analysis(&net, &idf, &opts)?;
|
||||
Ok(format_analysis(&a))
|
||||
}
|
||||
|
||||
/// The built-in demonstration network (properly sized, with plan coordinates).
|
||||
fn demo() -> (Network, IdfCurve, AnalysisOptions) {
|
||||
let net = Network {
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// Storm-sewer data carried on drawing entities via XDATA, and reconstruction
|
||||
// of a `stormsewer::Network` from the drawn entities.
|
||||
//
|
||||
// Structures are circles tagged with the `STORMSEWER_STRUCT` app record
|
||||
// [kind, invert, rim, area, C]; pipes are lines tagged with `STORMSEWER_PIPE`
|
||||
// [diameter, n, from-handle, to-handle]. Connectivity is by entity handle, so
|
||||
// the drawn network round-trips to DWG/DXF and is analyzable directly.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use acadrust::xdata::{ExtendedDataRecord, XDataValue};
|
||||
use acadrust::{EntityType, Handle};
|
||||
|
||||
use stormsewer::network::{Network, Node, NodeKind, Pipe};
|
||||
|
||||
pub const APP_STRUCT: &str = "STORMSEWER_STRUCT";
|
||||
pub const APP_PIPE: &str = "STORMSEWER_PIPE";
|
||||
|
||||
pub fn kind_str(k: NodeKind) -> &'static str {
|
||||
match k {
|
||||
NodeKind::Inlet => "inlet",
|
||||
NodeKind::Junction => "junction",
|
||||
NodeKind::Outfall => "outfall",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_kind(s: &str) -> NodeKind {
|
||||
match s {
|
||||
"outfall" => NodeKind::Outfall,
|
||||
"junction" => NodeKind::Junction,
|
||||
_ => NodeKind::Inlet,
|
||||
}
|
||||
}
|
||||
|
||||
/// XDATA record for a structure marker.
|
||||
pub fn structure_xdata(kind: NodeKind, invert: f64, rim: f64, area: f64, c: f64) -> ExtendedDataRecord {
|
||||
let mut r = ExtendedDataRecord::new(APP_STRUCT);
|
||||
r.add_value(XDataValue::String(kind_str(kind).to_string()));
|
||||
r.add_value(XDataValue::Real(invert));
|
||||
r.add_value(XDataValue::Real(rim));
|
||||
r.add_value(XDataValue::Real(area));
|
||||
r.add_value(XDataValue::Real(c));
|
||||
r
|
||||
}
|
||||
|
||||
/// XDATA record for a pipe, linking the two structures it connects by handle.
|
||||
pub fn pipe_xdata(diameter: f64, n: f64, from: Handle, to: Handle) -> ExtendedDataRecord {
|
||||
let mut r = ExtendedDataRecord::new(APP_PIPE);
|
||||
r.add_value(XDataValue::Real(diameter));
|
||||
r.add_value(XDataValue::Real(n));
|
||||
r.add_value(XDataValue::Handle(from));
|
||||
r.add_value(XDataValue::Handle(to));
|
||||
r
|
||||
}
|
||||
|
||||
fn real(v: &XDataValue) -> Option<f64> {
|
||||
if let XDataValue::Real(x) = v {
|
||||
Some(*x)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(v: &XDataValue) -> Option<Handle> {
|
||||
if let XDataValue::Handle(h) = v {
|
||||
Some(*h)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
struct StructRec {
|
||||
handle: Handle,
|
||||
kind: NodeKind,
|
||||
invert: f64,
|
||||
rim: f64,
|
||||
area: f64,
|
||||
c: f64,
|
||||
x: f64,
|
||||
y: f64,
|
||||
}
|
||||
|
||||
struct PipeRec {
|
||||
diameter: f64,
|
||||
n: f64,
|
||||
from: Handle,
|
||||
to: Handle,
|
||||
length: f64,
|
||||
}
|
||||
|
||||
fn read_structure(e: &EntityType) -> Option<StructRec> {
|
||||
let rec = e.common().extended_data.get_record(APP_STRUCT)?;
|
||||
if rec.values.len() < 5 {
|
||||
return None;
|
||||
}
|
||||
let kind = match &rec.values[0] {
|
||||
XDataValue::String(s) => parse_kind(s),
|
||||
_ => return None,
|
||||
};
|
||||
let (x, y) = match e {
|
||||
EntityType::Circle(c) => (c.center.x, c.center.y),
|
||||
_ => return None,
|
||||
};
|
||||
Some(StructRec {
|
||||
handle: e.common().handle,
|
||||
kind,
|
||||
invert: real(&rec.values[1])?,
|
||||
rim: real(&rec.values[2])?,
|
||||
area: real(&rec.values[3])?,
|
||||
c: real(&rec.values[4])?,
|
||||
x,
|
||||
y,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_pipe(e: &EntityType) -> Option<PipeRec> {
|
||||
let rec = e.common().extended_data.get_record(APP_PIPE)?;
|
||||
if rec.values.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
let length = match e {
|
||||
EntityType::Line(l) => {
|
||||
let dx = l.end.x - l.start.x;
|
||||
let dy = l.end.y - l.start.y;
|
||||
(dx * dx + dy * dy).sqrt()
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(PipeRec {
|
||||
diameter: real(&rec.values[0])?,
|
||||
n: real(&rec.values[1])?,
|
||||
from: handle(&rec.values[2])?,
|
||||
to: handle(&rec.values[3])?,
|
||||
length,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a `stormsewer::Network` from drawn entities. Structures become nodes
|
||||
/// (named N1, N2, … in encounter order); pipes become links, mapped to nodes
|
||||
/// by the handles stored in their XDATA.
|
||||
pub fn network_from_entities<'a>(entities: impl Iterator<Item = &'a EntityType>) -> Result<Network, String> {
|
||||
let mut structs: Vec<StructRec> = Vec::new();
|
||||
let mut pipes_raw: Vec<PipeRec> = Vec::new();
|
||||
for e in entities {
|
||||
if let Some(s) = read_structure(e) {
|
||||
structs.push(s);
|
||||
} else if let Some(p) = read_pipe(e) {
|
||||
pipes_raw.push(p);
|
||||
}
|
||||
}
|
||||
if structs.is_empty() {
|
||||
return Err("No storm-sewer structures in the drawing — place Inlet/Junction/Outfall first.".into());
|
||||
}
|
||||
|
||||
let mut id_of: HashMap<u64, String> = HashMap::new();
|
||||
let mut nodes = Vec::with_capacity(structs.len());
|
||||
for (idx, s) in structs.iter().enumerate() {
|
||||
let id = format!("N{}", idx + 1);
|
||||
id_of.insert(s.handle.value(), id.clone());
|
||||
let node = match s.kind {
|
||||
NodeKind::Inlet => Node::inlet(&id, s.invert, s.rim, s.area, s.c),
|
||||
NodeKind::Junction => Node::junction(&id, s.invert, s.rim, s.area, s.c),
|
||||
NodeKind::Outfall => Node::outfall(&id, s.invert, s.rim),
|
||||
}
|
||||
.at(s.x, s.y);
|
||||
nodes.push(node);
|
||||
}
|
||||
|
||||
let mut pipes = Vec::new();
|
||||
let mut dropped = 0;
|
||||
for (k, p) in pipes_raw.iter().enumerate() {
|
||||
match (id_of.get(&p.from.value()), id_of.get(&p.to.value())) {
|
||||
(Some(f), Some(t)) => {
|
||||
pipes.push(Pipe::new(&format!("P{}", k + 1), f, t, p.length, p.diameter, p.n));
|
||||
}
|
||||
_ => dropped += 1,
|
||||
}
|
||||
}
|
||||
if pipes.is_empty() {
|
||||
return Err(format!(
|
||||
"No connected storm-sewer pipes ({} structure(s) found, {} dangling pipe(s)). Use Pipe Run to connect structures.",
|
||||
structs.len(), dropped
|
||||
));
|
||||
}
|
||||
Ok(Network { nodes, pipes })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::{Circle, Line};
|
||||
|
||||
fn structure(h: u64, kind: NodeKind, x: f64, invert: f64) -> EntityType {
|
||||
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(structure_xdata(kind, invert, invert + 6.0, 1.0, 0.7));
|
||||
e
|
||||
}
|
||||
fn pipe(from: u64, to: u64, x1: f64, x2: f64) -> EntityType {
|
||||
let mut e = EntityType::Line(Line::from_points(Vector3::new(x1, 0.0, 0.0), Vector3::new(x2, 0.0, 0.0)));
|
||||
e.common_mut().extended_data.add_record(pipe_xdata(1.5, 0.013, Handle::new(from), Handle::new(to)));
|
||||
e
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconstructs_network_from_tagged_entities() {
|
||||
let ents = vec![
|
||||
structure(1, NodeKind::Inlet, 0.0, 104.0),
|
||||
structure(2, NodeKind::Outfall, 100.0, 100.0),
|
||||
pipe(1, 2, 0.0, 100.0),
|
||||
];
|
||||
let net = network_from_entities(ents.iter()).unwrap();
|
||||
assert_eq!(net.nodes.len(), 2);
|
||||
assert_eq!(net.pipes.len(), 1);
|
||||
assert_eq!(net.pipes[0].from, "N1");
|
||||
assert_eq!(net.pipes[0].to, "N2");
|
||||
assert!((net.pipes[0].length - 100.0).abs() < 1e-6);
|
||||
assert!((net.pipes[0].diameter - 1.5).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_when_no_structures() {
|
||||
let ents: Vec<EntityType> = vec![];
|
||||
assert!(network_from_entities(ents.iter()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dangling_pipe_is_reported() {
|
||||
let ents = vec![
|
||||
structure(1, NodeKind::Inlet, 0.0, 104.0),
|
||||
pipe(1, 99, 0.0, 100.0), // 99 doesn't exist
|
||||
];
|
||||
assert!(network_from_entities(ents.iter()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
// 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};
|
||||
@@ -156,4 +157,38 @@ PIPE P2 N2 OUT 300 1.5 0.013
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,66 @@
|
||||
// Interactive storm-sewer drafting commands: place structure markers and draw
|
||||
// pipe runs on the canvas.
|
||||
// Interactive storm-sewer drafting commands.
|
||||
//
|
||||
// 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.
|
||||
// PlaceStructure: pick a location, then prompt invert/rim/area/C on the command
|
||||
// line; commits a circle marker carrying StormSewer XDATA.
|
||||
// PlacePipe: pick a start structure and an end structure (snaps to their
|
||||
// centers), prompt diameter/n; commits a line carrying XDATA with connectivity.
|
||||
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::{Circle, EntityType, Line};
|
||||
use acadrust::{Circle, EntityType, Handle, Line};
|
||||
use glam::Vec3;
|
||||
|
||||
use stormsewer::network::NodeKind;
|
||||
|
||||
use super::data;
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
|
||||
fn v3(p: Vec3) -> Vector3 {
|
||||
Vector3::new(p.x as f64, p.y as f64, 0.0)
|
||||
fn parse_num(text: &str) -> Option<f64> {
|
||||
text.trim().replace(',', ".").parse::<f64>().ok()
|
||||
}
|
||||
|
||||
// ── Structure placement ─────────────────────────────────────────────────────
|
||||
|
||||
enum SStep {
|
||||
Point,
|
||||
Invert,
|
||||
Rim,
|
||||
Area,
|
||||
C,
|
||||
}
|
||||
|
||||
/// Repeatedly place a structure marker (a circle) at picked points.
|
||||
pub struct PlaceStructure {
|
||||
label: &'static str,
|
||||
kind: NodeKind,
|
||||
radius: f64,
|
||||
x: f64,
|
||||
y: f64,
|
||||
invert: f64,
|
||||
rim: f64,
|
||||
area: f64,
|
||||
c: f64,
|
||||
step: SStep,
|
||||
}
|
||||
|
||||
impl PlaceStructure {
|
||||
pub fn inlet() -> Self {
|
||||
Self { label: "inlet", radius: 3.0 }
|
||||
Self::new(NodeKind::Inlet, 3.0)
|
||||
}
|
||||
pub fn junction() -> Self {
|
||||
Self { label: "junction", radius: 4.0 }
|
||||
Self::new(NodeKind::Junction, 4.0)
|
||||
}
|
||||
pub fn outfall() -> Self {
|
||||
Self { label: "outfall", radius: 6.0 }
|
||||
Self::new(NodeKind::Outfall, 6.0)
|
||||
}
|
||||
fn new(kind: NodeKind, radius: f64) -> Self {
|
||||
Self { kind, radius, x: 0.0, y: 0.0, invert: 100.0, rim: 105.0, area: 1.0, c: 0.70, step: SStep::Point }
|
||||
}
|
||||
fn commit(&self) -> CmdResult {
|
||||
let circ = Circle { center: Vector3::new(self.x, self.y, 0.0), radius: self.radius, ..Default::default() };
|
||||
let mut ent = EntityType::Circle(circ);
|
||||
let (area, c) = if self.kind == NodeKind::Outfall { (0.0, 0.0) } else { (self.area, self.c) };
|
||||
ent.common_mut()
|
||||
.extended_data
|
||||
.add_record(data::structure_xdata(self.kind, self.invert, self.rim, area, c));
|
||||
CmdResult::CommitAndExit(ent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,26 +69,110 @@ impl CadCommand for PlaceStructure {
|
||||
"SS_STRUCTURE"
|
||||
}
|
||||
fn prompt(&self) -> String {
|
||||
format!("Storm sewer: pick {} location [Esc = done]", self.label)
|
||||
match self.step {
|
||||
SStep::Point => format!("Storm {}: pick location:", data::kind_str(self.kind)),
|
||||
SStep::Invert => format!("Invert elevation <{:.2}>:", self.invert),
|
||||
SStep::Rim => format!("Rim elevation <{:.2}>:", self.rim),
|
||||
SStep::Area => format!("Drainage area, ac <{:.2}>:", self.area),
|
||||
SStep::C => format!("Runoff coefficient C <{:.2}>:", self.c),
|
||||
}
|
||||
}
|
||||
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))
|
||||
if let SStep::Point = self.step {
|
||||
self.x = pt.x as f64;
|
||||
self.y = pt.y as f64;
|
||||
self.step = SStep::Invert;
|
||||
}
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
fn wants_text_input(&self) -> bool {
|
||||
!matches!(self.step, SStep::Point)
|
||||
}
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let v = parse_num(text);
|
||||
match self.step {
|
||||
SStep::Point => None,
|
||||
SStep::Invert => {
|
||||
if let Some(x) = v {
|
||||
self.invert = x;
|
||||
}
|
||||
self.step = SStep::Rim;
|
||||
None
|
||||
}
|
||||
SStep::Rim => {
|
||||
if let Some(x) = v {
|
||||
self.rim = x;
|
||||
}
|
||||
if self.kind == NodeKind::Outfall {
|
||||
Some(self.commit())
|
||||
} else {
|
||||
self.step = SStep::Area;
|
||||
None
|
||||
}
|
||||
}
|
||||
SStep::Area => {
|
||||
if let Some(x) = v {
|
||||
self.area = x;
|
||||
}
|
||||
self.step = SStep::C;
|
||||
None
|
||||
}
|
||||
SStep::C => {
|
||||
if let Some(x) = v {
|
||||
self.c = x;
|
||||
}
|
||||
Some(self.commit())
|
||||
}
|
||||
}
|
||||
}
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw pipe runs as connected line segments (chains like LINE).
|
||||
// ── Pipe placement ──────────────────────────────────────────────────────────
|
||||
|
||||
enum PStep {
|
||||
PickStart,
|
||||
PickEnd,
|
||||
Diameter,
|
||||
N,
|
||||
}
|
||||
|
||||
pub struct PlacePipe {
|
||||
last: Option<Vec3>,
|
||||
step: PStep,
|
||||
start_handle: Option<Handle>,
|
||||
start_xy: (f64, f64),
|
||||
end_handle: Option<Handle>,
|
||||
end_xy: (f64, f64),
|
||||
pending: Option<Handle>,
|
||||
diameter: f64,
|
||||
n: f64,
|
||||
}
|
||||
|
||||
impl PlacePipe {
|
||||
pub fn new() -> Self {
|
||||
Self { last: None }
|
||||
Self {
|
||||
step: PStep::PickStart,
|
||||
start_handle: None,
|
||||
start_xy: (0.0, 0.0),
|
||||
end_handle: None,
|
||||
end_xy: (0.0, 0.0),
|
||||
pending: None,
|
||||
diameter: 1.25,
|
||||
n: 0.013,
|
||||
}
|
||||
}
|
||||
fn commit(&self) -> CmdResult {
|
||||
let line = Line::from_points(
|
||||
Vector3::new(self.start_xy.0, self.start_xy.1, 0.0),
|
||||
Vector3::new(self.end_xy.0, self.end_xy.1, 0.0),
|
||||
);
|
||||
let mut ent = EntityType::Line(line);
|
||||
if let (Some(f), Some(t)) = (self.start_handle, self.end_handle) {
|
||||
ent.common_mut().extended_data.add_record(data::pipe_xdata(self.diameter, self.n, f, t));
|
||||
}
|
||||
CmdResult::CommitAndExit(ent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,24 +187,70 @@ impl CadCommand for PlacePipe {
|
||||
"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()
|
||||
match self.step {
|
||||
PStep::PickStart => "Pipe: pick START structure:".into(),
|
||||
PStep::PickEnd => "Pipe: pick END structure:".into(),
|
||||
PStep::Diameter => format!("Pipe diameter, ft <{:.2}>:", self.diameter),
|
||||
PStep::N => format!("Manning n <{:.3}>:", self.n),
|
||||
}
|
||||
}
|
||||
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 needs_entity_pick(&self) -> bool {
|
||||
matches!(self.step, PStep::PickStart | PStep::PickEnd)
|
||||
}
|
||||
fn on_entity_pick(&mut self, handle: Handle, _pt: Vec3) -> CmdResult {
|
||||
// Stash the handle; inject_picked_entity validates it and reads center.
|
||||
self.pending = Some(handle);
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
fn inject_picked_entity(&mut self, entity: EntityType) {
|
||||
let is_struct = entity.common().extended_data.get_record(data::APP_STRUCT).is_some();
|
||||
let center = match &entity {
|
||||
EntityType::Circle(c) => Some((c.center.x, c.center.y)),
|
||||
_ => None,
|
||||
};
|
||||
if !is_struct || center.is_none() {
|
||||
self.pending = None;
|
||||
return;
|
||||
}
|
||||
let center = center.unwrap();
|
||||
match self.step {
|
||||
PStep::PickStart => {
|
||||
self.start_handle = self.pending.take();
|
||||
self.start_xy = center;
|
||||
self.step = PStep::PickEnd;
|
||||
}
|
||||
PStep::PickEnd => {
|
||||
self.end_handle = self.pending.take();
|
||||
self.end_xy = center;
|
||||
self.step = PStep::Diameter;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn wants_text_input(&self) -> bool {
|
||||
matches!(self.step, PStep::Diameter | PStep::N)
|
||||
}
|
||||
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
|
||||
let v = parse_num(text);
|
||||
match self.step {
|
||||
PStep::Diameter => {
|
||||
if let Some(x) = v {
|
||||
self.diameter = x;
|
||||
}
|
||||
self.step = PStep::N;
|
||||
None
|
||||
}
|
||||
PStep::N => {
|
||||
if let Some(x) = v {
|
||||
self.n = x;
|
||||
}
|
||||
Some(self.commit())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
|
||||
CmdResult::NeedPoint
|
||||
}
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
@@ -102,28 +262,56 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn structure_commits_a_circle_and_stays_active() {
|
||||
fn structure_prompts_then_commits_tagged_circle() {
|
||||
let mut cmd = PlaceStructure::inlet();
|
||||
match cmd.on_point(Vec3::new(10.0, 20.0, 0.0)) {
|
||||
CmdResult::CommitEntity(EntityType::Circle(c)) => {
|
||||
// pick a point
|
||||
assert!(matches!(cmd.on_point(Vec3::new(10.0, 20.0, 0.0)), CmdResult::NeedPoint));
|
||||
assert!(cmd.wants_text_input());
|
||||
// invert, rim, area, then C completes
|
||||
assert!(cmd.on_text_input("104").is_none()); // invert -> rim
|
||||
assert!(cmd.on_text_input("110").is_none()); // rim -> area
|
||||
assert!(cmd.on_text_input("2.0").is_none()); // area -> C
|
||||
match cmd.on_text_input("0.8") {
|
||||
Some(CmdResult::CommitAndExit(EntityType::Circle(c))) => {
|
||||
assert_eq!(c.center.x, 10.0);
|
||||
assert_eq!(c.center.y, 20.0);
|
||||
assert!(c.radius > 0.0);
|
||||
let rec = EntityType::Circle(c.clone());
|
||||
assert!(rec.common().extended_data.get_record(data::APP_STRUCT).is_some());
|
||||
}
|
||||
_ => panic!("expected CommitEntity(Circle)"),
|
||||
_ => panic!("expected CommitAndExit(Circle) with XDATA"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipe_needs_two_points_then_commits_a_line() {
|
||||
fn outfall_skips_area_and_c() {
|
||||
let mut cmd = PlaceStructure::outfall();
|
||||
cmd.on_point(Vec3::new(0.0, 0.0, 0.0));
|
||||
assert!(cmd.on_text_input("100").is_none()); // invert -> rim
|
||||
// rim completes immediately for an outfall
|
||||
assert!(matches!(cmd.on_text_input("105"), Some(CmdResult::CommitAndExit(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipe_connects_two_structures() {
|
||||
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!(cmd.needs_entity_pick());
|
||||
// simulate picking the start structure
|
||||
let mut s1 = EntityType::Circle(Circle { center: Vector3::new(0.0, 0.0, 0.0), radius: 3.0, ..Default::default() });
|
||||
s1.common_mut().extended_data.add_record(data::structure_xdata(NodeKind::Inlet, 100.0, 105.0, 1.0, 0.7));
|
||||
cmd.on_entity_pick(Handle::default(), Vec3::ZERO);
|
||||
cmd.inject_picked_entity(s1);
|
||||
// now picking the end structure
|
||||
let mut s2 = EntityType::Circle(Circle { center: Vector3::new(100.0, 0.0, 0.0), radius: 3.0, ..Default::default() });
|
||||
s2.common_mut().extended_data.add_record(data::structure_xdata(NodeKind::Outfall, 99.0, 104.0, 0.0, 0.0));
|
||||
cmd.on_entity_pick(Handle::default(), Vec3::ZERO);
|
||||
cmd.inject_picked_entity(s2);
|
||||
assert!(cmd.wants_text_input(), "should be prompting diameter");
|
||||
assert!(cmd.on_text_input("1.5").is_none()); // diameter -> n
|
||||
match cmd.on_text_input("0.013") {
|
||||
Some(CmdResult::CommitAndExit(EntityType::Line(l))) => {
|
||||
assert_eq!(l.start.x, 0.0);
|
||||
assert_eq!(l.end.x, 100.0);
|
||||
}
|
||||
_ => panic!("expected CommitEntity(Line)"),
|
||||
_ => panic!("expected CommitAndExit(Line)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user