Selection and grips respect the layers frozen in the active viewport (#3055)

Inside an MSPACE viewport an object on a layer frozen in that viewport (VPLAYER, VP Freeze) is
not drawn, but it could still be picked, window-selected and edited through the viewport, and a
selected one kept its grips and was taken by the commands that start from the selection.

Selecting: `select::selectable` and the `_in` variants of the selection functions take the
layers frozen in the active viewport (`DocState::viewport_frozen`). Every selection made in the
space being edited passes them: idle clicks and windows, "Select objects" with its options,
selection cycling, `select`, `selectall`, `selectsimilar`, group members, STRETCH, TRIM and
EXTEND by fence and by crossing window, AutoLISP `ssget` and `entsel`, the rollover highlight
and the hatch under the cursor.

Already selected: `Session::selection()` gives the selection without the model-space objects
the active viewport does not draw (`DocState::shown_selection`), so nothing that starts from
the selection edits them through that viewport. They stay in the stored selection
(`Session::stored_selection()`), which adding to the selection or removing from it starts
from, and count as selected again once the viewport draws them.

Grips: painting, hit testing, hot grips, the grip edit's targets, the typed GRIP command and
dynamic grips ask `DocState::shows`; the dynamic-grip cache key holds the frozen layers.

A viewport's frozen layers hide model-space objects only, never an object on the sheet.
Outside a viewport, and in a viewport that freezes no layer, nothing changes.
This commit is contained in:
V1dalTheAbidal
2026-11-13 15:16:16 +02:00
parent ddfa833bc2
commit eaae189f5d
28 changed files with 1465 additions and 109 deletions
+15 -6
View File
@@ -557,9 +557,15 @@ fn run_clear(s: &mut Session, _p: &Value) -> Result<Value> {
fn run_selectall(s: &mut Session, _p: &Value) -> Result<Value> {
let space = s.space();
let d = s.doc()?;
let frozen = s.viewport_frozen();
let hs: Vec<Handle> = d
.space(&space)
.map(|st| st.iter().filter(|e| d.is_visible(e) && d.layer(&e.common.layer).is_none_or(|l| !l.locked)).map(|e| e.handle).collect())
.map(|st| {
st.iter()
.filter(|e| crate::select::selectable(d, e, frozen) && d.layer(&e.common.layer).is_none_or(|l| !l.locked))
.map(|e| e.handle)
.collect()
})
.unwrap_or_default();
let n = hs.len();
s.set_selection(hs);
@@ -580,16 +586,19 @@ fn run_select(s: &mut Session, p: &Value) -> Result<Value> {
let add = bool_or(p, "add", false);
let picked: Vec<Handle> = if let Some(w) = points_param(p, "window") {
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return Err(bad("select", "window needs 2 corners")) };
crate::select::select_window(s.doc()?, &space, Bounds2::new(*a, *b), bool_or(p, "crossing", false))
crate::select::select_window_in(s.doc()?, &space, s.viewport_frozen(), Bounds2::new(*a, *b), bool_or(p, "crossing", false))
} else if let Some(at) = point_param(p, "at") {
let ap = f64_or(p, "aperture", s.pixel_size() * s.settings.pickbox * 1.5);
crate::select::pick(s.doc()?, &space, at, ap).into_iter().collect()
crate::select::pick_in(s.doc()?, &space, s.viewport_frozen(), at, ap).into_iter().collect()
} else if let Some(f) = points_param(p, "fence") {
crate::select::select_fence(s.doc()?, &space, &f)
} else {
crate::select::select_fence_in(s.doc()?, &space, s.viewport_frozen(), &f)
} else if ["handles", "handle"].iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) {
targets(s, p)?
} else {
// Nothing to select with: the selection stays, also what the active viewport does not draw.
s.stored_selection()
};
let mut sel = if add { s.selection() } else { Vec::new() };
let mut sel = if add { s.stored_selection() } else { Vec::new() };
sel.extend(picked);
s.set_selection(sel);
let sel = s.selection();
+11 -9
View File
@@ -44,7 +44,7 @@ pub(crate) struct GripEdit {
/// Typed `GRIP`: still collecting grips ("Select grips").
picking: bool,
/// What MOVE/ROTATE/SCALE/MIRROR transform: the hot grips' objects and the objects of the
/// selection that the drawing shows.
/// selection that the view being edited shows.
targets: Vec<Handle>,
base: Vec2,
mode: GripMode,
@@ -70,10 +70,11 @@ fn positive(v: Option<f64>) -> Result<f64> {
v.filter(|f| f.is_finite() && *f > 1e-12).ok_or_else(|| other("Requires a positive number."))
}
/// Whether the drawing shows `h`. A hidden object (HIDEOBJECTS, an off or frozen layer) takes no
/// part in a grip edit, even when Undo or a selection command put it back into the selection.
/// Whether the view being edited shows `h`. A hidden object (HIDEOBJECTS, an off or frozen layer,
/// a layer frozen in the active MSPACE viewport) takes no part in a grip edit, even when Undo or
/// a selection command put it back into the selection.
fn shown(s: &Session, h: Handle) -> bool {
s.doc().is_ok_and(|d| d.entity(h).is_some_and(|e| d.is_visible(e)))
s.state().is_ok_and(|st| st.doc.entity(h).is_some_and(|e| st.shows(e)))
}
impl GripEdit {
@@ -95,7 +96,8 @@ impl GripEdit {
}
/// Make grip `index` of `h` hot (the first one becomes the base point). The grip of an object
/// the drawing does not show is left out, and so are the hidden objects of the selection.
/// the view being edited does not show is left out, and so are the hidden objects of the
/// selection.
fn add_hot(&mut self, s: &Session, (h, i): (Handle, usize)) -> Result<()> {
if self.hot.contains(&(h, i)) || self.hot.len() >= MAX_HOT {
return Ok(());
@@ -119,13 +121,13 @@ impl GripEdit {
/// The grips of the selected objects at `p` (typed `GRIP`): all objects with a grip there.
fn grips_at(s: &Session, p: Vec2) -> Vec<(Handle, usize)> {
let tol = (s.pixel_size() * (s.settings.gripsize + 3.0)).max(1e-9 * (1.0 + p.len()));
let Ok(d) = s.doc() else { return Vec::new() };
let Ok(st) = s.state() else { return Vec::new() };
let mut out = Vec::new();
for h in s.selection().into_iter().take(MAX_HOT) {
if super::curves::is_locked(s, h) {
continue;
}
let Some(e) = d.entity(h).filter(|e| d.is_visible(e)) else { continue };
let Some(e) = st.doc.entity(h).filter(|e| st.shows(e)) else { continue };
let gs = e.kind.grips();
// The nearest grip of this object within the tolerance.
let best = gs
@@ -483,8 +485,8 @@ impl Session {
/// Make grips hot and start editing them (the UI's click on a grip; Shift+click collects
/// several first). `hot` lists (object, grip index); the first is the base grip. The grip
/// modes and their options then run at the command line like any prompt. Grips of objects the
/// drawing does not show are left out (the first grip left is the base grip), and it is an
/// error when none is left.
/// view being edited does not show are left out (the first grip left is the base grip), and it
/// is an error when none is left.
pub fn start_grip_edit(&mut self, hot: &[(Handle, usize)]) -> Result<()> {
if self.running.is_some() {
self.cancel();
+4 -2
View File
@@ -28,7 +28,8 @@ pub fn specs() -> Vec<CommandSpec> {
const MEMBER_PICKS: &[&str] = &["groupedit", "-group"];
/// `hs` plus the other members of the selectable groups they belong to (in the space being
/// edited, visible), when PICKSTYLE has group selection on. Order: `hs` first.
/// edited, visible, and inside an MSPACE viewport not on a layer frozen in it), when PICKSTYLE
/// has group selection on. Order: `hs` first.
pub(crate) fn expand(s: &Session, hs: Vec<Handle>) -> Vec<Handle> {
if s.settings.pickstyle & 1 == 0 || hs.is_empty() || s.running.as_ref().is_some_and(|r| MEMBER_PICKS.contains(&r.id.as_str())) {
return hs;
@@ -39,6 +40,7 @@ pub(crate) fn expand(s: &Session, hs: Vec<Handle>) -> Vec<Handle> {
}
let space = s.space();
let Some(store) = d.space(&space) else { return hs };
let frozen = s.viewport_frozen();
// Member → its selectable groups, built once (window selections may hold many objects).
let mut of: HashMap<Handle, Vec<usize>> = HashMap::new();
for (i, g) in d.groups.iter().enumerate().filter(|(_, g)| g.selectable) {
@@ -56,7 +58,7 @@ pub(crate) fn expand(s: &Session, hs: Vec<Handle>) -> Vec<Handle> {
}
let Some(g) = d.groups.get(*gi) else { continue };
for m in &g.members {
if store.get(*m).is_some_and(|e| d.is_visible(e)) && seen.insert(*m) {
if store.get(*m).is_some_and(|e| crate::select::selectable(d, e, frozen)) && seen.insert(*m) {
out.push(*m);
}
}
+1 -1
View File
@@ -591,7 +591,7 @@ fn run_inspect(s: &mut Session, p: &Value) -> Result<Value> {
"dimStyles": d.dim_styles.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
"blocks": d.blocks.keys().filter(|k| !k.starts_with('*')).cloned().collect::<Vec<_>>(),
"layouts": d.layouts.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
"selection": st.selection.iter().map(|h| h.hex()).collect::<Vec<_>>(),
"selection": st.shown_selection().iter().map(|h| h.hex()).collect::<Vec<_>>(),
"undo": st.undo.iter().rev().take(20).map(|u| u.label.clone()).collect::<Vec<_>>(),
"view": { "center": [st.view().center.x, st.view().center.y], "height": st.view().height },
// Records of the file the drawing came from that the reader could not place.
+27 -18
View File
@@ -21,14 +21,17 @@ impl StretchArea {
}
}
/// Add to `hs` the visible multilines in `space` with a control vertex inside `area`. A crossing
/// selection tests the drawn element lines, which sit away from the control vertices by the
/// multiline's scale, so a small area around a vertex alone would miss them.
fn add_mline_vertex_hits(d: &cadcraft_doc::Drawing, space: &cadcraft_doc::Space, area: &StretchArea, hs: &mut Vec<Handle>) {
let Some(store) = d.space(space) else { return };
/// Add to `hs` the multilines in the edit space that a selection can take there (visible, and
/// inside an MSPACE viewport not on a layer frozen in it) with a control vertex inside `area`. A
/// crossing selection tests the drawn element lines, which sit away from the control vertices by
/// the multiline's scale, so a small area around a vertex alone would miss them.
fn add_mline_vertex_hits(s: &Session, area: &StretchArea, hs: &mut Vec<Handle>) {
let Ok(d) = s.doc() else { return };
let Some(store) = d.space(&s.space()) else { return };
let frozen = s.viewport_frozen();
for e in store.iter() {
if let EntityKind::MLine(m) = &e.kind
&& d.is_visible(e)
&& crate::select::selectable(d, e, frozen)
&& !hs.contains(&e.handle)
&& m.vertices.iter().any(|v| area.contains(v.p.xy()))
{
@@ -94,19 +97,19 @@ pub(super) fn run_stretch(s: &mut Session, p: &Value) -> Result<Value> {
};
// A fence marks no area: the objects it crosses move whole.
if let Some(fence) = listed("fence", 2)? {
let hs = crate::select::select_fence(s.doc()?, &space, &fence);
let hs = crate::select::select_fence_in(s.doc()?, &space, s.viewport_frozen(), &fence);
let moved = transform_entities(s, &hs, &Mat3::translate(d), false)?;
return Ok(json!({ "stretched": moved.len() }));
}
let (mut hs, area) = if let Some(poly) = listed("polygon", 3)? {
(crate::select::select_polygon(s.doc()?, &space, &poly, true), StretchArea::Polygon(poly))
(crate::select::select_polygon_in(s.doc()?, &space, s.viewport_frozen(), &poly, true), StretchArea::Polygon(poly))
} else {
let w = points_param(p, "window").ok_or_else(|| bad("stretch", "`window` [[x,y],[x,y]], `polygon` or `fence` is required"))?;
let (Some(a), Some(b)) = (w.first(), w.get(1)) else { return Err(bad("stretch", "window needs 2 corners")) };
let bx = cadcraft_geom::Bounds2::new(*a, *b);
(crate::select::select_window(s.doc()?, &space, bx, true), StretchArea::Box(bx))
(crate::select::select_window_in(s.doc()?, &space, s.viewport_frozen(), bx, true), StretchArea::Box(bx))
};
add_mline_vertex_hits(s.doc()?, &space, &area, &mut hs);
add_mline_vertex_hits(s, &area, &mut hs);
hs.retain(|h| !crate::cmd::curves::is_locked(s, *h));
let doc = s.doc_mut()?;
let areas = [area];
@@ -173,7 +176,7 @@ impl StretchM {
/// of two) the mode is dropped.
fn finish_mode(&mut self, s: &mut Session, m: SelectMode) -> Result<()> {
let space = s.space();
let Some(mut hs) = m.select(s.doc()?, &space) else { return Ok(()) };
let Some(mut hs) = m.select_in(s.doc()?, &space, s.viewport_frozen()) else { return Ok(()) };
let crossing = match (m.kind, m.pts.first().zip(m.pts.get(1))) {
(SelectKind::Crossing | SelectKind::CPolygon, _) => true,
(SelectKind::Box, Some((a, b))) => b.x < a.x,
@@ -189,7 +192,7 @@ impl StretchM {
};
let whole = area.is_none();
if let (Some(a), true, false) = (&area, crossing, self.removing) {
add_mline_vertex_hits(s.doc()?, &space, a, &mut hs);
add_mline_vertex_hits(s, a, &mut hs);
}
if let (Some(a), false) = (area, self.removing) {
self.windows.push(a);
@@ -228,10 +231,10 @@ impl StretchM {
Some(c) => {
let bx = cadcraft_geom::Bounds2::new(c, p);
let space = s.space();
let mut hs = crate::select::select_window(s.doc()?, &space, bx, p.x < c.x);
let mut hs = crate::select::select_window_in(s.doc()?, &space, s.viewport_frozen(), bx, p.x < c.x);
if !self.removing {
if p.x < c.x {
add_mline_vertex_hits(s.doc()?, &space, &StretchArea::Box(bx), &mut hs);
add_mline_vertex_hits(s, &StretchArea::Box(bx), &mut hs);
}
self.windows.push(StretchArea::Box(bx));
}
@@ -240,7 +243,7 @@ impl StretchM {
None => {
let aperture = s.pixel_size() * s.settings.pickbox.max(1.0) * 1.5;
let space = s.space();
match crate::select::pick(s.doc()?, &space, p, aperture) {
match crate::select::pick_in(s.doc()?, &space, s.viewport_frozen(), p, aperture) {
Some(h) => self.add(s, vec![h], true),
None => self.corner = Some(p),
}
@@ -251,6 +254,7 @@ impl StretchM {
self.corner = None;
let d = s.doc()?;
let store = d.space(&s.space());
let frozen = s.viewport_frozen();
let word = t.trim().to_ascii_lowercase();
// `_C`, `_L`: the English name, as scripts write it.
let word = word.strip_prefix('_').unwrap_or(&word).to_string();
@@ -267,9 +271,14 @@ impl StretchM {
self.removing = false;
None
}
"all" => Some(store.map(|st| st.iter().filter(|e| d.is_visible(e)).map(|e| e.handle).collect()).unwrap_or_default()),
"l" | "last" => Some(store.and_then(|st| st.last()).map(|e| vec![e.handle]).unwrap_or_default()),
"p" | "previous" => Some(s.state()?.previous_selection.clone()),
"all" => Some(
store.map(|st| st.iter().filter(|e| crate::select::selectable(d, e, frozen)).map(|e| e.handle).collect()).unwrap_or_default(),
),
"l" | "last" => Some(store.and_then(|st| crate::select::last_in(st, frozen)).map(|h| vec![h]).unwrap_or_default()),
"p" | "previous" => {
let st = s.state()?;
Some(st.drawn_in_viewport(&st.previous_selection))
}
_ => {
s.echo("*Invalid selection*");
None
+1 -1
View File
@@ -280,7 +280,7 @@ fn run_qselect(s: &mut Session, p: &Value) -> Result<Value> {
let sel = match mode.as_str() {
"exclude" => excluded,
"append" => {
let mut cur = s.selection();
let mut cur = s.stored_selection();
for h in matched {
if !cur.contains(&h) {
cur.push(h);
+9 -2
View File
@@ -99,14 +99,21 @@ pub(crate) fn similar(e: &Entity, r: &Entity, mode: u32) -> bool {
&& (mode & NAME == 0 || same_text(object_name(e), object_name(r)))
}
/// The visible objects of the current space like any of `refs`.
/// The objects of the current space like any of `refs` that a selection can take there: visible,
/// and inside an MSPACE viewport not on a layer frozen in it.
fn similar_objects(s: &Session, d: &Drawing, refs: &[Handle], mode: u32) -> Vec<Handle> {
let refs: Vec<&Entity> = refs.iter().filter_map(|h| d.entity(*h).map(|e| e.as_ref())).collect();
if refs.is_empty() {
return Vec::new();
}
let Some(store) = d.space(&s.space()) else { return Vec::new() };
store.iter().take(MAX_SCAN).filter(|e| d.is_visible(e) && refs.iter().any(|r| similar(e, r, mode))).map(|e| e.handle).collect()
let frozen = s.viewport_frozen();
store
.iter()
.take(MAX_SCAN)
.filter(|e| crate::select::selectable(d, e, frozen) && refs.iter().any(|r| similar(e, r, mode)))
.map(|e| e.handle)
.collect()
}
fn mode_param(s: &Session, p: &Value) -> Result<u32> {
+2 -2
View File
@@ -59,7 +59,7 @@ fn fence(s: &mut Session, pts: &[Vec2], extending: bool, edges: Edges, quick: bo
let fb = Bounds2::from_points(pts.iter().copied());
let tol = crate::select::window_tol(&fb);
let mut work: Vec<(Handle, Vec<Vec2>)> = Vec::new();
for h in crate::select::select_fence(d, &space, pts) {
for h in crate::select::select_fence_in(d, &space, s.viewport_frozen(), pts) {
let Some(e) = d.entity(h) else { continue };
let polys = crate::select::hit_polylines(d, e, tol);
let mut xs: Vec<Vec2> = Vec::new();
@@ -96,7 +96,7 @@ fn crossing(s: &mut Session, a: Vec2, b: Vec2, extending: bool, edges: Edges, qu
];
let inside = |p: Vec2| bx.expand(tol).contains(p);
let mut work: Vec<(Handle, Vec<Vec2>)> = Vec::new();
for h in crate::select::select_window(d, &space, bx, true) {
for h in crate::select::select_window_in(d, &space, s.viewport_frozen(), bx, true) {
let Some(e) = d.entity(h) else { continue };
let mut best: Option<Vec2> = None;
for pl in crate::select::hit_polylines(d, e, tol) {
+59 -1
View File
@@ -8,6 +8,12 @@ pub struct Snapshot {
pub selection: Vec<Handle>,
}
/// [`DocState::drawn_in_viewport`] looks its handles up one by one while there is fewer than one
/// of them for every `LOOKUP_COST` objects of model space, and passes over model space once
/// otherwise. Timed on drawings of 40,000 and of 200,000 lines, the two cost the same at about
/// one handle for every 47 and for every 18 objects.
const LOOKUP_COST: usize = 32;
/// One open drawing.
#[derive(Clone, Debug)]
pub struct DocState {
@@ -119,13 +125,65 @@ impl DocState {
}
/// The active MSPACE viewport, if it still exists in the current layout.
pub fn active_viewport(&self) -> Option<(Handle, cadcraft_doc::Viewport)> {
self.active_viewport_ref().map(|(h, v)| (h, v.clone()))
}
/// [`DocState::active_viewport`] without copying the viewport.
fn active_viewport_ref(&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())),
cadcraft_doc::EntityKind::Viewport(v) if v.height > 1e-12 && v.view_height > 1e-12 => Some((h, v)),
_ => None,
}
}
/// The layers frozen in the active MSPACE viewport (VPLAYER, "VP Freeze"): that viewport does
/// not draw their objects, so picks, selections and grips made through it leave them out.
/// Empty in paper space and on the Model tab.
pub fn viewport_frozen(&self) -> &[String] {
match self.active_viewport_ref() {
Some((_, v)) => &v.frozen_layers,
None => &[],
}
}
/// Whether `e` is a model-space object the active MSPACE viewport does not draw: it is on a
/// layer frozen in that viewport ([`DocState::viewport_frozen`]). Objects on the sheet are not
/// drawn through the viewport, so its frozen layers do not hide them.
pub fn hidden_in_viewport(&self, e: &cadcraft_doc::Entity) -> bool {
crate::select::on_frozen_layer(self.viewport_frozen(), e) && self.doc.model.contains(e.handle)
}
/// Whether the view being edited shows `e`: the drawing shows it ([`Drawing::is_visible`]),
/// and the active MSPACE viewport, if there is one, draws it
/// ([`DocState::hidden_in_viewport`]).
pub fn shows(&self, e: &cadcraft_doc::Entity) -> bool {
self.doc.is_visible(e) && !self.hidden_in_viewport(e)
}
/// `handles` without the objects the active MSPACE viewport does not draw
/// ([`DocState::hidden_in_viewport`]): a set of objects made earlier (the selection, the
/// Previous set) as that viewport takes it. All of them anywhere else.
pub(crate) fn drawn_in_viewport(&self, handles: &[Handle]) -> Vec<Handle> {
let frozen = self.viewport_frozen();
if frozen.is_empty() || handles.is_empty() {
return handles.to_vec();
}
let model = &self.doc.model;
let hidden = |e: &Arc<cadcraft_doc::Entity>| crate::select::on_frozen_layer(frozen, e);
// The UI asks for the selection several times a frame. A few handles are looked up one
// by one; beyond that, one pass over model space costs less than the lookups.
if handles.len().saturating_mul(LOOKUP_COST) < model.len() {
return handles.iter().copied().filter(|h| !model.get(*h).is_some_and(hidden)).collect();
}
let mut gone: Vec<Handle> = model.iter().filter(|e| hidden(e)).map(|e| e.handle).collect();
gone.sort_unstable();
handles.iter().copied().filter(|h| gone.binary_search(h).is_err()).collect()
}
/// The selection as the view being edited has it: [`DocState::selection`] without the objects
/// the active MSPACE viewport does not draw. Those stay in [`DocState::selection`] until the
/// selection is replaced or cleared, and count as selected again once that viewport draws
/// them; until then nothing that starts from the selection (a command, a grip, a palette)
/// edits them through that viewport.
pub fn shown_selection(&self) -> Vec<Handle> {
self.drawn_in_viewport(&self.selection)
}
pub fn paper_view(&self) -> View {
self.views.iter().find(|(s, _)| *s == self.space).map(|(_, v)| *v).unwrap_or_default()
}
+14
View File
@@ -236,11 +236,25 @@ impl Session {
pub fn space(&self) -> Space {
self.state().map(DocState::edit_space).unwrap_or_default()
}
/// The layers frozen in the active MSPACE viewport ([`DocState::viewport_frozen`]): what a
/// pick or a selection in [`Session::space`] leaves out. Empty with no drawing open.
pub fn viewport_frozen(&self) -> &[String] {
self.state().map(DocState::viewport_frozen).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()
}
/// The selected objects as the view being edited has them ([`DocState::shown_selection`]):
/// inside an MSPACE viewport, without the objects on layers frozen in that viewport. Commands,
/// grips and palettes start from this; [`DocState::selection`] holds every selected object.
pub fn selection(&self) -> Vec<Handle> {
self.state().map(DocState::shown_selection).unwrap_or_default()
}
/// Every selected object ([`DocState::selection`]), also the ones the active MSPACE viewport
/// does not draw, which [`Session::selection`] leaves out. Adding to the selection or removing
/// from it starts from this, so that it keeps them.
pub(crate) fn stored_selection(&self) -> Vec<Handle> {
self.state().map(|s| s.selection.clone()).unwrap_or_default()
}
pub fn set_selection(&mut self, sel: Vec<Handle>) {
+10 -6
View File
@@ -651,15 +651,19 @@ pub fn ssget(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
Some("X") => all(s, true),
Some("L") => {
let order = handles_in_order(ctx);
order.last().copied().into_iter().collect()
// Inside an MSPACE viewport, the objects that viewport does not draw are passed over.
let st = ctx.s.state().ok();
let drawn = |h: &Handle| st.is_none_or(|st| st.doc.entity(*h).is_none_or(|e| !st.hidden_in_viewport(e)));
order.iter().rev().copied().find(drawn).into_iter().collect()
}
Some("P") => ctx.s.state().map(|st| st.previous_selection.clone()).unwrap_or_default(),
Some("P") => ctx.s.state().map(|st| st.drawn_in_viewport(&st.previous_selection)).unwrap_or_default(),
Some("I") => ctx.s.selection(),
Some("W") | Some("C") => {
let (p1, p2) = (point(0)?, point(1)?);
let s = &*ctx.s;
let Ok(d) = s.doc() else { return Ok(Flow::Value(Value::Nil)) };
crate::select::select_window(d, &space, cadcraft_geom::Bounds2::from_points([p1, p2]), mode.as_deref() == Some("C"))
let bx = cadcraft_geom::Bounds2::from_points([p1, p2]);
crate::select::select_window_in(d, &space, s.viewport_frozen(), bx, mode.as_deref() == Some("C"))
}
Some("F") | Some("WP") | Some("CP") => {
let poly: Vec<Vec2> =
@@ -667,8 +671,8 @@ pub fn ssget(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
let s = &*ctx.s;
let Ok(d) = s.doc() else { return Ok(Flow::Value(Value::Nil)) };
match mode.as_deref() {
Some("F") => crate::select::select_fence(d, &space, &poly),
m => crate::select::select_polygon(d, &space, &poly, m == Some("CP")),
Some("F") => crate::select::select_fence_in(d, &space, s.viewport_frozen(), &poly),
m => crate::select::select_polygon_in(d, &space, s.viewport_frozen(), &poly, m == Some("CP")),
}
}
Some(":S") | Some("A") | None => {
@@ -684,7 +688,7 @@ pub fn ssget(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
let s = &*ctx.s;
let ap = s.settings.pickbox.max(1.0) * s.pixel_size();
let Ok(d) = s.doc() else { return Ok(Flow::Value(Value::Nil)) };
crate::select::pick(d, &space, p, ap).into_iter().collect()
crate::select::pick_in(d, &space, s.viewport_frozen(), p, ap).into_iter().collect()
}
}
Some(m) => return Err(LispError::new(format!("bad argument value: ssget mode {m}"))),
+1 -1
View File
@@ -141,7 +141,7 @@ impl GetRequest {
let ap = s.settings.pickbox.max(1.0) * s.pixel_size();
let space = s.state().map(|st| st.edit_space()).map_err(|e| Again::Msg(e.to_string()))?;
let d = s.doc().map_err(|e| Again::Msg(e.to_string()))?;
match crate::select::pick(d, &space, Vec2::new(p[0], p[1]), ap) {
match crate::select::pick_in(d, &space, s.viewport_frozen(), Vec2::new(p[0], p[1]), ap) {
Some(h) => Ok(Value::list(vec![Value::Ename(h), Value::point(&p)])),
None => Err("Missed, try again.".into()),
}
+101 -21
View File
@@ -152,8 +152,28 @@ fn in_box(pts: impl Iterator<Item = Vec2>, angle: f64, p: Vec2) -> bool {
!b.is_empty() && b.contains(turn(p))
}
fn selectable(d: &Drawing, e: &Entity) -> bool {
d.is_visible(e)
/// Whether `e` is on one of the layers in `frozen`: the layers frozen in a layout viewport, which
/// does not draw their objects. The names are compared as the renderer compares them.
pub(crate) fn on_frozen_layer(frozen: &[String], e: &Entity) -> bool {
frozen.iter().any(|l| l.eq_ignore_ascii_case(&e.common.layer))
}
/// The last object of `store`, in draw order, that is not on one of the layers in `frozen`, the
/// layers frozen in the layout viewport the selection is made through: what the Last option
/// takes there. With no such layers it is the last object, whether the drawing shows it or not.
pub(crate) fn last_in(store: &cadcraft_doc::EntityStore, frozen: &[String]) -> Option<Handle> {
if frozen.is_empty() {
return store.last().map(|e| e.handle);
}
store.iter().filter(|e| !on_frozen_layer(frozen, e)).last().map(|e| e.handle)
}
/// Whether a selection can take `e`: the drawing shows it ([`Drawing::is_visible`]) and it is not
/// on one of the layers in `frozen`, the layers frozen in the layout viewport the selection is
/// made through ([`crate::DocState::viewport_frozen`]; empty anywhere else). A viewport shows
/// model space, so `frozen` goes with a selection in model space only.
pub(crate) fn selectable(d: &Drawing, e: &Entity, frozen: &[String]) -> bool {
d.is_visible(e) && !on_frozen_layer(frozen, e)
}
/// Entities to test, in the order to test them, each with its bounds when already known.
@@ -196,15 +216,28 @@ pub(crate) fn candidates<'a>(
/// the nearest, as AutoCAD 2024 picks (two objects under the pickbox: the one drawn later is
/// picked, and DRAWORDER changes which).
pub fn pick(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Option<Handle> {
let ix = spatial::index(d, space);
pick_with(d, space, &ix, p, aperture)
pick_in(d, space, &[], p, aperture)
}
pub(crate) fn pick_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<SpatialIndex>>, p: Vec2, aperture: f64) -> Option<Handle> {
/// [`pick`] through a layout viewport: the objects on the layers in `frozen`, the layers frozen
/// in that viewport, are not drawn there and are not picked.
pub fn pick_in(d: &Drawing, space: &Space, frozen: &[String], p: Vec2, aperture: f64) -> Option<Handle> {
let ix = spatial::index(d, space);
pick_with(d, space, &ix, frozen, p, aperture)
}
pub(crate) fn pick_with(
d: &Drawing,
space: &Space,
ix: &Option<std::sync::Arc<SpatialIndex>>,
frozen: &[String],
p: Vec2,
aperture: f64,
) -> Option<Handle> {
let probe = Bounds2::new(p, p).expand(aperture);
// Candidates come topmost first: the first within the aperture is the one.
for (e, known) in candidates(d, space, ix, &probe.expand(aperture), true, true)? {
if !selectable(d, e) {
if !selectable(d, e, frozen) {
continue;
}
let infinite = is_infinite(e);
@@ -221,11 +254,16 @@ pub(crate) fn pick_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<S
/// Every entity within `aperture` of `p`, topmost first (so the first is what [`pick`] chooses):
/// the objects selection cycling lists.
pub fn pick_all(d: &Drawing, space: &Space, p: Vec2, aperture: f64) -> Vec<Handle> {
pick_all_in(d, space, &[], p, aperture)
}
/// [`pick_all`] through a layout viewport that freezes the layers in `frozen` ([`pick_in`]).
pub fn pick_all_in(d: &Drawing, space: &Space, frozen: &[String], p: Vec2, aperture: f64) -> Vec<Handle> {
let ix = spatial::index(d, space);
let probe = Bounds2::new(p, p).expand(aperture);
let Some(cands) = candidates(d, space, &ix, &probe.expand(aperture), true, true) else { return Vec::new() };
cands
.filter(|(e, known)| selectable(d, e) && (is_infinite(e) || bounds_of(d, e, *known).expand(aperture).intersects(&probe)))
.filter(|(e, known)| selectable(d, e, frozen) && (is_infinite(e) || bounds_of(d, e, *known).expand(aperture).intersects(&probe)))
.filter(|(e, _)| entity_distance(d, e, p, aperture / 4.0) <= aperture)
.map(|(e, _)| e.handle)
.collect()
@@ -253,14 +291,27 @@ fn seg_hits_box(a: Vec2, b: Vec2, bx: &Bounds2) -> bool {
/// Window selection (entirely inside) or crossing selection (inside or touching).
pub fn select_window(d: &Drawing, space: &Space, bx: Bounds2, crossing: bool) -> Vec<Handle> {
let ix = spatial::index(d, space);
select_window_with(d, space, &ix, bx, crossing)
select_window_in(d, space, &[], bx, crossing)
}
pub(crate) fn select_window_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<SpatialIndex>>, bx: Bounds2, crossing: bool) -> Vec<Handle> {
/// [`select_window`] through a layout viewport: the objects on the layers in `frozen`, the layers
/// frozen in that viewport, are not drawn there and are not selected.
pub fn select_window_in(d: &Drawing, space: &Space, frozen: &[String], bx: Bounds2, crossing: bool) -> Vec<Handle> {
let ix = spatial::index(d, space);
select_window_with(d, space, &ix, frozen, bx, crossing)
}
pub(crate) fn select_window_with(
d: &Drawing,
space: &Space,
ix: &Option<std::sync::Arc<SpatialIndex>>,
frozen: &[String],
bx: Bounds2,
crossing: bool,
) -> Vec<Handle> {
let Some(cands) = candidates(d, space, ix, &bx, crossing, false) else { return Vec::new() };
let tol = window_tol(&bx);
cands.filter(|(e, known)| selectable(d, e) && in_window(d, e, *known, &bx, crossing, tol)).map(|(e, _)| e.handle).collect()
cands.filter(|(e, known)| selectable(d, e, frozen) && in_window(d, e, *known, &bx, crossing, tol)).map(|(e, _)| e.handle).collect()
}
/// The tessellation tolerance window and crossing tests use for a box.
@@ -306,11 +357,23 @@ pub(crate) fn in_window(d: &Drawing, e: &Entity, known: Option<Bounds2>, bx: &Bo
/// Fence selection: entities crossed by the fence polyline.
pub fn select_fence(d: &Drawing, space: &Space, fence: &[Vec2]) -> Vec<Handle> {
let ix = spatial::index(d, space);
select_fence_with(d, space, &ix, fence)
select_fence_in(d, space, &[], fence)
}
pub(crate) fn select_fence_with(d: &Drawing, space: &Space, ix: &Option<std::sync::Arc<SpatialIndex>>, fence: &[Vec2]) -> Vec<Handle> {
/// [`select_fence`] through a layout viewport that freezes the layers in `frozen`
/// ([`select_window_in`]).
pub fn select_fence_in(d: &Drawing, space: &Space, frozen: &[String], fence: &[Vec2]) -> Vec<Handle> {
let ix = spatial::index(d, space);
select_fence_with(d, space, &ix, frozen, fence)
}
pub(crate) fn select_fence_with(
d: &Drawing,
space: &Space,
ix: &Option<std::sync::Arc<SpatialIndex>>,
frozen: &[String],
fence: &[Vec2],
) -> Vec<Handle> {
let fb = Bounds2::from_points(fence.iter().copied());
let tol = (fb.width() + fb.height()).max(1e-9) / 2000.0;
let probe = fb.expand(tol);
@@ -318,7 +381,7 @@ pub(crate) fn select_fence_with(d: &Drawing, space: &Space, ix: &Option<std::syn
let mut out = Vec::new();
for (e, known) in cands {
// Infinite objects have no useful bounds; like window selection, test their geometry directly.
if !selectable(d, e) || (!is_infinite(e) && !bounds_of(d, e, known).intersects(&probe)) {
if !selectable(d, e, frozen) || (!is_infinite(e) && !bounds_of(d, e, known).intersects(&probe)) {
continue;
}
let polys = hit_polylines(d, e, tol);
@@ -339,6 +402,12 @@ pub(crate) fn select_fence_with(d: &Drawing, space: &Space, ix: &Option<std::syn
/// Window polygon selection (entities entirely inside `poly`) or crossing polygon selection
/// (inside or crossing its edges). The polygon closes back to its first point.
pub fn select_polygon(d: &Drawing, space: &Space, poly: &[Vec2], crossing: bool) -> Vec<Handle> {
select_polygon_in(d, space, &[], poly, crossing)
}
/// [`select_polygon`] through a layout viewport that freezes the layers in `frozen`
/// ([`select_window_in`]).
pub fn select_polygon_in(d: &Drawing, space: &Space, frozen: &[String], poly: &[Vec2], crossing: bool) -> Vec<Handle> {
let (Some(first), true) = (poly.first(), poly.len() >= 3) else { return Vec::new() };
let ix = spatial::index(d, space);
let pb = Bounds2::from_points(poly.iter().copied());
@@ -349,7 +418,7 @@ pub fn select_polygon(d: &Drawing, space: &Space, poly: &[Vec2], crossing: bool)
let mut out = Vec::new();
for (e, known) in cands {
let infinite = is_infinite(e);
if !selectable(d, e) || (infinite && !crossing) {
if !selectable(d, e, frozen) || (infinite && !crossing) {
continue;
}
let eb = bounds_of(d, e, known);
@@ -378,9 +447,15 @@ pub fn select_polygon(d: &Drawing, space: &Space, poly: &[Vec2], crossing: bool)
/// The visible objects in `space` of the group called `name` (any case), or `None` when the
/// drawing has no such group.
pub fn select_group(d: &Drawing, space: &Space, name: &str) -> Option<Vec<Handle>> {
select_group_in(d, space, &[], name)
}
/// [`select_group`] through a layout viewport that freezes the layers in `frozen`: the group's
/// objects on those layers are left out.
pub fn select_group_in(d: &Drawing, space: &Space, frozen: &[String], name: &str) -> Option<Vec<Handle>> {
let g = d.groups.iter().find(|g| g.name.eq_ignore_ascii_case(name.trim()))?;
let store = d.space(space)?;
Some(g.members.iter().copied().filter(|h| store.get(*h).is_some_and(|e| d.is_visible(e))).collect())
Some(g.members.iter().copied().filter(|h| store.get(*h).is_some_and(|e| selectable(d, e, frozen))).collect())
}
/// A selection mode typed at "Select objects", collecting its points before it selects.
@@ -454,14 +529,19 @@ impl SelectMode {
}
/// The objects the mode selects, or `None` while it has too few points.
pub fn select(&self, d: &Drawing, space: &Space) -> Option<Vec<Handle>> {
self.select_in(d, space, &[])
}
/// [`SelectMode::select`] through a layout viewport: the objects on the layers in `frozen`,
/// the layers frozen in that viewport, are not drawn there and are not selected.
pub fn select_in(&self, d: &Drawing, space: &Space, frozen: &[String]) -> Option<Vec<Handle>> {
match (self.kind, self.pts.as_slice()) {
(SelectKind::Window | SelectKind::Crossing, [a, b, ..]) => {
Some(select_window(d, space, Bounds2::new(*a, *b), self.kind == SelectKind::Crossing))
Some(select_window_in(d, space, frozen, Bounds2::new(*a, *b), self.kind == SelectKind::Crossing))
}
(SelectKind::Box, [a, b, ..]) => Some(select_window(d, space, Bounds2::new(*a, *b), b.x < a.x)),
(SelectKind::Fence, pts) if pts.len() >= 2 => Some(select_fence(d, space, pts)),
(SelectKind::Box, [a, b, ..]) => Some(select_window_in(d, space, frozen, Bounds2::new(*a, *b), b.x < a.x)),
(SelectKind::Fence, pts) if pts.len() >= 2 => Some(select_fence_in(d, space, frozen, pts)),
(SelectKind::WPolygon | SelectKind::CPolygon, pts) if pts.len() >= 3 => {
Some(select_polygon(d, space, pts, self.kind == SelectKind::CPolygon))
Some(select_polygon_in(d, space, frozen, pts, self.kind == SelectKind::CPolygon))
}
_ => None,
}
+13 -8
View File
@@ -202,10 +202,10 @@ impl Session {
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
if let Some(pw) = self.pending_window.take() {
let crossing = self.window_is_crossing(pw.corner, p);
let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing);
let hs = select::select_window_in(self.doc()?, &space, self.viewport_frozen(), Bounds2::new(pw.corner, p), crossing);
return Ok(Some(Input::Pick(cmd::groups::expand(self, hs))));
}
match select::pick(self.doc()?, &space, p, ap) {
match select::pick_in(self.doc()?, &space, self.viewport_frozen(), 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 });
@@ -217,10 +217,14 @@ impl Session {
let tl = t.trim().to_ascii_lowercase();
let d = self.doc()?;
let store = d.space(&space);
let frozen = self.viewport_frozen();
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()),
"all" => store.map(|s| s.iter().filter(|e| select::selectable(d, e, frozen)).map(|e| e.handle).collect()),
"l" | "last" => store.and_then(|s| select::last_in(s, frozen)).map(|h| vec![h]),
"p" | "previous" => {
let st = self.state()?;
Some(st.drawn_in_viewport(&st.previous_selection))
}
_ => None,
};
match picked {
@@ -241,8 +245,9 @@ impl Session {
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 {
let space = self.space();
return match input {
Input::Text(name) | Input::Keyword(name) => match select::select_group(self.doc()?, &self.space(), &name) {
Input::Text(name) | Input::Keyword(name) => match select::select_group_in(self.doc()?, &space, self.viewport_frozen(), &name) {
Some(hs) => Ok(Some(Input::Pick(hs))),
None => {
self.echo(format!("No group named \"{}\".", name.trim()));
@@ -262,14 +267,14 @@ impl Session {
match input {
Input::Point(p) => {
if mode.push(p) {
return Ok(mode.select(self.doc()?, &self.space()).map(Input::Pick));
return Ok(mode.select_in(self.doc()?, &self.space(), self.viewport_frozen()).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::Enter => return Ok(mode.select_in(self.doc()?, &self.space(), self.viewport_frozen()).map(Input::Pick)),
Input::Cancel | Input::Pick(_) => return Ok(Some(input)),
_ => self.echo("Invalid point."),
}
+10 -7
View File
@@ -32,9 +32,9 @@ impl Session {
let pickadd = self.settings.pickadd;
if let Some(pw) = self.pending_window.take() {
let crossing = self.window_is_crossing(pw.corner, p);
let hs = select::select_window(self.doc()?, &space, Bounds2::new(pw.corner, p), crossing);
let hs = select::select_window_in(self.doc()?, &space, self.viewport_frozen(), Bounds2::new(pw.corner, p), crossing);
let hs = cmd::groups::expand(self, hs);
let mut sel = if pickadd || shift { self.selection() } else { Vec::new() };
let mut sel = if pickadd || shift { self.stored_selection() } else { Vec::new() };
if shift && pickadd {
sel.retain(|h| !hs.contains(h));
} else {
@@ -44,7 +44,7 @@ impl Session {
return Ok(());
}
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
match select::pick(self.doc()?, &space, p, ap) {
match select::pick_in(self.doc()?, &space, self.viewport_frozen(), p, ap) {
Some(h) => self.idle_pick(h, shift),
None => self.pending_window = Some(PendingWindow { corner: p, during_command: false }),
}
@@ -56,7 +56,7 @@ impl Session {
let pickadd = self.settings.pickadd;
// 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 mut sel = if pickadd || shift { self.stored_selection() } else { Vec::new() };
let had = sel.contains(&h);
if shift && (pickadd || had) {
sel.retain(|x| !hs.contains(x));
@@ -93,7 +93,7 @@ impl Session {
return Vec::new();
}
let ap = self.pixel_size() * self.settings.pickbox.max(1.0) * 1.5;
self.doc().map(|d| select::pick_all(d, &self.space(), p, ap)).unwrap_or_default()
self.doc().map(|d| select::pick_all_in(d, &self.space(), self.viewport_frozen(), p, ap)).unwrap_or_default()
}
/// Whether selection cycling marks `p` with its badge (SELECTIONCYCLING 1 or 2): overlapping
@@ -155,7 +155,8 @@ impl Session {
/// The object a prompt picking one object finds at `p` in `space`: the one named for that
/// point ([`Session::input_object`]), which is none when it lies in another space, else the
/// object under `p` within `aperture`.
/// object under `p` within `aperture`. Inside an MSPACE viewport a pick in model space finds
/// what that viewport draws: not the objects on the layers frozen in it.
pub fn pick_object(&self, space: &Space, p: Vec2, aperture: f64) -> Result<Option<Handle>> {
let d = self.doc()?;
if let Some((h, q)) = self.pick_hint
@@ -163,7 +164,9 @@ impl Session {
{
return Ok((d.space_of(h).as_ref() == Some(space)).then_some(h));
}
Ok(select::pick(d, space, p, aperture))
// The viewport shows model space only: its frozen layers hide nothing on the sheet.
let frozen = if *space == Space::Model { self.viewport_frozen() } else { &[] };
Ok(select::pick_in(d, space, frozen, p, aperture))
}
}
+13 -7
View File
@@ -424,8 +424,8 @@ mod tests {
for k in 0..400 {
let p = Vec2::new(r.r(-5.0, 105.0), r.r(-5.0, 105.0));
let ap = [0.05, 0.3, 1.5][k % 3];
let a = select::pick_with(&d, &Space::Model, &ix, p, ap);
let b = select::pick_with(&d, &Space::Model, &None, p, ap);
let a = select::pick_with(&d, &Space::Model, &ix, &[], p, ap);
let b = select::pick_with(&d, &Space::Model, &None, &[], p, ap);
assert_eq!(a, b, "seed {seed} p {p:?} ap {ap}");
hits += usize::from(a.is_some());
}
@@ -444,12 +444,15 @@ mod tests {
let b = a + Vec2::new(r.r(-40.0, 40.0), r.r(-40.0, 40.0));
let bx = Bounds2::new(a, b);
for crossing in [false, true] {
let x = select::select_window_with(&d, &Space::Model, &ix, bx, crossing);
let y = select::select_window_with(&d, &Space::Model, &None, bx, crossing);
let x = select::select_window_with(&d, &Space::Model, &ix, &[], bx, crossing);
let y = select::select_window_with(&d, &Space::Model, &None, &[], bx, crossing);
assert_eq!(x, y, "seed {seed} box {bx:?} crossing {crossing}");
}
let fence: Vec<Vec2> = (0..(2 + k % 3)).map(|_| Vec2::new(r.r(0.0, 100.0), r.r(0.0, 100.0))).collect();
assert_eq!(select::select_fence_with(&d, &Space::Model, &ix, &fence), select::select_fence_with(&d, &Space::Model, &None, &fence));
assert_eq!(
select::select_fence_with(&d, &Space::Model, &ix, &[], &fence),
select::select_fence_with(&d, &Space::Model, &None, &[], &fence)
);
}
}
}
@@ -484,10 +487,13 @@ mod tests {
for (p, ap) in
[(Vec2::new(f64::NAN, 1.0), 1.0), (Vec2::new(1.0, 1.0), f64::NAN), (Vec2::new(f64::INFINITY, 0.0), 1.0), (Vec2::new(50.0, 50.0), 1e300)]
{
assert_eq!(select::pick_with(&d, &Space::Model, &ix, p, ap), select::pick_with(&d, &Space::Model, &None, p, ap));
assert_eq!(select::pick_with(&d, &Space::Model, &ix, &[], p, ap), select::pick_with(&d, &Space::Model, &None, &[], p, ap));
}
let bx = Bounds2 { min: Vec2::new(f64::NEG_INFINITY, 0.0), max: Vec2::new(f64::INFINITY, 50.0) };
assert_eq!(select::select_window_with(&d, &Space::Model, &ix, bx, true), select::select_window_with(&d, &Space::Model, &None, bx, true));
assert_eq!(
select::select_window_with(&d, &Space::Model, &ix, &[], bx, true),
select::select_window_with(&d, &Space::Model, &None, &[], bx, true)
);
}
#[test]
+1
View File
@@ -166,6 +166,7 @@ mod trim_extend_options;
mod undo_count;
mod undo_layout_tab;
mod viewport_clip;
mod viewport_frozen_selection;
mod vplayer_prompts;
mod vports;
mod wipeout_grips;
@@ -0,0 +1,783 @@
//! Inside an MSPACE viewport an object on a layer frozen in that viewport (VPLAYER, "VP Freeze")
//! is not drawn there, so the selections made through that viewport leave it out: picks, windows,
//! the typed selection modes, the commands that select for themselves, and, for an object that is
//! already selected, its grips and the commands that start from the selection. In a viewport that
//! does not freeze the layer, in paper space and on the Model tab everything is as before.
use cadcraft_engine::Session;
use cadcraft_engine::doc::{EntityKind, Handle, Space};
use cadcraft_engine::geom::Vec2;
use serde_json::{Value, json};
/// Draws a line from `a` to `b` on the current layer and returns its handle.
fn line(s: &mut Session, a: [f64; 2], b: [f64; 2]) -> Handle {
let r = s.execute("line", &json!({ "points": [a, b] })).unwrap();
Handle::parse_hex(r["handles"][0].as_str().unwrap()).unwrap()
}
/// Puts `h` on the layer "Hidden", which [`viewports`] freezes in its first viewport.
fn hide(s: &mut Session, h: Handle) {
if s.doc().unwrap().layer("Hidden").is_none() {
s.execute("layer.new", &json!({ "name": "Hidden" })).unwrap();
}
s.execute("properties.set", &json!({ "handles": [h.hex()], "layer": "Hidden" })).unwrap();
assert_eq!(s.doc().unwrap().entity(h).unwrap().common.layer, "Hidden");
}
/// Two viewports on Layout1: the first freezes the layer "Hidden", the second freezes nothing.
/// The session is left in paper space.
fn viewports(s: &mut Session) -> (String, String) {
if s.doc().unwrap().layer("Hidden").is_none() {
s.execute("layer.new", &json!({ "name": "Hidden" })).unwrap();
}
s.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
let r = s.execute("mview", &json!({ "count": 2, "p1": [20, 20], "p2": [220, 120] })).unwrap();
let (a, b) = (r["viewports"][0].as_str().unwrap().to_string(), r["viewports"][1].as_str().unwrap().to_string());
s.execute("vplayer", &json!({ "handles": [a], "freeze": ["Hidden"] })).unwrap();
(a, b)
}
/// Makes `viewport` the active MSPACE viewport.
fn enter(s: &mut Session, viewport: &str) {
s.execute("mspace", &json!({ "handle": viewport })).unwrap();
assert_eq!(s.space(), Space::Model, "inside a viewport the edit space is model space");
}
/// What most tests draw: `shown` on layer 0 and, drawn after it and so on top of it, `hidden` on
/// the layer "Hidden", both from (0, 0) to (10, 0); and the two viewports of [`viewports`].
struct Sheet {
s: Session,
shown: Handle,
hidden: Handle,
/// The viewport that freezes "Hidden".
a: String,
/// The viewport that freezes nothing.
b: String,
}
/// The drawing of [`Sheet`], in paper space.
fn sheet() -> Sheet {
let mut s = Session::new();
let shown = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
let hidden = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
hide(&mut s, hidden);
assert_eq!(s.doc().unwrap().entity(shown).unwrap().common.layer, "0");
let (a, b) = viewports(&mut s);
Sheet { s, shown, hidden, a, b }
}
/// The two ends of the line `h`.
fn ends(s: &Session, h: Handle) -> (Vec2, Vec2) {
match &s.doc().unwrap().entity(h).unwrap().kind {
EntityKind::Line(l) => (l.a.xy(), l.b.xy()),
k => panic!("not a line: {k:?}"),
}
}
/// Whether the line `h` still runs from (0, 0) to (10, 0).
fn untouched(s: &Session, h: Handle) -> bool {
let (a, b) = ends(s, h);
a.near(Vec2::new(0.0, 0.0), 1e-9) && b.near(Vec2::new(10.0, 0.0), 1e-9)
}
/// Whether the drawing still has the object `h`.
fn exists(s: &Session, h: Handle) -> bool {
s.doc().unwrap().entity(h).is_some()
}
/// Every selected object, in handle order: the stored selection, which also holds the objects
/// the active viewport does not draw ([`Session::selection`] leaves those out).
fn selected(s: &Session) -> Vec<Handle> {
let mut v = s.state().unwrap().selection.clone();
v.sort();
v
}
/// Whether the line `h` was erased or no longer runs from (0, 0) to (10, 0).
fn edited(s: &Session, h: Handle) -> bool {
!exists(s, h) || !untouched(s, h)
}
/// The index of the grip of `h` at the origin.
fn origin_grip(s: &Session, h: Handle) -> usize {
s.grips_of(h).unwrap().iter().position(|g| g.near(Vec2::new(0.0, 0.0), 1e-9)).unwrap()
}
/// `hs` in handle order.
fn sorted(mut hs: Vec<Handle>) -> Vec<Handle> {
hs.sort();
hs
}
/// Runs `text` as a script and ends whatever prompt it leaves open with Enter.
fn run(s: &mut Session, text: &str) {
s.script(text).unwrap();
for _ in 0..4 {
if s.running.is_none() {
break;
}
s.cmdline("").unwrap();
}
assert!(s.running.is_none(), "`{text}` left a prompt open: {}", s.prompt_text());
}
/// The JSON `select` command replaces the selection with what `how` finds.
fn select(s: &mut Session, how: Value) -> Vec<Handle> {
s.set_selection(Vec::new());
s.execute("select", &how).unwrap();
selected(s)
}
/// A pick takes the topmost object the viewport draws: the frozen line lies on top of the other
/// one, and a pick at the same point finds the line under it. Where the layer is not frozen the
/// pick finds the line on top, as it always did.
#[test]
fn a_pick_finds_the_topmost_object_the_viewport_draws() {
let Sheet { mut s, shown, hidden, a, b } = sheet();
let at = json!({ "at": [5, 0], "aperture": 0.5 });
enter(&mut s, &a);
assert_eq!(select(&mut s, at.clone()), [shown], "the viewport freezes the layer of the line on top");
enter(&mut s, &b);
assert_eq!(select(&mut s, at.clone()), [hidden], "this viewport draws both: the one on top is picked");
s.execute("layout.set", &json!({ "name": "Model" })).unwrap();
assert_eq!(select(&mut s, at), [hidden], "the Model tab draws both");
}
/// Window, crossing and fence selection through the viewport leave the frozen line out, and take
/// both lines through the viewport that draws both.
#[test]
fn a_window_a_crossing_and_a_fence_leave_out_what_the_viewport_does_not_draw() {
let Sheet { mut s, shown, hidden, a, b } = sheet();
let ways =
[json!({ "window": [[-1, -1], [11, 1]] }), json!({ "window": [[4, -1], [6, 1]], "crossing": true }), json!({ "fence": [[5, -1], [5, 1]] })];
for how in &ways {
enter(&mut s, &a);
assert_eq!(select(&mut s, how.clone()), [shown], "{how}");
enter(&mut s, &b);
assert_eq!(select(&mut s, how.clone()), sorted(vec![shown, hidden]), "{how}");
}
}
/// Clicks with no command running: a click on the two lines selects the one the viewport draws,
/// and a window dragged around them takes only that one.
#[test]
fn idle_clicks_and_windows_select_what_the_viewport_draws() {
let Sheet { mut s, shown, hidden, a, b } = sheet();
enter(&mut s, &a);
s.idle_click(Vec2::new(5.0, 0.0), false).unwrap();
assert_eq!(selected(&s), [shown]);
s.set_selection(Vec::new());
// A click on nothing opens a window; the next click closes it (left to right: a window).
s.idle_click(Vec2::new(-1.0, -1.0), false).unwrap();
assert!(s.pending_window.is_some(), "the first click must be on nothing");
s.idle_click(Vec2::new(11.0, 1.0), false).unwrap();
assert_eq!(selected(&s), [shown]);
enter(&mut s, &b);
s.idle_click(Vec2::new(5.0, 0.0), false).unwrap();
assert_eq!(selected(&s), [hidden], "this viewport draws both: the one on top is picked");
s.set_selection(Vec::new());
s.idle_click(Vec2::new(-1.0, -1.0), false).unwrap();
s.idle_click(Vec2::new(11.0, 1.0), false).unwrap();
assert_eq!(selected(&s), sorted(vec![shown, hidden]));
}
/// Selection cycling lists the objects under the pick box that the viewport draws.
#[test]
fn selection_cycling_lists_what_the_viewport_draws() {
let Sheet { mut s, shown, hidden, a, b } = sheet();
enter(&mut s, &a);
assert_eq!(s.pick_candidates(Vec2::new(5.0, 0.0)), [shown]);
enter(&mut s, &b);
assert_eq!(s.pick_candidates(Vec2::new(5.0, 0.0)), [hidden, shown], "topmost first");
}
/// Every way of answering "Select objects" leaves the frozen line out: ERASE through the
/// viewport that freezes its layer erases the other line only, and through the other viewport
/// both.
#[test]
fn the_answers_to_select_objects_leave_out_what_the_viewport_does_not_draw() {
let Sheet { mut s, shown, hidden, a, b } = sheet();
enter(&mut s, &a);
s.execute("group", &json!({ "name": "PAIR", "handles": [shown.hex(), hidden.hex()] })).unwrap();
// Group selection off: a pick takes the object alone, so each answer is tested by itself.
s.settings.pickstyle = 0;
let answers = [
"5,0",
"-1,-1 11,1",
"ALL",
"W -1,-1 11,1",
"C 6,1 4,-1",
"BOX 6,1 4,-1",
"F 5,-1 5,1\n",
"WP -1,-1 11,-1 11,1 -1,1\n",
"CP 4,-1 6,-1 5,1\n",
"G PAIR",
];
for answer in answers {
enter(&mut s, &a);
run(&mut s, &format!("ERASE {answer}\n"));
assert!(!exists(&s, shown), "`{answer}` must erase the line the viewport draws");
assert!(exists(&s, hidden), "`{answer}` erased a line the viewport does not draw");
s.execute("undo", &json!({})).unwrap();
assert!(exists(&s, shown) && exists(&s, hidden));
enter(&mut s, &b);
run(&mut s, &format!("ERASE {answer}\n"));
// A pick takes one object, the one on top; every other answer takes both.
assert_eq!((exists(&s, shown), exists(&s, hidden)), (answer == "5,0", false), "`{answer}` where both are drawn");
s.execute("undo", &json!({})).unwrap();
assert!(exists(&s, shown) && exists(&s, hidden));
}
}
/// With group selection on, a pick brings the members of its group that the viewport draws.
#[test]
fn a_pick_brings_the_group_members_the_viewport_draws() {
let Sheet { mut s, shown, hidden, a, b } = sheet();
enter(&mut s, &a);
s.execute("group", &json!({ "name": "PAIR", "handles": [shown.hex(), hidden.hex()] })).unwrap();
s.settings.pickstyle = 1;
s.set_selection(Vec::new());
s.idle_click(Vec2::new(5.0, 0.0), false).unwrap();
assert_eq!(selected(&s), [shown]);
enter(&mut s, &b);
s.idle_click(Vec2::new(5.0, 0.0), false).unwrap();
assert_eq!(selected(&s), sorted(vec![shown, hidden]));
}
/// Select All and Select Similar take what the viewport draws.
#[test]
fn select_all_and_select_similar_take_what_the_viewport_draws() {
let Sheet { mut s, shown, hidden, a, b } = sheet();
// Mode 0: any object of the same type is similar, whatever its layer.
let similar = json!({ "handles": [shown.hex()], "mode": 0 });
enter(&mut s, &a);
s.execute("selectall", &json!({})).unwrap();
assert_eq!(selected(&s), [shown]);
s.execute("selectsimilar", &similar).unwrap();
assert_eq!(selected(&s), [shown]);
enter(&mut s, &b);
s.execute("selectall", &json!({})).unwrap();
assert_eq!(selected(&s), sorted(vec![shown, hidden]));
s.execute("selectsimilar", &similar).unwrap();
assert_eq!(selected(&s), sorted(vec![shown, hidden]));
}
/// STRETCH selects for itself: its crossing window, polygon and fence, called with JSON or
/// typed, and its typed All, Last and Previous, move the line the viewport draws and leave the
/// frozen one where it was.
#[test]
fn stretch_moves_what_the_viewport_draws() {
let calls = [
json!({ "window": [[9, -1], [11, 1]], "delta": [0, 5] }),
json!({ "polygon": [[9, -1], [11, -1], [11, 1], [9, 1]], "delta": [0, 5] }),
json!({ "fence": [[5, -1], [5, 1]], "delta": [0, 5] }),
];
for call in &calls {
let Sheet { mut s, shown, hidden, a, b } = sheet();
enter(&mut s, &a);
s.execute("stretch", call).unwrap();
assert!(!untouched(&s, shown), "{call}: the line the viewport draws must move");
assert!(untouched(&s, hidden), "{call}: a line the viewport does not draw moved: {:?}", ends(&s, hidden));
enter(&mut s, &b);
s.execute("stretch", call).unwrap();
assert!(!untouched(&s, hidden), "{call}: where both are drawn both move");
}
// Typed: a crossing window, named and implied, All, a pick, Last, and Previous (both lines).
for objects in ["C 11,1 9,-1", "11,1 9,-1", "ALL", "5,0", "L", "P"] {
let typed = format!("STRETCH {objects}\n\n0,0 0,5\n");
let Sheet { mut s, shown, hidden, a, b } = sheet();
enter(&mut s, &a);
s.remember_selection(&[shown, hidden]);
run(&mut s, &typed);
assert!(!untouched(&s, shown), "{typed:?}: the line the viewport draws must move");
assert!(untouched(&s, hidden), "{typed:?}: a line the viewport does not draw moved: {:?}", ends(&s, hidden));
enter(&mut s, &b);
s.remember_selection(&[shown, hidden]);
run(&mut s, &typed);
assert!(!untouched(&s, hidden), "{typed:?}: where both are drawn the one on top moves");
}
}
/// A multiline whose control vertex is inside STRETCH's crossing window is stretched although
/// the window misses its drawn lines, but not when the viewport does not draw it.
#[test]
fn stretch_leaves_a_multiline_the_viewport_does_not_draw() {
/// The control vertices of the multiline `h`.
fn vertices(s: &Session, h: Handle) -> Vec<Vec2> {
match &s.doc().unwrap().entity(h).unwrap().kind {
EntityKind::MLine(m) => m.vertices.iter().map(|v| v.p.xy()).collect(),
k => panic!("not a multiline: {k:?}"),
}
}
let mut s = Session::new();
// Zero justification at scale 2: the element lines run one unit either side of the vertices.
let r = s.execute("mline", &json!({ "points": [[0, 0], [10, 0]], "scale": 2, "justification": "zero" })).unwrap();
let mline = Handle::parse_hex(r["handle"].as_str().or_else(|| r["handles"][0].as_str()).unwrap()).unwrap();
hide(&mut s, mline);
let (a, b) = viewports(&mut s);
let call = json!({ "window": [[9.9, -0.1], [10.1, 0.1]], "delta": [0, 5] });
enter(&mut s, &a);
s.execute("stretch", &call).unwrap();
assert!(vertices(&s, mline)[1].near(Vec2::new(10.0, 0.0), 1e-9), "a multiline the viewport does not draw was stretched");
enter(&mut s, &b);
s.execute("stretch", &call).unwrap();
assert!(vertices(&s, mline)[1].near(Vec2::new(10.0, 5.0), 1e-9), "{:?}", vertices(&s, mline));
}
/// TRIM's fence and crossing window trim the line the viewport draws and leave the frozen one.
#[test]
fn trim_by_fence_and_crossing_leaves_what_the_viewport_does_not_draw() {
for call in [json!({ "fence": [[8, -1], [8, 1]] }), json!({ "crossing": [[7.5, -1], [8.5, 1]] })] {
let mut s = Session::new();
let shown = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
let hidden = line(&mut s, [0.0, 0.0], [10.0, 0.0]);
// The cutting edge, on a layer every viewport draws.
line(&mut s, [5.0, -5.0], [5.0, 5.0]);
hide(&mut s, hidden);
let (a, b) = viewports(&mut s);
enter(&mut s, &a);
s.execute("trim", &call).unwrap();
assert!(!untouched(&s, shown), "{call}: the line the viewport draws must be trimmed");
assert!(untouched(&s, hidden), "{call}: a line the viewport does not draw was trimmed: {:?}", ends(&s, hidden));
enter(&mut s, &b);
s.execute("trim", &call).unwrap();
assert!(!untouched(&s, hidden), "{call}: where both are drawn both are trimmed");
}
}
/// A prompt that picks one object (BREAK's "Select object") finds the one the viewport draws.
#[test]
fn a_prompt_for_one_object_picks_what_the_viewport_draws() {
let Sheet { mut s, shown, hidden, a, b } = sheet();
enter(&mut s, &a);
run(&mut s, "BREAK 4,0 6,0\n");
assert!(untouched(&s, hidden), "a line the viewport does not draw was broken: {:?}", ends(&s, hidden));
assert!(!untouched(&s, shown), "the line the viewport draws must be broken");
enter(&mut s, &b);
run(&mut s, "BREAK 4,0 6,0\n");
assert!(!untouched(&s, hidden), "where both are drawn the one on top is broken");
}
/// LEADER's Copy option picks the text to copy: at a point where two texts lie, the one the
/// viewport draws.
#[test]
fn leader_copies_the_text_the_viewport_draws() {
/// The contents of the multiline texts in model space: LEADER's copies.
fn copies(s: &Session) -> Vec<String> {
s.doc().unwrap().model.iter().filter_map(|e| if let EntityKind::MText(m) = &e.kind { Some(m.contents.clone()) } else { None }).collect()
}
let mut s = Session::new();
for text in ["SHOWN", "HIDDEN"] {
s.execute("text", &json!({ "at": [0, 0], "text": text, "height": 2.5 })).unwrap();
}
// The text drawn last lies on top.
let hidden = *s.doc().unwrap().model.handles().last().unwrap();
hide(&mut s, hidden);
let (a, b) = viewports(&mut s);
let leader = "(command \"_.LEADER\" '(20.0 20.0) '(30.0 25.0) \"\" \"\" \"_C\" '(1.0 1.0))";
enter(&mut s, &a);
s.cmdline(leader).unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(copies(&s), ["SHOWN"], "the viewport freezes the layer of the text on top");
s.execute("undo", &json!({})).unwrap();
assert!(copies(&s).is_empty());
enter(&mut s, &b);
s.cmdline(leader).unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert_eq!(copies(&s), ["HIDDEN"], "this viewport draws both: the one on top is picked");
}
/// AutoLISP selects through the viewport too: `ssget` with a window, a crossing window, a fence,
/// a polygon, a crossing polygon or a point leaves the frozen line out, and `entsel` picks the
/// line the viewport draws. The sets made earlier (the implied selection, `ssgetfirst`, Previous)
/// come without the frozen line, and Last is the last object the viewport draws.
#[test]
fn lisp_selects_what_the_viewport_draws() {
use cadcraft_engine::lisp::value::Value as Lisp;
/// The objects of the selection set that `code` returns (none for nil), in handle order.
fn set(s: &mut Session, code: &str) -> Vec<Handle> {
match s.lisp_eval(code, false, true, Vec::new()).unwrap() {
Some(Ok(Lisp::PickSet(set))) => sorted(set.items.lock().unwrap().clone()),
Some(Ok(Lisp::Nil)) => Vec::new(),
other => panic!("`{code}` returned {other:?}"),
}
}
/// The object `entsel` returns for a pick at (5, 0).
fn entsel(s: &mut Session) -> Handle {
s.cmdline("(setq picked (car (entsel)))").unwrap();
s.input(cadcraft_engine::Input::Point(Vec2::new(5.0, 0.0))).unwrap();
match s.lisp_var("picked") {
Lisp::Ename(h) => h,
other => panic!("entsel returned {other:?}: {}", s.prompt_text()),
}
}
let Sheet { mut s, shown, hidden, a, b } = sheet();
let areas = [
"(ssget \"W\" '(-1 -1) '(11 1))",
"(ssget \"C\" '(4 -1) '(6 1))",
"(ssget \"F\" '((5 -1) (5 1)))",
"(ssget \"WP\" '((-1 -1) (11 -1) (11 1) (-1 1)))",
"(ssget \"CP\" '((4 -1) (6 -1) (5 1)))",
];
enter(&mut s, &a);
for code in areas {
assert_eq!(set(&mut s, code), [shown], "{code}");
}
assert_eq!(set(&mut s, "(ssget '(5 0))"), [shown], "a point picks the object the viewport draws");
assert_eq!(entsel(&mut s), shown);
enter(&mut s, &b);
for code in areas {
assert_eq!(set(&mut s, code), sorted(vec![shown, hidden]), "{code}");
}
assert_eq!(set(&mut s, "(ssget '(5 0))"), [hidden], "this viewport draws both: the one on top is picked");
assert_eq!(entsel(&mut s), hidden);
let earlier = ["(ssget \"I\")", "(ssget)", "(cadr (ssgetfirst))", "(ssget \"P\")"];
for (viewport, expected) in [(&a, vec![shown]), (&b, sorted(vec![shown, hidden]))] {
enter(&mut s, viewport);
for code in earlier {
s.set_selection(vec![shown, hidden]);
s.remember_selection(&[shown, hidden]);
assert_eq!(set(&mut s, code), expected, "{code}");
}
}
// Last: of two lines made after everything else, the later one is on the layer the first
// viewport freezes, so through that viewport Last is the earlier one.
s.set_selection(Vec::new());
enter(&mut s, &b);
let drawn = line(&mut s, [0.0, 2.0], [10.0, 2.0]);
let last = line(&mut s, [0.0, 3.0], [10.0, 3.0]);
hide(&mut s, last);
assert_eq!(set(&mut s, "(ssget \"L\")"), [last]);
enter(&mut s, &a);
assert_eq!(set(&mut s, "(ssget \"L\")"), [drawn], "Last must pass over an object the viewport does not draw");
}
/// The grips of a selected object the viewport does not draw take no part in a grip edit: a
/// shared grip stretches only the line that is drawn. Through the other viewport both stretch.
#[test]
fn a_selected_line_the_viewport_does_not_draw_is_not_stretched_with_a_shared_grip() {
let Sheet { mut s, shown, hidden, a, b } = sheet();
enter(&mut s, &a);
s.set_selection(vec![shown, hidden]);
let i = origin_grip(&s, shown);
s.start_grip_edit(&[(shown, i)]).unwrap();
s.cmdline("3,4").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert!(ends(&s, shown).0.near(Vec2::new(3.0, 4.0), 1e-9), "{:?}", ends(&s, shown));
assert!(untouched(&s, hidden), "a line the viewport does not draw moved: {:?}", ends(&s, hidden));
// Its own grip starts no edit there.
let before = s.state().unwrap().doc.clone();
let j = origin_grip(&s, hidden);
assert!(s.start_grip_edit(&[(hidden, j)]).is_err());
assert!(s.running.is_none());
assert!(std::sync::Arc::ptr_eq(&before, &s.state().unwrap().doc), "nothing may change");
s.execute("undo", &json!({})).unwrap();
enter(&mut s, &b);
s.set_selection(vec![shown, hidden]);
s.start_grip_edit(&[(shown, i)]).unwrap();
s.cmdline("3,4").unwrap();
assert!(s.running.is_none(), "{}", s.prompt_text());
assert!(ends(&s, shown).0.near(Vec2::new(3.0, 4.0), 1e-9), "{:?}", ends(&s, shown));
assert!(ends(&s, hidden).0.near(Vec2::new(3.0, 4.0), 1e-9), "where both are drawn both stretch: {:?}", ends(&s, hidden));
}
/// A layer frozen in the viewport while its object is selected: from then on the object's grips
/// are not there, and thawing the layer in the viewport brings them back.
#[test]
fn freezing_a_layer_in_the_viewport_removes_the_grips_of_a_selected_object() {
let Sheet { mut s, hidden, b, .. } = sheet();
enter(&mut s, &b);
s.set_selection(vec![hidden]);
// The typed GRIP command finds a grip of the selected line at its start.
let grip_there = |s: &mut Session| {
s.cmdline("grip").unwrap();
s.cmdline("0,0").unwrap();
let none = s.log.iter().any(|l| l.contains("No grip of a selected object there"));
s.input(cadcraft_engine::Input::Cancel).unwrap();
s.log.clear();
!none
};
assert!(grip_there(&mut s), "the viewport draws the line: its grip is there");
s.execute("vplayer", &json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
s.set_selection(vec![hidden]);
assert!(!grip_there(&mut s), "the viewport no longer draws the line");
s.execute("vplayer", &json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
s.set_selection(vec![hidden]);
assert!(grip_there(&mut s), "thawed in the viewport: the grip is back");
}
/// What the edited view shows: the frozen layer hides its model-space objects inside that
/// viewport only, and never an object on the sheet.
#[test]
fn only_the_viewport_that_freezes_a_layer_hides_its_model_objects() {
let Sheet { mut s, shown, hidden, a, b } = sheet();
// A line on the sheet, on the layer the first viewport freezes.
let on_sheet = line(&mut s, [1.0, 1.0], [2.0, 1.0]);
hide(&mut s, on_sheet);
assert_eq!(s.doc().unwrap().space_of(on_sheet), Some(Space::Paper("Layout1".into())));
let shows = |s: &Session, h: Handle| {
let st = s.state().unwrap();
st.shows(st.doc.entity(h).unwrap())
};
assert!(s.viewport_frozen().is_empty(), "paper space: no viewport is active");
assert!(shows(&s, hidden) && shows(&s, shown) && shows(&s, on_sheet));
enter(&mut s, &a);
assert_eq!(s.viewport_frozen(), ["Hidden".to_string()]);
assert!(!shows(&s, hidden), "frozen in the active viewport");
assert!(shows(&s, shown));
assert!(shows(&s, on_sheet), "the viewport draws model space: its frozen layers hide nothing on the sheet");
// A prompt that picks one object on the sheet while the viewport is active finds it.
let sheet_space = Space::Paper("Layout1".into());
assert_eq!(s.pick_object(&sheet_space, Vec2::new(1.5, 1.0), 0.5).unwrap(), Some(on_sheet));
assert_eq!(s.pick_object(&Space::Model, Vec2::new(5.0, 0.0), 0.5).unwrap(), Some(shown), "in model space the frozen line is left out");
enter(&mut s, &b);
assert!(s.viewport_frozen().is_empty());
assert!(shows(&s, hidden));
// Layer names are not case sensitive: frozen under another spelling, the line is hidden too.
s.execute("vplayer", &json!({ "handles": [b], "freeze": ["HIDDEN"] })).unwrap();
assert_eq!(s.viewport_frozen(), ["HIDDEN".to_string()]);
assert!(!shows(&s, hidden));
assert_eq!(select(&mut s, json!({ "at": [5, 0], "aperture": 0.5 })), [shown]);
s.execute("vplayer", &json!({ "handles": [b], "thaw": ["hidden"] })).unwrap();
assert!(shows(&s, hidden));
s.execute("layout.set", &json!({ "name": "Model" })).unwrap();
assert!(s.viewport_frozen().is_empty(), "the Model tab has no layout viewport");
assert!(shows(&s, hidden));
}
/// An object that is selected when its layer is frozen in the active viewport stays selected, but
/// nothing that starts from the selection takes it there: a typed command with the selection
/// made before it (PICKFIRST), a JSON command that names no objects, and Delete.
#[test]
fn a_command_leaves_out_a_selected_object_the_viewport_does_not_draw() {
/// One way of editing the selection.
type Edit = fn(&mut Session);
let edits: [(&str, Edit); 5] = [
("typed ERASE", |s: &mut Session| run(s, "ERASE\n")),
("typed MOVE", |s: &mut Session| run(s, "MOVE 0,0 0,5\n")),
("JSON erase", |s: &mut Session| drop(s.execute("erase", &json!({})).unwrap())),
("JSON move", |s: &mut Session| drop(s.execute("move", &json!({ "delta": [0, 5] })).unwrap())),
("Delete", |s: &mut Session| drop(s.execute("erase.selection", &json!({})).unwrap())),
];
for (name, edit) in edits {
// Where both lines are drawn, both are edited.
let Sheet { mut s, shown, hidden, b, .. } = sheet();
enter(&mut s, &b);
s.set_selection(vec![shown, hidden]);
edit(&mut s);
assert!(edited(&s, shown) && edited(&s, hidden), "{name}: where both are drawn both are edited");
// The layer is frozen in the active viewport while both lines are selected.
let Sheet { mut s, shown, hidden, b, .. } = sheet();
enter(&mut s, &b);
s.set_selection(vec![shown, hidden]);
s.execute("vplayer", &json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
assert_eq!(selected(&s), sorted(vec![shown, hidden]), "{name}: both lines must still be selected");
assert_eq!(s.selection(), [shown], "{name}: the selection as the viewport has it");
edit(&mut s);
assert!(edited(&s, shown), "{name}: the line the viewport draws must be edited");
assert!(!edited(&s, hidden), "{name}: a line the viewport does not draw was edited: {:?}", ends(&s, hidden));
}
// With only that line selected there is nothing to start from.
let Sheet { mut s, hidden, a, b, .. } = sheet();
enter(&mut s, &a);
s.set_selection(vec![hidden]);
assert!(s.selection().is_empty());
assert!(s.execute("erase.selection", &json!({})).is_err(), "Delete must find nothing selected");
s.script("ERASE\n").unwrap();
assert!(s.running.is_some() && s.prompt_text().contains("Select objects"), "ERASE must ask for objects: {}", s.prompt_text());
s.input(cadcraft_engine::Input::Cancel).unwrap();
assert!(!edited(&s, hidden));
// The line is still selected, and a viewport that draws it has it again.
s.set_selection(vec![hidden]);
s.execute("vplayer", &json!({ "handles": [a], "thaw": ["Hidden"] })).unwrap();
assert_eq!(s.selection(), [hidden], "thawed in the viewport: selected as before");
s.execute("vplayer", &json!({ "handles": [a], "freeze": ["Hidden"] })).unwrap();
assert!(s.selection().is_empty());
enter(&mut s, &b);
s.set_selection(vec![hidden]);
run(&mut s, "ERASE\n");
assert!(!exists(&s, hidden), "where the line is drawn the selection made before ERASE is erased");
}
/// The Previous and Last options of "Select objects" take what the viewport draws: of the set a
/// command used before, and the last object that is not frozen there.
#[test]
fn previous_and_last_leave_out_what_the_viewport_does_not_draw() {
let Sheet { mut s, shown, hidden, a, b } = sheet();
for option in ["P", "L"] {
enter(&mut s, &a);
s.set_selection(Vec::new());
s.remember_selection(&[shown, hidden]);
run(&mut s, &format!("ERASE {option}\n"));
assert!(!exists(&s, shown), "`{option}` must erase the line the viewport draws");
assert!(exists(&s, hidden), "`{option}` erased a line the viewport does not draw");
s.execute("undo", &json!({})).unwrap();
assert!(exists(&s, shown) && exists(&s, hidden));
enter(&mut s, &b);
s.set_selection(Vec::new());
s.remember_selection(&[shown, hidden]);
run(&mut s, &format!("ERASE {option}\n"));
// Previous is both lines; Last is the one made last, the line on the layer "Hidden".
assert_eq!((exists(&s, shown), exists(&s, hidden)), (option == "L", false), "`{option}` where both are drawn");
s.execute("undo", &json!({})).unwrap();
assert!(exists(&s, shown) && exists(&s, hidden));
}
}
/// The JSON grip edits that cover the selection (`grip.stretch` with hot grips, and
/// [`Session::grip_edit`]) drag the grips of the other selected objects on the same point, but
/// not the grip of a selected line the viewport does not draw.
#[test]
fn the_grip_edits_that_cover_the_selection_leave_out_what_the_viewport_does_not_draw() {
use cadcraft_engine::grips::GripMode;
/// Drags grip `i` of the line `h` to (3, 4).
type Edit = fn(&mut Session, Handle, usize);
let edits: [(&str, Edit); 2] = [
("grip.stretch", |s: &mut Session, h: Handle, i: usize| {
drop(s.execute("grip.stretch", &json!({ "grips": [[h.hex(), i]], "to": [3, 4] })).unwrap())
}),
("grip_edit", |s: &mut Session, h: Handle, i: usize| s.grip_edit(h, i, Vec2::new(3.0, 4.0), GripMode::Stretch).unwrap()),
];
for (name, edit) in edits {
for frozen in [true, false] {
let Sheet { mut s, shown, hidden, a, b } = sheet();
enter(&mut s, if frozen { &a } else { &b });
s.set_selection(vec![shown, hidden]);
let i = origin_grip(&s, shown);
edit(&mut s, shown, i);
assert!(ends(&s, shown).0.near(Vec2::new(3.0, 4.0), 1e-9), "{name}: {:?}", ends(&s, shown));
assert_eq!(untouched(&s, hidden), frozen, "{name}, layer frozen in the viewport: {frozen}: {:?}", ends(&s, hidden));
}
}
}
/// What the session reports as the selection is the selection as the viewport has it, while
/// every selected object stays in the stored selection.
#[test]
fn the_reported_selection_leaves_out_what_the_viewport_does_not_draw() {
/// The handles in `list`, a JSON array of hex strings, in handle order.
fn handles(list: &Value) -> Vec<Handle> {
sorted(list.as_array().unwrap().iter().map(|h| Handle::parse_hex(h.as_str().unwrap()).unwrap()).collect())
}
let Sheet { mut s, shown, hidden, a, b } = sheet();
for (viewport, expected) in [(&a, vec![shown]), (&b, sorted(vec![shown, hidden]))] {
enter(&mut s, viewport);
s.set_selection(vec![shown, hidden]);
assert_eq!(selected(&s), sorted(vec![shown, hidden]), "both lines are selected");
assert_eq!(sorted(s.selection()), expected);
assert_eq!(handles(&s.summary()["selection"]), expected);
let inspect = s.execute("drawing.inspect", &json!({})).unwrap();
assert_eq!(handles(&inspect["selection"]), expected);
}
// A selected object on the sheet is never left out, whatever its layer.
s.execute("pspace", &json!({})).unwrap();
let on_sheet = line(&mut s, [1.0, 1.0], [2.0, 1.0]);
hide(&mut s, on_sheet);
enter(&mut s, &a);
s.set_selection(vec![shown, hidden, on_sheet]);
assert_eq!(sorted(s.selection()), sorted(vec![shown, on_sheet]));
// Where no viewport is active nothing is left out: in paper space and on the Model tab.
s.execute("pspace", &json!({})).unwrap();
s.set_selection(vec![shown, hidden, on_sheet]);
assert_eq!(sorted(s.selection()), sorted(vec![shown, hidden, on_sheet]), "paper space");
s.execute("layout.set", &json!({ "name": "Model" })).unwrap();
s.set_selection(vec![shown, hidden]);
assert_eq!(sorted(s.selection()), sorted(vec![shown, hidden]), "the Model tab");
}
/// Adding to the selection or removing from it inside the viewport keeps a selected object the
/// viewport does not draw: nothing drops it behind the user's back, and it counts again once its
/// layer is thawed there.
#[test]
fn changing_the_selection_keeps_a_selected_object_the_viewport_does_not_draw() {
let Sheet { mut s, shown, hidden, b, .. } = sheet();
enter(&mut s, &b);
// A third line, on layer 0 like `shown`, away from the other two.
let other = line(&mut s, [0.0, 5.0], [10.0, 5.0]);
// Selects `hidden` where it is drawn, then freezes its layer in the active viewport.
let freeze = |s: &mut Session| {
s.execute("vplayer", &json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
s.set_selection(vec![hidden]);
s.execute("vplayer", &json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
assert_eq!(selected(s), [hidden], "the line must still be selected");
assert!(s.selection().is_empty(), "the viewport does not draw it");
};
// A click adds (PICKADD), Shift+click removes.
freeze(&mut s);
s.idle_click(Vec2::new(5.0, 5.0), false).unwrap();
assert_eq!(selected(&s), sorted(vec![hidden, other]), "a click dropped the selected line the viewport does not draw");
assert_eq!(s.selection(), [other]);
s.idle_click(Vec2::new(5.0, 5.0), true).unwrap();
assert_eq!(selected(&s), [hidden], "Shift+click must remove only the line clicked");
// A window adds.
freeze(&mut s);
s.idle_click(Vec2::new(-1.0, 4.0), false).unwrap();
assert!(s.pending_window.is_some(), "the first click must be on nothing");
s.idle_click(Vec2::new(11.0, 6.0), false).unwrap();
assert_eq!(selected(&s), sorted(vec![hidden, other]), "a window dropped the selected line the viewport does not draw");
// The JSON `select` with `add`, and QSELECT appending the objects on layer 0.
freeze(&mut s);
s.execute("select", &json!({ "at": [5, 5], "aperture": 0.5, "add": true })).unwrap();
assert_eq!(selected(&s), sorted(vec![hidden, other]), "`select` with `add`");
// Given nothing to select with, `select` leaves the selection as it is.
s.execute("select", &json!({})).unwrap();
assert_eq!(selected(&s), sorted(vec![hidden, other]), "`select` with nothing to select with");
freeze(&mut s);
s.execute("qselect", &json!({ "layer": "0", "mode": "append" })).unwrap();
assert_eq!(selected(&s), sorted(vec![shown, hidden, other]), "QSELECT appending");
assert_eq!(sorted(s.selection()), sorted(vec![shown, other]));
// Thawed in the viewport, the line counts as selected again.
s.execute("vplayer", &json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
assert_eq!(sorted(s.selection()), sorted(vec![shown, hidden, other]));
}
/// [`Session::selection`] looks a few selected objects up one by one and, when there are many,
/// passes over model space once. Both leave out the same objects and keep the order of the
/// selection: in a drawing of some hundreds of lines whose draw order is not the order of their
/// handles, a selection of a few lines and the selection of every line.
#[test]
fn a_small_and_a_large_selection_leave_out_the_same_objects() {
let Sheet { mut s, shown, hidden, a, b } = sheet();
// A line on the sheet, on the layer the first viewport freezes: no viewport hides it.
let on_sheet = line(&mut s, [1.0, 1.0], [2.0, 1.0]);
hide(&mut s, on_sheet);
assert!(!s.doc().unwrap().model.contains(on_sheet), "the line must be on the sheet");
enter(&mut s, &b);
let more: Vec<Handle> = (0..400).map(|i| line(&mut s, [f64::from(i), 5.0], [f64::from(i), 6.0])).collect();
// Every second one of them goes on the layer the first viewport freezes.
let frozen: Vec<String> = more.iter().step_by(2).map(|h| h.hex()).collect();
s.execute("properties.set", &json!({ "handles": frozen, "layer": "Hidden" })).unwrap();
// The frozen line made last goes to the back: it is now the first object in draw order.
s.execute("draworder", &json!({ "handles": [more[398].hex()], "order": "back" })).unwrap();
assert_eq!(s.doc().unwrap().model.iter().next().map(|e| e.handle), Some(more[398]));
let all: Vec<Handle> = [shown, hidden].into_iter().chain(more.iter().copied()).collect();
let drawn: Vec<Handle> = std::iter::once(shown).chain(more.iter().copied().skip(1).step_by(2)).collect();
assert_eq!(s.doc().unwrap().model.len(), all.len());
enter(&mut s, &a);
s.set_selection(vec![shown, hidden]);
assert_eq!(s.selection(), [shown], "two selected lines of some hundreds");
s.set_selection(vec![hidden, on_sheet, more[0], more[1]]);
assert_eq!(s.selection(), [on_sheet, more[1]], "a line on the sheet is never left out");
s.set_selection(all.clone());
assert_eq!(s.selection(), drawn, "every line selected");
assert_eq!(s.state().unwrap().selection, all, "every line must still be selected");
// The viewport that freezes nothing has them all, few or many.
enter(&mut s, &b);
s.set_selection(vec![shown, hidden]);
assert_eq!(s.selection(), [shown, hidden]);
s.set_selection(all.clone());
assert_eq!(s.selection(), all);
}
+3 -2
View File
@@ -96,8 +96,9 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value,
return None;
}
let st = app.session.state().ok()?;
let refs = st.selection.iter().filter(|h| st.doc.entity(**h).is_some_and(|e| matches!(e.kind, EntityKind::Insert(_)))).count();
if refs == 1 && st.selection.len() == 1 {
let selection = st.shown_selection();
let refs = selection.iter().filter(|h| st.doc.entity(**h).is_some_and(|e| matches!(e.kind, EntityKind::Insert(_)))).count();
if refs == 1 && selection.len() == 1 {
return None;
}
// Start on the block last edited if it is still there, else the first one.
+3 -2
View File
@@ -700,7 +700,8 @@ fn update_hover(app: &mut CadApp, ui: &egui::Ui, inside: bool, hover_pos: Option
&& let (Some(w), Ok(st)) = (raw_world, app.session.state())
{
let filter = app.session.settings.previewfilter;
app.canvas.hover = cadcraft_engine::select::pick(&st.doc, &st.edit_space(), w, app.session.settings.pickbox.max(1.0) * 1.5 / scale)
let ap = app.session.settings.pickbox.max(1.0) * 1.5 / scale;
app.canvas.hover = cadcraft_engine::select::pick_in(&st.doc, &st.edit_space(), st.viewport_frozen(), w, ap)
.filter(|h| st.doc.entity(*h).is_none_or(|e| !cadcraft_engine::select::preview_filtered(&st.doc, e, filter)));
app.canvas.hover_at = hover_pos;
}
@@ -1104,7 +1105,7 @@ fn paint_canvas(frame: CanvasPaint<'_>) {
let gs = app.session.settings.gripsize as f32;
for h in sel.iter().take(200) {
if let Some(e) = st.doc.entity(*h)
&& st.doc.is_visible(e)
&& st.shows(e)
{
let grips = e.kind.grips();
let count = grips.len();
+5 -4
View File
@@ -3,10 +3,11 @@ use super::{CadApp, Handle, Input, Pos2, Rect, Xf, vec2};
/// A click on grip `g` (idle): Shift+click makes it hot or cold again; a plain click starts
/// editing it, with the other hot grips when it is one of them.
pub(super) fn click_grip(app: &mut CadApp, g: (Handle, usize), shift: bool) {
// A hot grip whose object was hidden since (a layer turned off, HIDEOBJECTS) is no longer
// painted, so it must not be stretched along with the visible ones.
// A hot grip whose object was hidden since (a layer turned off, HIDEOBJECTS, a layer frozen
// in the active viewport) is no longer painted, so it must not be stretched along with the
// visible ones.
let Ok(st) = app.session.state() else { return };
app.canvas.hot_grips.retain(|(h, _)| st.doc.entity(*h).is_some_and(|e| st.doc.is_visible(e)));
app.canvas.hot_grips.retain(|(h, _)| st.doc.entity(*h).is_some_and(|e| st.shows(e)));
let hot = &mut app.canvas.hot_grips;
if shift {
match hot.iter().position(|x| *x == g) {
@@ -89,7 +90,7 @@ pub(super) fn grip_at(app: &CadApp, xf: &Xf, hover: Option<Pos2>) -> Option<(Han
}
for h in sel.iter().take(200) {
let e = st.doc.entity(*h)?;
if !st.doc.is_visible(e) || st.doc.layer(&e.common.layer).is_some_and(|l| l.locked) {
if !st.shows(e) || st.doc.layer(&e.common.layer).is_some_and(|l| l.locked) {
continue;
}
let grips = e.kind.grips();
+1 -1
View File
@@ -52,7 +52,7 @@ pub(super) fn edit_text_at(app: &mut CadApp, p: Vec2) -> bool {
pub(super) fn hatch_at(app: &CadApp, w: Vec2, scale: f64) -> Option<Handle> {
let st = app.session.state().ok()?;
let aperture = app.session.settings.pickbox.max(1.0) * 1.5 / scale.max(1e-12);
let h = cadcraft_engine::select::pick(&st.doc, &st.edit_space(), w, aperture)?;
let h = cadcraft_engine::select::pick_in(&st.doc, &st.edit_space(), st.viewport_frozen(), w, aperture)?;
matches!(st.doc.entity(h)?.kind, cadcraft_doc::EntityKind::Hatch(_)).then_some(h)
}
+118
View File
@@ -248,3 +248,121 @@ fn clicking_a_hot_grip_leaves_hidden_objects_out_of_the_stretch() {
click_grip(&mut app, (shown, 0), false);
assert_eq!(app.canvas.hot_grips, vec![(shown, 0)], "the hidden object's grip must leave the hot set");
}
/// Makes the layer "Hidden" and, on Layout1, two viewports over the model: the first freezes
/// "Hidden", the second freezes nothing. The app is left in paper space.
fn viewports_freezing_hidden(app: &mut CadApp) -> (String, String) {
app.session.execute("layer.new", &serde_json::json!({ "name": "Hidden" })).unwrap();
app.session.execute("layout.set", &serde_json::json!({ "name": "Layout1" })).unwrap();
let made = app.session.execute("mview", &serde_json::json!({ "count": 2, "p1": [20, 20], "p2": [220, 120] })).unwrap();
let viewport = |i: usize| made["viewports"][i].as_str().unwrap().to_string();
let (a, b) = (viewport(0), viewport(1));
app.session.execute("vplayer", &serde_json::json!({ "handles": [a], "freeze": ["Hidden"] })).unwrap();
(a, b)
}
/// An app with two model-space lines, the second on the layer "Hidden", and the two viewports of
/// [`viewports_freezing_hidden`]: (app, the first line, the second line, the viewport that
/// freezes "Hidden", the viewport that freezes nothing).
fn lines_in_two_viewports() -> (CadApp, Handle, Handle, String, String) {
let mut app = CadApp::new(cadcraft_engine::Session::new(), crate::Services::default());
for y in [0, 30] {
app.run("line", serde_json::json!({ "points": [[0, y], [100, y]] })).unwrap();
}
let handles = app.session.doc().unwrap().model.handles();
let (shown, hidden) = (handles[0], handles[1]);
let (a, b) = viewports_freezing_hidden(&mut app);
app.session.execute("properties.set", &serde_json::json!({ "handles": [hidden.hex()], "layer": "Hidden" })).unwrap();
assert_eq!(app.session.doc().unwrap().entity(hidden).unwrap().common.layer, "Hidden");
(app, shown, hidden, a, b)
}
/// A hot grip whose object is on a layer frozen in the active viewport is not painted there, so
/// clicking another hot grip must not stretch that object along with it. Through a viewport that
/// draws the layer both grips stay hot.
#[test]
fn clicking_a_hot_grip_leaves_out_objects_the_viewport_does_not_draw() {
let (mut app, shown, hidden, a, b) = lines_in_two_viewports();
for (viewport, hot) in [(&a, vec![(shown, 0)]), (&b, vec![(shown, 0), (hidden, 0)])] {
app.session.execute("mspace", &serde_json::json!({ "handle": viewport })).unwrap();
app.session.set_selection(vec![shown, hidden]);
app.canvas.hot_grips = vec![(shown, 0), (hidden, 0)];
click_grip(&mut app, (shown, 0), false);
assert_eq!(app.canvas.hot_grips, hot);
app.session.cancel();
}
// Frozen in the active viewport while both grips are hot.
app.session.set_selection(vec![shown, hidden]);
app.canvas.hot_grips = vec![(shown, 0), (hidden, 0)];
app.session.execute("vplayer", &serde_json::json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
click_grip(&mut app, (shown, 0), false);
assert_eq!(app.canvas.hot_grips, vec![(shown, 0)], "the grip of the line the viewport no longer draws must leave the hot set");
}
/// The cursor finds no grip of a selected object the viewport does not draw; it finds the grips
/// of the other selected object there, and both through a viewport that draws both.
#[test]
fn the_cursor_finds_no_grip_of_an_object_the_viewport_does_not_draw() {
let (mut app, shown, hidden, a, b) = lines_in_two_viewports();
let rect = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0));
let xf = Xf { rect, center: Vec2::new(50.0, 15.0), scale: 2.0, twist: 0.0 };
// The start of each line, where its first grip is.
let (on_shown, on_hidden) = (Some(xf.to_screen(Vec2::new(0.0, 0.0))), Some(xf.to_screen(Vec2::new(0.0, 30.0))));
app.session.execute("mspace", &serde_json::json!({ "handle": a })).unwrap();
app.session.set_selection(vec![shown, hidden]);
assert_eq!(grip_at(&app, &xf, on_shown), Some((shown, 0)));
assert_eq!(grip_at(&app, &xf, on_hidden), None, "a grip of a line the viewport does not draw was hit");
app.session.execute("mspace", &serde_json::json!({ "handle": b })).unwrap();
app.session.set_selection(vec![shown, hidden]);
assert_eq!(grip_at(&app, &xf, on_hidden), Some((hidden, 0)));
// Frozen in the active viewport while the line is selected, then thawed again.
app.session.execute("vplayer", &serde_json::json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
assert_eq!(app.session.state().unwrap().selection, [shown, hidden], "both lines must still be selected");
assert_eq!(grip_at(&app, &xf, on_hidden), None);
app.session.execute("vplayer", &serde_json::json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
assert_eq!(grip_at(&app, &xf, on_hidden), Some((hidden, 0)));
}
/// The hatch under the cursor, which a double-click edits, is one the viewport draws.
#[test]
fn the_cursor_finds_no_hatch_the_viewport_does_not_draw() {
let mut app = CadApp::new(cadcraft_engine::Session::new(), crate::Services::default());
let handle = |v: &serde_json::Value| Handle::parse_hex(v.get("handle").unwrap_or(&v["handles"][0]).as_str().unwrap()).unwrap();
let square = app.session.execute("pline", &serde_json::json!({ "vertices": [[0, 0], [20, 0], [20, 20], [0, 20]], "closed": true })).unwrap();
let hatch = handle(&app.session.execute("hatch", &serde_json::json!({ "handles": [handle(&square).hex()], "pattern": "SOLID" })).unwrap());
let (a, b) = viewports_freezing_hidden(&mut app);
app.session.execute("properties.set", &serde_json::json!({ "handles": [hatch.hex()], "layer": "Hidden" })).unwrap();
let inside = Vec2::new(10.0, 10.0);
app.session.execute("mspace", &serde_json::json!({ "handle": a })).unwrap();
assert_eq!(hatch_at(&app, inside, 1.0), None, "a hatch the viewport does not draw was found");
app.session.execute("mspace", &serde_json::json!({ "handle": b })).unwrap();
assert_eq!(hatch_at(&app, inside, 1.0), Some(hatch));
}
/// The highlight of the selection inside the active viewport leaves out a selected object on a
/// layer frozen in that viewport, also when the layer is frozen while the object is selected,
/// and is built again for another viewport although the selection and the drawing are the same.
#[test]
fn the_viewport_highlight_leaves_out_objects_the_viewport_does_not_draw() {
let (mut app, shown, hidden, a, b) = lines_in_two_viewports();
/// The objects `highlight` outlines for the selection of both lines.
fn outlined(app: &CadApp, highlight: &mut VpHighlight, both: [Handle; 2]) -> Vec<Handle> {
highlight.update(app.session.state().unwrap(), &both, 10.0);
highlight.list.prims.iter().map(|p| p.handle).collect::<std::collections::BTreeSet<_>>().into_iter().collect()
}
let both = [shown, hidden];
let mut highlight = VpHighlight::default();
app.session.execute("mspace", &serde_json::json!({ "handle": b })).unwrap();
assert_eq!(outlined(&app, &mut highlight, both), both, "this viewport draws both lines");
let revision = app.session.state().unwrap().revision;
app.session.execute("mspace", &serde_json::json!({ "handle": a })).unwrap();
assert_eq!(app.session.state().unwrap().revision, revision, "entering a viewport must not look like a change of the drawing");
assert_eq!(outlined(&app, &mut highlight, both), [shown], "a line the viewport does not draw was outlined");
app.session.execute("mspace", &serde_json::json!({ "handle": b })).unwrap();
assert_eq!(outlined(&app, &mut highlight, both), both, "the list built for the other viewport was reused");
// Frozen in the active viewport while the line is selected, then thawed again.
app.session.execute("vplayer", &serde_json::json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
assert_eq!(outlined(&app, &mut highlight, both), [shown]);
app.session.execute("vplayer", &serde_json::json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
assert_eq!(outlined(&app, &mut highlight, both), both);
}
+7 -3
View File
@@ -271,7 +271,9 @@ pub(super) fn viewport_highlight_tolerance(scale: f64) -> f64 {
/// The selected or the hovered objects tessellated in model units for the active MSPACE
/// viewport, whose contents can't be looked up by handle in the layout's lists. The selection and
/// the hover are kept apart, so moving the cursor over objects doesn't tessellate the selection
/// again; each is rebuilt only when the drawing, the objects or the zoom band change.
/// again; each is rebuilt only when the drawing, the objects, the zoom band or the layers frozen
/// in the viewport change. An object on one of those layers is not drawn in the viewport, so it
/// is not highlighted there either.
#[derive(Default)]
pub(super) struct VpHighlight {
key: Option<u64>,
@@ -286,12 +288,14 @@ impl VpHighlight {
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, tolerance.to_bits(), handles, super::list::font_generation())
// Entering another viewport changes what is drawn without changing the drawing.
let frozen = st.viewport_frozen();
(st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, tolerance.to_bits(), handles, frozen, super::list::font_generation())
.hash(&mut h);
h.finish()
};
if self.key != Some(key) {
let ents = handles.iter().filter_map(|h| st.doc.model.get(*h)).map(|e| e.as_ref());
let ents = handles.iter().filter_map(|h| st.doc.model.get(*h)).filter(|e| !st.hidden_in_viewport(e)).map(|e| e.as_ref());
self.list = cadcraft_render::build_entities(&st.doc, ents, &cadcraft_render::Options { tolerance, ..Default::default() });
self.key = Some(key);
self.list_gen = self.list_gen.wrapping_add(1);
+75 -4
View File
@@ -18,10 +18,12 @@ use cadcraft_engine::doc::dynblock::{DynGrip, GripShape};
/// Most selected references whose grips are shown.
const MAX_REFERENCES: usize = 200;
/// The dynamic grips of the selection, worked out once per drawing revision and selection.
/// The dynamic grips of the selection, worked out once per drawing revision, selection and set
/// of layers frozen in the active viewport: entering another viewport changes which references
/// are shown without changing the drawing.
#[derive(Clone, Default)]
pub struct Cache {
key: (u64, u64, Vec<Handle>),
key: (u64, u64, Vec<Handle>, Vec<String>),
grips: Arc<Vec<(Handle, DynGrip)>>,
}
@@ -29,12 +31,12 @@ pub struct Cache {
pub(crate) fn of_selection(app: &mut CadApp) -> Arc<Vec<(Handle, DynGrip)>> {
let sel = app.session.selection();
let Ok(st) = app.session.state() else { return Arc::default() };
let key = (st.uid, st.revision, sel.clone());
let key = (st.uid, st.revision, sel.clone(), st.viewport_frozen().to_vec());
if app.canvas.dyn_grips.key != key {
let grips: Vec<(Handle, DynGrip)> = sel
.iter()
.filter(|h| matches!(st.doc.entity(**h).map(|e| &e.kind), Some(cadcraft_engine::doc::EntityKind::Insert(_))))
.filter(|h| st.doc.entity(**h).is_some_and(|e| st.doc.is_visible(e) && !st.doc.layer(&e.common.layer).is_some_and(|l| l.locked)))
.filter(|h| st.doc.entity(**h).is_some_and(|e| st.shows(e) && !st.doc.layer(&e.common.layer).is_some_and(|l| l.locked)))
.take(MAX_REFERENCES)
.flat_map(|h| cadcraft_engine::doc::dynblock::grips(&st.doc, *h).into_iter().map(move |g| (*h, g)))
.collect();
@@ -195,6 +197,62 @@ mod tests {
(s, h)
}
/// An app with the reference of [`door`] on the layer "Hidden", and two viewports on Layout1
/// over the model: the first freezes "Hidden", the second freezes nothing. Returns the app,
/// the reference and the two viewports, in paper space.
fn door_in_two_viewports() -> (CadApp, Handle, String, String) {
let (session, h) = door();
let mut app = CadApp::new(session, Services::default());
app.session.execute("layer.new", &json!({ "name": "Hidden" })).unwrap();
app.session.execute("properties.set", &json!({ "handles": [h.hex()], "layer": "Hidden" })).unwrap();
assert_eq!(app.session.doc().unwrap().entity(h).unwrap().common.layer, "Hidden");
app.session.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
let made = app.session.execute("mview", &json!({ "count": 2, "p1": [20, 20], "p2": [220, 120] })).unwrap();
let viewport = |i: usize| made["viewports"][i].as_str().unwrap().to_string();
let (a, b) = (viewport(0), viewport(1));
app.session.execute("vplayer", &json!({ "handles": [a], "freeze": ["Hidden"] })).unwrap();
(app, h, a, b)
}
/// Makes `viewport` the active MSPACE viewport and selects `h` there (entering a viewport
/// clears the selection; Undo or another selection source can put the reference back).
fn enter_and_select(app: &mut CadApp, viewport: &str, h: Handle) {
app.session.execute("mspace", &json!({ "handle": viewport })).unwrap();
app.session.set_selection(vec![h]);
}
/// A selected reference shows its dynamic grips through a viewport that draws its layer. Once
/// the layer is frozen in that viewport the grips are gone although the reference is still
/// selected, and thawing the layer there brings them back.
#[test]
fn a_reference_the_viewport_does_not_draw_has_no_dynamic_grips() {
let (mut app, h, _, b) = door_in_two_viewports();
enter_and_select(&mut app, &b, h);
assert_eq!(of_selection(&mut app).len(), 2, "this viewport draws the reference: its two grips");
app.session.execute("vplayer", &json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
assert_eq!(app.session.state().unwrap().selection, [h], "the reference must still be selected");
assert!(of_selection(&mut app).is_empty(), "a reference the viewport no longer draws kept its dynamic grips");
app.session.execute("vplayer", &json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
assert_eq!(app.session.state().unwrap().selection, [h]);
assert_eq!(of_selection(&mut app).len(), 2, "thawed in the viewport: the grips are back");
}
/// Entering another viewport changes which references are drawn without changing the drawing,
/// so the cached grips of the same selection must not be reused from one viewport to the next.
#[test]
fn the_dynamic_grip_cache_follows_the_active_viewport() {
let (mut app, h, a, b) = door_in_two_viewports();
enter_and_select(&mut app, &b, h);
assert_eq!(of_selection(&mut app).len(), 2);
let revision = app.session.state().unwrap().revision;
enter_and_select(&mut app, &a, h);
assert_eq!(app.session.state().unwrap().revision, revision, "entering a viewport must not look like a change of the drawing");
assert!(of_selection(&mut app).is_empty(), "the grips cached in the other viewport were reused where the reference is not drawn");
enter_and_select(&mut app, &b, h);
assert_eq!(app.session.state().unwrap().revision, revision);
assert_eq!(of_selection(&mut app).len(), 2, "the empty result cached in the other viewport was reused where the reference is drawn");
}
/// A hidden reference restored to the selection (Undo, or another selection source) shows
/// and takes no dynamic grips, as ordinary grips are not shown for it either.
#[test]
@@ -209,4 +267,17 @@ mod tests {
assert!(!st.doc.is_visible(st.doc.entity(h).unwrap()), "the reference must be hidden");
assert!(of_selection(&mut app).is_empty(), "a hidden reference must not offer dynamic grips");
}
/// BEDIT without a block name opens the editor on the one selected reference, and its dialog
/// when there is none. A selected reference the viewport does not draw is none to start from.
#[test]
fn bedit_opens_its_dialog_when_the_viewport_does_not_draw_the_selected_reference() {
let (mut app, h, a, b) = door_in_two_viewports();
enter_and_select(&mut app, &b, h);
assert!(crate::bedit::route(&mut app, "bedit", &serde_json::Value::Null).is_none(), "one reference is selected: no dialog");
assert_eq!(app.ui.dialog, None);
enter_and_select(&mut app, &a, h);
assert!(crate::bedit::route(&mut app, "bedit", &serde_json::Value::Null).is_some(), "the dialog must open");
assert_eq!(app.ui.dialog.as_deref(), Some("bedit"));
}
}
+1 -1
View File
@@ -297,7 +297,7 @@ pub fn open_create(app: &mut CadApp, gradient: bool) {
/// pick a hatch first.
pub fn open_edit(app: &mut CadApp) {
let Ok(st) = app.session.state() else { return };
let (mut edit, mut create) = (edit_state(&st.doc, st.uid, st.selection.clone()), create_state(&st.doc, st.uid, false));
let (mut edit, mut create) = (edit_state(&st.doc, st.uid, st.shown_selection()), create_state(&st.doc, st.uid, false));
create.space = st.edit_space();
if let Some(e) = edit.as_mut() {
e.space = st.edit_space();
+32
View File
@@ -416,3 +416,35 @@ fn a_gradient_hatch_offers_the_current_pattern_on_the_pattern_tab() {
k => panic!("{k:?}"),
}
}
/// Edit mode opens on the selected hatches. A selected hatch on a layer frozen in the active
/// viewport is not drawn there, so it is not one of them and the command asks for a hatch.
#[test]
fn edit_mode_leaves_out_a_selected_hatch_the_viewport_does_not_draw() {
let (mut app, _) = app_with_rect();
let hx = app.session.execute("hatch", &json!({ "points": [[5, 5]] })).unwrap()["handle"].as_str().unwrap().to_string();
let hatch = Handle::parse_hex(&hx).unwrap();
app.session.execute("layer.new", &json!({ "name": "Hidden" })).unwrap();
app.session.execute("properties.set", &json!({ "handles": [hx], "layer": "Hidden" })).unwrap();
app.session.execute("layout.set", &json!({ "name": "Layout1" })).unwrap();
let made = app.session.execute("mview", &json!({ "count": 2, "p1": [20, 20], "p2": [220, 120] })).unwrap();
let viewport = |i: usize| made["viewports"][i].as_str().unwrap().to_string();
let (a, b) = (viewport(0), viewport(1));
app.session.execute("vplayer", &json!({ "handles": [a], "freeze": ["Hidden"] })).unwrap();
// Through the viewport that draws the hatch, edit mode opens on it.
app.session.execute("mspace", &json!({ "handle": b })).unwrap();
app.session.set_selection(vec![hatch]);
open_edit(&mut app);
assert_eq!(app.ui.dialog.as_deref(), Some("hatch"));
assert_eq!(app.hatch_dlg.as_ref().map(|d| d.edit.clone()), Some(vec![hatch]));
// Through the viewport that freezes its layer there is no hatch to edit yet.
(app.ui.dialog, app.hatch_dlg) = (None, None);
app.session.execute("mspace", &json!({ "handle": a })).unwrap();
app.session.set_selection(vec![hatch]);
open_edit(&mut app);
assert_eq!(app.ui.dialog, None, "no dialog: the command asks for a hatch first");
assert_eq!(app.hatch_dlg.as_ref().map(|d| d.edit.clone()), Some(Vec::new()), "a hatch the viewport does not draw must not be edited");
assert!(app.session.running.is_some(), "HATCHPICK must be asking for a hatch");
}
@@ -0,0 +1,145 @@
//! Inside a layout viewport an object on a layer frozen in that viewport is not drawn: selected,
//! it paints no grips there, and the cursor does not highlight it.
use cadcraft_engine::doc::Handle;
use cadcraft_engine::geom::Vec2;
use cadcraft_engine::{Session, View};
use cadcraft_ui_egui::{CadApp, Services, theme::Tokens};
use serde_json::json;
/// What the tests draw: two model-space lines from (0, 0) to (100, 0), `shown` on layer 0 and,
/// drawn after it and so on top of it, `hidden` on the layer "Hidden"; and two viewports on
/// Layout1, `a` freezing "Hidden" and `b` freezing nothing.
struct Sheet {
app: CadApp,
shown: Handle,
hidden: Handle,
/// The viewport that freezes "Hidden".
a: String,
/// The viewport that freezes nothing.
b: String,
}
/// The drawing of [`Sheet`], in paper space with nothing selected.
fn sheet() -> Sheet {
let mut app = CadApp::new(Session::new(), Services::default());
app.ui.start_tab = false;
app.ui.in_window_menu = false;
app.integrated_titlebar = true;
// A third line gives the model a height for the viewports to fit.
for points in [[[0, 0], [100, 0]], [[0, 0], [100, 0]], [[0, 40], [100, 40]]] {
app.run("line", json!({ "points": points })).unwrap();
}
let handles = app.session.doc().unwrap().model.handles();
assert_eq!(handles.len(), 3);
let (shown, hidden) = (handles[0], handles[1]);
app.run("layer.new", json!({ "name": "Hidden" })).unwrap();
app.run("properties.set", json!({ "handles": [hidden.hex()], "layer": "Hidden" })).unwrap();
assert_eq!(app.session.doc().unwrap().entity(hidden).unwrap().common.layer, "Hidden");
app.run("layout.set", json!({ "name": "Layout1" })).unwrap();
let made = app.session.execute("mview", &json!({ "count": 2, "p1": [20, 20], "p2": [220, 120] })).unwrap();
let viewport = |i: usize| made["viewports"][i].as_str().unwrap().to_string();
let (a, b) = (viewport(0), viewport(1));
app.run("vplayer", json!({ "handles": [a], "freeze": ["Hidden"] })).unwrap();
// Show the part of the sheet that holds both viewports, with room around them.
assert!(app.session.state_mut().unwrap().set_view(View { center: Vec2::new(120.0, 70.0), height: 300.0, twist: 0.0 }));
app.session.set_selection(Vec::new());
Sheet { app, shown, hidden, a, b }
}
/// Makes `viewport` the active MSPACE viewport. That clears the selection.
fn enter(app: &mut CadApp, viewport: &str) {
app.run("mspace", json!({ "handle": viewport })).unwrap();
}
/// The grip rectangles (filled with `color`) inside `canvas` among the painted shapes.
fn count_grips(shape: &egui::Shape, canvas: egui::Rect, color: egui::Color32) -> usize {
match shape {
egui::Shape::Rect(rect) => usize::from(rect.fill == color && canvas.contains(rect.rect.center())),
egui::Shape::Vec(shapes) => shapes.iter().map(|shape| count_grips(shape, canvas, color)).sum(),
_ => 0,
}
}
/// Runs one frame of the app with the pointer at `pointer` (none: away from the window) and
/// counts the grip rectangles painted on its canvas.
fn frame(app: &mut CadApp, ctx: &egui::Context, pointer: Option<egui::Pos2>) -> usize {
app.logic(ctx);
let input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0))),
events: vec![pointer.map_or(egui::Event::PointerGone, egui::Event::PointerMoved)],
..Default::default()
};
let mut output = ctx.run_ui(input, |ui| app.ui(ui));
output.textures_delta.clear();
let canvas = app.canvas.rect.unwrap();
let color = Tokens::get().grip;
output.shapes.iter().map(|shape| count_grips(&shape.shape, canvas, color)).sum()
}
/// The grip rectangles painted once the frame has settled, with the pointer away.
fn painted_grips(app: &mut CadApp, ctx: &egui::Context) -> usize {
frame(app, ctx, None);
frame(app, ctx, None)
}
/// Whether every grip of `h` is on the canvas, where it would be painted.
fn grips_on_canvas(app: &CadApp, h: Handle) -> bool {
let xf = app.canvas.xf.unwrap();
let state = app.session.state().unwrap();
state.doc.entity(h).unwrap().kind.grips().iter().all(|point| xf.rect.contains(xf.to_screen(*point)))
}
/// A selected line paints its grips through a viewport that draws its layer. Once the layer is
/// frozen in that viewport the grips are gone although the line is still selected, the grips of
/// the other selected line stay, and thawing the layer brings them back. The same holds from one
/// viewport to the other.
#[test]
fn a_selected_object_the_viewport_does_not_draw_paints_no_grips() {
let Sheet { mut app, shown, hidden, a, b } = sheet();
let ctx = egui::Context::default();
// Settle the dock layout.
painted_grips(&mut app, &ctx);
enter(&mut app, &b);
app.session.set_selection(vec![shown]);
let one = painted_grips(&mut app, &ctx);
assert!(grips_on_canvas(&app, shown) && grips_on_canvas(&app, hidden), "the viewport must show both lines whole");
assert!(one >= 2, "the counter must find the grips of one selected line, got {one}");
app.session.set_selection(vec![shown, hidden]);
assert_eq!(painted_grips(&mut app, &ctx), 2 * one, "this viewport draws both lines");
// The layer is frozen in the active viewport while its line is selected.
app.run("vplayer", json!({ "handles": [b], "freeze": ["Hidden"] })).unwrap();
assert_eq!(app.session.state().unwrap().selection, [shown, hidden], "both lines must still be selected");
assert_eq!(painted_grips(&mut app, &ctx), one, "a line the viewport no longer draws kept its grips");
app.run("vplayer", json!({ "handles": [b], "thaw": ["Hidden"] })).unwrap();
assert_eq!(app.session.state().unwrap().selection, [shown, hidden]);
assert_eq!(painted_grips(&mut app, &ctx), 2 * one, "thawed in the viewport: the grips are back");
// The same selection through the viewport that freezes the layer, and back.
enter(&mut app, &a);
app.session.set_selection(vec![shown, hidden]);
assert_eq!(painted_grips(&mut app, &ctx), one, "a line the viewport does not draw painted grips");
assert!(grips_on_canvas(&app, hidden), "its grips would be on the canvas");
enter(&mut app, &b);
app.session.set_selection(vec![shown, hidden]);
assert_eq!(painted_grips(&mut app, &ctx), 2 * one);
}
/// The cursor highlights the object a click would select: over the two lines, the one the
/// viewport draws.
#[test]
fn the_cursor_highlights_the_object_the_viewport_draws() {
let Sheet { mut app, shown, hidden, a, b } = sheet();
let ctx = egui::Context::default();
painted_grips(&mut app, &ctx);
for (viewport, under_cursor) in [(&a, shown), (&b, hidden)] {
enter(&mut app, viewport);
painted_grips(&mut app, &ctx);
let on_lines = app.canvas.xf.unwrap().to_screen(Vec2::new(50.0, 0.0));
assert!(app.canvas.rect.unwrap().contains(on_lines), "the viewport must show the lines");
frame(&mut app, &ctx, Some(on_lines));
frame(&mut app, &ctx, Some(on_lines));
assert_eq!(app.canvas.hover, Some(under_cursor));
}
}