From 3f57f8d3d49ca22e83bae85e4d1a8b445e385bc2 Mon Sep 17 00:00:00 2001 From: midasmatterhorn <248507632+midasmatterhorn@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:01:46 +0300 Subject: [PATCH] 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 --- crates/io/tests/it/block_expansion.rs | 65 ++++++++++++++++++++++++++- crates/mcp/src/backend.rs | 2 +- crates/render/src/display.rs | 4 ++ crates/render/src/lib.rs | 6 ++- crates/render/src/viewports.rs | 1 + crates/ui-egui/src/control.rs | 20 +++++---- 6 files changed, 87 insertions(+), 11 deletions(-) diff --git a/crates/io/tests/it/block_expansion.rs b/crates/io/tests/it/block_expansion.rs index b190156f8..64fca189e 100644 --- a/crates/io/tests/it/block_expansion.rs +++ b/crates/io/tests/it/block_expansion.rs @@ -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"); +} diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 20a86fce7..1a36144ca 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -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")?; diff --git a/crates/render/src/display.rs b/crates/render/src/display.rs index e6ba6b7e9..9731ecbf7 100644 --- a/crates/render/src/display.rs +++ b/crates/render/src/display.rs @@ -59,6 +59,10 @@ pub struct DisplayList { pub sheet: Option, /// 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, diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6fa236e4c..e93c9fbff 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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 } } diff --git a/crates/render/src/viewports.rs b/crates/render/src/viewports.rs index 6f1e57517..67c31c694 100644 --- a/crates/render/src/viewports.rs +++ b/crates/render/src/viewports.rs @@ -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); } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8d0a39bf4..4abc6b1f4 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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); } }