render: a display list past the block-expansion budget says what it left out (#1278)

* render: a display list past the block-expansion budget says what it left out

* Report viewport truncation only after the expansion budget is exceeded

* fmt: keep ui-egui control.rs rustfmt-clean
This commit is contained in:
midasmatterhorn
2026-11-02 15:01:46 -07:00
committed by GitHub
parent 4e874d089a
commit 3f57f8d3d4
6 changed files with 87 additions and 11 deletions
+64 -1
View File
@@ -1,7 +1,10 @@
//! Nested, self-referencing and arrayed block references in a hostile DXF must not hang drawing
//! or extents (issue #255): the expansion is bounded, not only its depth.
use cadcraft_doc::{Drawing, EntityKind, Space};
use std::sync::Arc;
use cadcraft_doc::{Block, Common, Drawing, Entity, EntityKind, Handle, Insert, Line, Space, Viewport};
use cadcraft_geom::{Vec2, Vec3};
use cadcraft_render::{MAX_BLOCK_EXPANSION, Options, build};
fn read(text: &str) -> Drawing {
@@ -63,3 +66,63 @@ fn ordinary_nested_and_arrayed_blocks_are_drawn_whole() {
assert!(matches!(d.model.iter().next().map(|e| &e.kind), Some(EntityKind::Insert(_))));
assert_eq!(build(&d, &Space::Model, &Options::default()).prims.len(), cadcraft_doc::MAX_BLOCK_DEPTH);
}
#[test]
fn a_build_that_spends_the_expansion_budget_says_so() {
// 10,000 x 10,000 copies of a one-line block: far past the budget. The list stops at the
// budget and says so, so a screen, plot or export missing contents is not silent (#1070).
let d = read(&minsert(10_000, 10_000));
let list = build(&d, &Space::Model, &Options::default());
assert!(list.truncated, "block contents were left out without a word");
assert!(list.prims.len() <= MAX_BLOCK_EXPANSION, "{}", list.prims.len());
// A drawing within the budget is whole and says nothing.
let d = read(&minsert(3, 2));
let list = build(&d, &Space::Model, &Options::default());
assert!(!list.truncated);
assert_eq!(list.prims.len(), 6);
}
#[test]
fn an_empty_followup_viewport_does_not_mark_an_exact_budget_truncated() {
let mut d = Drawing::new_imperial();
d.ensure_layer("FROZEN");
let mut block = Block::new("A");
block.entities.push(Entity {
handle: Handle(0x200),
common: Common { layer: "FROZEN".into(), ..Common::default() },
kind: EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 1.0, 0.0) }),
});
d.blocks.insert(block.name.clone(), Arc::new(block));
let insert = Insert {
block: "A".into(),
insert: Vec3::ZERO,
scale: Vec3::new(1.0, 1.0, 1.0),
rotation: 0.0,
attribs: Vec::new(),
cols: 1_000,
rows: 1_000,
col_spacing: 0.0,
row_spacing: 0.0,
};
d.add(&Space::Model, Common::default(), EntityKind::Insert(insert)).unwrap();
let viewport = |center: Vec3, view_center: Vec2, frozen_layers| Viewport {
center,
width: 100.0,
height: 100.0,
view_center,
view_height: 100.0,
id: 2,
locked: false,
frozen_layers,
layer_colors: Vec::new(),
clip: None,
on: true,
};
let paper = Space::Paper("Layout1".into());
d.add(&paper, Common::default(), EntityKind::Viewport(viewport(Vec3::new(50.0, 50.0, 0.0), Vec2::ZERO, vec!["FROZEN".into()]))).unwrap();
d.add(&paper, Common::default(), EntityKind::Viewport(viewport(Vec3::new(200.0, 50.0, 0.0), Vec2::new(10_000.0, 10_000.0), Vec::new()))).unwrap();
let list = build(&d, &paper, &Options::default());
assert_eq!(list.expanded, MAX_BLOCK_EXPANSION);
assert!(!list.truncated, "the first viewport consumed the budget exactly and the second saw no model content");
}
+1 -1
View File
@@ -418,7 +418,7 @@ impl Backend for Headless {
if let Some(path) = str_p("path") {
std::fs::write(&path, &png).map_err(|e| format!("{path}: {e}"))?;
}
Ok(json!({"pngBase64": cadcraft_engine::cmd::file::base64_encode(&png), "width": w, "height": h}))
Ok(json!({"pngBase64": cadcraft_engine::cmd::file::base64_encode(&png), "width": w, "height": h, "truncated": list.truncated}))
}
"app.open" => {
let path = str_p("path").ok_or("missing path")?;
+4
View File
@@ -59,6 +59,10 @@ pub struct DisplayList {
pub sheet: Option<Sheet>,
/// Block contents expanded while building (see [`MAX_BLOCK_EXPANSION`]).
pub expanded: usize,
/// The expansion budget was spent: block contents past it are not in the list. The screen,
/// plots and exports draw what is here; the CLI and the control channel report this, so an
/// output that leaves contents out is never silent (#1070).
pub truncated: bool,
/// Plot style end, join and fill style per primitive (parallel to `prims`; missing entries
/// are "use object"), turned into geometry by [`pen::apply`].
pub pens: Vec<Pen>,
+5 -1
View File
@@ -291,10 +291,14 @@ impl Builder<'_> {
/// Take one unit of the block-expansion budget; false once it is spent.
fn expand(&mut self) -> bool {
self.list.expanded = self.list.expanded.saturating_add(1);
match self.shared_budget {
let ok = match self.shared_budget {
Some(spent) => spent.fetch_add(1, std::sync::atomic::Ordering::Relaxed) < MAX_BLOCK_EXPANSION,
None => self.list.expanded <= MAX_BLOCK_EXPANSION,
};
if !ok {
self.list.truncated = true;
}
ok
}
}
+1
View File
@@ -174,6 +174,7 @@ fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bou
// The contents are in paper units now: draw their pens before clipping.
pen::apply(&mut sub.list, unit_mm);
b.list.expanded = b.list.expanded.saturating_add(sub.list.expanded);
b.list.truncated |= sub.list.truncated;
append_clipped(&mut b.list, &sub.list, clip);
}
+12 -8
View File
@@ -269,7 +269,7 @@ pub fn inspect(app: &CadApp, ctx: &egui::Context) -> Value {
"snap": app.canvas.snap.map(|s| json!({"point": [s.point.x, s.point.y], "mode": s.name})),
"session": app.session.summary(),
"closePrompt": app.close_prompt(),
"perf": {"frameMs": app.frame_ms, "buildMs": app.canvas.build_ms, "drawMs": app.canvas.draw_ms, "meshMs": app.canvas.mesh_ms, "building": app.canvas.building(), "renderer": if app.canvas.gpu.is_some() { "gpu" } else { "cpu" }, "prims": app.canvas.list.as_ref().map(|l| l.prims.len()), "uiFrames": app.ui_frames, "passIndex": ctx.current_pass_index()},
"perf": {"frameMs": app.frame_ms, "buildMs": app.canvas.build_ms, "drawMs": app.canvas.draw_ms, "meshMs": app.canvas.mesh_ms, "building": app.canvas.building(), "renderer": if app.canvas.gpu.is_some() { "gpu" } else { "cpu" }, "prims": app.canvas.list.as_ref().map(|l| l.prims.len()), "truncated": app.canvas.list.as_ref().map(|l| l.truncated), "uiFrames": app.ui_frames, "passIndex": ctx.current_pass_index()},
})
}
@@ -492,9 +492,9 @@ pub fn handle(app: &mut CadApp, ctx: &egui::Context, req: &ControlRequest) -> Ou
if let Err(e) = std::fs::write(path, &png) {
return err(e);
}
return ok(render_reply(&png, w, h, Some(path)));
return ok(render_reply(&png, w, h, Some(path), list.truncated));
}
ok(render_reply(&png, w, h, None))
ok(render_reply(&png, w, h, None, list.truncated))
}
"app.open" => {
let Some(path) = s("path") else { return err("missing path") };
@@ -537,9 +537,11 @@ fn render_view(bounds: &cadcraft_geom::Bounds2, center: Vec2, height: f64, fit:
}
}
/// The `ui.render` reply, matching the headless backend (`pngBase64`, `width`, `height`), plus `bytes` and `path`.
fn render_reply(png: &[u8], w: u32, h: u32, path: Option<&str>) -> Value {
let mut v = json!({"pngBase64": cadcraft_engine::cmd::file::base64_encode(png), "width": w, "height": h, "bytes": png.len()});
/// The `ui.render` reply, matching the headless backend (`pngBase64`, `width`, `height`), plus
/// `bytes`, `path` and `truncated` (the build left block contents out, see #1070).
fn render_reply(png: &[u8], w: u32, h: u32, path: Option<&str>, truncated: bool) -> Value {
let mut v =
json!({"pngBase64": cadcraft_engine::cmd::file::base64_encode(png), "width": w, "height": h, "bytes": png.len(), "truncated": truncated});
if let (Some(path), Some(o)) = (path, v.as_object_mut()) {
o.insert("path".to_string(), json!(path));
}
@@ -816,12 +818,14 @@ mod tests {
#[test]
fn render_reply_has_base64_width_height_and_keeps_old_keys() {
let r = render_reply(b"abc", 7, 5, Some("x.png"));
let r = render_reply(b"abc", 7, 5, Some("x.png"), false);
assert_eq!(r["pngBase64"], "YWJj");
assert_eq!(r["width"], 7);
assert_eq!(r["height"], 5);
assert_eq!(r["bytes"], 3);
assert_eq!(r["path"], "x.png");
assert!(render_reply(b"abc", 7, 5, None).get("path").is_none());
assert_eq!(r["truncated"], false);
assert!(render_reply(b"abc", 7, 5, None, true).get("path").is_none());
assert_eq!(render_reply(b"abc", 7, 5, None, true)["truncated"], true);
}
}