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, PmiProjection, PmiReport,
41    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}
108
109/// The file's 3D content: the U3D bytes, the node names in stream order, and
110/// every saved view that has a camera.
111#[derive(Debug, Clone, PartialEq)]
112pub struct Model3d {
113    pub u3d: Vec<u8>,
114    pub nodes: Vec<String>,
115    pub views: Vec<View3d>,
116    /// How many annotations are drawn, over every view.
117    pub annotations: usize,
118}
119
120impl Model3d {
121    pub fn view(&self, id: &str) -> Option<&View3d> {
122        self.views.iter().find(|view| view.id == id)
123    }
124}
125
126/// A node name a PDF `/3DNode /N` can match exactly: printable ASCII, unique.
127fn node_name(wanted: &str, taken: &mut std::collections::HashSet<String>) -> String {
128    let base: String = wanted
129        .chars()
130        .map(|c| if (' '..='~').contains(&c) { c } else { '_' })
131        .collect::<String>()
132        .trim()
133        .to_string();
134    let base = if base.is_empty() { "node".to_string() } else { base };
135    let mut name = base.clone();
136    let mut n = 2;
137    while !taken.insert(name.clone()) {
138        name = format!("{base} #{n}");
139        n += 1;
140    }
141    name
142}
143
144fn material_for(rgb: [u8; 3], lit_line: bool) -> Material {
145    let c = rgb.map(|v| f32::from(v) / 255.0);
146    Material {
147        name: format!("C{:02X}{:02X}{:02X}{}", rgb[0], rgb[1], rgb[2], if lit_line { "L" } else { "" }),
148        ambient: c.map(|v| v * 0.3),
149        diffuse: c,
150        specular: [0.15; 3],
151        // A line has no surface to light: it shows its own colour.
152        emissive: if lit_line { c } else { [0.0; 3] },
153        reflectivity: 0.1,
154        opacity: 1.0,
155    }
156}
157
158/// The index of `rgb`'s material, added once.
159fn material_index(scene: &mut Scene, rgb: [u8; 3], line: bool) -> usize {
160    let wanted = material_for(rgb, line);
161    if let Some(index) = scene.materials.iter().position(|m| m.name == wanted.name) {
162        return index;
163    }
164    scene.materials.push(wanted);
165    scene.materials.len() - 1
166}
167
168fn to_f32(p: [f64; 3]) -> [f32; 3] {
169    [p[0] as f32, p[1] as f32, p[2] as f32]
170}
171
172/// Polylines as one line set: consecutive points joined, zero-length steps
173/// dropped.
174fn line_set(polylines: &[Vec<[f32; 3]>]) -> Option<Lines> {
175    let mut lines = Lines::default();
176    for polyline in polylines {
177        let mut previous: Option<u32> = None;
178        for p in polyline {
179            if !p.iter().all(|c| c.is_finite()) {
180                previous = None;
181                continue;
182            }
183            if let Some(prev) = previous {
184                if lines.positions[prev as usize] == *p {
185                    continue;
186                }
187            }
188            let index = lines.positions.len() as u32;
189            lines.positions.push(*p);
190            if let Some(prev) = previous {
191                lines.segments.push(([prev, index], 0));
192            }
193            previous = Some(index);
194        }
195    }
196    (!lines.segments.is_empty()).then_some(lines)
197}
198
199/// Triangles as a flat-shaded mesh: three vertices each, carrying the face
200/// normal.
201fn triangle_mesh(triangles: &[[[f64; 3]; 3]]) -> Option<Mesh> {
202    let mut mesh = Mesh::default();
203    for t in triangles {
204        let u = [t[1][0] - t[0][0], t[1][1] - t[0][1], t[1][2] - t[0][2]];
205        let v = [t[2][0] - t[0][0], t[2][1] - t[0][1], t[2][2] - t[0][2]];
206        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]];
207        let len = (n[0] * n[0] + n[1] * n[1] + n[2] * n[2]).sqrt();
208        if !(len > 1e-12) {
209            continue;
210        }
211        let normal = to_f32([n[0] / len, n[1] / len, n[2] / len]);
212        let base = mesh.positions.len() as u32;
213        for corner in t {
214            mesh.positions.push(to_f32(*corner));
215            mesh.normals.push(normal);
216        }
217        mesh.triangles.push(([base, base + 1, base + 2], 0));
218    }
219    (!mesh.triangles.is_empty()).then_some(mesh)
220}
221
222fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
223    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
224}
225fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
226    [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
227}
228fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
229    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
230}
231fn unit(a: [f64; 3]) -> Option<[f64; 3]> {
232    let len = dot(a, a).sqrt();
233    (len > 1e-12 && len.is_finite()).then(|| [a[0] / len, a[1] / len, a[2] / len])
234}
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        let Some((right, _, forward)) = camera_frame(camera) else { continue };
298        // Sized as the viewport sized them when the view was saved: the
299        // overlay's pixel sizes times the saved camera's world per pixel. The
300        // model units a U3D line lives in cannot stay a constant size on
301        // screen, so this is exact at the view's own framing and scales with
302        // the model when the reader zooms.
303        let wpp = world_per_pixel(camera);
304        let text_height = TEXT_PX_AT_12PT * wpp * (view.display.text_size_pt / 12.0);
305        let style = PmiLayoutStyle {
306            arrow: ARROW_PX * wpp,
307            text_height,
308            view_dir: forward,
309            view_up: camera.up,
310            plane: None,
311        };
312        for row in &rows.annotations {
313            if !drawable(row) {
314                continue;
315            }
316            // A view-aligned row is laid out in the view-parallel plane
317            // through its label, facing the camera — the STEP presentation's
318            // rule, so the two 3D exports draw the same thing.
319            let row_style = PmiLayoutStyle {
320                plane: row.plane.or(Some(PmiPlane {
321                    origin: row.label_world,
322                    normal: [-forward[0], -forward[1], -forward[2]],
323                    x_axis: right,
324                })),
325                ..style
326            };
327            let drawn = pmi_present(&row.geometry, row.label_world, &row.text, &row_style);
328            let mut polylines: Vec<Vec<[f32; 3]>> =
329                drawn.polylines.iter().chain(&drawn.frames).map(|line| line.iter().copied().map(to_f32).collect()).collect();
330            for run in &drawn.texts {
331                polylines.extend(run.strokes().into_iter().map(|stroke| stroke.into_iter().map(to_f32).collect()));
332            }
333            let lines = line_set(&polylines);
334            let fill = triangle_mesh(&drawn.arrows);
335            if lines.is_none() && fill.is_none() {
336                continue;
337            }
338            annotations += 1;
339            let base = format!("PMI {} {}", view.id, row.id);
340            if let Some(lines) = lines {
341                let material = material_index(&mut scene, PMI_RGB, true);
342                let name = node_name(&base, &mut taken);
343                scene.models.push(Model { name, geometry: Geometry::Lines(lines), shaders: vec![material], two_sided: false });
344                owners.push(Owner::View(view.id.clone()));
345            }
346            if let Some(fill) = fill {
347                let material = material_index(&mut scene, PMI_RGB, true);
348                let name = node_name(&format!("{base} fill"), &mut taken);
349                scene.models.push(Model { name, geometry: Geometry::Mesh(fill), shaders: vec![material], two_sided: true });
350                owners.push(Owner::View(view.id.clone()));
351            }
352        }
353    }
354
355    let u3d = u3d::write(&scene)?;
356    let nodes: Vec<String> = scene.models.iter().map(|model| model.name.clone()).collect();
357    let mut views: Vec<View3d> = cameras
358        .iter()
359        .map(|(view, camera)| View3d {
360            id: view.id.clone(),
361            name: if view.name.trim().is_empty() { view.id.clone() } else { view.name.clone() },
362            camera: (*camera).clone(),
363            wireframe: view.display.wireframe,
364            visible: owners
365                .iter()
366                .map(|owner| match owner {
367                    Owner::Solid(solid) => !view.display.hidden.iter().any(|hidden| hidden == solid),
368                    Owner::View(id) => *id == view.id,
369                })
370                .collect(),
371        })
372        .collect();
373    // A document with no saved view still opens on SOMETHING: an isometric
374    // view fitted to the solids, every solid shown.
375    if views.is_empty() {
376        views.push(View3d {
377            id: ISOMETRIC.into(),
378            name: "Isometric".into(),
379            camera: isometric_camera(bodies),
380            wireframe: false,
381            visible: owners.iter().map(|owner| matches!(owner, Owner::Solid(_))).collect(),
382        });
383    }
384    Ok(Model3d { u3d, nodes, views, annotations })
385}
386
387/// The id of the view a document without saved views opens on.
388pub const ISOMETRIC: &str = "ISOMETRIC";
389
390/// Looking down the (−1, −1, −1) diagonal at the solids' box centre, +Y up
391/// (the application's), orthographic, the bounding sphere filling the height.
392pub fn isometric_camera(bodies: &[Body]) -> PmiCamera {
393    let mut min = [f64::INFINITY; 3];
394    let mut max = [f64::NEG_INFINITY; 3];
395    for p in bodies.iter().flat_map(|body| body.positions.iter()) {
396        for axis in 0..3 {
397            min[axis] = min[axis].min(f64::from(p[axis]));
398            max[axis] = max[axis].max(f64::from(p[axis]));
399        }
400    }
401    if !min[0].is_finite() {
402        (min, max) = ([-1.0; 3], [1.0; 3]);
403    }
404    let centre = [(min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5, (min[2] + max[2]) * 0.5];
405    let radius = (dot(sub(max, min), sub(max, min)).sqrt() * 0.5).max(1e-3);
406    let d = radius * 4.0 / 3f64.sqrt();
407    PmiCamera {
408        eye: [centre[0] + d, centre[1] + d, centre[2] + d],
409        target: centre,
410        up: [0.0, 1.0, 0.0],
411        projection: PmiProjection::Orthographic { half_height: radius * 1.1 },
412        viewport: [1280.0, 800.0],
413    }
414}
415
416/// A report row the 3D model draws: enabled, resolved, and not an explode
417/// (which moves solids rather than annotating them, and which the STEP
418/// presentation leaves out for the same reason).
419fn drawable(row: &PmiAnnotationReport) -> bool {
420    row.enabled && row.status == PmiStatus::Ok && !matches!(row.geometry, PmiGeometry::Explode { .. })
421}
422
423// ---------------------------------------------------------------------------
424// The PDF dictionaries
425// ---------------------------------------------------------------------------
426
427fn num(value: f64) -> String {
428    let text = format!("{value:.6}");
429    let text = text.trim_end_matches('0').trim_end_matches('.');
430    if text == "-0" || text.is_empty() {
431        "0".into()
432    } else {
433        text.into()
434    }
435}
436
437/// The 12-element camera-to-world matrix and the distance to the centre of
438/// orbit (the camera's target).
439pub fn c2w(camera: &PmiCamera) -> Option<([f64; 12], f64)> {
440    let (right, up, forward) = camera_frame(camera)?;
441    let y = if CAMERA_Y_UP { up } else { [-up[0], -up[1], -up[2]] };
442    let e = camera.eye;
443    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]];
444    Some((m, dot(sub(camera.target, camera.eye), forward)))
445}
446
447/// A view's projection: a perspective camera keeps its field of view; an
448/// orthographic one is scaled so its saved half height fills half of a 3D
449/// box `box_height_pt` tall, unless `scale` fixes it.
450pub fn projection(camera: &PmiCamera, box_height_pt: f64, scale: Option<f64>) -> Projection3d {
451    match (&camera.projection, scale) {
452        (_, Some(scale)) => Projection3d::Orthographic { scale },
453        (PmiProjection::Perspective { fov_y_deg }, None) => Projection3d::Perspective { fov_y_deg: *fov_y_deg },
454        (PmiProjection::Orthographic { half_height }, None) => {
455            Projection3d::Orthographic { scale: box_height_pt * 0.5 / half_height.max(1e-9) }
456        }
457    }
458}
459
460/// A `/3DView` dictionary: `name` in the reader's menu, the view's camera,
461/// `projection`, its render mode, a CAD lighting scheme, and every node's
462/// visibility.
463pub fn view_dict(model: &Model3d, view: &View3d, name: &str, projection: Projection3d) -> String {
464    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));
465    let matrix: Vec<String> = matrix.iter().map(|v| num(*v)).collect();
466    let projection = match projection {
467        Projection3d::Orthographic { scale } => format!("<< /Subtype /O /OS {} >>", num(scale)),
468        Projection3d::Perspective { fov_y_deg } => {
469            format!("<< /Subtype /P /FOV {} /PS /H >>", num(fov_y_deg.clamp(1.0, 179.0)))
470        }
471    };
472    let nodes: Vec<String> = model
473        .nodes
474        .iter()
475        .zip(&view.visible)
476        .map(|(node, visible)| format!("<< /Type /3DNode /N ({}) /V {visible} >>", string_literal(node)))
477        .collect();
478    format!(
479        "<< /Type /3DView /XN ({}) /IN ({}) /MS /M /C2W [{}] /CO {} /P {} \
480         /BG << /Type /3DBG /Subtype /SC /C [1 1 1] >> \
481         /RM << /Type /3DRenderMode /Subtype /{} >> \
482         /LS << /Type /3DLightingScheme /Subtype /Hard >> /NR true /NA [{}] >>",
483        string_literal(name),
484        string_literal(&view.id),
485        matrix.join(" "),
486        num(orbit.max(0.0)),
487        projection,
488        if view.wireframe { "Wireframe" } else { "Solid" },
489        nodes.join(" ")
490    )
491}
492
493/// The 3D stream: every saved view in `/VA`, the default the first, and the
494/// U3D bytes ASCII85-encoded so the file stays ASCII (the PDF writer's
495/// contract).
496pub fn stream_object(model: &Model3d, va_box_height_pt: f64) -> String {
497    let views: Vec<String> = model
498        .views
499        .iter()
500        .map(|view| view_dict(model, view, &view.name, projection(&view.camera, va_box_height_pt, None)))
501        .collect();
502    // The stream's data ends with its own newline, counted in /Length — the
503    // PDF writer's convention for every stream it writes.
504    let data = ascii85(&model.u3d) + "\n";
505    format!(
506        "<< /Type /3D /Subtype /U3D /Filter /ASCII85Decode /DV 0 /VA [{}] /Length {} >>\nstream\n{}endstream",
507        views.join(" "),
508        data.len(),
509        data
510    )
511}
512
513/// ASCII85 (ISO 32000-1 §7.4.3), `~>`-terminated, in 76-character lines.
514pub fn ascii85(bytes: &[u8]) -> String {
515    let mut out = String::with_capacity(bytes.len() * 5 / 4 + bytes.len() / 60 + 4);
516    let mut column = 0;
517    let mut push = |c: char, out: &mut String| {
518        out.push(c);
519        column += 1;
520        if column == 76 {
521            out.push('\n');
522            column = 0;
523        }
524    };
525    for chunk in bytes.chunks(4) {
526        let mut word = [0u8; 4];
527        word[..chunk.len()].copy_from_slice(chunk);
528        let value = u32::from_be_bytes(word);
529        if chunk.len() == 4 && value == 0 {
530            push('z', &mut out);
531            continue;
532        }
533        let mut digits = [0u8; 5];
534        let mut v = value;
535        for digit in digits.iter_mut().rev() {
536            *digit = (v % 85) as u8;
537            v /= 85;
538        }
539        for digit in &digits[..chunk.len() + 1] {
540            push((b'!' + digit) as char, &mut out);
541        }
542    }
543    out.push_str("~>");
544    out
545}
546
547/// A 3D annotation's rectangle in PDF points: centred on `centre_mm` (sheet
548/// millimetres from the top-left), `half_mm` either way, on a page
549/// `page_height_mm` tall.
550pub fn rect_pt(centre_mm: [f64; 2], half_mm: [f64; 2], page_height_mm: f64) -> [f64; 4] {
551    [
552        (centre_mm[0] - half_mm[0]) * PT_PER_MM,
553        (page_height_mm - centre_mm[1] - half_mm[1]) * PT_PER_MM,
554        (centre_mm[0] + half_mm[0]) * PT_PER_MM,
555        (page_height_mm - centre_mm[1] + half_mm[1]) * PT_PER_MM,
556    ]
557}
558
559// ---------------------------------------------------------------------------
560// Where the model is shown
561// ---------------------------------------------------------------------------
562
563/// Everything 3D a sheet PDF carries.
564#[derive(Debug, Clone)]
565pub struct Pdf3d {
566    pub model: Model3d,
567    pub placements: Vec<Placement3d>,
568    /// A last page given over to the model.
569    pub page: Option<Page3d>,
570}
571
572/// A placement drawn live: a 3D box over the placement's drawing, opening on
573/// the placement's own camera at the placement's own scale, so activating it
574/// changes nothing until the reader turns the model.
575#[derive(Debug, Clone, PartialEq)]
576pub struct Placement3d {
577    /// The sheet's index among the file's pages.
578    pub page: usize,
579    /// The placement id (`SV2`).
580    pub id: String,
581    /// The saved view it places (`VIEW1`).
582    pub view: String,
583    /// Sheet millimetres from the top-left: the camera target's point, and the
584    /// box's half extents about it.
585    pub centre_mm: [f64; 2],
586    pub half_mm: [f64; 2],
587    /// Paper millimetres per model unit.
588    pub scale: f64,
589}
590
591/// A view button on the 3D page.
592#[derive(Debug, Clone, PartialEq)]
593pub struct Button {
594    /// `[x0, y0, x1, y1]`, sheet millimetres from the top-left.
595    pub rect_mm: [f64; 4],
596    pub label: String,
597    /// The view's index in [`Model3d::views`] (and so in `/VA`).
598    pub view: usize,
599}
600
601/// The 3D page: a title, the 3D box, a button per saved view, a caption.
602#[derive(Debug, Clone)]
603pub struct Page3d {
604    pub width_mm: f64,
605    pub height_mm: f64,
606    pub title: String,
607    pub title_at: [f64; 2],
608    pub title_mm: f64,
609    /// `[x0, y0, x1, y1]` of the 3D box, sheet millimetres from the top-left.
610    pub box_mm: [f64; 4],
611    /// The view the box opens on — the first saved view with a camera.
612    pub default_view: usize,
613    /// The default view drawn as a sheet placement at the centre of the box —
614    /// what a reader without 3D shows. `None` when there is nothing to draw.
615    pub poster: Option<ViewDrawing>,
616    /// The poster's scale (paper millimetres per model unit): the box opens at
617    /// it, so activating the model does not jump.
618    pub poster_scale: Option<f64>,
619    pub buttons: Vec<Button>,
620    pub button_text_mm: f64,
621    pub caption: Vec<String>,
622    pub caption_at: Vec<[f64; 2]>,
623    pub caption_mm: f64,
624}
625
626use super::project::ViewDrawing;
627
628const PAGE_MARGIN_MM: f64 = 12.0;
629const TITLE_MM: f64 = 5.0;
630const BUTTON_H_MM: f64 = 8.0;
631const BUTTON_GAP_MM: f64 = 3.0;
632const BUTTON_TEXT_MM: f64 = 2.8;
633const BUTTONS_PER_ROW: usize = 8;
634const CAPTION_MM: f64 = 2.8;
635
636/// The paragraph under the 3D box — the truth about who can show it.
637pub const CAPTION: [&str; 2] = [
638    "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.",
639    "Other PDF viewers show the drawing above in place of the model.",
640];
641
642impl Page3d {
643    /// The page laid out on paper `width_mm` × `height_mm`: the title at the
644    /// top, the caption at the foot, a row of buttons above it (wrapping
645    /// every [`BUTTONS_PER_ROW`]), and the box filling what is left.
646    pub fn layout(width_mm: f64, height_mm: f64, title: &str, model: &Model3d) -> Page3d {
647        let (w, h) = (width_mm.max(100.0), height_mm.max(100.0));
648        let inner = w - 2.0 * PAGE_MARGIN_MM;
649        let caption_at: Vec<[f64; 2]> = (0..CAPTION.len())
650            .map(|line| [w * 0.5, h - PAGE_MARGIN_MM - (CAPTION.len() - 1 - line) as f64 * CAPTION_MM * 1.8])
651            .collect();
652        let rows = model.views.len().div_ceil(BUTTONS_PER_ROW);
653        let per_row = model.views.len().min(BUTTONS_PER_ROW).max(1);
654        let button_w = ((inner - (per_row - 1) as f64 * BUTTON_GAP_MM) / per_row as f64).min(48.0);
655        let buttons_bottom = caption_at[0][1] - CAPTION_MM * 2.0;
656        let buttons_top = buttons_bottom - rows as f64 * (BUTTON_H_MM + BUTTON_GAP_MM) + BUTTON_GAP_MM;
657        let buttons = model
658            .views
659            .iter()
660            .enumerate()
661            .map(|(index, view)| {
662                let (row, column) = (index / BUTTONS_PER_ROW, index % BUTTONS_PER_ROW);
663                let in_row = (model.views.len() - row * BUTTONS_PER_ROW).min(BUTTONS_PER_ROW);
664                let row_w = in_row as f64 * button_w + (in_row - 1) as f64 * BUTTON_GAP_MM;
665                let x0 = (w - row_w) * 0.5 + column as f64 * (button_w + BUTTON_GAP_MM);
666                let y0 = buttons_top + row as f64 * (BUTTON_H_MM + BUTTON_GAP_MM);
667                Button { rect_mm: [x0, y0, x0 + button_w, y0 + BUTTON_H_MM], label: view.name.clone(), view: index }
668            })
669            .collect();
670        let box_top = PAGE_MARGIN_MM + TITLE_MM * 2.0;
671        let box_bottom = if rows > 0 { buttons_top - BUTTON_GAP_MM * 2.0 } else { buttons_bottom };
672        Page3d {
673            width_mm: w,
674            height_mm: h,
675            title: title.to_string(),
676            title_at: [w * 0.5, PAGE_MARGIN_MM + TITLE_MM * 0.5],
677            title_mm: TITLE_MM,
678            box_mm: [PAGE_MARGIN_MM, box_top, w - PAGE_MARGIN_MM, box_bottom.max(box_top + 20.0)],
679            default_view: 0,
680            poster: None,
681            poster_scale: None,
682            buttons,
683            button_text_mm: BUTTON_TEXT_MM,
684            caption: CAPTION.iter().map(|line| line.to_string()).collect(),
685            caption_at,
686            caption_mm: CAPTION_MM,
687        }
688    }
689
690    pub fn box_centre_mm(&self) -> [f64; 2] {
691        [(self.box_mm[0] + self.box_mm[2]) * 0.5, (self.box_mm[1] + self.box_mm[3]) * 0.5]
692    }
693
694    pub fn box_half_mm(&self) -> [f64; 2] {
695        [(self.box_mm[2] - self.box_mm[0]) * 0.5, (self.box_mm[3] - self.box_mm[1]) * 0.5]
696    }
697}
698
699/// The object numbers of every 3D object, assigned before any is written so
700/// the page dictionaries can name their annotations.
701#[derive(Debug, Clone)]
702pub struct Plan {
703    stream: usize,
704    /// Per placement: (annotation, appearance).
705    placements: Vec<(usize, usize)>,
706    /// The 3D page's (annotation, appearance, links).
707    page: Option<(usize, usize, Vec<usize>)>,
708    /// Which page each placement sits on.
709    placement_pages: Vec<usize>,
710    /// The 3D page's index among the file's pages.
711    page_index: usize,
712}
713
714fn rect_text(r: [f64; 4]) -> String {
715    format!("[{:.4} {:.4} {:.4} {:.4}]", r[0], r[1], r[2], r[3])
716}
717
718/// An appearance that draws nothing: the page's own marks show through until
719/// the reader activates the model.
720fn empty_appearance(rect: [f64; 4]) -> String {
721    format!(
722        "<< /Type /XObject /Subtype /Form /BBox [0 0 {:.4} {:.4}] /Length 1 >>\nstream\n\nendstream",
723        rect[2] - rect[0],
724        rect[3] - rect[1]
725    )
726}
727
728impl Plan {
729    /// Numbers from `next` on, for the 3D content of `three_d` in a file of
730    /// `sheets` sheet pages (the 3D page, when there is one, follows them).
731    pub fn new(three_d: &Pdf3d, mut next: usize, sheets: usize) -> Plan {
732        let stream = next;
733        next += 1;
734        let placements = three_d
735            .placements
736            .iter()
737            .map(|_| {
738                next += 2;
739                (next - 2, next - 1)
740            })
741            .collect();
742        let page = three_d.page.as_ref().map(|page| {
743            let annot = next;
744            let links = (0..page.buttons.len()).map(|i| annot + 2 + i).collect::<Vec<_>>();
745            next += 2 + page.buttons.len();
746            (annot, annot + 1, links)
747        });
748        Plan {
749            stream,
750            placements,
751            page,
752            placement_pages: three_d.placements.iter().map(|p| p.page).collect(),
753            page_index: sheets,
754        }
755    }
756
757    /// ` /Annots […]` for page `index` (a sheet, or — one past the sheets —
758    /// the 3D page), or nothing when it carries none.
759    pub fn annots_on(&self, index: usize) -> String {
760        let mut refs: Vec<String> = self
761            .placements
762            .iter()
763            .zip(&self.placement_pages)
764            .filter(|(_, page)| **page == index)
765            .map(|((annot, _), _)| format!("{annot} 0 R"))
766            .collect();
767        if let Some((annot, _, links)) = &self.page {
768            if index == self.page_index {
769                refs.push(format!("{annot} 0 R"));
770                refs.extend(links.iter().map(|link| format!("{link} 0 R")));
771            }
772        }
773        if refs.is_empty() {
774            String::new()
775        } else {
776            format!(" /Annots [{}]", refs.join(" "))
777        }
778    }
779
780    /// Every 3D object, in object-number order: the stream, each placement's
781    /// annotation and appearance, the 3D page's.
782    pub fn objects(&self, three_d: &Pdf3d, pages: &[&super::project::SheetDrawing], first_page: usize) -> Vec<(usize, String)> {
783        let model = &three_d.model;
784        let placement_box_pt = three_d
785            .placements
786            .iter()
787            .map(|placement| placement.half_mm[1] * 2.0 * PT_PER_MM)
788            .fold(0.0f64, f64::max);
789        let va_box_pt = match &three_d.page {
790            Some(page) => page.box_half_mm()[1] * 2.0 * PT_PER_MM,
791            None if placement_box_pt > 0.0 => placement_box_pt,
792            None => 400.0,
793        };
794        let mut out = vec![(self.stream, stream_object(model, va_box_pt))];
795        for (placement, (annot, appearance)) in three_d.placements.iter().zip(&self.placements) {
796            let page_h = pages.get(placement.page).map_or(297.0, |page| page.height_mm.max(1.0));
797            let rect = rect_pt(placement.centre_mm, placement.half_mm, page_h);
798            let view = model.view(&placement.view);
799            let default = match view {
800                Some(view) => view_dict(
801                    model,
802                    view,
803                    &format!("{} {}", placement.id, view.name),
804                    Projection3d::Orthographic { scale: placement.scale * PT_PER_MM },
805                ),
806                None => "0".to_string(),
807            };
808            out.push((
809                *annot,
810                format!(
811                    "<< /Type /Annot /Subtype /3D /Rect {} /F 4 /P {} 0 R /NM ({}) \
812                     /Contents (Click to turn this view in 3D: {}) /3DD {} 0 R /3DV {} \
813                     /3DA << /A /XA /D /PC /TB true /NP false >> /AP << /N {} 0 R >> >>",
814                    rect_text(rect),
815                    first_page + placement.page * 2,
816                    string_literal(&placement.id),
817                    string_literal(&view.map_or(placement.view.clone(), |view| view.name.clone())),
818                    self.stream,
819                    default,
820                    appearance
821                ),
822            ));
823            out.push((*appearance, empty_appearance(rect)));
824        }
825        if let (Some(page), Some((annot, appearance, links))) = (&three_d.page, &self.page) {
826            let page_number = first_page + self.page_index * 2;
827            let rect = rect_pt(page.box_centre_mm(), page.box_half_mm(), page.height_mm);
828            let default = match (model.views.get(page.default_view), page.poster_scale) {
829                (Some(view), Some(scale)) => {
830                    view_dict(model, view, &view.name, Projection3d::Orthographic { scale: scale * PT_PER_MM })
831                }
832                _ => page.default_view.to_string(),
833            };
834            out.push((
835                *annot,
836                format!(
837                    "<< /Type /Annot /Subtype /3D /Rect {} /F 4 /P {page_number} 0 R /NM (MODEL3D) \
838                     /Contents (The 3D model with its PMI views) /3DD {} 0 R /3DV {default} \
839                     /3DA << /A /PO /D /PC /TB true /NP true >> /AP << /N {appearance} 0 R >> >>",
840                    rect_text(rect),
841                    self.stream
842                ),
843            ));
844            out.push((*appearance, empty_appearance(rect)));
845            for (button, link) in page.buttons.iter().zip(links) {
846                let [x0, y0, x1, y1] = button.rect_mm;
847                let r = rect_pt([(x0 + x1) * 0.5, (y0 + y1) * 0.5], [(x1 - x0) * 0.5, (y1 - y0) * 0.5], page.height_mm);
848                out.push((
849                    *link,
850                    format!(
851                        "<< /Type /Annot /Subtype /Link /Rect {} /Border [0 0 0] /P {page_number} 0 R \
852                         /A << /S /GoTo3DView /TA {annot} 0 R /V {} >> >>",
853                        rect_text(r),
854                        button.view
855                    ),
856                ));
857            }
858        }
859        out
860    }
861}
862
863/// What a PDF carries in 3D, read back from its bytes: the count of 3D
864/// annotations, of views in the stream's `/VA`, of view links, and the U3D
865/// stream's decoded size — what the export door reports about the file.
866#[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]
867pub struct Summary {
868    pub annotations: usize,
869    pub views: usize,
870    pub links: usize,
871    #[serde(rename = "u3dBytes")]
872    pub u3d_bytes: usize,
873}
874
875/// [`Summary`] of a file this module wrote; `None` when it carries no 3D.
876pub fn summary(pdf: &[u8]) -> Option<Summary> {
877    let text = String::from_utf8_lossy(pdf);
878    let stream_at = text.find("/Subtype /U3D")?;
879    let dict_end = text[stream_at..].find("\nstream\n")? + stream_at;
880    let dict = &text[stream_at..dict_end];
881    let data_start = dict_end + "\nstream\n".len();
882    let data_end = text[data_start..].find("~>")? + data_start;
883    let data = &text[data_start..data_end];
884    let mut u3d_bytes = 0;
885    let mut group = 0;
886    for c in data.chars().filter(|c| !c.is_whitespace()) {
887        if c == 'z' {
888            u3d_bytes += 4;
889        } else {
890            group += 1;
891            if group == 5 {
892                u3d_bytes += 4;
893                group = 0;
894            }
895        }
896    }
897    if group > 0 {
898        u3d_bytes += group - 1;
899    }
900    Some(Summary {
901        annotations: text.matches("/Subtype /3D /Rect").count(),
902        views: dict.matches("/Type /3DView").count(),
903        links: text.matches("/S /GoTo3DView").count(),
904        u3d_bytes,
905    })
906}
907