1use 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
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}
108
109#[derive(Debug, Clone, PartialEq)]
112pub struct Model3d {
113 pub u3d: Vec<u8>,
114 pub nodes: Vec<String>,
115 pub views: Vec<View3d>,
116 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
126fn 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 emissive: if lit_line { c } else { [0.0; 3] },
153 reflectivity: 0.1,
154 opacity: 1.0,
155 }
156}
157
158fn 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
172fn 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
199fn 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
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 let Some((right, _, forward)) = camera_frame(camera) else { continue };
298 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 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 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
387pub const ISOMETRIC: &str = "ISOMETRIC";
389
390pub 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
416fn drawable(row: &PmiAnnotationReport) -> bool {
420 row.enabled && row.status == PmiStatus::Ok && !matches!(row.geometry, PmiGeometry::Explode { .. })
421}
422
423fn 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
437pub 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
447pub 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
460pub 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
493pub 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 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
513pub 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
547pub 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#[derive(Debug, Clone)]
565pub struct Pdf3d {
566 pub model: Model3d,
567 pub placements: Vec<Placement3d>,
568 pub page: Option<Page3d>,
570}
571
572#[derive(Debug, Clone, PartialEq)]
576pub struct Placement3d {
577 pub page: usize,
579 pub id: String,
581 pub view: String,
583 pub centre_mm: [f64; 2],
586 pub half_mm: [f64; 2],
587 pub scale: f64,
589}
590
591#[derive(Debug, Clone, PartialEq)]
593pub struct Button {
594 pub rect_mm: [f64; 4],
596 pub label: String,
597 pub view: usize,
599}
600
601#[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 pub box_mm: [f64; 4],
611 pub default_view: usize,
613 pub poster: Option<ViewDrawing>,
616 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
636pub 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 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#[derive(Debug, Clone)]
702pub struct Plan {
703 stream: usize,
704 placements: Vec<(usize, usize)>,
706 page: Option<(usize, usize, Vec<usize>)>,
708 placement_pages: Vec<usize>,
710 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
718fn 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 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 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 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#[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
875pub 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