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:
parent
1199895f0e
commit
e46701456f
66 changed files with 4538 additions and 141 deletions
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,5 +1,6 @@
|
|||
mod cmd_result;
|
||||
mod commands;
|
||||
pub mod plugin_host;
|
||||
mod document;
|
||||
mod helpers;
|
||||
mod history;
|
||||
|
|
|
|||
94
src/app/plugin_host.rs
Normal file
94
src/app/plugin_host.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue