fix(blocks): prevent circular block nesting and cut render cycles

Introduce would_create_cycle in block_service to prevent recursive block definitions with case-insensitive matching across block records. Enforce cycle refusal at INSERT commit in BEDIT and during block redefinition (redefine_block_raw) with a shared error message. Ensure render-time recursion truncates safely with a one-time session warning on cyclic documents.
This commit is contained in:
Karim Jerbi committed 2026-10-13 11:10:11 +01:00
1 parent 59bb6faabd
commit 9c08e8a3ed
8 files changed
+435 -24

No files matched your search

+29
View File
@@ -982,6 +982,35 @@ impl OpenCADStudio {
pub(super) fn handle_attreq_needed(&mut self, block_name: String) -> Option<Task<Message>> {
let i = self.active_tab;
// When active space is a block editor session (BEDIT), refuse inserting
// any block that would create a circular nesting dependency.
if let Some(edit_handle) = self.tabs[i].scene.block_edit_block {
let container_name = self.tabs[i]
.scene
.document
.block_records
.iter()
.find(|br| br.handle == edit_handle)
.map(|br| br.name.clone());
if let Some(container) = container_name {
if crate::scene::block_service::would_create_cycle(
&self.tabs[i].scene.document,
&container,
&block_name,
) {
self.command_line.push_error(
&crate::scene::block_service::circular_dependency_error(
&container,
&block_name,
),
);
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
return None;
}
}
}
// Collect the full AttributeDefinitions owned by this block
// record so each created attribute keeps its geometry (#255).
let mut attdefs: Vec<codec::entities::AttributeDefinition> = {
+44
View File
@@ -1519,3 +1519,47 @@ fn locked_attedit_attribute_flags_cases() {
));
assert_eq!(preset_entity.get_value(), "edited_preset");
}
#[test]
fn locked_bedit_insert_refuses_cycle() {
let mut app = app();
let name_a = "BEDIT_CYC_A";
let name_b = "BEDIT_CYC_B";
define_block(&mut app, name_a);
// B contains an insert of A
let ins_a = Insert::new(name_a.to_string(), Vector3::ZERO);
let i = tab(&app);
app.tabs[i]
.scene
.define_block_raw(name_b, Vector3::ZERO, vec![EntityType::Insert(ins_a)]);
// Now open BEDIT for block A
let insert_a = add_insert(&mut app, name_a);
begin_bedit(&mut app, insert_a);
assert!(bedit_open(&app));
// Try committing an INSERT of B into A inside BEDIT space:
// Inserting B into A would create A -> B -> A cycle
let _ = app.dispatch_command("-INSERT");
let mut feed = |text: &str| {
let _ = app.update(crate::app::Message::CommandInput(text.to_string()));
let _ = app.update(crate::app::Message::CommandSubmit);
};
feed(name_b);
feed("0,0");
feed("1");
feed("1");
feed("0");
// Verify refusal error on command line
let errs = errors(&app);
assert!(
errs.iter()
.any(|e| e.contains("circular dependency detected")),
"expected cycle error in errors: {errs:?}"
);
let _ = app.dispatch_command("BEDIT_DISCARD");
}
+5 -1
View File
@@ -427,7 +427,11 @@ impl OpenCADStudio {
for def in deps.blocks {
let scene = &mut self.tabs[i].scene;
if redefine && def.name.eq_ignore_ascii_case(name) {
scene.redefine_block_raw(&def.name, def.base_point, def.entities);
if let Err(err) = scene.redefine_block_raw(&def.name, def.base_point, def.entities)
{
self.command_line.push_error(&err);
return Err(err);
}
} else if scene.document.block_records.get(&def.name).is_none() {
scene.define_block_raw(&def.name, def.base_point, def.entities);
}
+5 -2
View File
@@ -580,11 +580,14 @@ impl OpenCADStudio {
.define_block_raw(&def.name, def.base_point, def.entities);
}
if redefine {
self.tabs[i].scene.redefine_block_raw(
if let Err(err) = self.tabs[i].scene.redefine_block_raw(
&name,
codec::types::Vector3::new(base.x, base.y, base.z),
entities,
);
) {
self.command_line.push_error(&err);
return Err(err);
}
} else {
self.tabs[i]
.scene
+63 -16
View File
@@ -538,14 +538,16 @@ fn locked_redefine_with_a_changed_base_moves_the_instance() {
);
// The redefine path: the same contents, but a new base point (0,20,0).
scene.redefine_block_raw(
NAME,
v(0.0, 20.0, 0.0),
vec![EntityType::Line(Line::from_points(
v(0.0, 0.0, 0.0),
v(20.0, 0.0, 0.0),
))],
);
assert!(scene
.redefine_block_raw(
NAME,
v(0.0, 20.0, 0.0),
vec![EntityType::Line(Line::from_points(
v(0.0, 0.0, 0.0),
v(20.0, 0.0, 0.0),
))],
)
.is_ok());
assert_close(
block_base_point(&scene.document, NAME),
[0.0, 20.0, 0.0],
@@ -616,14 +618,16 @@ fn locked_noop_redefine_leaves_the_instance_where_it_is() {
assert_close(end, [62.990381, 27.5, 0.0], 1e-6, "line end before");
// No-op redefine: identical contents, same base.
scene.redefine_block_raw(
NAME,
base,
vec![EntityType::Line(Line::from_points(
v(6.0, 4.0, 0.0),
v(16.0, 4.0, 0.0),
))],
);
assert!(scene
.redefine_block_raw(
NAME,
base,
vec![EntityType::Line(Line::from_points(
v(6.0, 4.0, 0.0),
v(16.0, 4.0, 0.0),
))],
)
.is_ok());
assert_close(
block_base_point(&scene.document, NAME),
[6.0, 4.0, 0.0],
@@ -1153,3 +1157,46 @@ fn locked_nested_insert_with_attributes_exploded_one_level() {
"outer attdef insertion point",
);
}
#[test]
fn locked_redefine_block_raw_refuses_cycle() {
let mut scene = Scene::new();
let blk_a = "REDEF_A";
let blk_b = "REDEF_B";
scene.define_block_raw(
blk_a,
Vector3::ZERO,
vec![EntityType::Line(Line::from_points(
Vector3::ZERO,
Vector3::new(1.0, 0.0, 0.0),
))],
);
// B contains A
scene.define_block_raw(
blk_b,
Vector3::ZERO,
vec![EntityType::Insert(Insert::new(
blk_a.to_string(),
Vector3::ZERO,
))],
);
// Redefining A to contain B would create cycle A -> B -> A; must refuse
let res = scene.redefine_block_raw(
blk_a,
Vector3::ZERO,
vec![EntityType::Insert(Insert::new(
blk_b.to_string(),
Vector3::ZERO,
))],
);
assert!(res.is_err());
let err = res.unwrap_err();
assert!(
err.contains("circular dependency detected"),
"expected cycle error, got: {err}"
);
}
+141
View File
@@ -598,7 +598,88 @@ fn record_of<'a>(
.get(name)
.ok_or_else(|| missing(name))
}
/// Checks whether inserting `inserted_block` into `container_block` would create a circular
/// block nesting dependency, directly or transitively.
///
/// Matching is case-insensitive, consistent with the rest of the block service.
///
/// Xrefs (`|` in name or xref flags) and anonymous blocks (`*` prefix) are handled as follows:
/// - Anonymous blocks (`*U...`) are fully traversed like normal blocks because their geometry
/// and nested inserts live in the document.
/// - Externally referenced blocks (xrefs) have definitions that come from external files and
/// are not edited inline in the host document, but if an xref name is tested, it is matched
/// case-insensitively and its local block record entities (if any) are traversed.
pub fn would_create_cycle(
document: &CadDocument,
container_block: &str,
inserted_block: &str,
) -> bool {
let container = container_block.trim();
let inserted = inserted_block.trim();
if container.is_empty() || inserted.is_empty() {
return false;
}
if container.eq_ignore_ascii_case(inserted) {
return true;
}
let mut visited: HashSet<String> = HashSet::default();
let mut stack: Vec<String> = vec![inserted.to_ascii_uppercase()];
while let Some(current_upper) = stack.pop() {
if !visited.insert(current_upper.clone()) {
continue;
}
// Find block record matching current_upper case-insensitively
let Some(record) = document.block_records.get(&current_upper) else {
continue;
};
// Check entities owned by this block record: either registered in entity_handles
// or having owner_handle == record.handle in the document.
let mut sub_inserts = Vec::new();
for &handle in &record.entity_handles {
if let Some(EntityType::Insert(sub_ins)) = document.get_entity(handle) {
sub_inserts.push(sub_ins.block_name.trim().to_string());
}
}
for entity in document.entities() {
if entity.common().owner_handle == record.handle {
if let EntityType::Insert(sub_ins) = entity {
sub_inserts.push(sub_ins.block_name.trim().to_string());
}
}
}
for sub_name in sub_inserts {
if sub_name.eq_ignore_ascii_case(container) {
return true;
}
let sub_upper = sub_name.to_ascii_uppercase();
if !visited.contains(&sub_upper) {
stack.push(sub_upper);
}
}
}
false
}
pub const CIRCULAR_BLOCK_DEPENDENCY_ERROR: &str =
"Cannot insert block: circular dependency detected.";
/// Standard command-line error text when block insertion would create a circular dependency.
pub fn circular_dependency_error(container_block: &str, inserted_block: &str) -> String {
format!(
"Cannot insert block \"{}\" into block \"{}\": circular dependency detected.",
inserted_block, container_block
)
}
#[cfg(test)]
mod tests {
use super::*;
use codec::entities::{
@@ -1097,4 +1178,64 @@ mod tests {
"block \"A\" is open in the block editor; close the editor first"
);
}
#[test]
fn locked_would_create_cycle_self_insert() {
let mut scene = scene();
define(&mut scene, "BLK_A");
assert!(would_create_cycle(&scene.document, "BLK_A", "BLK_A"));
assert!(would_create_cycle(&scene.document, "blk_a", "BLK_A"));
assert!(would_create_cycle(&scene.document, "BLK_A", "blk_a"));
}
#[test]
fn locked_would_create_cycle_direct_pair() {
let mut scene = scene();
let blk_a = define(&mut scene, "BLK_A");
define(&mut scene, "BLK_B");
// A contains B
add_insert(&mut scene, "BLK_B", Some(blk_a));
// Inserting A into B would create a cycle
assert!(would_create_cycle(&scene.document, "BLK_B", "BLK_A"));
// Inserting B into A is not allowed either since A already inserts B
assert!(!would_create_cycle(&scene.document, "BLK_A", "BLK_B"));
}
#[test]
fn locked_would_create_cycle_three_level_chain() {
let mut scene = scene();
let blk_a = define(&mut scene, "BLK_A");
let blk_b = define(&mut scene, "BLK_B");
define(&mut scene, "BLK_C");
// A contains B, B contains C
add_insert(&mut scene, "BLK_B", Some(blk_a));
add_insert(&mut scene, "BLK_C", Some(blk_b));
// Inserting A into C creates cycle C -> A -> B -> C
assert!(would_create_cycle(&scene.document, "BLK_C", "BLK_A"));
// Inserting B into C creates cycle C -> B -> C
assert!(would_create_cycle(&scene.document, "BLK_C", "BLK_B"));
}
#[test]
fn locked_would_create_cycle_shared_child_not_a_cycle() {
let mut scene = scene();
let blk_a = define(&mut scene, "BLK_A");
let blk_b = define(&mut scene, "BLK_B");
define(&mut scene, "BLK_SHARED");
// A contains SHARED, B contains SHARED. Inserting B into A is fine (diamond/DAG, not cycle).
add_insert(&mut scene, "BLK_SHARED", Some(blk_a));
add_insert(&mut scene, "BLK_SHARED", Some(blk_b));
assert!(!would_create_cycle(&scene.document, "BLK_A", "BLK_B"));
assert!(!would_create_cycle(&scene.document, "BLK_B", "BLK_A"));
}
#[test]
fn locked_would_create_cycle_case_differences() {
let mut scene = scene();
let blk_a = define(&mut scene, "BlockA");
let _blk_b = define(&mut scene, "BlockB");
add_insert(&mut scene, "BlockB", Some(blk_a));
assert!(would_create_cycle(&scene.document, "blockb", "BLOCKA"));
assert!(!would_create_cycle(&scene.document, "blocka", "BLOCKB"));
}
}
+121 -2
View File
@@ -178,6 +178,7 @@ pub struct BlockCache {
HashMap<ExpansionPrototypeKey, Arc<std::sync::Mutex<Option<Arc<CachedExpansion>>>>>,
>,
clip_prototypes: std::sync::Mutex<HashMap<(u64, Vec<u64>), u64>>,
cycle_warnings: std::sync::Mutex<HashSet<String>>,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
@@ -219,6 +220,22 @@ impl BlockCache {
Self::default()
}
pub fn record_cycle_warning(&self, block_name: &str) -> bool {
let mut warnings = self
.cycle_warnings
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
warnings.insert(block_name.to_ascii_uppercase())
}
pub fn drain_cycle_warnings(&self) -> Vec<String> {
let mut warnings = self
.cycle_warnings
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
warnings.drain().collect()
}
pub fn defn(&self, block_name: &str) -> Option<&Arc<BlockDefn>> {
self.defns.get(block_name).or_else(|| {
self.defns
@@ -1105,7 +1122,7 @@ pub fn expand_insert(
);
}
let mut batches = Batches::default();
let mut visited: Vec<String> = Vec::with_capacity(8);
let mut visited: Vec<String> = vec![ins.block_name.clone()];
let offsets = crate::scene::render_graph::array_offsets(ins);
let cell_xform = |offset: &[f64; 3]| {
@@ -1952,7 +1969,16 @@ fn expand_defn(
emit_wire(attachment, &attachment_transform, &inner_ctx, out, d_range);
}
}
if visited.iter().any(|n| n == &nref.block_name) {
if visited
.iter()
.any(|n| n.eq_ignore_ascii_case(&nref.block_name))
{
if ctx.cache.record_cycle_warning(&nref.block_name) {
eprintln!(
"Warning: circular block reference detected for block \"{}\"; cutting cycle",
nref.block_name
);
}
continue;
}
let Some(nested_defn) = ctx.cache.defn(&nref.block_name) else {
@@ -3484,4 +3510,97 @@ mod metrics_depth_tests {
assert_ne!(root.metrics.aabb_local, [0.0; 4]);
assert_eq!(root.metrics.aabb_local, tail.metrics.aabb_local);
}
#[test]
fn locked_cyclic_block_document_renders_without_hanging_and_warns_once() {
// Document built in code with pre-existing cycle:
// A contains B, B contains A
let mut document = CadDocument::new();
let blk_a = add_block(&mut document, "CYC_A");
let blk_b = add_block(&mut document, "CYC_B");
add_owned(
&mut document,
blk_a,
EntityType::Insert(Insert::new("CYC_B", Vector3::ZERO)),
);
add_owned(
&mut document,
blk_b,
EntityType::Insert(Insert::new("CYC_A", Vector3::ZERO)),
);
// Add a line in A so there is some actual geometry
add_owned(
&mut document,
blk_a,
EntityType::Line(Line::from_points(
Vector3::ZERO,
Vector3::new(10.0, 0.0, 0.0),
)),
);
// Model space references CYC_A
document
.add_entity(EntityType::Insert(Insert::new("CYC_A", Vector3::ZERO)))
.unwrap();
// Building the cache computes defns and metrics without hanging
let cache = BlockCache::build(
&document,
1.0,
None,
true,
[0.0, 0.0, 0.0, 1.0],
None,
&HashMap::default(),
);
// Verify defns exist
assert!(cache.defns.contains_key("CYC_A"));
assert!(cache.defns.contains_key("CYC_B"));
// Expanding / tessellating into wires does not hang and cuts the cycle
let ins = Insert::new("CYC_A", Vector3::ZERO);
let inherit_style = crate::scene::view::render::InheritStyle {
color: [1.0, 1.0, 1.0, 1.0],
pat_len: 1.0,
pat: [0.0; 8],
lw_px: 1.0,
};
let result = expand_insert(
&document,
&cache,
&ins,
Handle::new(1),
[1.0, 1.0, 1.0, 1.0],
7,
1.0,
[0.0; 8],
1.0,
inherit_style,
7,
true,
false,
1.0,
None,
None,
false,
[0.0, 0.0, 0.0, 1.0],
1.0,
crate::scene::BlockScalePolicy::FromInsert,
false,
);
assert!(result.is_some());
// Warning was recorded once
let warnings = cache.drain_cycle_warnings();
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0], "CYC_A");
// Subsequent expansion does not warn again because warned set retains it
let warnings_after = cache.drain_cycle_warnings();
assert!(warnings_after.is_empty());
}
}
+27 -3
View File
@@ -1263,19 +1263,35 @@ impl Scene {
/// Replace the contents and base point of block `name`, defining it when
/// absent. References keep pointing at it and show the new contents.
/// Refuses and returns Err if the new contents lead back to `name` directly or transitively.
pub fn redefine_block_raw(
&mut self,
name: &str,
base_point: codec::types::Vector3,
entities: Vec<EntityType>,
) {
) -> Result<(), String> {
for entity in &entities {
if let EntityType::Insert(ins) = entity {
if crate::scene::block_service::would_create_cycle(
&self.document,
name,
&ins.block_name,
) {
return Err(crate::scene::block_service::circular_dependency_error(
name,
&ins.block_name,
));
}
}
}
let Some((br_handle, block_handle)) = self
.document
.block_records
.get(name)
.map(|br| (br.handle, br.block_entity_handle))
else {
return self.define_block_raw(name, base_point, entities);
self.define_block_raw(name, base_point, entities);
return Ok(());
};
let owned: Vec<Handle> = self
.document
@@ -1299,6 +1315,7 @@ impl Scene {
let _ = self.document.add_entity(entity);
}
self.bump_geometry();
Ok(())
}
pub(super) fn synced_hatch_models(
@@ -2228,7 +2245,14 @@ impl Scene {
.colors
.get(i)
.and_then(|e| e.color.rgb())
.map(|(r, g, b)| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, color[3]])
.map(|(r, g, b)| {
[
r as f32 / 255.0,
g as f32 / 255.0,
b as f32 / 255.0,
color[3],
]
})
};
gradient_color1 = stop(0);
let color1 = gradient_color1.unwrap_or(color);