feat(blocks): wire Stretch actions and independent reference evaluation

Author a native Stretch action from a Linear, Polar or XY parameter, a two-corner stretch frame, an object selection and a marker. Preview and retain its frame in BEDIT, with selectable and movable action markers.

Freeze line and planar lightweight-polyline vertex bindings, preserve curved-segment height, move wholly selected objects, and evaluate independent saved INSERTs from their definition. Retain native DWG/DXF data, copied-reference isolation and undo/redo.

Validate with synthetic frame, topology, parameter, history, Test Block, drawing-reference, COPY and roundtrip regressions: 340 block tests pass (6 existing opt-in ignored), plus 41 dock and 27 viewport tests. Other entity types, Polar Stretch, custom multipliers and chaining remain unsupported.
This commit is contained in:
Karim Jerbi committed 2026-10-17 10:03:59 +01:00
1 parent 94940ad77c
commit 042f26a4cc
14 files changed
+827 -27

No files matched your search

+11 -3
View File
@@ -38,7 +38,7 @@ impl OpenCADStudio {
};
if !args.is_empty() && kind.is_none() {
self.command_line.push_error(
crate::t!("Supported actions: Move, Scale, Rotate, Flip. Other action types are not wired yet.")
crate::t!("Supported actions: Move, Scale, Stretch, Rotate, Flip. Other action types are not wired yet.")
.as_ref(),
);
return Task::none();
@@ -76,6 +76,7 @@ impl OpenCADStudio {
parameter,
targets,
location,
frame,
} => {
if self.tabs[i].id != document
|| self.tabs[i]
@@ -90,8 +91,15 @@ impl OpenCADStudio {
return Task::none();
}
let mut staged = self.tabs[i].scene.document.clone();
match actions::create(&mut staged, definition, kind, parameter, &targets, location)
{
match actions::create_with_frame(
&mut staged,
definition,
kind,
parameter,
&targets,
location,
frame,
) {
Ok(_) => {
self.push_undo_snapshot(i, &format!("{} action", kind.name()));
self.tabs[i].scene.document = staged;
+133
View File
@@ -59,6 +59,138 @@ fn feed(app: &mut OpenCADStudio, text: &str) {
let _ = app.update(Message::CommandSubmit);
}
#[test]
fn block_actions_stretch_frame_selection_history_test_and_saved_grip() {
use crate::scene::block_actions::{self as actions, Kind};
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let def = app.tabs[i].active_block_edit_session().unwrap().br_handle;
let _ = app.run_command_line("BPARAMETER LINEAR 0,0 10,0 5,2");
let p = actions::parameters(&app.tabs[i].scene.document, def, Kind::Stretch)[0].clone();
let before = app.tabs[i].scene.document.clone();
// A half-authored frame and Escape create neither objects nor history.
let depth = app.tabs[i].history.undo_stack.len();
let _ = app.dispatch_command("BACTION Stretch");
feed(&mut app, &p.name);
feed(&mut app, "0.5,-1");
let _ = app.update(Message::CommandEscape);
assert_eq!(app.tabs[i].scene.document, before);
assert_eq!(app.tabs[i].history.undo_stack.len(), depth);
let _ = app.dispatch_command("BACTION Stretch");
feed(&mut app, &p.name);
assert!(!app.tabs[i]
.active_cmd
.as_ref()
.unwrap()
.is_selection_gathering());
feed(&mut app, "0.5,-1");
feed(&mut app, "0.5,1"); // zero-width error retains first corner
assert!(!app.tabs[i]
.active_cmd
.as_ref()
.unwrap()
.is_selection_gathering());
feed(&mut app, "2,1");
assert!(app.tabs[i]
.active_cmd
.as_ref()
.unwrap()
.is_selection_gathering());
let targets = members(&app);
let _ = app.feed_command(crate::command::StepInput::SelectionComplete(
targets.clone(),
));
let _ = app.feed_command(crate::command::StepInput::Enter);
feed(&mut app, "3,4");
assert!(app.tabs[i].active_cmd.is_none());
assert_eq!(app.tabs[i].history.undo_stack.len(), depth + 1);
let action = actions::actions(&app.tabs[i].scene.document, def)[0].0;
assert_eq!(
actions::frame_outline(&app.tabs[i].scene.document, action).len(),
4
);
app.undo_steps(1);
assert!(actions::actions(&app.tabs[i].scene.document, def).is_empty());
app.redo_steps(1);
let editor = app.tabs[i].scene.document.clone();
let _ = app.dispatch_command("BTESTBLOCK");
let ti = app.active_tab;
let test_insert = app.tabs[ti].block_test.as_ref().unwrap().insert;
app.tabs[ti].scene.selected.insert(test_insert);
app.refresh_properties();
let grip = app.tabs[ti]
.selected_grips
.iter()
.find(|g| super::block_actions::is_test_grip(g.id))
.unwrap()
.id;
assert!(app.start_test_action_grip(test_insert, grip));
feed(&mut app, "12,0");
let EntityType::Line(line) = app.tabs[ti].scene.document.get_entity(targets[0]).unwrap() else {
panic!()
};
assert_eq!(line.start, Vector3::ZERO);
assert_eq!(line.end, Vector3::new(3., 0., 0.));
let _ = app.dispatch_command("BCLOSETESTBLOCK");
assert_eq!(app.tabs[i].scene.document, editor);
let _ = app.dispatch_command("BSAVE");
let _ = app.dispatch_command("BCLOSE");
app.tabs[i].scene.selected.insert(reference);
app.refresh_properties();
let grip = app.tabs[i]
.selected_grips
.iter()
.find(|g| super::block_actions::is_test_grip(g.id))
.unwrap()
.id;
let depth = app.tabs[i].history.undo_stack.len();
assert!(app.start_test_action_grip(reference, grip));
feed(&mut app, "15,0");
assert_eq!(app.tabs[i].history.undo_stack.len(), depth + 1);
let changed = app.tabs[i].scene.document.clone();
let EntityType::Insert(insert) = changed.get_entity(reference).unwrap() else {
panic!()
};
let member = changed
.block_records
.get(&insert.block_name)
.unwrap()
.entity_handles[0];
let EntityType::Line(line) = changed.get_entity(member).unwrap() else {
panic!()
};
assert_eq!(line.start, Vector3::ZERO);
assert_eq!(line.end, Vector3::new(6., 0., 0.));
assert_eq!(
changed.get_entity(targets[0]),
editor.get_entity(targets[0])
);
app.undo_steps(1);
assert_eq!(
app.tabs[i].scene.document.get_entity(reference),
editor.get_entity(reference)
);
app.redo_steps(1);
assert_eq!(
app.tabs[i].scene.document.get_entity(member),
changed.get_entity(member)
);
let bytes = codec::DwgWriter::write_to_vec(&changed).unwrap();
app.tabs[i].scene.document = codec::DwgReader::from_stream(std::io::Cursor::new(bytes))
.read()
.unwrap();
begin(&mut app, reference);
assert_eq!(
app.tabs[i].active_block_edit_session().unwrap().br_handle,
def
);
assert_eq!(
actions::frame_outline(&app.tabs[i].scene.document, action).len(),
4
);
}
#[test]
fn block_parameters_rotation_only_save_close_reopen_then_flip() {
use crate::scene::block_actions::{self as actions, Kind};
@@ -903,6 +1035,7 @@ fn block_actions_rejected_outside_editor_preserve_running_command_and_stale_resu
parameter: p,
targets: members(&app),
location: glam::DVec3::ZERO,
frame: None,
});
assert_eq!(app.tabs[i].scene.document, before);
let _ = app.dispatch_command("LINE");
+21
View File
@@ -1244,6 +1244,27 @@ fn block_actions_palette_controls_require_compatible_parameters_and_start_comman
});
}
#[test]
fn block_actions_stretch_palette_requires_distance_parameter() {
with_stack(|| {
let mut app = authoring_app();
let _ = app.dispatch_command("BAUTHORPALETTE");
app.authoring_palette_tab = crate::ui::window::authoring_palettes::Tab::Actions;
let point = authoring_control(&app, "authoring-tool-stretch-action");
authoring_click(&mut app, point);
assert!(app.tabs[app.active_tab].active_cmd.is_none());
let _ = app.run_command_line("BPARAMETER LINEAR 0,0 10,0 5,2");
let point = authoring_control(&app, "authoring-tool-stretch-action");
authoring_click(&mut app, point);
assert!(app.tabs[app.active_tab]
.active_cmd
.as_ref()
.unwrap()
.prompt()
.contains("Stretch"));
});
}
#[test]
fn block_actions_rotate_flip_palette_requires_matching_parameters() {
with_stack(|| {
+1
View File
@@ -415,6 +415,7 @@ impl OpenCADStudio {
self.action_error(Some(crate::scene::block_actions::Kind::Scale)),
self.action_error(Some(crate::scene::block_actions::Kind::Rotate)),
self.action_error(Some(crate::scene::block_actions::Kind::Flip)),
self.action_error(Some(crate::scene::block_actions::Kind::Stretch)),
)
}
crate::ui::dock::PanelId::BlockPalette => {
+12 -1
View File
@@ -1203,8 +1203,19 @@ bg={bg_ms:.1}ms n={view_count}"
session.br_handle,
)
.into_iter()
.map(|(_, action, _, _)| {
.map(|(handle, action, _, _)| {
let mut g = crate::scene::block_distance_parameter::Graphic::default();
let frame = crate::scene::block_actions::frame_outline(
&tab.scene.document,
handle,
);
g.lines.extend(
frame
.iter()
.zip(frame.iter().cycle().skip(1))
.take(frame.len())
.map(|(a, b)| [*a, *b]),
);
g.grips.push((action.display_location, false));
g.labels
.push((action.display_location, action.element.name.clone()));
+1
View File
@@ -1528,6 +1528,7 @@ pub enum CmdResult {
parameter: Handle,
targets: Vec<Handle>,
location: DVec3,
frame: Option<[DVec3; 2]>,
},
EvaluateBlockParameter {
insert: Option<Handle>,
+65 -8
View File
@@ -16,6 +16,7 @@ pub(crate) struct ActionCommand {
parameter: Option<Handle>,
targets: Vec<Handle>,
selecting: bool,
frame: Vec<DVec3>,
context: Option<PointPickContext>,
}
impl ActionCommand {
@@ -33,6 +34,7 @@ impl ActionCommand {
parameter: None,
targets: vec![],
selecting: false,
frame: vec![],
context: None,
}
}
@@ -43,7 +45,7 @@ impl ActionCommand {
);
}
self.parameter = Some(p.handle);
self.selecting = true;
self.selecting = self.kind != Some(Kind::Stretch);
CmdResult::DeselectAndContinue
}
}
@@ -62,7 +64,8 @@ impl CadCommand for ActionCommand {
}
fn prompt(&self) -> String {
if self.kind.is_none() {
return crate::t!("BACTION Enter action type [Move/Scale/Rotate/Flip]:").into_owned();
return crate::t!("BACTION Enter action type [Move/Scale/Stretch/Rotate/Flip]:")
.into_owned();
}
if self.parameter.is_none() {
return crate::tf!(
@@ -71,11 +74,17 @@ impl CadCommand for ActionCommand {
)
.into_owned();
}
crate::t!(if self.selecting {
"BACTION Select objects for the action, then press Enter:"
} else {
"BACTION Specify action marker location:"
})
crate::t!(
if self.kind == Some(Kind::Stretch) && self.frame.is_empty() {
"BACTION Specify first corner of stretch frame:"
} else if self.kind == Some(Kind::Stretch) && self.frame.len() == 1 {
"BACTION Specify opposite corner of stretch frame:"
} else if self.selecting {
"BACTION Select objects for the action, then press Enter:"
} else {
"BACTION Specify action marker location:"
}
)
.into_owned()
}
fn options(&self) -> Vec<CmdOption> {
@@ -83,6 +92,7 @@ impl CadCommand for ActionCommand {
vec![
CmdOption::new("Move", "Move"),
CmdOption::new("Scale", "Scale"),
CmdOption::new("Stretch", "Stretch"),
CmdOption::new("Rotate", "Rotate"),
CmdOption::new("Flip", "Flip"),
]
@@ -97,7 +107,7 @@ impl CadCommand for ActionCommand {
CmdResult::NeedPoint
} else {
CmdResult::ReportError(
crate::t!("Choose Move, Scale, Rotate or Flip.").into_owned(),
crate::t!("Choose Move, Scale, Stretch, Rotate or Flip.").into_owned(),
)
});
}
@@ -149,6 +159,35 @@ impl CadCommand for ActionCommand {
)
});
}
if self.kind == Some(Kind::Stretch) && self.frame.len() < 2 {
let plane = self
.candidates
.iter()
.find(|p| Some(p.handle) == self.parameter)
.unwrap()
.end
.z;
if !point.is_finite() || (point.z - plane).abs() > 1e-8 {
return CmdResult::ReportError(
crate::t!("Specify a finite corner in the parameter's XY plane.").into_owned(),
);
}
if self
.frame
.first()
.is_some_and(|a| (point.x - a.x).abs() < 1e-9 || (point.y - a.y).abs() < 1e-9)
{
return CmdResult::ReportError(
crate::t!("The stretch frame needs a nonzero width and height.").into_owned(),
);
}
self.frame.push(point);
if self.frame.len() == 2 {
self.selecting = true;
return CmdResult::DeselectAndContinue;
}
return CmdResult::NeedPoint;
}
if self.selecting {
return CmdResult::NeedPoint;
}
@@ -164,8 +203,26 @@ impl CadCommand for ActionCommand {
parameter: self.parameter.unwrap(),
targets: self.targets.clone(),
location: point,
frame: (self.frame.len() == 2).then(|| [self.frame[0], self.frame[1]]),
}
}
fn on_preview_wires(&mut self, point: DVec3) -> Vec<crate::scene::WireModel> {
let Some(a) = self.frame.first().filter(|_| point.is_finite()) else {
return vec![];
};
let b = self.frame.get(1).copied().unwrap_or(point);
vec![crate::scene::WireModel::solid_f64(
"preview_stretch_frame".into(),
vec![
a.to_array(),
[b.x, a.y, a.z],
[b.x, b.y, a.z],
[a.x, b.y, a.z],
],
crate::scene::WireModel::CYAN,
true,
)]
}
fn is_selection_gathering(&self) -> bool {
self.selecting
}
+7 -1
View File
@@ -277,7 +277,13 @@ pub fn stretch_entity(
/// The bulge of the segment from vertex `k` once its ends moved from
/// `before` to `after`: the arc keeps its height, bulge · chord / 2.
fn held_bulge(bulge: f64, before: &[[f64; 2]], after: &[[f64; 2]], k: usize, closed: bool) -> f64 {
pub(crate) fn held_bulge(
bulge: f64,
before: &[[f64; 2]],
after: &[[f64; 2]],
k: usize,
closed: bool,
) -> f64 {
let next = if k + 1 < before.len() {
k + 1
} else if closed {
+95 -7
View File
@@ -1,14 +1,15 @@
//! Native Move/Scale/Rotate/Flip authoring and deterministic block evaluation.
//! Native Move/Scale/Stretch/Rotate/Flip authoring and deterministic block evaluation.
use super::block_authoring::{self as authoring, *};
use codec::objects::{
BlockAction, BlockActionOffsets, BlockActionWithBasePoint, BlockBasePointAction,
BlockConnection, BlockElement, BlockEvalValue, BlockMoveAction, BlockFlipAction,
DynamicBlockData, ObjectType,
BlockStretchAction, DynamicBlockData, ObjectType,
};
use codec::types::{Transform, Vector3};
use codec::{CadDocument, EntityType, Handle};
use glam::DVec3;
pub(crate) mod instance;
mod stretch;
#[cfg(test)]
mod tests;
@@ -18,6 +19,7 @@ pub enum Kind {
Scale,
Rotate,
Flip,
Stretch,
}
impl Kind {
pub(crate) fn parse(s: &str) -> Option<Self> {
@@ -26,6 +28,7 @@ impl Kind {
"SCALE" | "S" => Some(Self::Scale),
"ROTATE" | "R" => Some(Self::Rotate),
"FLIP" | "F" => Some(Self::Flip),
"STRETCH" | "ST" => Some(Self::Stretch),
_ => None,
}
}
@@ -35,6 +38,7 @@ impl Kind {
Self::Scale => "Scale",
Self::Rotate => "Rotate",
Self::Flip => "Flip",
Self::Stretch => "Stretch",
}
}
}
@@ -78,6 +82,7 @@ pub(crate) fn all_parameters(doc: &CadDocument, definition: Handle) -> Vec<Param
pub(crate) fn compatible(p: &Parameter, kind: Kind) -> bool {
match kind {
Kind::Move => !p.rotation && !p.flip,
Kind::Stretch => !p.rotation && !p.flip && !p.point,
Kind::Scale => !p.rotation && !p.flip && !p.point && !p.xy,
Kind::Rotate => p.rotation,
Kind::Flip => p.flip,
@@ -342,6 +347,7 @@ pub(crate) fn supported(e: &EntityType) -> bool {
| EntityType::MText(_)
)
}
#[cfg(test)]
pub(crate) fn create(
doc: &mut CadDocument,
definition: Handle,
@@ -349,6 +355,18 @@ pub(crate) fn create(
parameter: Handle,
targets: &[Handle],
location: DVec3,
) -> Result<Handle, &'static str> {
create_with_frame(doc, definition, kind, parameter, targets, location, None)
}
pub(crate) fn create_with_frame(
doc: &mut CadDocument,
definition: Handle,
kind: Kind,
parameter: Handle,
targets: &[Handle],
location: DVec3,
frame: Option<[DVec3; 2]>,
) -> Result<Handle, &'static str> {
if !location.is_finite() {
return Err("Specify a finite action location.");
@@ -374,6 +392,19 @@ pub(crate) fn create(
}) {
return Err("This milestone supports lines, polylines, arcs, circles, ellipses, points and text belonging to this block.");
}
let stretch_binding = if kind == Kind::Stretch {
if (location.z - p.end.z).abs() > 1e-8 {
return Err("Place the Stretch marker in the parameter's XY plane.");
}
Some(stretch::bind(
doc,
targets,
frame.ok_or("Specify a stretch frame before selecting objects.")?,
p.end.z,
)?)
} else {
None
};
let mut staged = doc.clone();
let (graph, slots) = authoring::prepare(&mut staged, definition)?;
let (dxf, cpp) = match kind {
@@ -381,6 +412,7 @@ pub(crate) fn create(
Kind::Scale => ("BLOCKSCALEACTION", "AcDbBlockScaleAction"),
Kind::Rotate => ("BLOCKROTATEACTION", "AcDbBlockRotationAction"),
Kind::Flip => ("BLOCKFLIPACTION", "AcDbBlockFlipAction"),
Kind::Stretch => ("BLOCKSTRETCHACTION", "AcDbBlockStretchAction"),
};
register_classes(&mut staged, &[(dxf, cpp, 55)]);
let handle = staged.allocate_handle();
@@ -404,6 +436,21 @@ pub(crate) fn create(
name: name.into(),
};
let data = match kind {
Kind::Stretch => {
let (points, handles) = stretch_binding.unwrap();
DynamicBlockData::StretchAction(BlockStretchAction {
action,
connections: [connection("EndXDelta"), connection("EndYDelta")],
points,
handles,
codes: vec![],
offsets: BlockActionOffsets {
distance_multiplier: 1.,
angle_offset: 0.,
flags: 0,
},
})
}
Kind::Move => DynamicBlockData::MoveAction(BlockMoveAction {
action,
connections: if p.point {
@@ -489,6 +536,9 @@ pub(crate) fn actions(
DynamicBlockData::MoveAction(a) => {
Some((o.handle, &a.action, Kind::Move, a.connections[0].code))
}
DynamicBlockData::StretchAction(a) => {
Some((o.handle, &a.action, Kind::Stretch, a.connections[0].code))
}
DynamicBlockData::FlipAction(a) => {
Some((o.handle, &a.action, Kind::Flip, a.connections[0].code))
}
@@ -505,6 +555,16 @@ pub(crate) fn actions(
})
.collect()
}
pub(crate) fn frame_outline(doc: &CadDocument, handle: Handle) -> Vec<Vector3> {
let Some(ObjectType::DynamicBlock(o)) = doc.objects.get(&handle) else {
return vec![];
};
let DynamicBlockData::StretchAction(a) = &o.data else {
return vec![];
};
stretch::outline(&a.points, a.action.display_location.z)
}
pub(crate) fn reframe(doc: &mut CadDocument, definition: Handle, t: &Transform) -> usize {
let handles = actions(doc, definition)
.iter()
@@ -518,6 +578,17 @@ pub(crate) fn reframe(doc: &mut CadDocument, definition: Handle, t: &Transform)
DynamicBlockData::MoveAction(a) => {
a.action.display_location = t.apply(a.action.display_location)
}
DynamicBlockData::StretchAction(a) => {
let plane = a.action.display_location.z;
a.action.display_location = t.apply(a.action.display_location);
a.points = stretch::outline(&a.points, plane)
.into_iter()
.map(|p| {
let p = t.apply(p);
codec::types::Vector2::new(p.x, p.y)
})
.collect();
}
DynamicBlockData::FlipAction(a) => {
a.action.display_location = t.apply(a.action.display_location)
}
@@ -613,8 +684,10 @@ pub(crate) fn evaluate_value(
) -> Result<bool, &'static str> {
let graph = authoring::graph(baseline, definition)?
.ok_or("The block authoring graph is unavailable.")?;
if baseline.objects.values().any(|o|matches!(o,ObjectType::DynamicBlock(o) if o.owner==graph && matches!(&o.data,
DynamicBlockData::StretchAction(_)|DynamicBlockData::PolarStretchAction(_)|DynamicBlockData::ArrayAction(_)))) {
if baseline.objects.values().any(|o| {
matches!(o,ObjectType::DynamicBlock(o) if o.owner==graph && matches!(&o.data,
DynamicBlockData::PolarStretchAction(_)|DynamicBlockData::ArrayAction(_)))
}) {
return Err("This block contains action types not supported yet.");
}
let p = all_parameters(baseline, definition)
@@ -660,7 +733,16 @@ pub(crate) fn evaluate_value(
set_value(&mut staged, &p, input)?;
let all = actions(baseline, definition);
let mut affected = std::collections::BTreeSet::new();
for (_, a, _, _) in &all {
for (h, a, kind, _) in &all {
if *kind == Kind::Stretch {
let ObjectType::DynamicBlock(o) = &baseline.objects[h] else {
unreachable!()
};
let DynamicBlockData::StretchAction(s) = &o.data else {
unreachable!()
};
stretch::validate(baseline, s)?;
}
affected.extend(a.dependencies.iter().copied());
}
for h in affected {
@@ -688,7 +770,9 @@ pub(crate) fn evaluate_value(
}
let end = current(&staged, &q);
let transform = match kind {
Kind::Move => crate::command::EntityTransform::Translate(end - q.end),
Kind::Move | Kind::Stretch => {
crate::command::EntityTransform::Translate(end - q.end)
}
Kind::Rotate => {
let a = end - q.base;
let b = q.end - q.base;
@@ -805,7 +889,11 @@ pub(crate) fn evaluate_value(
}
_ => {}
}
super::view::dispatch::apply_transform(&mut entity, &transform);
if let DynamicBlockData::StretchAction(s) = &o.data {
stretch::apply(&mut entity, s, end - q.end)?;
} else {
super::view::dispatch::apply_transform(&mut entity, &transform);
}
let bounds = entity.as_entity().bounding_box();
if !v(bounds.min).is_finite() || !v(bounds.max).is_finite() {
return Err("The parameter value produces non-finite geometry.");
+195
View File
@@ -0,0 +1,195 @@
//! A native Stretch action freezes its vertex membership at authoring time.
use super::*;
use codec::objects::{BlockStretchAction, BlockStretchHandle};
use codec::types::Vector2;
pub(crate) fn control_points(e: &EntityType) -> Result<Vec<DVec3>, &'static str> {
let points = match e {
EntityType::Line(l) => vec![v(l.start), v(l.end)],
EntityType::LwPolyline(p) => {
let p = crate::entities::curve::lwpolyline_world_xy(p)
.ok_or("Stretch requires a planar XY lightweight polyline.")?;
p.vertices
.iter()
.map(|v| DVec3::new(v.location.x, v.location.y, p.elevation))
.collect()
}
_ => return Err("Stretch currently supports lines and planar lightweight polylines."),
};
if points.is_empty() || points.iter().any(|p| !p.is_finite()) {
return Err("Stretch targets need finite vertices.");
}
Ok(points)
}
pub(crate) fn bind(
doc: &CadDocument,
targets: &[Handle],
corners: [DVec3; 2],
plane: f64,
) -> Result<(Vec<Vector2>, Vec<BlockStretchHandle>), &'static str> {
if corners
.iter()
.any(|p| !p.is_finite() || (p.z - plane).abs() > 1e-8)
{
return Err("Specify finite stretch-frame corners in the parameter's XY plane.");
}
let low = corners[0].min(corners[1]);
let high = corners[0].max(corners[1]);
if high.x - low.x < 1e-9 || high.y - low.y < 1e-9 {
return Err("The stretch frame needs a nonzero width and height.");
}
let mut bindings = vec![];
let mut unique = targets.to_vec();
unique.sort();
unique.dedup();
for handle in unique {
let entity = doc
.get_entity(handle)
.ok_or("A Stretch target is unavailable.")?;
let points = control_points(entity)?;
if points.iter().any(|p| (p.z - plane).abs() > 1e-8) {
return Err("Stretch targets must lie in the parameter's XY plane.");
}
let indexes = points
.iter()
.enumerate()
.filter(|(_, p)| p.x >= low.x && p.x <= high.x && p.y >= low.y && p.y <= high.y)
.map(|(i, _)| i as i32)
.collect::<Vec<_>>();
if !indexes.is_empty() {
bindings.push(BlockStretchHandle { handle, indexes });
}
}
Ok((
vec![Vector2::new(low.x, low.y), Vector2::new(high.x, high.y)],
bindings,
))
}
pub(crate) fn validate(doc: &CadDocument, a: &BlockStretchAction) -> Result<(), &'static str> {
if a.points.len() < 2
|| a.points
.iter()
.any(|p| !p.x.is_finite() || !p.y.is_finite())
|| a.offsets.distance_multiplier != 1.
|| a.offsets.angle_offset != 0.
|| a.offsets.flags != 0
|| !a.codes.is_empty()
|| !a.action.action_ids.is_empty()
|| a.connections[0].code != a.connections[1].code
|| a.connections[0].name != "EndXDelta"
|| a.connections[1].name != "EndYDelta"
{
return Err("This Stretch action uses options or parameter bindings not supported yet.");
}
let mut seen = std::collections::BTreeSet::new();
for binding in &a.handles {
if !a.action.dependencies.contains(&binding.handle) || !seen.insert(binding.handle) {
return Err("The Stretch action has invalid target bindings.");
}
let count = control_points(
doc.get_entity(binding.handle)
.ok_or("A Stretch target is unavailable.")?,
)?
.len();
let unique = binding
.indexes
.iter()
.copied()
.collect::<std::collections::BTreeSet<_>>();
if unique.len() != binding.indexes.len()
|| binding
.indexes
.iter()
.any(|i| *i < 0 || *i as usize >= count)
{
return Err("A Stretch vertex binding no longer matches its object.");
}
}
// Selected objects without a partial vertex binding move as a whole.
for h in &a.action.dependencies {
control_points(
doc.get_entity(*h)
.ok_or("A Stretch target is unavailable.")?,
)?;
}
Ok(())
}
pub(crate) fn apply(
e: &mut EntityType,
a: &BlockStretchAction,
delta: DVec3,
) -> Result<(), &'static str> {
let Some(binding) = a
.handles
.iter()
.find(|b| b.handle == e.common().handle && !b.indexes.is_empty())
else {
crate::scene::view::dispatch::apply_transform(
e,
&crate::command::EntityTransform::Translate(delta),
);
return Ok(());
};
match e {
EntityType::Line(l) => {
for index in &binding.indexes {
let at = match index {
0 => &mut l.start,
1 => &mut l.end,
_ => return Err("Invalid Stretch line endpoint."),
};
*at = *at + native(delta);
}
}
EntityType::LwPolyline(p) => {
let mut world = crate::entities::curve::lwpolyline_world_xy(p)
.ok_or("Stretch requires a planar XY lightweight polyline.")?;
let before = world
.vertices
.iter()
.map(|v| [v.location.x, v.location.y])
.collect::<Vec<_>>();
for index in &binding.indexes {
let v = world
.vertices
.get_mut(*index as usize)
.ok_or("Invalid Stretch polyline vertex.")?;
v.location.x += delta.x;
v.location.y += delta.y;
}
let after = world
.vertices
.iter()
.map(|v| [v.location.x, v.location.y])
.collect::<Vec<_>>();
for (i, v) in world.vertices.iter_mut().enumerate() {
v.bulge = crate::modules::draw::modify::stretch::held_bulge(
v.bulge,
&before,
&after,
i,
world.is_closed,
);
}
*p = world;
}
_ => return Err("Unsupported Stretch target."),
}
Ok(())
}
pub(crate) fn outline(points: &[Vector2], z: f64) -> Vec<Vector3> {
let points = if points.len() == 2 {
let (a, b) = (points[0], points[1]);
vec![a, Vector2::new(b.x, a.y), b, Vector2::new(a.x, b.y)]
} else {
points.to_vec()
};
points
.into_iter()
.map(|p| Vector3::new(p.x, p.y, z))
.collect()
}
+261
View File
@@ -605,3 +605,264 @@ fn block_actions_reframe_moves_markers_and_retains_targets_and_connections() {
assert_eq!(s.action.action.dependencies, b.action.action.dependencies);
assert_eq!(s.connections, b.connections);
}
fn stretch_fixture() -> (CadDocument, Handle, Vec<Handle>, Handle, Handle) {
let mut scene = Scene::new();
let mut poly = codec::entities::LwPolyline::from_points(vec![
codec::types::Vector2::new(0., 1.),
codec::types::Vector2::new(10., 1.),
codec::types::Vector2::new(10., 3.),
codec::types::Vector2::new(0., 3.),
]);
poly.is_closed = true;
poly.vertices[0].bulge = 0.2;
scene.define_block_raw(
"STRETCH",
Vector3::ZERO,
vec![
EntityType::Line(codec::entities::Line::from_points(
Vector3::ZERO,
Vector3::new(10., 0., 0.),
)),
EntityType::LwPolyline(poly),
EntityType::Line(codec::entities::Line::from_points(
Vector3::new(20., 0., 0.),
Vector3::new(21., 0., 0.),
)),
],
);
let br = scene.document.block_records.get("STRETCH").unwrap();
let (def, handles) = (br.handle, br.entity_handles.clone());
let mut doc = scene.document;
let p = linear(&mut doc, def);
let action = create_with_frame(
&mut doc,
def,
Kind::Stretch,
p,
&handles,
DVec3::new(5., 4., 0.),
Some([DVec3::new(9., -1., 0.), DVec3::new(11., 4., 0.)]),
)
.unwrap();
(doc, def, handles, p, action)
}
#[test]
fn block_actions_stretch_frozen_vertices_polyline_and_whole_objects_roundtrip() {
let (doc, def, handles, p, action) = stretch_fixture();
let ObjectType::DynamicBlock(o) = &doc.objects[&action] else {
panic!()
};
let DynamicBlockData::StretchAction(a) = &o.data else {
panic!()
};
assert_eq!(a.handles[0].indexes, vec![1]);
assert_eq!(a.handles[1].indexes, vec![1, 2]);
assert_eq!(a.action.dependencies, handles);
let mut evaluated = doc.clone();
for x in [20., 30., 12.] {
evaluate(&mut evaluated, &doc, def, p, DVec3::new(x, 0., 0.)).unwrap();
let EntityType::Line(line) = evaluated.get_entity(handles[0]).unwrap() else {
panic!()
};
assert_eq!(line.start, Vector3::ZERO);
assert_eq!(line.end, Vector3::new(x, 0., 0.));
// Entirely outside the frame but explicitly in the action selection.
assert_eq!(start(&evaluated, handles[2]), Vector3::new(x + 10., 0., 0.));
let EntityType::LwPolyline(poly) = evaluated.get_entity(handles[1]).unwrap() else {
panic!()
};
assert_eq!(poly.vertices[0].location.x, 0.);
assert_eq!(poly.vertices[1].location.x, x);
assert_eq!(poly.vertices[2].location.x, x);
assert_eq!(poly.vertices[3].location.x, 0.);
// Preserve the curved segment's sagitta when its chord changes.
assert!((poly.vertices[0].bulge * x / 2. - 1.).abs() < 1e-8);
}
for reopened in [
DwgReader::from_stream(Cursor::new(DwgWriter::write_to_vec(&doc).unwrap()))
.read()
.unwrap(),
DxfReader::from_reader(Cursor::new(DxfWriter::new(&doc).write_to_vec().unwrap()))
.unwrap()
.read()
.unwrap(),
] {
assert_eq!(reopened.objects[&action], doc.objects[&action]);
let mut changed = reopened.clone();
evaluate(&mut changed, &reopened, def, p, DVec3::new(14., 0., 0.)).unwrap();
let EntityType::Line(line) = changed.get_entity(handles[0]).unwrap() else {
panic!()
};
assert_eq!(line.end, Vector3::new(14., 0., 0.));
}
}
#[test]
fn block_actions_stretch_saved_reference_independence_copy_and_reopen() {
let (mut doc, def, handles, p, _) = stretch_fixture();
let a = doc
.add_entity(EntityType::Insert(codec::entities::Insert::new(
"STRETCH",
Vector3::ZERO,
)))
.unwrap();
let b = doc
.add_entity(EntityType::Insert(codec::entities::Insert::new(
"STRETCH",
Vector3::new(40., 0., 0.),
)))
.unwrap();
let original = doc.clone();
doc = instance::evaluate(&doc, a, p, DVec3::new(18., 0., 0.))
.unwrap()
.unwrap();
assert_eq!(doc.get_entity(handles[0]), original.get_entity(handles[0]));
assert_eq!(doc.get_entity(b), original.get_entity(b));
let mut scene = Scene::new();
scene.document = doc;
let c = scene.copy_entities(
&[a],
&crate::command::EntityTransform::Translate(DVec3::new(60., 0., 0.)),
)[0];
let mut doc = scene.document;
let EntityType::Insert(ins) = doc.get_entity(a).unwrap() else {
panic!()
};
let a_member = doc
.block_records
.get(&ins.block_name)
.unwrap()
.entity_handles[0];
doc = instance::evaluate(&doc, c, p, DVec3::new(22., 0., 0.))
.unwrap()
.unwrap();
let EntityType::Line(line) = doc.get_entity(a_member).unwrap() else {
panic!()
};
assert_eq!(line.end, Vector3::new(18., 0., 0.));
let reopened = DwgReader::from_stream(Cursor::new(DwgWriter::write_to_vec(&doc).unwrap()))
.read()
.unwrap();
let parameter = parameters(&reopened, def, Kind::Stretch)[0].clone();
assert_eq!(
instance::current(&reopened, c, &parameter).unwrap(),
DVec3::new(22., 0., 0.)
);
let changed = instance::evaluate(&reopened, c, p, DVec3::new(13., 0., 0.))
.unwrap()
.unwrap();
let EntityType::Insert(ins) = changed.get_entity(c).unwrap() else {
panic!()
};
let EntityType::Line(line) = changed
.get_entity(
changed
.block_records
.get(&ins.block_name)
.unwrap()
.entity_handles[0],
)
.unwrap()
else {
panic!()
};
assert_eq!(line.end, Vector3::new(13., 0., 0.));
}
#[test]
fn block_actions_stretch_invalid_frame_binding_and_options_are_atomic() {
let (mut doc, def, handles, p, action) = stretch_fixture();
let before = doc.clone();
for frame in [
None,
Some([DVec3::ZERO, DVec3::X]),
Some([DVec3::ZERO, DVec3::splat(f64::NAN)]),
] {
assert!(create_with_frame(
&mut doc,
def,
Kind::Stretch,
p,
&handles,
DVec3::ZERO,
frame
)
.is_err());
assert_eq!(doc, before);
}
for corrupt in [0, 1, 2] {
let mut invalid = doc.clone();
let ObjectType::DynamicBlock(o) = invalid.objects.get_mut(&action).unwrap() else {
panic!()
};
let DynamicBlockData::StretchAction(a) = &mut o.data else {
panic!()
};
match corrupt {
0 => a.handles[0].indexes = vec![99],
1 => a.offsets.distance_multiplier = 2.,
_ => a.connections[0].name = "BaseXDelta".into(),
}
let baseline = invalid.clone();
assert!(evaluate(&mut invalid, &baseline, def, p, DVec3::new(12., 0., 0.)).is_err());
assert_eq!(invalid, baseline);
}
}
#[test]
fn block_actions_stretch_polar_xy_and_reframed_marker_keep_native_bindings() {
for kind in [distance::Kind::Polar, distance::Kind::XY] {
let (mut doc, def, targets) = fixture();
let p = distance::create(
&mut doc,
def,
kind,
Vector3::ZERO,
Vector3::new(10., 1., 0.),
Vector3::new(5., 2., 0.),
)
.unwrap();
let action = create_with_frame(
&mut doc,
def,
Kind::Stretch,
p,
&targets[..1],
DVec3::new(3., 4., 0.),
Some([DVec3::new(1.5, -1., 0.), DVec3::new(2.5, 1., 0.)]),
)
.unwrap();
let baseline = doc.clone();
evaluate(&mut doc, &baseline, def, p, DVec3::new(12., 4., 0.)).unwrap();
let EntityType::Line(line) = doc.get_entity(targets[0]).unwrap() else {
panic!()
};
assert_eq!(line.start, Vector3::UNIT_X);
assert!(line.end.distance(&Vector3::new(4., 3., 0.)) < 1e-8);
super::super::block_authoring::edit::move_part(
&mut doc,
def,
action,
super::super::block_authoring::edit::Part::Point,
Vector3::new(6., 7., 0.),
)
.unwrap();
let frame = frame_outline(&doc, action);
assert_eq!(frame[0], Vector3::new(1.5, -1., 0.));
reframe(
&mut doc,
def,
&Transform::from_translation(Vector3::new(-2., -3., 0.)),
);
assert_eq!(frame_outline(&doc, action)[0], Vector3::new(-0.5, -4., 0.));
let ObjectType::DynamicBlock(o) = &doc.objects[&action] else {
panic!()
};
let DynamicBlockData::StretchAction(a) = &o.data else {
panic!()
};
assert_eq!(a.handles[0].indexes, vec![1]);
}
}
+13 -1
View File
@@ -104,7 +104,18 @@ pub fn items(doc: &CadDocument, definition: Handle) -> Vec<Item> {
result.push(single(p.handle, p.name.clone(), p.def_point));
}
for (handle, p, _, _) in crate::scene::block_actions::actions(doc, definition) {
result.push(single(handle, p.element.name.clone(), p.display_location));
let mut item = single(handle, p.element.name.clone(), p.display_location);
let frame = crate::scene::block_actions::frame_outline(doc, handle);
if !frame.is_empty() {
item.graphic.lines.extend(
frame
.iter()
.zip(frame.iter().cycle().skip(1))
.take(frame.len())
.map(|(a, b)| [*a, *b]),
);
}
result.push(item);
}
result
}
@@ -277,6 +288,7 @@ pub fn move_part(
}
}
DynamicBlockData::MoveAction(p) => p.action.display_location = at,
DynamicBlockData::StretchAction(p) => p.action.display_location = at,
DynamicBlockData::FlipAction(p) => p.action.display_location = at,
DynamicBlockData::ScaleAction(p) | DynamicBlockData::RotateAction(p) => {
p.action.action.display_location = at
+4 -1
View File
@@ -1725,7 +1725,10 @@ impl Scene {
.chain(
super::block_actions::actions(&self.document, block)
.into_iter()
.map(|(_, a, _, _)| a.display_location),
.flat_map(|(handle, a, _, _)| {
std::iter::once(a.display_location)
.chain(super::block_actions::frame_outline(&self.document, handle))
}),
);
for point in locations {
let point = glam::DVec3::new(point.x, point.y, point.z);
+8 -5
View File
@@ -198,6 +198,7 @@ fn catalog(
scale_error: Option<&'static str>,
rotate_error: Option<&'static str>,
flip_action_error: Option<&'static str>,
stretch_action_error: Option<&'static str>,
) -> Element<'static, Message> {
let (entries, unavailable) = match tab {
Tab::Parameters => (
@@ -219,8 +220,8 @@ fn catalog(
let orientation_error = if *label=="Flip" {flip_error} else {point_error};
let single=tab==Tab::Parameters&&matches!(*label,"Lookup"|"Basepoint");
let lookup_action=tab==Tab::Actions&&*label=="Lookup";
let geometry_action=tab==Tab::Actions&&matches!(*label,"Move"|"Scale"|"Rotate"|"Flip");
let action_error=match *label {"Scale"=>scale_error,"Rotate"=>rotate_error,"Flip"=>flip_action_error,_=>move_error};
let geometry_action=tab==Tab::Actions&&matches!(*label,"Move"|"Scale"|"Stretch"|"Rotate"|"Flip");
let action_error=match *label {"Scale"=>scale_error,"Stretch"=>stretch_action_error,"Rotate"=>rotate_error,"Flip"=>flip_action_error,_=>move_error};
let enabled = (geometry_action&&action_error.is_none())||(single&&point_error.is_none())||(lookup_action&&lookup_error.is_none())||(orientation && orientation_error.is_none()) || (visibility && visibility_error.is_none()) || ((point || distance) && point_error.is_none());
let base = if enabled {
icons::semantic(icon, 28.0)
@@ -266,7 +267,7 @@ fn catalog(
} else if single {
crate::t!(point_error.unwrap_or(if *label=="Basepoint" {"Place or relocate the dynamic block insertion anchor."}else{"Place a Lookup parameter, then use the Lookup action to edit its property table."})).into_owned()
} else if geometry_action {
crate::t!(action_error.unwrap_or("Select a parameter endpoint, select the objects it controls, then place the action marker. Use the reference parameter grip in the drawing or Test Block.")).into_owned()
crate::t!(action_error.unwrap_or(if *label=="Stretch" {"Select a parameter endpoint, define the stretch frame, select its objects, then place the action marker."}else{"Select a parameter endpoint, select the objects it controls, then place the action marker. Use the reference parameter grip in the drawing or Test Block."})).into_owned()
} else if lookup_action {
crate::t!(lookup_error.unwrap_or("Create or edit property Lookup tables for this block.")).into_owned()
} else {
@@ -284,7 +285,7 @@ fn catalog(
} else if single {
container(entry).id(if *label=="Lookup" {"authoring-tool-lookup"}else{"authoring-tool-basepoint"}).into()
} else if geometry_action {
container(entry).id(match *label {"Move"=>"authoring-tool-move-action","Scale"=>"authoring-tool-scale-action","Rotate"=>"authoring-tool-rotate-action",_=>"authoring-tool-flip-action"}).into()
container(entry).id(match *label {"Move"=>"authoring-tool-move-action","Scale"=>"authoring-tool-scale-action","Rotate"=>"authoring-tool-rotate-action","Stretch"=>"authoring-tool-stretch-action",_=>"authoring-tool-flip-action"}).into()
} else if lookup_action {
container(entry).id("authoring-tool-lookup-table").into()
} else {
@@ -405,6 +406,7 @@ pub fn view(
scale_error: Option<&'static str>,
rotate_error: Option<&'static str>,
flip_action_error: Option<&'static str>,
stretch_action_error: Option<&'static str>,
) -> Element<'static, Message> {
let tabs = column(Tab::ALL.into_iter().map(|category| {
let label = category.label();
@@ -463,7 +465,8 @@ pub fn view(
move_error,
scale_error,
rotate_error,
flip_action_error
flip_action_error,
stretch_action_error
))
.width(Fill)
.height(Fill),