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
231fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
232    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
233}
234fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
235    [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
236}
237fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
238    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
239}
240fn unit(a: [f64; 3]) -> Option<[f64; 3]> {
241    let len = dot(a, a).sqrt();
242    (len > 1e-12 && len.is_finite()).then(|| [a[0] / len, a[1] / len, a[2] / len])
243}
244
245/// The camera's right, up and forward (eye → target) unit vectors; `None`
246/// for a degenerate camera (eye on target, or up along the view).
247pub fn camera_frame(camera: &PmiCamera) -> Option<([f64; 3], [f64; 3], [f64; 3])> {
248    let forward = unit(sub(camera.target, camera.eye))?;
249    let right = unit(cross(forward, camera.up))?;
250    let up = cross(right, forward);
251    Some((right, up, forward))
252}
253
254/// Build the 3D model: the solids, then every saved view's annotations.
255pub fn build(bodies: &[Body], pmi: &PmiState, report: Option<&PmiReport>) -> Result<Model3d, String> {
256    let mut scene = Scene::default();
257    let mut taken = std::collections::HashSet::new();
258    // Per node: the scene object it belongs to (a solid name) or the view it
259    // annotates — what decides its visibility in each view.
260    enum Owner {
261        Solid(String),
262        View(String),
263    }
264    let mut owners: Vec<Owner> = Vec::new();
265
266    for body in bodies {
267        if body.indices.len() < 3 || body.positions.is_empty() {
268            continue;
269        }
270        let mut shaders: Vec<usize> = Vec::new();
271        let mut mesh = Mesh { positions: body.positions.clone(), normals: body.normals.clone(), triangles: Vec::new() };
272        for (index, corners) in body.indices.chunks_exact(3).enumerate() {
273            let rgb = body.triangle_colors.get(index).copied().unwrap_or([0xB0, 0xB4, 0xBC]);
274            let material = material_index(&mut scene, rgb, false);
275            let shading = match shaders.iter().position(|m| *m == material) {
276                Some(slot) => slot,
277                None => {
278                    shaders.push(material);
279                    shaders.len() - 1
280                }
281            };
282            mesh.triangles.push(([corners[0], corners[1], corners[2]], shading as u32));
283        }
284        if mesh.triangles.is_empty() {
285            continue;
286        }
287        let name = node_name(&body.name, &mut taken);
288        scene.models.push(Model { name, geometry: Geometry::Mesh(mesh), shaders, two_sided: false });
289        owners.push(Owner::Solid(body.name.clone()));
290        if let Some(lines) = line_set(&body.edges) {
291            let material = material_index(&mut scene, EDGE_RGB, true);
292            let name = node_name(&format!("{} edges", body.name), &mut taken);
293            scene.models.push(Model { name, geometry: Geometry::Lines(lines), shaders: vec![material], two_sided: false });
294            owners.push(Owner::Solid(body.name.clone()));
295        }
296    }
297    if scene.models.is_empty() {
298        return Err("the scene has no solid to show in 3D".into());
299    }
300
301    let mut annotations = 0;
302    let cameras: Vec<(&PmiView, &PmiCamera)> =
303        pmi.views.iter().filter_map(|view| view.camera.as_ref().map(|camera| (view, camera))).collect();
304    for (view, camera) in &cameras {
305        let Some(rows) = report.and_then(|report| report.view(&view.id)) else { continue };
306        let Some((right, _, forward)) = camera_frame(camera) else { continue };
307        // Sized as the viewport sized them when the view was saved: the
308        // overlay's pixel sizes times the saved camera's world per pixel. The
309        // model units a U3D line lives in cannot stay a constant size on
310        // screen, so this is exact at the view's own framing and scales with
311        // the model when the reader zooms.
312        let wpp = world_per_pixel(camera);
313        let text_height = TEXT_PX_AT_12PT * wpp * (view.display.text_size_pt / 12.0);
314        let style = PmiLayoutStyle {
315            arrow: ARROW_PX * wpp,
316            text_height,
317            view_dir: forward,
318            view_up: camera.up,
319            plane: None,
320        };
321        for row in &rows.annotations {
322            if !drawable(row) {
323                continue;
324            }
325            // A view-aligned row is laid out in the view-parallel plane
326            // through its label, facing the camera — the STEP presentation's
327            // rule, so the two 3D exports draw the same thing.
328            let row_style = PmiLayoutStyle {
329                plane: row.plane.or(Some(PmiPlane {
330                    origin: row.label_world,
331                    normal: [-forward[0], -forward[1], -forward[2]],
332                    x_axis: right,
333                })),
334                ..style
335            };
336            let drawn = pmi_present(&row.geometry, row.label_world, &row.text, &row_style);
337            let mut polylines: Vec<Vec<[f32; 3]>> =
338                drawn.polylines.iter().chain(&drawn.frames).map(|line| line.iter().copied().map(to_f32).collect()).collect();
339            for run in &drawn.texts {
340                polylines.extend(run.strokes().into_iter().map(|stroke| stroke.into_iter().map(to_f32).collect()));
341            }
342            let lines = line_set(&polylines);
343            let fill = triangle_mesh(&drawn.arrows);
344            if lines.is_none() && fill.is_none() {
345                continue;
346            }
347            annotations += 1;
348            let base = format!("PMI {} {}", view.id, row.id);
349            if let Some(lines) = lines {
350                let material = material_index(&mut scene, PMI_RGB, true);
351                let name = node_name(&base, &mut taken);
352                scene.models.push(Model { name, geometry: Geometry::Lines(lines), shaders: vec![material], two_sided: false });
353                owners.push(Owner::View(view.id.clone()));
354            }
355            if let Some(fill) = fill {
356                let material = material_index(&mut scene, PMI_RGB, true);
357                let name = node_name(&format!("{base} fill"), &mut taken);
358                scene.models.push(Model { name, geometry: Geometry::Mesh(fill), shaders: vec![material], two_sided: true });
359                owners.push(Owner::View(view.id.clone()));
360            }
361        }
362    }
363
364    let u3d = u3d::write(&scene)?;
365    let nodes: Vec<String> = scene.models.iter().map(|model| model.name.clone()).collect();
366    let mut views: Vec<View3d> = cameras
367        .iter()
368        .map(|(view, camera)| {
369            // The stream holds every solid where it was modelled; a view that
370            // explodes moves them by node, with the same resolved rows the
371            // viewport and the sheets pose by, step after step.
372            let rows = report.and_then(|report| report.view(&view.id));
373            let mut stages: std::collections::HashMap<&str, Vec<PmiPose>> = std::collections::HashMap::new();
374            for row in rows.into_iter().flat_map(|rows| &rows.annotations) {
375                if !row.enabled || row.status != PmiStatus::Ok {
376                    continue;
377                }
378                if let PmiGeometry::Explode { solids, translate, rotate_deg, scale, center, .. } = &row.geometry {
379                    for solid in solids {
380                        stages.entry(solid.as_str()).or_default().push(PmiPose {
381                            center: *center,
382                            translate: *translate,
383                            rotate_deg: *rotate_deg,
384                            scale: *scale,
385                        });
386                    }
387                }
388            }
389            View3d {
390                id: view.id.clone(),
391                name: if view.name.trim().is_empty() { view.id.clone() } else { view.name.clone() },
392                camera: (*camera).clone(),
393                wireframe: view.display.wireframe,
394                visible: owners
395                    .iter()
396                    .map(|owner| match owner {
397                        Owner::Solid(solid) => !view.display.hidden.iter().any(|hidden| hidden == solid),
398                        Owner::View(id) => *id == view.id,
399                    })
400                    .collect(),
401                poses: owners
402                    .iter()
403                    .map(|owner| match owner {
404                        Owner::Solid(solid) => stages.get(solid.as_str()).map(|stages| stage_matrix(stages)),
405                        Owner::View(_) => None,
406                    })
407                    .collect(),
408                section: view.section_plane_in(rows),
409            }
410        })
411        .collect();
412    // A document with no saved view still opens on SOMETHING: an isometric
413    // view fitted to the solids, every solid shown.
414    if views.is_empty() {
415        views.push(View3d {
416            id: ISOMETRIC.into(),
417            name: "Isometric".into(),
418            camera: isometric_camera(bodies),
419            wireframe: false,
420            visible: owners.iter().map(|owner| matches!(owner, Owner::Solid(_))).collect(),
421            poses: vec![None; owners.len()],
422            section: None,
423        });
424    }
425    Ok(Model3d { u3d, nodes, views, annotations })
426}
427
428/// A solid's explode stages as a `/3DNode /M`: the matrix's three columns,
429/// then its translation, the order `/C2W` is written in. Every stage is
430/// affine, so the composition is read off where it takes the origin and the
431/// three unit points.
432pub fn stage_matrix(stages: &[PmiPose]) -> [f64; 12] {
433    let apply = |point: [f64; 3]| stages.iter().fold(point, |p, stage| stage.apply(p));
434    let origin = apply([0.0; 3]);
435    let mut matrix = [0.0; 12];
436    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() {
437        let column = sub(apply(unit), origin);
438        matrix[axis * 3..axis * 3 + 3].copy_from_slice(&column);
439    }
440    matrix[9..].copy_from_slice(&origin);
441    matrix
442}
443
444/// A sectioned view's cut as the `/3DCrossSection` its `/SA` lists (PDF
445/// 1.7): centred on `point`, the default plane (normal +x) turned by `/O
446/// [null β γ]`, β about y then γ about z, onto `normal`. The plane itself is
447/// not drawn (`/PO 0`); its intersection with the model is. Which side a
448/// reader removes is not stated by the standard and has not been checked in
449/// a reader here.
450fn cross_section(point: [f64; 3], normal: [f64; 3]) -> String {
451    let n = unit(normal).unwrap_or([1.0, 0.0, 0.0]);
452    let beta = (-n[2]).clamp(-1.0, 1.0).asin().to_degrees();
453    let gamma = n[1].atan2(n[0]).to_degrees();
454    format!(
455        "<< /Type /3DCrossSection /C [{} {} {}] /O [null {} {}] /PO 0 /IV true >>",
456        num(point[0]),
457        num(point[1]),
458        num(point[2]),
459        num(beta),
460        num(gamma)
461    )
462}
463
464/// The id of the view a document without saved views opens on.
465pub const ISOMETRIC: &str = "ISOMETRIC";
466
467/// Looking down the (−1, −1, −1) diagonal at the solids' box centre, +Y up
468/// (the application's), orthographic, the bounding sphere filling the height.
469pub fn isometric_camera(bodies: &[Body]) -> PmiCamera {
470    let mut min = [f64::INFINITY; 3];
471    let mut max = [f64::NEG_INFINITY; 3];
472    for p in bodies.iter().flat_map(|body| body.positions.iter()) {
473        for axis in 0..3 {
474            min[axis] = min[axis].min(f64::from(p[axis]));
475            max[axis] = max[axis].max(f64::from(p[axis]));
476        }
477    }
478    if !min[0].is_finite() {
479        (min, max) = ([-1.0; 3], [1.0; 3]);
480    }
481    let centre = [(min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5, (min[2] + max[2]) * 0.5];
482    let radius = (dot(sub(max, min), sub(max, min)).sqrt() * 0.5).max(1e-3);
483    let d = radius * 4.0 / 3f64.sqrt();
484    PmiCamera {
485        eye: [centre[0] + d, centre[1] + d, centre[2] + d],
486        target: centre,
487        up: [0.0, 1.0, 0.0],
488        projection: PmiProjection::Orthographic { half_height: radius * 1.1 },
489        viewport: [1280.0, 800.0],
490    }
491}
492
493/// A report row the 3D model draws: enabled, resolved, and not an explode
494/// (which moves solids rather than annotating them, and which the STEP
495/// presentation leaves out for the same reason).
496fn drawable(row: &PmiAnnotationReport) -> bool {
497    row.enabled && row.status == PmiStatus::Ok && !matches!(row.geometry, PmiGeometry::Explode { .. })
498}
499
500// ---------------------------------------------------------------------------
501// The PDF dictionaries
502// ---------------------------------------------------------------------------
503
504fn num(value: f64) -> String {
505    let text = format!("{value:.6}");
506    let text = text.trim_end_matches('0').trim_end_matches('.');
507    if text == "-0" || text.is_empty() {
508        "0".into()
509    } else {
510        text.into()
511    }
512}
513
514/// The 12-element camera-to-world matrix and the distance to the centre of
515/// orbit (the camera's target).
516pub fn c2w(camera: &PmiCamera) -> Option<([f64; 12], f64)> {
517    let (right, up, forward) = camera_frame(camera)?;
518    let y = if CAMERA_Y_UP { up } else { [-up[0], -up[1], -up[2]] };
519    let e = camera.eye;
520    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]];
521    Some((m, dot(sub(camera.target, camera.eye), forward)))
522}
523
524/// A view's projection: a perspective camera keeps its field of view; an
525/// orthographic one is scaled so its saved half height fills half of a 3D
526/// box `box_height_pt` tall, unless `scale` fixes it.
527pub fn projection(camera: &PmiCamera, box_height_pt: f64, scale: Option<f64>) -> Projection3d {
528    match (&camera.projection, scale) {
529        (_, Some(scale)) => Projection3d::Orthographic { scale },
530        (PmiProjection::Perspective { fov_y_deg }, None) => Projection3d::Perspective { fov_y_deg: *fov_y_deg },
531        (PmiProjection::Orthographic { half_height }, None) => {
532            Projection3d::Orthographic { scale: box_height_pt * 0.5 / half_height.max(1e-9) }
533        }
534    }
535}
536
537/// A `/3DView` dictionary: `name` in the reader's menu, the view's camera,
538/// `projection`, its render mode, a CAD lighting scheme, and every node's
539/// visibility.
540pub fn view_dict(model: &Model3d, view: &View3d, name: &str, projection: Projection3d) -> String {
541    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));
542    let matrix: Vec<String> = matrix.iter().map(|v| num(*v)).collect();
543    let projection = match projection {
544        Projection3d::Orthographic { scale } => format!("<< /Subtype /O /OS {} >>", num(scale)),
545        Projection3d::Perspective { fov_y_deg } => {
546            format!("<< /Subtype /P /FOV {} /PS /H >>", num(fov_y_deg.clamp(1.0, 179.0)))
547        }
548    };
549    let nodes: Vec<String> = model
550        .nodes
551        .iter()
552        .zip(&view.visible)
553        .zip(view.poses.iter().chain(std::iter::repeat(&None)))
554        .map(|((node, visible), pose)| match pose {
555            Some(pose) => {
556                let pose: Vec<String> = pose.iter().map(|v| num(*v)).collect();
557                format!("<< /Type /3DNode /N ({}) /V {visible} /M [{}] >>", string_literal(node), pose.join(" "))
558            }
559            None => format!("<< /Type /3DNode /N ({}) /V {visible} >>", string_literal(node)),
560        })
561        .collect();
562    let section = view
563        .section
564        .map(|(point, normal)| format!(" /SA [{}]", cross_section(point, normal)))
565        .unwrap_or_default();
566    format!(
567        "<< /Type /3DView /XN ({}) /IN ({}) /MS /M /C2W [{}] /CO {} /P {} \
568         /BG << /Type /3DBG /Subtype /SC /C [1 1 1] >> \
569         /RM << /Type /3DRenderMode /Subtype /{} >> \
570         /LS << /Type /3DLightingScheme /Subtype /Hard >>{} /NR true /NA [{}] >>",
571        string_literal(name),
572        string_literal(&view.id),
573        matrix.join(" "),
574        num(orbit.max(0.0)),
575        projection,
576        if view.wireframe { "Wireframe" } else { "Solid" },
577        section,
578        nodes.join(" ")
579    )
580}
581
582/// The 3D stream: every saved view in `/VA`, the default the first, and the
583/// U3D bytes ASCII85-encoded so the file stays ASCII (the PDF writer's
584/// contract).
585pub fn stream_object(model: &Model3d, va_box_height_pt: f64) -> String {
586    let views: Vec<String> = model
587        .views
588        .iter()
589        .map(|view| view_dict(model, view, &view.name, projection(&view.camera, va_box_height_pt, None)))
590        .collect();
591    // The stream's data ends with its own newline, counted in /Length — the
592    // PDF writer's convention for every stream it writes.
593    let data = ascii85(&model.u3d) + "\n";
594    format!(
595        "<< /Type /3D /Subtype /U3D /Filter /ASCII85Decode /DV 0 /VA [{}] /Length {} >>\nstream\n{}endstream",
596        views.join(" "),
597        data.len(),
598        data
599    )
600}
601
602/// ASCII85 (ISO 32000-1 §7.4.3), `~>`-terminated, in 76-character lines.
603pub fn ascii85(bytes: &[u8]) -> String {
604    let mut out = String::with_capacity(bytes.len() * 5 / 4 + bytes.len() / 60 + 4);
605    let mut column = 0;
606    let mut push = |c: char, out: &mut String| {
607        out.push(c);
608        column += 1;
609        if column == 76 {
610            out.push('\n');
611            column = 0;
612        }
613    };
614    for chunk in bytes.chunks(4) {
615        let mut word = [0u8; 4];
616        word[..chunk.len()].copy_from_slice(chunk);
617        let value = u32::from_be_bytes(word);
618        if chunk.len() == 4 && value == 0 {
619            push('z', &mut out);
620            continue;
621        }
622        let mut digits = [0u8; 5];
623        let mut v = value;
624        for digit in digits.iter_mut().rev() {
625            *digit = (v % 85) as u8;
626            v /= 85;
627        }
628        for digit in &digits[..chunk.len() + 1] {
629            push((b'!' + digit) as char, &mut out);
630        }
631    }
632    out.push_str("~>");
633    out
634}
635
636/// A 3D annotation's rectangle in PDF points: centred on `centre_mm` (sheet
637/// millimetres from the top-left), `half_mm` either way, on a page
638/// `page_height_mm` tall.
639pub fn rect_pt(centre_mm: [f64; 2], half_mm: [f64; 2], page_height_mm: f64) -> [f64; 4] {
640    [
641        (centre_mm[0] - half_mm[0]) * PT_PER_MM,
642        (page_height_mm - centre_mm[1] - half_mm[1]) * PT_PER_MM,
643        (centre_mm[0] + half_mm[0]) * PT_PER_MM,
644        (page_height_mm - centre_mm[1] + half_mm[1]) * PT_PER_MM,
645    ]
646}
647
648// ---------------------------------------------------------------------------
649// Where the model is shown
650// ---------------------------------------------------------------------------
651
652/// Everything 3D a sheet PDF carries.
653#[derive(Debug, Clone)]
654pub struct Pdf3d {
655    pub model: Model3d,
656    pub placements: Vec<Placement3d>,
657    /// A last page given over to the model.
658    pub page: Option<Page3d>,
659}
660
661/// A placement drawn live: a 3D box over the placement's drawing, opening on
662/// the placement's own camera at the placement's own scale, so activating it
663/// changes nothing until the reader turns the model.
664#[derive(Debug, Clone, PartialEq)]
665pub struct Placement3d {
666    /// The sheet's index among the file's pages.
667    pub page: usize,
668    /// The placement id (`SV2`).
669    pub id: String,
670    /// The saved view it places (`VIEW1`).
671    pub view: String,
672    /// Sheet millimetres from the top-left: the camera target's point, and the
673    /// box's half extents about it.
674    pub centre_mm: [f64; 2],
675    pub half_mm: [f64; 2],
676    /// Paper millimetres per model unit.
677    pub scale: f64,
678}
679
680/// A view button on the 3D page.
681#[derive(Debug, Clone, PartialEq)]
682pub struct Button {
683    /// `[x0, y0, x1, y1]`, sheet millimetres from the top-left.
684    pub rect_mm: [f64; 4],
685    pub label: String,
686    /// The view's index in [`Model3d::views`] (and so in `/VA`).
687    pub view: usize,
688}
689
690/// The 3D page: a title, the 3D box, a button per saved view, a caption.
691#[derive(Debug, Clone)]
692pub struct Page3d {
693    pub width_mm: f64,
694    pub height_mm: f64,
695    pub title: String,
696    pub title_at: [f64; 2],
697    pub title_mm: f64,
698    /// `[x0, y0, x1, y1]` of the 3D box, sheet millimetres from the top-left.
699    pub box_mm: [f64; 4],
700    /// The view the box opens on — the first saved view with a camera.
701    pub default_view: usize,
702    /// The default view drawn as a sheet placement at the centre of the box —
703    /// what a reader without 3D shows. `None` when there is nothing to draw.
704    pub poster: Option<ViewDrawing>,
705    /// The poster's scale (paper millimetres per model unit): the box opens at
706    /// it, so activating the model does not jump.
707    pub poster_scale: Option<f64>,
708    pub buttons: Vec<Button>,
709    pub button_text_mm: f64,
710    pub caption: Vec<String>,
711    pub caption_at: Vec<[f64; 2]>,
712    pub caption_mm: f64,
713}
714
715use super::project::ViewDrawing;
716
717const PAGE_MARGIN_MM: f64 = 12.0;
718const TITLE_MM: f64 = 5.0;
719const BUTTON_H_MM: f64 = 8.0;
720const BUTTON_GAP_MM: f64 = 3.0;
721const BUTTON_TEXT_MM: f64 = 2.8;
722const BUTTONS_PER_ROW: usize = 8;
723const CAPTION_MM: f64 = 2.8;
724
725/// The paragraph under the 3D box — the truth about who can show it.
726pub const CAPTION: [&str; 2] = [
727    "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.",
728    "Other PDF viewers show the drawing above in place of the model.",
729];
730
731impl Page3d {
732    /// The page laid out on paper `width_mm` × `height_mm`: the title at the
733    /// top, the caption at the foot, a row of buttons above it (wrapping
734    /// every `BUTTONS_PER_ROW`), and the box filling what is left.
735    pub fn layout(width_mm: f64, height_mm: f64, title: &str, model: &Model3d) -> Page3d {
736        let (w, h) = (width_mm.max(100.0), height_mm.max(100.0));
737        let inner = w - 2.0 * PAGE_MARGIN_MM;
738        let caption_at: Vec<[f64; 2]> = (0..CAPTION.len())
739            .map(|line| [w * 0.5, h - PAGE_MARGIN_MM - (CAPTION.len() - 1 - line) as f64 * CAPTION_MM * 1.8])
740            .collect();
741        let rows = model.views.len().div_ceil(BUTTONS_PER_ROW);
742        let per_row = model.views.len().min(BUTTONS_PER_ROW).max(1);
743        let button_w = ((inner - (per_row - 1) as f64 * BUTTON_GAP_MM) / per_row as f64).min(48.0);
744        let buttons_bottom = caption_at[0][1] - CAPTION_MM * 2.0;
745        let buttons_top = buttons_bottom - rows as f64 * (BUTTON_H_MM + BUTTON_GAP_MM) + BUTTON_GAP_MM;
746        let buttons = model
747            .views
748            .iter()
749            .enumerate()
750            .map(|(index, view)| {
751                let (row, column) = (index / BUTTONS_PER_ROW, index % BUTTONS_PER_ROW);
752                let in_row = (model.views.len() - row * BUTTONS_PER_ROW).min(BUTTONS_PER_ROW);
753                let row_w = in_row as f64 * button_w + (in_row - 1) as f64 * BUTTON_GAP_MM;
754                let x0 = (w - row_w) * 0.5 + column as f64 * (button_w + BUTTON_GAP_MM);
755                let y0 = buttons_top + row as f64 * (BUTTON_H_MM + BUTTON_GAP_MM);
756                Button { rect_mm: [x0, y0, x0 + button_w, y0 + BUTTON_H_MM], label: view.name.clone(), view: index }
757            })
758            .collect();
759        let box_top = PAGE_MARGIN_MM + TITLE_MM * 2.0;
760        let box_bottom = if rows > 0 { buttons_top - BUTTON_GAP_MM * 2.0 } else { buttons_bottom };
761        Page3d {
762            width_mm: w,
763            height_mm: h,
764            title: title.to_string(),
765            title_at: [w * 0.5, PAGE_MARGIN_MM + TITLE_MM * 0.5],
766            title_mm: TITLE_MM,
767            box_mm: [PAGE_MARGIN_MM, box_top, w - PAGE_MARGIN_MM, box_bottom.max(box_top + 20.0)],
768            default_view: 0,
769            poster: None,
770            poster_scale: None,
771            buttons,
772            button_text_mm: BUTTON_TEXT_MM,
773            caption: CAPTION.iter().map(|line| line.to_string()).collect(),
774            caption_at,
775            caption_mm: CAPTION_MM,
776        }
777    }
778
779    pub fn box_centre_mm(&self) -> [f64; 2] {
780        [(self.box_mm[0] + self.box_mm[2]) * 0.5, (self.box_mm[1] + self.box_mm[3]) * 0.5]
781    }
782
783    pub fn box_half_mm(&self) -> [f64; 2] {
784        [(self.box_mm[2] - self.box_mm[0]) * 0.5, (self.box_mm[3] - self.box_mm[1]) * 0.5]
785    }
786}
787
788/// The object numbers of every 3D object, assigned before any is written so
789/// the page dictionaries can name their annotations.
790#[derive(Debug, Clone)]
791pub struct Plan {
792    stream: usize,
793    /// Per placement: (annotation, appearance).
794    placements: Vec<(usize, usize)>,
795    /// The 3D page's (annotation, appearance, links).
796    page: Option<(usize, usize, Vec<usize>)>,
797    /// Which page each placement sits on.
798    placement_pages: Vec<usize>,
799    /// The 3D page's index among the file's pages.
800    page_index: usize,
801}
802
803fn rect_text(r: [f64; 4]) -> String {
804    format!("[{:.4} {:.4} {:.4} {:.4}]", r[0], r[1], r[2], r[3])
805}
806
807/// An appearance that draws nothing: the page's own marks show through until
808/// the reader activates the model.
809fn empty_appearance(rect: [f64; 4]) -> String {
810    format!(
811        "<< /Type /XObject /Subtype /Form /BBox [0 0 {:.4} {:.4}] /Length 1 >>\nstream\n\nendstream",
812        rect[2] - rect[0],
813        rect[3] - rect[1]
814    )
815}
816
817impl Plan {
818    /// Numbers from `next` on, for the 3D content of `three_d` in a file of
819    /// `sheets` sheet pages (the 3D page, when there is one, follows them).
820    pub fn new(three_d: &Pdf3d, mut next: usize, sheets: usize) -> Plan {
821        let stream = next;
822        next += 1;
823        let placements = three_d
824            .placements
825            .iter()
826            .map(|_| {
827                next += 2;
828                (next - 2, next - 1)
829            })
830            .collect();
831        let page = three_d.page.as_ref().map(|page| {
832            let annot = next;
833            let links = (0..page.buttons.len()).map(|i| annot + 2 + i).collect::<Vec<_>>();
834            next += 2 + page.buttons.len();
835            (annot, annot + 1, links)
836        });
837        Plan {
838            stream,
839            placements,
840            page,
841            placement_pages: three_d.placements.iter().map(|p| p.page).collect(),
842            page_index: sheets,
843        }
844    }
845
846    /// ` /Annots […]` for page `index` (a sheet, or — one past the sheets —
847    /// the 3D page), or nothing when it carries none.
848    pub fn annots_on(&self, index: usize) -> String {
849        let mut refs: Vec<String> = self
850            .placements
851            .iter()
852            .zip(&self.placement_pages)
853            .filter(|(_, page)| **page == index)
854            .map(|((annot, _), _)| format!("{annot} 0 R"))
855            .collect();
856        if let Some((annot, _, links)) = &self.page {
857            if index == self.page_index {
858                refs.push(format!("{annot} 0 R"));
859                refs.extend(links.iter().map(|link| format!("{link} 0 R")));
860            }
861        }
862        if refs.is_empty() {
863            String::new()
864        } else {
865            format!(" /Annots [{}]", refs.join(" "))
866        }
867    }
868
869    /// Every 3D object, in object-number order: the stream, each placement's
870    /// annotation and appearance, the 3D page's.
871    pub fn objects(&self, three_d: &Pdf3d, pages: &[&super::project::SheetDrawing], first_page: usize) -> Vec<(usize, String)> {
872        let model = &three_d.model;
873        let placement_box_pt = three_d
874            .placements
875            .iter()
876            .map(|placement| placement.half_mm[1] * 2.0 * PT_PER_MM)
877            .fold(0.0f64, f64::max);
878        let va_box_pt = match &three_d.page {
879            Some(page) => page.box_half_mm()[1] * 2.0 * PT_PER_MM,
880            None if placement_box_pt > 0.0 => placement_box_pt,
881            None => 400.0,
882        };
883        let mut out = vec![(self.stream, stream_object(model, va_box_pt))];
884        for (placement, (annot, appearance)) in three_d.placements.iter().zip(&self.placements) {
885            let page_h = pages.get(placement.page).map_or(297.0, |page| page.height_mm.max(1.0));
886            let rect = rect_pt(placement.centre_mm, placement.half_mm, page_h);
887            let view = model.view(&placement.view);
888            let default = match view {
889                Some(view) => view_dict(
890                    model,
891                    view,
892                    &format!("{} {}", placement.id, view.name),
893                    Projection3d::Orthographic { scale: placement.scale * PT_PER_MM },
894                ),
895                None => "0".to_string(),
896            };
897            out.push((
898                *annot,
899                format!(
900                    "<< /Type /Annot /Subtype /3D /Rect {} /F 4 /P {} 0 R /NM ({}) \
901                     /Contents (Click to turn this view in 3D: {}) /3DD {} 0 R /3DV {} \
902                     /3DA << /A /XA /D /PC /TB true /NP false >> /AP << /N {} 0 R >> >>",
903                    rect_text(rect),
904                    first_page + placement.page * 2,
905                    string_literal(&placement.id),
906                    string_literal(&view.map_or(placement.view.clone(), |view| view.name.clone())),
907                    self.stream,
908                    default,
909                    appearance
910                ),
911            ));
912            out.push((*appearance, empty_appearance(rect)));
913        }
914        if let (Some(page), Some((annot, appearance, links))) = (&three_d.page, &self.page) {
915            let page_number = first_page + self.page_index * 2;
916            let rect = rect_pt(page.box_centre_mm(), page.box_half_mm(), page.height_mm);
917            let default = match (model.views.get(page.default_view), page.poster_scale) {
918                (Some(view), Some(scale)) => {
919                    view_dict(model, view, &view.name, Projection3d::Orthographic { scale: scale * PT_PER_MM })
920                }
921                _ => page.default_view.to_string(),
922            };
923            out.push((
924                *annot,
925                format!(
926                    "<< /Type /Annot /Subtype /3D /Rect {} /F 4 /P {page_number} 0 R /NM (MODEL3D) \
927                     /Contents (The 3D model with its PMI views) /3DD {} 0 R /3DV {default} \
928                     /3DA << /A /PO /D /PC /TB true /NP true >> /AP << /N {appearance} 0 R >> >>",
929                    rect_text(rect),
930                    self.stream
931                ),
932            ));
933            out.push((*appearance, empty_appearance(rect)));
934            for (button, link) in page.buttons.iter().zip(links) {
935                let [x0, y0, x1, y1] = button.rect_mm;
936                let r = rect_pt([(x0 + x1) * 0.5, (y0 + y1) * 0.5], [(x1 - x0) * 0.5, (y1 - y0) * 0.5], page.height_mm);
937                out.push((
938                    *link,
939                    format!(
940                        "<< /Type /Annot /Subtype /Link /Rect {} /Border [0 0 0] /P {page_number} 0 R \
941                         /A << /S /GoTo3DView /TA {annot} 0 R /V {} >> >>",
942                        rect_text(r),
943                        button.view
944                    ),
945                ));
946            }
947        }
948        out
949    }
950}
951
952/// What a PDF carries in 3D, read back from its bytes: the count of 3D
953/// annotations, of views in the stream's `/VA`, of view links, and the U3D
954/// stream's decoded size — what the export door reports about the file.
955#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]
956pub struct Summary {
957    pub annotations: usize,
958    pub views: usize,
959    pub links: usize,
960    #[serde(rename = "u3dBytes")]
961    pub u3d_bytes: usize,
962}
963
964/// [`Summary`] of a file this module wrote; `None` when it carries no 3D.
965pub fn summary(pdf: &[u8]) -> Option<Summary> {
966    let text = String::from_utf8_lossy(pdf);
967    let stream_at = text.find("/Subtype /U3D")?;
968    let dict_end = text[stream_at..].find("\nstream\n")? + stream_at;
969    let dict = &text[stream_at..dict_end];
970    let data_start = dict_end + "\nstream\n".len();
971    let data_end = text[data_start..].find("~>")? + data_start;
972    let data = &text[data_start..data_end];
973    let mut u3d_bytes = 0;
974    let mut group = 0;
975    for c in data.chars().filter(|c| !c.is_whitespace()) {
976        if c == 'z' {
977            u3d_bytes += 4;
978        } else {
979            group += 1;
980            if group == 5 {
981                u3d_bytes += 4;
982                group = 0;
983            }
984        }
985    }
986    if group > 0 {
987        u3d_bytes += group - 1;
988    }
989    Some(Summary {
990        annotations: text.matches("/Subtype /3D /Rect").count(),
991        views: dict.matches("/Type /3DView").count(),
992        links: text.matches("/S /GoTo3DView").count(),
993        u3d_bytes,
994    })
995}
996