Skip to main content

brep_render/sheets/
pdf3d.rs

1//! The live 3D model a sheet PDF can carry — a U3D stream ([`super::u3d`]) and
2//! the PDF 1.7 dictionaries that show it (ISO 32000-1 §13.6).
3//!
4//! # What the reader sees
5//!
6//! One U3D stream holds the whole scene, shared by every 3D annotation in the
7//! file:
8//!
9//! - each drawn solid as a MESH node named for the solid, and its edges as a
10//!   dark LINE SET node beside it (`{solid} edges`), so the model reads as a
11//!   CAD model and not a smooth blob;
12//! - each enabled, resolved annotation of each saved view with a camera as a
13//!   LINE SET node (`PMI {view} {annotation}`: dimension, extension and leader
14//!   lines, frames, and the text STROKED with the PMI font — the same
15//!   [`pmi_present`] layout the viewport and the STEP presentation use, sized
16//!   as the viewport drew it when the view was saved), plus a small MESH node
17//!   for its filled arrowheads (`… fill`).
18//!
19//! Each saved view is a `/3DView` in the stream's `/VA` list: its camera as the
20//! camera-to-world matrix, its projection, and a `/NA` node list that shows
21//! THAT view's annotations and hides every other view's, and hides the solids
22//! the view hides. A reader's view menu is therefore the document's PMI view
23//! list, and picking one shows exactly that view's PMI.
24//!
25//! Only readers that implement 3D annotations draw any of this — Adobe
26//! Acrobat / Reader (with 3D enabled) and Foxit. Every other reader shows the
27//! annotation's appearance, which is why the sheet's own vector drawing stays
28//! on the page beneath a placement, and why the full 3D page draws the default
29//! view as a hidden-line drawing under its 3D box.
30//!
31//! # The camera
32//!
33//! A view's `/C2W` maps camera coordinates to world coordinates: its columns
34//! are the camera's x axis (right), y axis and z axis (the viewing direction,
35//! eye → target), then the eye. ISO 32000-1 §13.6.5 states the camera's y axis
36//! points UP; [`CAMERA_Y_UP`] records which way this writer points it, because
37//! the claim is checked in a real reader, not taken from the text.
38
39use brep_kernel::{
40    pmi_present, PmiAnnotationReport, PmiCamera, PmiGeometry, PmiLayoutStyle, PmiPlane, PmiPose, PmiProjection,
41    PmiReport, PmiState, PmiStatus, PmiView,
42};
43
44use super::pdf::{string_literal, PT_PER_MM};
45use super::u3d::{self, Geometry, Lines, Material, Mesh, Model, Scene};
46
47/// The camera's y axis is the view's UP vector (true) or its down vector
48/// (false). See the module doc.
49pub const CAMERA_Y_UP: bool = true;
50
51use crate::engine_state::pmi_overlay::{ARROW_PX, TEXT_PX_AT_12PT};
52
53/// Model units per viewport pixel under a saved camera, in the viewport the
54/// view was saved from — `ViewCamera::world_per_pixel`'s rule.
55pub fn world_per_pixel(camera: &PmiCamera) -> f64 {
56    let height = camera.viewport[1].max(1.0);
57    match camera.projection {
58        PmiProjection::Orthographic { half_height } => 2.0 * half_height / height,
59        PmiProjection::Perspective { fov_y_deg } => {
60            let d = sub(camera.target, camera.eye);
61            2.0 * (fov_y_deg.to_radians() * 0.5).tan() * dot(d, d).sqrt() / height
62        }
63    }
64}
65
66/// Edge lines: near-black, so they read on any body colour.
67const EDGE_RGB: [u8; 3] = [0x20, 0x20, 0x24];
68/// PMI: a deep orange — the viewport's amber darkened enough to read on the
69/// PDF's white background, and clear of the blues and greys bodies default
70/// to (a blue first drawn here vanished into a blue cube in Acrobat).
71const PMI_RGB: [u8; 3] = [0xD9, 0x68, 0x00];
72
73/// One solid, gathered by the engine from the display scene.
74#[derive(Debug, Clone)]
75pub struct Body {
76    pub name: String,
77    pub positions: Vec<[f32; 3]>,
78    pub normals: Vec<[f32; 3]>,
79    /// Three per triangle.
80    pub indices: Vec<u32>,
81    /// sRGB per triangle.
82    pub triangle_colors: Vec<[u8; 3]>,
83    /// World polylines of the solid's real edges.
84    pub edges: Vec<Vec<[f32; 3]>>,
85}
86
87/// How a view projects.
88#[derive(Debug, Clone, Copy, PartialEq)]
89pub enum Projection3d {
90    /// Points of the annotation per model unit.
91    Orthographic { scale: f64 },
92    /// Vertical field of view, degrees.
93    Perspective { fov_y_deg: f64 },
94}
95
96/// A saved view as the reader will show it.
97#[derive(Debug, Clone, PartialEq)]
98pub struct View3d {
99    /// The PMI view id (`VIEW1`) — the dictionary's internal name.
100    pub id: String,
101    /// What the reader's view menu lists.
102    pub name: String,
103    pub camera: PmiCamera,
104    pub wireframe: bool,
105    /// Visibility per [`Model3d::nodes`] entry.
106    pub visible: Vec<bool>,
107    /// Per [`Model3d::nodes`] entry, the node's `/M` in this view: a solid
108    /// an explode step moves, as its composed stages ([`stage_matrix`]).
109    /// `None` leaves the node where the stream put it.
110    pub poses: Vec<Option<[f64; 12]>>,
111    /// The view's section plane, `(point, normal)`, kept where
112    /// `(p − point)·normal ≥ 0` as in the viewport. A half section's further
113    /// planes are not carried: a `/3DCrossSection` cannot keep what either
114    /// plane keeps, so the reader cuts the first plane only.
115    pub section: Option<([f64; 3], [f64; 3])>,
116}
117
118/// The file's 3D content: the U3D bytes, the node names in stream order, and
119/// every saved view that has a camera.
120#[derive(Debug, Clone, PartialEq)]
121pub struct Model3d {
122    pub u3d: Vec<u8>,
123    pub nodes: Vec<String>,
124    pub views: Vec<View3d>,
125    /// How many annotations are drawn, over every view.
126    pub annotations: usize,
127}
128
129impl Model3d {
130    pub fn view(&self, id: &str) -> Option<&View3d> {
131        self.views.iter().find(|view| view.id == id)
132    }
133}
134
135/// A node name a PDF `/3DNode /N` can match exactly: printable ASCII, unique.
136fn node_name(wanted: &str, taken: &mut std::collections::HashSet<String>) -> String {
137    let base: String = wanted
138        .chars()
139        .map(|c| if (' '..='~').contains(&c) { c } else { '_' })
140        .collect::<String>()
141        .trim()
142        .to_string();
143    let base = if base.is_empty() { "node".to_string() } else { base };
144    let mut name = base.clone();
145    let mut n = 2;
146    while !taken.insert(name.clone()) {
147        name = format!("{base} #{n}");
148        n += 1;
149    }
150    name
151}
152
153fn material_for(rgb: [u8; 3], lit_line: bool) -> Material {
154    let c = rgb.map(|v| f32::from(v) / 255.0);
155    Material {
156        name: format!("C{:02X}{:02X}{:02X}{}", rgb[0], rgb[1], rgb[2], if lit_line { "L" } else { "" }),
157        ambient: c.map(|v| v * 0.3),
158        diffuse: c,
159        specular: [0.15; 3],
160        // A line has no surface to light: it shows its own colour.
161        emissive: if lit_line { c } else { [0.0; 3] },
162        reflectivity: 0.1,
163        opacity: 1.0,
164    }
165}
166
167/// The index of `rgb`'s material, added once.
168fn material_index(scene: &mut Scene, rgb: [u8; 3], line: bool) -> usize {
169    let wanted = material_for(rgb, line);
170    if let Some(index) = scene.materials.iter().position(|m| m.name == wanted.name) {
171        return index;
172    }
173    scene.materials.push(wanted);
174    scene.materials.len() - 1
175}
176
177fn to_f32(p: [f64; 3]) -> [f32; 3] {
178    [p[0] as f32, p[1] as f32, p[2] as f32]
179}
180
181/// Polylines as one line set: consecutive points joined, zero-length steps
182/// dropped.
183fn line_set(polylines: &[Vec<[f32; 3]>]) -> Option<Lines> {
184    let mut lines = Lines::default();
185    for polyline in polylines {
186        let mut previous: Option<u32> = None;
187        for p in polyline {
188            if !p.iter().all(|c| c.is_finite()) {
189                previous = None;
190                continue;
191            }
192            if let Some(prev) = previous {
193                if lines.positions[prev as usize] == *p {
194                    continue;
195                }
196            }
197            let index = lines.positions.len() as u32;
198            lines.positions.push(*p);
199            if let Some(prev) = previous {
200                lines.segments.push(([prev, index], 0));
201            }
202            previous = Some(index);
203        }
204    }
205    (!lines.segments.is_empty()).then_some(lines)
206}
207
208/// Triangles as a flat-shaded mesh: three vertices each, carrying the face
209/// normal.
210fn triangle_mesh(triangles: &[[[f64; 3]; 3]]) -> Option<Mesh> {
211    let mut mesh = Mesh::default();
212    for t in triangles {
213        let u = [t[1][0] - t[0][0], t[1][1] - t[0][1], t[1][2] - t[0][2]];
214        let v = [t[2][0] - t[0][0], t[2][1] - t[0][1], t[2][2] - t[0][2]];
215        let n = [u[1] * v[2] - u[2] * v[1], u[2] * v[0] - u[0] * v[2], u[0] * v[1] - u[1] * v[0]];
216        let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
217        if !(len > 1e-12) {
218            continue;
219        }
220        let normal = to_f32([n[0] / len, n[1] / len, n[2] / len]);
221        let base = mesh.positions.len() as u32;
222        for corner in t {
223            mesh.positions.push(to_f32(*corner));
224            mesh.normals.push(normal);
225        }
226        mesh.triangles.push(([base, base + 1, base + 2], 0));
227    }
228    (!mesh.triangles.is_empty()).then_some(mesh)
229}
230
231use crate::geometry3d::sub3 as sub;
232use crate::geometry3d::cross3 as cross;
233use crate::geometry3d::dot3 as dot;
234use crate::geometry3d::normalize3 as unit;
235
236/// The camera's right, up and forward (eye → target) unit vectors; `None`
237/// for a degenerate camera (eye on target, or up along the view).
238pub fn camera_frame(camera: &PmiCamera) -> Option<([f64; 3], [f64; 3], [f64; 3])> {
239    let forward = unit(sub(camera.target, camera.eye))?;
240    let right = unit(cross(forward, camera.up))?;
241    let up = cross(right, forward);
242    Some((right, up, forward))
243}
244
245/// Build the 3D model: the solids, then every saved view's annotations.
246pub fn build(bodies: &[Body], pmi: &PmiState, report: Option<&PmiReport>) -> Result<Model3d, String> {
247    let mut scene = Scene::default();
248    let mut taken = std::collections::HashSet::new();
249    // Per node: the scene object it belongs to (a solid name) or the view it
250    // annotates — what decides its visibility in each view.
251    enum Owner {
252        Solid(String),
253        View(String),
254    }
255    let mut owners: Vec<Owner> = Vec::new();
256
257    for body in bodies {
258        if body.indices.len() < 3 || body.positions.is_empty() {
259            continue;
260        }
261        let mut shaders: Vec<usize> = Vec::new();
262        let mut mesh = Mesh { positions: body.positions.clone(), normals: body.normals.clone(), triangles: Vec::new() };
263        for (index, corners) in body.indices.chunks_exact(3).enumerate() {
264            let rgb = body.triangle_colors.get(index).copied().unwrap_or([0xB0, 0xB4, 0xBC]);
265            let material = material_index(&mut scene, rgb, false);
266            let shading = match shaders.iter().position(|m| *m == material) {
267                Some(slot) => slot,
268                None => {
269                    shaders.push(material);
270                    shaders.len() - 1
271                }
272            };
273            mesh.triangles.push(([corners[0], corners[1], corners[2]], shading as u32));
274        }
275        if mesh.triangles.is_empty() {
276            continue;
277        }
278        let name = node_name(&body.name, &mut taken);
279        scene.models.push(Model { name, geometry: Geometry::Mesh(mesh), shaders, two_sided: false });
280        owners.push(Owner::Solid(body.name.clone()));
281        if let Some(lines) = line_set(&body.edges) {
282            let material = material_index(&mut scene, EDGE_RGB, true);
283            let name = node_name(&format!("{} edges", body.name), &mut taken);
284            scene.models.push(Model { name, geometry: Geometry::Lines(lines), shaders: vec![material], two_sided: false });
285            owners.push(Owner::Solid(body.name.clone()));
286        }
287    }
288    if scene.models.is_empty() {
289        return Err("the scene has no solid to show in 3D".into());
290    }
291
292    let mut annotations = 0;
293    let cameras: Vec<(&PmiView, &PmiCamera)> =
294        pmi.views.iter().filter_map(|view| view.camera.as_ref().map(|camera| (view, camera))).collect();
295    for (view, camera) in &cameras {
296        let Some(rows) = report.and_then(|report| report.view(&view.id)) else { continue };
297        for (row, drawn) in view_annotations(view, camera, rows) {
298            let polylines: Vec<Vec<[f32; 3]>> = drawn.lines.iter().map(|line| line.iter().copied().map(to_f32).collect()).collect();
299            let lines = line_set(&polylines);
300            let fill = triangle_mesh(&drawn.arrows);
301            if lines.is_none() && fill.is_none() {
302                continue;
303            }
304            annotations += 1;
305            let base = format!("PMI {} {}", view.id, row.id);
306            if let Some(lines) = lines {
307                let material = material_index(&mut scene, PMI_RGB, true);
308                let name = node_name(&base, &mut taken);
309                scene.models.push(Model { name, geometry: Geometry::Lines(lines), shaders: vec![material], two_sided: false });
310                owners.push(Owner::View(view.id.clone()));
311            }
312            if let Some(fill) = fill {
313                let material = material_index(&mut scene, PMI_RGB, true);
314                let name = node_name(&format!("{base} fill"), &mut taken);
315                scene.models.push(Model { name, geometry: Geometry::Mesh(fill), shaders: vec![material], two_sided: true });
316                owners.push(Owner::View(view.id.clone()));
317            }
318        }
319    }
320
321    let u3d = u3d::write(&scene)?;
322    let nodes: Vec<String> = scene.models.iter().map(|model| model.name.clone()).collect();
323    let mut views: Vec<View3d> = cameras
324        .iter()
325        .map(|(view, camera)| {
326            // The stream holds every solid where it was modelled; a view that
327            // explodes moves them by node, with the same resolved rows the
328            // viewport and the sheets pose by, step after step.
329            let rows = report.and_then(|report| report.view(&view.id));
330            let mut stages: std::collections::HashMap<&str, Vec<PmiPose>> = std::collections::HashMap::new();
331            for row in rows.into_iter().flat_map(|rows| &rows.annotations) {
332                if !row.enabled || row.status != PmiStatus::Ok {
333                    continue;
334                }
335                if let PmiGeometry::Explode { solids, translate, rotate_deg, scale, center, .. } = &row.geometry {
336                    for solid in solids {
337                        stages.entry(solid.as_str()).or_default().push(PmiPose {
338                            center: *center,
339                            translate: *translate,
340                            rotate_deg: *rotate_deg,
341                            scale: *scale,
342                        });
343                    }
344                }
345            }
346            View3d {
347                id: view.id.clone(),
348                name: if view.name.trim().is_empty() { view.id.clone() } else { view.name.clone() },
349                camera: (*camera).clone(),
350                wireframe: view.display.wireframe,
351                visible: owners
352                    .iter()
353                    .map(|owner| match owner {
354                        Owner::Solid(solid) => !view.hides(solid),
355                        Owner::View(id) => *id == view.id,
356                    })
357                    .collect(),
358                poses: owners
359                    .iter()
360                    .map(|owner| match owner {
361                        Owner::Solid(solid) => stages.get(solid.as_str()).map(|stages| stage_matrix(stages)),
362                        Owner::View(_) => None,
363                    })
364                    .collect(),
365                section: view.section_plane_in(rows),
366            }
367        })
368        .collect();
369    // A document with no saved view still opens on SOMETHING: an isometric
370    // view fitted to the solids, every solid shown.
371    if views.is_empty() {
372        views.push(View3d {
373            id: ISOMETRIC.into(),
374            name: "Isometric".into(),
375            camera: isometric_camera(bodies),
376            wireframe: false,
377            visible: owners.iter().map(|owner| matches!(owner, Owner::Solid(_))).collect(),
378            poses: vec![None; owners.len()],
379            section: None,
380        });
381    }
382    Ok(Model3d { u3d, nodes, views, annotations })
383}
384
385/// One annotation laid out in world space as a 3D view draws it.
386#[derive(Debug, Clone, Default, PartialEq)]
387pub struct DrawnAnnotation {
388    /// Dimension, extension and leader lines, frames, and the text stroked
389    /// with the PMI font, as open polylines.
390    pub lines: Vec<Vec<[f64; 3]>>,
391    /// The filled arrowheads, as triangles.
392    pub arrows: Vec<[[f64; 3]; 3]>,
393}
394
395/// Every drawable annotation of a saved view (`rows`, its report) laid out
396/// for a 3D model shown from `camera`: sized as the viewport sized them when
397/// the view was saved (the overlay's pixel sizes times the saved camera's
398/// world per pixel; a model-unit line cannot stay a constant size on screen,
399/// so this is exact at the view's own framing and scales with the model when
400/// the reader zooms), a view-aligned row laid out in the view-parallel plane
401/// through its label, facing the camera, as the STEP presentation lays it
402/// out. The 3D PDF and the instructions player both draw these.
403pub fn view_annotations<'a>(view: &PmiView, camera: &PmiCamera, rows: &'a brep_kernel::PmiViewReport) -> Vec<(&'a PmiAnnotationReport, DrawnAnnotation)> {
404    let Some((right, _, forward)) = camera_frame(camera) else { return Vec::new() };
405    let wpp = world_per_pixel(camera);
406    let text_height = TEXT_PX_AT_12PT * wpp * (view.display.text_size_pt / 12.0);
407    let style = PmiLayoutStyle { arrow: ARROW_PX * wpp, text_height, view_dir: forward, view_up: camera.up, plane: None };
408    let mut out = Vec::new();
409    for row in &rows.annotations {
410        if !drawable(row) {
411            continue;
412        }
413        let row_style = PmiLayoutStyle {
414            plane: row.plane.or(Some(PmiPlane { origin: row.label_world, normal: [-forward[0], -forward[1], -forward[2]], x_axis: right })),
415            ..style
416        };
417        let drawn = pmi_present(&row.geometry, row.label_world, &row.text, &row_style);
418        let mut lines: Vec<Vec<[f64; 3]>> = drawn.polylines.iter().chain(&drawn.frames).cloned().collect();
419        for run in &drawn.texts {
420            lines.extend(run.strokes());
421        }
422        lines.retain(|line| line.len() >= 2);
423        if lines.is_empty() && drawn.arrows.is_empty() {
424            continue;
425        }
426        out.push((row, DrawnAnnotation { lines, arrows: drawn.arrows }));
427    }
428    out
429}
430
431/// A solid's explode stages as a `/3DNode /M`: the matrix's three columns,
432/// then its translation, the order `/C2W` is written in. Every stage is
433/// affine, so the composition is read off where it takes the origin and the
434/// three unit points.
435pub fn stage_matrix(stages: &[PmiPose]) -> [f64; 12] {
436    let apply = |point: [f64; 3]| stages.iter().fold(point, |p, stage| stage.apply(p));
437    let origin = apply([0.0; 3]);
438    let mut matrix = [0.0; 12];
439    for (axis, unit) in [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]].into_iter().enumerate() {
440        let column = sub(apply(unit), origin);
441        matrix[axis * 3..axis * 3 + 3].copy_from_slice(&column);
442    }
443    matrix[9..].copy_from_slice(&origin);
444    matrix
445}
446
447/// A sectioned view's cut as the `/3DCrossSection` its `/SA` lists (PDF
448/// 1.7): centred on `point`, the default plane (normal +x) turned by `/O
449/// [null β γ]`, β about y then γ about z, onto `normal`. The plane itself is
450/// not drawn (`/PO 0`); its intersection with the model is. Which side a
451/// reader removes is not stated by the standard and has not been checked in
452/// a reader here.
453fn cross_section(point: [f64; 3], normal: [f64; 3]) -> String {
454    let n = unit(normal).unwrap_or([1.0, 0.0, 0.0]);
455    let beta = (-n[2]).clamp(-1.0, 1.0).asin().to_degrees();
456    let gamma = n[1].atan2(n[0]).to_degrees();
457    format!(
458        "<< /Type /3DCrossSection /C [{} {} {}] /O [null {} {}] /PO 0 /IV true >>",
459        num(point[0]),
460        num(point[1]),
461        num(point[2]),
462        num(beta),
463        num(gamma)
464    )
465}
466
467/// The id of the view a document without saved views opens on.
468pub const ISOMETRIC: &str = "ISOMETRIC";
469
470/// Looking down the (−1, −1, −1) diagonal at the solids' box centre, +Y up
471/// (the application's), orthographic, the bounding sphere filling the height.
472pub fn isometric_camera(bodies: &[Body]) -> PmiCamera {
473    let mut min = [f64::INFINITY; 3];
474    let mut max = [f64::NEG_INFINITY; 3];
475    for p in bodies.iter().flat_map(|body| body.positions.iter()) {
476        for axis in 0..3 {
477            min[axis] = min[axis].min(f64::from(p[axis]));
478            max[axis] = max[axis].max(f64::from(p[axis]));
479        }
480    }
481    if !min[0].is_finite() {
482        (min, max) = ([-1.0; 3], [1.0; 3]);
483    }
484    let centre = [(min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5, (min[2] + max[2]) * 0.5];
485    let radius = (dot(sub(max, min), sub(max, min)).sqrt() * 0.5).max(1e-3);
486    let d = radius * 4.0 / 3f64.sqrt();
487    PmiCamera {
488        eye: [centre[0] + d, centre[1] + d, centre[2] + d],
489        target: centre,
490        up: [0.0, 1.0, 0.0],
491        projection: PmiProjection::Orthographic { half_height: radius * 1.1 },
492        viewport: [1280.0, 800.0],
493    }
494}
495
496/// A report row the 3D model draws: enabled, resolved, and not an explode
497/// or hide step (which move or hide solids rather than annotating them, and
498/// which the STEP presentation leaves out for the same reason).
499fn drawable(row: &PmiAnnotationReport) -> bool {
500    row.enabled && row.status == PmiStatus::Ok && !matches!(row.geometry, PmiGeometry::Explode { .. } | PmiGeometry::Hide { .. })
501}
502
503// ---------------------------------------------------------------------------
504// The PDF dictionaries
505// ---------------------------------------------------------------------------
506
507fn num(value: f64) -> String {
508    let text = format!("{value:.6}");
509    let text = text.trim_end_matches('0').trim_end_matches('.');
510    if text == "-0" || text.is_empty() {
511        "0".into()
512    } else {
513        text.into()
514    }
515}
516
517/// The 12-element camera-to-world matrix and the distance to the centre of
518/// orbit (the camera's target).
519pub fn c2w(camera: &PmiCamera) -> Option<([f64; 12], f64)> {
520    let (right, up, forward) = camera_frame(camera)?;
521    let y = if CAMERA_Y_UP { up } else { [-up[0], -up[1], -up[2]] };
522    let e = camera.eye;
523    let m = [right[0], right[1], right[2], y[0], y[1], y[2], forward[0], forward[1], forward[2], e[0], e[1], e[2]];
524    Some((m, dot(sub(camera.target, camera.eye), forward)))
525}
526
527/// A view's projection: a perspective camera keeps its field of view; an
528/// orthographic one is scaled so its saved half height fills half of a 3D
529/// box `box_height_pt` tall, unless `scale` fixes it.
530pub fn projection(camera: &PmiCamera, box_height_pt: f64, scale: Option<f64>) -> Projection3d {
531    match (&camera.projection, scale) {
532        (_, Some(scale)) => Projection3d::Orthographic { scale },
533        (PmiProjection::Perspective { fov_y_deg }, None) => Projection3d::Perspective { fov_y_deg: *fov_y_deg },
534        (PmiProjection::Orthographic { half_height }, None) => {
535            Projection3d::Orthographic { scale: box_height_pt * 0.5 / half_height.max(1e-9) }
536        }
537    }
538}
539
540/// A `/3DView` dictionary: `name` in the reader's menu, the view's camera,
541/// `projection`, its render mode, a CAD lighting scheme, and every node's
542/// visibility.
543pub fn view_dict(model: &Model3d, view: &View3d, name: &str, projection: Projection3d) -> String {
544    let (matrix, orbit) = c2w(&view.camera).unwrap_or(([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0], 1.0));
545    let matrix: Vec<String> = matrix.iter().map(|v| num(*v)).collect();
546    let projection = match projection {
547        Projection3d::Orthographic { scale } => format!("<< /Subtype /O /OS {} >>", num(scale)),
548        Projection3d::Perspective { fov_y_deg } => {
549            format!("<< /Subtype /P /FOV {} /PS /H >>", num(fov_y_deg.clamp(1.0, 179.0)))
550        }
551    };
552    let nodes: Vec<String> = model
553        .nodes
554        .iter()
555        .zip(&view.visible)
556        .zip(view.poses.iter().chain(std::iter::repeat(&None)))
557        .map(|((node, visible), pose)| match pose {
558            Some(pose) => {
559                let pose: Vec<String> = pose.iter().map(|v| num(*v)).collect();
560                format!("<< /Type /3DNode /N ({}) /V {visible} /M [{}] >>", string_literal(node), pose.join(" "))
561            }
562            None => format!("<< /Type /3DNode /N ({}) /V {visible} >>", string_literal(node)),
563        })
564        .collect();
565    let section = view
566        .section
567        .map(|(point, normal)| format!(" /SA [{}]", cross_section(point, normal)))
568        .unwrap_or_default();
569    format!(
570        "<< /Type /3DView /XN ({}) /IN ({}) /MS /M /C2W [{}] /CO {} /P {} \
571         /BG << /Type /3DBG /Subtype /SC /C [1 1 1] >> \
572         /RM << /Type /3DRenderMode /Subtype /{} >> \
573         /LS << /Type /3DLightingScheme /Subtype /Hard >>{} /NR true /NA [{}] >>",
574        string_literal(name),
575        string_literal(&view.id),
576        matrix.join(" "),
577        num(orbit.max(0.0)),
578        projection,
579        if view.wireframe { "Wireframe" } else { "Solid" },
580        section,
581        nodes.join(" ")
582    )
583}
584
585/// The 3D stream: every saved view in `/VA`, the default the first, and the
586/// U3D bytes ASCII85-encoded so the file stays ASCII (the PDF writer's
587/// contract).
588pub fn stream_object(model: &Model3d, va_box_height_pt: f64) -> String {
589    let views: Vec<String> = model
590        .views
591        .iter()
592        .map(|view| view_dict(model, view, &view.name, projection(&view.camera, va_box_height_pt, None)))
593        .collect();
594    // The stream's data ends with its own newline, counted in /Length — the
595    // PDF writer's convention for every stream it writes.
596    let data = ascii85(&model.u3d) + "\n";
597    format!(
598        "<< /Type /3D /Subtype /U3D /Filter /ASCII85Decode /DV 0 /VA [{}] /Length {} >>\nstream\n{}endstream",
599        views.join(" "),
600        data.len(),
601        data
602    )
603}
604
605/// ASCII85 (ISO 32000-1 §7.4.3), `~>`-terminated, in 76-character lines.
606pub fn ascii85(bytes: &[u8]) -> String {
607    let mut out = String::with_capacity(bytes.len() * 5 / 4 + bytes.len() / 60 + 4);
608    let mut column = 0;
609    let mut push = |c: char, out: &mut String| {
610        out.push(c);
611        column += 1;
612        if column == 76 {
613            out.push('\n');
614            column = 0;
615        }
616    };
617    for chunk in bytes.chunks(4) {
618        let mut word = [0u8; 4];
619        word[..chunk.len()].copy_from_slice(chunk);
620        let value = u32::from_be_bytes(word);
621        if chunk.len() == 4 && value == 0 {
622            push('z', &mut out);
623            continue;
624        }
625        let mut digits = [0u8; 5];
626        let mut v = value;
627        for digit in digits.iter_mut().rev() {
628            *digit = (v % 85) as u8;
629            v /= 85;
630        }
631        for digit in &digits[..chunk.len() + 1] {
632            push((b'!' + digit) as char, &mut out);
633        }
634    }
635    out.push_str("~>");
636    out
637}
638
639/// A 3D annotation's rectangle in PDF points: centred on `centre_mm` (sheet
640/// millimetres from the top-left), `half_mm` either way, on a page
641/// `page_height_mm` tall.
642pub fn rect_pt(centre_mm: [f64; 2], half_mm: [f64; 2], page_height_mm: f64) -> [f64; 4] {
643    [
644        (centre_mm[0] - half_mm[0]) * PT_PER_MM,
645        (page_height_mm - centre_mm[1] - half_mm[1]) * PT_PER_MM,
646        (centre_mm[0] + half_mm[0]) * PT_PER_MM,
647        (page_height_mm - centre_mm[1] + half_mm[1]) * PT_PER_MM,
648    ]
649}
650
651// ---------------------------------------------------------------------------
652// Where the model is shown
653// ---------------------------------------------------------------------------
654
655/// Everything 3D a sheet PDF carries.
656#[derive(Debug, Clone)]
657pub struct Pdf3d {
658    pub model: Model3d,
659    pub placements: Vec<Placement3d>,
660    /// A last page given over to the model.
661    pub page: Option<Page3d>,
662}
663
664/// A placement drawn live: a 3D box over the placement's drawing, opening on
665/// the placement's own camera at the placement's own scale, so activating it
666/// changes nothing until the reader turns the model.
667#[derive(Debug, Clone, PartialEq)]
668pub struct Placement3d {
669    /// The sheet's index among the file's pages.
670    pub page: usize,
671    /// The placement id (`SV2`).
672    pub id: String,
673    /// The saved view it places (`VIEW1`).
674    pub view: String,
675    /// Sheet millimetres from the top-left: the camera target's point, and the
676    /// box's half extents about it.
677    pub centre_mm: [f64; 2],
678    pub half_mm: [f64; 2],
679    /// Paper millimetres per model unit.
680    pub scale: f64,
681}
682
683/// A view button on the 3D page.
684#[derive(Debug, Clone, PartialEq)]
685pub struct Button {
686    /// `[x0, y0, x1, y1]`, sheet millimetres from the top-left.
687    pub rect_mm: [f64; 4],
688    pub label: String,
689    /// The view's index in [`Model3d::views`] (and so in `/VA`).
690    pub view: usize,
691}
692
693/// The 3D page: a title, the 3D box, a button per saved view, a caption.
694#[derive(Debug, Clone)]
695pub struct Page3d {
696    pub width_mm: f64,
697    pub height_mm: f64,
698    pub title: String,
699    pub title_at: [f64; 2],
700    pub title_mm: f64,
701    /// `[x0, y0, x1, y1]` of the 3D box, sheet millimetres from the top-left.
702    pub box_mm: [f64; 4],
703    /// The view the box opens on — the first saved view with a camera.
704    pub default_view: usize,
705    /// The default view drawn as a sheet placement at the centre of the box —
706    /// what a reader without 3D shows. `None` when there is nothing to draw.
707    pub poster: Option<ViewDrawing>,
708    /// The poster's scale (paper millimetres per model unit): the box opens at
709    /// it, so activating the model does not jump.
710    pub poster_scale: Option<f64>,
711    pub buttons: Vec<Button>,
712    pub button_text_mm: f64,
713    pub caption: Vec<String>,
714    pub caption_at: Vec<[f64; 2]>,
715    pub caption_mm: f64,
716}
717
718use super::project::ViewDrawing;
719
720const PAGE_MARGIN_MM: f64 = 12.0;
721const TITLE_MM: f64 = 5.0;
722const BUTTON_H_MM: f64 = 8.0;
723const BUTTON_GAP_MM: f64 = 3.0;
724const BUTTON_TEXT_MM: f64 = 2.8;
725const BUTTONS_PER_ROW: usize = 8;
726const CAPTION_MM: f64 = 2.8;
727
728/// The paragraph under the 3D box — the truth about who can show it.
729pub const CAPTION: [&str; 2] = [
730    "Interactive 3D model: open this file in Adobe Acrobat or Reader (with 3D content enabled) or in Foxit to rotate, zoom and switch PMI views.",
731    "Other PDF viewers show the drawing above in place of the model.",
732];
733
734impl Page3d {
735    /// The page laid out on paper `width_mm` × `height_mm`: the title at the
736    /// top, the caption at the foot, a row of buttons above it (wrapping
737    /// every `BUTTONS_PER_ROW`), and the box filling what is left.
738    pub fn layout(width_mm: f64, height_mm: f64, title: &str, model: &Model3d) -> Page3d {
739        let (w, h) = (width_mm.max(100.0), height_mm.max(100.0));
740        let inner = w - 2.0 * PAGE_MARGIN_MM;
741        let caption_at: Vec<[f64; 2]> = (0..CAPTION.len())
742            .map(|line| [w * 0.5, h - PAGE_MARGIN_MM - (CAPTION.len() - 1 - line) as f64 * CAPTION_MM * 1.8])
743            .collect();
744        let rows = model.views.len().div_ceil(BUTTONS_PER_ROW);
745        let per_row = model.views.len().min(BUTTONS_PER_ROW).max(1);
746        let button_w = ((inner - (per_row - 1) as f64 * BUTTON_GAP_MM) / per_row as f64).min(48.0);
747        let buttons_bottom = caption_at[0][1] - CAPTION_MM * 2.0;
748        let buttons_top = buttons_bottom - rows as f64 * (BUTTON_H_MM + BUTTON_GAP_MM) + BUTTON_GAP_MM;
749        let buttons = model
750            .views
751            .iter()
752            .enumerate()
753            .map(|(index, view)| {
754                let (row, column) = (index / BUTTONS_PER_ROW, index % BUTTONS_PER_ROW);
755                let in_row = (model.views.len() - row * BUTTONS_PER_ROW).min(BUTTONS_PER_ROW);
756                let row_w = in_row as f64 * button_w + (in_row - 1) as f64 * BUTTON_GAP_MM;
757                let x0 = (w - row_w) * 0.5 + column as f64 * (button_w + BUTTON_GAP_MM);
758                let y0 = buttons_top + row as f64 * (BUTTON_H_MM + BUTTON_GAP_MM);
759                Button { rect_mm: [x0, y0, x0 + button_w, y0 + BUTTON_H_MM], label: view.name.clone(), view: index }
760            })
761            .collect();
762        let box_top = PAGE_MARGIN_MM + TITLE_MM * 2.0;
763        let box_bottom = if rows > 0 { buttons_top - BUTTON_GAP_MM * 2.0 } else { buttons_bottom };
764        Page3d {
765            width_mm: w,
766            height_mm: h,
767            title: title.to_string(),
768            title_at: [w * 0.5, PAGE_MARGIN_MM + TITLE_MM * 0.5],
769            title_mm: TITLE_MM,
770            box_mm: [PAGE_MARGIN_MM, box_top, w - PAGE_MARGIN_MM, box_bottom.max(box_top + 20.0)],
771            default_view: 0,
772            poster: None,
773            poster_scale: None,
774            buttons,
775            button_text_mm: BUTTON_TEXT_MM,
776            caption: CAPTION.iter().map(|line| line.to_string()).collect(),
777            caption_at,
778            caption_mm: CAPTION_MM,
779        }
780    }
781
782    pub fn box_centre_mm(&self) -> [f64; 2] {
783        [(self.box_mm[0] + self.box_mm[2]) * 0.5, (self.box_mm[1] + self.box_mm[3]) * 0.5]
784    }
785
786    pub fn box_half_mm(&self) -> [f64; 2] {
787        [(self.box_mm[2] - self.box_mm[0]) * 0.5, (self.box_mm[3] - self.box_mm[1]) * 0.5]
788    }
789}
790
791/// The object numbers of every 3D object, assigned before any is written so
792/// the page dictionaries can name their annotations.
793#[derive(Debug, Clone)]
794pub struct Plan {
795    stream: usize,
796    /// Per placement: (annotation, appearance).
797    placements: Vec<(usize, usize)>,
798    /// The 3D page's (annotation, appearance, links).
799    page: Option<(usize, usize, Vec<usize>)>,
800    /// Which page each placement sits on.
801    placement_pages: Vec<usize>,
802    /// The 3D page's index among the file's pages.
803    page_index: usize,
804}
805
806fn rect_text(r: [f64; 4]) -> String {
807    format!("[{:.4} {:.4} {:.4} {:.4}]", r[0], r[1], r[2], r[3])
808}
809
810/// An appearance that draws nothing: the page's own marks show through until
811/// the reader activates the model.
812fn empty_appearance(rect: [f64; 4]) -> String {
813    format!(
814        "<< /Type /XObject /Subtype /Form /BBox [0 0 {:.4} {:.4}] /Length 1 >>\nstream\n\nendstream",
815        rect[2] - rect[0],
816        rect[3] - rect[1]
817    )
818}
819
820impl Plan {
821    /// Numbers from `next` on, for the 3D content of `three_d` in a file of
822    /// `sheets` sheet pages (the 3D page, when there is one, follows them).
823    pub fn new(three_d: &Pdf3d, mut next: usize, sheets: usize) -> Plan {
824        let stream = next;
825        next += 1;
826        let placements = three_d
827            .placements
828            .iter()
829            .map(|_| {
830                next += 2;
831                (next - 2, next - 1)
832            })
833            .collect();
834        let page = three_d.page.as_ref().map(|page| {
835            let annot = next;
836            let links = (0..page.buttons.len()).map(|i| annot + 2 + i).collect::<Vec<_>>();
837            next += 2 + page.buttons.len();
838            (annot, annot + 1, links)
839        });
840        Plan {
841            stream,
842            placements,
843            page,
844            placement_pages: three_d.placements.iter().map(|p| p.page).collect(),
845            page_index: sheets,
846        }
847    }
848
849    /// ` /Annots […]` for page `index` (a sheet, or — one past the sheets —
850    /// the 3D page), or nothing when it carries none.
851    pub fn annots_on(&self, index: usize) -> String {
852        let mut refs: Vec<String> = self
853            .placements
854            .iter()
855            .zip(&self.placement_pages)
856            .filter(|(_, page)| **page == index)
857            .map(|((annot, _), _)| format!("{annot} 0 R"))
858            .collect();
859        if let Some((annot, _, links)) = &self.page {
860            if index == self.page_index {
861                refs.push(format!("{annot} 0 R"));
862                refs.extend(links.iter().map(|link| format!("{link} 0 R")));
863            }
864        }
865        if refs.is_empty() {
866            String::new()
867        } else {
868            format!(" /Annots [{}]", refs.join(" "))
869        }
870    }
871
872    /// Every 3D object, in object-number order: the stream, each placement's
873    /// annotation and appearance, the 3D page's.
874    pub fn objects(&self, three_d: &Pdf3d, pages: &[&super::project::SheetDrawing], first_page: usize) -> Vec<(usize, String)> {
875        let model = &three_d.model;
876        let placement_box_pt = three_d
877            .placements
878            .iter()
879            .map(|placement| placement.half_mm[1] * 2.0 * PT_PER_MM)
880            .fold(0.0f64, f64::max);
881        let va_box_pt = match &three_d.page {
882            Some(page) => page.box_half_mm()[1] * 2.0 * PT_PER_MM,
883            None if placement_box_pt > 0.0 => placement_box_pt,
884            None => 400.0,
885        };
886        let mut out = vec![(self.stream, stream_object(model, va_box_pt))];
887        for (placement, (annot, appearance)) in three_d.placements.iter().zip(&self.placements) {
888            let page_h = pages.get(placement.page).map_or(297.0, |page| page.height_mm.max(1.0));
889            let rect = rect_pt(placement.centre_mm, placement.half_mm, page_h);
890            let view = model.view(&placement.view);
891            let default = match view {
892                Some(view) => view_dict(
893                    model,
894                    view,
895                    &format!("{} {}", placement.id, view.name),
896                    Projection3d::Orthographic { scale: placement.scale * PT_PER_MM },
897                ),
898                None => "0".to_string(),
899            };
900            out.push((
901                *annot,
902                format!(
903                    "<< /Type /Annot /Subtype /3D /Rect {} /F 4 /P {} 0 R /NM ({}) \
904                     /Contents (Click to turn this view in 3D: {}) /3DD {} 0 R /3DV {} \
905                     /3DA << /A /XA /D /PC /TB true /NP false >> /AP << /N {} 0 R >> >>",
906                    rect_text(rect),
907                    first_page + placement.page * 2,
908                    string_literal(&placement.id),
909                    string_literal(&view.map_or(placement.view.clone(), |view| view.name.clone())),
910                    self.stream,
911                    default,
912                    appearance
913                ),
914            ));
915            out.push((*appearance, empty_appearance(rect)));
916        }
917        if let (Some(page), Some((annot, appearance, links))) = (&three_d.page, &self.page) {
918            let page_number = first_page + self.page_index * 2;
919            let rect = rect_pt(page.box_centre_mm(), page.box_half_mm(), page.height_mm);
920            let default = match (model.views.get(page.default_view), page.poster_scale) {
921                (Some(view), Some(scale)) => {
922                    view_dict(model, view, &view.name, Projection3d::Orthographic { scale: scale * PT_PER_MM })
923                }
924                _ => page.default_view.to_string(),
925            };
926            out.push((
927                *annot,
928                format!(
929                    "<< /Type /Annot /Subtype /3D /Rect {} /F 4 /P {page_number} 0 R /NM (MODEL3D) \
930                     /Contents (The 3D model with its PMI views) /3DD {} 0 R /3DV {default} \
931                     /3DA << /A /PO /D /PC /TB true /NP true >> /AP << /N {appearance} 0 R >> >>",
932                    rect_text(rect),
933                    self.stream
934                ),
935            ));
936            out.push((*appearance, empty_appearance(rect)));
937            for (button, link) in page.buttons.iter().zip(links) {
938                let [x0, y0, x1, y1] = button.rect_mm;
939                let r = rect_pt([(x0 + x1) * 0.5, (y0 + y1) * 0.5], [(x1 - x0) * 0.5, (y1 - y0) * 0.5], page.height_mm);
940                out.push((
941                    *link,
942                    format!(
943                        "<< /Type /Annot /Subtype /Link /Rect {} /Border [0 0 0] /P {page_number} 0 R \
944                         /A << /S /GoTo3DView /TA {annot} 0 R /V {} >> >>",
945                        rect_text(r),
946                        button.view
947                    ),
948                ));
949            }
950        }
951        out
952    }
953}
954
955/// What a PDF carries in 3D, read back from its bytes: the count of 3D
956/// annotations, of views in the stream's `/VA`, of view links, and the U3D
957/// stream's decoded size — what the export door reports about the file.
958#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]
959pub struct Summary {
960    pub annotations: usize,
961    pub views: usize,
962    pub links: usize,
963    #[serde(rename = "u3dBytes")]
964    pub u3d_bytes: usize,
965}
966
967/// [`Summary`] of a file this module wrote; `None` when it carries no 3D.
968pub fn summary(pdf: &[u8]) -> Option<Summary> {
969    let text = String::from_utf8_lossy(pdf);
970    let stream_at = text.find("/Subtype /U3D")?;
971    let dict_end = text[stream_at..].find("\nstream\n")? + stream_at;
972    let dict = &text[stream_at..dict_end];
973    let data_start = dict_end + "\nstream\n".len();
974    let data_end = text[data_start..].find("~>")? + data_start;
975    let data = &text[data_start..data_end];
976    let mut u3d_bytes = 0;
977    let mut group = 0;
978    for c in data.chars().filter(|c| !c.is_whitespace()) {
979        if c == 'z' {
980            u3d_bytes += 4;
981        } else {
982            group += 1;
983            if group == 5 {
984                u3d_bytes += 4;
985                group = 0;
986            }
987        }
988    }
989    if group > 0 {
990        u3d_bytes += group - 1;
991    }
992    Some(Summary {
993        annotations: text.matches("/Subtype /3D /Rect").count(),
994        views: dict.matches("/Type /3DView").count(),
995        links: text.matches("/S /GoTo3DView").count(),
996        u3d_bytes,
997    })
998}
999