1564 lines
64 KiB
Rust
1564 lines
64 KiB
Rust
//! 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, ¶ms)?;
|
||
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;
|