feat(view): implement LIMITS command

Closes #478
This commit is contained in:
Hakan Seven 2026-07-27 16:15:28 +03:00
commit 8018ab1523
15 changed files with 478 additions and 6 deletions

4
AGENTS.md Normal file
View file

@ -0,0 +1,4 @@
# Agent Instructions
- Never run `cargo fmt`, `rustfmt`, or any automatic source-formatting command in this repository.
- Preserve the existing formatting and make only targeted edits.

View file

@ -241,6 +241,7 @@ Status of every standard CAD command in Open CAD Studio:
| Command | Alias | Description | Status |
|---|---|---|---|
| `ZOOM` | Z | Zoom | ✅ |
| `LIMITS` | — | Drawing/grid limits and point checking | ✅ |
| `PAN` | P | Pan | ✅ |
| `ORBIT` | 3DO | 3D orbit | ✅ |
| `VPORTS` | — | Viewport configuration | ✅ |
@ -356,11 +357,11 @@ Status of every standard CAD command in Open CAD Studio:
| Layer | 19 | 17 | 0 | 2 |
| Block & Reference | 23 | 18 | 2 | 3 |
| 3D Modeling | 27 | 25 | 0 | 2 |
| View & Navigation | 32 | 15 | 8 | 9 |
| View & Navigation | 33 | 16 | 8 | 9 |
| Inquiry | 12 | 12 | 0 | 0 |
| File & Plot | 17 | 16 | 0 | 1 |
| Manage & Customize | 22 | 13 | 3 | 6 |
| **Total** | **262** | **219** | **15** | **28** |
| **Total** | **263** | **220** | **15** | **28** |
> Counts include commands listed under more than one category (e.g. `SLICE`, `HELIX`,
> `MINSERT`, `SUBTRACT`/`UNION`/`INTERSECT` appear in both their 2D and 3D groups).

View file

@ -4,6 +4,22 @@ use acadrust::Handle;
use iced::Task;
impl OpenCADStudio {
/// Apply LIMCHECK/PLIMCHECK to a point before an interactive command
/// consumes it. LIMITS itself must be able to redefine a rectangle beyond
/// the old boundary, so it is the sole bypass.
pub(super) fn command_point_allowed(&mut self, i: usize, point: glam::DVec3) -> bool {
let checks_limits = self.tabs[i]
.active_cmd
.as_ref()
.is_some_and(|command| command.name() != "LIMITS")
&& self.tabs[i].scene.drawing_limit_check_enabled();
if checks_limits && !self.tabs[i].scene.point_inside_drawing_limits(point) {
self.command_line.push_error("Outside limits.");
return false;
}
true
}
/// Drive the active command's step machine with one [`StepInput`], then
/// apply the result. This is the single entry point every input source
/// (command line, headless, dynamic input, plugin API, viewport) funnels
@ -19,6 +35,11 @@ impl OpenCADStudio {
}
}
let i = self.active_tab;
if let StepInput::Point(point) = &input {
if !self.command_point_allowed(i, *point) {
return Task::none();
}
}
let ctrl = self.ctrl_down;
let shift = self.shift_down;
let result: Option<CmdResult> = {
@ -229,6 +250,9 @@ impl OpenCADStudio {
None => coord,
},
};
if !self.command_point_allowed(i, wcs) {
return;
}
self.last_point = Some(wcs);
self.push_ucs_to_cmd(i);
let _ = self.feed_command(StepInput::Point(wcs));

View file

@ -692,9 +692,9 @@ impl OpenCADStudio {
self.command_line.push_output("Zoom Out");
}
// ZOOM ALL — fit all entities (same as EXTENTS for now)
// ZOOM ALL — fit the configured drawing limits.
"ZOOM ALL" | "ZOOM A" | "ZA" => {
self.tabs[i].scene.fit_all();
self.tabs[i].scene.fit_all_with_limits();
self.command_line.push_output("Zoom All");
}

View file

@ -142,6 +142,62 @@ impl OpenCADStudio {
return Some(Task::done(Message::ToggleViewCube));
}
// ── LIMITS — drawing/grid boundary for the active space ─────────────
"LIMITS" => {
use crate::modules::view::limits::LimitsCommand;
let (min, max) = self.tabs[i]
.scene
.current_drawing_limits()
.unwrap_or((glam::DVec2::ZERO, glam::DVec2::new(12.0, 9.0)));
let command = LimitsCommand::new(min, max);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"LIMITS ON" | "LIMITS OFF" => {
let enabled = cmd.ends_with("ON");
if self.tabs[i].scene.drawing_limit_check_enabled() != enabled {
self.push_undo_snapshot(i, "LIMITS");
self.tabs[i].scene.set_drawing_limit_check(enabled);
self.tabs[i].dirty = true;
}
self.command_line.push_output(if enabled {
"Limits checking ON."
} else {
"Limits checking OFF."
});
}
cmd if cmd.starts_with("LIMITS SET ") => {
let tokens: Vec<&str> = cmd["LIMITS SET ".len()..].split_whitespace().collect();
let values: Result<Vec<f64>, _> =
tokens.iter().map(|value| value.parse()).collect();
let Ok(values) = values else {
self.command_line
.push_error("LIMITS: four numeric coordinates required.");
return Some(Task::none());
};
if tokens.len() != 4 || !values.iter().all(|value| value.is_finite()) {
self.command_line
.push_error("LIMITS: four finite numeric coordinates required.");
} else {
let first = glam::DVec2::new(values[0], values[1]);
let opposite = glam::DVec2::new(values[2], values[3]);
let min = first.min(opposite);
let max = first.max(opposite);
if min.x == max.x || min.y == max.y {
self.command_line
.push_error("LIMITS: corners must define a non-zero area.");
} else {
self.push_undo_snapshot(i, "LIMITS");
self.tabs[i].scene.set_current_drawing_limits(min, max);
self.tabs[i].dirty = true;
self.command_line.push_output(&format!(
"Drawing limits: {:.4},{:.4} to {:.4},{:.4}.",
min.x, min.y, max.x, max.y
));
}
}
}
// ── PROPERTIES — toggle Properties panel visibility ──────────────────
"PROPERTIES" | "PROPS" => {
return Some(Task::done(Message::ToggleProperties));

View file

@ -90,8 +90,14 @@ impl OpenCADStudio {
.borrow_mut()
.right_click_entered = false;
// A fresh command starts at the polar/cartesian default — clear
// any `,`-driven reshape from a previous command (#35).
// any `,`-driven reshape and locked dynamic-input values from a
// previous command. Otherwise a bare Enter on the first point prompt
// can commit that stale coordinate instead of accepting the command's
// default (LIMITS then compares an unintended lower-left point with
// the displayed default upper-right).
self.dyn_user_reshaped = false;
self.tabs[i].dyn_fields.clear();
self.tabs[i].dyn_active = 0;
if let Some(path_str) = cmd.strip_prefix("OPEN_RECENT:") {
let path = PathBuf::from(path_str);
@ -208,6 +214,7 @@ impl OpenCADStudio {
let i = self.active_tab;
if self.tabs[i].active_cmd.is_some() {
self.tabs[i].last_cmd = Some(cmd.to_string());
self.sync_dyn_fields();
self.focus_cmd_input()
} else {
Task::none()

View file

@ -323,6 +323,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
if let Some((base, dir)) = self.otrack_active {
if let Some(dist) = crate::app::expr_eval::eval_number(text.trim()) {
let pt = base + dir * dist;
if !self.command_point_allowed(i, pt) {
return Task::none();
}
self.last_point = Some(pt);
self.dyn_user_reshaped = false;
self.sync_dyn_fields();
@ -365,6 +368,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
}
}
};
if !self.command_point_allowed(i, wcs_pt) {
return Task::none();
}
self.last_point = Some(wcs_pt);
self.dyn_user_reshaped = false;
self.sync_dyn_fields();

View file

@ -484,6 +484,9 @@ impl OpenCADStudio {
{
if let Some(dist) = crate::app::expr_eval::eval_number(text.trim()) {
let pt = base + dir * dist;
if !self.command_point_allowed(i, pt) {
return Some(Task::none());
}
self.last_point = Some(pt);
for f in self.tabs[i].dyn_fields.iter_mut() {
f.buffer = None;
@ -547,6 +550,9 @@ impl OpenCADStudio {
});
}
let pt = self.dyn_resolve_point()?;
if !self.command_point_allowed(i, pt) {
return Some(Task::none());
}
self.last_point = Some(pt);
self.dyn_user_reshaped = false;
self.sync_dyn_fields();

View file

@ -2383,6 +2383,8 @@ impl OpenCADStudio {
self.command_line.push_info("Select a tangent object.");
None
}
} else if !self.command_point_allowed(i, world_pt) {
None
} else {
// A scalar typed into the dynamic-input box but not
// yet confirmed with Enter is applied before the

View file

@ -238,6 +238,7 @@ impl OpenCADStudio {
),
origin,
axes,
limits: tab.scene.grid_limits_for_viewport(handle),
}
})
.collect();

View file

@ -0,0 +1,98 @@
use glam::{DVec2, DVec3};
use crate::command::{CadCommand, CmdOption, CmdResult};
enum LimitsStep {
FirstCorner,
OppositeCorner(DVec2),
}
/// Interactive front-end for LIMITS. The command itself only gathers input;
/// the dispatched form mutates the active drawing/layout in one central place.
pub struct LimitsCommand {
step: LimitsStep,
current_min: DVec2,
current_max: DVec2,
}
impl LimitsCommand {
pub fn new(current_min: DVec2, current_max: DVec2) -> Self {
Self {
step: LimitsStep::FirstCorner,
current_min,
current_max,
}
}
fn point_text(point: DVec2) -> String {
format!("{:.17} {:.17}", point.x, point.y)
}
}
impl CadCommand for LimitsCommand {
fn name(&self) -> &'static str {
"LIMITS"
}
fn prompt(&self) -> String {
match self.step {
LimitsStep::FirstCorner => format!(
"LIMITS Specify first corner or [On / Off] <{:.4},{:.4}>:",
self.current_min.x, self.current_min.y
),
LimitsStep::OppositeCorner(_) => format!(
"LIMITS Specify opposite corner <{:.4},{:.4}>:",
self.current_max.x, self.current_max.y
),
}
}
fn options(&self) -> Vec<CmdOption> {
match self.step {
LimitsStep::FirstCorner => vec![CmdOption::new("On", "ON"), CmdOption::new("Off", "OFF")],
LimitsStep::OppositeCorner(_) => Vec::new(),
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if !matches!(self.step, LimitsStep::FirstCorner) {
return None;
}
match text.trim().to_ascii_uppercase().as_str() {
"ON" => Some(CmdResult::Dispatch("LIMITS ON".to_string())),
"OFF" => Some(CmdResult::Dispatch("LIMITS OFF".to_string())),
_ => None,
}
}
fn on_point(&mut self, point: DVec3) -> CmdResult {
let point = point.truncate();
match self.step {
LimitsStep::FirstCorner => {
self.step = LimitsStep::OppositeCorner(point);
CmdResult::NeedPoint
}
LimitsStep::OppositeCorner(first) => CmdResult::Dispatch(format!(
"LIMITS SET {} {}",
Self::point_text(first),
Self::point_text(point)
)),
}
}
fn on_enter(&mut self) -> CmdResult {
match self.step {
LimitsStep::FirstCorner => {
self.step = LimitsStep::OppositeCorner(self.current_min);
CmdResult::NeedPoint
}
LimitsStep::OppositeCorner(first) => CmdResult::Dispatch(format!(
"LIMITS SET {} {}",
Self::point_text(first),
Self::point_text(self.current_max)
)),
}
}
}
inventory::submit!(crate::command::CommandRegistration { names: &["LIMITS"] });

View file

@ -4,6 +4,7 @@ mod cascade;
mod file_tabs;
mod hidden;
mod layout_tabs;
pub mod limits;
mod orbit;
mod ortho;
mod pan;

173
src/scene/limits.rs Normal file
View file

@ -0,0 +1,173 @@
use super::*;
impl Scene {
fn model_limits(&self) -> Option<(glam::DVec2, glam::DVec2)> {
let min = self.document.header.model_space_limits_min;
let max = self.document.header.model_space_limits_max;
Self::valid_limits(
glam::DVec2::new(min.x, min.y),
glam::DVec2::new(max.x, max.y),
)
}
fn paper_layout_limits(&self) -> Option<(glam::DVec2, glam::DVec2)> {
self.document.objects.values().find_map(|object| {
let ObjectType::Layout(layout) = object else {
return None;
};
(layout.name == self.current_layout).then(|| {
Self::valid_limits(
glam::DVec2::new(layout.min_limits.0, layout.min_limits.1),
glam::DVec2::new(layout.max_limits.0, layout.max_limits.1),
)
})?
})
}
fn valid_limits(min: glam::DVec2, max: glam::DVec2) -> Option<(glam::DVec2, glam::DVec2)> {
const SANE_LIMIT: f64 = 1.0e16;
(min.is_finite()
&& max.is_finite()
&& min.x < max.x
&& min.y < max.y
&& min.abs().max_element() < SANE_LIMIT
&& max.abs().max_element() < SANE_LIMIT)
.then_some((min, max))
}
/// The active input space is model space on the Model tab and while editing
/// through a floating paper-space viewport (MSPACE).
pub fn input_uses_model_space(&self) -> bool {
self.current_layout == "Model" || self.active_viewport.is_some()
}
/// LIMITS rectangle for the active point-input space.
pub fn current_drawing_limits(&self) -> Option<(glam::DVec2, glam::DVec2)> {
if self.input_uses_model_space() {
self.model_limits()
} else {
self.paper_layout_limits().or_else(|| {
let min = self.document.header.paper_space_limits_min;
let max = self.document.header.paper_space_limits_max;
Self::valid_limits(
glam::DVec2::new(min.x, min.y),
glam::DVec2::new(max.x, max.y),
)
})
}
}
/// LIMITS rectangle belonging to a rendered grid viewport. Floating
/// viewports display model space; the sheet viewport displays paper space.
pub fn grid_limits_for_viewport(&self, viewport: Handle) -> Option<(glam::DVec2, glam::DVec2)> {
if self.current_layout == "Model" {
return self.model_limits();
}
let sheet = self.current_layout_sheet_viewport_handle();
if viewport.is_valid() && viewport != sheet {
self.model_limits()
} else {
self.paper_layout_limits()
}
}
pub fn drawing_limit_check_enabled(&self) -> bool {
if self.input_uses_model_space() {
self.document.header.limit_check
} else {
self.document.header.paper_space_limit_check
}
}
pub fn point_inside_drawing_limits(&self, point: glam::DVec3) -> bool {
let Some((min, max)) = self.current_drawing_limits() else {
return true;
};
point.x >= min.x && point.x <= max.x && point.y >= min.y && point.y <= max.y
}
pub fn set_drawing_limit_check(&mut self, enabled: bool) {
if self.input_uses_model_space() {
self.document.header.limit_check = enabled;
} else {
self.document.header.paper_space_limit_check = enabled;
}
}
pub fn set_current_drawing_limits(&mut self, min: glam::DVec2, max: glam::DVec2) {
if self.input_uses_model_space() {
self.document.header.model_space_limits_min =
acadrust::types::Vector2::new(min.x, min.y);
self.document.header.model_space_limits_max =
acadrust::types::Vector2::new(max.x, max.y);
} else {
self.document.header.paper_space_limits_min =
acadrust::types::Vector2::new(min.x, min.y);
self.document.header.paper_space_limits_max =
acadrust::types::Vector2::new(max.x, max.y);
}
// Keep the current Layout object synchronized with the header values.
// DWG stores per-layout limits here as well as the current-space header.
for object in self.document.objects.values_mut() {
if let ObjectType::Layout(layout) = object {
if layout.name == self.current_layout {
layout.min_limits = (min.x, min.y);
layout.max_limits = (max.x, max.y);
break;
}
}
}
}
/// ZOOM All frames the configured drawing limits. Object-only framing
/// remains the responsibility of ZOOM Extents.
pub fn fit_all_with_limits(&mut self) {
let Some((limit_min, limit_max)) = self.current_drawing_limits() else {
self.fit_all();
return;
};
let min = glam::Vec3::new(limit_min.x as f32, limit_min.y as f32, 0.0);
let max = glam::Vec3::new(limit_max.x as f32, limit_max.y as f32, 0.0);
// MSPACE owns a camera encoded on the active viewport entity.
if let Some(viewport_handle) = self.active_viewport {
let (width, height, locked) = match self.document.get_entity(viewport_handle) {
Some(EntityType::Viewport(viewport)) => {
(viewport.width, viewport.height, viewport.status.locked)
}
_ => return,
};
if locked {
return;
}
let aspect = (width / height.max(1e-9)) as f32;
let mut camera = match self.viewport_edit_frame(self.selection.borrow().vp_size) {
Some((camera, _)) => camera,
None => return,
};
camera.fit_to_bounds(min, max, aspect.max(0.01));
if let Some(EntityType::Viewport(viewport)) =
self.document.get_entity_mut(viewport_handle)
{
viewport.view_target.x = camera.target.x;
viewport.view_target.y = camera.target.y;
viewport.view_target.z = camera.target.z;
viewport.view_center.x = 0.0;
viewport.view_center.y = 0.0;
viewport.view_height = camera.ortho_size() as f64 * 2.0;
if viewport.view_height > 1e-9 {
viewport.custom_scale = viewport.height / viewport.view_height;
}
}
self.camera_generation += 1;
return;
}
self.camera
.borrow_mut()
.fit_to_bounds(min, max, self.last_render_aspect.get().max(0.01));
self.camera_generation += 1;
}
}

View file

@ -20,6 +20,7 @@ mod camera_ops;
mod entity;
mod group_layer;
mod layout;
mod limits;
mod modify;
mod mspace;
mod page_setup;

View file

@ -52,6 +52,9 @@ pub struct GridParams {
/// `(ZERO, X, Y, Z)`.
pub origin: glam::DVec3,
pub axes: (Vec3, Vec3, Vec3),
/// WCS XY drawing limits. When present, grid lines stop at this rectangle
/// instead of extending across the full viewport.
pub limits: Option<(glam::DVec2, glam::DVec2)>,
}
/// Compute the adaptive grid step size (world units) from camera zoom.
@ -191,7 +194,7 @@ impl canvas::Program<Message> for GridCanvas {
height: cy1 - cy0,
};
frame.with_clip(clip, |f| {
draw_grid(f, g.view_rot, g.eye, gb, g.step, g.origin, g.axes)
draw_grid(f, g.view_rot, g.eye, gb, g.step, g.origin, g.axes, g.limits)
});
}
@ -1163,6 +1166,7 @@ fn draw_grid(
step: f32,
grid_origin: glam::DVec3,
grid_axes: (Vec3, Vec3, Vec3),
limits: Option<(glam::DVec2, glam::DVec2)>,
) {
if bounds.width <= 0.0 || bounds.height <= 0.0 {
return;
@ -1558,6 +1562,94 @@ fn draw_grid(
frame.stroke(&path, st.clone());
};
// A finite LIMITS rectangle replaces the usual viewport/horizon extent.
// Clip each UCS grid line analytically against the WCS XY rectangle, then
// project only that finite segment. This keeps the grid bounded even when
// the active UCS is rotated.
if let Some((limit_min, limit_max)) = limits {
let corners = [
glam::DVec3::new(limit_min.x, limit_min.y, grid_origin.z),
glam::DVec3::new(limit_max.x, limit_min.y, grid_origin.z),
glam::DVec3::new(limit_max.x, limit_max.y, grid_origin.z),
glam::DVec3::new(limit_min.x, limit_max.y, grid_origin.z),
];
let coordinate_range = |axis: Vec3| {
corners
.iter()
.fold((f32::INFINITY, f32::NEG_INFINITY), |(min, max), corner| {
let value = (*corner - grid_origin).as_vec3().dot(axis);
(min.min(value), max.max(value))
})
};
let clip_world_line = |family: usize, value: f32| -> Option<(Point, Point)> {
let (base, direction) = if family == 0 {
(grid_origin + (axis1 * value).as_dvec3(), axis2.as_dvec3())
} else {
(grid_origin + (axis2 * value).as_dvec3(), axis1.as_dvec3())
};
let (mut t0, mut t1) = (f64::NEG_INFINITY, f64::INFINITY);
let mut clip_axis = |origin: f64, delta: f64, low: f64, high: f64| {
if delta.abs() < 1e-12 {
return origin >= low && origin <= high;
}
let a = (low - origin) / delta;
let b = (high - origin) / delta;
t0 = t0.max(a.min(b));
t1 = t1.min(a.max(b));
t0 <= t1
};
if !clip_axis(base.x, direction.x, limit_min.x, limit_max.x)
|| !clip_axis(base.y, direction.y, limit_min.y, limit_max.y)
|| !t0.is_finite()
|| !t1.is_finite()
{
return None;
}
let p0 = project(base + direction * t0)?;
let p1 = project(base + direction * t1)?;
let local_bounds = iced::Rectangle {
x: 0.0,
y: 0.0,
width: bounds.width,
height: bounds.height,
};
clip_seg(p0, p1, local_bounds).map(|(p0, p1)| {
(
Point::new(p0.x + bounds.x, p0.y + bounds.y),
Point::new(p1.x + bounds.x, p1.y + bounds.y),
)
})
};
let (min1, max1) = coordinate_range(axis1);
let (min2, max2) = coordinate_range(axis2);
let mut segments = Vec::new();
if let Some((_, anchor_world, gap)) = best_anchor(0) {
if gap >= MIN_HORIZON_GRID_PX {
let anchor = (anchor_world - grid_origin).as_vec3().dot(axis1);
let (start, end) = line_range(min1, max1, anchor);
for index in start..=end {
if let Some(segment) = clip_world_line(0, index as f32 * s) {
segments.push(segment);
}
}
}
}
if let Some((_, anchor_world, gap)) = best_anchor(1) {
if gap >= MIN_HORIZON_GRID_PX {
let anchor = (anchor_world - grid_origin).as_vec3().dot(axis2);
let (start, end) = line_range(min2, max2, anchor);
for index in start..=end {
if let Some(segment) = clip_world_line(1, index as f32 * s) {
segments.push(segment);
}
}
}
}
draw_segments(frame, &segments);
return;
}
let mut axis_extent = 0.0_f32;
// Lines parallel to axis2 (varying axis1 position).