fix(blocks): one base point convention for creation, redefine, insert, explode, XCLIP and REFEDIT

world = insert_point + Rot * Scale * (P - base_point). Attribute placement, EXPLODE, XCLIP storage, REFEDIT's working copy, the UCS reframe of attributes and the dynamic-block visibility grip ignored the base point and were offset by Rot*Scale*base. They now share insert_transform_with_base. Creation stores a ZERO base and redefine resets it; a no-op redefine moves nothing; the Block Definition dialog prefills the base from the definition. XCLIP filters stored by older builds keep their old displacement.
This commit is contained in:
Karim Jerbi committed 2026-10-12 04:35:19 +01:00
1 parent a456367b26
commit 5dfa41c032
17 files changed
+1447 -19

No files matched your search

+9
View File
@@ -1049,6 +1049,15 @@ impl OpenCADStudio {
}
}
} else {
// Attributes are placed through the same base-point convention as
// the render path, so hand the command the definition's base point.
let block_base = crate::scene::render_graph::block_base_point(
&self.tabs[i].scene.document,
&block_name,
);
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
cmd.attreq_set_block_base(block_base);
}
let completed = self.tabs[i]
.active_cmd
.as_mut()
+275
View File
@@ -1479,3 +1479,278 @@ mod bedit_characterization_tests {
);
}
}
#[cfg(test)]
mod block_base_point_tests {
use super::super::*;
use crate::command::{CadCommand, CmdResult};
use crate::modules::insert::insert_block::InsertBlockCommand;
use codec::entities::AttributeDefinition;
use codec::types::Vector3;
use codec::EntityType;
/// base-point fix (A), host side: `AttreqNeeded` is where the host resolves the
/// block's ATTDEFs, so it also injects the definition's base point. Without
/// that the committed attribute lands on the codec transform's position.
#[test]
fn locked_attreq_injects_the_block_base_point_for_attribute_placement() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
// Block "ATTREQ_BP": base (10,5,0); LINE (10,5)-(30,5); ATTDEF TAG1 at
// (12,8). Rendering convention (see scene::block_base_point_tests): the
// ATTDEF lands at (100,100) + Rot(30)*Scale(2)*((12,8) - (10,5))
// = (100,100) + 2*(1.732051 - 1.5, 1 + 2.598076) = (100.464102, 107.196152).
app.tabs[i].scene.define_block_raw(
"ATTREQ_BP",
Vector3::new(10.0, 5.0, 0.0),
vec![
EntityType::Line(codec::entities::Line::from_points(
Vector3::new(10.0, 5.0, 0.0),
Vector3::new(30.0, 5.0, 0.0),
)),
EntityType::AttributeDefinition(AttributeDefinition {
insertion_point: Vector3::new(12.0, 8.0, 0.0),
height: 1.0,
..AttributeDefinition::new(
"TAG1".to_string(),
"Tag one".to_string(),
String::new(),
)
}),
],
);
let mut cmd = InsertBlockCommand::classic_for_block(
"ATTREQ_BP".to_string(),
Vec::new(),
String::new(),
);
let _ = cmd.on_point(glam::DVec3::new(100.0, 100.0, 0.0));
assert!(matches!(cmd.on_text_input("2"), Some(CmdResult::NeedPoint)));
assert!(matches!(cmd.on_text_input("2"), Some(CmdResult::NeedPoint)));
assert!(matches!(
cmd.on_text_input("30"),
Some(CmdResult::AttreqNeeded { .. })
));
app.tabs[i].active_cmd = Some(Box::new(cmd));
let _ = app.apply_cmd_result(CmdResult::AttreqNeeded {
block_name: "ATTREQ_BP".to_string(),
});
let result = app.tabs[i]
.active_cmd
.as_mut()
.expect("the INSERT command waits for the attribute value")
.on_text_input("VALUE1")
.expect("the value finishes the INSERT");
let _ = app.apply_cmd_result(result);
let attribute = app.tabs[i]
.scene
.document
.entities()
.find_map(|entity| match entity {
EntityType::Insert(ins) if ins.block_name == "ATTREQ_BP" => {
ins.attributes.first().cloned()
}
_ => None,
})
.expect("the committed INSERT's attribute");
assert_eq!(attribute.tag, "TAG1");
let point = attribute.insertion_point;
assert!(
(point.x - 100.464102).abs() < 1e-6
&& (point.y - 107.196152).abs() < 1e-6
&& point.z.abs() < 1e-9,
"attribute at ({:.6},{:.6},{:.6}), expected (100.464102,107.196152,0)",
point.x,
point.y,
point.z
);
}
/// base-point fix (D), host side: picking an existing block in the Block
/// Definition dialog prefills the base point from that definition.
#[test]
fn locked_block_def_name_select_prefills_the_definition_base_point() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
app.tabs[i].scene.define_block_raw(
"DEF_BASE",
Vector3::new(10.0, 5.0, 0.0),
vec![EntityType::Line(codec::entities::Line::from_points(
Vector3::new(10.0, 5.0, 0.0),
Vector3::new(30.0, 5.0, 0.0),
))],
);
app.block_definition = Some(
crate::ui::window::block_definition::BlockDefinitionState::new(
vec!["DEF_BASE".to_string()],
Vec::new(),
0,
),
);
assert_eq!(
app.block_definition
.as_ref()
.map(|s| s.base_point_y.as_str()),
Some("0.0000"),
"a new block still starts at 0.0000"
);
let _ = app.update(Message::BlockDefNameSelect("DEF_BASE".to_string()));
let state = app.block_definition.as_ref().expect("the dialog is open");
assert_eq!(state.name, "DEF_BASE");
assert_eq!(state.base_point_x, "10.0000");
assert_eq!(state.base_point_y, "5.0000");
assert_eq!(state.base_point_z, "0.0000");
assert_eq!(state.parse_base_point(), glam::DVec3::new(10.0, 5.0, 0.0));
}
/// A block with base (10,5,0) and LINE (10,5)-(30,5), referenced by an
/// INSERT at (100,100) with 30° rotation and uniform scale 2. Under the
/// convention the LINE renders at (100,100)-(134.641016,120).
fn block_and_instance_with_base(app: &mut OpenCADStudio, name: &str) -> codec::Handle {
let _ = app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
app.tabs[i].scene.define_block_raw(
name,
Vector3::new(10.0, 5.0, 0.0),
vec![EntityType::Line(codec::entities::Line::from_points(
Vector3::new(10.0, 5.0, 0.0),
Vector3::new(30.0, 5.0, 0.0),
))],
);
let mut insert =
codec::entities::Insert::new(name.to_string(), Vector3::new(100.0, 100.0, 0.0));
insert.set_x_scale(2.0);
insert.set_y_scale(2.0);
insert.set_z_scale(2.0);
insert.rotation = std::f64::consts::PI / 6.0;
app.tabs[i].scene.add_entity(EntityType::Insert(insert))
}
/// base-point follow-up fix: the REFEDIT working copy is placed through the
/// base-point-aware INSERT transform, so it sits exactly on the rendered
/// reference, and saving folds it back to the same block-local geometry.
#[test]
fn locked_refedit_working_copy_sits_on_the_rendered_reference() {
let mut app = OpenCADStudio::new_for_test();
let handle = block_and_instance_with_base(&mut app, "REFEDIT_BP");
let i = app.active_tab;
let _ = app.dispatch_command(&format!("REFEDIT_BEGIN:{}", handle.value()));
assert!(
app.tabs[i].refedit_session.is_some(),
"setup: REFEDIT_BEGIN opens a session"
);
// The working copy is the LINE the session moved into model space.
let temp: Vec<codec::Handle> = app.tabs[i]
.refedit_session
.as_ref()
.expect("the session")
.temp_handles
.clone();
let line = temp
.iter()
.find_map(|h| match app.tabs[i].scene.document.get_entity(*h) {
Some(EntityType::Line(line)) => Some(line.clone()),
_ => None,
})
.expect("the working-copy LINE");
// (100,100) + Rot(30)*2*((10,5)-(10,5)) and ...*((30,5)-(10,5)).
assert!(
(line.start.x - 100.0).abs() < 1e-6
&& (line.start.y - 100.0).abs() < 1e-9
&& (line.end.x - 134.641016).abs() < 1e-6
&& (line.end.y - 120.0).abs() < 1e-6,
"working copy at ({:.6},{:.6})-({:.6},{:.6}), expected (100,100)-(134.641016,120)",
line.start.x,
line.start.y,
line.end.x,
line.end.y
);
// Saving without editing restores the definition verbatim: the
// inverse is the exact inverse of the forward transform.
let _ = app.dispatch_command("REFCLOSE_SAVE");
assert!(app.tabs[i].refedit_session.is_none());
let stored = app.tabs[i]
.scene
.document
.block_records
.get("REFEDIT_BP")
.expect("the block record")
.entity_handles
.iter()
.find_map(|h| match app.tabs[i].scene.document.get_entity(*h) {
Some(EntityType::Line(line)) => Some(line.clone()),
_ => None,
})
.expect("the stored LINE");
assert!(
(stored.start.x - 10.0).abs() < 1e-9
&& (stored.start.y - 5.0).abs() < 1e-9
&& (stored.end.x - 30.0).abs() < 1e-9
&& (stored.end.y - 5.0).abs() < 1e-9,
"stored LINE at ({:.9},{:.9})-({:.9},{:.9}), expected (10,5)-(30,5)",
stored.start.x,
stored.start.y,
stored.end.x,
stored.end.y
);
assert_eq!(
crate::scene::render_graph::block_base_point(&app.tabs[i].scene.document, "REFEDIT_BP"),
Vector3::new(10.0, 5.0, 0.0),
"the base point survives the round trip"
);
}
/// base-point follow-up fix: the dynamic-block visibility grip is a
/// block-definition coordinate, so it follows the base-point convention
/// too: (100,100) + Rot(30)*2*((12,3) - (10,5)) = (105.464102, 98.535898).
#[test]
fn locked_visibility_grip_follows_the_base_point_convention() {
let mut app = OpenCADStudio::new_for_test();
let handle = block_and_instance_with_base(&mut app, "VIS_BP");
let i = app.active_tab;
let def_block = app.tabs[i]
.scene
.document
.block_records
.get("VIS_BP")
.expect("the block record")
.handle;
let mut param = codec::objects::BlockVisibilityParameter::default();
param.owner = def_block;
param.def_point = Vector3::new(12.0, 3.0, 0.0);
app.tabs[i]
.scene
.document
.block_visibility_params
.insert(codec::Handle::new(0x70), param);
app.tabs[i].selected_handle = Some(handle);
app.refresh_visibility_grip([0.0, 0.0, 0.0]);
let grip = app.tabs[i]
.selected_grips
.last()
.expect("the visibility lookup grip");
assert!(
(grip.world.x - 105.464102).abs() < 1e-6 && (grip.world.y - 98.535898).abs() < 1e-6,
"grip at ({:.6},{:.6}), expected (105.464102,98.535898)",
grip.world.x,
grip.world.y
);
assert!(
app.tabs[i].visibility_grip.is_some(),
"the selection is a dynamic-block reference"
);
}
}
+15 -5
View File
@@ -809,12 +809,19 @@ impl OpenCADStudio {
};
// Build the INSERT's full placement transform (OCS + rotation +
// scale, including non-uniform / mirrored) and its inverse, so
// edits round-trip back to block-local coordinates on SAVE.
// scale, including non-uniform / mirrored, minus the block base
// point) and its inverse, so edits round-trip back to
// block-local coordinates on SAVE. The base point is part of
// the placement: without it the working copy sits Rot*Scale*base
// away from the reference the user picked.
let sx = insert.x_scale();
let sy = insert.y_scale();
let sz = insert.z_scale();
let forward = insert.get_transform();
let base = crate::scene::render_graph::block_base_point(
&self.tabs[i].scene.document,
&insert.block_name,
);
let forward = crate::scene::render_graph::insert_transform_with_base(&insert, base);
let inverse = {
use codec::types::{Matrix3, Matrix4, Transform};
let ocs_t =
@@ -826,8 +833,11 @@ impl OpenCADStudio {
);
let r_inv = Matrix4::rotation_z(-insert.rotation);
let s_inv = Matrix4::scaling(1.0 / sx, 1.0 / sy, 1.0 / sz);
// inverse(OCS·T·R·S) = S⁻¹·R⁻¹·T⁻¹·OCSᵀ
Transform::from_matrix(s_inv * r_inv * t_inv * ocs_t)
// inverse(T(-base)·OCS·T·R·S) = T(+base)·S⁻¹·R⁻¹·T⁻¹·OCSᵀ
Transform::from_matrix(
Matrix4::translation(base.x, base.y, base.z)
* (s_inv * r_inv * t_inv * ocs_t),
)
};
// Find the block record.
+1 -1
View File
@@ -2269,8 +2269,8 @@ impl OpenCADStudio {
let host = self.tabs[i].scene.document.header.insertion_units;
let k = crate::app::properties::insert_unit_scale(host, units).unwrap_or(1.0);
let mut insert = codec::entities::Insert::new(name.to_string(), at).with_scale(k, k, k);
let transform = insert.get_transform();
let doc = &self.tabs[i].scene.document;
let transform = crate::scene::render_graph::insert_transform(doc, &insert);
let attdefs: Vec<codec::entities::AttributeDefinition> = doc
.block_records
.get(name)
+11 -3
View File
@@ -6,13 +6,14 @@ use crate::scene::pick::xclip as clip;
impl OpenCADStudio {
/// A block reference's extents in WCS: its block's objects through the
/// insert transform.
/// insert transform — the base-point-aware one, because that is where the
/// renderer puts the contents the user drew the boundary around.
fn insert_extents(&self, i: usize, insert: codec::Handle) -> ([f64; 2], [f64; 2]) {
let document = &self.tabs[i].scene.document;
let Some(codec::EntityType::Insert(ins)) = document.get_entity(insert) else {
return ([0.0; 2], [0.0; 2]);
};
let xform = ins.get_transform();
let xform = crate::scene::render_graph::insert_transform(document, ins);
let (mut lo, mut hi) = ([f64::MAX; 2], [f64::MIN; 2]);
for e in document.entities_in_block(&ins.block_name) {
let (a, b) = crate::scene::convert::tess::entity_bounds_in(document, e);
@@ -77,7 +78,14 @@ impl OpenCADStudio {
else {
continue;
};
let outline = clip::clip_outline_world(doc, filter, &ins.get_transform());
// The same base-point-aware transform the spatial filter
// was stored with, so the generated polyline lands on the
// boundary the user drew (the two cancel exactly).
let outline = clip::clip_outline_world(
doc,
filter,
&crate::scene::render_graph::insert_transform(doc, ins),
);
let mut pl = codec::entities::LwPolyline::from_points(
outline
.iter()
+8 -1
View File
@@ -5017,9 +5017,16 @@ impl OpenCADStudio {
}
Message::BlockDefNameSelect(chosen) => {
if let Some(state) = self.block_definition.as_mut() {
state.name = chosen;
state.name = chosen.clone();
state.error_message = None;
state.confirm_redefine = None;
// Redefining an existing block starts from the base point
// that definition already has (0.0000 for a new block).
let base = crate::scene::render_graph::block_base_point(
&self.tabs[self.active_tab].scene.document,
&chosen,
);
state.set_base_point(glam::DVec3::new(base.x, base.y, base.z));
}
Task::none()
}
+4 -1
View File
@@ -136,7 +136,10 @@ impl OpenCADStudio {
return;
};
let wp = ins.get_transform().apply(Vector3::new(
// `def_point` is a block-definition coordinate (see the codec's
// BlockVisibilityParameter), so the grip follows the same base-point
// convention as the rendered reference.
let wp = crate::scene::render_graph::insert_transform(doc, ins).apply(Vector3::new(
param.def_point.x,
param.def_point.y,
param.def_point.z,
+5
View File
@@ -3035,6 +3035,11 @@ pub trait CadCommand: Send {
None
}
/// Supply the referenced block's insertion base point so the attributes
/// the command now owns are placed through the same base-point convention
/// as the render path. Default: no-op (no base point).
fn attreq_set_block_base(&mut self, _base: codec::types::Vector3) {}
/// After the host commits an ATTREQ insert: keep the command running
/// (repeated placement) instead of ending it.
fn attreq_continue(&self) -> bool {
+20 -3
View File
@@ -23,8 +23,8 @@ use codec::entities::{
};
use codec::entities::{Polyline, Polyline2D};
use codec::tables::BlockRecord;
use codec::types::Vector3;
use codec::{CadDocument, EntityType, Handle};
use codec::types::{Transform, Vector3};
use codec::{CadDocument, Entity, EntityType, Handle};
use crate::command::{CadCommand, CmdResult, WorkingPlane};
use crate::entities::curve::lwpolyline_world_xy;
@@ -106,7 +106,24 @@ pub fn explode_entity(entity: &EntityType, document: &CadDocument) -> Vec<Entity
if !explodable {
vec![]
} else {
ins.explode_from_document(document)
// The codec applies only `Insert::get_transform`, which does
// not know the block's base point, so shift the definition's
// contents by -base first: get_transform(P - base) is exactly
// world = insert_point + Rot*Scale*(P - base), the placement
// the render path uses. MINSERT cells and nested references
// get the same treatment because the shift happens on the
// contents, not on the insert.
let base = crate::scene::render_graph::block_base_point(document, &ins.block_name);
let to_block = Transform::from_translation(Vector3::new(-base.x, -base.y, -base.z));
let entities: Vec<EntityType> = document
.entities_in_block(&ins.block_name)
.map(|entity| {
let mut shifted = entity.clone();
Entity::apply_transform(shifted.as_entity_mut(), &to_block);
shifted
})
.collect();
ins.explode(&entities)
.into_iter()
.map(normalize_insert_entity)
.collect()
+16 -1
View File
@@ -126,6 +126,10 @@ pub struct InsertBlockCommand {
rotation: f64,
pending_insert: Option<Insert>,
plane: WorkingPlane,
/// The referenced block's insertion base point. Attributes are placed
/// through the same base-point convention the render path uses, so the
/// host injects it when it resolves the block's ATTDEFs.
block_base: Vector3,
}
impl InsertBlockCommand {
@@ -152,6 +156,7 @@ impl InsertBlockCommand {
rotation: 0.0,
pending_insert: None,
plane: WorkingPlane::default(),
block_base: Vector3::ZERO,
}
}
@@ -852,9 +857,18 @@ impl CadCommand for InsertBlockCommand {
fn attreq_take_insert(&mut self) -> Option<codec::EntityType> {
self.pending_insert.take().map(EntityType::Insert)
}
fn attreq_set_block_base(&mut self, base: Vector3) {
self.set_block_base(base);
}
}
impl InsertBlockCommand {
/// The referenced block's insertion base point, for attribute placement.
pub fn set_block_base(&mut self, base: Vector3) {
self.block_base = base;
}
fn answer(&mut self, ask: Ask, text: Option<&str>) -> CmdResult {
let default = !matches!(ask, Ask::RepeatScale | Ask::RepeatRotate);
let Some(yes) = Self::yes_no(text.unwrap_or(""), default) else {
@@ -955,7 +969,8 @@ impl InsertBlockCommand {
_ => return None,
};
let mut insert = self.pending_insert.take()?;
let transform = insert.get_transform();
let transform =
crate::scene::render_graph::insert_transform_with_base(&insert, self.block_base);
for (attdef_idx, value) in values {
let Some(attdef) = attdefs.get(attdef_idx) else {
continue;
File diff suppressed because it is too large. Load diff
+13
View File
@@ -947,6 +947,14 @@ impl Scene {
&mut self,
options: CreateBlockOptions,
) -> Result<Handle, String> {
// Base-point contract: the contents are stored *relative to the picked
// base* because `world_to_block` already subtracts it, so the
// definition's own `Block.base_point` stays ZERO and the picked base
// becomes the block origin (inserting at a point puts that point where
// the base was). Callers must not also store the picked base in the
// definition — that would subtract it twice. Imported definitions,
// whose geometry arrives relative to their own base point, keep that
// base point and are never rewritten here.
let name = options.name.trim();
if name.is_empty() {
return Err("Block name cannot be empty.".into());
@@ -988,6 +996,11 @@ impl Scene {
// Update existing Block entity fields
if let Some(EntityType::Block(b)) = self.document.get_entity_mut(existing_blk_h) {
b.description = options.description.clone();
// The new contents are baked relative to the picked base (see
// the contract above), so the definition's base point has to
// go back to ZERO. Leaving an imported definition's non-zero
// base point here would subtract the picked base twice.
b.base_point = codec::types::Vector3::ZERO;
}
if options.annotative {
let annotative_vals = vec![codec::xdata::XDataValue::Integer16(1)];
+2
View File
@@ -26,6 +26,8 @@ pub enum BlockScalePolicy {
// Topic submodules split out of this root (each contributes `impl Scene`
// blocks and/or free functions). Pure text-move from the original mod.rs.
#[cfg(test)]
mod block_base_point_tests;
mod boundary;
mod camera_ops;
pub(crate) mod centerline;
+7 -2
View File
@@ -506,7 +506,11 @@ impl Scene {
// ATTRIBs live inline on each INSERT in that reference's owner space.
// Conjugate the block-local reframe through the INSERT transform:
// owner' = M · local_from_old · M⁻¹ · owner.
// owner' = M · local_from_old · M⁻¹ · owner. M is the block-local →
// world map the render path uses (`insert_transform`), because that is
// the map the attribute positions on the INSERT were placed through;
// the codec's base-ignoring transform would conjugate them through a
// displaced frame whenever the definition has a non-zero base point.
let references: Vec<(Handle, codec::types::Transform)> = self
.document
.entities()
@@ -516,7 +520,8 @@ impl Scene {
&& !reference.attributes.is_empty()
&& !owned.contains(&reference.common.handle) =>
{
let insertion = reference.get_transform();
let insertion =
crate::scene::render_graph::insert_transform(&self.document, reference);
let inverse = inverse_affine(&insertion)?;
Some((
reference.common.handle,
+6 -1
View File
@@ -250,7 +250,12 @@ pub fn set_insert_clip(
let Some(codec::EntityType::Insert(ins)) = doc.get_entity(insert) else {
return false;
};
let t = ins.get_transform().matrix.m;
// The stored inverse is the inverse of the transform every consumer
// composes the boundary back with — the base-point-aware instance
// transform the render path uses. Storing the codec's base-ignoring
// inverse would displace the ring by exactly -base on the next render.
let forward = crate::scene::render_graph::insert_transform(doc, ins);
let t = forward.matrix.m;
let forward = glam::DMat4::from_cols_array_2d(&[
[t[0][0], t[1][0], t[2][0], t[3][0]],
[t[0][1], t[1][1], t[2][1], t[3][1]],
+8 -1
View File
@@ -688,7 +688,14 @@ pub(crate) fn clear_dim_picture_placement(entity: &mut EntityType) {
}
pub fn insert_transform(document: &CadDocument, insert: &Insert) -> Transform {
let base = block_base_point(document, &insert.block_name);
insert_transform_with_base(insert, block_base_point(document, &insert.block_name))
}
/// [`insert_transform`] for a caller that already resolved the block's base
/// point (the `-INSERT` command builds the INSERT before the drawing is in
/// scope), so the base-point convention lives in exactly one place:
/// world = insert_point + Rot(rotation) * Scale * (P - base).
pub fn insert_transform_with_base(insert: &Insert, base: Vector3) -> Transform {
Transform::from_translation(Vector3::new(-base.x, -base.y, -base.z))
.then(&insert.get_transform())
}
+9
View File
@@ -139,6 +139,15 @@ impl BlockDefinitionState {
glam::DVec3::new(x, y, z)
}
/// Prefill the base point fields from an existing definition, so
/// redefining a block starts from the base it already has (0.0000 for a
/// new block). Formatted the same way the pick point button writes them.
pub fn set_base_point(&mut self, base: glam::DVec3) {
self.base_point_x = format!("{:.4}", base.x);
self.base_point_y = format!("{:.4}", base.y);
self.base_point_z = format!("{:.4}", base.z);
}
pub fn is_existing_name(&self) -> bool {
let trimmed = self.name.trim();
!trimmed.is_empty()