fix: open-issue sweep — drawing, plot, snap, layout and UI fixes

Fixes:
- Font prompt no longer lists bundled ltypeshp/qcadshp shape files (#1626)
- Wide polylines step at a discontinuous segment width; PEDIT marks the
  current vertex; keyword steps refresh the command preview (#1628)
- Radius-0 fillet drops the zero-length segment it left, so the corner
  fillets again (#1422)
- FILLET Trim / No trim option (#1558)
- True-colour wires plot in their own colour; colour picker stores the
  value it shows (#1621)
- Model tab keeps its page setup; "Save changes to layout" in Model (#1620)
- QNEW default template in Options > Files; .dwt in the open dialog
  (#1134, #1113)
- GRIDUNIT / GRIDMODE / PERSPECTIVE variables (#1618, #1524)
- PTYPE (#1433)
- ZOOM nX / nXP (#1465)
- CHSPACE between model and paper space through a viewport (#1428)
- Moving a dimension keeps its automatic text placement
- Shell icon; "&&" button labels (#1554)
- LINETYPE Load from a .lin file (#1588)
- XLINE / RAY hide the crosshair arms (#1437)
- Dynamic-input boxes keep clear of the crosshair and each other (#1546)
- Snap marker colour setting, dark default on light canvases
  (#1497, #1229, #1128)
- Dimension style precision as a 0.00 pattern dropdown (#1426)
- Node graph nodes left of the origin no longer clip away (#1592)
- Collapse button in the command history header (#1094)
- Freeze-in-current-viewport toggle in the ribbon layer list (#1466)
- Geometric Center object snap for closed polylines (#1369)

New strings translated in all 21 locales.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 5.5 committed 2026-10-06 15:59:30 +03:00
1 parent abf302eba5
commit 178c7dff41
70 files changed
+1227 -145

No files matched your search

+20 -4
View File
@@ -911,13 +911,29 @@ impl OpenCADStudio {
.or_else(|| cmd.strip_prefix("ZOOM S "))
.or_else(|| cmd.strip_prefix("ZS "))
.unwrap_or("1");
if let Ok(factor) = rest.trim().parse::<f32>() {
if factor > 0.0 {
self.tabs[i].scene.remember_current_view();
self.tabs[i].scene.zoom_camera(1.0 / factor);
// nXP scales the active viewport against paper space, nX (or
// a bare n) the current view (#1465). n may be an expression
// such as 1/50.
let rest = rest.trim().to_ascii_uppercase();
let (number, xp) = match rest.strip_suffix("XP") {
Some(n) => (n, true),
None => (rest.strip_suffix('X').unwrap_or(&rest), false),
};
let factor = crate::app::expr_eval::eval_number(number.trim())
.filter(|f| f.is_finite() && *f > 0.0);
match factor {
Some(factor) if xp && self.tabs[i].scene.set_viewport_scale_factor(factor) => {
self.tabs[i].dirty = true;
self.command_line
.push_output(crate::tf!("Zoom Scale ×{factor:.3}").as_ref());
}
Some(factor) => {
self.tabs[i].scene.remember_current_view();
self.tabs[i].scene.zoom_camera(1.0 / factor as f32);
self.command_line
.push_output(crate::tf!("Zoom Scale ×{factor:.3}").as_ref());
}
None => {}
}
}
+4
View File
@@ -634,6 +634,9 @@ inventory::submit!(crate::command::CommandRegistration {
"FITSPLINE",
// System variables (typeable directly).
"MIRRTEXT",
"PERSPECTIVE",
"GRIDMODE",
"GRIDUNIT",
"ZOOMWHEEL",
"ZOOMFACTOR",
"SHORTCUTMENU",
@@ -844,6 +847,7 @@ inventory::submit!(crate::command::CommandRegistration {
"MLSTYLE",
"MS",
"MSPACE",
"CHSPACE",
"NAVVCUBE",
"NEW",
"OBJIMPORT",
+69 -2
View File
@@ -3,6 +3,65 @@ use super::*;
impl OpenCADStudio {
pub(super) fn dispatch_styleprops(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
match cmd {
// View / grid variables held by the app rather than the header,
// typed bare or through SETVAR (#1524, #1618).
cmd if matches!(
cmd.strip_prefix("SETVAR ").unwrap_or(cmd).split_whitespace().next(),
Some("PERSPECTIVE" | "GRIDMODE" | "GRIDUNIT")
) =>
{
let mut words = cmd.strip_prefix("SETVAR ").unwrap_or(cmd).splitn(2, ' ');
let name = words.next().unwrap_or_default().to_string();
let value = words.next().map(str::trim).filter(|v| !v.is_empty());
let current = match name.as_str() {
"PERSPECTIVE" => {
let persp = self.tabs[i].scene.camera.borrow().projection
== crate::scene::Projection::Perspective;
(persp as i32).to_string()
}
"GRIDMODE" => (self.show_grid as i32).to_string(),
_ => format!("{},{}", self.grid_spacing_x, self.grid_spacing_y),
};
let Some(value) = value else {
self.command_line
.push_output(&crate::tf!("Enter new value for {name} <{current}>:"));
self.pending_setvar = Some(name);
return Some(Task::none());
};
match (name.as_str(), value) {
("PERSPECTIVE", "0" | "1") => {
return Some(Task::done(Message::SetProjection(value == "0")));
}
("GRIDMODE", "0" | "1") => {
if (value == "1") != self.show_grid {
return Some(Task::done(Message::ToggleGrid));
}
}
("GRIDUNIT", _) => {
let mut parts = value.split(',').map(|p| p.trim().parse::<f32>());
let x = parts.next().and_then(Result::ok).filter(|v| *v > 0.0);
let y = match parts.next() {
Some(p) => p.ok().filter(|v| *v > 0.0),
None => x,
};
let (Some(x), Some(y)) = (x, y) else {
self.command_line.push_error(
crate::t!("Requires two positive values (x,y) or one for both.")
.as_ref(),
);
return Some(Task::none());
};
self.grid_spacing_x = x;
self.grid_spacing_y = y;
self.sync_vport_display(i);
self.persist_settings_if_changed();
}
_ => self
.command_line
.push_error(crate::t!("Requires 0 or 1").as_ref()),
}
return Some(Task::none());
}
"CETRANSPARENCY" => return self.dispatch_styleprops("SETVAR CETRANSPARENCY", i),
cmd if cmd.starts_with("CETRANSPARENCY ") => {
return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i);
@@ -84,9 +143,10 @@ impl OpenCADStudio {
use crate::command::KeywordCommand;
let c = KeywordCommand::new(
"LINETYPE",
"LINETYPE [List / Set]:",
"LINETYPE [List / Set / Load]:",
vec![
("List", "LIST", None),
("Load", "LOAD", None),
("Set", "SET", Some("LINETYPE SET linetype name (ByLayer / ByBlock / …):")),
],
);
@@ -98,6 +158,13 @@ impl OpenCADStudio {
let parts: Vec<&str> = raw_rest.split_whitespace().collect();
let sub = parts.get(0).map(|s| s.to_uppercase()).unwrap_or_default();
match sub.as_str() {
// Load the definitions of a `.lin` file (#1588).
"LOAD" | "L" => {
return Some(Task::perform(
crate::io::pick_linetype_file(),
Message::LinetypeLoaded,
));
}
"" | "LIST" | "?" => {
let ltypes: Vec<String> = self.tabs[i]
.scene
@@ -3247,7 +3314,7 @@ impl OpenCADStudio {
);
}
}
cmd if cmd == "DDPTYPE" => {
cmd if cmd == "DDPTYPE" || cmd == "PTYPE" => {
// The dialog shows the magnitude; the sign (relative/absolute)
// is driven by the radio buttons. A positive PDSIZE is absolute;
// zero or negative is relative.
+96
View File
@@ -548,6 +548,102 @@ impl OpenCADStudio {
return Some(Task::done(Message::PspaceCommand));
}
// ── CHSPACE — move objects between model and paper space through
// a viewport, keeping their apparent size and place (#1428).
// Inside a viewport the selection goes to the sheet; on the
// sheet it goes into the model through the viewport under it.
"CHSPACE" => {
let handles: Vec<codec::Handle> = self.tabs[i]
.scene
.selected_entities()
.into_iter()
.map(|(h, _)| h)
.collect();
if self.tabs[i].scene.current_layout == "Model" {
self.command_line
.push_error(crate::t!("CHSPACE: Command not allowed in the Model tab.").as_ref());
return Some(Task::none());
}
if handles.is_empty() {
use crate::modules::draw::select::SelectObjectsCommand;
let cmd = SelectObjectsCommand::new("CHSPACE");
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
return Some(Task::none());
}
let scene = &self.tabs[i].scene;
let to_paper = scene.active_viewport.is_some();
let frame = match scene.active_viewport {
Some(vp) => scene.viewport_frame(vp),
None => {
// The viewport under the middle of the selection.
let (mut lo, mut hi) = (glam::DVec2::MAX, glam::DVec2::MIN);
for wire in scene.wire_models_for(&handles) {
let [x0, y0, x1, y1] = wire.aabb.map(f64::from);
if [x0, y0, x1, y1].iter().all(|v| v.is_finite()) {
lo = lo.min(glam::DVec2::new(x0, y0));
hi = hi.max(glam::DVec2::new(x1, y1));
}
}
let mid = (lo + hi) * 0.5;
mid.is_finite()
.then(|| scene.viewport_frames_at_paper_point(mid.extend(0.0)))
.and_then(|frames| frames.into_iter().next())
}
};
let Some(frame) = frame else {
self.command_line.push_error(
crate::t!("CHSPACE: No plan-view viewport to change space through.").as_ref(),
);
return Some(Task::none());
};
use crate::command::EntityTransform as T;
let z = glam::DVec3::Z;
let steps = if to_paper {
[
T::Translate((-frame.model_target).extend(0.0)),
T::Rotate { center: glam::DVec3::ZERO, axis: z, angle_rad: frame.twist },
T::Scale { center: glam::DVec3::ZERO, factor: frame.scale },
T::Translate(frame.paper_center.extend(0.0)),
]
} else {
[
T::Translate((-frame.paper_center).extend(0.0)),
T::Scale { center: glam::DVec3::ZERO, factor: 1.0 / frame.scale },
T::Rotate { center: glam::DVec3::ZERO, axis: z, angle_rad: -frame.twist },
T::Translate(frame.model_target.extend(0.0)),
]
};
self.push_undo_snapshot(i, "CHSPACE");
let scene = &mut self.tabs[i].scene;
let entities: Vec<codec::EntityType> = handles
.iter()
.filter_map(|h| scene.document.get_entity(*h).cloned())
.collect();
scene.deselect_all();
scene.erase_entities(&handles);
// The clones land in the other space: the sheet with no active
// viewport, the model with one.
let active = scene.active_viewport;
scene.active_viewport = if to_paper { None } else { Some(frame.viewport) };
let moved: Vec<codec::Handle> =
entities.into_iter().map(|e| scene.add_entity_clone(e)).collect();
scene.active_viewport = active;
for step in &steps {
scene.transform_entities(&moved, step);
}
self.tabs[i].dirty = true;
let count = moved.len();
self.command_line.push_output(
if to_paper {
crate::tf!("CHSPACE: {count} object(s) changed to paper space.")
} else {
crate::tf!("CHSPACE: {count} object(s) changed to model space.")
}
.as_ref(),
);
}
// ── Viewport arrangement shortcuts ────────────────────────────
// Tile the model viewports into preset splits. Each delegates to the
// matching VPORTS configuration so the Model/paper handling stays in