Merge remote-tracking branch 'origin/main' into codex/sheet-set-manager

# Conflicts:
#	Cargo.lock
#	src/app/control/actions.rs
#	src/ui/window/field_dialog.rs
This commit is contained in:
ramox81 committed 2026-10-05 09:20:40 +03:00
commit 1696316e1f
95 files changed
+4269 -722

No files matched your search

+67
View File
@@ -189,4 +189,71 @@ impl CadCommand for FieldObjectPickCommand {
}
}
/// The Field dialog's Average / Sum / Count / Cell buttons: a cell range (or
/// one cell) is picked in a table and handed back as
/// `_FIELD_CELL <function> <table> <x> <y> <z> [<table> <x> <y> <z>]`.
pub struct FieldTablePickCommand {
pub function: &'static str,
first: Option<(Handle, DVec3)>,
}
impl FieldTablePickCommand {
pub fn new(function: &'static str) -> Self {
Self { function, first: None }
}
}
impl CadCommand for FieldTablePickCommand {
fn name(&self) -> &'static str {
"FIELD"
}
fn prompt(&self) -> String {
match (self.function, self.first) {
("Cell", _) => "Select table cell:".into(),
(_, None) => "Select first corner of table cell range:".into(),
_ => "Select second corner of table cell range:".into(),
}
}
fn needs_entity_pick(&self) -> bool {
true
}
fn entity_pick_highlights_hover(&self) -> bool {
true
}
fn on_entity_pick(&mut self, handle: Handle, pt: DVec3) -> CmdResult {
if handle.is_null() {
return CmdResult::NeedPoint;
}
let at = |h: Handle, p: DVec3| format!("{:X} {} {} {}", h.value(), p.x, p.y, p.z);
match (self.function, self.first) {
("Cell", _) => CmdResult::Dispatch(format!("_FIELD_CELL Cell {}", at(handle, pt))),
(_, None) => {
self.first = Some((handle, pt));
CmdResult::NeedPoint
}
(function, Some((first, first_pt))) => CmdResult::Dispatch(format!(
"_FIELD_CELL {function} {} {}",
at(first, first_pt),
at(handle, pt)
)),
}
}
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Dispatch("_FIELD_CELL".into())
}
fn on_escape(&mut self) -> CmdResult {
CmdResult::Dispatch("_FIELD_CELL".into())
}
}
inventory::submit!(crate::command::CommandRegistration { names: &["FIELD"] });
+73
View File
@@ -1970,4 +1970,77 @@ mod tests {
assert!((line.start.x - 2.0).abs() < 1e-12);
assert!((line.end.x - 2.0).abs() < 1e-12);
}
// RED (TDD): a real DIMLINEAR must survive save -> reload with its baked
// *D geometry intact and a clean handle graph (no duplicates, no dangling
// owners). Headless codec round-trip, no GUI needed.
#[test]
fn dimlinear_survives_save_reload_with_baked_geometry() {
let mut failures = Vec::new();
for ext in ["dxf", "dwg"] {
let mut doc = CadDocument::new();
let mut d =
DimensionLinear::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0));
d.definition_point = Vector3::new(0.0, 5.0, 0.0);
d.base.text_middle_point = Vector3::new(5.0, 5.0, 0.0);
doc.add_entity(EntityType::Dimension(Dimension::Linear(d)))
.unwrap();
let path = std::env::temp_dir().join(format!(
"ocs_dim_rt_{}.{ext}",
std::process::id()
));
crate::io::save_as_version(&doc, &path, codec::DxfVersion::AC1032)
.expect("save");
let loaded = crate::io::load_file(&path).expect("load");
let _ = std::fs::remove_file(&path);
let mut seen = std::collections::HashSet::new();
for e in loaded.entities() {
if !seen.insert(e.common().handle) {
failures.push(format!(
"{ext}: duplicate handle {:?}",
e.common().handle
));
}
}
let dims: Vec<_> = loaded
.entities()
.filter_map(|e| match e {
EntityType::Dimension(d) => Some(d.clone()),
_ => None,
})
.collect();
if dims.len() != 1 {
failures.push(format!("{ext}: dimension entity lost"));
continue;
}
let block_name = dims[0].base().block_name.clone();
if block_name.trim().is_empty() {
failures.push(format!("{ext}: dimension came back blockless"));
continue;
}
let Some(rec) = loaded.block_records.get(&block_name) else {
failures.push(format!("{ext}: *D block {block_name} missing after reload"));
continue;
};
let owned: Vec<Handle> = {
let mut h = rec.entity_handles.clone();
h.push(rec.block_entity_handle);
h.push(rec.block_end_handle);
h
};
if rec.entity_handles.is_empty() {
failures.push(format!("{ext}: baked block has no sub-entities"));
}
for h in &owned {
if h.is_null() || loaded.get_entity(*h).is_none() {
failures.push(format!(
"{ext}: dangling handle {h:?} in baked block {block_name}"
));
}
}
}
assert!(failures.is_empty(), "round-trip failures:\n{}", failures.join("\n"));
}
}
+319 -108
View File
@@ -9,6 +9,10 @@
// 2. Pick object to offset (Line, Arc, Circle, LwPolyline)
// 3. Pick a point on the side to offset toward, or choose Multiple
// to keep offsetting the newly created result at the same distance
//
// With objects already selected, the command opens at step 3 instead, the
// preview following the cursor. At step 3 a typed distance places the
// offset at once on the cursor's side; a negative one, on the other side.
use crate::entities::curve::{entity_curve, lwpolyline_world_xy};
use crate::modules::draw::modify::spline_ops::spline_sample_xy;
@@ -331,13 +335,18 @@ fn compute_offsets(entity: &EntityType, dist: f64, side_pt: Vec3) -> Vec<EntityT
// distance for every supported entity type.
fn perp_distance(entity: &EntityType, pt: Vec3) -> f64 {
closest_on_wire(entity, pt).map_or(0.0, |(_, d)| d)
}
/// Nearest point of the entity outline to `pt`, and its distance.
fn closest_on_wire(entity: &EntityType, pt: Vec3) -> Option<([f64; 2], f64)> {
let pts = entity_wire_pts(entity);
if pts.len() < 2 {
return 0.0;
return None;
}
let px = pt.x as f64;
let py = pt.y as f64;
let mut best = f64::INFINITY;
let mut best = ([px, py], f64::INFINITY);
for w in pts.windows(2) {
let ax = w[0][0] as f64;
let ay = w[0][1] as f64;
@@ -354,11 +363,35 @@ fn perp_distance(entity: &EntityType, pt: Vec3) -> f64 {
let cx = ax + t * dx;
let cy = ay + t * dy;
let d = ((px - cx).powi(2) + (py - cy).powi(2)).sqrt();
if d < best {
best = d;
if d < best.1 {
best = ([cx, cy], d);
}
}
best
Some(best)
}
// ── Opposite side ─────────────────────────────────────────────────────────
//
// A negative typed distance offsets away from the cursor. Rather than teach
// every offset function a sign, the cursor is mirrored through its nearest
// point on the outline, which puts it on the other side for every type: the
// other half-plane of a line, inside instead of outside a circle or closed
// loop. The mirror stops at half the offset distance so that, in a narrow
// part of a shape, it cannot cross the far wall and come back to the
// cursor's own side.
fn far_side(entity: &EntityType, pt: Vec3, dist: f64) -> Option<Vec3> {
let ([qx, qy], d) = closest_on_wire(entity, pt)?;
if d < 1e-9 {
// On the outline itself: there is no side to be opposite to.
return None;
}
let step = d.min(dist * 0.5) / d;
Some(Vec3::new(
(qx + (qx - pt.x as f64) * step) as f32,
(qy + (qy - pt.y as f64) * step) as f32,
pt.z,
))
}
// ── Wire preview points ─────────────────────────────────────────────────────
@@ -440,9 +473,13 @@ pub struct OffsetCommand {
/// Unlike the command's opening snapshot, this also includes objects
/// created by earlier offsets while the command remains active.
picked: Option<EntityType>,
/// Pre-selected offsettable objects (pick-first, #422); consumed when the
/// distance step resolves.
preselected: Vec<EntityType>,
/// Last cursor position the preview saw. A distance typed at the side
/// step places the offset relative to it.
cursor: Option<DVec3>,
/// The command opened on pre-selected objects (#422) and has made nothing
/// yet: Enter takes the last distance, and Erase / Layer, which the
/// skipped distance step would have offered, are offered here.
drag_start: bool,
/// A Yes/No or Current/Source answer is being collected for this option.
awaiting: Option<Await>,
/// Erase option: replace the source object with its offset.
@@ -485,7 +522,8 @@ impl OffsetCommand {
all_entities,
entity_index,
picked: None,
preselected: Vec::new(),
cursor: None,
drag_start: false,
awaiting: None,
erase_source: false,
layer_current: false,
@@ -494,38 +532,28 @@ impl OffsetCommand {
}
}
/// Pick-first flow (#422): the distance step still comes first, then the
/// pre-selected objects go straight to the side step.
/// Pick-first flow (#422): the objects are already chosen, so the command
/// opens at the side step with the preview following the cursor. Clicking
/// offsets through the cursor; typing a distance places it at that
/// distance; Enter repeats the last distance (#418).
pub fn with_selection(all_entities: Vec<EntityType>, targets: Vec<EntityType>) -> Self {
let entity_index = ModifyEntityIndex::build(&all_entities);
Self {
step: Step::Distance,
all_entities,
entity_index,
picked: None,
preselected: targets,
awaiting: None,
erase_source: false,
layer_current: false,
targets_are_sources: false,
made: 0,
let mut cmd = Self::new(all_entities);
if !targets.is_empty() {
cmd.step = Step::PickSide {
targets,
locked: None,
multiple: false,
};
cmd.targets_are_sources = true;
cmd.drag_start = true;
}
cmd
}
/// Leave the distance step with the given mode (Some = locked distance,
/// None = through): pre-selected objects jump to the side step, otherwise
/// the object-pick loop starts.
/// None = through) for the object-pick loop.
fn advance_from_distance(&mut self, locked: Option<f64>) -> CmdResult {
if self.preselected.is_empty() {
self.step = Step::SelectObject { locked };
} else {
self.step = Step::PickSide {
targets: std::mem::take(&mut self.preselected),
locked,
multiple: false,
};
self.targets_are_sources = true;
}
self.step = Step::SelectObject { locked };
CmdResult::NeedPoint
}
@@ -554,6 +582,73 @@ impl OffsetCommand {
t!("OFFSET distance = %{d}", d = d).into_owned()
)
}
/// Offset the side step's targets with `pt` choosing the side: toward it,
/// or away from it when `flip` is set. Each target offsets by its own
/// through-distance, or by the locked magnitude.
fn place(&mut self, pt: DVec3, flip: bool) -> CmdResult {
let (locked, targets, multiple) = match &self.step {
Step::PickSide {
locked,
targets,
multiple,
} => (*locked, targets.clone(), *multiple),
_ => return CmdResult::NeedPoint,
};
let per_target: Vec<(Handle, Vec<EntityType>)> = targets
.iter()
.filter_map(|entity| {
let mag = locked.unwrap_or_else(|| perp_distance(entity, pt.as_vec3()));
if mag < 1e-9 {
return None;
}
let side = if flip {
far_side(entity, pt.as_vec3(), mag)?
} else {
pt.as_vec3()
};
let offsets = compute_offsets(entity, mag, side);
(!offsets.is_empty()).then(|| (entity.common().handle, offsets))
})
.collect();
let mut news: Vec<EntityType> = per_target
.iter()
.flat_map(|(_, offsets)| offsets.iter().cloned())
.collect();
if news.is_empty() {
return CmdResult::NeedPoint;
}
self.made += 1;
self.drag_start = false;
// Erase option: the offset replaces its source object. Only the
// objects picked from the drawing qualify — a Multiple chain's
// targets are the offsets just made, which are not replaced again.
let erase = self.erase_source && self.targets_are_sources;
self.targets_are_sources = false;
if multiple {
// Multiple mode chains from the result just created, matching
// OFFSET's repeated fixed-distance behavior (parallel/concentric
// series instead of duplicate copies at the first offset).
self.step = Step::PickSide {
targets: news.clone(),
locked,
multiple: true,
};
} else {
// Classic loop (#418): commit this offset and go back to the object
// pick at the same distance, until Enter / Esc finishes.
self.step = Step::SelectObject { locked };
}
if erase {
return CmdResult::ReplaceManyContinue(per_target);
}
if news.len() == 1 {
CmdResult::CommitEntity(news.pop().unwrap())
} else {
CmdResult::CommitEntities(news)
}
}
}
impl CadCommand for OffsetCommand {
@@ -602,6 +697,23 @@ impl CadCommand for OffsetCommand {
} else {
std::borrow::Cow::Borrowed("")
};
if self.drag_start {
let d = format!("{:.4}", defaults::get_offset_dist());
return if *multiple {
t!(
"OFFSET%{n} Multiple Click through point or type distance, negative for the other side [Erase/Layer] <%{d}>:",
n = n,
d = d
)
} else {
t!(
"OFFSET%{n} Click through point or type distance, negative for the other side [Multiple/Erase/Layer] <%{d}>:",
n = n,
d = d
)
}
.into_owned();
}
match (locked, multiple) {
(Some(d), false) => {
let d = format!("{:.4}", d);
@@ -658,6 +770,16 @@ impl CadCommand for OffsetCommand {
opts.push(CmdOption::enter("Exit"));
opts
}
Step::PickSide { multiple, .. } if self.drag_start => {
let mut opts = Vec::new();
if !multiple {
opts.push(CmdOption::new("Multiple", "M"));
}
opts.push(CmdOption::new("Erase", "E"));
opts.push(CmdOption::new("Layer", "L"));
opts.push(CmdOption::enter(&format!("{:.4}", defaults::get_offset_dist())));
opts
}
Step::PickSide { multiple, .. } => {
let mut opts = Vec::new();
if !multiple {
@@ -777,19 +899,23 @@ impl CadCommand for OffsetCommand {
{
return self.undo_last();
}
// Erase / Layer belong to the distance step, or to the side step when
// a pre-selection skipped the distance step.
if matches!(self.step, Step::Distance) || self.drag_start {
if upper == "E" || upper == "ERASE" {
self.awaiting = Some(Await::Erase);
return Some(CmdResult::NeedPoint);
}
if upper == "L" || upper == "LAYER" {
self.awaiting = Some(Await::Layer);
return Some(CmdResult::NeedPoint);
}
}
match &mut self.step {
Step::Distance => {
if t.eq_ignore_ascii_case("t") || t.eq_ignore_ascii_case("through") {
return Some(self.advance_from_distance(None));
}
if upper == "E" || upper == "ERASE" {
self.awaiting = Some(Await::Erase);
return Some(CmdResult::NeedPoint);
}
if upper == "L" || upper == "LAYER" {
self.awaiting = Some(Await::Layer);
return Some(CmdResult::NeedPoint);
}
if let Some(d) = crate::entities::common::parse_typed_length(&t) {
return Some(self.accept_distance(d));
}
@@ -804,15 +930,20 @@ impl CadCommand for OffsetCommand {
*multiple = true;
return Some(CmdResult::NeedPoint);
}
if !t.is_empty() {
if let Some(d) = crate::entities::common::parse_typed_length(&t) {
let d = d.abs().max(1e-9);
defaults::set_offset_dist(d);
*locked = Some(d);
}
let Some(d) = crate::entities::common::parse_typed_length(&t) else {
return Some(CmdResult::NeedPoint);
};
let mag = d.abs().max(1e-9);
defaults::set_offset_dist(mag);
*locked = Some(mag);
// The cursor has already chosen the side, so the value places
// the offset now: on the cursor's side, or the other side when
// negative. Before the cursor has been seen, the click still
// chooses.
match self.cursor {
Some(cursor) => Some(self.place(cursor, d < 0.0)),
None => Some(CmdResult::NeedPoint),
}
// Stay on the side step — the click chooses which side.
Some(CmdResult::NeedPoint)
}
_ => None,
}
@@ -863,66 +994,11 @@ impl CadCommand for OffsetCommand {
_ => {}
}
let (locked, targets, multiple) = match &self.step {
Step::PickSide {
locked,
targets,
multiple,
} => (*locked, targets.clone(), *multiple),
_ => return CmdResult::NeedPoint,
};
// Each target offsets by its own through-distance (or the locked
// magnitude), toward the clicked side.
let per_target: Vec<(Handle, Vec<EntityType>)> = targets
.iter()
.filter_map(|entity| {
let mag = locked.unwrap_or_else(|| perp_distance(entity, pt.as_vec3()));
if mag < 1e-9 {
return None;
}
let offsets = compute_offsets(entity, mag, pt.as_vec3());
(!offsets.is_empty()).then(|| (entity.common().handle, offsets))
})
.collect();
let mut news: Vec<EntityType> = per_target
.iter()
.flat_map(|(_, offsets)| offsets.iter().cloned())
.collect();
if news.is_empty() {
return CmdResult::NeedPoint;
}
self.made += 1;
// Erase option: the offset replaces its source object. Only the
// objects picked from the drawing qualify — a Multiple chain's
// targets are the offsets just made, which are not replaced again.
let erase = self.erase_source && self.targets_are_sources;
self.targets_are_sources = false;
if multiple {
// Multiple mode chains from the result just created, matching
// OFFSET's repeated fixed-distance behavior (parallel/concentric
// series instead of duplicate copies at the first offset).
self.step = Step::PickSide {
targets: news.clone(),
locked,
multiple: true,
};
} else {
// Classic loop (#418): commit this offset and go back to the object
// pick at the same distance, until Enter / Esc finishes.
self.step = Step::SelectObject { locked };
}
if erase {
return CmdResult::ReplaceManyContinue(per_target);
}
if news.len() == 1 {
CmdResult::CommitEntity(news.pop().unwrap())
} else {
CmdResult::CommitEntities(news)
}
self.place(pt, false)
}
fn on_preview_wires(&mut self, pt: DVec3) -> Vec<WireModel> {
self.cursor = Some(pt);
if let Step::ReferenceSecond { first } = &self.step {
return vec![WireModel::solid(
"offset_reference_distance".into(),
@@ -979,6 +1055,17 @@ impl CadCommand for OffsetCommand {
let d = defaults::get_offset_dist();
self.accept_distance(d)
}
// Opened on a pre-selection, the same Enter offsets by the last
// distance on the cursor's side.
Step::PickSide { .. } if self.drag_start => {
let Some(cursor) = self.cursor else {
return CmdResult::NeedPoint;
};
if let Step::PickSide { locked, .. } = &mut self.step {
*locked = Some(defaults::get_offset_dist());
}
self.place(cursor, false)
}
_ => CmdResult::Cancel,
}
}
@@ -1100,3 +1187,127 @@ mod option_tests {
assert!(matches!(cmd.on_point(DVec3::new(5.0, 5.0, 0.0)), CmdResult::CommitEntity(_)));
}
}
#[cfg(test)]
mod drag_tests {
use super::*;
use codec::types::Vector2;
fn line_y0() -> EntityType {
let mut line = codec::entities::Line::from_coords(0.0, 0.0, 0.0, 10.0, 0.0, 0.0);
line.common.handle = Handle::new(1);
EntityType::Line(line)
}
/// OFFSET opened on `target`, with the cursor hovering at `cursor`.
fn dragging(target: EntityType, cursor: [f64; 2]) -> OffsetCommand {
let mut cmd = OffsetCommand::with_selection(vec![target.clone()], vec![target]);
cmd.on_preview_wires(DVec3::new(cursor[0], cursor[1], 0.0));
cmd
}
fn committed_line_y(result: Option<CmdResult>) -> f64 {
match result {
Some(CmdResult::CommitEntity(EntityType::Line(l))) => l.start.y,
_ => panic!("expected one committed line"),
}
}
#[test]
fn preselection_opens_on_the_live_preview() {
let mut cmd = OffsetCommand::with_selection(vec![line_y0()], vec![line_y0()]);
assert!(matches!(cmd.step, Step::PickSide { locked: None, .. }));
assert_eq!(cmd.options().into_iter().map(|o| o.keyword).collect::<Vec<_>>(), ["M", "E", "L", ""]);
// The preview passes through the cursor before anything is typed.
assert_eq!(cmd.on_preview_wires(DVec3::new(5.0, 3.0, 0.0)).len(), 1);
assert_eq!(cmd.dyn_live_value(DVec3::new(5.0, 3.0, 0.0)), Some(3.0));
// A click still offsets through the cursor.
assert!(matches!(cmd.on_point(DVec3::new(5.0, 3.0, 0.0)), CmdResult::CommitEntity(_)));
}
#[test]
fn typed_distance_places_on_the_cursor_side() {
let mut cmd = dragging(line_y0(), [5.0, 3.0]);
assert!((committed_line_y(cmd.on_text_input("2")) - 2.0).abs() < 1e-9);
// The loop continues at the typed distance.
assert!(matches!(cmd.step, Step::SelectObject { locked: Some(d) } if d == 2.0));
}
#[test]
fn negative_distance_places_on_the_other_side() {
let mut cmd = dragging(line_y0(), [5.0, 3.0]);
assert!((committed_line_y(cmd.on_text_input("-2")) + 2.0).abs() < 1e-9);
assert_eq!(defaults::get_offset_dist(), 2.0);
}
#[test]
fn negative_distance_inside_a_circle_grows_it() {
let circle = EntityType::Circle(CircleEnt {
center: codec::types::Vector3::new(0.0, 0.0, 0.0),
radius: 10.0,
..Default::default()
});
let mut cmd = dragging(circle.clone(), [3.0, 0.0]);
match cmd.on_text_input("-1") {
Some(CmdResult::CommitEntity(EntityType::Circle(c))) => assert!((c.radius - 11.0).abs() < 1e-9),
_ => panic!("expected one committed circle"),
}
let mut cmd = dragging(circle, [3.0, 0.0]);
match cmd.on_text_input("1") {
Some(CmdResult::CommitEntity(EntityType::Circle(c))) => assert!((c.radius - 9.0).abs() < 1e-9),
_ => panic!("expected one committed circle"),
}
}
// A U whose arms sit 1 apart. From inside the left arm the nearest wall
// faces the gap; mirroring the cursor its full 1.9 across would land in
// the right arm, back inside the loop. -0.25 must still go outward.
#[test]
fn negative_distance_does_not_mirror_across_a_gap() {
let u = EntityType::LwPolyline(LwPolyline {
vertices: [
[0.0, 0.0], [9.0, 0.0], [9.0, 10.0], [5.0, 10.0],
[5.0, 2.0], [4.0, 2.0], [4.0, 10.0], [0.0, 10.0],
]
.iter()
.map(|&[x, y]| LwVertex::new(Vector2::new(x, y)))
.collect(),
is_closed: true,
..Default::default()
});
let mut cmd = dragging(u, [2.1, 6.0]);
match cmd.on_text_input("-0.25") {
Some(CmdResult::CommitEntity(EntityType::LwPolyline(p))) => {
let min_x = p.vertices.iter().map(|v| v.location.x).fold(f64::INFINITY, f64::min);
assert!((min_x + 0.25).abs() < 1e-6, "offset went inward: min x {min_x}");
}
_ => panic!("expected one committed polyline"),
}
}
#[test]
fn enter_repeats_the_last_distance_on_the_cursor_side() {
defaults::set_offset_dist(4.0);
let mut cmd = dragging(line_y0(), [5.0, -1.0]);
match cmd.on_enter() {
CmdResult::CommitEntity(EntityType::Line(l)) => assert!((l.start.y + 4.0).abs() < 1e-9),
_ => panic!("expected one committed line"),
}
// After that, Enter finishes as before.
assert!(matches!(cmd.on_enter(), CmdResult::Cancel));
}
#[test]
fn erase_is_offered_without_the_distance_step() {
let mut cmd = dragging(line_y0(), [5.0, 3.0]);
cmd.on_text_input("E");
cmd.on_text_input("Y");
assert!(matches!(cmd.on_text_input("2"), Some(CmdResult::ReplaceManyContinue(_))));
}
#[test]
fn without_preselection_the_distance_comes_first() {
let cmd = OffsetCommand::new(vec![line_y0()]);
assert!(matches!(cmd.step, Step::Distance));
}
}
+8 -2
View File
@@ -1894,7 +1894,7 @@ impl PrimitiveCommand {
Some((
placed,
solid_history::box_op(
self.history_transform_axes(origin, x_axis, y_axis),
self.history_transform_axes(box_centre(origin, x_axis, y_axis, length, width, height), x_axis, y_axis),
length,
width,
height,
@@ -2024,7 +2024,7 @@ impl PrimitiveCommand {
Some((
placed,
solid_history::wedge_op(
self.history_transform_axes(origin, x_axis, y_axis),
self.history_transform_axes(box_centre(origin, x_axis, y_axis, length, width, height), x_axis, y_axis),
length,
width,
height,
@@ -3714,3 +3714,9 @@ fn wire(name: &str, points: Vec<[f32; 3]>) -> WireModel {
..Default::default()
}
}
/// Box and wedge histories are framed at the centre of their bounding box,
/// as the reference stores them (lengths stay positive).
fn box_centre(origin: DVec3, x_axis: DVec3, y_axis: DVec3, length: f64, width: f64, height: f64) -> DVec3 {
origin + x_axis * (length * 0.5) + y_axis * (width * 0.5) + DVec3::Z * (height * 0.5)
}