Replace the custom model_tiles tiling with iced 0.14 pane_grid as the layout source of truth, while paper-space floating viewports are left untouched (they overlap/free-place, which pane_grid cannot model). Each Model pane is its own shader widget rendering its camera into the pane bounds; the renderer Primitive carries a base_slot so several shader widgets share the type-keyed MultiPipeline storage on disjoint slots. A second pane_grid of transparent mouse_areas (plus on_resize) layers above the crosshair overlay to receive input — the overlay's Hidden cursor interaction otherwise levitates the cursor and starves any layer beneath it. The active pane follows the cursor (hover-activate), and grid / crosshair / UCS icon render per pane while the ViewCube and render-mode picker stay on the active pane only. Split / close / VPORTS presets and the VPort table round-trip now drive pane_grid (guillotine reconstruction on load). Adds drag-to-swap via a controls bar handle with a ghost-card preview, visible pane dividers from the pane_grid split regions, a default model background of RGB(33,40,48), and removes all the superseded custom-tile code. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
900 lines
45 KiB
Rust
900 lines
45 KiB
Rust
use super::*;
|
||
|
||
impl OpenCADStudio {
|
||
pub(super) fn dispatch_view(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
|
||
match cmd {
|
||
"HELP" | "?" => {
|
||
self.command_line.push_output(
|
||
"Draw: LINE CIRCLE ARC PLINE RECTANG(RECT) POLYGON(POLY) POINT ELLIPSE SPLINE RAY XLINE HATCH DONUT REVCLOUD WIPEOUT MLINE ATTDEF | \
|
||
Modify: MOVE COPY ROTATE SCALE MIRROR ERASE OFFSET EXTEND FILLET CHAMFER STRETCH EXPLODE TRIM BREAK JOIN LENGTHEN ALIGN PEDIT | \
|
||
Array: ARRAY ARRAYRECT ARRAYPOLAR ARRAYPATH | \
|
||
Text: TEXT MTEXT LEADER MLEADER | \
|
||
Dimension: DIMLINEAR DIMALIGNED DIMANGULAR DIMRADIUS DIMDIAMETER DIMCONTINUE DIMBASELINE | \
|
||
Annotation: TOLERANCE | \
|
||
Inquiry: DIST ID AREA LIST FIND FINDALL COUNT QSELECT | Draw on entity: DIVIDE MEASURE | \
|
||
Attributes: ATTEDIT ATTDISP | \
|
||
Utilities: FLATTEN LAYISO LAYUNISO | \
|
||
View: ZOOM EXTENTS ZOOM WINDOW VIEW LIST/SAVE/RESTORE/DELETE | \
|
||
Layer: LAYER LIST/NEW/ON/OFF/FREEZE/THAW/LOCK/UNLOCK/COLOR/SET | \
|
||
Viewport: MVIEW VPLAYER VPORTS MS PS DRAWORDER | \
|
||
Tables: STYLE DIMSTYLE LINETYPE UCS RENAME PURGE | \
|
||
File: NEW OPEN SAVE SAVEAS PRINT PURGE UNDO REDO"
|
||
);
|
||
}
|
||
|
||
"DONATE" => {
|
||
crate::sys::open_url("https://patreon.com/HakanSeven12");
|
||
self.command_line.push_info("Opening Patreon page...");
|
||
}
|
||
|
||
"WEBVERSION" => {
|
||
crate::sys::open_url("https://hakanseven12.github.io/OpenCADStudio/");
|
||
self.command_line.push_info("Opening OCS Web...");
|
||
}
|
||
|
||
// ── DWGPROPS — print round-trip-only HeaderVariables ─────────
|
||
// No UI dialog for these yet; the command surfaces them so
|
||
// users can confirm the values that the parser populated and
|
||
// the writer will round-trip on save.
|
||
"DWGPROPS" | "DWGPROP" => {
|
||
let i = self.active_tab;
|
||
let h = &self.tabs[i].scene.document.header;
|
||
let path_label = self.tabs[i]
|
||
.current_path
|
||
.as_ref()
|
||
.map(|p| p.display().to_string())
|
||
.unwrap_or_else(|| "(unsaved)".to_string());
|
||
self.command_line
|
||
.push_output(&format!("Drawing: {}", path_label));
|
||
self.command_line.push_output(&format!(
|
||
" Created (Julian): {:.6}",
|
||
h.create_date_julian
|
||
));
|
||
self.command_line.push_output(&format!(
|
||
" Updated (Julian): {:.6}",
|
||
h.update_date_julian
|
||
));
|
||
self.command_line.push_output(&format!(
|
||
" Total edit time: {:.4}",
|
||
h.total_editing_time
|
||
));
|
||
self.command_line.push_output(&format!(
|
||
" User elapsed: {:.4}",
|
||
h.user_elapsed_time
|
||
));
|
||
self.command_line.push_output(&format!(
|
||
" Last saved by: {}",
|
||
if h.last_saved_by.is_empty() {
|
||
"(unknown)"
|
||
} else {
|
||
&h.last_saved_by
|
||
}
|
||
));
|
||
self.command_line.push_output(&format!(
|
||
" Fingerprint GUID: {}",
|
||
if h.fingerprint_guid.is_empty() {
|
||
"(none)"
|
||
} else {
|
||
&h.fingerprint_guid
|
||
}
|
||
));
|
||
self.command_line.push_output(&format!(
|
||
" Version GUID: {}",
|
||
if h.version_guid.is_empty() {
|
||
"(none)"
|
||
} else {
|
||
&h.version_guid
|
||
}
|
||
));
|
||
self.command_line
|
||
.push_output(&format!(" Code page: {}", h.code_page));
|
||
self.command_line.push_output(&format!(
|
||
" Menu name: {}",
|
||
if h.menu_name.is_empty() {
|
||
"(none)"
|
||
} else {
|
||
&h.menu_name
|
||
}
|
||
));
|
||
self.command_line.push_output(&format!(
|
||
" Hyperlink base: {}",
|
||
if h.hyperlink_base.is_empty() {
|
||
"(none)"
|
||
} else {
|
||
&h.hyperlink_base
|
||
}
|
||
));
|
||
self.command_line.push_output(&format!(
|
||
" Project name: {}",
|
||
if h.project_name.is_empty() {
|
||
"(none)"
|
||
} else {
|
||
&h.project_name
|
||
}
|
||
));
|
||
self.command_line.push_output(&format!(
|
||
" Stylesheet: {}",
|
||
if h.stylesheet.is_empty() {
|
||
"(none)"
|
||
} else {
|
||
&h.stylesheet
|
||
}
|
||
));
|
||
self.command_line.push_output(&format!(
|
||
" Required versions: {:#018x}",
|
||
h.required_versions
|
||
));
|
||
self.command_line.push_output(&format!(
|
||
" Measurement: {} ({})",
|
||
h.measurement,
|
||
if h.measurement == 1 { "Metric" } else { "Imperial" }
|
||
));
|
||
self.command_line.push_output(&format!(
|
||
" Proxy graphics: {}",
|
||
h.proxy_graphics
|
||
));
|
||
self.command_line
|
||
.push_output(&format!(" Tree depth: {}", h.tree_depth));
|
||
self.command_line.push_output(&format!(
|
||
" User vars (int): {} {} {} {} {}",
|
||
h.user_int1, h.user_int2, h.user_int3, h.user_int4, h.user_int5
|
||
));
|
||
self.command_line.push_output(&format!(
|
||
" User vars (real): {:.6} {:.6} {:.6} {:.6} {:.6}",
|
||
h.user_real1, h.user_real2, h.user_real3, h.user_real4, h.user_real5
|
||
));
|
||
self.command_line.push_output(&format!(
|
||
" User timer: {}",
|
||
if h.user_timer { "On" } else { "Off" }
|
||
));
|
||
}
|
||
|
||
// Edit a USERI1..USERI5 / USERR1..USERR5 slot. Lets the user
|
||
// store drawing-scoped scalars (and save them through round-trip)
|
||
// even though we don't have a LISP / DIESEL runtime yet.
|
||
// USERI 1 42 → header.user_int1 = 42
|
||
// USERR 3 1.5e-3 → header.user_real3 = 0.0015
|
||
cmd if cmd.starts_with("USERI") || cmd.starts_with("USERR") => {
|
||
let is_real = cmd.starts_with("USERR");
|
||
let rest = if is_real {
|
||
cmd.trim_start_matches("USERR").trim()
|
||
} else {
|
||
cmd.trim_start_matches("USERI").trim()
|
||
};
|
||
let parts: Vec<&str> = rest.splitn(2, ' ').collect();
|
||
let slot: Option<usize> = parts.first().and_then(|s| s.parse().ok());
|
||
let value = parts.get(1).copied().unwrap_or("").trim();
|
||
let i = self.active_tab;
|
||
let h = &mut self.tabs[i].scene.document.header;
|
||
match (slot, value, is_real) {
|
||
(Some(n @ 1..=5), v, true) => {
|
||
if let Ok(val) = v.parse::<f64>() {
|
||
match n {
|
||
1 => h.user_real1 = val,
|
||
2 => h.user_real2 = val,
|
||
3 => h.user_real3 = val,
|
||
4 => h.user_real4 = val,
|
||
_ => h.user_real5 = val,
|
||
}
|
||
self.tabs[i].dirty = true;
|
||
self.command_line
|
||
.push_output(&format!("USERR{n} = {val}"));
|
||
} else {
|
||
self.command_line
|
||
.push_info("Usage: USERR <1-5> <real>");
|
||
}
|
||
}
|
||
(Some(n @ 1..=5), v, false) => {
|
||
if let Ok(val) = v.parse::<i16>() {
|
||
match n {
|
||
1 => h.user_int1 = val,
|
||
2 => h.user_int2 = val,
|
||
3 => h.user_int3 = val,
|
||
4 => h.user_int4 = val,
|
||
_ => h.user_int5 = val,
|
||
}
|
||
self.tabs[i].dirty = true;
|
||
self.command_line
|
||
.push_output(&format!("USERI{n} = {val}"));
|
||
} else {
|
||
self.command_line
|
||
.push_info("Usage: USERI <1-5> <integer>");
|
||
}
|
||
}
|
||
_ => self
|
||
.command_line
|
||
.push_info("Usage: USERI <1-5> <int> | USERR <1-5> <real>"),
|
||
}
|
||
}
|
||
|
||
"REPORT" => {
|
||
// Pre-fill the GitHub issue body with version + platform so
|
||
// reports arrive with the basics already filled in.
|
||
let body = format!(
|
||
"<!-- Describe the issue and the steps to reproduce it. -->\n\n\n\
|
||
---\n- Open CAD Studio: v{}\n- Platform: {}\n",
|
||
env!("CARGO_PKG_VERSION"),
|
||
crate::sys::platform_info(),
|
||
);
|
||
let url = format!(
|
||
"https://github.com/HakanSeven12/OpenCADStudio/issues/new?body={}",
|
||
crate::sys::percent_encode(&body)
|
||
);
|
||
crate::sys::open_url(&url);
|
||
self.command_line.push_info("Opening feedback page...");
|
||
}
|
||
|
||
"ABOUT" => {
|
||
return Some(Task::done(Message::AboutOpen));
|
||
}
|
||
|
||
"PLUGINS" | "PLUGINMANAGER" => {
|
||
return Some(Task::done(Message::PluginManagerOpen));
|
||
}
|
||
|
||
"CHANGELOG" => {
|
||
crate::sys::open_url("https://github.com/HakanSeven12/OpenCADStudio/releases");
|
||
self.command_line.push_info("Opening release notes...");
|
||
}
|
||
|
||
// ── Keyboard Shortcuts panel ──────────────────────────────────
|
||
cmd if cmd == "SHORTCUTS" || cmd.starts_with("SHORTCUTS ") => {
|
||
let raw_rest = cmd.trim_start_matches("SHORTCUTS").trim();
|
||
let parts: Vec<&str> = raw_rest.splitn(3, ' ').collect();
|
||
let sub = parts.first().map(|s| s.to_uppercase()).unwrap_or_default();
|
||
match sub.as_str() {
|
||
"" | "LIST" | "?" => {
|
||
return Some(Task::done(Message::ShortcutsPanelOpen));
|
||
}
|
||
"SET" | "S" => {
|
||
// SHORTCUTS SET <key> <command>
|
||
// e.g. SHORTCUTS SET CTRL+D DIST
|
||
let key = parts.get(1).map(|s| s.to_uppercase()).unwrap_or_default();
|
||
let cmd_str = parts.get(2).map(|s| s.to_uppercase()).unwrap_or_default();
|
||
if key.is_empty() || cmd_str.is_empty() {
|
||
self.command_line.push_error("Usage: SHORTCUTS SET <key> <command> e.g. SHORTCUTS SET CTRL+D DIST");
|
||
} else {
|
||
self.shortcut_overrides.insert(key.clone(), cmd_str.clone());
|
||
self.command_line
|
||
.push_output(&format!("Shortcut set: {key} → {cmd_str}"));
|
||
}
|
||
}
|
||
"CLEAR" | "DELETE" | "REMOVE" => {
|
||
let key = parts.get(1).map(|s| s.to_uppercase()).unwrap_or_default();
|
||
if key.is_empty() {
|
||
self.command_line.push_error("Usage: SHORTCUTS CLEAR <key>");
|
||
} else if self.shortcut_overrides.remove(&key).is_some() {
|
||
self.command_line
|
||
.push_output(&format!("Shortcut '{key}' removed."));
|
||
} else {
|
||
self.command_line
|
||
.push_error(&format!("Shortcut '{key}' not found."));
|
||
}
|
||
}
|
||
_ => {
|
||
self.command_line
|
||
.push_info("Usage: SHORTCUTS LIST | SET <key> <cmd> | CLEAR <key>");
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Color Scheme / Theme selector ─────────────────────────────
|
||
cmd if cmd == "COLORSCHEME" || cmd.starts_with("COLORSCHEME ") => {
|
||
use iced::Theme;
|
||
let sub = cmd
|
||
.split_once(' ')
|
||
.map(|(_, r)| r.trim())
|
||
.unwrap_or("")
|
||
.to_uppercase();
|
||
// Map name to Theme variant.
|
||
let theme: Option<Theme> = match sub.as_str() {
|
||
"DARK" => Some(Theme::Dark),
|
||
"LIGHT" => Some(Theme::Light),
|
||
"DRACULA" => Some(Theme::Dracula),
|
||
"NORD" => Some(Theme::Nord),
|
||
"SOLARIZED_LIGHT" | "SOLARIZEDLIGHT" => Some(Theme::SolarizedLight),
|
||
"SOLARIZED_DARK" | "SOLARIZEDDARK" => Some(Theme::SolarizedDark),
|
||
"GRUVBOX_LIGHT" | "GRUVBOXLIGHT" => Some(Theme::GruvboxLight),
|
||
"GRUVBOX_DARK" | "GRUVBOXDARK" => Some(Theme::GruvboxDark),
|
||
"TOKYONIGHT" | "TOKYO_NIGHT" => Some(Theme::TokyoNight),
|
||
"TOKYONIGHTSTORM" | "TOKYO_NIGHT_STORM" => Some(Theme::TokyoNightStorm),
|
||
"TOKYONIGHTLIGHT" | "TOKYO_NIGHT_LIGHT" => Some(Theme::TokyoNightLight),
|
||
"KANAGAWAWAVE" | "KANAGAWA_WAVE" => Some(Theme::KanagawaWave),
|
||
"KANAGAWADRAGON" | "KANAGAWA_DRAGON" => Some(Theme::KanagawaDragon),
|
||
"KANAGAWALOTUS" | "KANAGAWA_LOTUS" => Some(Theme::KanagawaLotus),
|
||
"MOONFLY" => Some(Theme::Moonfly),
|
||
"NIGHTFLY" => Some(Theme::Nightfly),
|
||
"OXOCARBON" => Some(Theme::Oxocarbon),
|
||
"FERRA" => Some(Theme::Ferra),
|
||
"" | "LIST" | "?" => {
|
||
self.command_line.push_output(
|
||
"Available themes: DARK LIGHT DRACULA NORD SOLARIZED_LIGHT SOLARIZED_DARK \
|
||
GRUVBOX_LIGHT GRUVBOX_DARK TOKYONIGHT TOKYONIGHTSTORM TOKYONIGHTLIGHT \
|
||
KANAGAWAWAVE KANAGAWADRAGON KANAGAWALOTUS MOONFLY NIGHTFLY OXOCARBON FERRA"
|
||
);
|
||
return Some(Task::none());
|
||
}
|
||
_ => {
|
||
self.command_line.push_error(&format!(
|
||
"COLORSCHEME: unknown theme '{}'. Type COLORSCHEME LIST for options.",
|
||
sub
|
||
));
|
||
return Some(Task::none());
|
||
}
|
||
};
|
||
if let Some(t) = theme {
|
||
let name = format!("{:?}", t);
|
||
self.command_line
|
||
.push_output(&format!("Color scheme set to '{name}'."));
|
||
return Some(Task::done(Message::SetTheme(t)));
|
||
}
|
||
return Some(Task::none());
|
||
}
|
||
|
||
// ── Layout Manager GUI ─────────────────────────────────────────
|
||
"LAYOUTMANAGER" | "LAYOUTPANEL" => {
|
||
return Some(Task::done(Message::LayoutManagerOpen));
|
||
}
|
||
|
||
// ── Layout / viewport ──────────────────────────────────────────
|
||
"MVIEW" | "MV" => {
|
||
if self.tabs[i].scene.current_layout == "Model" {
|
||
self.command_line
|
||
.push_error("MVIEW: switch to a paper space layout first.");
|
||
} else {
|
||
use crate::modules::layout::mview::MviewCommand;
|
||
let new_cmd = MviewCommand::new();
|
||
self.command_line.push_info(&new_cmd.prompt());
|
||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||
}
|
||
}
|
||
|
||
// ── MSPACE / PSPACE ───────────────────────────────────────────
|
||
"MS" | "MSPACE" => {
|
||
return Some(Task::done(Message::MspaceCommand));
|
||
}
|
||
"PSPACE" => {
|
||
return Some(Task::done(Message::PspaceCommand));
|
||
}
|
||
|
||
// ── VPORTS — list or create preset viewport configurations ────
|
||
cmd if cmd == "VPORTS" || cmd.starts_with("VPORTS ") => {
|
||
let sub = cmd.split_whitespace().nth(1).unwrap_or("").to_uppercase();
|
||
let scene = &self.tabs[i].scene;
|
||
if scene.current_layout == "Model" {
|
||
// Bare VPORTS → ask for the configuration interactively;
|
||
// the next command-line entry supplies it.
|
||
if sub.is_empty() {
|
||
self.awaiting_vports = true;
|
||
self.command_line
|
||
.push_info("VPORTS Configuration [SIngle/2H/2V/4]:");
|
||
return Some(self.focus_cmd_input());
|
||
}
|
||
// Model space: split the tiled viewport layout via pane_grid.
|
||
use iced::widget::pane_grid::{Axis, Configuration as C};
|
||
let split = |axis, a, b| C::Split {
|
||
axis,
|
||
ratio: 0.5,
|
||
a: Box::new(a),
|
||
b: Box::new(b),
|
||
};
|
||
let config: Option<(C<usize>, usize)> = match sub.as_str() {
|
||
"SINGLE" | "SI" | "1" => Some((C::Pane(0), 1)),
|
||
"2H" | "2" => {
|
||
Some((split(Axis::Horizontal, C::Pane(0), C::Pane(1)), 2))
|
||
}
|
||
"2V" => Some((split(Axis::Vertical, C::Pane(0), C::Pane(1)), 2)),
|
||
"4" => Some((
|
||
split(
|
||
Axis::Vertical,
|
||
split(Axis::Horizontal, C::Pane(0), C::Pane(2)),
|
||
split(Axis::Horizontal, C::Pane(1), C::Pane(3)),
|
||
),
|
||
4,
|
||
)),
|
||
_ => None,
|
||
};
|
||
match config {
|
||
Some((config, n)) => {
|
||
self.tabs[i].scene.set_model_panes(config);
|
||
self.tabs[i].scene.camera_generation += 1;
|
||
self.command_line
|
||
.push_output(&format!("VPORTS: {n} viewport(s)."));
|
||
}
|
||
None => {
|
||
self.command_line
|
||
.push_error("VPORTS: use SINGLE | 2H | 2V | 4.");
|
||
}
|
||
}
|
||
} else if sub.is_empty() {
|
||
// ── List existing viewports ──────────────────────────
|
||
let layout_block = scene.current_layout_block_handle_pub();
|
||
let viewports: Vec<_> = scene
|
||
.document
|
||
.entities()
|
||
.filter_map(|e| {
|
||
if let acadrust::EntityType::Viewport(vp) = e {
|
||
if vp.id > 1 && vp.common.owner_handle == layout_block {
|
||
Some((
|
||
vp.id,
|
||
vp.center.clone(),
|
||
vp.width,
|
||
vp.height,
|
||
crate::scene::vp_effective_scale(
|
||
vp.custom_scale,
|
||
vp.view_height,
|
||
vp.height,
|
||
),
|
||
vp.status.is_on,
|
||
vp.status.locked,
|
||
))
|
||
} else {
|
||
None
|
||
}
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.collect();
|
||
if viewports.is_empty() {
|
||
self.command_line.push_info("No viewports. Use MVIEW to create one, or VPORTS 2H / 2V / 4 / SINGLE.");
|
||
} else {
|
||
self.command_line.push_output(&format!(
|
||
"{} viewport(s) in layout \"{}\":",
|
||
viewports.len(),
|
||
scene.current_layout
|
||
));
|
||
for (id, center, w, h, scale, is_on, locked) in &viewports {
|
||
let state = match (is_on, locked) {
|
||
(true, true) => "On, Locked",
|
||
(true, false) => "On",
|
||
(false, _) => "Off",
|
||
};
|
||
self.command_line.push_output(&format!(
|
||
" VP #{id}: {w:.1}×{h:.1} @ ({:.1},{:.1}) scale={scale:.4} [{state}]",
|
||
center.x, center.y
|
||
));
|
||
}
|
||
}
|
||
} else {
|
||
// ── Preset viewport layout ───────────────────────────
|
||
// Determine paper dimensions from PlotSettings (fallback A4 landscape).
|
||
let layout_name = scene.current_layout.clone();
|
||
let (paper_w, paper_h) = {
|
||
use acadrust::objects::ObjectType;
|
||
let mut pw = 297.0_f64;
|
||
let mut ph = 210.0_f64;
|
||
for (_, obj) in &scene.document.objects {
|
||
if let ObjectType::PlotSettings(ps) = obj {
|
||
if ps.page_name == layout_name && ps.paper_width > 0.0 {
|
||
pw = ps.paper_width;
|
||
ph = ps.paper_height;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
(pw, ph)
|
||
};
|
||
let margin = 5.0_f64; // mm margin around the usable area
|
||
let uw = paper_w - 2.0 * margin; // usable width
|
||
let uh = paper_h - 2.0 * margin; // usable height
|
||
// Collect rectangle specs: (cx, cz, w, h) in mm
|
||
let rects: Vec<(f64, f64, f64, f64)> = match sub.as_str() {
|
||
"2H" => {
|
||
// Two viewports side by side (horizontal split)
|
||
let vw = (uw - 2.0) / 2.0;
|
||
vec![
|
||
(margin + vw / 2.0, margin + uh / 2.0, vw, uh),
|
||
(margin + vw + 2.0 + vw / 2.0, margin + uh / 2.0, vw, uh),
|
||
]
|
||
}
|
||
"2V" => {
|
||
// Two viewports stacked (vertical split)
|
||
let vh = (uh - 2.0) / 2.0;
|
||
vec![
|
||
(margin + uw / 2.0, margin + vh + 2.0 + vh / 2.0, uw, vh),
|
||
(margin + uw / 2.0, margin + vh / 2.0, uw, vh),
|
||
]
|
||
}
|
||
"4" => {
|
||
// Four equal viewports (2×2 grid)
|
||
let vw = (uw - 2.0) / 2.0;
|
||
let vh = (uh - 2.0) / 2.0;
|
||
vec![
|
||
(margin + vw / 2.0, margin + vh + 2.0 + vh / 2.0, vw, vh),
|
||
(
|
||
margin + vw + 2.0 + vw / 2.0,
|
||
margin + vh + 2.0 + vh / 2.0,
|
||
vw,
|
||
vh,
|
||
),
|
||
(margin + vw / 2.0, margin + vh / 2.0, vw, vh),
|
||
(margin + vw + 2.0 + vw / 2.0, margin + vh / 2.0, vw, vh),
|
||
]
|
||
}
|
||
"SINGLE" | "1" => {
|
||
// Single full-page viewport
|
||
vec![(margin + uw / 2.0, margin + uh / 2.0, uw, uh)]
|
||
}
|
||
_ => {
|
||
self.command_line.push_error(
|
||
"VPORTS: unknown option. Use VPORTS 2H | 2V | 4 | SINGLE",
|
||
);
|
||
vec![]
|
||
}
|
||
};
|
||
if !rects.is_empty() {
|
||
// Remove existing user viewports in this layout first.
|
||
let layout_block = self.tabs[i].scene.current_layout_block_handle_pub();
|
||
let to_erase: Vec<acadrust::Handle> = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.entities()
|
||
.filter_map(|e| {
|
||
if let acadrust::EntityType::Viewport(vp) = e {
|
||
if vp.id > 1 && vp.common.owner_handle == layout_block {
|
||
Some(vp.common.handle)
|
||
} else {
|
||
None
|
||
}
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.collect();
|
||
self.push_undo_snapshot(i, "VPORTS");
|
||
self.tabs[i].scene.erase_entities(&to_erase);
|
||
// Create new viewports.
|
||
for (cx, cz, w, h) in &rects {
|
||
let mut vp = acadrust::entities::Viewport::new();
|
||
vp.center = acadrust::types::Vector3::new(*cx, 0.0, *cz);
|
||
vp.width = *w;
|
||
vp.height = *h;
|
||
vp.id = 2; // commit_entity will assign unique IDs
|
||
match self.tabs[i].scene.document.add_entity_to_layout(
|
||
acadrust::EntityType::Viewport(vp),
|
||
&layout_name,
|
||
) {
|
||
Ok(handle) => {
|
||
self.tabs[i].scene.auto_fit_viewport(handle);
|
||
}
|
||
Err(e) => {
|
||
self.command_line.push_error(&format!("VPORTS: {e}"));
|
||
}
|
||
}
|
||
}
|
||
// Re-assign unique IDs (1 + existing max per viewport).
|
||
let layout_block2 = self.tabs[i].scene.current_layout_block_handle_pub();
|
||
let mut id_counter = 2_i16;
|
||
let handles: Vec<acadrust::Handle> = self.tabs[i]
|
||
.scene
|
||
.document
|
||
.entities()
|
||
.filter_map(|e| {
|
||
if let acadrust::EntityType::Viewport(vp) = e {
|
||
if vp.id >= 2 && vp.common.owner_handle == layout_block2 {
|
||
Some(vp.common.handle)
|
||
} else {
|
||
None
|
||
}
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
.collect();
|
||
for h in handles {
|
||
if let Some(acadrust::EntityType::Viewport(vp)) =
|
||
self.tabs[i].scene.document.get_entity_mut(h)
|
||
{
|
||
vp.id = id_counter;
|
||
id_counter += 1;
|
||
}
|
||
}
|
||
self.tabs[i].dirty = true;
|
||
self.command_line.push_output(&format!(
|
||
"VPORTS: created {} viewport(s) [{}].",
|
||
rects.len(),
|
||
sub
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── VPLAYER — per-viewport layer freeze/thaw ──────────────────
|
||
"VPLAYER" => {
|
||
let scene = &self.tabs[i].scene;
|
||
if scene.current_layout == "Model" {
|
||
self.command_line
|
||
.push_error("VPLAYER: switch to a paper space layout first.");
|
||
} else if scene.active_viewport.is_none() {
|
||
self.command_line
|
||
.push_error("VPLAYER: enter a viewport first (double-click or MS).");
|
||
} else {
|
||
use crate::modules::layout::vplayer::VplayerCommand;
|
||
let vp_handle = scene.active_viewport.unwrap();
|
||
// Collect current frozen layer names for display.
|
||
let frozen_names: Vec<String> = {
|
||
if let Some(acadrust::EntityType::Viewport(vp)) =
|
||
scene.document.get_entity(vp_handle)
|
||
{
|
||
vp.frozen_layers
|
||
.iter()
|
||
.filter_map(|h| {
|
||
scene
|
||
.document
|
||
.layers
|
||
.iter()
|
||
.find(|l| l.handle == *h)
|
||
.map(|l| l.name.clone())
|
||
})
|
||
.collect()
|
||
} else {
|
||
vec![]
|
||
}
|
||
};
|
||
if frozen_names.is_empty() {
|
||
self.command_line
|
||
.push_info("VPLAYER: no frozen layers in active viewport.");
|
||
} else {
|
||
self.command_line.push_info(&format!(
|
||
"VPLAYER: frozen layers: {}",
|
||
frozen_names.join(", ")
|
||
));
|
||
}
|
||
let new_cmd = VplayerCommand::new(vp_handle);
|
||
self.command_line.push_info(&new_cmd.prompt());
|
||
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
|
||
}
|
||
}
|
||
|
||
// ── Draw Order ────────────────────────────────────────────────
|
||
cmd if cmd.starts_with("DRAWORDER") => {
|
||
use acadrust::objects::{ObjectType, SortEntitiesTable};
|
||
let parts: Vec<&str> = cmd.split_whitespace().collect();
|
||
let option = parts.get(1).unwrap_or(&"").to_uppercase();
|
||
let i = self.active_tab;
|
||
let selected: Vec<acadrust::Handle> = self.tabs[i]
|
||
.scene
|
||
.selected_entities()
|
||
.iter()
|
||
.map(|(h, _)| *h)
|
||
.collect();
|
||
if selected.is_empty() {
|
||
self.command_line
|
||
.push_error("DRAWORDER: select entities first.");
|
||
} else {
|
||
// Parse relative target handle for ABOVE/UNDER.
|
||
let relative_target: Option<(bool, acadrust::Handle)> = match option.as_str() {
|
||
"A" | "ABOVE" => {
|
||
let h_val = parts.get(2).and_then(|s| u64::from_str_radix(s, 16).ok());
|
||
h_val.map(|v| (true, acadrust::Handle::new(v)))
|
||
}
|
||
"U" | "UNDER" | "BELOW" => {
|
||
let h_val = parts.get(2).and_then(|s| u64::from_str_radix(s, 16).ok());
|
||
h_val.map(|v| (false, acadrust::Handle::new(v)))
|
||
}
|
||
_ => None,
|
||
};
|
||
let to_front_opt = match option.as_str() {
|
||
"F" | "FRONT" => Some(true),
|
||
"B" | "BACK" => Some(false),
|
||
_ => None,
|
||
};
|
||
|
||
if relative_target.is_some() || to_front_opt.is_some() {
|
||
self.push_undo_snapshot(i, "DRAWORDER");
|
||
let block_handle = self.tabs[i].scene.current_layout_block_handle_pub();
|
||
|
||
// For FRONT/BACK, anchor the new sort handle to the
|
||
// block's current effective draw-order range so the moved
|
||
// entities land strictly above/below every sibling —
|
||
// including ones not yet in the table, which sort by
|
||
// their own handle. (min_eff, max_eff) over siblings.
|
||
let fb_baseline: Option<(u64, u64)> = if to_front_opt.is_some() {
|
||
let selected_set: rustc_hash::FxHashSet<u64> =
|
||
selected.iter().map(|h| h.value()).collect();
|
||
let doc_ref = &self.tabs[i].scene.document;
|
||
let overrides: rustc_hash::FxHashMap<u64, u64> = doc_ref
|
||
.objects
|
||
.values()
|
||
.find_map(|obj| {
|
||
if let ObjectType::SortEntitiesTable(t) = obj {
|
||
if t.block_owner_handle == block_handle {
|
||
return Some(
|
||
t.entries()
|
||
.map(|e| {
|
||
(
|
||
e.entity_handle.value(),
|
||
e.sort_handle.value(),
|
||
)
|
||
})
|
||
.collect(),
|
||
);
|
||
}
|
||
}
|
||
None
|
||
})
|
||
.unwrap_or_default();
|
||
let mut max_eff = 0u64;
|
||
let mut min_eff = u64::MAX;
|
||
for e in doc_ref.entities() {
|
||
let c = e.common();
|
||
let hv = c.handle.value();
|
||
if selected_set.contains(&hv) {
|
||
continue;
|
||
}
|
||
if c.owner_handle != block_handle && !c.owner_handle.is_null() {
|
||
continue;
|
||
}
|
||
let eff = overrides.get(&hv).copied().unwrap_or(hv);
|
||
max_eff = max_eff.max(eff);
|
||
min_eff = min_eff.min(eff);
|
||
}
|
||
if min_eff == u64::MAX {
|
||
min_eff = 1;
|
||
}
|
||
Some((min_eff, max_eff))
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let doc = &mut self.tabs[i].scene.document;
|
||
let table_handle = doc.objects.iter().find_map(|(h, obj)| {
|
||
if let ObjectType::SortEntitiesTable(t) = obj {
|
||
if t.block_owner_handle == block_handle {
|
||
Some(*h)
|
||
} else {
|
||
None
|
||
}
|
||
} else {
|
||
None
|
||
}
|
||
});
|
||
let get_or_create =
|
||
|doc: &mut acadrust::CadDocument, block_handle| -> acadrust::Handle {
|
||
if let Some(th) = doc.objects.iter().find_map(|(h, obj)| {
|
||
if let ObjectType::SortEntitiesTable(t) = obj {
|
||
if t.block_owner_handle == block_handle {
|
||
Some(*h)
|
||
} else {
|
||
None
|
||
}
|
||
} else {
|
||
None
|
||
}
|
||
}) {
|
||
th
|
||
} else {
|
||
let nh = acadrust::Handle::new(doc.next_handle());
|
||
let mut table = SortEntitiesTable::for_block(block_handle);
|
||
table.handle = nh;
|
||
doc.objects.insert(nh, ObjectType::SortEntitiesTable(table));
|
||
nh
|
||
}
|
||
};
|
||
let th = table_handle.unwrap_or_else(|| {
|
||
let nh = acadrust::Handle::new(doc.next_handle());
|
||
let mut table = SortEntitiesTable::for_block(block_handle);
|
||
table.handle = nh;
|
||
doc.objects.insert(nh, ObjectType::SortEntitiesTable(table));
|
||
nh
|
||
});
|
||
let _ = get_or_create; // suppress unused warning
|
||
if let Some(ObjectType::SortEntitiesTable(table)) = doc.objects.get_mut(&th)
|
||
{
|
||
if let Some((above, target)) = relative_target {
|
||
// move_above/move_below read the target's sort
|
||
// handle from the table and no-op when it is
|
||
// absent. A reference object that was never
|
||
// reordered isn't in the table yet, so seed it
|
||
// with its own handle as the implicit sort key.
|
||
if !table.contains(target) {
|
||
table.add_entry(target, target);
|
||
}
|
||
for h in &selected {
|
||
if above {
|
||
table.move_above(*h, target);
|
||
} else {
|
||
table.move_below(*h, target);
|
||
}
|
||
}
|
||
let rel = if above { "above" } else { "below" };
|
||
self.command_line.push_info(&format!(
|
||
"DRAWORDER: moved {} entities {} {:x}.",
|
||
selected.len(),
|
||
rel,
|
||
target.value()
|
||
));
|
||
} else if let Some(to_front) = to_front_opt {
|
||
let (min_eff, max_eff) = fb_baseline.unwrap_or((1, 0));
|
||
for (k, h) in selected.iter().enumerate() {
|
||
let sort = if to_front {
|
||
max_eff.saturating_add(1 + k as u64)
|
||
} else {
|
||
min_eff.saturating_sub(1 + k as u64).max(1)
|
||
};
|
||
table.add_entry(*h, acadrust::Handle::new(sort));
|
||
}
|
||
let dir = if to_front { "front" } else { "back" };
|
||
self.command_line.push_info(&format!(
|
||
"DRAWORDER: moved {} entities to {}.",
|
||
selected.len(),
|
||
dir
|
||
));
|
||
}
|
||
}
|
||
// Sort order lives in SortEntitiesTable, which the
|
||
// render-side `sort_cache` rebuilds per geometry epoch.
|
||
// Bump it so the new draw order shows immediately
|
||
// instead of waiting for an unrelated geometry change.
|
||
self.tabs[i].scene.bump_geometry();
|
||
self.tabs[i].dirty = true;
|
||
} else {
|
||
self.command_line.push_info(
|
||
"Usage: DRAWORDER F|FRONT | B|BACK | A|ABOVE <handle> | U|UNDER <handle>"
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
_ => return None,
|
||
}
|
||
Some(self.finish_dispatch(cmd))
|
||
}
|
||
}
|
||
|
||
// ── Draw Order: interactive reference-object pick ──────────────────────────
|
||
|
||
/// Moves a captured selection above or below a reference object the user
|
||
/// picks in the viewport. On pick it relaunches `DRAWORDER A|U <handle>`
|
||
/// with the captured handles reinstalled as the selection, so the existing
|
||
/// command path performs the actual reorder.
|
||
pub(crate) struct DrawOrderRefCommand {
|
||
to_move: Vec<acadrust::Handle>,
|
||
above: bool,
|
||
}
|
||
|
||
impl DrawOrderRefCommand {
|
||
pub(crate) fn new(to_move: Vec<acadrust::Handle>, above: bool) -> Self {
|
||
Self { to_move, above }
|
||
}
|
||
}
|
||
|
||
impl CadCommand for DrawOrderRefCommand {
|
||
fn name(&self) -> &'static str {
|
||
"DRAWORDER"
|
||
}
|
||
|
||
fn prompt(&self) -> String {
|
||
if self.above {
|
||
"DRAWORDER Select reference object (move selection above):".into()
|
||
} else {
|
||
"DRAWORDER Select reference object (move selection under):".into()
|
||
}
|
||
}
|
||
|
||
fn needs_entity_pick(&self) -> bool {
|
||
true
|
||
}
|
||
|
||
fn on_entity_pick(
|
||
&mut self,
|
||
handle: acadrust::Handle,
|
||
_pt: glam::DVec3,
|
||
) -> crate::command::CmdResult {
|
||
if handle.is_null() {
|
||
return crate::command::CmdResult::NeedPoint;
|
||
}
|
||
let opt = if self.above { "A" } else { "U" };
|
||
let cmd = format!("DRAWORDER {} {:x}", opt, handle.value());
|
||
crate::command::CmdResult::Relaunch(cmd, std::mem::take(&mut self.to_move))
|
||
}
|
||
|
||
fn on_point(&mut self, _pt: glam::DVec3) -> crate::command::CmdResult {
|
||
crate::command::CmdResult::NeedPoint
|
||
}
|
||
|
||
fn on_enter(&mut self) -> crate::command::CmdResult {
|
||
crate::command::CmdResult::Cancel
|
||
}
|
||
}
|