From de1e49a4c5868f417885a854670179876b5110ed Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 15 Oct 2026 13:04:39 +0300 Subject: [PATCH] feat(plot): PDF output carries drawing layers Every plotted wire and hatch resolves to its entity's layer, and the PDF wraps each run of same-layer items in an optional-content group, so a viewer's layer panel lists the drawing's layers and can hide them. Draw order is unchanged; images stay outside any group. Expanded block geometry goes with its INSERT's layer. Co-Authored-By: Claude Opus 5.5 --- src/app/update/file.rs | 35 +++++++++++++++++++++++++++++++++++ src/io/pdf_export.rs | 40 ++++++++++++++++++++++++++++++++++++++++ src/io/plot.rs | 3 +++ 3 files changed, 78 insertions(+) diff --git a/src/app/update/file.rs b/src/app/update/file.rs index f14bbd8a6..3062d905f 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -604,12 +604,15 @@ fn plot_scene_content( wipeouts: paper_wipeouts.len(), images: paper_images.len(), }; + let entity_layers = + plot_entity_layers(scene, &paper_wires, &paper_hatches, &paper_wipeouts); return PlotContent { wires: std::sync::Arc::new(paper_wires), hatches: paper_hatches, wipeouts: paper_wipeouts, images: paper_images, group_splits: splits, + entity_layers, }; } let (mut model_pattern_wires, model_hatches, model_wipeouts, model_images) = @@ -658,15 +661,47 @@ fn plot_scene_content( images.extend(model_images); (wires, hatches, wipeouts, images, splits) }; + let entity_layers = plot_entity_layers(scene, &wires, &hatches, &wipeouts); PlotContent { wires: std::sync::Arc::new(wires), hatches, wipeouts, images, group_splits: splits, + entity_layers, } } +/// Drawing layer of every entity the plot draws, keyed by the handle in its +/// wire or hatch name. +// ponytail: expanded block geometry carries its INSERT's handle, so a block's +// children all land on the INSERT's layer; per-child layers need BlockCache to +// keep the child handle on each wire. +fn plot_entity_layers( + scene: &crate::scene::Scene, + wires: &[crate::io::pdf_export::PlotWire], + hatches: &[crate::scene::model::hatch_model::HatchModel], + wipeouts: &[crate::scene::model::hatch_model::HatchModel], +) -> std::collections::HashMap { + let names = wires + .iter() + .map(|wire| wire.name.as_str()) + .chain(hatches.iter().chain(wipeouts).map(|hatch| hatch.name.as_str())); + let mut layers = std::collections::HashMap::new(); + for name in names { + let Some(handle) = crate::scene::Scene::handle_from_wire_name(name) else { + continue; + }; + if layers.contains_key(&handle) { + continue; + } + if let Some(entity) = scene.document.get_entity(handle) { + layers.insert(handle, entity.common().layer.clone()); + } + } + layers +} + impl OpenCADStudio { /// Persist exact ACIS bodies, and an edge cache with them, for solids that /// have no ACIS data before saving. A solid that has ACIS data keeps its diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 791d1bd62..caa28875d 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -442,11 +442,14 @@ pub(crate) fn render_pdf_bytes( // Allocation addresses cannot be reused by another source during this export. let mut image_resources = std::collections::HashMap::new(); let mut text_fonts = TextFonts::default(); + // One optional-content group per drawing layer, shared by every page. + let mut layer_ids = std::collections::HashMap::new(); for (index, page) in pages.iter().enumerate() { append_pdf_page( &mut doc, &mut image_resources, &mut text_fonts, + &mut layer_ids, page, plot_style, &fonts, @@ -473,6 +476,7 @@ fn append_pdf_page( doc: &mut PdfDocument, image_resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>, text_fonts: &mut TextFonts, + layer_ids: &mut std::collections::HashMap, page: &PdfPageInput, fallback_plot_style: Option<&PlotStyleTable>, fonts: &SearchableFontSet, @@ -494,7 +498,13 @@ fn append_pdf_page( wipeouts, images, group_splits, + entity_layers, } = &page.content; + let layer_of = |name: &str| { + crate::scene::Scene::handle_from_wire_name(name) + .and_then(|handle| entity_layers.get(&handle)) + .map(String::as_str) + }; let (paper_w, paper_h) = (page.paper_w as f32, page.paper_h as f32); let (ox, oy) = (page.offset_x, page.offset_y); let (rotation_deg, scale, clip) = (page.rotation_deg, page.scale, page.clip); @@ -686,8 +696,35 @@ fn append_pdf_page( // Current PDF dash array (empty = solid). Tracked so the dash op is only // re-emitted when it actually changes between wires. let mut last_dash: Option> = None; + // Consecutive items on one drawing layer share a marked-content run, + // so a viewer's layer panel can hide that layer. + let mut open_layer: Option<&str> = None; for (_, _, _, item) in draw_items { + let layer = match &item { + DrawItem::WireFill(wire) | DrawItem::Wire(wire) | DrawItem::Text(wire) => { + layer_of(&wire.name) + } + DrawItem::Hatch(hatch) => layer_of(&hatch.name), + DrawItem::Image(_) => None, + }; + if layer != open_layer { + if open_layer.is_some() { + ops.push(Op::EndLayer); + } + if let Some(name) = layer { + let layer_id = layer_ids + .entry(name.to_string()) + .or_insert_with(|| { + let mut layer = printpdf::Layer::new(name); + layer.creator = "Open CAD Studio".into(); + doc.add_layer(&layer) + }) + .clone(); + ops.push(Op::BeginLayer { layer_id }); + } + open_layer = layer; + } let wire = match item { DrawItem::WireFill(wire) => { emit_wire_fills( @@ -1070,6 +1107,9 @@ fn append_pdf_page( } flush_line(&mut ops, &segment, dot_radius); } + if open_layer.is_some() { + ops.push(Op::EndLayer); + } } if needs_state { diff --git a/src/io/plot.rs b/src/io/plot.rs index d4c733ee8..9062bbdd9 100644 --- a/src/io/plot.rs +++ b/src/io/plot.rs @@ -90,6 +90,9 @@ pub struct PlotContent { pub wipeouts: Vec, pub images: Vec, pub group_splits: PlotGroupSplits, + /// Drawing layer of each plotted entity, keyed by the handle its wire or + /// hatch name carries; the PDF groups output into one layer per entry. + pub entity_layers: std::collections::HashMap, } /// Owned geometry and settings for one plot page.