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:
Michael Flynn 2026-06-09 07:29:14 -04:00
commit 1e68219b43
7 changed files with 609 additions and 104 deletions

View file

@ -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),

View file

@ -2812,7 +2812,7 @@ impl OpenCADStudio {
.map(|c| {
matches!(
c.name(),
"DIMTEDIT" | "MLEADERADD" | "MLEADERREMOVE"
"DIMTEDIT" | "MLEADERADD" | "MLEADERREMOVE" | "SS_PIPE"
)
})
.unwrap_or(false);