Add plugin host architecture and Storm Sewer add-on package

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.
This commit is contained in:
Michael Flynn committed 2026-06-09 13:41:21 -04:00
1 parent 1199895f0e
commit e46701456f
66 files changed
+4538 -141

No files matched your search

+9
View File
@@ -251,6 +251,11 @@ impl OpenCADStudio {
}
CmdResult::ReplaceMany(replacements, additions) => {
let label = self.history_label_from_active_cmd(i, "FILLET");
let was_catchment = self
.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|c| c.name() == "SS_CATCHMENT");
self.push_undo_snapshot(i, label);
for (handle, entities) in replacements {
self.tabs[i].scene.erase_entities(&[handle]);
@@ -265,6 +270,10 @@ impl OpenCADStudio {
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
if was_catchment {
self.command_line
.push_info("Catchment tagged successfully.");
}
self.refresh_properties();
}
CmdResult::ReplaceEntity(handle, new_entities) => {
+4 -59
View File
@@ -28,66 +28,11 @@ impl OpenCADStudio {
return Task::done(Message::OpenPathPicked(Some((path, size))));
}
if crate::plugin::try_dispatch(self, i, cmd) {
return Task::none();
}
match cmd {
// ── Storm Sewer module ──────────────────────────────────────────
"SS_ANALYZE" => {
use crate::modules::storm_sewer::analysis as ss;
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);
}
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::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),
}
}
"SS_INLET" | "SS_JUNCTION" | "SS_OUTFALL" => {
use crate::modules::storm_sewer::structures::PlaceStructure;
let cmd = match cmd {
"SS_INLET" => PlaceStructure::inlet(),
"SS_JUNCTION" => PlaceStructure::junction(),
_ => PlaceStructure::outfall(),
};
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
"SS_PIPE" => {
let cmd = crate::modules::storm_sewer::structures::PlacePipe::new();
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
"SS_PROFILE" => {
use crate::modules::storm_sewer::analysis as ss;
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);
}
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),
"OPEN" => return Task::done(Message::OpenFile),
"SAVE" | "QSAVE" => return Task::done(Message::SaveFile),
+36
View File
@@ -9,6 +9,8 @@ use crate::ui::{LayerPanel, PropertiesPanel};
use acadrust::tables::Ucs;
use acadrust::{CadDocument, Handle};
use iced;
use std::any::{Any, TypeId};
use std::collections::HashMap;
use std::path::PathBuf;
// ── Dynamic input ──────────────────────────────────────────────────────────
@@ -96,6 +98,8 @@ pub(super) struct DocumentTab {
/// of the model-space shader. The scene is still constructed so the
/// rest of the code can treat it as a normal tab when reading.
pub(super) is_start: bool,
/// Per-plugin document state (`plugin::BuiltinPlugin` manifest id → state).
pub(super) plugin_state: HashMap<&'static str, Box<dyn Any + Send + Sync>>,
}
impl DocumentTab {
@@ -142,9 +146,41 @@ impl DocumentTab {
active_mleader_style: "Standard".to_string(),
last_synced_camera_gen: 0,
is_start: false,
plugin_state: HashMap::new(),
}
}
pub(super) fn plugin_state<T: Any + Send + Sync + 'static>(
&self,
plugin_id: &'static str,
_type_id: TypeId,
) -> Option<&T> {
self.plugin_state.get(plugin_id)?.downcast_ref::<T>()
}
pub(super) fn plugin_state_mut<T: Any + Send + Sync + 'static>(
&mut self,
plugin_id: &'static str,
_type_id: TypeId,
) -> Option<&mut T> {
self.plugin_state.get_mut(plugin_id)?.downcast_mut::<T>()
}
pub(super) fn ensure_plugin_state<T: Any + Send + Sync + 'static>(
&mut self,
plugin_id: &'static str,
init: impl FnOnce() -> T,
) -> &mut T {
if !self.plugin_state.contains_key(plugin_id) {
self.plugin_state.insert(plugin_id, Box::new(init()));
}
self.plugin_state
.get_mut(plugin_id)
.expect("just inserted")
.downcast_mut::<T>()
.expect("plugin_id type mismatch")
}
/// Welcome / Start tab. Carries a dummy Scene so the rest of the app
/// can read tab state uniformly; the viewport renderer detects
/// `is_start` and shows a welcome page instead.
+1
View File
@@ -1,5 +1,6 @@
mod cmd_result;
mod commands;
pub mod plugin_host;
mod document;
mod helpers;
mod history;
+94
View File
@@ -0,0 +1,94 @@
// HostSession — plugin-facing API implemented inside `app` (private field access).
use std::any::{Any, TypeId};
use acadrust::{CadDocument, EntityType, Handle};
use super::OpenCADStudio;
use crate::command::CadCommand;
/// Session adapter: one active document tab, command line, undo.
pub(crate) struct HostSession<'a> {
app: &'a mut OpenCADStudio,
tab: usize,
}
impl<'a> HostSession<'a> {
pub(crate) fn new(app: &'a mut OpenCADStudio, tab: usize) -> Self {
Self { app, tab }
}
pub fn tab_index(&self) -> usize {
self.tab
}
pub fn document(&self) -> &CadDocument {
&self.app.tabs[self.tab].scene.document
}
pub fn document_mut(&mut self) -> &mut CadDocument {
&mut self.app.tabs[self.tab].scene.document
}
pub fn entities(&self) -> impl Iterator<Item = &EntityType> {
self.document().entities()
}
pub fn entities_mut(&mut self) -> impl Iterator<Item = &mut EntityType> {
self.document_mut().entities_mut()
}
pub fn add_entity(&mut self, entity: EntityType) -> Handle {
self.app.tabs[self.tab].scene.add_entity(entity)
}
pub fn bump_geometry(&mut self) {
self.app.tabs[self.tab].scene.bump_geometry();
}
pub fn push_undo(&mut self, label: &str) {
self.app.push_undo_snapshot(self.tab, label);
}
pub fn set_dirty(&mut self) {
self.app.tabs[self.tab].dirty = true;
}
pub fn push_info(&mut self, msg: &str) {
self.app.command_line.push_info(msg);
}
pub fn push_output(&mut self, msg: &str) {
self.app.command_line.push_output(msg);
}
pub fn push_error(&mut self, msg: &str) {
self.app.command_line.push_error(msg);
}
pub fn set_active_command(&mut self, cmd: Box<dyn CadCommand>) {
self.app.tabs[self.tab].active_cmd = Some(cmd);
}
pub fn plugin_state<T: Any + Send + Sync + 'static>(
&self,
plugin_id: &'static str,
) -> Option<&T> {
self.app.tabs[self.tab].plugin_state(plugin_id, TypeId::of::<T>())
}
pub fn plugin_state_mut<T: Any + Send + Sync + 'static>(
&mut self,
plugin_id: &'static str,
) -> Option<&mut T> {
self.app.tabs[self.tab].plugin_state_mut(plugin_id, TypeId::of::<T>())
}
pub fn ensure_plugin_state<T: Any + Send + Sync + 'static>(
&mut self,
plugin_id: &'static str,
init: impl FnOnce() -> T,
) -> &mut T {
self.app.tabs[self.tab].ensure_plugin_state(plugin_id, init)
}
}
+115 -4
View File
@@ -2278,6 +2278,11 @@ impl OpenCADStudio {
.as_ref()
.map(|c| c.needs_entity_pick())
.unwrap_or(false);
let needs_structure = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.needs_structure_point_pick())
.unwrap_or(false);
let is_gathering = self.tabs[i]
.active_cmd
.as_ref()
@@ -2288,7 +2293,7 @@ impl OpenCADStudio {
.as_ref()
.map(|c| c.needs_tangent_pick())
.unwrap_or(false);
self.tabs[i].snap_result = if needs_entity || is_gathering {
self.tabs[i].snap_result = if needs_entity || is_gathering || needs_structure {
None
} else if needs_tan {
self.snapper.snap_tangent_only(
@@ -2345,6 +2350,39 @@ impl OpenCADStudio {
};
self.tabs[i].last_cursor_world = effective;
self.tabs[i].last_cursor_screen = p_full;
// C3D-style orange object snap (plugin commands implement resolve_object_pick).
if needs_structure {
use crate::snap::{SnapResult, SnapType};
let pick = self.tabs[i].active_cmd.as_ref().and_then(|c| {
c.resolve_object_pick(
&self.tabs[i].scene,
effective.x as f64,
effective.y as f64,
)
});
if let Some(pick) = pick {
let world =
glam::Vec3::new(pick.x as f32, pick.y as f32, effective.z);
let ndc = view_proj.project_point3(world);
let screen = iced::Point::new(
(ndc.x + 1.0) * 0.5 * bounds.width,
(1.0 - ndc.y) * 0.5 * bounds.height,
);
self.tabs[i].snap_result = Some(SnapResult {
world,
screen,
snap_type: SnapType::ObjectPick,
tangent_obj: None,
});
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
cmd.set_acquisition_hint(Some(pick.label));
}
} else if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
cmd.set_acquisition_hint(None);
}
}
// Snap glyph is positioned in canvas space; shift the
// tile-local snap screen point back to the full canvas.
if let Some(s) = self.tabs[i].snap_result.as_mut() {
@@ -2352,16 +2390,42 @@ impl OpenCADStudio {
s.screen.y += tile_b.y;
}
let mut previews = if needs_entity {
let mut previews = if needs_structure {
let mut p = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.object_pick_hover_previews(&self.tabs[i].scene, effective))
.unwrap_or_default();
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
p.extend(cmd.on_preview_wires(effective));
}
p
} else if needs_entity {
let hover_handle =
scene::hit_test::click_hit(p, &all_wires[..], view_proj, bounds)
.and_then(|s| Scene::handle_from_wire_name(s))
.unwrap_or(acadrust::Handle::NULL);
self.tabs[i]
let mut p = self.tabs[i]
.active_cmd
.as_mut()
.map(|c| c.on_hover_entity(hover_handle, effective))
.unwrap_or_default()
.unwrap_or_default();
if !hover_handle.is_null() {
if let Some(cmd) = self.tabs[i].active_cmd.as_ref() {
p.extend(cmd.entity_pick_acquire_previews(
&self.tabs[i].scene,
hover_handle,
));
}
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
if let Some(hint) =
cmd.entity_pick_acquire_hint(hover_handle)
{
cmd.set_acquisition_hint(Some(hint));
}
}
}
p
} else {
self.tabs[i]
.active_cmd
@@ -2763,6 +2827,36 @@ impl OpenCADStudio {
};
let result = if self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.needs_structure_point_pick())
.unwrap_or(false)
{
let pick = self.tabs[i].active_cmd.as_ref().and_then(|c| {
c.resolve_object_pick(
&self.tabs[i].scene,
world_pt.x as f64,
world_pt.y as f64,
)
});
if let Some(pick) = pick {
let center = glam::Vec3::new(pick.x as f32, pick.y as f32, world_pt.z);
let result = self.tabs[i].active_cmd.as_mut().map(|c| {
c.on_structure_pick(pick.handle, center)
});
self.command_line
.push_info(&format!("{} acquired.", pick.label));
result
} else {
let msg = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.object_pick_miss_message())
.unwrap_or("No object near click.");
self.command_line.push_error(msg);
None
}
} else if self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.needs_entity_pick())
@@ -2773,6 +2867,23 @@ impl OpenCADStudio {
let hit = scene::hit_test::click_hit(p, &all_wires2[..], vp_mat2, bounds)
.and_then(|s| Scene::handle_from_wire_name(s));
if let Some(handle) = hit {
// Some commands (e.g. SS_CATCHMENT) need the entity
// body before `on_entity_pick` can advance.
let inject_first = self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.inject_before_entity_pick())
.unwrap_or(false);
if inject_first {
if let Some(entity) =
self.tabs[i].scene.document.get_entity(handle).cloned()
{
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
cmd.inject_picked_entity(entity);
}
}
}
let result = self.tabs[i]
.active_cmd
.as_mut()
+55 -1
View File
@@ -7,9 +7,19 @@
use crate::scene::hatch_model::HatchModel;
use crate::scene::wire_model::WireModel;
use crate::scene::Scene;
use acadrust::{EntityType, Handle};
use glam::Vec3;
/// Domain object resolved under the cursor for C3D-style ObjectPick snapping.
#[derive(Clone, Copy, Debug)]
pub struct ObjectPickHit {
pub handle: Handle,
pub x: f64,
pub y: f64,
pub label: &'static str,
}
// ── Transform ─────────────────────────────────────────────────────────────
/// A geometric transformation applied to existing entities.
@@ -267,6 +277,44 @@ pub trait CadCommand: Send {
CmdResult::Cancel
}
/// Point-click pick of domain objects (wire hit-test often misses small markers).
fn needs_structure_point_pick(&self) -> bool {
false
}
/// Resolve a domain object near `(x, y)` while `needs_structure_point_pick()` is active.
fn resolve_object_pick(&self, _scene: &Scene, _x: f64, _y: f64) -> Option<ObjectPickHit> {
None
}
/// Preview wires while hovering during object-point pick.
fn object_pick_hover_previews(&self, _scene: &Scene, _cursor: Vec3) -> Vec<WireModel> {
vec![]
}
/// Message when `resolve_object_pick` returns none on click.
fn object_pick_miss_message(&self) -> &'static str {
"No object near click."
}
/// Called when `needs_structure_point_pick()` is true and a structure is found near the click.
fn on_structure_pick(&mut self, _handle: Handle, _pt: Vec3) -> CmdResult {
CmdResult::Cancel
}
/// Extra acquisition previews during entity pick (besides `on_hover_entity`).
fn entity_pick_acquire_previews(&self, _scene: &Scene, _handle: Handle) -> Vec<WireModel> {
vec![]
}
/// Acquisition hint label during entity pick hover.
fn entity_pick_acquire_hint(&self, _handle: Handle) -> Option<&'static str> {
None
}
/// Hover label for C3D-style object acquisition (e.g. "Inlet" under cursor).
fn set_acquisition_hint(&mut self, _hint: Option<&str>) {}
/// Called after `CmdResult::ReplaceEntity` is applied to the document.
/// `old` is the erased handle; `new_handles` are the handles assigned to the replacement entities.
/// Commands that stay active across replaces should update their internal snapshots here.
@@ -367,7 +415,13 @@ pub trait CadCommand: Send {
self.on_point(hit)
}
/// Called by update.rs after `on_entity_pick` to inject the cloned entity into commands
/// When true, `update.rs` injects the picked entity before calling
/// `on_entity_pick` (required when the pick handler reads injected state).
fn inject_before_entity_pick(&self) -> bool {
false
}
/// Called by update.rs to inject the cloned entity into commands
/// that need to read/modify it (e.g. DIMTEDIT, MLEADERADD, MLEADERREMOVE).
/// Default: no-op.
fn inject_picked_entity(&mut self, _entity: acadrust::EntityType) {}
+1
View File
@@ -6,6 +6,7 @@ pub mod entities;
pub mod io;
pub mod linetypes;
pub mod modules;
pub mod plugin;
pub mod patterns;
pub mod scene;
pub mod snap;
+1
View File
@@ -10,6 +10,7 @@ mod entities;
mod io;
mod linetypes;
mod modules;
mod plugin;
mod patterns;
mod scene;
mod snap;
+6 -3
View File
@@ -1,10 +1,13 @@
// Module system — CadModule, ToolDef, RibbonGroup.
//
// To add a new module:
// 1. Create `src/modules/my_name/` directory
// To add a **core** ribbon tab (Home, View, …):
// 1. Create `src/modules/my_name/` directory (no `plugin.toml`)
// 2. Add `src/modules/my_name/mod.rs` implementing `CadModule` as `MyNameModule`
// 3. Add `pub mod my_name;` below
// 4. `cargo build` — module appears in the ribbon automatically
// 4. `cargo build` — tab appears via build.rs registry
//
// To add an **add-on plugin** (Storm Sewer, …):
// See `docs/plugin-architecture.md` and copy `docs/plugin-template/`.
//
// Each module folder contains:
// - mod.rs : module definition (ribbon groups + tool layout)
-1
View File
@@ -14,6 +14,5 @@ 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),
]
}
+69
View File
@@ -0,0 +1,69 @@
# Storm Sewer (`opencad.storm_sewer`)
Add-on package for gravity storm-drain network design and analysis.
- **Engine:** `crates/stormsewer` (headless hydraulics)
- **Host integration:** `plugin.rs`, `dispatch.rs`, `register.rs`
- **Architecture:** `docs/plugin-architecture.md`
## Commands
| Command | Description |
|---------|-------------|
| `SS_INLET` | Place inlet structure |
| `SS_JUNCTION` | Place junction structure |
| `SS_OUTFALL` | Place outfall structure |
| `SS_PIPE` | Draw pipe between two structures |
| `SS_CATCHMENT` | Draw catchment boundary polyline |
| `SS_APPLYTC` | Apply time-of-concentration from catchments |
| `SS_LANDXML` / `SS_IMPORTXML` | Import LandXML storm network |
| `SS_ANALYZE` | Run hydraulic analysis on drawn network |
| `SS_SIZE` | Size pipes from analysis |
| `SS_PARAMS` | Set rainfall / analysis parameters |
| `SS_MULTIRP` | Multi return-period analysis |
| `SS_REPORT` | Print analysis report |
| `SS_PROFILE` | Draw HGL profile |
## XDATA schemas
All records use application names registered in `manifest.rs`. Values are stored as XDATA on DWG entities so networks round-trip through save/load.
### `STORMSEWER_STRUCT` (on `CIRCLE`)
| Index | Field | Type | Notes |
|-------|-------|------|-------|
| 0 | kind | int | 0=inlet, 1=junction, 2=outfall |
| 1 | invert | real | Structure invert elevation |
| 2 | rim | real | Rim elevation |
| 3 | area | real | Contributing area (acres) |
| 4 | C | real | Runoff coefficient |
### `STORMSEWER_PIPE` (on `LINE`)
| Index | Field | Type | Notes |
|-------|-------|------|-------|
| 0 | diameter | real | Pipe diameter (inches) |
| 1 | n | real | Manning's n |
| 2 | from_handle | int | Start structure entity handle |
| 3 | to_handle | int | End structure entity handle |
### `STORMSEWER_CATCHMENT` (on `LWPOLYLINE`)
| Index | Field | Type | Notes |
|-------|-------|------|-------|
| 0 | area_acres | real | Catchment area |
| 1 | C | real | Runoff coefficient |
| 2 | length_ft | real | Flow path length |
| 3 | slope | real | Average slope |
| 4 | inlet_handle | int | Optional inlet structure handle (0 = none) |
## Per-document state
Stored under plugin id `opencad.storm_sewer` as `StormTabState` (`state.rs`):
- `StormAnalysisParams` — IDF, tailwater, min Tc, return periods, etc.
## Dependencies
- `stormsewer` workspace crate — must not depend on CAD host crates.
- This package — must not edit `src/app/commands.rs`; use `dispatch.rs`.
+76 -17
View File
@@ -8,18 +8,19 @@
use acadrust::types::Vector3;
use acadrust::{Circle, EntityType, Line, MText};
use stormsewer::design::check_inlet;
use stormsewer::drawing::{draw_network, DrawConfig};
use stormsewer::idf::IdfCurve;
use stormsewer::network::{Analysis, AnalysisOptions, Network, Node, Pipe};
use stormsewer::network::{Analysis, AnalysisOptions, Network, Node, NodeKind, Pipe};
use stormsewer::params::StormAnalysisParams;
use stormsewer::parse::parse_ssn;
use stormsewer::report::format_analysis;
use stormsewer::report::{format_analysis, format_multi_rp};
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())
/// Default network-level analysis parameters (tests / fallbacks).
pub fn default_params() -> StormAnalysisParams {
StormAnalysisParams::municipal()
}
/// Annotation labels only (flow + HGL), for overlaying on an already-drawn
@@ -36,27 +37,85 @@ fn build_annotations(net: &Network, a: &Analysis) -> Vec<EntityType> {
/// 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> {
pub fn analyze_doc<'a>(
entities: impl Iterator<Item = &'a EntityType>,
params: &StormAnalysisParams,
) -> Result<(Vec<EntityType>, String, Analysis), 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)))
let a = run_analysis(&net, params.idf.design_curve(), &params.hydraulics)?;
let report = full_report(&net, &a, params);
Ok((build_annotations(&net, &a), report, 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> {
pub fn profile_doc<'a>(
entities: impl Iterator<Item = &'a EntityType>,
params: &StormAnalysisParams,
) -> Result<Vec<EntityType>, String> {
let net = data::network_from_entities(entities)?;
let (idf, opts) = default_params();
let a = run_analysis(&net, &idf, &opts)?;
let a = run_analysis(&net, params.idf.design_curve(), &params.hydraulics)?;
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> {
pub fn report_doc<'a>(
entities: impl Iterator<Item = &'a EntityType>,
params: &StormAnalysisParams,
) -> 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))
let a = run_analysis(&net, params.idf.design_curve(), &params.hydraulics)?;
Ok(full_report(&net, &a, params))
}
/// Multi-return-period peak-flow comparison table.
pub fn multi_rp_report<'a>(
entities: impl Iterator<Item = &'a EntityType>,
params: &StormAnalysisParams,
) -> Result<String, String> {
let net = data::network_from_entities(entities)?;
Ok(format_multi_rp(&net, &params.idf, &params.hydraulics))
}
fn full_report(net: &Network, a: &Analysis, params: &StormAnalysisParams) -> String {
let mut s = format_analysis(a);
s.push_str(&inlet_section(net, a, params));
s
}
fn inlet_section(net: &Network, a: &Analysis, params: &StormAnalysisParams) -> String {
let mut s = String::new();
let mut any = false;
for nd in &net.nodes {
if nd.kind != NodeKind::Inlet {
continue;
}
let q = a
.pipes
.iter()
.filter(|p| p.from == nd.id)
.map(|p| p.design_q)
.fold(0.0f64, f64::max);
if q <= 0.0 {
continue;
}
if !any {
s.push_str("\n=== INLET CAPACITY (HEC-22 grate, simplified) ===\n");
s.push_str("Node Q(cfs) Cap(cfs) Status\n");
any = true;
}
let chk = check_inlet(
q,
params.inlet_grate_length_ft,
params.inlet_flow_depth_ft,
params.inlet_gutter_slope,
);
let status = if chk.ok { "ok" } else { "BYPASS" };
s.push_str(&format!(
"{:<6} {:>6.2} {:>8.2} {status}\n",
nd.id, chk.design_q_cfs, chk.capacity_cfs
));
}
s
}
/// The built-in demonstration network (properly sized, with plan coordinates).
+309
View File
@@ -0,0 +1,309 @@
// Interactive catchment tagging: closed LwPolyline + STORMSEWER_CATCHMENT XDATA.
use acadrust::entities::LwPolyline;
use acadrust::{EntityType, Handle};
use glam::Vec3;
use stormsewer::catchment::{default_flow_length_ft, polygon_centroid, shoelace_area_sqft, sqft_to_acres};
use super::data;
use super::preview;
use crate::command::{CadCommand, CmdResult, ObjectPickHit};
use crate::scene::{Scene, WireModel};
fn parse_num(text: &str) -> Option<f64> {
text.trim().replace(',', ".").parse::<f64>().ok()
}
enum CStep {
PickPolyline,
RunoffC,
FlowLength,
Slope,
PickInlet,
}
pub struct TagCatchment {
step: CStep,
picked: Option<EntityType>,
c: f64,
flow_length: f64,
slope: f64,
inlet_handle: Handle,
area_ac: f64,
acquire_hint: Option<String>,
}
impl TagCatchment {
pub fn new() -> Self {
Self {
step: CStep::PickPolyline,
picked: None,
c: 0.70,
flow_length: 0.0,
slope: 0.01,
inlet_handle: Handle::NULL,
area_ac: 0.0,
acquire_hint: None,
}
}
fn commit(&self) -> CmdResult {
let mut ent = self.picked.clone().expect("polyline not picked");
let EntityType::LwPolyline(pl) = &ent else {
return CmdResult::Cancel;
};
if !pl.is_closed {
return CmdResult::Cancel;
}
let handle = ent.common().handle;
let xd = &mut ent.common_mut().extended_data;
let kept: Vec<_> = xd
.records()
.iter()
.filter(|r| r.application_name != data::APP_CATCHMENT)
.cloned()
.collect();
xd.clear();
for r in kept {
xd.add_record(r);
}
xd.add_record(data::catchment_xdata(
self.c,
self.flow_length,
self.slope,
self.inlet_handle,
));
CmdResult::ReplaceMany(vec![(handle, vec![ent])], vec![])
}
fn assign_inlet(&mut self, handle: Handle, pt: Vec3) {
self.inlet_handle = handle;
if self.flow_length <= 0.0 {
if let Some(ref ent) = self.picked {
if let EntityType::LwPolyline(pl) = ent {
let verts: Vec<_> = pl.vertices.iter().map(|v| (v.location.x, v.location.y)).collect();
let centroid = polygon_centroid(&verts);
self.flow_length =
default_flow_length_ft(centroid, (pt.x as f64, pt.y as f64));
}
}
}
}
}
impl Default for TagCatchment {
fn default() -> Self {
Self::new()
}
}
impl CadCommand for TagCatchment {
fn name(&self) -> &'static str {
"SS_CATCHMENT"
}
fn prompt(&self) -> String {
match self.step {
CStep::PickPolyline => {
"Catchment: click closed drainage-area polyline (highlights orange):".into()
}
CStep::RunoffC => format!(
"Catchment area {:.3} ac — runoff C <{:.2}> (Enter to accept):",
self.area_ac, self.c
),
CStep::FlowLength => format!(
"Flow path length, ft <{:.1}> (0 = auto from centroid to inlet):",
self.flow_length
),
CStep::Slope => format!("Average slope, ft/ft <{:.4}> (Enter to accept):", self.slope),
CStep::PickInlet => {
let hint = self
.acquire_hint
.as_deref()
.map(|h| format!(" [{h}]"))
.unwrap_or_default();
format!(
"Catchment: click inlet/junction to drain to (orange snap){hint} — Enter = nearest:"
)
}
}
}
fn set_acquisition_hint(&mut self, hint: Option<&str>) {
if matches!(self.step, CStep::PickInlet) {
self.acquire_hint = hint.map(str::to_string);
}
}
fn wants_text_input(&self) -> bool {
matches!(self.step, CStep::RunoffC | CStep::FlowLength | CStep::Slope)
}
fn needs_entity_pick(&self) -> bool {
matches!(self.step, CStep::PickPolyline)
}
fn needs_structure_point_pick(&self) -> bool {
matches!(self.step, CStep::PickInlet)
}
fn resolve_object_pick(&self, scene: &Scene, x: f64, y: f64) -> Option<ObjectPickHit> {
let pick = preview::structure_under_cursor(scene, x, y, true)?;
Some(ObjectPickHit {
handle: pick.handle,
x: pick.x,
y: pick.y,
label: pick.label(),
})
}
fn object_pick_hover_previews(&self, scene: &Scene, cursor: Vec3) -> Vec<WireModel> {
preview::structure_acquire_previews(scene, cursor, true)
}
fn object_pick_miss_message(&self) -> &'static str {
"No storm structure near click — move closer or press Enter for nearest."
}
fn entity_pick_acquire_previews(&self, scene: &Scene, handle: Handle) -> Vec<WireModel> {
if matches!(self.step, CStep::PickPolyline) {
preview::catchment_poly_under_cursor(scene, handle)
} else {
vec![]
}
}
fn entity_pick_acquire_hint(&self, _handle: Handle) -> Option<&'static str> {
if matches!(self.step, CStep::PickPolyline) {
Some("Catchment area")
} else {
None
}
}
fn inject_picked_entity(&mut self, entity: EntityType) {
if !matches!(self.step, CStep::PickPolyline) {
return;
}
if let EntityType::LwPolyline(ref pl) = entity {
if pl.is_closed && pl.vertices.len() >= 3 {
let verts: Vec<_> = pl.vertices.iter().map(|v| (v.location.x, v.location.y)).collect();
self.area_ac = sqft_to_acres(shoelace_area_sqft(&verts));
self.picked = Some(entity);
}
}
}
fn inject_before_entity_pick(&self) -> bool {
true
}
fn on_entity_pick(&mut self, _handle: Handle, _pt: Vec3) -> CmdResult {
if self.picked.is_none() {
return CmdResult::NeedPoint;
}
self.step = CStep::RunoffC;
CmdResult::NeedPoint
}
fn on_structure_pick(&mut self, handle: Handle, pt: Vec3) -> CmdResult {
self.assign_inlet(handle, pt);
self.acquire_hint = None;
self.commit()
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let v = parse_num(text);
match self.step {
CStep::RunoffC => {
if let Some(x) = v {
self.c = x;
}
self.step = CStep::FlowLength;
}
CStep::FlowLength => {
if let Some(x) = v {
self.flow_length = x;
}
self.step = CStep::Slope;
}
CStep::Slope => {
if let Some(x) = v {
self.slope = x;
}
self.step = CStep::PickInlet;
}
_ => {}
}
None
}
fn on_enter(&mut self) -> CmdResult {
match self.step {
CStep::PickInlet => {
self.acquire_hint = None;
self.commit()
}
CStep::RunoffC => {
self.step = CStep::FlowLength;
CmdResult::NeedPoint
}
CStep::FlowLength => {
self.step = CStep::Slope;
CmdResult::NeedPoint
}
CStep::Slope => {
self.step = CStep::PickInlet;
CmdResult::NeedPoint
}
CStep::PickPolyline => CmdResult::NeedPoint,
}
}
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
CmdResult::NeedPoint
}
}
/// Inspect a closed polyline and suggest defaults from geometry.
pub fn catchment_defaults_from_poly(pl: &LwPolyline, target_xy: Option<(f64, f64)>) -> (f64, f64, f64) {
let verts: Vec<_> = pl.vertices.iter().map(|v| (v.location.x, v.location.y)).collect();
let area_ac = sqft_to_acres(shoelace_area_sqft(&verts));
let centroid = polygon_centroid(&verts);
let flow_len = target_xy
.map(|t| default_flow_length_ft(centroid, t))
.unwrap_or(0.0);
(area_ac, flow_len, 0.01)
}
/// Update structure entities with Tc computed from catchments + network assembly.
pub fn apply_tc_from_network<'a>(
entities: impl Iterator<Item = &'a EntityType>,
entities_mut: impl Iterator<Item = &'a mut EntityType>,
) -> Result<usize, String> {
data::apply_tc_in_document(entities, entities_mut)
}
#[cfg(test)]
mod tests {
use super::*;
use acadrust::entities::LwVertex;
use acadrust::types::Vector2;
#[test]
fn defaults_from_unit_square() {
let mut pl = LwPolyline::default();
pl.is_closed = true;
pl.vertices = vec![
LwVertex::new(Vector2::new(0.0, 0.0)),
LwVertex::new(Vector2::new(10.0, 0.0)),
LwVertex::new(Vector2::new(10.0, 10.0)),
LwVertex::new(Vector2::new(0.0, 10.0)),
];
let (area, flow, slope) = catchment_defaults_from_poly(&pl, Some((0.0, 0.0)));
assert!((area - sqft_to_acres(100.0)).abs() < 1e-6);
assert!(flow > 0.0);
assert!((slope - 0.01).abs() < 1e-9);
}
}
+429 -12
View File
@@ -2,19 +2,36 @@
// 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.
// [kind, invert, rim, area, C, tc_inlet?]; pipes are lines tagged with
// `STORMSEWER_PIPE` [diameter, n, from-handle, to-handle]. Catchments are
// closed LwPolylines tagged with `STORMSEWER_CATCHMENT`
// [C, flow_length_ft, slope, inlet_handle (0 = auto)]. Connectivity is by
// entity handle, so the drawn network round-trips to DWG/DXF and is analyzable
// directly.
use std::collections::HashMap;
use acadrust::entities::LwPolyline;
use acadrust::xdata::{ExtendedDataRecord, XDataValue};
use acadrust::{EntityType, Handle};
use stormsewer::catchment::{catchment_tc_minutes, default_flow_length_ft, polygon_centroid, shoelace_area_sqft, sqft_to_acres};
use stormsewer::network::{Network, Node, NodeKind, Pipe};
/// A storm network reconstructed from drawing entities, with entity handles
/// for round-tripping sizing edits back to the document.
#[derive(Debug)]
pub struct DrawnNetwork {
pub network: Network,
/// Structure entity handles, same order as `network.nodes`.
pub node_handles: Vec<Handle>,
/// Pipe entity handles, same order as `network.pipes`.
pub pipe_handles: Vec<Handle>,
}
pub const APP_STRUCT: &str = "STORMSEWER_STRUCT";
pub const APP_PIPE: &str = "STORMSEWER_PIPE";
pub const APP_CATCHMENT: &str = "STORMSEWER_CATCHMENT";
pub fn kind_str(k: NodeKind) -> &'static str {
match k {
@@ -34,12 +51,25 @@ fn parse_kind(s: &str) -> NodeKind {
/// XDATA record for a structure marker.
pub fn structure_xdata(kind: NodeKind, invert: f64, rim: f64, area: f64, c: f64) -> ExtendedDataRecord {
structure_xdata_tc(kind, invert, rim, area, c, 10.0)
}
/// XDATA record for a structure marker including inlet Tc (minutes).
pub fn structure_xdata_tc(
kind: NodeKind,
invert: f64,
rim: f64,
area: f64,
c: f64,
tc_inlet: 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.add_value(XDataValue::Real(tc_inlet));
r
}
@@ -53,6 +83,17 @@ pub fn pipe_xdata(diameter: f64, n: f64, from: Handle, to: Handle) -> ExtendedDa
r
}
/// XDATA for a catchment drainage polygon.
/// `inlet_handle` = `Handle::NULL` for auto-assign to nearest inlet/junction.
pub fn catchment_xdata(c: f64, flow_length_ft: f64, slope: f64, inlet_handle: Handle) -> ExtendedDataRecord {
let mut r = ExtendedDataRecord::new(APP_CATCHMENT);
r.add_value(XDataValue::Real(c));
r.add_value(XDataValue::Real(flow_length_ft));
r.add_value(XDataValue::Real(slope));
r.add_value(XDataValue::Handle(inlet_handle));
r
}
fn real(v: &XDataValue) -> Option<f64> {
if let XDataValue::Real(x) = v {
Some(*x)
@@ -76,10 +117,12 @@ struct StructRec {
rim: f64,
area: f64,
c: f64,
tc_inlet: f64,
x: f64,
y: f64,
}
#[derive(Clone)]
struct PipeRec {
diameter: f64,
n: f64,
@@ -88,6 +131,99 @@ struct PipeRec {
length: f64,
}
struct CatchmentRec {
c: f64,
flow_length_ft: f64,
slope: f64,
inlet_handle: Option<Handle>,
area_ac: f64,
centroid: (f64, f64),
}
/// True when the entity is a tagged storm-sewer structure circle.
pub fn is_structure_entity(e: &EntityType) -> bool {
read_structure(e).is_some()
}
/// Storm structure resolved from a plan click (center + kind).
#[derive(Clone, Debug)]
pub struct StructurePick {
pub handle: Handle,
pub kind: NodeKind,
pub x: f64,
pub y: f64,
}
impl StructurePick {
pub fn label(&self) -> &'static str {
match self.kind {
NodeKind::Inlet => "Inlet",
NodeKind::Junction => "Junction",
NodeKind::Outfall => "Outfall",
}
}
}
/// Nearest storm structure within click tolerance of `(x, y)` (ft).
/// `pick_padding_ft` is added to each marker circle's radius.
pub fn structure_at_point<'a>(
entities: impl Iterator<Item = &'a EntityType>,
x: f64,
y: f64,
pick_padding_ft: f64,
include_outfalls: bool,
) -> Option<StructurePick> {
let mut best: Option<(StructurePick, f64)> = None;
for e in entities {
let s = read_structure(e)?;
if !include_outfalls && s.kind == NodeKind::Outfall {
continue;
}
let radius = match e {
EntityType::Circle(c) => c.radius,
_ => 3.0,
};
let dx = s.x - x;
let dy = s.y - y;
let dist = (dx * dx + dy * dy).sqrt();
let limit = radius + pick_padding_ft;
if dist <= limit {
if best.as_ref().map(|(_, d)| dist < *d).unwrap_or(true) {
best = Some((
StructurePick {
handle: s.handle,
kind: s.kind,
x: s.x,
y: s.y,
},
dist,
));
}
}
}
best.map(|(p, _)| p)
}
pub fn nearest_structure_at_point<'a>(
entities: impl Iterator<Item = &'a EntityType>,
x: f64,
y: f64,
pick_padding_ft: f64,
include_outfalls: bool,
) -> Option<Handle> {
structure_at_point(entities, x, y, pick_padding_ft, include_outfalls).map(|p| p.handle)
}
/// Nearest inlet/junction only (for catchment drainage targets).
pub fn nearest_drainage_structure_at_point<'a>(
entities: impl Iterator<Item = &'a EntityType>,
x: f64,
y: f64,
pick_padding_ft: f64,
) -> Option<Handle> {
nearest_structure_at_point(entities, x, y, pick_padding_ft, false)
}
fn read_structure(e: &EntityType) -> Option<StructRec> {
let rec = e.common().extended_data.get_record(APP_STRUCT)?;
if rec.values.len() < 5 {
@@ -101,6 +237,11 @@ fn read_structure(e: &EntityType) -> Option<StructRec> {
EntityType::Circle(c) => (c.center.x, c.center.y),
_ => return None,
};
let tc_inlet = if rec.values.len() >= 6 {
real(&rec.values[5]).unwrap_or(10.0)
} else {
10.0
};
Some(StructRec {
handle: e.common().handle,
kind,
@@ -108,6 +249,7 @@ fn read_structure(e: &EntityType) -> Option<StructRec> {
rim: real(&rec.values[2])?,
area: real(&rec.values[3])?,
c: real(&rec.values[4])?,
tc_inlet,
x,
y,
})
@@ -135,19 +277,233 @@ fn read_pipe(e: &EntityType) -> Option<PipeRec> {
})
}
/// 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> {
fn polyline_vertices(pl: &LwPolyline) -> Vec<(f64, f64)> {
pl.vertices.iter().map(|v| (v.location.x, v.location.y)).collect()
}
fn read_catchment(e: &EntityType) -> Option<CatchmentRec> {
let EntityType::LwPolyline(pl) = e else {
return None;
};
if !pl.is_closed || pl.vertices.len() < 3 {
return None;
}
let rec = e.common().extended_data.get_record(APP_CATCHMENT)?;
if rec.values.len() < 4 {
return None;
}
let verts = polyline_vertices(pl);
let area_ac = sqft_to_acres(shoelace_area_sqft(&verts));
let inlet = handle(&rec.values[3]).filter(|h| !h.is_null());
Some(CatchmentRec {
c: real(&rec.values[0])?,
flow_length_ft: real(&rec.values[1])?,
slope: real(&rec.values[2])?,
inlet_handle: inlet,
area_ac,
centroid: polygon_centroid(&verts),
})
}
/// Replace the diameter on a storm-sewer pipe line entity.
pub fn set_pipe_diameter(e: &mut EntityType, new_dia: f64) -> bool {
let EntityType::Line(_) = e else {
return false;
};
let xd = &mut e.common_mut().extended_data;
let Some(old) = xd.records().iter().find(|r| r.application_name == APP_PIPE) else {
return false;
};
if old.values.len() < 4 {
return false;
};
let Some(n) = real(&old.values[1]) else {
return false;
};
let Some(from) = handle(&old.values[2]) else {
return false;
};
let Some(to) = handle(&old.values[3]) else {
return false;
};
let kept: Vec<_> = xd
.records()
.iter()
.filter(|r| r.application_name != APP_PIPE)
.cloned()
.collect();
xd.clear();
for r in kept {
xd.add_record(r);
}
xd.add_record(pipe_xdata(new_dia, n, from, to));
true
}
/// Recompute Tc from catchments and write onto structure entities in the document.
pub fn apply_tc_in_document<'a>(
entities: impl Iterator<Item = &'a EntityType>,
entities_mut: impl Iterator<Item = &'a mut EntityType>,
) -> Result<usize, String> {
let drawn = drawn_network_from_entities(entities)?;
let mut tc_by_handle: HashMap<Handle, f64> = HashMap::new();
for (node, &h) in drawn.network.nodes.iter().zip(drawn.node_handles.iter()) {
if node.kind != NodeKind::Outfall {
tc_by_handle.insert(h, node.tc_inlet);
}
}
let mut updated = 0;
for ent in entities_mut {
let h = ent.common().handle;
let Some(&tc) = tc_by_handle.get(&h) else {
continue;
};
let EntityType::Circle(_) = ent else { continue };
let xd = &mut ent.common_mut().extended_data;
let Some(old) = xd.records().iter().find(|r| r.application_name == APP_STRUCT).cloned() else {
continue;
};
if old.values.len() < 5 {
continue;
}
let kind = match &old.values[0] {
XDataValue::String(s) => parse_kind(s),
_ => continue,
};
let invert = real(&old.values[1]).unwrap_or(0.0);
let rim = real(&old.values[2]).unwrap_or(0.0);
let area = real(&old.values[3]).unwrap_or(0.0);
let c = real(&old.values[4]).unwrap_or(0.0);
let kept: Vec<_> = xd
.records()
.iter()
.filter(|r| r.application_name != APP_STRUCT)
.cloned()
.collect();
xd.clear();
for r in kept {
xd.add_record(r);
}
xd.add_record(structure_xdata_tc(kind, invert, rim, area, c, tc));
updated += 1;
}
Ok(updated)
}
/// Write computed inlet Tc back onto structure circle entities (by handle order).
pub fn apply_tc_to_structures(entities: &mut [EntityType], drawn: &DrawnNetwork) -> usize {
let mut updated = 0;
for (node, &h) in drawn.network.nodes.iter().zip(drawn.node_handles.iter()) {
if node.kind == NodeKind::Outfall {
continue;
}
let Some(ent) = entities.iter_mut().find(|e| e.common().handle == h) else {
continue;
};
let EntityType::Circle(_) = ent else { continue };
let xd = &mut ent.common_mut().extended_data;
let Some(old) = xd.records().iter().find(|r| r.application_name == APP_STRUCT).cloned() else {
continue;
};
if old.values.len() < 5 {
continue;
}
let kind = match &old.values[0] {
XDataValue::String(s) => parse_kind(s),
_ => continue,
};
let invert = real(&old.values[1]).unwrap_or(node.invert);
let rim = real(&old.values[2]).unwrap_or(node.rim);
let area = real(&old.values[3]).unwrap_or(node.area_ac);
let c = real(&old.values[4]).unwrap_or(node.c);
let kept: Vec<_> = xd
.records()
.iter()
.filter(|r| r.application_name != APP_STRUCT)
.cloned()
.collect();
xd.clear();
for r in kept {
xd.add_record(r);
}
xd.add_record(structure_xdata_tc(kind, invert, rim, area, c, node.tc_inlet));
updated += 1;
}
updated
}
fn nearest_drainage_structure(structs: &[StructRec], point: (f64, f64)) -> Option<usize> {
structs
.iter()
.enumerate()
.filter(|(_, s)| s.kind != NodeKind::Outfall)
.min_by(|(_, a), (_, b)| {
let da = (a.x - point.0).powi(2) + (a.y - point.1).powi(2);
let db = (b.x - point.0).powi(2) + (b.y - point.1).powi(2);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(i, _)| i)
}
fn apply_catchments(structs: &mut [StructRec], catchments: &[CatchmentRec]) {
for cat in catchments {
let target = if let Some(h) = cat.inlet_handle {
structs.iter().position(|s| s.handle == h)
} else {
nearest_drainage_structure(structs, cat.centroid)
};
let Some(idx) = target else { continue };
let s = &mut structs[idx];
let local_ca = s.c * s.area;
let add_ca = cat.c * cat.area_ac;
let total_area = s.area + cat.area_ac;
if total_area > 0.0 {
s.area = total_area;
s.c = (local_ca + add_ca) / total_area;
}
let flow_len = if cat.flow_length_ft > 0.0 {
cat.flow_length_ft
} else {
default_flow_length_ft(cat.centroid, (s.x, s.y))
};
let slope = if cat.slope > 0.0 { cat.slope } else { 0.01 };
let tc = catchment_tc_minutes(flow_len, slope);
s.tc_inlet = s.tc_inlet.max(tc);
}
}
/// Build a [`DrawnNetwork`] from entities. Structures become nodes (N1, N2, …);
/// pipes become links mapped by structure handles in XDATA.
pub fn drawn_network_from_entities<'a>(entities: impl Iterator<Item = &'a EntityType>) -> Result<DrawnNetwork, String> {
let mut structs: Vec<StructRec> = Vec::new();
let mut pipes_raw: Vec<PipeRec> = Vec::new();
let mut pipes_raw: Vec<(Handle, PipeRec)> = Vec::new();
let mut catchments: Vec<CatchmentRec> = 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);
pipes_raw.push((e.common().handle, p));
} else if let Some(c) = read_catchment(e) {
catchments.push(c);
}
}
apply_catchments(&mut structs, &catchments);
let network = assemble_network(&structs, &pipes_raw.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>())?;
Ok(DrawnNetwork {
network,
node_handles: structs.iter().map(|s| s.handle).collect(),
pipe_handles: pipes_raw.iter().map(|(h, _)| *h).collect(),
})
}
/// 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> {
Ok(drawn_network_from_entities(entities)?.network)
}
fn assemble_network(structs: &[StructRec], pipes_raw: &[PipeRec]) -> Result<Network, String> {
if structs.is_empty() {
return Err("No storm-sewer structures in the drawing — place Inlet/Junction/Outfall first.".into());
}
@@ -162,6 +518,7 @@ pub fn network_from_entities<'a>(entities: impl Iterator<Item = &'a EntityType>)
NodeKind::Junction => Node::junction(&id, s.invert, s.rim, s.area, s.c),
NodeKind::Outfall => Node::outfall(&id, s.invert, s.rim),
}
.with_tc_inlet(s.tc_inlet)
.at(s.x, s.y);
nodes.push(node);
}
@@ -188,8 +545,9 @@ pub fn network_from_entities<'a>(entities: impl Iterator<Item = &'a EntityType>)
#[cfg(test)]
mod tests {
use super::*;
use acadrust::types::Vector3;
use acadrust::{Circle, Line};
use acadrust::entities::LwVertex;
use acadrust::types::{Vector2, Vector3};
use acadrust::{Circle, Line, LwPolyline};
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() });
@@ -203,6 +561,26 @@ mod tests {
e
}
fn catchment_poly(h: u64, c: f64, inlet: u64) -> EntityType {
let mut pl = LwPolyline::default();
pl.is_closed = true;
pl.vertices = vec![
LwVertex::new(Vector2::new(40.0, -20.0)),
LwVertex::new(Vector2::new(60.0, -20.0)),
LwVertex::new(Vector2::new(60.0, 20.0)),
LwVertex::new(Vector2::new(40.0, 20.0)),
];
let mut e = EntityType::LwPolyline(pl);
e.common_mut().handle = Handle::new(h);
e.common_mut().extended_data.add_record(catchment_xdata(
c,
2500.0,
0.02,
if inlet == 0 { Handle::NULL } else { Handle::new(inlet) },
));
e
}
#[test]
fn reconstructs_network_from_tagged_entities() {
let ents = vec![
@@ -219,12 +597,51 @@ mod tests {
assert!((net.pipes[0].diameter - 1.5).abs() < 1e-9);
}
#[test]
fn catchment_adds_area_and_tc_to_inlet() {
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),
catchment_poly(10, 0.8, 1),
];
let net = network_from_entities(ents.iter()).unwrap();
let n1 = &net.nodes[0];
assert!(n1.area_ac > 1.0, "catchment should add area, got {}", n1.area_ac);
assert!(n1.tc_inlet > 10.0, "Kirpich tc should exceed default 10 min");
}
#[test]
fn errors_when_no_structures() {
let ents: Vec<EntityType> = vec![];
assert!(network_from_entities(ents.iter()).is_err());
}
#[test]
fn set_pipe_diameter_updates_xdata() {
let mut e = pipe(1, 2, 0.0, 100.0);
assert!(set_pipe_diameter(&mut e, 2.0));
let rec = e.common().extended_data.get_record(APP_PIPE).unwrap();
assert!((real(&rec.values[0]).unwrap() - 2.0).abs() < 1e-9);
}
#[test]
fn nearest_structure_respects_click_tolerance() {
let ents = vec![
structure(1, NodeKind::Inlet, 0.0, 104.0),
structure(2, NodeKind::Outfall, 100.0, 100.0),
];
assert_eq!(
nearest_structure_at_point(ents.iter(), 1.0, 0.0, 5.0, true),
Some(Handle::new(1))
);
assert!(nearest_drainage_structure_at_point(ents.iter(), 100.0, 0.0, 5.0).is_none());
assert_eq!(
nearest_drainage_structure_at_point(ents.iter(), 1.0, 0.0, 5.0),
Some(Handle::new(1))
);
}
#[test]
fn dangling_pipe_is_reported() {
let ents = vec![
@@ -233,4 +650,4 @@ mod tests {
];
assert!(network_from_entities(ents.iter()).is_err());
}
}
}
+172
View File
@@ -0,0 +1,172 @@
// Storm Sewer command handlers — all domain logic lives here, not in `src/plugin/`.
use crate::command::CadCommand;
use crate::plugin::host::HostSession;
use super::analysis;
use super::catchment::{apply_tc_from_network, TagCatchment};
use super::landxml_import;
use super::manifest::PLUGIN_ID;
use super::params_cmd;
use super::sizing;
use super::state::StormTabState;
use super::structures::{PlacePipe, PlaceStructure};
use super::{data, style};
fn tab_params(host: &mut HostSession<'_>) -> stormsewer::params::StormAnalysisParams {
host.ensure_plugin_state(PLUGIN_ID, StormTabState::default)
.params()
.clone()
}
/// Handle any `SS_*` command. Returns true when consumed.
pub fn handle(host: &mut HostSession<'_>, cmd: &str) -> bool {
if !cmd.starts_with("SS_") {
return false;
}
match cmd {
"SS_ANALYZE" => {
let params = tab_params(host);
match analysis::analyze_doc(host.entities(), &params) {
Ok((ents, report, analysis)) => {
for e in ents {
let _ = host.add_entity(e);
}
if let Ok(drawn) = data::drawn_network_from_entities(host.entities()) {
host.push_undo("SS_STYLE");
let (sur, flood) = style::apply_analysis_style(host.entities_mut(), &drawn, &analysis);
if sur > 0 || flood > 0 {
host.set_dirty();
host.push_info(&format!(
"Styled {sur} surcharged pipe(s), {flood} flooded structure(s)."
));
}
}
host.bump_geometry();
host.push_info(&format!("Storm sewer analyzed ({}).", params.summary()));
for line in report.lines() {
host.push_output(line);
}
}
Err(e) => host.push_error(&e),
}
true
}
"SS_REPORT" => {
let params = tab_params(host);
match analysis::report_doc(host.entities(), &params) {
Ok(report) => {
for line in report.lines() {
host.push_output(line);
}
}
Err(e) => host.push_error(&e),
}
true
}
"SS_MULTIRP" => {
let params = tab_params(host);
match analysis::multi_rp_report(host.entities(), &params) {
Ok(report) => {
for line in report.lines() {
host.push_output(line);
}
}
Err(e) => host.push_error(&e),
}
true
}
"SS_PROFILE" => {
let params = tab_params(host);
match analysis::profile_doc(host.entities(), &params) {
Ok(ents) => {
for e in ents {
let _ = host.add_entity(e);
}
host.bump_geometry();
host.push_info("Storm sewer HGL profile drawn.");
}
Err(e) => host.push_error(&e),
}
true
}
"SS_SIZE" => {
let params = tab_params(host);
match sizing::plan_size_updates(host.entities(), &params) {
Ok((updates, report, pending)) => {
for line in report.lines() {
host.push_output(line);
}
if pending == 0 {
host.push_info("Storm sewer: all pipes already meet sizing criteria.");
} else {
host.push_undo("SS_SIZE");
let applied = sizing::apply_updates(host.entities_mut(), &updates);
host.bump_geometry();
host.set_dirty();
host.push_info(&format!("Storm sewer: applied {applied} pipe diameter update(s)."));
}
}
Err(e) => host.push_error(&e),
}
true
}
"SS_INLET" | "SS_JUNCTION" | "SS_OUTFALL" => {
let c = match cmd {
"SS_INLET" => PlaceStructure::inlet(),
"SS_JUNCTION" => PlaceStructure::junction(),
_ => PlaceStructure::outfall(),
};
host.push_info(&c.prompt());
host.set_active_command(Box::new(c));
true
}
"SS_PIPE" => {
let c = PlacePipe::new();
host.push_info(&c.prompt());
host.set_active_command(Box::new(c));
true
}
"SS_CATCHMENT" => {
let c = TagCatchment::new();
host.push_info(&c.prompt());
host.set_active_command(Box::new(c));
true
}
"SS_APPLYTC" => {
host.push_undo("SS_APPLYTC");
let snapshot: Vec<_> = host.entities().cloned().collect();
match apply_tc_from_network(snapshot.iter(), host.entities_mut()) {
Ok(n) => {
host.set_dirty();
host.bump_geometry();
host.push_info(&format!("Storm sewer: updated inlet Tc on {n} structure(s)."));
}
Err(e) => host.push_error(&e),
}
true
}
"SS_LANDXML" | "SS_IMPORTXML" => {
match landxml_import::pick_landxml_file() {
None => host.push_info("LandXML import cancelled."),
Some(Ok(xml)) => match landxml_import::import_landxml(host, &xml) {
Ok(msg) => host.push_info(&msg),
Err(e) => host.push_error(&e),
},
Some(Err(e)) => host.push_error(&e),
}
true
}
cmd if cmd == "SS_PARAMS" || cmd.starts_with("SS_PARAMS ") => {
let rest = cmd.trim_start_matches("SS_PARAMS").trim();
let state = host.ensure_plugin_state(PLUGIN_ID, StormTabState::default);
match params_cmd::apply_params(state, rest) {
Ok(msg) => host.push_info(&msg),
Err(e) => host.push_error(&e),
}
true
}
_ => false,
}
}
+286
View File
@@ -0,0 +1,286 @@
//! Headless tests for storm-sewer UX: structure picking, C3D-style acquisition,
//! catchment/pipe command flows (no GUI, no file I/O).
#[cfg(test)]
mod tests {
use acadrust::entities::LwVertex;
use acadrust::types::{Vector2, Vector3};
use acadrust::xdata::XDataValue;
use acadrust::{Circle, EntityType, Handle, Line, LwPolyline};
use glam::Vec3;
use stormsewer::network::NodeKind;
use crate::command::{CadCommand, CmdResult};
use crate::modules::storm_sewer::catchment::TagCatchment;
use crate::modules::storm_sewer::data::{
self, catchment_xdata, structure_at_point, structure_xdata, APP_CATCHMENT, APP_PIPE,
};
use crate::modules::storm_sewer::preview;
use crate::modules::storm_sewer::structures::PlacePipe;
use crate::scene::{Scene, WireModel};
use crate::snap::{SnapType, ALL_SNAP_MODES};
fn structure(h: u64, kind: NodeKind, x: f64, y: f64, radius: f64) -> EntityType {
let mut e = EntityType::Circle(Circle {
center: Vector3::new(x, y, 0.0),
radius,
..Default::default()
});
e.common_mut().handle = Handle::new(h);
e.common_mut()
.extended_data
.add_record(structure_xdata(kind, 100.0, 106.0, 1.0, 0.7));
e
}
fn closed_poly(h: u64) -> EntityType {
let mut pl = LwPolyline::default();
pl.is_closed = true;
pl.vertices = vec![
LwVertex::new(Vector2::new(0.0, 0.0)),
LwVertex::new(Vector2::new(100.0, 0.0)),
LwVertex::new(Vector2::new(100.0, 100.0)),
LwVertex::new(Vector2::new(0.0, 100.0)),
];
let mut e = EntityType::LwPolyline(pl);
e.common_mut().handle = Handle::new(h);
e
}
fn advance_catchment_to_inlet(cmd: &mut TagCatchment, poly: EntityType) {
cmd.inject_picked_entity(poly);
let _ = cmd.on_entity_pick(Handle::new(1), Vec3::ZERO);
let _ = cmd.on_enter(); // RunoffC -> FlowLength
let _ = cmd.on_enter(); // FlowLength -> Slope
let _ = cmd.on_enter(); // Slope -> PickInlet
}
// ── Structure resolution (point pick engine) ────────────────────────────
#[test]
fn structure_at_point_returns_kind_label_and_center() {
let ents = vec![structure(1, NodeKind::Junction, 50.0, 25.0, 4.0)];
let pick = structure_at_point(ents.iter(), 50.0, 25.0, 5.0, true).unwrap();
assert_eq!(pick.handle, Handle::new(1));
assert_eq!(pick.label(), "Junction");
assert!((pick.x - 50.0).abs() < 1e-9);
assert!((pick.y - 25.0).abs() < 1e-9);
}
#[test]
fn structure_at_point_prefers_nearest_marker() {
let ents = vec![
structure(1, NodeKind::Inlet, 0.0, 0.0, 3.0),
structure(2, NodeKind::Inlet, 40.0, 0.0, 3.0),
];
let pick = structure_at_point(ents.iter(), 38.0, 0.0, 5.0, true).unwrap();
assert_eq!(pick.handle, Handle::new(2));
}
#[test]
fn catchment_pick_excludes_outfall() {
let ents = vec![structure(9, NodeKind::Outfall, 0.0, 0.0, 6.0)];
assert!(structure_at_point(ents.iter(), 0.0, 0.0, 20.0, false).is_none());
assert!(data::nearest_drainage_structure_at_point(ents.iter(), 0.0, 0.0, 20.0).is_none());
}
#[test]
fn pipe_pick_includes_outfall() {
let ents = vec![structure(9, NodeKind::Outfall, 0.0, 0.0, 6.0)];
let pick = structure_at_point(ents.iter(), 0.0, 0.0, 20.0, true).unwrap();
assert_eq!(pick.label(), "Outfall");
}
// ── C3D acquisition constants ───────────────────────────────────────────
#[test]
fn object_pick_not_in_osnap_palette() {
assert!(!ALL_SNAP_MODES.iter().any(|(t, _, _)| *t == SnapType::ObjectPick));
}
#[test]
fn pick_highlight_color_is_orange() {
assert!(WireModel::PICK_HIGHLIGHT[0] > 0.9);
assert!(WireModel::PICK_HIGHLIGHT[1] > 0.4);
assert!(WireModel::PICK_HIGHLIGHT[2] < 0.2);
}
#[test]
fn pipe_rubber_band_connects_start_to_cursor() {
let w = preview::pipe_run_rubber_band(10.0, 20.0, Vec3::new(50.0, 60.0, 0.0));
assert_eq!(w.points.len(), 2);
assert!((w.points[0][0] - 10.0).abs() < 1e-6);
assert!((w.points[1][0] - 50.0).abs() < 1e-6);
assert_eq!(w.color, WireModel::CYAN);
}
// ── Scene-integrated structure under cursor ─────────────────────────────
#[test]
fn scene_structure_under_cursor_at_marker_center() {
let mut scene = Scene::new();
let ent = structure(1, NodeKind::Inlet, 200.0, 150.0, 3.0);
scene.add_entity(ent);
let pick = preview::structure_under_cursor(&scene, 200.0, 150.0, true).unwrap();
assert_eq!(pick.label(), "Inlet");
let wires = preview::structure_acquire_previews(&scene, Vec3::new(200.0, 150.0, 0.0), true);
assert!(!wires.is_empty());
assert_eq!(wires[0].color, WireModel::PICK_HIGHLIGHT);
assert!(wires[0].line_weight_px >= 3.0);
}
// ── SS_CATCHMENT command flow ───────────────────────────────────────────
#[test]
fn catchment_uses_structure_pick_only_on_inlet_step() {
let mut cmd = TagCatchment::new();
assert!(cmd.needs_entity_pick());
assert!(!cmd.needs_structure_point_pick());
advance_catchment_to_inlet(&mut cmd, closed_poly(10));
assert!(!cmd.needs_entity_pick());
assert!(cmd.needs_structure_point_pick());
assert!(cmd.prompt().contains("orange snap"));
}
#[test]
fn catchment_prompt_includes_acquisition_hint() {
let mut cmd = TagCatchment::new();
advance_catchment_to_inlet(&mut cmd, closed_poly(10));
cmd.set_acquisition_hint(Some("Inlet"));
assert!(cmd.prompt().contains("[Inlet]"));
}
#[test]
fn catchment_explicit_inlet_writes_xdata_and_ends_command() {
let mut cmd = TagCatchment::new();
let poly = closed_poly(10);
advance_catchment_to_inlet(&mut cmd, poly);
match cmd.on_structure_pick(Handle::new(42), Vec3::new(10.0, 10.0, 0.0)) {
CmdResult::ReplaceMany(replacements, additions) => {
assert!(additions.is_empty());
assert_eq!(replacements.len(), 1);
let (_, ents) = &replacements[0];
let ent = &ents[0];
let rec = ent.common().extended_data.get_record(APP_CATCHMENT).unwrap();
assert!(matches!(&rec.values[3], XDataValue::Handle(h) if *h == Handle::new(42)));
}
other => panic!("expected ReplaceMany, got {:?}", std::mem::discriminant(&other)),
}
}
#[test]
fn catchment_enter_at_inlet_auto_assigns_nearest() {
let mut cmd = TagCatchment::new();
advance_catchment_to_inlet(&mut cmd, closed_poly(10));
match cmd.on_enter() {
CmdResult::ReplaceMany(replacements, _) => {
let ent = &replacements[0].1[0];
let rec = ent.common().extended_data.get_record(APP_CATCHMENT).unwrap();
assert!(matches!(&rec.values[3], XDataValue::Handle(h) if h.is_null()));
}
other => panic!("expected ReplaceMany, got {:?}", std::mem::discriminant(&other)),
}
}
#[test]
fn catchment_structure_pick_sets_flow_length_when_zero() {
let mut cmd = TagCatchment::new();
advance_catchment_to_inlet(&mut cmd, closed_poly(10));
match cmd.on_structure_pick(Handle::new(1), Vec3::new(0.0, 0.0, 0.0)) {
CmdResult::ReplaceMany(replacements, _) => {
let rec = replacements[0].1[0]
.common()
.extended_data
.get_record(APP_CATCHMENT)
.unwrap();
let flow = match &rec.values[1] {
XDataValue::Real(v) => *v,
_ => 0.0,
};
// Centroid (50,50) → inlet at (0,0) ≈ 70.7 ft
assert!(flow > 50.0, "expected auto flow length, got {flow}");
}
other => panic!("expected ReplaceMany, got {:?}", std::mem::discriminant(&other)),
}
}
// ── SS_PIPE command flow ────────────────────────────────────────────────
#[test]
fn pipe_uses_structure_point_pick() {
let cmd = PlacePipe::new();
assert!(!cmd.needs_entity_pick());
assert!(cmd.needs_structure_point_pick());
assert!(cmd.prompt().contains("orange snap"));
}
#[test]
fn pipe_end_prompt_references_start_structure_label() {
let mut cmd = PlacePipe::new();
cmd.set_acquisition_hint(Some("Inlet"));
cmd.on_structure_pick(Handle::new(1), Vec3::new(0.0, 0.0, 0.0));
assert!(cmd.prompt().contains("from Inlet"));
}
#[test]
fn pipe_commit_links_structures_in_xdata() {
let mut cmd = PlacePipe::new();
cmd.on_structure_pick(Handle::new(1), Vec3::new(0.0, 0.0, 0.0));
cmd.on_structure_pick(Handle::new(2), Vec3::new(100.0, 0.0, 0.0));
let mut cmd = PlacePipe::new();
cmd.on_structure_pick(Handle::new(1), Vec3::new(0.0, 0.0, 0.0));
if let CmdResult::CommitAndExit(e) = cmd.on_structure_pick(Handle::new(2), Vec3::new(100.0, 0.0, 0.0))
{
let rec = e.common().extended_data.get_record(APP_PIPE).unwrap();
assert!(matches!(&rec.values[2], XDataValue::Handle(h) if *h == Handle::new(1)));
assert!(matches!(&rec.values[3], XDataValue::Handle(h) if *h == Handle::new(2)));
} else {
panic!("second pick should commit pipe");
}
}
#[test]
fn pipe_preview_only_on_second_pick() {
let mut cmd = PlacePipe::new();
assert!(cmd.on_preview_wires(Vec3::new(10.0, 0.0, 0.0)).is_empty());
cmd.on_structure_pick(Handle::new(1), Vec3::ZERO);
assert_eq!(cmd.on_preview_wires(Vec3::new(10.0, 0.0, 0.0)).len(), 1);
}
// ── End-to-end: catchment + network assembly ────────────────────────────
#[test]
fn explicit_catchment_inlet_feeds_network_analysis() {
let mut ents = vec![
structure(1, NodeKind::Inlet, 0.0, 0.0, 3.0),
structure(2, NodeKind::Outfall, 100.0, 0.0, 6.0),
EntityType::Line(Line::from_points(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(100.0, 0.0, 0.0),
)),
];
ents[2].common_mut().handle = Handle::new(3);
ents[2]
.common_mut()
.extended_data
.add_record(data::pipe_xdata(1.5, 0.013, Handle::new(1), Handle::new(2)));
let mut poly = closed_poly(10);
poly.common_mut()
.extended_data
.add_record(catchment_xdata(0.8, 2500.0, 0.02, Handle::new(1)));
ents.push(poly);
let net = data::network_from_entities(ents.iter()).unwrap();
assert!(net.nodes[0].area_ac > 1.0, "catchment area should merge onto inlet");
assert!(
net.nodes[0].tc_inlet > 10.0,
"Kirpich tc should exceed default, got {}",
net.nodes[0].tc_inlet
);
}
}
+118
View File
@@ -0,0 +1,118 @@
// LandXML pipe-network import → storm-sewer drawing entities.
use std::collections::HashMap;
use acadrust::types::Vector3;
use acadrust::{Circle, EntityType, Handle, Line};
use stormsewer::io::landxml::{parse_landxml, LandXmlStruct};
use super::data;
use crate::plugin::host::HostSession;
fn structure_entity(s: &LandXmlStruct, radius: f64) -> EntityType {
let mut e = EntityType::Circle(Circle {
center: Vector3::new(s.x, s.y, 0.0),
radius,
..Default::default()
});
let (area, c) = if s.kind == stormsewer::network::NodeKind::Outfall {
(0.0, 0.0)
} else {
(s.area_ac, s.c)
};
e.common_mut()
.extended_data
.add_record(data::structure_xdata(s.kind, s.invert, s.rim, area, c));
e
}
fn pipe_entity(diameter: f64, n: f64, from: Handle, to: Handle, x0: f64, y0: f64, x1: f64, y1: f64) -> EntityType {
let mut e = EntityType::Line(Line::from_points(
Vector3::new(x0, y0, 0.0),
Vector3::new(x1, y1, 0.0),
));
e.common_mut()
.extended_data
.add_record(data::pipe_xdata(diameter, n, from, to));
e
}
/// Open a LandXML file dialog and return the file text.
pub fn pick_landxml_file() -> Option<Result<String, String>> {
let path = rfd::FileDialog::new()
.add_filter("LandXML", &["xml", "landxml"])
.set_title("Import LandXML pipe network")
.pick_file()?;
Some(std::fs::read_to_string(&path).map_err(|e| format!("cannot read {}: {e}", path.display())))
}
/// Import a LandXML document into the active drawing tab.
pub fn import_landxml(host: &mut HostSession<'_>, xml: &str) -> Result<String, String> {
let doc = parse_landxml(xml)?;
let net = doc.primary_network()?.clone();
if net.structures.is_empty() {
return Err("LandXML: no structures to import".into());
}
host.push_undo("SS_LANDXML");
let mut name_to_handle: HashMap<String, Handle> = HashMap::new();
let mut coord: HashMap<String, (f64, f64)> = HashMap::new();
for s in &net.structures {
let radius = match s.kind {
stormsewer::network::NodeKind::Outfall => 6.0,
stormsewer::network::NodeKind::Inlet => 3.0,
stormsewer::network::NodeKind::Junction => 4.0,
};
let ent = structure_entity(s, radius);
let h = host.add_entity(ent);
name_to_handle.insert(s.name.clone(), h);
coord.insert(s.name.clone(), (s.x, s.y));
}
let mut pipe_count = 0;
for p in &net.pipes {
let Some(&from_h) = name_to_handle.get(&p.from) else {
continue;
};
let Some(&to_h) = name_to_handle.get(&p.to) else {
continue;
};
let (x0, y0) = coord.get(&p.from).copied().unwrap_or((0.0, 0.0));
let (x1, y1) = coord.get(&p.to).copied().unwrap_or((0.0, 0.0));
host.add_entity(pipe_entity(p.diameter_ft, p.n, from_h, to_h, x0, y0, x1, y1));
pipe_count += 1;
}
if pipe_count == 0 {
return Err("LandXML: structures imported but no pipes could be connected — check StartStruct/EndStruct names.".into());
}
host.bump_geometry();
host.set_dirty();
Ok(format!(
"Imported LandXML \"{}\": {} structure(s), {} pipe(s).",
net.name,
net.structures.len(),
pipe_count
))
}
#[cfg(test)]
mod tests {
use stormsewer::io::landxml::parse_landxml;
const SAMPLE: &str = include_str!("../../../crates/stormsewer/examples/sample_landxml.xml");
#[test]
fn sample_xml_parses_three_structures() {
let doc = parse_landxml(SAMPLE).unwrap();
let net = doc.primary_network().unwrap();
assert_eq!(net.structures.len(), 3);
assert_eq!(net.pipes.len(), 2);
}
}
+16
View File
@@ -0,0 +1,16 @@
// Storm Sewer plugin identity (domain-specific; not part of `src/plugin/` runtime).
use crate::plugin::manifest::{ApiVersion, PluginManifest};
pub const PLUGIN_ID: &str = "opencad.storm_sewer";
pub static MANIFEST: PluginManifest = PluginManifest {
id: PLUGIN_ID,
name: "Storm Sewer",
version: "0.2.0",
description: "Gravity storm-drain network design and analysis",
api_version: ApiVersion::CURRENT,
ribbon_order: 50,
xdata_apps: &["STORMSEWER_STRUCT", "STORMSEWER_PIPE", "STORMSEWER_CATCHMENT"],
command_prefixes: &["SS_"],
};
+37 -10
View File
@@ -1,14 +1,24 @@
// Storm Sewer module — gravity storm-drain network design & analysis.
// Storm Sewer add-on (`opencad.storm_sewer`) — gravity storm-drain 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.
// 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};
@@ -21,7 +31,14 @@ inventory::submit!(crate::command::CommandRegistration {
"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",
]
@@ -32,6 +49,7 @@ 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");
@@ -63,6 +81,8 @@ impl CadModule for StormSewerModule {
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 ─────────────────
@@ -70,6 +90,10 @@ impl CadModule for StormSewerModule {
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)),
],
@@ -81,11 +105,11 @@ impl CadModule for StormSewerModule {
#[cfg(test)]
mod tests {
use super::*;
use crate::modules::registry;
use crate::plugin::all_ribbon_modules;
#[test]
fn module_is_registered_in_ribbon() {
let titles: Vec<&str> = registry::all_modules().iter().map(|m| m.title()).collect();
let titles: Vec<&str> = all_ribbon_modules().iter().map(|m| m.title()).collect();
assert!(titles.contains(&"Storm Sewer"), "ribbon tabs: {titles:?}");
}
@@ -99,7 +123,9 @@ mod tests {
}
}
}
for needed in ["SS_INLET", "SS_PIPE", "SS_ANALYZE", "SS_REPORT", "SS_PROFILE"] {
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:?}");
}
}
@@ -187,7 +213,8 @@ PIPE P2 N2 OUT 300 1.5 0.013
pipe,
];
let (annotations, report) = super::analysis::analyze_doc(ents.iter()).expect("analyze drawn net");
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}");
}
+137
View File
@@ -0,0 +1,137 @@
// Parse `SS_PARAMS` subcommands and update StormTabState.
use stormsewer::idf::IdfCurve;
use stormsewer::params::StormAnalysisParams;
use super::state::StormTabState;
fn parse_f64(s: &str) -> Result<f64, String> {
s.trim()
.replace(',', ".")
.parse::<f64>()
.map_err(|_| format!("`{s}` is not a number"))
}
/// Apply `SS_PARAMS …` tokens. Empty rest → show summary.
pub fn apply_params(state: &mut StormTabState, rest: &str) -> Result<String, String> {
let t: Vec<&str> = rest.split_whitespace().collect();
if t.is_empty() {
return Ok(format!("Storm params: {}", state.params.summary()));
}
let key = t[0].to_ascii_uppercase();
match key.as_str() {
"RP" | "RETURN" => {
let rp: u32 = t
.get(1)
.ok_or("SS_PARAMS RP needs return period years (e.g. SS_PARAMS RP 25)")?
.parse()
.map_err(|_| "return period must be an integer year")?;
state.params.idf.set_design_rp(rp);
if state.params.idf.curve(rp).is_none() {
// Seed a scaled curve from the design curve if missing.
let base = state.params.idf.design_curve();
let scale = (rp as f64 / 10.0).sqrt().max(1.0);
state.params.idf.set_curve(rp, IdfCurve::new(base.a * scale, base.b, base.c));
}
Ok(format!("Design return period set to {rp} yr."))
}
"IDF" => {
let (rp, ai) = if t.len() == 5 {
let rp: u32 = t[1].parse().map_err(|_| "IDF return period must be integer")?;
(rp, 2)
} else if t.len() == 4 {
(state.params.idf.design_rp, 1)
} else {
return Err("SS_PARAMS IDF [rp] <a> <b> <c> (e.g. SS_PARAMS IDF 60 10 0.8)".into());
};
let a = parse_f64(t[ai])?;
let b = parse_f64(t[ai + 1])?;
let c = parse_f64(t[ai + 2])?;
state.params.idf.set_curve(rp, IdfCurve::new(a, b, c));
state.params.idf.set_design_rp(rp);
Ok(format!("IDF for {rp}-yr set: i = {a}/(t+{b})^{c}"))
}
"TAILWATER" | "TW" => {
let v = t.get(1).map(|s| s.to_ascii_uppercase());
match v.as_deref() {
None => Err("SS_PARAMS TAILWATER <elev_ft> | NONE".into()),
Some("NONE" | "FREE") => {
state.params.hydraulics.tailwater = None;
Ok("Tailwater: free outfall.".into())
}
Some(s) => {
let elev = parse_f64(s)?;
state.params.hydraulics.tailwater = Some(elev);
Ok(format!("Tailwater elevation set to {elev:.2} ft."))
}
}
}
"MINTC" => {
let v = parse_f64(t.get(1).ok_or("SS_PARAMS MINTC <minutes>")?)?;
state.params.hydraulics.min_tc = v;
Ok(format!("Minimum Tc set to {v:.1} min."))
}
"JUNCTIONK" | "JK" => {
let v = parse_f64(t.get(1).ok_or("SS_PARAMS JUNCTIONK <k>")?)?;
state.params.hydraulics.junction_k = v;
Ok(format!("Junction loss K set to {v:.2}."))
}
"VMIN" => {
let v = parse_f64(t.get(1).ok_or("SS_PARAMS VMIN <ft/s>")?)?;
state.params.sizing.min_velocity = v;
Ok(format!("Minimum velocity set to {v:.2} ft/s."))
}
"VMAX" => {
let v = parse_f64(t.get(1).ok_or("SS_PARAMS VMAX <ft/s>")?)?;
state.params.sizing.max_velocity = v;
Ok(format!("Maximum velocity set to {v:.2} ft/s."))
}
"MAXFULL" | "PFULL" => {
let v = parse_f64(t.get(1).ok_or("SS_PARAMS MAXFULL <percent>")?)?;
state.params.sizing.max_pct_full = (v / 100.0).clamp(0.1, 1.0);
Ok(format!("Max % full set to {v:.0}%."))
}
"INLETLEN" | "GRATE" => {
let v = parse_f64(t.get(1).ok_or("SS_PARAMS INLETLEN <ft>")?)?;
state.params.inlet_grate_length_ft = v;
Ok(format!("Inlet grate length set to {v:.2} ft."))
}
"INLETD" | "CURBDEPTH" => {
let v = parse_f64(t.get(1).ok_or("SS_PARAMS INLETD <ft>")?)?;
state.params.inlet_flow_depth_ft = v;
Ok(format!("Inlet curb flow depth set to {v:.3} ft."))
}
"INLETS" | "GUTTERS" => {
let v = parse_f64(t.get(1).ok_or("SS_PARAMS INLETS <ft/ft>")?)?;
state.params.inlet_gutter_slope = v;
Ok(format!("Inlet gutter slope set to {v:.4} ft/ft."))
}
"RESET" => {
state.params = StormAnalysisParams::municipal();
Ok("Storm params reset to municipal defaults.".into())
}
_ => Err(format!(
"Unknown SS_PARAMS key `{key}`. Keys: RP, IDF, TAILWATER, MINTC, JUNCTIONK, VMIN, VMAX, MAXFULL, INLETLEN, INLETD, INLETS, RESET"
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sets_return_period() {
let mut s = StormTabState::default();
apply_params(&mut s, "RP 25").unwrap();
assert_eq!(s.params.idf.design_rp, 25);
}
#[test]
fn sets_idf_coefficients() {
let mut s = StormTabState::default();
apply_params(&mut s, "IDF 70 12 0.75").unwrap();
let c = s.params.idf.design_curve();
assert!((c.a - 70.0).abs() < 1e-9);
}
}
+24
View File
@@ -0,0 +1,24 @@
// Thin adapter: implements the generic `BuiltinPlugin` trait for Storm Sewer.
// Domain logic is in `dispatch.rs`; identity in `manifest.rs`; hook in `register.rs`.
use crate::plugin::host::{BuiltinPlugin, HostSession};
use crate::plugin::manifest::PluginManifest;
use super::dispatch;
use super::manifest;
pub struct StormSewerPlugin;
impl BuiltinPlugin for StormSewerPlugin {
fn manifest(&self) -> &'static PluginManifest {
&manifest::MANIFEST
}
fn ribbon(&self) -> Box<dyn crate::modules::CadModule> {
Box::new(super::StormSewerModule)
}
fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool {
dispatch::handle(host, cmd)
}
}
+16
View File
@@ -0,0 +1,16 @@
# Storm Sewer add-on metadata.
# Must stay in sync with manifest.rs (compile-time source of truth for the host).
[plugin]
id = "opencad.storm_sewer"
name = "Storm Sewer"
version = "0.2.0"
description = "Gravity storm-drain network design and analysis"
author = "Open CAD Studio contributors"
license = "GPL-3.0-only"
[opencad]
api_version = 1
ribbon_order = 50
command_prefixes = ["SS_"]
xdata_apps = ["STORMSEWER_STRUCT", "STORMSEWER_PIPE", "STORMSEWER_CATCHMENT"]
+96
View File
@@ -0,0 +1,96 @@
// C3D-style acquisition previews: orange structure highlight, pipe rubber-band.
use acadrust::{EntityType, Handle};
use glam::Vec3;
use crate::scene::{Scene, WireModel};
use super::data::{self, StructurePick};
pub const STRUCTURE_PICK_PAD_FT: f64 = 15.0;
/// Orange highlight wires for a structure under the cursor.
pub fn highlight_structure(scene: &Scene, pick: &StructurePick) -> Vec<WireModel> {
highlight_handles(scene, &[pick.handle], WireModel::PICK_HIGHLIGHT, 3.0)
}
/// Highlight any entity (e.g. catchment polyline) in acquisition color.
pub fn highlight_entity(scene: &Scene, handle: Handle) -> Vec<WireModel> {
highlight_handles(scene, &[handle], WireModel::PICK_HIGHLIGHT, 2.5)
}
/// Dim orange fill on closed catchment while hovering.
pub fn highlight_catchment_poly(scene: &Scene, handle: Handle) -> Vec<WireModel> {
let mut wires = highlight_handles(scene, &[handle], WireModel::PICK_HIGHLIGHT_DIM, 2.0);
for w in &mut wires {
w.line_weight_px = 2.0;
}
wires
}
pub fn highlight_handles(
scene: &Scene,
handles: &[Handle],
color: [f32; 4],
line_weight_px: f32,
) -> Vec<WireModel> {
let mut out = scene.wire_models_for(handles);
for w in &mut out {
w.color = color;
w.line_weight_px = line_weight_px;
}
out
}
/// Resolve the structure under the cursor for storm network commands.
pub fn structure_under_cursor(
scene: &Scene,
x: f64,
y: f64,
catchment_inlet_only: bool,
) -> Option<StructurePick> {
data::structure_at_point(
scene.document.entities(),
x,
y,
STRUCTURE_PICK_PAD_FT,
!catchment_inlet_only,
)
}
/// Cyan rubber-band from a fixed structure center to the cursor (pipe run preview).
pub fn pipe_run_rubber_band(from_x: f64, from_y: f64, to: Vec3) -> WireModel {
WireModel::solid(
"__ss_pipe_preview__".into(),
vec![
[from_x as f32, from_y as f32, 0.0],
[to.x, to.y, to.z],
],
WireModel::CYAN,
false,
)
}
/// Extra preview wires for storm-sewer structure acquisition at `cursor`.
pub fn structure_acquire_previews(
scene: &Scene,
cursor: Vec3,
catchment_inlet_only: bool,
) -> Vec<WireModel> {
let Some(pick) = structure_under_cursor(scene, cursor.x as f64, cursor.y as f64, catchment_inlet_only)
else {
return vec![];
};
highlight_structure(scene, &pick)
}
/// Closed polyline under cursor (catchment step 1).
pub fn catchment_poly_under_cursor(scene: &Scene, handle: Handle) -> Vec<WireModel> {
let Some(ent) = scene.document.get_entity(handle) else {
return vec![];
};
if !matches!(ent, EntityType::LwPolyline(pl) if pl.is_closed) {
return vec![];
}
highlight_catchment_poly(scene, handle)
}
+10
View File
@@ -0,0 +1,10 @@
// Compile-time registration with the generic plugin host.
// Keep this file free of storm-sewer logic — only the hook.
use super::plugin::StormSewerPlugin;
inventory::submit! {
crate::plugin::registry::PluginRegistration {
construct: || Box::new(StormSewerPlugin),
}
}
+132
View File
@@ -0,0 +1,132 @@
// Apply storm-sewer pipe sizing to the drawn network in the active document.
use acadrust::{EntityType, Handle};
use stormsewer::design::SizeOutcome;
use stormsewer::params::StormAnalysisParams;
use stormsewer::report::format_sizing;
use super::data;
/// A pipe diameter change to apply in the document.
#[derive(Clone, Debug, PartialEq)]
pub struct PipeDiameterUpdate {
pub handle: Handle,
pub new_diameter_ft: f64,
}
/// Size pipes from drawn entities without modifying the document.
pub fn size_doc_report<'a>(
entities: impl Iterator<Item = &'a EntityType>,
params: &StormAnalysisParams,
) -> Result<String, String> {
let (_, report, _) = plan_size_updates(entities, params)?;
Ok(report)
}
/// Compute sizing recommendations and the list of pipe updates to apply.
pub fn plan_size_updates<'a>(
entities: impl Iterator<Item = &'a EntityType>,
params: &StormAnalysisParams,
) -> Result<(Vec<PipeDiameterUpdate>, String, usize), String> {
let drawn = data::drawn_network_from_entities(entities)?;
let (_, recs) = drawn
.network
.analyze_and_size_params(params)
.map_err(|e| e.to_string())?;
let mut updates = Vec::new();
for (handle, rec) in drawn.pipe_handles.iter().zip(recs.iter()) {
if rec.outcome == SizeOutcome::NoSolution {
continue;
}
if (rec.recommended_diameter_ft - rec.current_diameter_ft).abs() < 1e-6 {
continue;
}
updates.push(PipeDiameterUpdate {
handle: *handle,
new_diameter_ft: rec.recommended_diameter_ft,
});
}
let report = format_sizing(&recs);
let pending = updates.len();
Ok((updates, report, pending))
}
/// Write planned diameter updates onto matching pipe entities.
pub fn apply_updates<'a>(entities: impl Iterator<Item = &'a mut EntityType>, updates: &[PipeDiameterUpdate]) -> usize {
let mut applied = 0usize;
for e in entities {
let h = e.common().handle;
if let Some(u) = updates.iter().find(|u| u.handle == h) {
if data::set_pipe_diameter(e, u.new_diameter_ft) {
applied += 1;
}
}
}
applied
}
#[cfg(test)]
mod tests {
use super::*;
use acadrust::types::Vector3;
use acadrust::{Circle, Line};
use stormsewer::network::NodeKind;
fn mk_struct(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(data::structure_xdata(kind, invert, invert + 6.0, 2.0, 0.7));
e
}
fn mk_pipe(from: u64, to: u64, x1: f64, x2: f64, dia: 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().handle = Handle::new(from + 100);
e.common_mut()
.extended_data
.add_record(data::pipe_xdata(dia, 0.013, Handle::new(from), Handle::new(to)));
e
}
#[test]
fn plan_size_finds_undersized_trunk() {
let ents = vec![
mk_struct(1, NodeKind::Inlet, 0.0, 100.0),
mk_struct(2, NodeKind::Inlet, 100.0, 99.0),
mk_struct(3, NodeKind::Outfall, 200.0, 98.0),
mk_pipe(1, 2, 0.0, 100.0, 1.5),
mk_pipe(2, 3, 100.0, 200.0, 1.5),
];
let p = stormsewer::params::StormAnalysisParams::municipal();
let (updates, report, pending) = plan_size_updates(ents.iter(), &p).expect("size");
assert!(pending >= 1, "report:\n{report}");
assert!(!updates.is_empty());
}
#[test]
fn apply_updates_writes_xdata() {
let mut ents = vec![
mk_struct(1, NodeKind::Inlet, 0.0, 100.0),
mk_struct(2, NodeKind::Outfall, 200.0, 98.0),
mk_pipe(1, 2, 0.0, 200.0, 0.5),
];
let p = stormsewer::params::StormAnalysisParams::municipal();
let (updates, _, _) = plan_size_updates(ents.iter(), &p).expect("plan");
let applied = apply_updates(ents.iter_mut(), &updates);
assert!(applied >= 1);
let drawn = data::drawn_network_from_entities(ents.iter()).unwrap();
assert!(drawn.network.pipes[0].diameter > 0.5);
}
}
+26
View File
@@ -0,0 +1,26 @@
// Per-document storm-sewer parameters (stored in host tab plugin_state).
use stormsewer::params::StormAnalysisParams;
#[derive(Clone, Debug, PartialEq)]
pub struct StormTabState {
pub params: StormAnalysisParams,
}
impl Default for StormTabState {
fn default() -> Self {
Self {
params: StormAnalysisParams::municipal(),
}
}
}
impl StormTabState {
pub fn params(&self) -> &StormAnalysisParams {
&self.params
}
pub fn params_mut(&mut self) -> &mut StormAnalysisParams {
&mut self.params
}
}
+78 -17
View File
@@ -15,7 +15,9 @@ use glam::Vec3;
use stormsewer::network::NodeKind;
use super::data;
use crate::command::{CadCommand, CmdResult};
use super::preview;
use crate::command::{CadCommand, CmdResult, ObjectPickHit};
use crate::scene::{Scene, WireModel};
fn parse_num(text: &str) -> Option<f64> {
text.trim().replace(',', ".").parse::<f64>().ok()
@@ -113,8 +115,6 @@ impl CadCommand for PlaceStructure {
None
}
fn on_point(&mut self, pt: Vec3) -> CmdResult {
// A click always places the structure, using whatever values have been
// entered (remaining fields keep their defaults).
self.commit(pt.x as f64, pt.y as f64)
}
fn on_enter(&mut self) -> CmdResult {
@@ -135,11 +135,21 @@ pub struct PlacePipe {
n: f64,
start_handle: Option<Handle>,
start_xy: (f64, f64),
start_label: Option<String>,
acquire_hint: Option<String>,
}
impl PlacePipe {
pub fn new() -> Self {
Self { step: PStep::PickStart, diameter: 1.25, n: 0.013, start_handle: None, start_xy: (0.0, 0.0) }
Self {
step: PStep::PickStart,
diameter: 1.25,
n: 0.013,
start_handle: None,
start_xy: (0.0, 0.0),
start_label: None,
acquire_hint: None,
}
}
fn commit(&self, end_handle: Handle, ex: f64, ey: f64) -> CmdResult {
let line = Line::from_points(
@@ -164,25 +174,69 @@ impl CadCommand for PlacePipe {
"SS_PIPE"
}
fn prompt(&self) -> String {
let hint = self
.acquire_hint
.as_deref()
.map(|h| format!(" [{h}]"))
.unwrap_or_default();
match self.step {
PStep::PickStart => "Pipe: click the START structure:".into(),
PStep::PickEnd => format!("Pipe: click the END structure (dia {:.2} ft, n {:.3}):", self.diameter, self.n),
PStep::PickStart => format!("Pipe run: click START structure (orange snap){hint}:"),
PStep::PickEnd => {
let from = self
.start_label
.as_deref()
.unwrap_or("structure");
format!(
"Pipe run: click END from {from} (dia {:.2} ft, n {:.3}){hint}:",
self.diameter, self.n
)
}
}
}
fn needs_entity_pick(&self) -> bool {
false
}
fn needs_structure_point_pick(&self) -> bool {
true
}
fn on_entity_pick(&mut self, handle: Handle, pt: Vec3) -> CmdResult {
fn resolve_object_pick(&self, scene: &Scene, x: f64, y: f64) -> Option<ObjectPickHit> {
let pick = preview::structure_under_cursor(scene, x, y, false)?;
Some(ObjectPickHit {
handle: pick.handle,
x: pick.x,
y: pick.y,
label: pick.label(),
})
}
fn object_pick_hover_previews(&self, scene: &Scene, cursor: Vec3) -> Vec<WireModel> {
preview::structure_acquire_previews(scene, cursor, false)
}
fn object_pick_miss_message(&self) -> &'static str {
"No storm structure near click — move closer or press Enter for nearest."
}
fn set_acquisition_hint(&mut self, hint: Option<&str>) {
self.acquire_hint = hint.map(str::to_string);
}
fn on_structure_pick(&mut self, handle: Handle, pt: Vec3) -> CmdResult {
match self.step {
PStep::PickStart => {
self.start_handle = Some(handle);
self.start_xy = (pt.x as f64, pt.y as f64);
self.start_label = self.acquire_hint.clone();
self.acquire_hint = None;
self.step = PStep::PickEnd;
CmdResult::NeedPoint
}
PStep::PickEnd => self.commit(handle, pt.x as f64, pt.y as f64),
}
}
fn on_preview_wires(&mut self, pt: Vec3) -> Vec<WireModel> {
if matches!(self.step, PStep::PickEnd) {
vec![preview::pipe_run_rubber_band(self.start_xy.0, self.start_xy.1, pt)]
} else {
vec![]
}
}
fn on_point(&mut self, _pt: Vec3) -> CmdResult {
CmdResult::NeedPoint
}
@@ -197,7 +251,6 @@ mod tests {
#[test]
fn click_places_structure_with_defaults() {
// A click commits immediately (no typed values needed).
let mut cmd = PlaceStructure::inlet();
match cmd.on_point(Vec3::new(10.0, 20.0, 0.0)) {
CmdResult::CommitAndExit(EntityType::Circle(c)) => {
@@ -212,20 +265,19 @@ mod tests {
#[test]
fn typed_values_are_captured_then_click_places() {
let mut cmd = PlaceStructure::inlet();
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
assert!(cmd.on_text_input("0.8").is_none()); // C -> Ready
assert!(matches!(cmd.step, SStep::Ready));
assert!(cmd.on_text_input("104").is_none());
assert!(cmd.on_text_input("110").is_none());
assert!(cmd.on_text_input("2.0").is_none());
assert!(cmd.on_text_input("0.8").is_none());
assert!(matches!(cmd.on_point(Vec3::ZERO), CmdResult::CommitAndExit(_)));
}
#[test]
fn pipe_connects_two_structures_on_two_clicks() {
let mut cmd = PlacePipe::new();
assert!(cmd.needs_entity_pick());
assert!(matches!(cmd.on_entity_pick(Handle::new(1), Vec3::new(0.0, 0.0, 0.0)), CmdResult::NeedPoint));
match cmd.on_entity_pick(Handle::new(2), Vec3::new(100.0, 0.0, 0.0)) {
assert!(cmd.needs_structure_point_pick());
assert!(matches!(cmd.on_structure_pick(Handle::new(1), Vec3::new(0.0, 0.0, 0.0)), CmdResult::NeedPoint));
match cmd.on_structure_pick(Handle::new(2), Vec3::new(100.0, 0.0, 0.0)) {
CmdResult::CommitAndExit(EntityType::Line(l)) => {
assert_eq!(l.start.x, 0.0);
assert_eq!(l.end.x, 100.0);
@@ -235,4 +287,13 @@ mod tests {
_ => panic!("expected CommitAndExit(Line)"),
}
}
}
#[test]
fn pipe_preview_rubber_band_on_second_step() {
let mut cmd = PlacePipe::new();
cmd.on_structure_pick(Handle::new(1), Vec3::new(0.0, 0.0, 0.0));
let wires = cmd.on_preview_wires(Vec3::new(50.0, 0.0, 0.0));
assert_eq!(wires.len(), 1);
assert_eq!(wires[0].points.len(), 2);
}
}
+61
View File
@@ -0,0 +1,61 @@
// Visual feedback for analysis results (surcharged pipes, flooded structures).
use acadrust::types::Color;
use acadrust::{EntityType, Handle};
use stormsewer::network::Analysis;
use super::data::DrawnNetwork;
fn color_surcharged_pipe() -> Color {
Color::from_index(1)
}
fn color_flooded_struct() -> Color {
Color::from_index(6)
}
/// Build handle → color assignments from an analysis result.
pub fn style_assignments(drawn: &DrawnNetwork, analysis: &Analysis) -> Vec<(Handle, Color)> {
let mut out = Vec::new();
for (handle, pr) in drawn.pipe_handles.iter().zip(analysis.pipes.iter()) {
if pr.surcharged {
out.push((*handle, color_surcharged_pipe()));
}
}
for (handle, nr) in drawn.node_handles.iter().zip(analysis.nodes.iter()) {
if nr.surcharge_to_surface {
out.push((*handle, color_flooded_struct()));
}
}
out
}
/// Apply handle → color assignments to drawing entities.
pub fn apply_colors<'a>(
entities: impl Iterator<Item = &'a mut EntityType>,
assignments: &[(Handle, Color)],
) -> usize {
let mut applied = 0usize;
for e in entities {
let h = e.common().handle;
if let Some((_, color)) = assignments.iter().find(|(handle, _)| *handle == h) {
e.common_mut().color = *color;
applied += 1;
}
}
applied
}
/// Color-code pipes and structures from an analysis result.
pub fn apply_analysis_style<'a>(
entities: impl Iterator<Item = &'a mut EntityType>,
drawn: &DrawnNetwork,
analysis: &Analysis,
) -> (usize, usize) {
let assignments = style_assignments(drawn, analysis);
let surcharged = analysis.pipes.iter().filter(|p| p.surcharged).count();
let flooded = analysis.nodes.iter().filter(|n| n.surcharge_to_surface).count();
let _ = apply_colors(entities, &assignments);
(surcharged, flooded)
}
+17
View File
@@ -0,0 +1,17 @@
// Plugin traits — HostSession lives in `app::plugin_host` (same-crate field access).
pub(crate) use crate::app::plugin_host::HostSession;
use crate::modules::CadModule;
use super::manifest::PluginManifest;
/// Add-on package entry point (phase 1: in-tree, in-process).
///
/// One `PluginRegistration` per package — ribbon tab, manifest, and command
/// dispatch are owned here. See `docs/plugin-architecture.md`.
pub trait BuiltinPlugin: Send + Sync {
fn manifest(&self) -> &'static PluginManifest;
fn ribbon(&self) -> Box<dyn CadModule>;
fn dispatch(&self, host: &mut HostSession<'_>, cmd: &str) -> bool;
}
+32
View File
@@ -0,0 +1,32 @@
// Plugin identity and capability declaration.
/// Host plugin API version. Bump when HostApi breaks compatibility.
pub const API_VERSION: u32 = 1;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ApiVersion {
pub major: u32,
}
impl ApiVersion {
pub const CURRENT: Self = Self { major: API_VERSION };
pub fn is_compatible_with(host: ApiVersion) -> bool {
Self::CURRENT.major == host.major
}
}
/// Static metadata every plugin supplies at registration time.
/// Keep fields in sync with `plugin.toml` beside the package.
#[derive(Clone, Copy, Debug)]
pub struct PluginManifest {
pub id: &'static str,
pub name: &'static str,
pub version: &'static str,
pub description: &'static str,
pub api_version: ApiVersion,
/// Sort key for add-on ribbon tabs (lower = further left among plugins).
pub ribbon_order: i32,
pub xdata_apps: &'static [&'static str],
pub command_prefixes: &'static [&'static str],
}
+13
View File
@@ -0,0 +1,13 @@
// Open CAD Studio plugin runtime (phase 1: built-in, in-process).
//
// Generic host only — no domain logic. See `docs/plugin-architecture.md`.
// Domain plugins (e.g. storm_sewer) live under `src/modules/<name>/` and
// register here via `inventory::submit!(PluginRegistration { … })`.
pub mod host;
pub mod manifest;
pub mod registry;
pub use registry::all_ribbon_modules;
pub(crate) use host::BuiltinPlugin;
pub(crate) use registry::try_dispatch;
+42
View File
@@ -0,0 +1,42 @@
// Compile-time plugin registry via `inventory`.
use super::host::{BuiltinPlugin, HostSession};
use crate::app::OpenCADStudio;
use crate::modules::{registry as core_registry, CadModule};
pub struct PluginRegistration {
pub construct: fn() -> Box<dyn BuiltinPlugin>,
}
inventory::collect!(PluginRegistration);
/// Construct every registered built-in plugin (once per process).
pub fn all_plugins() -> Vec<Box<dyn BuiltinPlugin>> {
inventory::iter::<PluginRegistration>
.into_iter()
.map(|r| (r.construct)())
.collect()
}
/// Core ribbon tabs plus add-on tabs (sorted by `manifest.ribbon_order`).
pub fn all_ribbon_modules() -> Vec<Box<dyn CadModule>> {
let mut core = core_registry::all_modules();
let mut addons: Vec<(i32, Box<dyn CadModule>)> = all_plugins()
.into_iter()
.map(|p| (p.manifest().ribbon_order, p.ribbon()))
.collect();
addons.sort_by_key(|(order, _)| *order);
core.extend(addons.into_iter().map(|(_, ribbon)| ribbon));
core
}
/// Try each plugin until one handles `cmd`. Returns true if handled.
pub(crate) fn try_dispatch(app: &mut OpenCADStudio, tab: usize, cmd: &str) -> bool {
let mut host = HostSession::new(app, tab);
for plugin in all_plugins() {
if plugin.dispatch(&mut host, cmd) {
return true;
}
}
false
}
+3
View File
@@ -79,6 +79,9 @@ impl WireModel {
pub const WHITE: [f32; 4] = [1.00, 1.00, 1.00, 1.0];
pub const CYAN: [f32; 4] = [0.25, 0.85, 1.00, 1.0];
pub const SELECTED: [f32; 4] = [0.15, 0.55, 1.00, 1.0];
/// Civil 3D-style object acquisition highlight (structure / catchment hover).
pub const PICK_HIGHLIGHT: [f32; 4] = [0.95, 0.50, 0.08, 1.0];
pub const PICK_HIGHLIGHT_DIM: [f32; 4] = [0.95, 0.50, 0.08, 0.45];
/// Sentinel AABB that never rejects any snap query.
pub const UNBOUNDED_AABB: [f32; 4] = [
f32::NEG_INFINITY,
+2
View File
@@ -30,6 +30,8 @@ pub enum SnapType {
ApparentIntersection,
Parallel,
Grid,
/// C3D-style object acquisition (storm structure / network pick) — orange marker.
ObjectPick,
}
/// Ordered list used by the popup and snap engine.
+27 -8
View File
@@ -500,18 +500,37 @@ impl canvas::Program<Message> for SelectionCanvas {
// ── Snap marker ───────────────────────────────────────────────────
if let Some((sp, snap_type)) = self.snap {
let yellow = Color {
r: 1.0,
g: 0.9,
b: 0.1,
a: 1.0,
let (r, g, b) = if snap_type == SnapType::ObjectPick {
(0.95_f32, 0.50, 0.08) // C3D-style orange object snap
} else {
(1.0, 0.9, 0.1) // classic yellow OSNAP
};
let marker = Color { r, g, b, a: 1.0 };
let stroke = canvas::Stroke {
width: 1.5,
style: canvas::Style::Solid(yellow),
width: if snap_type == SnapType::ObjectPick { 2.0 } else { 1.5 },
style: canvas::Style::Solid(marker),
..Default::default()
};
match snap_type {
SnapType::ObjectPick => {
// Target box + center dot (Civil 3D acquisition glyph).
let h = 7.0_f32;
let rect = canvas::Path::rectangle(
Point::new(sp.x - h, sp.y - h),
Size::new(h * 2.0, h * 2.0),
);
frame.stroke(&rect, stroke.clone());
let r = 3.0_f32;
frame.fill(
&canvas::Path::circle(sp, r),
Color {
r: 0.95,
g: 0.50,
b: 0.08,
a: 0.85,
},
);
}
SnapType::Endpoint => {
let h = 5.0_f32;
let rect = canvas::Path::rectangle(
@@ -655,7 +674,7 @@ impl canvas::Program<Message> for SelectionCanvas {
let r = 1.4_f32;
for k in [-7.0_f32, 0.0, 7.0] {
let dot = canvas::Path::circle(Point::new(sp.x + k, sp.y), r);
frame.fill(&dot, yellow);
frame.fill(&dot, marker);
}
}
SnapType::Parallel => {
+2 -2
View File
@@ -13,8 +13,8 @@ use iced::widget::{button, column, container, mouse_area, row, scrollable, svg,
use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme};
use crate::app::Message;
use crate::modules::registry;
use crate::modules::{CadModule, IconKind, RibbonItem};
use crate::plugin::all_ribbon_modules;
use crate::ui::properties::{color_picker_dropdown, lw_options, LinetypeItem};
mod widgets;
@@ -67,7 +67,7 @@ pub struct LayerInfo {
impl Ribbon {
pub fn new() -> Self {
Self {
modules: registry::all_modules(),
modules: all_ribbon_modules(),
active: 0,
active_tool: None,
wireframe: false,