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
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
245pub 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
254pub 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 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 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 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 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 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
428pub 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
444fn 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
464pub const ISOMETRIC: &str = "ISOMETRIC";
466
467pub 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
493fn drawable(row: &PmiAnnotationReport) -> bool {
497 row.enabled && row.status == PmiStatus::Ok && !matches!(row.geometry, PmiGeometry::Explode { .. })
498}
499
500fn 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
514pub 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
524pub 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
537pub 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
582pub 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 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
602pub 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
636pub 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#[derive(Debug, Clone)]
654pub struct Pdf3d {
655 pub model: Model3d,
656 pub placements: Vec<Placement3d>,
657 pub page: Option<Page3d>,
659}
660
661#[derive(Debug, Clone, PartialEq)]
665pub struct Placement3d {
666 pub page: usize,
668 pub id: String,
670 pub view: String,
672 pub centre_mm: [f64; 2],
675 pub half_mm: [f64; 2],
676 pub scale: f64,
678}
679
680#[derive(Debug, Clone, PartialEq)]
682pub struct Button {
683 pub rect_mm: [f64; 4],
685 pub label: String,
686 pub view: usize,
688}
689
690#[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 pub box_mm: [f64; 4],
700 pub default_view: usize,
702 pub poster: Option<ViewDrawing>,
705 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
725pub 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 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#[derive(Debug, Clone)]
791pub struct Plan {
792 stream: usize,
793 placements: Vec<(usize, usize)>,
795 page: Option<(usize, usize, Vec<usize>)>,
797 placement_pages: Vec<usize>,
799 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
807fn 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 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 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 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#[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
964pub 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