forked from 1b-agent/opencadstudio-upstream
Merge main into Select-Color-Redesign
Resolve conflicts with the new gradient and graphic-attributes colour targets: keep main's targets and menu handling, routed through the new ColorPicker state; adapt dock/view.rs to `color_picker`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8
This commit is contained in:
220 files changed
+37313
-5826
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -51,6 +51,44 @@ impl OpenCADStudio {
|
||||
self.persist_settings_if_changed();
|
||||
}
|
||||
BlockPaletteMsg::Refresh => self.refresh_block_palette(),
|
||||
// Web: read the picked file's bytes and parse them right away —
|
||||
// the picker's name-only path has nothing behind it to read later.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
BlockPaletteMsg::PickFile => {
|
||||
return Task::perform(
|
||||
async {
|
||||
crate::io::pick_and_parse_web(
|
||||
crate::t!("Select Drawing to Insert as Block").as_ref(),
|
||||
crate::t!("DWG/DXF Files").as_ref(),
|
||||
&["dwg", "dxf", "DWG", "DXF"],
|
||||
)
|
||||
.await
|
||||
.and_then(|picked| picked.ok_or_else(|| "Cancelled".to_string()))
|
||||
},
|
||||
|r| Message::BlockPalette(BlockPaletteMsg::FilePickedDoc(r)),
|
||||
);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
BlockPaletteMsg::FilePickedDoc(Ok((name, document))) => {
|
||||
let stem = std::path::Path::new(&name)
|
||||
.file_stem()
|
||||
.map(|stem| stem.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "Block".to_string());
|
||||
match self.import_document_block(document, stem, false) {
|
||||
Ok(name) => {
|
||||
self.refresh_block_palette();
|
||||
return self.start_palette_insert(&name, None);
|
||||
}
|
||||
Err(e) => self.command_line.push_error(&e),
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
BlockPaletteMsg::FilePickedDoc(Err(e)) => {
|
||||
if e != "Cancelled" {
|
||||
self.command_line.push_error(&e);
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
BlockPaletteMsg::PickFile => {
|
||||
return Task::perform(
|
||||
async {
|
||||
@@ -134,7 +172,7 @@ impl OpenCADStudio {
|
||||
/// BLOCKSPALETTE: open (expanded) on `tab`, or on the tab it was left on.
|
||||
pub(crate) fn open_blocks_palette(&mut self, tab: Option<Tab>) {
|
||||
self.show_block_palette = true;
|
||||
self.dock_expanded = Some(crate::ui::dock::PanelId::BlockPalette);
|
||||
self.dock_reveal(crate::ui::dock::PanelId::BlockPalette);
|
||||
self.show_block_palette_tab(tab.unwrap_or(self.block_palette.tab));
|
||||
}
|
||||
|
||||
@@ -491,20 +529,31 @@ impl OpenCADStudio {
|
||||
Tab::Current => {
|
||||
let name = item.name();
|
||||
let i = self.active_tab;
|
||||
let referenced = self.tabs[i].scene.document.entities().any(|e| {
|
||||
matches!(e, codec::EntityType::Insert(ins) if ins.block_name.eq_ignore_ascii_case(&name))
|
||||
});
|
||||
if referenced {
|
||||
self.command_line.push_error(&crate::tf!(
|
||||
"Block \"{name}\" is referenced in the drawing and cannot be removed."
|
||||
));
|
||||
} else {
|
||||
self.push_undo_snapshot(i, "PURGE");
|
||||
crate::app::control::erase_block_definition(&mut self.tabs[i].scene, &name);
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line
|
||||
.push_output(&crate::tf!("Block \"{name}\" removed."));
|
||||
self.refresh_block_palette();
|
||||
let open_edits: Vec<codec::Handle> = self.tabs[i]
|
||||
.block_edits
|
||||
.iter()
|
||||
.map(|session| session.br_handle)
|
||||
.collect();
|
||||
self.push_undo_snapshot(i, format!("Delete block {name}"));
|
||||
let outcome = crate::scene::block_service::delete(
|
||||
&mut self.tabs[i].scene,
|
||||
&name,
|
||||
crate::scene::block_service::DeleteOptions {
|
||||
force: false,
|
||||
open_edits: &open_edits,
|
||||
},
|
||||
);
|
||||
match outcome {
|
||||
Ok(_) => {
|
||||
self.tabs[i].dirty = true;
|
||||
self.command_line
|
||||
.push_output(&crate::tf!("Block \"{name}\" removed."));
|
||||
self.refresh_block_palette();
|
||||
}
|
||||
Err(error) => {
|
||||
self.discard_last_undo_entry(i);
|
||||
self.command_line.push_error(&error.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Tab::Recent | Tab::Favorites => {
|
||||
|
||||
+132
-17
@@ -195,6 +195,13 @@ impl OpenCADStudio {
|
||||
// This tab is closing for good — drop its autosave recovery copy.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _ = std::fs::remove_file(self.autosave_target(idx));
|
||||
// …and, on the web, its retained in-place save handle: the next tab
|
||||
// to reuse that id must start with a fresh pick instead of silently
|
||||
// writing over the closed drawing's file.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if let Some(closing_id) = closing_tab_id {
|
||||
crate::io::fs_access::drop_handle(closing_id);
|
||||
}
|
||||
// Only-tab case: when the lone non-start tab closes, fall
|
||||
// back to the Start tab if it exists; otherwise spawn a
|
||||
// fresh blank drawing (legacy behaviour).
|
||||
@@ -219,6 +226,10 @@ impl OpenCADStudio {
|
||||
if let Some(tab_id) = tab_id {
|
||||
v4_support::on_tab_closed(tab_id);
|
||||
}
|
||||
// The tab list just shrank: refresh the remembered session list
|
||||
// (web session memory).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
self.persist_web_session();
|
||||
Task::none()
|
||||
}
|
||||
|
||||
@@ -394,6 +405,10 @@ impl OpenCADStudio {
|
||||
// dropdown so the dispatched command's new prompt is
|
||||
// immediately visible on the overlay.
|
||||
self.command_line.close_history();
|
||||
if self.awaiting_fill_close_answer() {
|
||||
let input = std::mem::take(&mut self.command_line.input);
|
||||
return self.on_graphic_fill_close_input(&input);
|
||||
}
|
||||
// A leading `>` was only a "literal spaces" typing hint (see
|
||||
// CommandSpace) — drop it before the input is interpreted.
|
||||
// Free-form text prompts keep it: there it is content, not a
|
||||
@@ -814,27 +829,17 @@ impl OpenCADStudio {
|
||||
let is_mtp = raw.trim_start_matches('_').eq_ignore_ascii_case("MTP")
|
||||
|| raw.trim_start_matches('_').eq_ignore_ascii_case("M2P");
|
||||
if is_mtp {
|
||||
let is_point_step = !self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.input_kind().wants_text())
|
||||
.unwrap_or(true)
|
||||
|| self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.point_step_accepts_keywords())
|
||||
.unwrap_or(false);
|
||||
let not_entity_pick = !self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.map(|c| c.needs_entity_pick())
|
||||
.unwrap_or(false);
|
||||
if is_point_step && not_entity_pick {
|
||||
if self.point_modifier_step(i) {
|
||||
self.command_line.input.clear();
|
||||
self.start_mtp_modifier(i);
|
||||
return self.focus_cmd_input();
|
||||
}
|
||||
}
|
||||
if let Some(kind) = self.typed_snap_override(i, &raw) {
|
||||
self.command_line.input.clear();
|
||||
self.apply_snap_override(kind);
|
||||
return self.focus_cmd_input();
|
||||
}
|
||||
let text = if free_text {
|
||||
raw
|
||||
} else {
|
||||
@@ -933,6 +938,7 @@ impl OpenCADStudio {
|
||||
self.dyn_coord_absolute = false;
|
||||
self.sync_dyn_fields();
|
||||
self.reset_tracking_after_point();
|
||||
self.snapper.clear_override();
|
||||
self.push_ucs_to_cmd(i);
|
||||
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(wcs_pt));
|
||||
if let Some(r) = result {
|
||||
@@ -1048,6 +1054,7 @@ impl OpenCADStudio {
|
||||
self.dyn_coord_absolute = false;
|
||||
self.sync_dyn_fields();
|
||||
self.reset_tracking_after_point();
|
||||
self.snapper.clear_override();
|
||||
if let Some((_, dir)) = distance_ray {
|
||||
self.snapper.remember_distance_reference(dir);
|
||||
}
|
||||
@@ -1108,6 +1115,9 @@ impl OpenCADStudio {
|
||||
if !self.command_line.input.trim().is_empty() {
|
||||
return self.update(Message::CommandSubmit);
|
||||
}
|
||||
if self.awaiting_fill_close_answer() {
|
||||
return self.on_graphic_fill_close_input("");
|
||||
}
|
||||
// A grip edit is not an active CAD command, but its Dynamic Input
|
||||
// fields use the same keyboard path. Route Enter through CommandSubmit,
|
||||
// whose grip branch resolves Distance / Angle and finalizes the edit.
|
||||
@@ -1162,6 +1172,11 @@ impl OpenCADStudio {
|
||||
if self.ribbon.escape_extension() {
|
||||
return Task::none();
|
||||
}
|
||||
if self.awaiting_fill_close_answer() {
|
||||
self.graphic_attributes.pending_fill_close = None;
|
||||
self.command_line.push_info(crate::t!("*Cancel*").as_ref());
|
||||
return Task::none();
|
||||
}
|
||||
// Esc drops an unconsumed one-shot snap override and closes
|
||||
// its menu (#337). Falls through — Esc keeps its usual effect.
|
||||
self.snap_override_popup = None;
|
||||
@@ -2087,6 +2102,68 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach a clipboard image read on the web (a pasted screenshot) the way
|
||||
/// the IMAGE command's pick does: register the bytes name-keyed, decode
|
||||
/// for the file's true pixel size, then commit the entity — but placed
|
||||
/// directly at the cursor (0,0 before any pointer move) with the command's
|
||||
/// default width, skipping the pick prompts a paste should not run.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn paste_clipboard_image(&mut self, name: String, bytes: Vec<u8>) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
if self.tabs[i].is_start {
|
||||
self.command_line.push_error(
|
||||
crate::tr!("common", "no-drawing-open-use-new-or-open-first").as_ref(),
|
||||
);
|
||||
return Task::none();
|
||||
}
|
||||
crate::scene::model::image_model::register_source(&name, std::sync::Arc::new(bytes));
|
||||
let Some(decoded) = crate::scene::model::image_model::resolve_image(&name) else {
|
||||
self.command_line.push_error(
|
||||
crate::tr!("clipboard", "image-unsupported", name = name.clone()).as_ref(),
|
||||
);
|
||||
return Task::none();
|
||||
};
|
||||
let (pixels_w, pixels_h) = (decoded.source_width, decoded.source_height);
|
||||
// Same default footprint as the IMAGE command's Enter placement: one
|
||||
// world unit per 100 px (min 1), height from the true aspect ratio.
|
||||
let width = (pixels_w as f64 / 100.0).max(1.0);
|
||||
let aspect = if pixels_h == 0 {
|
||||
1.0
|
||||
} else {
|
||||
pixels_w as f64 / pixels_h as f64
|
||||
};
|
||||
let height = width / aspect;
|
||||
let origin = self.tabs[i].last_cursor_world;
|
||||
let mut image = codec::entities::RasterImage::with_size(
|
||||
&name,
|
||||
codec::types::Vector3::new(origin.x, origin.y, origin.z),
|
||||
pixels_w as f64,
|
||||
pixels_h as f64,
|
||||
width,
|
||||
height,
|
||||
);
|
||||
image.flags = codec::entities::ImageDisplayFlags::SHOW_IMAGE
|
||||
| codec::entities::ImageDisplayFlags::USE_CLIPPING_BOUNDARY;
|
||||
let short = std::path::Path::new(&name)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(&name)
|
||||
.to_string();
|
||||
self.command_line.push_output(
|
||||
crate::tr!(
|
||||
"clipboard",
|
||||
"pasted-image",
|
||||
name = short,
|
||||
width = pixels_w,
|
||||
height = pixels_h
|
||||
)
|
||||
.as_ref(),
|
||||
);
|
||||
self.apply_cmd_result(crate::command::CmdResult::CommitAndExit(
|
||||
codec::EntityType::RasterImage(image),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn on_qselect_open(&mut self) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
self.tabs[i].scene.selection.borrow_mut().menu.open_at = None;
|
||||
@@ -2233,6 +2310,35 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn on_ribbon_lineweight_changed(
|
||||
&mut self,
|
||||
lw: codec::types::LineWeight,
|
||||
) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
self.ribbon.close_dropdown();
|
||||
let handles = self.property_target_handles(i);
|
||||
if handles.is_empty() {
|
||||
if self.has_property_selection(i) {
|
||||
return Task::none();
|
||||
}
|
||||
// Persist into the tab's header (CELWEIGHT). #21.
|
||||
self.tabs[i].scene.document.header.current_line_weight = lw.value();
|
||||
self.tabs[i].dirty = true;
|
||||
self.ribbon.active_lineweight = lw;
|
||||
} else {
|
||||
// Lineweight is baked into the cached wire geometry —
|
||||
// re-tessellate so the change shows immediately (issue #231
|
||||
// class).
|
||||
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
|
||||
if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) {
|
||||
crate::scene::view::dispatch::apply_line_weight(entity, lw);
|
||||
}
|
||||
});
|
||||
self.ribbon.active_lineweight = lw;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn on_ribbon_linetype_changed(&mut self, lt: String) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
self.ribbon.close_dropdown();
|
||||
@@ -2339,11 +2445,20 @@ impl OpenCADStudio {
|
||||
pub(super) fn on_prop_hatch_pattern_changed(&mut self, name: String) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
let handles = self.property_target_handles(i);
|
||||
self.apply_hatch_pattern(i, &handles, name)
|
||||
}
|
||||
|
||||
pub(super) fn apply_hatch_pattern(
|
||||
&mut self,
|
||||
i: usize,
|
||||
handles: &[codec::Handle],
|
||||
name: String,
|
||||
) -> Task<Message> {
|
||||
if !handles.is_empty() {
|
||||
use crate::scene::model::hatch_patterns;
|
||||
if let Some(entry) = hatch_patterns::find(&name) {
|
||||
self.push_undo_snapshot(i, "HATCHEDIT");
|
||||
for &handle in &handles {
|
||||
for &handle in handles {
|
||||
if let Some(codec::EntityType::Hatch(dxf)) =
|
||||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||||
{
|
||||
|
||||
+54
-318
@@ -13,6 +13,7 @@ use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip
|
||||
use crate::scene::{
|
||||
self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
|
||||
};
|
||||
use crate::ui::window::block_palette::BlockPaletteMsg;
|
||||
use crate::ui::PropertiesPanel;
|
||||
use codec::types::Color as AcadColor;
|
||||
use codec::{EntityType as AcadEntityType, Handle};
|
||||
@@ -21,6 +22,15 @@ use iced::{mouse, Point, Task};
|
||||
|
||||
impl OpenCADStudio {
|
||||
pub(in crate::app) fn open_save_dialog_window(&mut self, tab_idx: usize) -> Task<Message> {
|
||||
// Every entry here is Save-As semantics (the SAVEAS command, the
|
||||
// recovery save-as, the native failure redirects): the user is
|
||||
// choosing a destination, so on the web the FS Access picker must
|
||||
// open even when the tab already holds a handle (plain Save sets
|
||||
// the flag back in save_with_default_format).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
self.save_plain_reuse_fs_handle = false;
|
||||
}
|
||||
// Default the format dropdown to the loaded file's own format — its
|
||||
// DWG-vs-DXF kind (from the extension) and its version (from the parsed
|
||||
// document) — so Save-As round-trips the format instead of silently
|
||||
@@ -188,9 +198,33 @@ impl OpenCADStudio {
|
||||
let Some(idx) = self.tabs.iter().position(|t| t.id == tab_id) else {
|
||||
return Task::batch([close_win, self.continue_tab_close_queue()]);
|
||||
};
|
||||
// …and, on the web, its retained in-place save handle — the
|
||||
// same hook on_tab_close runs: the user declined the save,
|
||||
// so the picked file must never receive a later write, and
|
||||
// the next tab to reuse this id must start with a fresh
|
||||
// pick instead of silently writing over the discarded
|
||||
// drawing's file.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
crate::io::fs_access::drop_handle(tab_id);
|
||||
// Discarded — drop this tab's autosave recovery copy.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let _ = std::fs::remove_file(self.autosave_target(idx));
|
||||
// Web counterpart: the OPFS snapshot must go too, or the
|
||||
// drawing the user just declined to save is offered back at
|
||||
// the next boot. A snapshot round still writing this tab's key
|
||||
// would resurrect it after the remove, so defer to the round's
|
||||
// end (the same guard the save flow uses).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let key = Self::web_autosave_key(&self.tabs[idx]);
|
||||
if self.web_autosave_in_flight {
|
||||
self.web_autosave_clear_after.push(key);
|
||||
} else {
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let _ = crate::io::web_autosave::remove(&key).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
if self.tabs.len() == 1 {
|
||||
self.tab_counter += 1;
|
||||
self.tabs[0] = crate::app::document::DocumentTab::new_drawing(self.tab_counter);
|
||||
@@ -208,6 +242,10 @@ impl OpenCADStudio {
|
||||
// last selection. #21.
|
||||
self.sync_ribbon_layers();
|
||||
self.sync_ribbon_from_selection();
|
||||
// The tab list just shrank: refresh the remembered
|
||||
// session list (web session memory).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
self.persist_web_session();
|
||||
return Task::batch([close_win, self.continue_tab_close_queue()]);
|
||||
}
|
||||
Some(crate::app::PendingClose::Quit) => {
|
||||
@@ -289,6 +327,21 @@ impl OpenCADStudio {
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
// Same guard the native half runs against active_save_jobs
|
||||
// above: while a web save is between capture and completion,
|
||||
// refuse a second one (the window is seconds — refuse, don't
|
||||
// queue) instead of letting two serializer rounds race for the
|
||||
// same tab's bookkeeping.
|
||||
if let Some(busy_tab) = crate::io::fs_access::web_save_in_flight() {
|
||||
if self.tabs.iter().any(|tab| tab.id == busy_tab) {
|
||||
self.command_line
|
||||
.push_info(crate::t!("Save already running for this drawing.").as_ref());
|
||||
return Task::none();
|
||||
}
|
||||
// Stale marker (its tab closed before settling): clear it
|
||||
// rather than block this save forever.
|
||||
crate::io::fs_access::end_web_save(busy_tab);
|
||||
}
|
||||
match self.pending_close.take() {
|
||||
Some(crate::app::PendingClose::Tab(tab_id)) => {
|
||||
let Some(idx) = self.tabs.iter().position(|t| t.id == tab_id) else {
|
||||
@@ -379,7 +432,7 @@ impl OpenCADStudio {
|
||||
self.import_document_block(doc, stem, false)
|
||||
}
|
||||
|
||||
fn import_document_block(
|
||||
pub(super) fn import_document_block(
|
||||
&mut self,
|
||||
doc: codec::CadDocument,
|
||||
stem: String,
|
||||
@@ -542,195 +595,6 @@ impl OpenCADStudio {
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// Dock chrome interaction (grab / pin / resize / hover / move) applied to
|
||||
/// whichever panel the message names.
|
||||
pub(super) fn on_dock(&mut self, m: crate::ui::dock::DockMsg) -> iced::Task<Message> {
|
||||
use crate::app::config::DockSide;
|
||||
use crate::ui::dock::{DockMsg, PanelId};
|
||||
match m {
|
||||
DockMsg::DockGrab(id) => {
|
||||
self.dock_dragging = Some(id);
|
||||
self.dock_resizing = None;
|
||||
self.dock_drag_last = None;
|
||||
self.dock_drag_target = self.dock.location(id);
|
||||
self.dock_expanded = Some(id);
|
||||
iced::Task::none()
|
||||
}
|
||||
DockMsg::ResizeGrab(id) => {
|
||||
self.dock_resizing = Some(id);
|
||||
self.dock_dragging = None;
|
||||
self.dock_drag_last = None;
|
||||
self.dock_drag_target = None;
|
||||
self.dock_expanded = Some(id);
|
||||
iced::Task::none()
|
||||
}
|
||||
DockMsg::WidthReset(id) => {
|
||||
self.dock.reset_width(id);
|
||||
self.save_config();
|
||||
iced::Task::none()
|
||||
}
|
||||
DockMsg::AutoCollapseToggle(id) => {
|
||||
let on = !self.dock.auto_collapse(id);
|
||||
self.dock.set_auto_collapse(id, on);
|
||||
self.dock_expanded = if on { None } else { Some(id) };
|
||||
self.save_config();
|
||||
iced::Task::none()
|
||||
}
|
||||
DockMsg::Close(id) => {
|
||||
match id {
|
||||
PanelId::BlockPalette => {
|
||||
self.show_block_palette = false;
|
||||
self.block_palette.placing = None;
|
||||
}
|
||||
PanelId::ExternalReferences => {
|
||||
self.show_external_references = false;
|
||||
}
|
||||
PanelId::Browser => {
|
||||
self.show_browser = false;
|
||||
}
|
||||
PanelId::NodeGraph => {
|
||||
self.show_node_graph = false;
|
||||
}
|
||||
PanelId::PointCloudManager => {
|
||||
self.pc_manager.show = false;
|
||||
}
|
||||
PanelId::Count => {
|
||||
self.count_palette.show = false;
|
||||
self.ribbon.set_count_palette(false);
|
||||
}
|
||||
PanelId::SheetSetManager => {
|
||||
self.sheet_set.show = false;
|
||||
self.ribbon.set_sheet_set(false);
|
||||
}
|
||||
PanelId::Properties => {
|
||||
self.show_properties = false;
|
||||
self.ribbon.set_properties(false);
|
||||
}
|
||||
}
|
||||
if self.dock_expanded == Some(id) {
|
||||
self.dock_expanded = None;
|
||||
}
|
||||
if self.dock_dragging == Some(id) {
|
||||
self.dock_dragging = None;
|
||||
}
|
||||
if self.dock_resizing == Some(id) {
|
||||
self.dock_resizing = None;
|
||||
}
|
||||
iced::Task::none()
|
||||
}
|
||||
DockMsg::Hover(id) => {
|
||||
// Ignored while dragging/resizing: the pointer is over the
|
||||
// drag preview, not a rail, so a hover must not collapse the
|
||||
// panel being dragged or repoint the resize target.
|
||||
if self.dock_dragging.is_none() && self.dock_resizing.is_none() {
|
||||
self.dock_expanded = Some(id);
|
||||
}
|
||||
iced::Task::none()
|
||||
}
|
||||
DockMsg::HoverExit => {
|
||||
if self.dock_dragging.is_none() && self.dock_resizing.is_none() {
|
||||
if let Some(id) = self.dock_expanded {
|
||||
if self.dock.auto_collapse(id) {
|
||||
self.dock_expanded = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
iced::Task::none()
|
||||
}
|
||||
DockMsg::DragMove(point) => {
|
||||
// NOTE: xref column drags intentionally do NOT ride this
|
||||
// path: its points live in workspace space while the header
|
||||
// tracker reports header-local points, and mixing the two
|
||||
// produced a one-time jump plus a stuck drag. Column moves
|
||||
// arrive via Message::XrefColMove only.
|
||||
if self.dock_dragging.is_some() {
|
||||
let avail = self.tabs[self.active_tab]
|
||||
.scene
|
||||
.selection
|
||||
.borrow()
|
||||
.view
|
||||
.vp_size
|
||||
.1;
|
||||
let side = if point.x < self.win_size.0 * 0.5 {
|
||||
DockSide::Left
|
||||
} else {
|
||||
DockSide::Right
|
||||
};
|
||||
let index = crate::ui::dock::drop_index(
|
||||
point.y,
|
||||
std::cmp::max(self.dock_visible_len(side), 1),
|
||||
avail,
|
||||
);
|
||||
self.dock_drag_target = Some((side, index));
|
||||
} else if let Some(id) = self.dock_resizing {
|
||||
if let Some(last) = self.dock_drag_last {
|
||||
let dx = point.x - last.x;
|
||||
let delta = match self.dock.location(id) {
|
||||
Some((DockSide::Left, _)) => dx,
|
||||
_ => -dx,
|
||||
};
|
||||
let cur = self.dock.settings(id).width + delta;
|
||||
self.dock.set_width(id, cur);
|
||||
}
|
||||
}
|
||||
if self.dock_dragging.is_some() || self.dock_resizing.is_some() {
|
||||
self.dock_drag_last = Some(point);
|
||||
}
|
||||
iced::Task::none()
|
||||
}
|
||||
DockMsg::DragRelease => {
|
||||
if let Some(id) = self.dock_dragging {
|
||||
if let Some((side, index)) = self.dock_drag_target {
|
||||
if self.dock.dock(id, side, index) {
|
||||
self.save_config();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.dock_dragging = None;
|
||||
self.dock_resizing = None;
|
||||
self.dock_drag_last = None;
|
||||
self.dock_drag_target = None;
|
||||
self.xref_col_drag = None;
|
||||
self.xref_col_last = None;
|
||||
self.xref_split_drag = false;
|
||||
iced::Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `id` is currently rendered (not closed, not on the start screen /
|
||||
/// clean-screen viewport). Mirrors the visibility filter the edge column
|
||||
/// renderer applies before building each side's stack.
|
||||
pub(crate) fn dock_panel_visible(&self, id: crate::ui::dock::PanelId) -> bool {
|
||||
use crate::ui::dock::PanelId;
|
||||
if self.tabs[self.active_tab].is_start || self.clean_screen {
|
||||
return false;
|
||||
}
|
||||
match id {
|
||||
PanelId::Properties => self.show_properties,
|
||||
PanelId::BlockPalette => self.show_block_palette,
|
||||
PanelId::ExternalReferences => self.show_external_references,
|
||||
PanelId::Browser => self.show_browser,
|
||||
PanelId::NodeGraph => self.show_node_graph,
|
||||
PanelId::PointCloudManager => self.pc_manager.show,
|
||||
PanelId::Count => self.count_palette.show,
|
||||
PanelId::SheetSetManager => self.sheet_set.show,
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of panels currently rendered on `side`. Closed panels keep their
|
||||
/// stack slot but take no vertical space, so drag preview geometry must
|
||||
/// size slots against only the panels that are actually visible.
|
||||
pub(crate) fn dock_visible_len(&self, side: crate::app::config::DockSide) -> usize {
|
||||
let ids: &[crate::ui::dock::PanelId] = match side {
|
||||
crate::app::config::DockSide::Left => &self.dock.left,
|
||||
crate::app::config::DockSide::Right => &self.dock.right,
|
||||
};
|
||||
ids.iter()
|
||||
.filter(|id| self.dock_panel_visible(**id))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Rebuild the Reference Manager's entry list from the active drawing.
|
||||
/// Uses the tab's session sets (unloaded + stat baseline) so CLI and
|
||||
/// palette agree; writes fresh load-time mtimes back into the tab's stat
|
||||
@@ -1605,134 +1469,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockpalette_pin_toggles_autocollapse_and_close_hides() {
|
||||
let mut app = fresh();
|
||||
app.show_block_palette = true;
|
||||
let id = crate::ui::dock::PanelId::BlockPalette;
|
||||
let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id));
|
||||
assert!(app.dock.auto_collapse(id), "pin enables auto-collapse");
|
||||
let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id));
|
||||
assert!(
|
||||
!app.dock.auto_collapse(id),
|
||||
"second pin disables auto-collapse"
|
||||
);
|
||||
let _ = app.on_dock(crate::ui::dock::DockMsg::Close(id));
|
||||
assert!(!app.show_block_palette, "close dismisses the sidebar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockpalette_dock_moves_to_other_side_and_persists() {
|
||||
let mut app = fresh();
|
||||
// Tests load the user's persisted config; reset the dock to a known
|
||||
// state so this stays hermetic.
|
||||
app.dock = Default::default();
|
||||
let id = crate::ui::dock::PanelId::BlockPalette;
|
||||
assert_eq!(
|
||||
app.dock.location(id),
|
||||
Some((crate::app::config::DockSide::Right, 0))
|
||||
);
|
||||
let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id));
|
||||
app.win_size = (1600.0, 900.0).into();
|
||||
let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(
|
||||
100.0, 100.0,
|
||||
)));
|
||||
assert_eq!(
|
||||
app.dock_drag_target,
|
||||
Some((crate::app::config::DockSide::Left, 0))
|
||||
);
|
||||
let _ = app.on_dock(crate::ui::dock::DockMsg::DragRelease);
|
||||
assert_eq!(
|
||||
app.dock.location(id),
|
||||
Some((crate::app::config::DockSide::Left, 0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_visible_len_counts_only_rendered_panels() {
|
||||
let mut app = fresh();
|
||||
app.dock = Default::default();
|
||||
app.dock.left = vec![
|
||||
crate::ui::dock::PanelId::BlockPalette,
|
||||
crate::ui::dock::PanelId::Properties,
|
||||
];
|
||||
// Left: block palette hidden, properties shown -> 1 visible panel.
|
||||
app.show_block_palette = false;
|
||||
app.show_properties = true;
|
||||
assert_eq!(app.dock_visible_len(crate::app::config::DockSide::Left), 1);
|
||||
// Reveal the block palette -> both count.
|
||||
app.show_block_palette = true;
|
||||
assert_eq!(app.dock_visible_len(crate::app::config::DockSide::Left), 2);
|
||||
// A hidden (closed) panel counts for nothing even when stacked.
|
||||
app.show_block_palette = false;
|
||||
assert_eq!(app.dock_visible_len(crate::app::config::DockSide::Left), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_drag_target_ignores_hidden_panels_on_the_side() {
|
||||
// Reproduce the persisted layout that exposed a half-height ghost: two
|
||||
// panels live in the left stack but the block palette is hidden
|
||||
// (show_block_palette=false), so only Properties renders. Drag geometry
|
||||
// must count only panels that are actually visible.
|
||||
let mut app = fresh();
|
||||
app.dock = Default::default();
|
||||
app.dock.left = vec![
|
||||
crate::ui::dock::PanelId::BlockPalette,
|
||||
crate::ui::dock::PanelId::Properties,
|
||||
];
|
||||
app.show_block_palette = false;
|
||||
app.show_properties = true;
|
||||
let i = app.active_tab;
|
||||
app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600.0, 900.0);
|
||||
app.win_size = (1600.0, 900.0).into();
|
||||
let id = crate::ui::dock::PanelId::Properties;
|
||||
let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id));
|
||||
// Pointer near the bottom of the left edge: one visible panel means a
|
||||
// single slot, so every y maps to index 0 (no top/bottom split).
|
||||
let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(
|
||||
100.0, 850.0,
|
||||
)));
|
||||
assert_eq!(
|
||||
app.dock_drag_target,
|
||||
Some((crate::app::config::DockSide::Left, 0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dock_drag_target_counts_all_visible_panels() {
|
||||
let mut app = fresh();
|
||||
app.dock = Default::default();
|
||||
app.dock.left = vec![
|
||||
crate::ui::dock::PanelId::BlockPalette,
|
||||
crate::ui::dock::PanelId::Properties,
|
||||
];
|
||||
// Both panels shown: two real slots on the left edge. A pointer near
|
||||
// the bottom maps to the append slot (index == 2).
|
||||
app.show_block_palette = true;
|
||||
app.show_properties = true;
|
||||
let i = app.active_tab;
|
||||
app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600.0, 900.0);
|
||||
app.win_size = (1600.0, 900.0).into();
|
||||
let id = crate::ui::dock::PanelId::Properties;
|
||||
let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id));
|
||||
let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(
|
||||
100.0, 850.0,
|
||||
)));
|
||||
assert_eq!(
|
||||
app.dock_drag_target,
|
||||
Some((crate::app::config::DockSide::Left, 2))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blockpalette_width_reset() {
|
||||
let mut app = fresh();
|
||||
let id = crate::ui::dock::PanelId::BlockPalette;
|
||||
app.dock.set_width(id, 500.0);
|
||||
let _ = app.on_dock(crate::ui::dock::DockMsg::WidthReset(id));
|
||||
assert_eq!(app.dock.settings(id).width, 260.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_name_from_file_avoids_collisions() {
|
||||
let mut app = fresh();
|
||||
|
||||
@@ -682,6 +682,7 @@ impl OpenCADStudio {
|
||||
self.dyn_coord_absolute = false;
|
||||
self.sync_dyn_fields();
|
||||
self.reset_tracking_after_point();
|
||||
self.snapper.clear_override();
|
||||
self.snapper.remember_distance_reference(dir);
|
||||
self.push_ucs_to_cmd(i);
|
||||
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt));
|
||||
@@ -755,6 +756,7 @@ impl OpenCADStudio {
|
||||
self.dyn_coord_absolute = false;
|
||||
self.sync_dyn_fields();
|
||||
self.reset_tracking_after_point();
|
||||
self.snapper.clear_override();
|
||||
self.push_ucs_to_cmd(i);
|
||||
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt));
|
||||
for f in self.tabs[i].dyn_fields.iter_mut() {
|
||||
|
||||
+1306
-121
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+762
-141
File diff suppressed because it is too large.
Load diff
@@ -13,6 +13,7 @@ use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip
|
||||
use crate::scene::{
|
||||
self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
|
||||
};
|
||||
use crate::ui::window::graphic_attributes::GraphicAttributesMsg;
|
||||
use crate::ui::PropertiesPanel;
|
||||
use codec::types::Color as AcadColor;
|
||||
use codec::{EntityType as AcadEntityType, Handle};
|
||||
@@ -1180,6 +1181,17 @@ impl OpenCADStudio {
|
||||
}
|
||||
let s = crate::ui::color_select::color_to_aci_string(color);
|
||||
let edit = match self.color_picker.take().map(|picker| picker.target) {
|
||||
Some(crate::app::ColorPickTarget::Gradient(index)) => {
|
||||
Some(Message::GraphicAttributes(GraphicAttributesMsg::Gradient(
|
||||
crate::ui::window::gradient_editor::GradientMsg::Color(index, color),
|
||||
)))
|
||||
}
|
||||
Some(crate::app::ColorPickTarget::GraphicAttributesLine) => Some(
|
||||
Message::GraphicAttributes(GraphicAttributesMsg::LineColor(color)),
|
||||
),
|
||||
Some(crate::app::ColorPickTarget::GraphicAttributesSolid) => Some(
|
||||
Message::GraphicAttributes(GraphicAttributesMsg::FillColor(color)),
|
||||
),
|
||||
Some(crate::app::ColorPickTarget::DimStyle(f)) => Some(Message::DsEdit(f, s)),
|
||||
Some(crate::app::ColorPickTarget::MLeader(f)) => {
|
||||
Some(Message::MLeaderStyleEdit { field: f, value: s })
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::scene::Scene;
|
||||
/// Returns (1.0, 1.0) for "Fit" or unknown formats.
|
||||
/// Sync the model-space annotation scale into its named drawing variable and
|
||||
/// numeric header mirror before a save.
|
||||
pub(super) fn sync_annotation_scale_header(scene: &mut Scene) {
|
||||
pub(in crate::app) fn sync_annotation_scale_header(scene: &mut Scene) {
|
||||
let anno = scene.annotation_scale;
|
||||
let value = if anno.abs() > 1e-9 {
|
||||
1.0 / anno as f64
|
||||
|
||||
@@ -5976,15 +5976,25 @@ properties={:.1}ms picked={}",
|
||||
}
|
||||
|
||||
fn snap_view_region(&mut self, region: CubeRegion, r_ucs: glam::Mat4) -> Task<Message> {
|
||||
self.snap_view_region_as(region, r_ucs, true)
|
||||
}
|
||||
|
||||
/// A standard view preset (-VIEW): the world view itself, kept when it
|
||||
/// is shown already, with no cube message.
|
||||
pub(in crate::app) fn snap_view_preset(&mut self, region: CubeRegion) -> Task<Message> {
|
||||
self.snap_view_region_as(region, glam::Mat4::IDENTITY, false)
|
||||
}
|
||||
|
||||
fn snap_view_region_as(&mut self, region: CubeRegion, r_ucs: glam::Mat4, cube: bool) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
self.clear_navigation_hover(i);
|
||||
self.tabs[i].scene.remember_current_view();
|
||||
let mut region = region;
|
||||
// "Already there → flip to opposite" check: compare the
|
||||
// current gaze direction with the region's target gaze.
|
||||
// "Already there → flip to opposite" check for a cube click: compare
|
||||
// the current gaze direction with the region's target gaze.
|
||||
let target_dir = r_ucs.transform_vector3(region.snap_direction());
|
||||
let cur_dir = self.tabs[i].scene.active_gaze_dir();
|
||||
if cur_dir.dot(target_dir) > 0.9999 {
|
||||
if cube && cur_dir.dot(target_dir) > 0.9999 {
|
||||
region = region.opposite();
|
||||
}
|
||||
let eye_dir = r_ucs.transform_vector3(region.snap_direction());
|
||||
@@ -6014,8 +6024,10 @@ properties={:.1}ms picked={}",
|
||||
}
|
||||
}
|
||||
self.tabs[i].scene.camera_generation += 1;
|
||||
self.command_line
|
||||
.push_output(crate::tf!("View: {}", crate::t!(region.label())).as_ref());
|
||||
if cube {
|
||||
self.command_line
|
||||
.push_output(crate::tf!("View: {}", crate::t!(region.label())).as_ref());
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,59 @@ impl OpenCADStudio {
|
||||
self.accepted_snaps.clear();
|
||||
}
|
||||
|
||||
/// The active command is at a point prompt (keywords allowed) and not
|
||||
/// picking an object: where MTP and typed snap overrides apply.
|
||||
pub(crate) fn point_modifier_step(&self, i: usize) -> bool {
|
||||
self.tabs[i].active_cmd.as_ref().is_some_and(|c| {
|
||||
(!c.input_kind().wants_text() || c.point_step_accepts_keywords())
|
||||
&& !c.needs_entity_pick()
|
||||
})
|
||||
}
|
||||
|
||||
/// A snap override typed at a point prompt (`MID`, `END`, `NON`, ...). An
|
||||
/// option the prompt offers wins, so `CEN` where Center is offered picks
|
||||
/// Center.
|
||||
pub(crate) fn typed_snap_override(
|
||||
&self,
|
||||
i: usize,
|
||||
token: &str,
|
||||
) -> Option<crate::snap::SnapOverrideKind> {
|
||||
let kind = crate::snap::parse_snap_override(token)?;
|
||||
if !self.point_modifier_step(i) {
|
||||
return None;
|
||||
}
|
||||
let typed = token.trim().trim_start_matches('_').to_uppercase();
|
||||
let offered = self.tabs[i].active_cmd.as_ref()?.options().iter().any(|option| {
|
||||
option.keyword.eq_ignore_ascii_case(&typed)
|
||||
|| option.label.to_uppercase().replace(' ', "").starts_with(&typed)
|
||||
});
|
||||
(!offered).then_some(kind)
|
||||
}
|
||||
|
||||
/// Apply a transparent one-shot snap override (from typed input or the context menu).
|
||||
pub(crate) fn apply_snap_override(&mut self, kind: crate::snap::SnapOverrideKind) {
|
||||
match kind {
|
||||
crate::snap::SnapOverrideKind::Mode(t) => {
|
||||
self.snap_override_popup = None;
|
||||
self.snapper.set_override(t);
|
||||
let label = crate::snap::ALL_SNAP_MODES
|
||||
.iter()
|
||||
.chain(crate::snap::ALL_3D_SNAP_MODES.iter())
|
||||
.find(|(m, _, _)| *m == t)
|
||||
.map(|(_, _, l)| *l)
|
||||
.unwrap_or("Snap");
|
||||
self.command_line
|
||||
.push_info(crate::tf!("Snap override: {label} (next pick only).").as_ref());
|
||||
}
|
||||
crate::snap::SnapOverrideKind::None => {
|
||||
self.snap_override_popup = None;
|
||||
self.snapper.set_override_none();
|
||||
self.command_line
|
||||
.push_info(crate::t!("Snap override: None (next pick only).").as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Record the snap a point step just accepted. `snap` is the displayed snap
|
||||
/// result (already in the current space's coordinates) or `None` for a free
|
||||
/// / typed point; `committed` is the point the command actually received
|
||||
|
||||
Reference in new issue
Block a user