diff --git a/src/app/commands.rs b/src/app/commands.rs index 9c22d75df..4af04a94b 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -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), diff --git a/src/app/update.rs b/src/app/update.rs index ee8f88bda..aa7cad1d5 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -2812,7 +2812,7 @@ impl OpenCADStudio { .map(|c| { matches!( c.name(), - "DIMTEDIT" | "MLEADERADD" | "MLEADERREMOVE" + "DIMTEDIT" | "MLEADERADD" | "MLEADERREMOVE" | "SS_PIPE" ) }) .unwrap_or(false); diff --git a/src/modules/storm_sewer/INTEGRATION.md b/src/modules/storm_sewer/INTEGRATION.md index dced1839d..5d7af6949 100644 --- a/src/modules/storm_sewer/INTEGRATION.md +++ b/src/modules/storm_sewer/INTEGRATION.md @@ -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 diff --git a/src/modules/storm_sewer/analysis.rs b/src/modules/storm_sewer/analysis.rs index 01d9a29fb..317573ee4 100644 --- a/src/modules/storm_sewer/analysis.rs +++ b/src/modules/storm_sewer/analysis.rs @@ -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 { + 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) -> Result<(Vec, 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) -> Result, 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) -> Result { + 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 { diff --git a/src/modules/storm_sewer/data.rs b/src/modules/storm_sewer/data.rs new file mode 100644 index 000000000..d0cbb5217 --- /dev/null +++ b/src/modules/storm_sewer/data.rs @@ -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 { + if let XDataValue::Real(x) = v { + Some(*x) + } else { + None + } +} + +fn handle(v: &XDataValue) -> Option { + 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 { + 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 { + 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) -> Result { + let mut structs: Vec = Vec::new(); + let mut pipes_raw: Vec = 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 = 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 = 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()); + } +} diff --git a/src/modules/storm_sewer/mod.rs b/src/modules/storm_sewer/mod.rs index d575892fe..e19699d9d 100644 --- a/src/modules/storm_sewer/mod.rs +++ b/src/modules/storm_sewer/mod.rs @@ -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}"); + } } diff --git a/src/modules/storm_sewer/structures.rs b/src/modules/storm_sewer/structures.rs index 4bb0f641c..9c13b6a28 100644 --- a/src/modules/storm_sewer/structures.rs +++ b/src/modules/storm_sewer/structures.rs @@ -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 { + text.trim().replace(',', ".").parse::().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 { + 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, + step: PStep, + start_handle: Option, + start_xy: (f64, f64), + end_handle: Option, + end_xy: (f64, f64), + pending: Option, + 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 { + 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)"), } } }