cadcraft/crates/engine/src/lib.rs

1564 lines
64 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! The CADCraft engine.
//!
//! Every user-visible action is a command with a stable id (AutoCAD's command names in lower
//! case: `line`, `circle`, `zoom`, `layer`…, plus dotted ids for UI operations). Commands run two
//! ways: programmatically with JSON parameters ([`Session::execute`], no dialogs, used by the
//! control channel, MCP, CLI and scripts) and interactively through prompts ([`Session::start`],
//! [`Session::input`], [`Session::cmdline`]) exactly like typing at AutoCAD's command line.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod assoc;
pub mod audit;
pub mod autosave;
pub mod cmd;
mod finite;
pub mod grip_dims;
pub mod grips;
mod guard;
pub mod pointmod;
pub mod prompt;
mod recent;
pub mod sample;
pub mod select;
pub mod snap;
pub mod spatial;
pub mod sysvars;
pub mod undoctl;
pub mod units;
use std::sync::Arc;
use cadcraft_doc::{Common, Drawing, Entity, EntityKind, Handle, Space};
use cadcraft_geom::{Bounds2, Vec2};
use serde::{Deserialize, Serialize};
use serde_json::Value;
pub use cadcraft_doc as doc;
pub use cadcraft_geom as geom;
pub use cadcraft_render as render;
pub use cmd::hatch::{HatchPicks, PatternChoice, PickMode, pattern_choices};
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use prompt::{Accept, Drag, Input, Interactive, Picker, Prompt, Step};
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
#[error("unknown command `{0}`")]
UnknownCommand(String),
#[error("command `{0}` is not available right now: {1}")]
Disabled(String, String),
#[error("invalid parameters for `{cmd}`: {msg}")]
BadParams { cmd: String, msg: String },
#[error("no active document")]
NoDocument,
#[error("{0}")]
Other(String),
/// A command panicked; the guard kept the drawing as it was (a bug: please report it).
#[error("internal error in `{0}` (the drawing was kept as it was): {1}")]
Internal(String, String),
}
impl From<cadcraft_doc::DocError> for EngineError {
fn from(e: cadcraft_doc::DocError) -> Self {
EngineError::Other(e.to_string())
}
}
pub type Result<T> = std::result::Result<T, EngineError>;
/// A 2D view of a space: world centre and visible height in drawing units.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct View {
pub center: Vec2,
pub height: f64,
}
impl Default for View {
fn default() -> Self {
View { center: Vec2::new(6.0, 4.5), height: 10.0 }
}
}
fn usable_view(v: &View) -> bool {
v.center.is_finite() && v.height.is_finite() && v.height > 1e-12
}
/// Tiled model viewports (VPORTS): the session side of the drawing's `*Active` configuration.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ModelTiles {
/// The tile corners these views belong to (when the drawing's tiles change, by VPORTS or
/// undo, its views take over).
pub rects: Vec<(Vec2, Vec2)>,
/// Each tile's view; the current tile shows the model space view itself.
pub views: Vec<View>,
/// The current tile.
pub active: usize,
}
/// One tile of the model space drawing area, for the UI ([`Session::model_tiles`]): its corners
/// as fractions of the drawing area (0,0 lower left) and its view.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ModelTile {
pub min: Vec2,
pub max: Vec2,
pub view: View,
pub active: bool,
}
#[derive(Clone, Debug)]
pub struct Snapshot {
pub label: String,
pub doc: Arc<Drawing>,
pub selection: Vec<Handle>,
}
/// One open drawing.
#[derive(Clone, Debug)]
pub struct DocState {
pub doc: Arc<Drawing>,
pub undo: Vec<Snapshot>,
pub redo: Vec<Snapshot>,
/// UNDO marks, BEgin/End group and Control (see [`undoctl`]).
pub undo_ctl: undoctl::UndoCtl,
pub selection: Vec<Handle>,
pub previous_selection: Vec<Handle>,
pub path: Option<String>,
pub title: String,
pub saved: Arc<Drawing>,
pub revision: u64,
pub space: Space,
/// Tiled model viewports (VPORTS).
pub tiles: ModelTiles,
/// MSPACE: the layout viewport whose model space is being edited (None = paper space).
pub mspace: Option<Handle>,
pub views: Vec<(Space, View)>,
pub view_history: Vec<View>,
pub uid: u64,
/// The drawing is saved as binary DXF (it was opened from one, or last saved as one).
pub binary_dxf: bool,
}
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
impl DocState {
pub fn new(doc: Drawing, title: &str, path: Option<String>) -> Self {
let doc = Arc::new(doc);
DocState {
saved: doc.clone(),
doc,
undo: Vec::new(),
redo: Vec::new(),
undo_ctl: undoctl::UndoCtl::default(),
selection: Vec::new(),
previous_selection: Vec::new(),
path,
title: title.into(),
revision: 1,
space: Space::Model,
tiles: ModelTiles::default(),
mspace: None,
views: Vec::new(),
view_history: Vec::new(),
uid: NEXT_UID.fetch_add(1, std::sync::atomic::Ordering::Relaxed),
binary_dxf: false,
}
}
pub fn is_dirty(&self) -> bool {
!Arc::ptr_eq(&self.doc, &self.saved)
}
/// After undo/redo replaced the drawing (`before` is the one shown until then): if the current
/// layout no longer exists, follow it to its other name when it was renamed (same tab order,
/// a name `before` did not have), else go to the Model tab.
fn follow_layout(&mut self, before: &Drawing) {
let Space::Paper(name) = &self.space else { return };
if self.doc.layouts.iter().any(|l| l.name == *name) {
return;
}
let renamed = before
.layouts
.iter()
.find(|l| l.name == *name)
.and_then(|old| self.doc.layouts.iter().find(|l| l.tab_order == old.tab_order && !before.layouts.iter().any(|b| b.name == l.name)));
match renamed.map(|l| Space::Paper(l.name.clone())) {
Some(to) => {
let from = self.space.clone();
for (sp, _) in self.views.iter_mut() {
if *sp == from {
*sp = to.clone();
}
}
self.space = to;
}
None => {
self.space = Space::Model;
self.mspace = None;
}
}
}
/// The space edits, picks and snaps act on: model space inside an active viewport.
pub fn edit_space(&self) -> Space {
if self.active_viewport().is_some() { Space::Model } else { self.space.clone() }
}
/// The active MSPACE viewport, if it still exists in the current layout.
pub fn active_viewport(&self) -> Option<(Handle, cadcraft_doc::Viewport)> {
let h = self.mspace?;
let e = self.doc.space(&self.space)?.get(h)?;
match &e.kind {
cadcraft_doc::EntityKind::Viewport(v) if v.height > 1e-12 && v.view_height > 1e-12 => Some((h, v.clone())),
_ => None,
}
}
pub fn paper_view(&self) -> View {
self.views.iter().find(|(s, _)| *s == self.space).map(|(_, v)| *v).unwrap_or_default()
}
/// The screen view. Inside an MSPACE viewport this is the model-space view that maps the
/// screen consistently with the paper view, so picks, snaps and previews work in model units.
pub fn view(&self) -> View {
let pv = self.paper_view();
match self.active_viewport() {
Some((_, vp)) => {
let k = vp.view_height / vp.height;
View { center: vp.view_center + (pv.center - vp.center.xy()) * k, height: pv.height * k }
}
None => pv,
}
}
/// Map a requested screen view to the paper view, or (in MSPACE) to the viewport's model view.
/// A finite request can still map to a view too large to represent: then nothing changes and
/// this returns false.
fn store_view(&mut self, v: View) -> bool {
if let Some((h, vp)) = self.active_viewport() {
let pv = self.paper_view();
let k = v.height / pv.height.max(1e-12);
if vp.locked {
// A locked viewport keeps its scale: pan/zoom the sheet instead.
let k0 = vp.view_height / vp.height;
let pc = vp.center.xy() + (v.center - vp.view_center) / k0;
let paper = View { center: pc, height: v.height / k0 };
if !usable_view(&paper) {
return false;
}
self.put_paper_view(paper);
} else {
let center = v.center - (pv.center - vp.center.xy()) * k;
let height = vp.height * k;
if !usable_view(&View { center, height }) {
return false;
}
let doc = Arc::make_mut(&mut self.doc);
if let Some(store) = doc.space_mut(&self.space.clone()) {
store.modify(h, |e| {
if let cadcraft_doc::EntityKind::Viewport(x) = &mut e.kind {
x.view_center = center;
x.view_height = height;
}
});
}
}
return true;
}
self.put_paper_view(v);
true
}
fn put_paper_view(&mut self, v: View) {
match self.views.iter_mut().find(|(s, _)| *s == self.space) {
Some((_, slot)) => *slot = v,
None => self.views.push((self.space.clone(), v)),
}
}
/// Set the view, recording view history. Returns false, changing nothing, when the view (or,
/// in MSPACE, the viewport view it maps to) isn't finite with a positive height.
pub fn set_view(&mut self, v: View) -> bool {
if !usable_view(&v) {
return false;
}
let old = self.view();
if !self.store_view(v) {
return false;
}
if old != v {
self.view_history.push(old);
if self.view_history.len() > 50 {
self.view_history.remove(0);
}
}
true
}
/// The active MSPACE viewport when ZOOM works on its own view, as in AutoCAD: extents and
/// windows fit the viewport, and zooms keep its centre. A locked viewport keeps its scale, so
/// zooming it moves the sheet instead (see `store_view`).
pub fn zoom_viewport(&self) -> Option<cadcraft_doc::Viewport> {
self.active_viewport().map(|(_, v)| v).filter(|v| !v.locked && v.width > 1e-12)
}
/// The view ZOOM starts from: the model view of the active unlocked viewport, else [`Self::view`].
pub fn zoom_frame(&self) -> View {
match self.zoom_viewport() {
Some(v) => View { center: v.view_center, height: v.view_height },
None => self.view(),
}
}
/// Width / height of the area ZOOM fits into: the active unlocked viewport, else `screen`.
pub fn zoom_aspect(&self, screen: f64) -> f64 {
self.zoom_viewport().map_or(screen, |v| v.width / v.height)
}
/// Set the view ZOOM works on (see [`Self::zoom_frame`]), recording view history.
pub fn set_zoom_frame(&mut self, f: View) {
let v = match self.zoom_viewport() {
// The screen view that `store_view` maps to this model view of the viewport.
Some(vp) => {
let pv = self.paper_view();
let k = f.height / vp.height;
View { center: f.center + (pv.center - vp.center.xy()) * k, height: pv.height * k }
}
None => f,
};
self.set_view(v);
}
/// Set without recording view history (realtime pan/zoom frames).
pub fn set_view_quiet(&mut self, v: View) {
if !(v.center.is_finite() && v.height.is_finite() && v.height > 1e-12) {
return;
}
self.store_view(v);
}
}
/// Session-level drafting settings. [`Session::drafting_settings`] supplies the active drawing's
/// snap/grid spacing in the corresponding fields for UI and JSON consumers.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct Settings {
pub osmode: u32,
pub orthomode: bool,
pub polarmode: bool,
/// POLARANG (radians).
pub polarang: f64,
pub gridmode: bool,
pub snapmode: bool,
/// SNAPUNIT in a drafting settings snapshot; stored in the drawing header.
pub snapunit: Vec2,
/// OSNAPHATCH: object snaps find hatch objects (off by default, as in AutoCAD).
pub osnaphatch: bool,
/// GRIDUNIT in a drafting settings snapshot; zero components follow SNAPUNIT.
pub gridunit: Vec2,
/// Major grid line every N minor lines (GRIDMAJOR).
pub gridmajor: u32,
/// ZOOMFACTOR: how far one mouse wheel step zooms (3..=100).
pub zoomfactor: u32,
/// -VIEW sEttings > Layer snapshot: named views save the layer settings with them.
pub view_layer_snapshot: bool,
pub dynmode: bool,
/// DYNDIGRIP: which dimensions grip dimensional input shows while a grip is stretched (bits:
/// 1 resulting length, 2 length change, 4 absolute angle, 8 angle change, 16 radius); read
/// when DYNDIVIS = 2.
pub dyndigrip: u32,
/// DYNDIVIS: how many of them: 0 the first, 1 the first two (default), 2 all DYNDIGRIP asks for.
pub dyndivis: u8,
/// DYNPIFORMAT = 1: Dynamic Input shows second and next points as Cartesian `x,y` instead of
/// polar `distance<angle` (the default).
pub dynpi_cartesian: bool,
/// DYNPICOORDS = 1: second and next points typed into Dynamic Input are absolute instead of
/// relative to the last point (the default).
pub dynpi_absolute: bool,
/// DYNPROMPT: Dynamic Input shows the prompt beside the cursor, and Down Arrow opens the
/// prompt's options there (on by default).
pub dynprompt: bool,
pub lwdisplay: bool,
pub transparency_display: bool,
pub selection_cycling: bool,
/// SELECTSIMILARMODE: what SELECTSIMILAR matches besides the object type (bits: 1 colour,
/// 2 layer, 4 linetype, 8 linetype scale, 16 lineweight, 32 plot style, 64 object style,
/// 128 name).
pub selectsimilarmode: u32,
pub otrack: bool,
/// PICKBOX in pixels.
pub pickbox: f64,
/// APERTURE in pixels.
pub aperture: f64,
pub pickfirst: bool,
pub pickadd: bool,
/// PICKSTYLE: bit 1 picks whole selectable groups ([`cmd::groups`]), bit 2 associative hatches
/// with their boundaries (stored, not used yet).
pub pickstyle: u8,
pub gripsize: f64,
pub cursorsize: f64,
/// MAXARRAY: the most objects one array command creates (items × selected objects).
pub maxarray: u64,
/// SNAPSTYL 1: isometric snap and grid, ortho and crosshair along the isometric plane's axes.
pub isodraft: bool,
/// SNAPISOPAIR: the current isometric plane (ISOPLANE, F5).
pub isoplane: snap::iso::IsoPlane,
pub annoallvisible: bool,
pub annoautoscale: bool,
/// QPMODE: show the Quick Properties palette when objects are selected.
pub qpmode: bool,
/// POLARMODE bits ([`snap::tracking::polarmode`]); polar tracking itself is on while
/// `polarmode` is (AUTOSNAP bit 8, F10).
pub polar_flags: u32,
/// POLARADDANG: additional polar tracking angles (radians), used with POLARMODE bit 4.
pub polaraddang: Vec<f64>,
/// SNAPTYPE 1: snap mode (F9) snaps along tracking paths at POLARDIST steps (PolarSnap)
/// instead of to the grid.
pub polarsnap: bool,
/// POLARDIST: the PolarSnap step; 0 uses the snap X spacing.
pub polardist: f64,
}
impl Default for Settings {
fn default() -> Self {
Settings {
osmode: 4133,
orthomode: false,
polarmode: true,
polarang: std::f64::consts::FRAC_PI_2,
gridmode: true,
snapmode: false,
snapunit: Vec2::new(0.5, 0.5),
osnaphatch: false,
gridunit: Vec2::new(0.5, 0.5),
gridmajor: 5,
zoomfactor: 60,
view_layer_snapshot: true,
dynmode: true,
dyndigrip: 31,
dyndivis: 1,
dynpi_cartesian: false,
dynpi_absolute: false,
dynprompt: true,
lwdisplay: false,
transparency_display: false,
selection_cycling: false,
selectsimilarmode: 130,
otrack: true,
pickbox: 3.0,
aperture: 10.0,
pickfirst: true,
pickadd: true,
pickstyle: 1,
gripsize: 5.0,
cursorsize: 5.0,
maxarray: 100_000,
isodraft: false,
isoplane: snap::iso::IsoPlane::Left,
annoallvisible: true,
annoautoscale: false,
qpmode: false,
polar_flags: 0,
polaraddang: Vec::new(),
polarsnap: false,
polardist: 0.0,
}
}
}
/// An interactive command in progress.
pub struct Running {
pub id: String,
pub machine: Box<dyn Interactive>,
pub before: Arc<Drawing>,
pub selection_before: Vec<Handle>,
}
/// A pending window/crossing selection started by a click on empty space.
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct PendingWindow {
pub corner: Vec2,
pub during_command: bool,
}
/// Options and values the interactive commands remember for the rest of the session, offered as
/// the `<default>` the next time they ask (as AutoCAD does). Values AutoCAD keeps in a drawing
/// system variable (HPNAME, OFFSETDIST, FILLETRAD, CHAMFERA, POLYSIDES…) live in the drawing
/// header instead. JSON calls never read these: their explicit parameters and documented defaults
/// stay authoritative.
#[derive(Clone, Debug, PartialEq)]
pub struct LastUsed {
/// HATCH/GRADIENT starts at "Select objects" instead of "Pick internal point".
pub hatch_select: bool,
/// POLYGON: circumscribed about the circle (the `C` option) instead of inscribed.
pub polygon_circumscribed: bool,
/// ROTATE angle (radians).
pub rotate_angle: f64,
/// SCALE factor.
pub scale_factor: f64,
/// MATCHPROP Settings: the properties it copies.
pub matchprop: cmd::MatchProps,
/// MLEADER placement order and Options.
pub mleader: MLeaderOptions,
/// MLEADER's block content name (Options > Content type > Block).
pub mleader_block: String,
}
impl Default for LastUsed {
fn default() -> Self {
LastUsed {
hatch_select: false,
polygon_circumscribed: false,
rotate_angle: 0.0,
scale_factor: 1.0,
matchprop: cmd::MatchProps::default(),
mleader: MLeaderOptions::default(),
mleader_block: String::new(),
}
}
}
/// Which part of a multileader MLEADER places first.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LeaderOrder {
#[default]
Arrowhead,
Landing,
Content,
}
/// The MLEADER placement order and the values set at its Options prompt.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MLeaderOptions {
pub order: LeaderOrder,
/// Maximum points of the leader line (2..=64).
pub max_points: usize,
/// First and second angle constraints (radians; 0 = free).
pub angles: [f64; 2],
/// Draw the landing line (dogleg).
pub landing: bool,
/// Ask for multiline text content (Content type None turns it off).
pub content: bool,
/// Leader lines: straight, spline, or none (content only).
pub leader_type: LeaderType,
/// The content is a block (`LastUsed::mleader_block`) instead of multiline text.
pub block: bool,
/// The block's extents centre (not its insertion point) sits at the end of the landing.
pub block_center: bool,
}
/// MLEADER's leader line type (Options > Leader type).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum LeaderType {
#[default]
Straight,
Spline,
None,
}
impl Default for MLeaderOptions {
fn default() -> Self {
MLeaderOptions {
order: LeaderOrder::Arrowhead,
max_points: 2,
angles: [0.0; 2],
landing: true,
content: true,
leader_type: LeaderType::Straight,
block: false,
block_center: true,
}
}
}
/// The editor session: open drawings, settings, the running command and the command log.
pub struct Session {
pub docs: Vec<DocState>,
pub active: usize,
pub settings: Settings,
pub running: Option<Running>,
/// A script is running ([`Session::script`]); a SCRIPT command inside it is refused.
in_script: bool,
/// The command suspended while a transparent command (`'ZOOM`, `'PAN`…) prompts inside it;
/// it resumes when that command ends. One level, as in AutoCAD.
pub suspended: Option<Running>,
pub log: Vec<String>,
pub last_command: Option<String>,
/// LASTPOINT.
pub last_point: Vec2,
/// The last segment given at a point prompt (its base point and the point): polar angles
/// relative to the last segment (POLARMODE bit 1) are measured from it.
pub last_segment: Option<(Vec2, Vec2)>,
/// The last typed `@distance<angle` input, as `(distance, angle in radians)`.
/// Set by [`Session::typed`] and consumed by commands that resolve a deferred
/// tangent first point (e.g. [`cmd::draw::LineM`]) when the next point is typed
/// rather than clicked. Cleared at [`Session::start`].
pub last_polar: Option<(f64, f64)>,
/// Point modifiers pending at the current point prompt (`FROM`, `M2P`, `.x`, `<a`…).
pub point_mods: Vec<pointmod::Frame>,
/// Cursor position in world coordinates (from the UI; used for direct distance entry).
pub cursor: Vec2,
/// The deferred tangent/perpendicular snap under the cursor, if any (from the UI; lets the
/// rubber band show the line it resolves to).
pub cursor_deferred: Option<snap::Deferred>,
/// Origin of the point currently being fed by `input_snapped`; never reused by typed points.
picked_viewport: Option<snap::ViewportPoint>,
/// The last drawing comparison (COMPARE), drawn over the drawing it was made for.
pub compare: Option<cmd::file::compare::Comparison>,
/// Object snap tracking: acquired points and the path under the cursor (from the UI's
/// [`Session::snap_cursor`] calls; direct distance entry follows the path).
pub tracking: snap::tracking::Tracker,
/// Viewport size in pixels (from the UI; used for zoom and pick apertures).
pub viewport_px: (f64, f64),
pub clipboard: Vec<Entity>,
pub clipboard_base: Vec2,
pub pending_window: Option<PendingWindow>,
/// A selection mode typed at "Select objects" (W, C, F, WP, CP) collecting its points.
pub select_mode: Option<select::SelectMode>,
/// SIngle typed at "Select objects": the next selection ends the prompt.
pub select_single: bool,
pub untitled_counter: u32,
/// The last dimension created (DIMCONTINUE / DIMBASELINE).
pub last_dim: Option<Handle>,
/// Options remembered between invocations of interactive commands.
pub last_used: LastUsed,
/// Where `clipboard` came from and the layers, styles and blocks its objects need.
pub clipboard_source: Option<cmd::clipboard::ClipSource>,
/// The window last picked with PLOTWINDOW (the Plot dialog's "Window <").
pub plot_window: Option<cadcraft_geom::Bounds2>,
/// What the last HATCHPICK kept (the Hatch and Gradient dialog's picks).
pub hatch_picks: Option<cmd::hatch::HatchPicks>,
}
impl Default for Session {
fn default() -> Self {
Session::new()
}
}
impl Session {
pub fn new() -> Self {
let mut s = Session::empty();
s.new_drawing(false);
s
}
/// A session with no open drawing (the Start tab).
pub fn empty() -> Self {
Session {
docs: Vec::new(),
active: 0,
settings: Settings::default(),
running: None,
in_script: false,
suspended: None,
log: Vec::new(),
last_command: None,
last_point: Vec2::ZERO,
last_segment: None,
last_polar: None,
point_mods: Vec::new(),
cursor: Vec2::ZERO,
cursor_deferred: None,
picked_viewport: None,
compare: None,
tracking: snap::tracking::Tracker::default(),
viewport_px: (1200.0, 800.0),
clipboard: Vec::new(),
clipboard_base: Vec2::ZERO,
pending_window: None,
select_mode: None,
select_single: false,
untitled_counter: 0,
last_dim: None,
last_used: LastUsed::default(),
clipboard_source: None,
plot_window: None,
hatch_picks: None,
}
}
pub fn new_drawing(&mut self, metric: bool) -> usize {
self.untitled_counter += 1;
let d = if metric { Drawing::new_metric() } else { Drawing::new_imperial() };
let mut st = DocState::new(d, &format!("Drawing{}", self.untitled_counter), None);
st.set_view_quiet(if metric { View { center: Vec2::new(210.0, 148.5), height: 297.0 } } else { View::default() });
self.end_running();
self.docs.push(st);
self.active = self.docs.len() - 1;
self.active
}
pub fn open_drawing(&mut self, d: Drawing, title: &str, path: Option<String>) -> usize {
let mut st = DocState::new(d, title, path);
let ext = st.doc.extents(&Space::Model);
if !ext.is_empty() {
st.set_view_quiet(View {
center: ext.center(),
height: (ext.height().max(ext.width() * self.viewport_px.1 / self.viewport_px.0.max(1.0)) * 1.1).max(1e-6),
});
}
self.end_running();
self.docs.push(st);
self.active = self.docs.len() - 1;
self.active
}
/// A command still waiting for input belongs to the drawing it started in: end it there
/// before another drawing becomes current, so its undo step lands in that drawing.
fn end_running(&mut self) {
if self.running.is_some() {
self.cancel();
}
}
/// Add a file read off-thread with [`cmd::file::read_file`] as the active drawing (OPEN's fast
/// half). A panic while adding it is reported as an error, never propagated.
pub fn finish_open(&mut self, f: cmd::file::ReadFile) -> Result<Value> {
let n = self.docs.len();
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| cmd::file::finish_open(self, f))) {
Ok(r) => r,
Err(_) => {
self.docs.truncate(n);
self.active = self.active.min(self.docs.len().saturating_sub(1));
Err(EngineError::Internal("open".into(), "panic".into()))
}
}
}
pub fn state(&self) -> Result<&DocState> {
self.docs.get(self.active).ok_or(EngineError::NoDocument)
}
pub fn state_mut(&mut self) -> Result<&mut DocState> {
self.docs.get_mut(self.active).ok_or(EngineError::NoDocument)
}
pub fn doc(&self) -> Result<&Drawing> {
Ok(&self.state()?.doc)
}
/// Copy-on-write access to the drawing.
pub fn doc_mut(&mut self) -> Result<&mut Drawing> {
Ok(Arc::make_mut(&mut self.state_mut()?.doc))
}
/// Settings for the active drawing, without caching drawing variables in the profile.
pub fn drafting_settings(&self) -> Settings {
let mut settings = self.settings.clone();
if let Ok(d) = self.doc() {
settings.snapunit = d.header.snap_unit();
settings.gridunit = d.header.grid_unit();
}
settings
}
/// The space commands act on (model space while working inside a layout viewport).
pub fn space(&self) -> Space {
self.state().map(|s| s.edit_space()).unwrap_or_default()
}
/// The tab being displayed: Model or a layout, regardless of MSPACE.
pub fn layout_space(&self) -> Space {
self.state().map(|s| s.space.clone()).unwrap_or_default()
}
pub fn selection(&self) -> Vec<Handle> {
self.state().map(|s| s.selection.clone()).unwrap_or_default()
}
pub fn set_selection(&mut self, sel: Vec<Handle>) {
if let Ok(st) = self.state_mut() {
let mut seen = std::collections::HashSet::new();
st.selection = sel.into_iter().filter(|h| seen.insert(*h)).collect();
}
}
/// Message on the command line history.
pub fn echo(&mut self, msg: impl Into<String>) {
let m = msg.into();
log::debug!("{m}");
self.log.push(m);
if self.log.len() > 5000 {
self.log.drain(..1000);
}
}
/// Common properties for new objects (CLAYER, CECOLOR, CELTYPE, CELWEIGHT, CELTSCALE).
pub fn current_common(&self) -> Common {
let Ok(d) = self.doc() else { return Common::default() };
let h = &d.header;
Common {
layer: h.str("CLAYER", "0"),
color: cadcraft_color::Color::from_aci(h.i64("CECOLOR", 256) as i16),
linetype: h.str("CELTYPE", "ByLayer"),
lineweight: cadcraft_doc::Lineweight::from_dxf(h.i64("CELWEIGHT", -1) as i16),
ltscale: h.f64("CELTSCALE", 1.0),
plot_style: h.str("CPLOTSTYLE", "ByLayer"),
..Common::default()
}
}
/// Add an entity to the current space with current properties.
pub fn add_entity(&mut self, kind: EntityKind) -> Result<Handle> {
let common = self.current_common();
let space = self.space();
let true_color = self.doc()?.header.get("CECOLOR_RGB").and_then(|v| v.as_i64());
let mut common = common;
if let Some(rgb) = true_color {
common.color = cadcraft_color::Color::True(cadcraft_color::Rgb::from_u32(rgb as u32));
}
// Text, dimensions, multileaders, hatches and block references of annotative styles are
// annotative, supporting the current annotation scale.
let d = self.doc()?;
if d.annotative_by_style(&kind) {
common.anno_scales = vec![d.current_anno_scale()];
}
let h = self.doc_mut()?.add(&space, common, kind)?;
Ok(h)
}
/// World units per pixel at the current view.
pub fn pixel_size(&self) -> f64 {
let v = self.state().map(|s| s.view()).unwrap_or_default();
v.height / self.viewport_px.1.max(1.0)
}
// ---------------- programmatic execution ----------------
/// Run a command with JSON parameters (no dialogs). Records undo when the drawing changes.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
(spec.enabled)(self).map_err(|m| EngineError::Disabled(spec.id.into(), m))?;
let before = self.state().ok().map(|s| (s.doc.clone(), s.selection.clone(), s.uid));
let run = spec.run;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(self, params)));
let result = match result {
Ok(r) => r,
Err(p) => {
let msg =
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
// Restore the drawing as it was.
if let Some((doc, sel, uid)) = &before
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == *uid)
{
st.doc = doc.clone();
st.selection = sel.clone();
}
return Err(EngineError::Internal(id.into(), msg));
}
};
let violation = before.as_ref().and_then(|b| cmd::blocks::refedit::after_command(self, &b.0, spec.id));
let result = match (violation, result) {
(Some(m), Ok(_)) => Err(EngineError::Other(m)),
(_, r) => r,
};
cmd::groups::after_command(self, before.as_ref().map(|b| &b.0));
cmd::constraints::after_command(self, before.as_ref().map(|b| &b.0), spec.undoable && result.is_ok());
assoc::after_command(self, before.as_ref().map(|b| &b.0), before.as_ref().map(|b| b.2));
let result = match (result, &before) {
(Ok(v), Some((doc, sel, uid))) if spec.undoable => self.refuse_non_finite(Some(*uid), doc, sel, spec.id).map(|()| v),
(r, _) => r,
};
if spec.undoable
&& let Some((doc, sel, uid)) = before
&& let Some(st) = self.docs.iter_mut().find(|d| d.uid == uid)
&& !Arc::ptr_eq(&doc, &st.doc)
{
st.push_undo(Snapshot { label: spec.label.to_string(), doc, selection: sel });
st.revision += 1;
}
result
}
/// Bump the revision without an undo step (view-only or settings changes that redraw).
pub fn touch(&mut self) {
if let Ok(st) = self.state_mut() {
st.revision += 1;
}
}
// ---------------- interactive commands ----------------
/// Start a command by name or alias as if typed at the command line.
pub fn start(&mut self, name: &str) -> Result<()> {
self.last_polar = None;
let name = name.trim();
let (transparent, name) = match name.strip_prefix('\'') {
Some(n) => (true, n),
None => (false, name),
};
let lower = name.trim_start_matches(['_', '.', '-']).to_ascii_lowercase();
// A command-line form with an id of its own (-ATTEDIT) is not the command it prefixes.
let dashed = cmd::resolve_alias(&name.trim_start_matches(['_', '.']).to_ascii_lowercase());
let id = if find_command(&dashed).is_some() { dashed } else { cmd::resolve_alias(&lower) };
let spec = find_command(&id).ok_or_else(|| EngineError::UnknownCommand(name.to_string()))?;
if transparent && spec.transparent && self.running.is_some() {
// Transparent commands (zoom, pan…) run without cancelling the active one.
if spec.interactive.is_none() {
self.echo(format!(">>{}", spec.label));
return self.execute(spec.id, &Value::Null).map(|_| ());
}
if self.suspended.is_some() {
self.echo("A transparent command is already in progress.");
return Ok(());
}
}
let nested = transparent && spec.transparent && self.running.is_some() && spec.interactive.is_some();
// Ctrl+Z / U / UNDO while a command's prompt offers its own Undo keyword (LINE, PLINE…)
// takes back the last segment and keeps the command running, as the keyword does.
if !nested && matches!(spec.id, "u" | "undo") && self.current_prompt().is_some_and(|p| p.keywords.iter().any(|k| k == "Undo")) {
self.echo(format!("Command: {}", spec.id.to_ascii_uppercase()));
return self.input(Input::Keyword("Undo".into()));
}
if !nested && self.running.is_some() {
self.cancel();
}
(spec.enabled)(self).map_err(|m| EngineError::Disabled(spec.id.into(), m))?;
if nested {
// The outer command stays the one Enter repeats.
self.echo(format!(">>{}", spec.id.to_ascii_uppercase()));
} else {
self.last_command = Some(spec.id.to_string());
self.echo(format!("Command: {}", spec.id.to_ascii_uppercase()));
}
match spec.interactive {
Some(factory) => {
let machine = guard::Guarded::create(spec.id, factory, self)?;
let st = self.state()?;
let before = st.doc.clone();
let selection_before = st.selection.clone();
if nested {
self.suspended = self.running.take();
}
self.running = Some(Running { id: spec.id.to_string(), machine, before, selection_before });
self.pending_window = None;
// Some commands complete immediately (e.g. ERASE with a pickfirst selection).
self.feed(None)?;
Ok(())
}
None => {
let r = self.execute(spec.id, &Value::Null)?;
if let Some(s) = r.as_str() {
self.echo(s.to_string());
} else if !r.is_null()
&& let Some(msg) = r.get("message").and_then(Value::as_str)
{
for l in msg.lines() {
self.echo(l.to_string());
}
}
Ok(())
}
}
}
/// Feed an object-snap pick, retaining its viewport origin for measurements. Point modifiers
/// that combine or constrain coordinates produce ordinary drawing-space points.
pub fn input_snapped(&mut self, hit: snap::SnapHit) -> Result<()> {
let source = hit.viewport.filter(|_| self.point_mods.iter().all(|f| matches!(f, pointmod::Frame::Snap { mode } if *mode != 0)));
let previous = std::mem::replace(&mut self.picked_viewport, source);
let result = self.input(hit.input());
self.picked_viewport = previous;
result
}
/// Give an input to the running command. With no command running, `Enter` repeats the last
/// command (as in AutoCAD) and points do nothing.
pub fn input(&mut self, input: Input) -> Result<()> {
if self.running.is_none() {
if input == Input::Enter
&& let Some(last) = self.last_command.clone()
{
return self.start(&last);
}
return Ok(());
}
if !self.point_mods.is_empty() {
return self.modifier_input(input);
}
// A deferred snap is only meaningful where the prompt resolves it; elsewhere it is the
// point it was picked at.
let input = match input {
Input::Deferred(d) if !self.current_prompt().is_some_and(|p| p.deferred) => Input::Point(d.at),
other => other,
};
// Generic selection handling during "Select objects:" prompts.
let input = self.preprocess_selection(input)?;
match input {
Some(i) => {
// SIngle: the first selection that finds objects also ends the prompt.
let single = self.select_single && matches!(&i, Input::Pick(hs) if !hs.is_empty());
self.feed(Some(i))?;
if single {
self.select_single = false;
if self.current_prompt().is_some_and(|p| p.accept.select) {
self.feed(Some(Input::Enter))?;
}
}
Ok(())
}
None => Ok(()),
}
}
fn preprocess_selection(&mut self, input: Input) -> Result<Option<Input>> {
// The command's own prompt: a typed selection mode only applies at its "Select objects".
let Some(prompt) = self.running.as_ref().map(|r| r.machine.prompt(self)) else { return Ok(Some(input)) };
if !prompt.accept.select {
return Ok(Some(input));
}
if self.select_mode.is_some() {
return self.select_mode_input(input);
}
if let Input::Text(t) | Input::Keyword(t) = &input
&& let Some(m) = select::SelectMode::from_keyword(t)
{
self.pending_window = None;
self.select_mode = Some(m);
return Ok(None);
}
if let Input::Text(t) | Input::Keyword(t) = &input
&& !prompt.keywords.iter().any(|k| k.eq_ignore_ascii_case(t.trim()))
{
match t.trim().to_ascii_lowercase().as_str() {
"si" | "single" => {
self.select_single = true;
return Ok(None);
}
// AUto (a pick, or a window where nothing is picked) is how points always select;
// Multiple picks the same way until Enter.
"au" | "auto" | "m" | "multiple" => return Ok(None),
_ => {}
}
}
let space = self.space();
match input {
Input::Point(p) => {
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
if let Some(pw) = self.pending_window.take() {
let crossing = p.x < pw.corner.x;
let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing);
return Ok(Some(Input::Pick(cmd::groups::expand(self, hs))));
}
match select::pick(self.doc()?, &space, p, ap) {
Some(h) => Ok(Some(Input::Pick(cmd::groups::expand(self, vec![h])))),
None => {
self.pending_window = Some(PendingWindow { corner: p, during_command: true });
Ok(None)
}
}
}
Input::Text(ref t) | Input::Keyword(ref t) => {
let tl = t.trim().to_ascii_lowercase();
let d = self.doc()?;
let store = d.space(&space);
let picked = match tl.as_str() {
"all" => store.map(|s| s.iter().filter(|e| d.is_visible(e)).map(|e| e.handle).collect()),
"l" | "last" => store.and_then(|s| s.last()).map(|e| vec![e.handle]),
"p" | "previous" => Some(self.state()?.previous_selection.clone()),
_ => None,
};
match picked {
Some(hs) => Ok(Some(Input::Pick(cmd::groups::expand(self, hs)))),
None => Ok(Some(input)),
}
}
Input::Cancel => {
self.pending_window = None;
Ok(Some(Input::Cancel))
}
other => Ok(Some(other)),
}
}
/// Input while a typed selection mode (W, C, F, WP, CP) collects its points: the selected
/// objects go to the command as a pick once the mode is complete.
fn select_mode_input(&mut self, input: Input) -> Result<Option<Input>> {
let Some(mut mode) = self.select_mode.take() else { return Ok(Some(input)) };
if mode.kind == select::SelectKind::Group {
return match input {
Input::Text(name) | Input::Keyword(name) => match select::select_group(self.doc()?, &self.space(), &name) {
Some(hs) => Ok(Some(Input::Pick(hs))),
None => {
self.echo(format!("No group named \"{}\".", name.trim()));
Ok(None)
}
},
// Enter or Esc leaves the group name prompt.
Input::Cancel => Ok(Some(input)),
Input::Enter => Ok(None),
_ => {
self.echo("Invalid group name.");
self.select_mode = Some(mode);
Ok(None)
}
};
}
match input {
Input::Point(p) => {
if mode.push(p) {
return Ok(mode.select(self.doc()?, &self.space()).map(Input::Pick));
}
}
Input::Keyword(k) if k == "Undo" => {
mode.pts.pop();
}
// Enter ends a fence or polygon; with too few points the mode is dropped.
Input::Enter => return Ok(mode.select(self.doc()?, &self.space()).map(Input::Pick)),
Input::Cancel | Input::Pick(_) => return Ok(Some(input)),
_ => self.echo("Invalid point."),
}
self.select_mode = Some(mode);
Ok(None)
}
fn feed(&mut self, input: Option<Input>) -> Result<()> {
let base = if matches!(input, Some(Input::Point(_))) { self.current_prompt().and_then(|p| p.base) } else { None };
let Some(mut run) = self.running.take() else { return Ok(()) };
let step = match input {
Some(Input::Cancel) => Ok(Step::Cancel),
Some(i) => {
if let Input::Point(p) = &i {
self.last_point = *p;
if let Some(b) = base.filter(|b| b.is_finite() && p.is_finite() && b.dist(*p) > 1e-12) {
self.last_segment = Some((b, *p));
}
// A temporary tracking point (TT) serves one point.
self.tracking.release_temporary();
}
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run.machine.input(self, i)));
match r {
Ok(r) => r,
Err(_) => Err(EngineError::Internal(run.id.clone(), "panic".into())),
}
}
// A command starts with no modifiers pending.
None => {
self.point_mods.clear();
run.machine.begin(self)
}
};
// Redraw what the command has added so far (LINE adds a segment per point), not only when it ends.
if let Ok(st) = self.state_mut()
&& !Arc::ptr_eq(&run.before, &st.doc)
{
st.revision += 1;
}
match step {
Ok(Step::Continue) => {
self.running = Some(run);
Ok(())
}
Ok(Step::Done) => {
self.finish(run, false);
self.resume_suspended();
Ok(())
}
Ok(Step::Cancel) => {
self.echo("*Cancel*");
self.finish(run, true);
self.resume_suspended();
Ok(())
}
Err(EngineError::Internal(id, m)) => {
// Restore and end the command.
if let Ok(st) = self.state_mut() {
st.doc = run.before.clone();
}
self.echo(format!("Internal error in {id}: {m}"));
self.resume_suspended();
Err(EngineError::Internal(id, m))
}
Err(e) => {
// Invalid input: report and keep prompting (AutoCAD re-prompts).
self.echo(e.to_string());
self.running = Some(run);
Ok(())
}
}
}
fn finish(&mut self, run: Running, cancelled: bool) {
// A typed selection mode or SIngle never outlives its command.
self.select_mode = None;
self.select_single = false;
if find_command(&run.id).is_some_and(|c| c.undoable)
&& let Err(e) = self.refuse_non_finite(None, &run.before, &run.selection_before, &run.id)
{
self.echo(e.to_string());
}
// A command outside undo that changed the drawing (UNDO, which swapped it for an earlier
// one) records no undo step, as in `execute`.
if find_command(&run.id).is_some_and(|c| !c.undoable) && self.state().is_ok_and(|st| !Arc::ptr_eq(&run.before, &st.doc)) {
self.pending_window = None;
return;
}
self.pending_window = None;
self.select_mode = None;
let label = find_command(&run.id).map(|c| c.label).unwrap_or("Command");
let _ = cancelled;
if let Some(m) = cmd::blocks::refedit::after_command(self, &run.before, &run.id) {
self.echo(m);
}
cmd::groups::after_command(self, Some(&run.before));
cmd::constraints::after_command(self, Some(&run.before), true);
assoc::after_command(self, Some(&run.before), None);
if self.suspended.is_some() {
// A transparent command's changes belong to the undo step of the command it ran inside.
if let Ok(st) = self.state_mut()
&& !Arc::ptr_eq(&run.before, &st.doc)
{
st.revision += 1;
}
return;
}
if let Ok(st) = self.state_mut()
&& !Arc::ptr_eq(&run.before, &st.doc)
{
st.push_undo(Snapshot { label: label.to_string(), doc: run.before, selection: run.selection_before });
st.revision += 1;
}
}
/// After a transparent command ends, the command it interrupted prompts again.
fn resume_suspended(&mut self) {
if let Some(outer) = self.suspended.take() {
self.echo(format!("Resuming {} command.", outer.id.to_ascii_uppercase()));
self.running = Some(outer);
}
}
/// Cancel the running command (Esc). With no command, clears the selection. Inside a
/// transparent command, cancels it and the command it interrupted.
pub fn cancel(&mut self) {
self.point_mods.clear();
if self.running.is_some() {
if self.suspended.is_some() {
if let Some(nested) = self.running.take() {
self.finish(nested, true);
}
self.running = self.suspended.take();
}
let _ = self.feed(Some(Input::Cancel));
} else {
self.pending_window = None;
self.set_selection(Vec::new());
}
}
pub fn current_prompt(&self) -> Option<Prompt> {
self.running.as_ref().map(|r| {
let p = r.machine.prompt(self);
match &self.select_mode {
Some(m) if p.accept.select => m.prompt(),
_ => p,
}
})
}
/// Whether the cursor picks objects right now, so the UI shows the pick box (PICKBOX) at the
/// crosshair: at prompts that select objects, and with no command running when PICKFIRST is on
/// (noun-verb selection).
pub fn picking_objects(&self) -> bool {
match self.current_prompt() {
Some(p) => p.picks_objects(),
None => self.settings.pickfirst,
}
}
/// The text shown on the command line: the active prompt, or "Command:".
pub fn prompt_text(&self) -> String {
match &self.running {
Some(r) if self.suspended.is_some() => format!(">>{} {}", r.id.to_ascii_uppercase(), r.machine.prompt(self).display()),
Some(r) => format!("{} {}", r.id.to_ascii_uppercase(), r.machine.prompt(self).display()),
None => "Command:".into(),
}
}
/// Rubber-band preview entities for the cursor.
pub fn preview(&self, cursor: Vec2) -> Vec<Entity> {
let Some(r) = &self.running else { return Vec::new() };
let common = self.current_common();
r.machine.preview(self, cursor).into_iter().map(|k| Entity { handle: Handle(0), common: common.clone(), kind: k }).collect()
}
/// The active command's preview as a transform of existing objects (see
/// [`Interactive::drag`]), with its extra rubber-band geometry as entities.
pub fn drag(&self, cursor: Vec2) -> Option<(Drag, Vec<Entity>)> {
let r = self.running.as_ref()?;
let d = r.machine.drag(self, cursor)?;
let common = self.current_common();
let extra = d.extra.iter().map(|k| Entity { handle: Handle(0), common: common.clone(), kind: k.clone() }).collect();
Some((d, extra))
}
/// Process one line typed at the command line (Enter pressed). Spaces separate inputs as in a
/// script line (see [`Session::script`]), so `MOVE L 0,0 5,5` ends the selection with the
/// second space and `ERASE L ` with the trailing one. Input after the command ends is ignored.
pub fn cmdline(&mut self, text: &str) -> Result<()> {
let t = text.trim_end_matches(['\r', '\n']);
// The command line types spaces literally at free-text prompts: the line is one input.
if self.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point && !p.accept.number) {
return self.typed(t);
}
// At `Command:` leading blanks before a command name are not Enter (`" LINE"` from the
// control channel starts LINE); a line of only blanks still is.
let t = if self.running.is_none() && !t.trim().is_empty() { t.trim_start() } else { t };
self.input_line(t, false)
}
/// One line of input: each space ends an input and an empty input (two spaces in a row, a
/// trailing space, an empty line) is Enter, which at `Command:` repeats the last command. In a
/// script, a free-text prompt (TEXT/MTEXT contents) takes the rest of the line, a prompt with
/// keywords still splits so `I 15` reaches the keyword and the value, and the line goes on
/// after its command ends; at the command line the rest after the command ends is ignored.
fn input_line(&mut self, line: &str, script: bool) -> Result<()> {
let mut rest = line;
let mut first = true;
loop {
if self.running.is_none() {
if !script && !first {
return Ok(());
}
// `cmd {json}`: programmatic call with parameters.
if let Some((name, json)) = rest.trim().split_once(' ')
&& json.trim_start().starts_with('{')
{
let params: Value =
serde_json::from_str(json.trim()).map_err(|e| EngineError::BadParams { cmd: name.into(), msg: e.to_string() })?;
let id = cmd::resolve_alias(&name.to_ascii_lowercase());
let r = self.execute(&id, &params)?;
if !r.is_null() {
self.echo(r.to_string());
}
return Ok(());
}
} else if script && self.current_prompt().is_some_and(|p| p.accept.text && !p.accept.point && !p.accept.number && p.keywords.is_empty()) {
return self.typed(rest);
}
first = false;
let (tok, more) = match rest.split_once([' ', '\t']) {
Some((tok, more)) => (tok, Some(more)),
None => (rest, None),
};
if self.running.is_some() {
self.typed(tok)?;
} else if tok.trim().is_empty() {
self.input(Input::Enter)?;
} else {
self.start(tok)?;
}
match more {
Some(more) => rest = more,
None => return Ok(()),
}
}
}
/// One token typed while a command runs.
fn typed(&mut self, t: &str) -> Result<()> {
let Some(prompt) = self.current_prompt() else { return Ok(()) };
let tt = t.trim();
if tt.is_empty() {
return self.input(Input::Enter);
}
// FROM, M2P, point filters, `<a` (before `'`: `'_from` is a modifier).
if let Some(r) = self.typed_modifier(&prompt, tt) {
return r;
}
// Transparent command.
if tt.starts_with('\'') {
return self.start(tt);
}
// At "Select objects" prompts the selection modes (Previous, Last, ALL) win over a command
// keyword sharing the letter (HATCH's "picK internal point" against `P`).
let selection_mode = prompt.accept.select && matches!(tt.to_ascii_lowercase().as_str(), "all" | "l" | "last" | "p" | "previous");
if !selection_mode
&& let Some(k) = prompt.match_keyword(tt)
&& !(prompt.accept.number && tt.parse::<f64>().is_ok())
{
return self.input(Input::Keyword(k));
}
if prompt.accept.point {
if let Some(p) = prompt::parse_point_with(tt, self.last_point, &self.angle_settings()) {
self.last_polar = prompt::parse_polar_text(tt, &self.angle_settings());
return self.input(Input::Point(p));
}
// Direct distance entry along the tracking path the cursor is on, from the point it
// comes from (an acquired or temporary tracking point needs no base point).
if let Some(dist) = units::parse_distance(tt).filter(|_| !prompt.accept.number)
&& let Some((from, dir)) = self.tracking.last.as_ref().filter(|t| t.point.near(self.cursor, 1e-9)).and_then(|t| t.along())
{
return self.input(Input::Point(from + dir * dist));
}
// Direct distance entry along the rubber band.
if let (Some(base), Some(dist)) = (prompt.base, units::parse_distance(tt))
&& !prompt.accept.number
{
let mut c = self.cursor;
if self.settings.orthomode && !prompt.corner {
c = snap::iso::constrain(&self.drafting_settings(), base, c);
}
let dir = (c - base).normalized();
let dir = if dir == Vec2::ZERO { Vec2::X } else { dir };
return self.input(Input::Point(base + dir * dist));
}
}
self.input(Input::Text(tt.to_string()))
}
/// Run a script, as AutoCAD reads one: a space or a line end ends an input, and an empty
/// input (two spaces in a row, a space at the end of a line, an empty line) is Enter. At
/// free-text prompts spaces are part of the text. Lines starting with `;` are comments.
///
/// A script cannot run a script: the SCRIPT command (or another `cmd {json}` call) inside a
/// running one is refused, so two files that run each other cannot recurse until the stack dies.
pub fn script(&mut self, text: &str) -> Result<()> {
if self.in_script {
return Err(EngineError::BadParams { cmd: "script".into(), msg: "a script is already running; nested scripts are not supported".into() });
}
self.in_script = true;
let r = self.script_lines(text);
self.in_script = false;
r
}
fn script_lines(&mut self, text: &str) -> Result<()> {
for line in text.lines() {
// Trailing spaces are inputs (Enter); other trailing whitespace (`\r`) is not.
let line = line.trim_end_matches(|c: char| c.is_whitespace() && c != ' ');
if line.trim_start().starts_with(';') {
continue;
}
self.input_line(line, true)?;
}
Ok(())
}
// ---------------- undo ----------------
pub fn undo(&mut self) -> Result<Option<String>> {
let st = self.state_mut()?;
let Some(snap) = st.undo.pop() else { return Ok(None) };
let cur = Snapshot { label: snap.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() };
st.doc = snap.doc;
st.follow_layout(&cur.doc);
st.selection = Vec::new();
st.redo.push(cur);
st.revision += 1;
Ok(Some(snap.label))
}
pub fn redo(&mut self) -> Result<Option<String>> {
let st = self.state_mut()?;
let Some(snap) = st.redo.pop() else { return Ok(None) };
let cur = Snapshot { label: snap.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() };
st.doc = snap.doc;
st.follow_layout(&cur.doc);
st.selection = Vec::new();
st.undo.push(cur);
st.revision += 1;
Ok(Some(snap.label))
}
// ---------------- picking without a command ----------------
/// Open a selection window with one corner at `corner`, for press-and-drag selection
/// (AutoCAD's PICKDRAG = 2: a drag opens the window wherever it starts, even over an object;
/// the next point, where the drag ends, closes it). Only while objects are being selected: no
/// command running, or a command asking for objects. Returns whether a window was opened.
pub fn begin_window(&mut self, corner: Vec2) -> bool {
let during_command = self.running.is_some();
if !corner.is_finite() || (during_command && !self.current_prompt().is_some_and(|p| p.accept.select)) {
return false;
}
self.pending_window = Some(PendingWindow { corner, during_command });
true
}
/// A click with no command running: pick/toggle objects or start a selection window.
///
/// PICKADD on (the default): picks add to the selection and Shift removes. PICKADD off: each pick
/// replaces the selection and Shift adds (Shift+pick on a selected object removes it).
pub fn idle_click(&mut self, p: Vec2, shift: bool) -> Result<()> {
let space = self.space();
let pickadd = self.settings.pickadd;
if let Some(pw) = self.pending_window.take() {
let crossing = p.x < pw.corner.x;
let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing);
let hs = cmd::groups::expand(self, hs);
let mut sel = if pickadd || shift { self.selection() } else { Vec::new() };
if shift && pickadd {
sel.retain(|h| !hs.contains(h));
} else {
sel.extend(hs);
}
self.set_selection(sel);
return Ok(());
}
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
match select::pick(self.doc()?, &space, p, ap) {
Some(h) => {
// With group selection the object's selectable groups come (or go) with it.
let hs = cmd::groups::expand(self, vec![h]);
let mut sel = if pickadd || shift { self.selection() } else { Vec::new() };
let had = sel.contains(&h);
if shift && (pickadd || had) {
sel.retain(|x| !hs.contains(x));
} else if !had {
sel.extend(hs);
}
self.set_selection(sel);
}
None => self.pending_window = Some(PendingWindow { corner: p, during_command: false }),
}
Ok(())
}
/// Remember the selection used by a command as "Previous".
pub fn remember_selection(&mut self, sel: &[Handle]) {
if let Ok(st) = self.state_mut() {
st.previous_selection = sel.to_vec();
}
}
// ---------------- views ----------------
/// The limits of the space being edited: the sheet in paper space, else LIMMIN/LIMMAX.
pub fn zoom_limits(&self) -> Result<Bounds2> {
let d = self.doc()?;
if let Space::Paper(name) = self.space()
&& let Some(sheet) = cadcraft_render::sheet(d, &name)
{
return Ok(sheet.bounds());
}
let lo = d.header.point("LIMMIN").map(|p| p.xy()).unwrap_or(Vec2::ZERO);
let hi = d.header.point("LIMMAX").map(|p| p.xy()).unwrap_or(Vec2::new(12.0, 9.0));
Ok(Bounds2::new(lo, hi))
}
pub fn zoom_extents(&mut self) -> Result<()> {
let space = self.space();
let ext = self.doc()?.extents(&space);
let (w, h) = self.viewport_px;
let ext = if ext.is_empty() { self.zoom_limits()? } else { ext };
let st = self.state_mut()?;
let aspect = st.zoom_aspect(w / h.max(1.0));
let height = ext.height().max(ext.width() / aspect.max(1e-6)).max(1e-6) * 1.05;
st.set_zoom_frame(View { center: ext.center(), height });
Ok(())
}
/// Zoom by `factor` (> 1 zooms in) keeping `about` fixed on screen.
pub fn zoom_about(&mut self, factor: f64, about: Vec2) -> Result<()> {
if !(factor.is_finite() && factor > 0.0) {
return Ok(());
}
let st = self.state_mut()?;
let v = st.view();
let h = (v.height / factor).clamp(1e-9, 1e12);
let k = h / v.height;
let c = about + (v.center - about) * k;
st.set_view_quiet(View { center: c, height: h });
Ok(())
}
/// Serializable summary for inspect/automation.
pub fn summary(&self) -> Value {
let docs: Vec<Value> = self
.docs
.iter()
.enumerate()
.map(|(i, d)| {
serde_json::json!({
"index": i, "title": d.title, "path": d.path, "dirty": d.is_dirty(),
"entities": d.doc.entity_count(), "active": i == self.active, "revision": d.revision,
})
})
.collect();
serde_json::json!({
"documents": docs,
"prompt": self.prompt_text(),
"running": self.running.as_ref().map(|r| r.id.clone()),
"selection": self.selection().iter().map(|h| h.hex()).collect::<Vec<_>>(),
"settings": self.drafting_settings(),
})
}
}
#[cfg(test)]
mod tests;