mirror of
https://github.com/storytold/cadcraft.git
synced 2026-11-10 20:40:30 -06:00
442 lines
18 KiB
Rust
442 lines
18 KiB
Rust
//! DVIEW: the dynamic view, on CADCraft's 2D plan views.
|
|
//!
|
|
//! It starts with "Select objects or <use DVIEWBLOCK>:": the objects
|
|
//! picked are the preview set, and Enter with nothing picked previews the DVIEWBLOCK block (the
|
|
//! drawing's own block of that name, else a built-in sample house). While DVIEW runs only the
|
|
//! preview set is drawn ([`Interactive::display_only`]); the whole drawing is regenerated when it
|
|
//! ends. Then it repeats "Enter option [CAmera/TArget/Distance/POints/PAn/Zoom/TWist/CLip/Hide/
|
|
//! Off/Undo]:" until Enter. TWist, PAn, Zoom, Off and Undo work; the options that need a 3D
|
|
//! camera say they are not available yet and stay at the option prompt.
|
|
//!
|
|
//! DVIEW works on the Model tab's view, or on the active (double-clicked) layout viewport; on a
|
|
//! layout with no active viewport it is not available. A Model tab twist is a view change like
|
|
//! ZOOM (view history, not undo). A layout viewport's twist belongs to the viewport entity, so a
|
|
//! DVIEW that changed it is one undo step.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use cadcraft_doc::{Entity, EntityKind, Handle, Insert, Line, Space};
|
|
use cadcraft_geom::{Vec2, Vec3};
|
|
use serde_json::{Value, json};
|
|
|
|
use super::super::machines::{SelOutcome, SelectPhase, number};
|
|
use super::super::{CommandSpec, bad, f64_req};
|
|
use crate::{Accept, DisplayOnly, EngineError, Input, Interactive, Prompt, Result, Session, Snapshot, Step, View};
|
|
|
|
/// What DVIEW (and a view twist asked for anywhere) says on a layout with no active viewport.
|
|
pub(super) const PAPER_SPACE: &str = "This command is not available in Paper space.";
|
|
|
|
/// The option prompt's keywords, in prompt order.
|
|
const OPTIONS: &[&str] = &["CAmera", "TArget", "Distance", "POints", "PAn", "Zoom", "TWist", "CLip", "Hide", "Off", "Undo"];
|
|
|
|
/// The block DVIEW previews when nothing is selected.
|
|
const DVIEWBLOCK: &str = "DVIEWBLOCK";
|
|
|
|
pub(super) fn spec() -> CommandSpec {
|
|
CommandSpec::new("dview", "DView", run_dview)
|
|
.alias(&["dv"])
|
|
.params("{twist: degrees counterclockwise} (DVIEW TWist: the Model tab's view twist, or the active layout viewport's, an undoable edit; not in Paper space)")
|
|
.noundo()
|
|
.interactive(|s| {
|
|
// Refused as it starts on a layout with no active viewport.
|
|
target(s)?;
|
|
Ok(Box::new(DviewM::default()))
|
|
})
|
|
}
|
|
|
|
/// The view DVIEW changes.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub(super) enum Target {
|
|
/// The Model tab's view.
|
|
Model,
|
|
/// The active (MSPACE) layout viewport: its own view twist.
|
|
Viewport(Handle),
|
|
}
|
|
|
|
/// The view DVIEW changes: the active layout viewport, else the Model tab's view. A layout with
|
|
/// no active viewport has none.
|
|
pub(super) fn target(s: &Session) -> Result<Target> {
|
|
let st = s.state()?;
|
|
if let Some((h, _)) = st.active_viewport() {
|
|
return Ok(Target::Viewport(h));
|
|
}
|
|
if st.space == Space::Model {
|
|
return Ok(Target::Model);
|
|
}
|
|
Err(EngineError::Other(PAPER_SPACE.into()))
|
|
}
|
|
|
|
/// A twist kept as files hold it: [0, 2π).
|
|
fn normal(twist: f64) -> f64 {
|
|
cadcraft_geom::norm_angle(twist)
|
|
}
|
|
|
|
/// The active viewport's twist, if it is `h`.
|
|
fn viewport_twist(s: &Session, h: Handle) -> Option<f64> {
|
|
s.state().ok()?.active_viewport().filter(|(vh, _)| *vh == h).map(|(_, vp)| vp.view_twist())
|
|
}
|
|
|
|
/// Turn `target`'s view to `twist` radians. The Model tab's twist is a view change (view
|
|
/// history, like ZOOM); a viewport's is a change to the viewport entity, which the caller
|
|
/// records as an undo step ([`record_viewport_edit`]).
|
|
pub(super) fn set_twist(s: &mut Session, target: Target, twist: f64) -> Result<()> {
|
|
if !twist.is_finite() {
|
|
return Err(bad("dview", "the twist must be a finite angle"));
|
|
}
|
|
let twist = normal(twist);
|
|
match target {
|
|
Target::Model => {
|
|
let st = s.state_mut()?;
|
|
let v = st.view();
|
|
st.set_view(View { twist, ..v });
|
|
}
|
|
Target::Viewport(h) => {
|
|
if viewport_twist(s, h) == Some(twist) {
|
|
return Ok(());
|
|
}
|
|
// A locked viewport keeps its view, the twist with it.
|
|
if s.state()?.active_viewport().is_some_and(|(vh, vp)| vh == h && vp.locked) {
|
|
return Err(EngineError::Other("The viewport is locked.".into()));
|
|
}
|
|
let space = s.state()?.space.clone();
|
|
let d = s.doc_mut()?;
|
|
if let Some(store) = d.space_mut(&space) {
|
|
store.modify(h, |e| {
|
|
if let EntityKind::Viewport(vp) = &mut e.kind {
|
|
vp.twist = twist;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Record a layout viewport's twist change as one undo step, `before` being the drawing (and
|
|
/// selection) before it. Nothing when the twist did not change, and nothing inside a LISP routine
|
|
/// (its own single step holds it).
|
|
pub(super) fn record_viewport_edit(s: &mut Session, before: (Arc<cadcraft_doc::Drawing>, Vec<Handle>), h: Handle) -> Result<()> {
|
|
let old = before.0.entity(h).and_then(|e| match &e.kind {
|
|
EntityKind::Viewport(vp) => Some(vp.view_twist()),
|
|
_ => None,
|
|
});
|
|
if old == viewport_twist(s, h) || s.lisp_groups_undo() {
|
|
return Ok(());
|
|
}
|
|
let st = s.state_mut()?;
|
|
if !Arc::ptr_eq(&before.0, &st.doc) {
|
|
st.push_undo(Snapshot { label: "DView".into(), doc: before.0, selection: before.1 });
|
|
st.revision += 1;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The drawing and selection now, for [`record_viewport_edit`].
|
|
fn snapshot(s: &Session) -> Result<(Arc<cadcraft_doc::Drawing>, Vec<Handle>)> {
|
|
let st = s.state()?;
|
|
Ok((st.doc.clone(), st.selection.clone()))
|
|
}
|
|
|
|
fn run_dview(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let t = target(s)?;
|
|
let deg = f64_req("dview", p, "twist")?;
|
|
let before = snapshot(s)?;
|
|
set_twist(s, t, deg.to_radians())?;
|
|
if let Target::Viewport(h) = t {
|
|
record_viewport_edit(s, before, h)?;
|
|
}
|
|
let v = s.state()?.view();
|
|
Ok(json!({ "twist": v.twist.to_degrees() }))
|
|
}
|
|
|
|
/// The built-in DVIEWBLOCK: a house front in the unit square, origin at the lower-left corner
|
|
/// (the size and origin a DVIEWBLOCK of one's own is expected to have). CADCraft's own drawing.
|
|
fn sample_house() -> Vec<(Vec2, Vec2)> {
|
|
let p = Vec2::new;
|
|
let mut lines = Vec::new();
|
|
let mut poly = |pts: &[Vec2], closed: bool| {
|
|
for w in pts.windows(2) {
|
|
lines.push((w[0], w[1]));
|
|
}
|
|
if closed && let (Some(a), Some(b)) = (pts.last(), pts.first()) {
|
|
lines.push((*a, *b));
|
|
}
|
|
};
|
|
// Walls.
|
|
poly(&[p(0.1, 0.0), p(0.9, 0.0), p(0.9, 0.6), p(0.1, 0.6)], true);
|
|
// Roof with eaves, and a chimney on its right slope.
|
|
poly(&[p(0.0, 0.55), p(0.5, 1.0), p(1.0, 0.55)], false);
|
|
poly(&[p(0.7, 0.82), p(0.7, 0.95), p(0.8, 0.95), p(0.8, 0.73)], false);
|
|
// Door.
|
|
poly(&[p(0.42, 0.0), p(0.42, 0.35), p(0.58, 0.35), p(0.58, 0.0)], false);
|
|
// Two windows with their bars.
|
|
for x in [0.17, 0.67] {
|
|
poly(&[p(x, 0.25), p(x + 0.16, 0.25), p(x + 0.16, 0.45), p(x, 0.45)], true);
|
|
poly(&[p(x + 0.08, 0.25), p(x + 0.08, 0.45)], false);
|
|
poly(&[p(x, 0.35), p(x + 0.16, 0.35)], false);
|
|
}
|
|
lines
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
|
enum Stage {
|
|
#[default]
|
|
Select,
|
|
Option,
|
|
Twist,
|
|
PanBase,
|
|
PanSecond(Vec2),
|
|
Zoom,
|
|
}
|
|
|
|
/// DVIEW, interactive.
|
|
#[derive(Default)]
|
|
struct DviewM {
|
|
stage: Stage,
|
|
sel: SelectPhase,
|
|
/// The preview set; empty with `sample` set: the DVIEWBLOCK.
|
|
preview: Vec<Handle>,
|
|
/// Where the DVIEWBLOCK preview stands: its lower-left corner and size (drawing units), fixed
|
|
/// as the options start so that PAn and Zoom move the view, not the house.
|
|
sample: Option<(Vec2, f64)>,
|
|
/// The views before each DVIEW action, for Undo.
|
|
undo: Vec<View>,
|
|
/// What DVIEW works on, and the drawing as it started (a viewport's twist change is one
|
|
/// undo step).
|
|
target: Option<Target>,
|
|
before: Option<(Arc<cadcraft_doc::Drawing>, Vec<Handle>)>,
|
|
}
|
|
|
|
impl DviewM {
|
|
fn target(&self, s: &Session) -> Result<Target> {
|
|
match self.target {
|
|
Some(t) => Ok(t),
|
|
None => target(s),
|
|
}
|
|
}
|
|
/// Start the options with this preview set (empty: the DVIEWBLOCK).
|
|
fn options(&mut self, s: &mut Session, picked: Vec<Handle>) -> Result<Step> {
|
|
// Objects picked at the prompt don't stay selected.
|
|
s.set_selection(Vec::new());
|
|
if picked.is_empty() {
|
|
let v = s.state()?.view();
|
|
let size = v.height * 0.5;
|
|
self.sample = Some((v.center - Vec2::new(size, size) * 0.5, size));
|
|
}
|
|
self.preview = picked;
|
|
self.stage = Stage::Option;
|
|
Ok(Step::Continue)
|
|
}
|
|
/// Show view `v` (centre, height and twist) on the target.
|
|
fn show(&self, s: &mut Session, v: View) -> Result<()> {
|
|
let t = self.target(s)?;
|
|
let before = s.state()?.doc.clone();
|
|
let twist = normal(v.twist);
|
|
if let Target::Viewport(_) = t {
|
|
// The twist is the viewport's; the view inside it then moves as asked.
|
|
set_twist(s, t, twist)?;
|
|
}
|
|
if !s.state_mut()?.set_view(View { twist, ..v }) {
|
|
s.state_mut()?.doc = before;
|
|
return Err(EngineError::Other("The view is too large to show.".into()));
|
|
}
|
|
Ok(())
|
|
}
|
|
/// Apply a view as one DVIEW action (Undo takes it back).
|
|
fn change(&mut self, s: &mut Session, f: impl FnOnce(View) -> View) -> Result<()> {
|
|
let v = s.state()?.view();
|
|
self.show(s, f(v))?;
|
|
self.undo.push(v);
|
|
Ok(())
|
|
}
|
|
/// The command ends: a viewport twist change becomes one undo step, and the whole drawing is
|
|
/// regenerated.
|
|
fn end(&mut self, s: &mut Session) -> Result<()> {
|
|
if let (Some(Target::Viewport(h)), Some(before)) = (self.target, self.before.take()) {
|
|
record_viewport_edit(s, before, h)?;
|
|
}
|
|
s.touch();
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Interactive for DviewM {
|
|
fn name(&self) -> &'static str {
|
|
"DVIEW"
|
|
}
|
|
fn begin(&mut self, s: &mut Session) -> Result<Step> {
|
|
self.target = Some(target(s)?);
|
|
self.before = Some(snapshot(s)?);
|
|
Ok(Step::Continue)
|
|
}
|
|
fn prompt(&self, s: &Session) -> Prompt {
|
|
match self.stage {
|
|
Stage::Select if self.sel.removing => self.sel.prompt(),
|
|
Stage::Select => Prompt::new("Select objects or", Accept::SELECT).default("use DVIEWBLOCK"),
|
|
Stage::Option => Prompt::new("Enter option", Accept::TEXT).kw(OPTIONS),
|
|
Stage::Twist => {
|
|
let (au, ap) = s.doc().map(|d| (d.header.i64("AUNITS", 0), d.header.i64("AUPREC", 0))).unwrap_or((0, 0));
|
|
let cur = s.state().map(|st| st.view().twist).unwrap_or(0.0);
|
|
// Offered as it would be typed: clockwise is positive when ANGDIR is 1.
|
|
let cur = if s.angle_settings().clockwise { -cur } else { cur };
|
|
let center = s.state().map(|st| st.view().center).ok();
|
|
Prompt::new("Specify view twist angle", Accept::POINT_OR_NUMBER).default(crate::units::format_angle(cur, au, ap)).base_opt(center)
|
|
}
|
|
Stage::PanBase => Prompt::new("Specify displacement base point", Accept::POINT),
|
|
Stage::PanSecond(b) => Prompt::new("Specify second point", Accept::POINT).base(b),
|
|
Stage::Zoom => Prompt::new("Specify zoom scale factor", Accept::NUMBER).default("1"),
|
|
}
|
|
}
|
|
fn cancelled(&mut self, s: &mut Session) {
|
|
if let Err(e) = self.end(s) {
|
|
s.echo(e.to_string());
|
|
}
|
|
}
|
|
fn input(&mut self, s: &mut Session, i: Input) -> Result<Step> {
|
|
match self.stage {
|
|
Stage::Select => match self.sel.feed(s, &i)? {
|
|
SelOutcome::More => Ok(Step::Continue),
|
|
SelOutcome::Empty => self.options(s, Vec::new()),
|
|
SelOutcome::Done(hs) => self.options(s, hs),
|
|
},
|
|
Stage::Option => {
|
|
let word = match &i {
|
|
Input::Enter => {
|
|
self.end(s)?;
|
|
return Ok(Step::Done);
|
|
}
|
|
Input::Keyword(t) | Input::Text(t) => self.prompt(s).match_keyword(t),
|
|
_ => None,
|
|
};
|
|
match word.as_deref() {
|
|
Some("TWist") => self.stage = Stage::Twist,
|
|
Some("PAn") => self.stage = Stage::PanBase,
|
|
Some("Zoom") => self.stage = Stage::Zoom,
|
|
// Perspective is never on in a 2D view: there is nothing to turn off.
|
|
Some("Off") => {}
|
|
Some("Undo") => {
|
|
if let Some(v) = self.undo.pop() {
|
|
self.show(s, v)?;
|
|
}
|
|
}
|
|
// CAmera, TArget, Distance, POints, CLip and Hide need a 3D view.
|
|
Some(other) => s.echo(format!("DVIEW {other} is not available yet.")),
|
|
None => return Err(EngineError::Other("Invalid option keyword.".into())),
|
|
}
|
|
Ok(Step::Continue)
|
|
}
|
|
Stage::Twist => {
|
|
let twist = match &i {
|
|
Input::Enter => None,
|
|
Input::Point(p) => {
|
|
let c = s.state()?.view().center;
|
|
let d = *p - c;
|
|
if d.len() < 1e-12 {
|
|
return Err(EngineError::Other("Requires a valid angle.".into()));
|
|
}
|
|
Some(d.y.atan2(d.x))
|
|
}
|
|
Input::Text(t) | Input::Keyword(t) if t.trim().is_empty() => None,
|
|
Input::Text(t) | Input::Keyword(t) => {
|
|
Some(s.angle_settings().rotation(t).ok_or_else(|| EngineError::Other("Requires a valid angle.".into()))?)
|
|
}
|
|
_ => return Ok(Step::Continue),
|
|
};
|
|
if let Some(a) = twist {
|
|
self.change(s, |v| View { twist: normal(a), ..v })?;
|
|
}
|
|
self.stage = Stage::Option;
|
|
Ok(Step::Continue)
|
|
}
|
|
Stage::PanBase => match i {
|
|
Input::Point(p) => {
|
|
self.stage = Stage::PanSecond(p);
|
|
Ok(Step::Continue)
|
|
}
|
|
Input::Enter => {
|
|
self.stage = Stage::Option;
|
|
Ok(Step::Continue)
|
|
}
|
|
_ => Err(EngineError::Other("Invalid point.".into())),
|
|
},
|
|
Stage::PanSecond(b) => match i {
|
|
Input::Point(p) => {
|
|
self.change(s, |v| View { center: v.center - (p - b), ..v })?;
|
|
self.stage = Stage::Option;
|
|
Ok(Step::Continue)
|
|
}
|
|
Input::Enter => {
|
|
self.stage = Stage::Option;
|
|
Ok(Step::Continue)
|
|
}
|
|
_ => Err(EngineError::Other("Invalid point.".into())),
|
|
},
|
|
Stage::Zoom => {
|
|
let f = match &i {
|
|
Input::Enter => None,
|
|
Input::Text(t) | Input::Keyword(t) if t.trim().is_empty() => None,
|
|
Input::Text(t) | Input::Keyword(t) => match number(t) {
|
|
Some(f) if f.is_finite() && f > 0.0 => Some(f),
|
|
_ => return Err(EngineError::Other("Requires a positive number.".into())),
|
|
},
|
|
_ => return Ok(Step::Continue),
|
|
};
|
|
if let Some(f) = f.filter(|f| *f != 1.0) {
|
|
self.change(s, |v| View { height: v.height / f, ..v })?;
|
|
}
|
|
self.stage = Stage::Option;
|
|
Ok(Step::Continue)
|
|
}
|
|
}
|
|
}
|
|
fn display_only(&self, s: &Session) -> Option<DisplayOnly> {
|
|
if self.stage == Stage::Select {
|
|
return None;
|
|
}
|
|
let mut extra = Vec::new();
|
|
if let Some((corner, size)) = self.sample {
|
|
let common = s.current_common();
|
|
let d = s.doc().ok()?;
|
|
if d.block(DVIEWBLOCK).is_some() {
|
|
// The drawing's own DVIEWBLOCK, defined in a 1 by 1 unit area.
|
|
let ins = Insert {
|
|
block: DVIEWBLOCK.into(),
|
|
insert: corner.to3(0.0),
|
|
scale: Vec3::new(size, size, size),
|
|
rotation: 0.0,
|
|
attribs: Vec::new(),
|
|
cols: 1,
|
|
rows: 1,
|
|
col_spacing: 0.0,
|
|
row_spacing: 0.0,
|
|
clip: None,
|
|
};
|
|
extra.push(Entity { handle: Handle(0), common, kind: EntityKind::Insert(ins) });
|
|
} else {
|
|
extra.extend(sample_house().into_iter().map(|(a, b)| Entity {
|
|
handle: Handle(0),
|
|
common: common.clone(),
|
|
kind: EntityKind::Line(Line { a: (corner + a * size).to3(0.0), b: (corner + b * size).to3(0.0) }),
|
|
}));
|
|
}
|
|
}
|
|
Some(DisplayOnly { handles: self.preview.clone(), extra })
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn the_sample_house_fits_the_unit_square() {
|
|
let lines = sample_house();
|
|
assert!(lines.len() > 10);
|
|
for (a, b) in lines {
|
|
for q in [a, b] {
|
|
assert!((0.0..=1.0).contains(&q.x) && (0.0..=1.0).contains(&q.y), "{q:?}");
|
|
}
|
|
}
|
|
}
|
|
}
|