1use 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
47pub const CAMERA_Y_UP: bool = true;
50
51use crate::engine_state::pmi_overlay::{ARROW_PX, TEXT_PX_AT_12PT};
52
53pub 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
66const EDGE_RGB: [u8; 3] = [0x20, 0x20, 0x24];
68const PMI_RGB: [u8; 3] = [0xD9, 0x68, 0x00];
72
73#[derive(Debug, Clone)]
75pub struct Body {
76 pub name: String,
77 pub positions: Vec<[f32; 3]>,
78 pub normals: Vec<[f32; 3]>,
79 pub indices: Vec<u32>,
81 pub triangle_colors: Vec<[u8; 3]>,
83 pub edges: Vec<Vec<[f32; 3]>>,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq)]
89pub enum Projection3d {
90 Orthographic { scale: f64 },
92 Perspective { fov_y_deg: f64 },
94}
95
96#[derive(Debug, Clone, PartialEq)]
98pub struct View3d {
99 pub id: String,
101 pub name: String,
103 pub camera: PmiCamera,
104 pub wireframe: bool,
105 pub visible: Vec<bool>,
107 pub poses: Vec<Option<[f64; 12]>>,
111 pub section: Option<([f64; 3], [f64; 3])>,
116}
117
118#[derive(Debug, Clone, PartialEq)]
121pub struct Model3d {
122 pub u3d: Vec<u8>,
123 pub nodes: Vec<String>,
124 pub views: Vec<View3d>,
125 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
135fn 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 emissive: if lit_line { c } else { [0.0; 3] },
162 reflectivity: 0.1,
163 opacity: 1.0,
164 }
165}
166
167fn 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
181fn 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
208fn 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
236pub 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
245pub 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 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 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 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#[derive(Debug, Clone, Default, PartialEq)]
387pub struct DrawnAnnotation {
388 pub lines: Vec<Vec<[f64; 3]>>,
391 pub arrows: Vec<[[f64; 3]; 3]>,
393}
394
395pub 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
431pub 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
447fn 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
467pub const ISOMETRIC: &str = "ISOMETRIC";
469
470pub 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
496fn drawable(row: &PmiAnnotationReport) -> bool {
500 row.enabled && row.status == PmiStatus::Ok && !matches!(row.geometry, PmiGeometry::Explode { .. } | PmiGeometry::Hide { .. })
501}
502
503fn 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
517pub 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
527pub 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
540pub 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
585pub 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 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
605pub 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
639pub 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#[derive(Debug, Clone)]
657pub struct Pdf3d {
658 pub model: Model3d,
659 pub placements: Vec<Placement3d>,
660 pub page: Option<Page3d>,
662}
663
664#[derive(Debug, Clone, PartialEq)]
668pub struct Placement3d {
669 pub page: usize,
671 pub id: String,
673 pub view: String,
675 pub centre_mm: [f64; 2],
678 pub half_mm: [f64; 2],
679 pub scale: f64,
681}
682
683#[derive(Debug, Clone, PartialEq)]
685pub struct Button {
686 pub rect_mm: [f64; 4],
688 pub label: String,
689 pub view: usize,
691}
692
693#[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 pub box_mm: [f64; 4],
703 pub default_view: usize,
705 pub poster: Option<ViewDrawing>,
708 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
728pub 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 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#[derive(Debug, Clone)]
794pub struct Plan {
795 stream: usize,
796 placements: Vec<(usize, usize)>,
798 page: Option<(usize, usize, Vec<usize>)>,
800 placement_pages: Vec<usize>,
802 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
810fn 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 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 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 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#[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
967pub 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