From b568977af45b1d8993ae17ed68437f776860460f Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Mon, 9 Nov 2026 00:29:05 +0700 Subject: [PATCH] Asset Export: 3D objects export as USDA, USDZ, glTF or OBJ meshes Squashed from #6168 onto main. --- crates/doc/src/assets.rs | 6 +- crates/doc/src/canvas_range.rs | 4 +- crates/effects/src/lib.rs | 5 +- crates/effects/src/revolve.rs | 39 ++ crates/effects/src/solids.rs | 204 ++++++++-- crates/effects/src/solids/tests_mesh.rs | 140 +++++++ crates/engine/src/cmd/assets.rs | 207 +++++++++- crates/engine/src/cmd/fileio.rs | 4 + crates/engine/src/cmd/fileio/mesh3d.rs | 260 ++++++++++++ crates/engine/src/cmd/fileio/mesh3d/tests.rs | 130 ++++++ crates/engine/src/cmd/fileio/screens.rs | 3 + crates/engine/src/cmd/fileio/zip.rs | 22 +- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/assets_3d.rs | 220 +++++++++++ crates/three-d/src/lib.rs | 41 ++ crates/three-d/src/mesh.rs | 371 ++++++++++++++++++ crates/three-d/src/mesh/tests.rs | 283 +++++++++++++ crates/three-d/src/solid.rs | 2 + crates/three-d/src/solid/build.rs | 42 +- crates/three-d/src/solid/inflate.rs | 20 +- .../ui-egui/src/dialogs/export_for_screens.rs | 34 +- .../src/dialogs/tests_screen_assets.rs | 22 ++ crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/bs.tsv | 3 + crates/ui-egui/src/i18n/ca.tsv | 8 + crates/ui-egui/src/i18n/cs.tsv | 8 + crates/ui-egui/src/i18n/da.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 8 + crates/ui-egui/src/i18n/el.tsv | 8 + crates/ui-egui/src/i18n/es.tsv | 8 + crates/ui-egui/src/i18n/fi.tsv | 8 + crates/ui-egui/src/i18n/fr.tsv | 8 + crates/ui-egui/src/i18n/hi.tsv | 8 + crates/ui-egui/src/i18n/hu.tsv | 8 + crates/ui-egui/src/i18n/id.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 8 + crates/ui-egui/src/i18n/ja.tsv | 8 + crates/ui-egui/src/i18n/ka.tsv | 8 + crates/ui-egui/src/i18n/ko.tsv | 8 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 8 + crates/ui-egui/src/i18n/pt-br.tsv | 8 + crates/ui-egui/src/i18n/ru.tsv | 8 + crates/ui-egui/src/i18n/sv.tsv | 8 + crates/ui-egui/src/i18n/tr.tsv | 8 + crates/ui-egui/src/i18n/uk.tsv | 8 + crates/ui-egui/src/i18n/vi.tsv | 8 + crates/ui-egui/src/i18n/zh-hans.tsv | 8 + crates/ui-egui/src/i18n/zh-hant.tsv | 8 + crates/ui-egui/src/io/export.rs | 6 +- crates/ui-egui/src/menus/invoke.rs | 10 +- crates/ui-egui/src/palette.rs | 2 +- crates/ui-egui/src/panels/asset_export.rs | 61 ++- .../ui-egui/src/panels/tests_asset_export.rs | 75 ++++ docs/effects-parity.md | 4 +- docs/file-format-parity.md | 4 +- docs/gaps.md | 2 +- docs/mcp.md | 32 ++ docs/target-app-parity.md | 6 +- 59 files changed, 2376 insertions(+), 77 deletions(-) create mode 100644 crates/effects/src/solids/tests_mesh.rs create mode 100644 crates/engine/src/cmd/fileio/mesh3d.rs create mode 100644 crates/engine/src/cmd/fileio/mesh3d/tests.rs create mode 100644 crates/engine/src/tests/assets_3d.rs create mode 100644 crates/three-d/src/mesh.rs create mode 100644 crates/three-d/src/mesh/tests.rs diff --git a/crates/doc/src/assets.rs b/crates/doc/src/assets.rs index 3c4451d54..824f3ebea 100644 --- a/crates/doc/src/assets.rs +++ b/crates/doc/src/assets.rs @@ -11,12 +11,16 @@ use serde::{Deserialize, Serialize}; use crate::{Document, NodeId}; /// One asset: what it is called (its files' names) and the objects it exports, cropped to their -/// visual bounds. +/// visual bounds, or for a 3D asset its object's solids as a mesh file. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ExportAsset { pub id: u64, pub name: String, pub nodes: Vec, + /// A 3D asset's mesh format (`usda`, `usdz`, `gltf` or `obj`): its object exports as a 3D + /// file. `None` for art exported as pictures. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub format_3d: Option, } /// What the assets are named when they aren't named after their object: `Asset 1`, `Asset 2`… diff --git a/crates/doc/src/canvas_range.rs b/crates/doc/src/canvas_range.rs index cf85507c7..114235696 100644 --- a/crates/doc/src/canvas_range.rs +++ b/crates/doc/src/canvas_range.rs @@ -313,8 +313,8 @@ mod tests { d.guides = vec![Guide { layer: Some(NodeId(40)), ..Guide::new(false, 5.0) }, Guide { layer: Some(d.layers[0].id), ..Guide::new(true, 5.0) }]; d.saved_selections = vec![crate::records::SavedSelection { name: "s".into(), objects: vec![NodeId(10), NodeId(11)] }].into(); d.assets = vec![ - crate::assets::ExportAsset { id: 1, name: "far".into(), nodes: vec![NodeId(11)] }, - crate::assets::ExportAsset { id: 2, name: "both".into(), nodes: vec![NodeId(10), NodeId(11)] }, + crate::assets::ExportAsset { id: 1, name: "far".into(), nodes: vec![NodeId(11)], format_3d: None }, + crate::assets::ExportAsset { id: 2, name: "both".into(), nodes: vec![NodeId(10), NodeId(11)], format_3d: None }, ]; d.variables.bindings.insert(NodeId(11), ["v".to_string()].into()); d.variables.bindings.insert(NodeId(10), ["w".to_string()].into()); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index b251b15a6..ccdb902d7 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -84,11 +84,12 @@ pub use reshape::{ }; pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve}; pub use solids::{ - EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, THREE_D_TYPES, extrude_options, has_solid, inflate_options, is_three_d, solid_art, - three_d_params_as, validate_solid, validate_three_d, view_options, + EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, SolidMesh, THREE_D_TYPES, extrude_options, has_solid, inflate_options, is_three_d, solid_art, + three_d_meshes, three_d_params_as, validate_solid, validate_three_d, view_options, }; pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings}; pub use trap::TRAP; +pub use vectorcraft_three_d::Mesh; pub use warp::{WarpStyle, warp_point}; mod catalog; diff --git a/crates/effects/src/revolve.rs b/crates/effects/src/revolve.rs index b2528818b..ba4b0e657 100644 --- a/crates/effects/src/revolve.rs +++ b/crates/effects/src/revolve.rs @@ -226,6 +226,45 @@ pub(crate) fn expand_revolve_art(n: &Node) -> Option { art(n, true) } +/// The surfaces [`revolve_art`] draws, as meshes ([`crate::three_d_meshes`]): with the effect on +/// the object, one in its top visible fill (else its stroke); with it on fills and strokes, one for +/// each of them. +pub(crate) fn revolve_meshes(n: &Node) -> Result, String> { + let path = profile(n).ok_or("Revolve currently requires a path or compound path")?; + let ctx = GeomContext::of(n); + let mesh = |path: &PathData, fx: &Effect, item: &AppearanceItem| -> Result { + let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?; + let (paint, opacity, _) = paint_of(item); + let mesh = vectorcraft_three_d::revolve_mesh(path, b, revolve_options(&merged_params(REVOLVE, &fx.params)))?; + Ok(crate::SolidMesh { mesh, color: crate::solids::mesh_color(&paint, opacity) }) + }; + if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) { + let before = n.appearance.effects.get(..index).unwrap_or_default(); + let Some(fx) = n.appearance.effects.get(index) else { return Ok(vec![]) }; + let path = geometry(&path, before, &ctx); + let item = n + .appearance + .items + .iter() + .rev() + .find(|i| matches!(i, AppearanceItem::Fill(_)) && crate::paints(i)) + .or_else(|| n.appearance.items.iter().rev().find(|i| crate::paints(i))) + .ok_or("the revolved object has no fill or stroke")?; + return Ok(vec![mesh(&path, fx, item)?]); + } + let path = geometry(&path, &n.appearance.effects, &ctx); + let mut out = vec![]; + for item in n.appearance.items.iter().filter(|i| crate::paints(i)) { + if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) + && let Some(fx) = item.effects().get(index) + { + let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); + out.push(mesh(&p, fx, item)?); + } + } + Ok(out) +} + fn art(n: &Node, expand: bool) -> Option { if !has_revolve(n) { return None; diff --git a/crates/effects/src/solids.rs b/crates/effects/src/solids.rs index 3319d79af..09a2fc8ec 100644 --- a/crates/effects/src/solids.rs +++ b/crates/effects/src/solids.rs @@ -6,7 +6,7 @@ use serde_json::Value; use vectorcraft_doc::color::{BlendMode, Paint}; use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind, StrokeLayer}; use vectorcraft_geom::{FillRule, PathData, Rect}; -use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, View}; +use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, Mesh, View}; #[cfg(test)] use vectorcraft_doc::color::Color; @@ -221,6 +221,10 @@ fn union_bounds(shapes: &[Profile]) -> Option { /// The most members a 3D effect on a group builds solids from. const MAX_MEMBERS: usize = 4096; +/// The most triangles the meshes of one 3D effect hold together (about 290 MB of corners and +/// normals): past it the object is too complex to export. +const MAX_MESH_TRIANGLES: usize = 2_000_000; + /// The shapes a 3D effect on group or layer `n` builds its solids from: every visible path, /// compound path and type in it, at any depth of its groups, each through its own geometry /// effects and painted with its own fills and strokes. Like the effect on a group in the @@ -338,6 +342,38 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData, } } +/// The parts Extrude & Bevel and Inflate build solids from: each shape's top fill and its strokes, +/// a group's members from the back up, each with its shape's opacity and the region it paints. +/// The solids share the bounds of all their parts (`fallback` when none has any): one center to +/// turn, twist and taper about, and Inflate's grid reaches over the strokes' outlines. +fn solid_parts(shapes: &[Profile], fallback: Rect) -> (Vec<(&AppearanceItem, f32, PathData, FillRule)>, Rect) { + let parts: Vec<(&AppearanceItem, f32, PathData, FillRule)> = shapes + .iter() + .flat_map(|s| { + let top_fill = s.items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_))); + s.items.iter().enumerate().filter(move |(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_))).map(|(_, i)| { + let (area, rule) = region(&s.path, s.rule, i); + (i, s.opacity, area, rule) + }) + }) + .collect(); + let bounds = parts.iter().filter_map(|(_, _, area, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(fallback); + (parts, bounds) +} + +/// `shapes`, the members of group or layer `n`, reshaped by the visible geometry effects `before` +/// its 3D effect, about the bounds of all of them. +fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) { + if before.iter().any(|e| e.visible && is_geometry(&e.id)) + && let Some(b) = union_bounds(shapes) + { + let ctx = GeomContext::of(n); + for s in shapes { + s.path = apply_geometry_with(before, &s.path, b, &ctx); + } + } +} + /// The 3D art of one effect `fx` on `path` (already through the effects before it). /// Rotate turns every fill and stroke, each in its own paint. Extrude & Bevel and Inflate build a /// solid from the path for the top visible fill and one from the outline of each visible stroke, @@ -363,20 +399,7 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option>> } return Some(out); } - // Each shape's top fill and its strokes, a group's members from the back up. - let parts: Vec<(&AppearanceItem, f32, PathData, FillRule)> = shapes - .iter() - .flat_map(|s| { - let top_fill = s.items.iter().rposition(|i| matches!(i, AppearanceItem::Fill(_))); - s.items.iter().enumerate().filter(move |(k, i)| Some(*k) == top_fill || matches!(i, AppearanceItem::Stroke(_))).map(|(_, i)| { - let (area, rule) = region(&s.path, s.rule, i); - (i, s.opacity, area, rule) - }) - }) - .collect(); - // The solids share the bounds of all their shapes: one center to turn, twist and taper about, - // and Inflate's grid reaches over the strokes' outlines. - let bounds = parts.iter().filter_map(|(_, _, area, _)| area.bounds()).reduce(|a, c| a.union(c)).unwrap_or(b); + let (parts, bounds) = solid_parts(shapes, b); let built = if fx.id == INFLATE { // One eye for every puff, as far away as the highest needs. let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule)| (area, *rule)).collect(); @@ -493,14 +516,7 @@ pub fn solid_art(n: &Node) -> Option { fn group_solid_art(n: &Node) -> Option { let (index, fx) = solid(&n.appearance.effects)?; let mut shapes = members(n, "").ok()?; - let before = n.appearance.effects.get(..index)?; - if before.iter().any(|e| e.visible && is_geometry(&e.id)) { - let b = union_bounds(&shapes)?; - let ctx = GeomContext::of(n); - for s in &mut shapes { - s.path = apply_geometry_with(before, &s.path, b, &ctx); - } - } + reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes); let children = art_for(n, fx, &shapes)?; let remaining = n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect(); @@ -510,5 +526,147 @@ fn group_solid_art(n: &Node) -> Option { Some(art) } +/// One solid of a 3D object as a mesh, with the color it is painted. +#[derive(Clone, Debug, PartialEq)] +pub struct SolidMesh { + pub mesh: Mesh, + /// The paint's display color (sRGB, 0..1) and its opacity: a gradient's stops averaged, gray + /// for a pattern. + pub color: [f32; 4], +} + +/// The color a mesh of `paint` at `opacity` takes ([`SolidMesh::color`]). +pub(crate) fn mesh_color(paint: &Paint, opacity: f32) -> [f32; 4] { + let (rgb, alpha) = match paint { + Paint::Solid { color, .. } => (color.to_rgb(), 1.0), + Paint::Gradient(g) if !g.gradient.stops.is_empty() => { + let n = g.gradient.stops.len() as f32; + let mut sum = [0.0f32; 4]; + for s in &g.gradient.stops { + let [r, gr, b] = s.color.to_rgb(); + sum = [sum[0] + r, sum[1] + gr, sum[2] + b, sum[3] + s.opacity]; + } + ([sum[0] / n, sum[1] / n, sum[2] / n], sum[3] / n) + } + _ => ([0.5; 3], 1.0), + }; + let unit = |v: f32| if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + [unit(rgb[0]), unit(rgb[1]), unit(rgb[2]), unit(alpha * opacity)] +} + +/// Add `mesh` to `out`, counting its triangles into `total`, or an error when that passes `limit`. +pub(crate) fn keep_mesh(out: &mut Vec, total: &mut usize, mesh: SolidMesh, limit: usize) -> Result<(), String> { + *total = total.saturating_add(mesh.mesh.triangles.len()); + if *total > limit { + return Err("The 3D object is too complex to export".into()); + } + out.push(mesh); + Ok(()) +} + +/// The solids effect `fx` builds from `shapes`, as [`art_for`] draws them, as meshes: Rotate turns +/// every fill and stroke; Extrude & Bevel and Inflate build one from each shape's top fill and one +/// from the outline of each stroke. A stroke whose solid is past the limits is left out. +fn meshes_for(fx: &Effect, shapes: &[Profile], total: &mut usize) -> Result, String> { + let label = crate::effect_info(&fx.id).map_or("This effect", |e| e.label.trim_end_matches('…')); + let b = union_bounds(shapes).ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?; + let p = crate::merged_params(&fx.id, &fx.params); + let view = view_options(&p); + let mut out = vec![]; + let mut keep = |out: &mut Vec, mesh: Mesh, color: [f32; 4]| keep_mesh(out, total, SolidMesh { mesh, color }, MAX_MESH_TRIANGLES); + if fx.id == ROTATE { + for s in shapes { + for item in &s.items { + let (paint, opacity, _) = paint_of(item, s.opacity); + let (area, rule) = region(&s.path, s.rule, item); + keep(&mut out, vectorcraft_three_d::plane_mesh(&area, rule, b, view)?, mesh_color(&paint, opacity))?; + } + } + return Ok(out); + } + let (parts, bounds) = solid_parts(shapes, b); + // Inflate lays out every puff at once (they share one eye); each mesh, and each Extrude & + // Bevel solid, is built as it is reached. + let mut inflated = (fx.id == INFLATE).then(|| { + let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule)| (area, *rule)).collect(); + vectorcraft_three_d::inflate_meshes(&shapes, bounds, inflate_options(&p), view) + }); + let extrude = extrude_options(&p); + for (item, member, area, rule) in &parts { + let item: &AppearanceItem = item; + let mesh = match inflated.as_mut() { + Some(built) => built.next().unwrap_or_else(|| Ok(Mesh::default())), + None => vectorcraft_three_d::extrude_mesh(area, *rule, bounds, extrude, view), + }; + match mesh { + Ok(mesh) => { + let (paint, opacity, _) = paint_of(item, *member); + keep(&mut out, mesh, mesh_color(&paint, opacity))?; + } + Err(_) if matches!(item, AppearanceItem::Stroke(_)) => {} + Err(e) => return Err(e), + } + } + Ok(out) +} + +/// The solids `n`'s Extrude & Bevel, Inflate, Rotate or Revolve effect builds, as meshes with the +/// colors they are painted ([`SolidMesh`]), faces turned away from the viewer included; meshes +/// with no triangles are left out. On a group or layer, the effect's solids of its members +/// ([`members`]). The object's opacity multiplies every color's. An error when `n` carries no +/// such effect, its solids can't be built, or none has a triangle. +pub fn three_d_meshes(n: &Node) -> Result, String> { + // Triangles of every solid together, against [`MAX_MESH_TRIANGLES`]. + let mut total = 0usize; + let meshes = if crate::has_revolve(n) { + let mut out = vec![]; + for m in crate::revolve::revolve_meshes(n)? { + keep_mesh(&mut out, &mut total, m, MAX_MESH_TRIANGLES)?; + } + out + } else if !has_solid(n) { + return Err("the object has no 3D effect (Extrude & Bevel, Revolve, Inflate or Rotate)".into()); + } else if is_container(n) { + let (index, fx) = solid(&n.appearance.effects).ok_or("a 3D effect on a group's fill or stroke draws nothing to export")?; + let label = crate::effect_info(&fx.id).map_or("This effect", |e| e.label.trim_end_matches('…')); + let mut shapes = members(n, label)?; + reshape_members(n, n.appearance.effects.get(..index).unwrap_or_default(), &mut shapes); + meshes_for(fx, &shapes, &mut total)? + } else { + let (path, rule) = profile(n).ok_or("3D export needs a path, compound path, type or group")?; + let ctx = GeomContext::of(n); + let paints = solid_paints(n); + if let Some((index, fx)) = solid(&n.appearance.effects) { + let path = geometry(&path, n.appearance.effects.get(..index).unwrap_or_default(), &ctx); + meshes_for(fx, &[Profile { path, rule, items: paints, opacity: 1.0 }], &mut total)? + } else { + let path = geometry(&path, &n.appearance.effects, &ctx); + let mut out = vec![]; + for item in &paints { + if let Some((index, fx)) = solid(item.effects()) { + let p = geometry(&path, item.effects().get(..index).unwrap_or_default(), &ctx.item(item)); + out.extend(meshes_for(fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0 }], &mut total)?); + } + } + out + } + }; + let meshes: Vec = meshes + .into_iter() + .filter(|m| !m.mesh.is_empty()) + .map(|mut m| { + m.color[3] *= n.opacity.clamp(0.0, 1.0); + m + }) + .collect(); + if meshes.is_empty() { + return Err("its 3D effect builds no solid".into()); + } + Ok(meshes) +} + #[cfg(test)] mod tests; + +#[cfg(test)] +mod tests_mesh; diff --git a/crates/effects/src/solids/tests_mesh.rs b/crates/effects/src/solids/tests_mesh.rs new file mode 100644 index 000000000..9aca2ab8c --- /dev/null +++ b/crates/effects/src/solids/tests_mesh.rs @@ -0,0 +1,140 @@ +use serde_json::json; +use vectorcraft_doc::NodeId; +use vectorcraft_doc::color::{Color, Gradient, GradientKind, GradientPaint, GradientStop}; +use vectorcraft_geom::BezPath; + +use super::*; + +const RED: Color = Color::Rgb { r: 0.8, g: 0.2, b: 0.1 }; + +fn square_node(id: u64, x: f64) -> Node { + let mut b = BezPath::new(); + b.move_to((x, 100.0)); + b.line_to((x + 100.0, 100.0)); + b.line_to((x + 100.0, 200.0)); + b.line_to((x, 200.0)); + b.close_path(); + Node::path(NodeId(id), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(RED), Paint::solid(Color::BLACK), 4.0)) +} + +fn fx(id: &str, params: Value) -> Effect { + crate::new_effect(id, ¶ms).unwrap() +} + +fn close(a: [f32; 4], b: [f32; 4]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5) +} + +#[test] +fn every_3d_effect_gives_a_mesh_for_its_fill_and_its_stroke() { + for id in SOLIDS { + let mut n = square_node(1, 100.0); + n.appearance.effects.push(fx(id, json!({}))); + let meshes = three_d_meshes(&n).unwrap_or_else(|e| panic!("{id}: {e}")); + assert_eq!(meshes.len(), 2, "{id}: the fill's solid and the stroke's"); + assert!(close(meshes[0].color, [0.8, 0.2, 0.1, 1.0]), "{id}: {:?}", meshes[0].color); + assert!(close(meshes[1].color, [0.0, 0.0, 0.0, 1.0]), "{id}: {:?}", meshes[1].color); + assert!(meshes.iter().all(|m| !m.mesh.is_empty()), "{id}"); + } +} + +#[test] +fn the_meshes_of_one_object_share_one_triangle_budget() { + let mut n = square_node(1, 100.0); + n.appearance.effects.push(fx(EXTRUDE, json!({}))); + let meshes = three_d_meshes(&n).unwrap(); + let first = meshes[0].mesh.triangles.len(); + assert!(first > 0); + // Every mesh counts against what the ones before it used. + let (mut out, mut total) = (vec![], 0); + keep_mesh(&mut out, &mut total, meshes[0].clone(), first).unwrap(); + assert!(keep_mesh(&mut out, &mut total, meshes[1].clone(), first).is_err()); + assert_eq!(out.len(), 1); + // Building past the budget stops with an error instead of holding every mesh. + let shapes = [Profile { path: profile(&n).unwrap().0, rule: FillRule::NonZero, items: solid_paints(&n), opacity: 1.0 }]; + let Some(effect) = n.appearance.effects.first() else { panic!("no effect") }; + let mut total = 0; + assert!(meshes_for(effect, &shapes, &mut total).is_ok()); + let mut total = MAX_MESH_TRIANGLES - first + 1; + assert!(meshes_for(effect, &shapes, &mut total).is_err_and(|e| e.contains("too complex"))); +} + +#[test] +fn an_extruded_mesh_is_as_deep_as_the_effect() { + let mut n = square_node(1, 100.0); + n.appearance.items.truncate(1); + n.appearance.effects.push(fx(EXTRUDE, json!({ "depth": 30.0, "rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0 }))); + let meshes = three_d_meshes(&n).unwrap(); + let (lo, hi) = meshes[0].mesh.bounds().unwrap(); + assert!((hi[2] - lo[2] - 30.0).abs() < 1e-9 && (hi[0] - lo[0] - 100.0).abs() < 1e-9, "{lo:?} {hi:?}"); +} + +#[test] +fn revolve_gives_a_mesh_in_the_top_fill() { + let mut b = BezPath::new(); + b.move_to((100.0, 40.0)); + b.line_to((130.0, 60.0)); + b.line_to((130.0, 140.0)); + b.line_to((100.0, 160.0)); + let mut n = Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(RED), Paint::None, 0.0)); + n.appearance.effects.push(fx(crate::REVOLVE, json!({}))); + let meshes = three_d_meshes(&n).unwrap(); + assert_eq!(meshes.len(), 1); + assert!(close(meshes[0].color, [0.8, 0.2, 0.1, 1.0])); + assert!(meshes[0].mesh.triangles.len() >= 64 * 3, "three profile edges swept in 64 steps"); +} + +#[test] +fn an_object_without_a_3d_effect_has_no_mesh() { + let mut n = square_node(1, 100.0); + assert!(three_d_meshes(&n).is_err()); + n.appearance.effects.push(fx(EXTRUDE, json!({}))); + n.appearance.effects[0].visible = false; + assert!(three_d_meshes(&n).is_err(), "a hidden effect builds nothing"); +} + +#[test] +fn the_object_and_the_paint_opacities_multiply() { + let mut n = square_node(1, 100.0); + n.appearance.items.truncate(1); + if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() { + f.opacity = 0.5; + } + n.opacity = 0.5; + n.appearance.effects.push(fx(EXTRUDE, json!({}))); + let meshes = three_d_meshes(&n).unwrap(); + assert!((meshes[0].color[3] - 0.25).abs() < 1e-6, "{:?}", meshes[0].color); +} + +#[test] +fn an_effect_on_one_fill_gives_that_fills_mesh_alone() { + let mut n = square_node(1, 100.0); + if let Some(AppearanceItem::Stroke(s)) = n.appearance.items.get_mut(1) { + s.effects.push(fx(EXTRUDE, json!({}))); + } + let meshes = three_d_meshes(&n).unwrap(); + assert_eq!(meshes.len(), 1); + assert!(close(meshes[0].color, [0.0, 0.0, 0.0, 1.0])); +} + +#[test] +fn a_group_gives_a_mesh_for_each_member() { + let mut g = Node::group(NodeId(9), vec![Arc::new(square_node(1, 0.0)), Arc::new(square_node(2, 300.0))]); + g.appearance.effects.push(fx(EXTRUDE, json!({ "rotationX": 0.0, "rotationY": 0.0, "rotationZ": 0.0 }))); + let meshes = three_d_meshes(&g).unwrap(); + assert_eq!(meshes.len(), 4, "each member's fill and stroke"); + // The members turn about their shared centre: the first is left of it, the second right. + let (lo, _) = meshes[0].mesh.bounds().unwrap(); + let (_, hi) = meshes[2].mesh.bounds().unwrap(); + assert!(lo[0] < -150.0 && hi[0] > 150.0, "{lo:?} {hi:?}"); +} + +#[test] +fn a_gradient_takes_the_average_of_its_stops() { + let stops = + vec![GradientStop::new(0.0, Color::rgb(1.0, 0.0, 0.0)), GradientStop { opacity: 0.5, ..GradientStop::new(1.0, Color::rgb(0.0, 0.0, 1.0)) }]; + let paint = Paint::Gradient(Box::new(GradientPaint::new(Gradient::new(GradientKind::Linear, stops)))); + assert!(close(mesh_color(&paint, 1.0), [0.5, 0.0, 0.5, 0.75])); + let pattern = Paint::Pattern { pattern: "x".into(), xf: vectorcraft_geom::Affine::IDENTITY }; + assert!(close(mesh_color(&pattern, 0.5), [0.5, 0.5, 0.5, 0.5])); +} diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index 133fb1fce..fd273e067 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -3,12 +3,17 @@ //! settings Export for Screens shares (`document.exportSettings`). File → Export Selection collects //! the selection this way and opens Export for Screens on its Assets tab (a UI command). +use std::collections::HashSet; + use serde_json::{Map, Value, json}; use vectorcraft_doc::{ExportAsset, NodeId}; use super::edit::{roots_of, selected_roots}; -use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_crop, export_screens, store_screen_settings, with_screen_export_preset}; -use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, has_doc, ok, str_param}; +use super::fileio::{ + DEFAULT_FORMAT_3D, FORMATS_3D, SCREEN_SETTINGS_KEYS, check_screen_settings, create_dir, export_crop, export_screens, mesh_file_names, mesh_files, + safe_file_name, store_screen_settings, with_screen_export_preset, write_file, +}; +use super::{CommandSpec, EngineError, Result, Session, bad, bool_or, checked_ids_param, cmd, has_doc, ok, str_param, switch_or, text_param}; /// The longest asset name kept (characters). const MAX_NAME: usize = 255; @@ -39,7 +44,7 @@ pub fn specs() -> Vec { "Assets", [], None, - "{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the bounds the renderer measures for what its art draws, effects included)}]} the Asset Export panel's assets, in order", + "{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the bounds the renderer measures for what its art draws, effects included), format3D: usda|usdz|gltf|obj | null (a 3D asset's mesh format; null for art exported as pictures)}]} the Asset Export panel's assets, in order", has_doc, list ), @@ -48,10 +53,37 @@ pub fn specs() -> Vec { "Export Assets", [], None, - "{assets?: [asset id…] (default all), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)", + "{assets?: [asset id…] (default all but the 3D assets, which assets.export3D writes), folder?, zip?, openLocation?, formats?, preset?, settings?, prefix?, subfolders?, antiAlias?} each asset's art alone, cropped to it, in every format row: document.exportForScreens {assets} at the document's export settings (document.exportSettings) under the ones given; with subfolders false every file goes into the folder itself, mobile and density rows too → what document.exportForScreens returns (no folder: the files, or one zip, as dataBase64). Leaves the remembered settings as they are (assets.settings.set changes them)", has_doc, export ), + cmd!( + "assets.add3D", + "Export 3D Object", + [], + None, + "{ids?: [id…] (default: the selected objects), format?: usda|usdz|gltf|obj (default usda)} collect each object with an Extrude & Bevel, Revolve, Inflate or Rotate effect as a 3D asset of Window › Asset Export, named after the object (else Asset 1, Asset 2…): assets.export3D writes it as a mesh file. An object already collected as a 3D asset keeps it; an object without such an effect is refused → {assets: [asset id…] (in order), added: n}", + has_doc, + add_3d + ), + cmd!( + "assets.set3DFormat", + "3D Export Format", + [], + None, + "{assets: [asset id…], format: usda|usdz|gltf|obj} the mesh format 3D assets export to (Asset Export's Format for a 3D asset) → {changed: n}", + has_doc, + set_3d_format + ), + cmd!( + "assets.export3D", + "Export 3D Assets", + [], + None, + "{assets?: [asset id…] (default every 3D asset), folder?, zip?} each 3D asset's object as a mesh file in its format (assets.set3DFormat), named after the asset: USDA or USDZ, glTF 2.0 (one .gltf, its buffer embedded) or OBJ with its .mtl (whitespace and # in an OBJ's name become _). Every solid of the object (the fill's, each stroke's, a group's members') is a mesh with a material in the color it is painted; faces turned away from the viewer are included, at the object's 3D rotation, y up, about the point it turns about, in points (glTF: meters). Files that would share a name get -2, -3…; an object with a hole that crosses its face's outline or another hole, or with more than 2,000,000 triangles, is refused → {files: [path…]} (no folder: {files: [{name, dataBase64}]}, or with zip one zip as {name, dataBase64, files})", + has_doc, + export_3d + ), cmd!( "assets.settings.set", "Asset Export Settings", @@ -115,7 +147,9 @@ fn add(s: &mut Session, p: &Value) -> Result { if name.is_some() && sets.len() > 1 { return Err(bad(C, "a name names one asset: give multiple: false, or one object")); } - let existing = |d: &vectorcraft_doc::Document, nodes: &[NodeId]| d.assets.iter().find(|a| a.nodes == nodes).map(|a| a.id); + // A 3D asset of the same art is another asset. + let existing = + |d: &vectorcraft_doc::Document, nodes: &[NodeId]| d.assets.iter().find(|a| a.nodes == nodes && a.format_3d.is_none()).map(|a| a.id); let d = &s.doc()?.doc; // Nothing new: no undo step. if sets.iter().all(|set| existing(d, set).is_some()) { @@ -134,7 +168,7 @@ fn add(s: &mut Session, p: &Value) -> Result { }; let name = name.clone().or(own).unwrap_or_else(|| d.next_asset_name()); let id = d.alloc_id().0; - d.assets.push(ExportAsset { id, name, nodes }); + d.assets.push(ExportAsset { id, name, nodes, format_3d: None }); out.push(id); added += 1; } @@ -142,6 +176,162 @@ fn add(s: &mut Session, p: &Value) -> Result { }) } +/// The format of param `format` of command `cmd` ([`FORMATS_3D`]), `default` when it is not given. +fn format_3d_param(p: &Value, cmd: &str, default: Option<&str>) -> Result { + match (p.get("format"), default) { + (None, Some(d)) => Ok(d.to_string()), + (Some(Value::String(f)), _) if FORMATS_3D.contains(&f.as_str()) => Ok(f.clone()), + _ => { + let key = "format"; + Err(bad(cmd, format!("`{key}` must be one of {}", FORMATS_3D.join(", ")))) + } + } +} + +/// The asset ids of param `key` of command `cmd`, each a 3D asset of `d`. +fn three_d_asset_ids(d: &vectorcraft_doc::Document, p: &Value, key: &str, cmd: &str) -> Result> { + let ids = asset_ids(p, key, cmd)?; + for id in &ids { + match d.asset(*id) { + None => return Err(bad(cmd, "no such assets (see assets.list)")), + Some(a) if a.format_3d.is_none() => return Err(bad(cmd, format!("asset {id} isn't a 3D asset"))), + _ => {} + } + } + Ok(ids) +} + +fn add_3d(s: &mut Session, p: &Value) -> Result { + const C: &str = "assets.add3D"; + let format = format_3d_param(p, C, Some(DEFAULT_FORMAT_3D))?; + let ids = if p.get("ids").is_some_and(|v| !v.is_null()) { checked_ids_param(s, p, "ids", C)? } else { selected_roots(s)? }; + if ids.is_empty() { + return Err(bad(C, "select an object with a 3D effect (or give its ids)")); + } + let d = &s.doc()?.doc; + let mut ids = d.paint_order(ids); + ids.dedup(); + for id in &ids { + let n = d.node(*id).ok_or_else(|| bad(C, format!("no such object {0}", id.0)))?; + if let Err(e) = vectorcraft_render::effects::three_d_meshes(n) { + let what = n.name.clone().unwrap_or_else(|| format!("#{}", id.0)); + return Err(bad(C, format!("{what} can't be exported in 3D: {e}"))); + } + } + let existing = |d: &vectorcraft_doc::Document, id: NodeId| d.assets.iter().find(|a| a.nodes == [id] && a.format_3d.is_some()).map(|a| a.id); + // Nothing new: no undo step. + if ids.iter().all(|id| existing(d, *id).is_some()) { + return Ok(json!({ "assets": ids.iter().filter_map(|id| existing(d, *id)).collect::>(), "added": 0 })); + } + s.edit("Export 3D Object", |d, _| { + let (mut out, mut added) = (vec![], 0); + for node in ids { + if let Some(id) = existing(d, node) { + out.push(id); + continue; + } + let name = d.node(node).and_then(|n| n.name.as_deref()).and_then(clean_name).unwrap_or_else(|| d.next_asset_name()); + let id = d.alloc_id().0; + d.assets.push(ExportAsset { id, name, nodes: vec![node], format_3d: Some(format.clone()) }); + out.push(id); + added += 1; + } + Ok(json!({ "assets": out, "added": added })) + }) +} + +fn set_3d_format(s: &mut Session, p: &Value) -> Result { + const C: &str = "assets.set3DFormat"; + let d = &s.doc()?.doc; + let ids = three_d_asset_ids(d, p, "assets", C)?; + let format = format_3d_param(p, C, None)?; + let changed = d.assets.iter().filter(|a| ids.contains(&a.id) && a.format_3d.as_deref() != Some(format.as_str())).count(); + // Nothing changes: no undo step. + if changed == 0 { + return Ok(json!({ "changed": 0 })); + } + s.edit("3D Export Format", |d, _| { + for a in d.assets.iter_mut().filter(|a| ids.contains(&a.id)) { + a.format_3d = Some(format.clone()); + } + Ok(json!({ "changed": changed })) + }) +} + +fn export_3d(s: &mut Session, p: &Value) -> Result { + const C: &str = "assets.export3D"; + let d = &s.doc()?.doc; + let ids = if p.get("assets").is_some_and(|v| !v.is_null()) { + three_d_asset_ids(d, p, "assets", C)? + } else { + d.assets.iter().filter(|a| a.format_3d.is_some()).map(|a| a.id).collect() + }; + if ids.is_empty() { + return Err(bad(C, "no 3D assets yet: collect an object with a 3D effect first (assets.add3D)")); + } + let folder = text_param(p, "folder", C)?.filter(|f| !f.is_empty()); + let zip = switch_or(p, "zip", false, C)?; + // Every file's name, compared without case: a later file that would take one gets -2, -3… + let mut taken: HashSet = HashSet::new(); + let mut files: Vec<(String, Vec)> = vec![]; + for id in ids { + let Some(a) = d.asset(id) else { continue }; + let format = a.format_3d.as_deref().unwrap_or(DEFAULT_FORMAT_3D); + let mut parts = vec![]; + for node in &a.nodes { + let n = d.node(*node).ok_or_else(|| bad(C, format!("no such object {0}", node.0)))?; + let what = &a.name; + parts.extend(vectorcraft_render::effects::three_d_meshes(n).map_err(|e| bad(C, format!("{what} can't be exported in 3D: {e}")))?); + } + let base = Some(safe_file_name(&a.name)).filter(|b| !b.is_empty()).unwrap_or_else(|| "Asset".into()); + let mut stem = base.clone(); + let mut k = 1; + loop { + let names = mesh_file_names(&stem, format).map_err(|e| bad(C, e))?; + if names.iter().all(|n| !taken.contains(&n.to_lowercase())) { + break; + } + k += 1; + stem = format!("{base}-{k}"); + } + let made = mesh_files(&stem, format, &parts).map_err(|e| bad(C, e))?; + taken.extend(made.iter().map(|(n, _)| n.to_lowercase())); + files.extend(made); + } + let names: Vec<&str> = files.iter().map(|(n, _)| n.as_str()).collect(); + if zip { + let archive = super::fileio::zip::store(&files).map_err(|e| bad(C, e))?; + // "-3D" keeps it apart from the picture assets' ZIP, which is named after the document. + let name = Some(safe_file_name(&d.title)).filter(|t| !t.is_empty()).map_or_else(|| "3D Assets.zip".into(), |t| format!("{t}-3D.zip")); + return match folder { + Some(dir) => { + create_dir(dir)?; + let path = format!("{dir}/{name}"); + write_file(&path, &archive)?; + Ok(json!({ "path": path, "bytes": archive.len(), "files": names })) + } + None => Ok(json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(&archive), "bytes": archive.len(), "files": names })), + }; + } + match folder { + Some(dir) => { + create_dir(dir)?; + let mut written = vec![]; + for (name, bytes) in &files { + let path = format!("{dir}/{name}"); + write_file(&path, bytes)?; + written.push(path); + } + Ok(json!({ "files": written })) + } + None => { + let files: Vec = + files.iter().map(|(name, bytes)| json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(bytes) })).collect(); + Ok(json!({ "files": files })) + } + } +} + /// The asset ids of param `key`. fn asset_ids(p: &Value, key: &str, cmd: &str) -> Result> { let ids = p.get(key).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{key}` must be an array of asset ids (see assets.list)")))?; @@ -186,6 +376,7 @@ fn list(s: &mut Session, _: &Value) -> Result { "name": a.name, "nodes": a.nodes.iter().map(|n| n.0).collect::>(), "bounds": bounds.map(|b| [b.x0, b.y0, b.width(), b.height()]), + "format3D": a.format_3d, }) }) .collect(); @@ -227,8 +418,10 @@ fn export(s: &mut Session, p: &Value) -> Result { if q.get("subfolders").and_then(Value::as_bool) == Some(false) && p.get("folders").is_none() { q.insert("folders".into(), json!("none")); } + let d = &s.doc()?.doc; + // 3D assets are written as mesh files, by assets.export3D: the export refuses one it is given. if !q.contains_key("assets") { - let all: Vec = s.doc()?.doc.assets.iter().map(|a| a.id).collect(); + let all: Vec = d.assets.iter().filter(|a| a.format_3d.is_none()).map(|a| a.id).collect(); if all.is_empty() { return Err(bad(C, "no assets yet: collect art for export first (assets.add)")); } diff --git a/crates/engine/src/cmd/fileio.rs b/crates/engine/src/cmd/fileio.rs index 3f881b8e4..c453fecd7 100644 --- a/crates/engine/src/cmd/fileio.rs +++ b/crates/engine/src/cmd/fileio.rs @@ -17,6 +17,7 @@ //! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`. //! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`. //! - `metafile`: EMF and WMF export, open and place. +//! - `mesh3d`: 3D assets as OBJ, glTF, USDA and USDZ mesh files (`assets.export3D`). //! //! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`], //! agents query `document.formats`. @@ -32,6 +33,7 @@ mod export; pub(crate) mod imagemap; mod legacy_ai; mod load; +mod mesh3d; pub(crate) use load::{MAX_RASTER_ALLOC, size_note}; mod heifread; mod jp2read; @@ -74,6 +76,8 @@ pub use load::{ read_for_open, texture_image, }; pub use load::{losses_summary, overwrite_losses}; +pub use mesh3d::{DEFAULT_FORMAT_3D, FORMATS_3D, format_3d_label}; +pub(crate) use mesh3d::{mesh_file_names, mesh_files}; pub use naming::{NameParts as FileNameParts, parse_date as parse_file_date, render as render_file_name}; pub(crate) use native::preview_png; pub use native::with_compression_pref; diff --git a/crates/engine/src/cmd/fileio/mesh3d.rs b/crates/engine/src/cmd/fileio/mesh3d.rs new file mode 100644 index 000000000..50a919d83 --- /dev/null +++ b/crates/engine/src/cmd/fileio/mesh3d.rs @@ -0,0 +1,260 @@ +//! 3D objects as mesh files, for a 3D asset of Window › Asset Export (`assets.export3D`): OBJ with +//! its MTL materials, glTF 2.0 (one `.gltf` file, its buffer embedded as a data URI), USDA and +//! USDZ (the USDA layer in an uncompressed ZIP, its data on a 64-byte boundary). +//! +//! Each solid of the object ([`vectorcraft_render::effects::three_d_meshes`]) is its own mesh, with a +//! material in the color it is painted (solids of one color share it). Positions are y up and +//! about the point the object turns about: in points in OBJ and USD (whose layer says how many +//! meters a unit is), in meters in glTF, as its specification asks. glTF and USD colors are linear, +//! as their specifications ask; MTL colors are the display colors. Materials are two-sided, since +//! an open solid or a turned plane is seen from both sides. +//! +//! Formats: glTF 2.0 , USD +//! (USDZ: ). + +use std::fmt::Write as _; + +use serde_json::{Value, json}; +use vectorcraft_render::effects::SolidMesh; + +/// The formats a 3D asset exports to, as `assets.set3DFormat` names them: USDA first, the default. +pub const FORMATS_3D: [&str; 4] = ["usda", "usdz", "gltf", "obj"]; +/// The format a new 3D asset exports to. +pub const DEFAULT_FORMAT_3D: &str = "usda"; +/// The meters in a point (1/72 inch). +const METERS_PER_POINT: f64 = 0.0254 / 72.0; + +/// The label of `format` for menus: `USDA`, `USDZ`, `glTF`, `OBJ`. +pub fn format_3d_label(format: &str) -> &'static str { + match format { + "usdz" => "USDZ", + "gltf" => "glTF", + "obj" => "OBJ", + _ => "USDA", + } +} + +/// `stem` as an OBJ file name: whitespace and `#` become `_`, so the `mtllib` line names the MTL +/// file as one word. +fn obj_stem(stem: &str) -> String { + stem.chars().map(|c| if c.is_whitespace() || c == '#' { '_' } else { c }).collect() +} + +/// The names of the files [`mesh_files`] writes for `stem` in `format`. +pub(crate) fn mesh_file_names(stem: &str, format: &str) -> Result, String> { + match format { + "obj" => { + let stem = obj_stem(stem); + Ok(vec![format!("{stem}.obj"), format!("{stem}.mtl")]) + } + "gltf" | "usda" | "usdz" => Ok(vec![format!("{stem}.{format}")]), + _ => { + let key = "format"; + Err(format!("`{key}` must be one of {}", FORMATS_3D.join(", "))) + } + } +} + +/// The files of a 3D object named `stem` in `format` ([`FORMATS_3D`]): `(file name, bytes)`, the +/// OBJ with its MTL after it. +pub(crate) fn mesh_files(stem: &str, format: &str, parts: &[SolidMesh]) -> Result)>, String> { + let names = mesh_file_names(stem, format)?; + let bytes = match format { + "obj" => { + let (obj, mtl) = obj(&obj_stem(stem), parts); + vec![obj.into_bytes(), mtl.into_bytes()] + } + "gltf" => vec![gltf(stem, parts).into_bytes()], + "usda" => vec![usda(stem, parts).into_bytes()], + _ => vec![super::zip::store_aligned(&[(format!("{stem}.usda"), usda(stem, parts).into_bytes())], 64)?], + }; + Ok(names.into_iter().zip(bytes).collect()) +} + +/// The distinct colors of `parts` in order, and each part's index among them. +fn materials(parts: &[SolidMesh]) -> (Vec<[f32; 4]>, Vec) { + let mut colors: Vec<[f32; 4]> = vec![]; + let index = parts + .iter() + .map(|p| { + colors.iter().position(|c| *c == p.color).unwrap_or_else(|| { + colors.push(p.color); + colors.len() - 1 + }) + }) + .collect(); + (colors, index) +} + +/// An sRGB channel (0..1) as linear light. +fn linear(c: f32) -> f32 { + if c <= 0.04045 { c / 12.92 } else { ((c + 0.055) / 1.055).powf(2.4) } +} + +/// A number as text: the shortest decimal that reads back as the same value. +fn num(v: f64) -> String { + format!("{v}") +} + +fn obj(stem: &str, parts: &[SolidMesh]) -> (String, String) { + let (colors, material) = materials(parts); + let mut o = String::new(); + let _ = writeln!(o, "mtllib {stem}.mtl"); + let _ = writeln!(o, "o {stem}"); + for p in parts { + for v in &p.mesh.positions { + let _ = writeln!(o, "v {} {} {}", num(v[0]), num(v[1]), num(v[2])); + } + } + for p in parts { + for n in &p.mesh.normals { + let _ = writeln!(o, "vn {} {} {}", num(n[0]), num(n[1]), num(n[2])); + } + } + let mut base = 1usize; + for (k, p) in parts.iter().enumerate() { + let _ = writeln!(o, "g Solid_{}", k + 1); + let _ = writeln!(o, "usemtl Material_{}", material.get(k).copied().unwrap_or(0) + 1); + for t in &p.mesh.triangles { + let [a, b, c] = t.map(|i| i as usize + base); + let _ = writeln!(o, "f {a}//{a} {b}//{b} {c}//{c}"); + } + base += p.mesh.positions.len(); + } + let mut m = String::new(); + for (k, c) in colors.iter().enumerate() { + let _ = writeln!(m, "newmtl Material_{}", k + 1); + let _ = writeln!(m, "Kd {} {} {}", num(f64::from(c[0])), num(f64::from(c[1])), num(f64::from(c[2]))); + let _ = writeln!(m, "d {}", num(f64::from(c[3]))); + let _ = writeln!(m, "illum 1"); + } + (o, m) +} + +fn gltf(stem: &str, parts: &[SolidMesh]) -> String { + let (colors, material) = materials(parts); + let mut buffer: Vec = vec![]; + let (mut views, mut accessors, mut primitives) = (vec![], vec![], vec![]); + let mut view = |buffer: &mut Vec, bytes: Vec, target: u32| -> usize { + views.push(json!({ "buffer": 0, "byteOffset": buffer.len(), "byteLength": bytes.len(), "target": target })); + buffer.extend(bytes); + views.len() - 1 + }; + for (k, p) in parts.iter().enumerate() { + let floats = |v: &[[f64; 3]], scale: f64| v.iter().flatten().flat_map(|x| ((x * scale) as f32).to_le_bytes()).collect::>(); + let (lo, hi) = p.mesh.bounds().unwrap_or_default(); + let positions = view(&mut buffer, floats(&p.mesh.positions, METERS_PER_POINT), 34962); + let normals = view(&mut buffer, floats(&p.mesh.normals, 1.0), 34962); + let indices = view(&mut buffer, p.mesh.triangles.iter().flatten().flat_map(|i| i.to_le_bytes()).collect(), 34963); + let count = p.mesh.positions.len(); + let at = accessors.len(); + accessors.push(json!({ + "bufferView": positions, "componentType": 5126, "count": count, "type": "VEC3", + "min": lo.map(|v| (v * METERS_PER_POINT) as f32), "max": hi.map(|v| (v * METERS_PER_POINT) as f32), + })); + accessors.push(json!({ "bufferView": normals, "componentType": 5126, "count": count, "type": "VEC3" })); + accessors.push(json!({ "bufferView": indices, "componentType": 5125, "count": p.mesh.triangles.len() * 3, "type": "SCALAR" })); + primitives.push(json!({ + "attributes": { "POSITION": at, "NORMAL": at + 1 }, + "indices": at + 2, + "material": material.get(k).copied().unwrap_or(0), + })); + } + let materials: Vec = colors + .iter() + .enumerate() + .map(|(k, c)| { + let mut m = json!({ + "name": format!("Material_{}", k + 1), + "pbrMetallicRoughness": { + "baseColorFactor": [linear(c[0]), linear(c[1]), linear(c[2]), c[3]], + "metallicFactor": 0.0, + "roughnessFactor": 1.0, + }, + "doubleSided": true, + }); + if c[3] < 1.0 { + m["alphaMode"] = json!("BLEND"); + } + m + }) + .collect(); + let doc = json!({ + "asset": { "version": "2.0", "generator": "VectorCraft" }, + "scene": 0, + "scenes": [{ "nodes": [0] }], + "nodes": [{ "name": stem, "mesh": 0 }], + "meshes": [{ "name": stem, "primitives": primitives }], + "materials": materials, + "buffers": [{ + "byteLength": buffer.len(), + "uri": format!("data:application/octet-stream;base64,{}", vectorcraft_format::base64_encode(&buffer)), + }], + "bufferViews": views, + "accessors": accessors, + }); + serde_json::to_string_pretty(&doc).unwrap_or_default() +} + +/// `name` as a USD prim name: letters, digits and `_`, not starting with a digit. +fn prim_name(name: &str) -> String { + let mut out: String = name.chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }).collect(); + if out.is_empty() || out.starts_with(|c: char| c.is_ascii_digit()) { + out.insert(0, '_'); + } + out +} + +fn usda(stem: &str, parts: &[SolidMesh]) -> String { + let (colors, material) = materials(parts); + let root = prim_name(stem); + let triple = |v: [f64; 3]| format!("({}, {}, {})", num(v[0]), num(v[1]), num(v[2])); + let list = |items: Vec| items.join(", "); + let mut u = String::new(); + let _ = writeln!(u, "#usda 1.0"); + let _ = writeln!(u, "(\n defaultPrim = \"{root}\"\n metersPerUnit = {}\n upAxis = \"Y\"\n)\n", METERS_PER_POINT); + let _ = writeln!(u, "def Xform \"{root}\"\n{{"); + for (k, p) in parts.iter().enumerate() { + let (lo, hi) = p.mesh.bounds().unwrap_or_default(); + let m = material.get(k).copied().unwrap_or(0) + 1; + let _ = writeln!(u, " def Mesh \"Solid_{}\" (\n prepend apiSchemas = [\"MaterialBindingAPI\"]\n )\n {{", k + 1); + let _ = writeln!(u, " uniform bool doubleSided = 1"); + let _ = writeln!(u, " float3[] extent = [{}, {}]", triple(lo), triple(hi)); + let _ = writeln!(u, " int[] faceVertexCounts = [{}]", list(vec!["3".to_string(); p.mesh.triangles.len()])); + let _ = writeln!(u, " int[] faceVertexIndices = [{}]", list(p.mesh.triangles.iter().flatten().map(u32::to_string).collect())); + let _ = writeln!(u, " rel material:binding = "); + let _ = writeln!( + u, + " normal3f[] normals = [{}] (\n interpolation = \"vertex\"\n )", + list(p.mesh.normals.iter().map(|n| triple(*n)).collect()) + ); + let _ = writeln!(u, " point3f[] points = [{}]", list(p.mesh.positions.iter().map(|v| triple(*v)).collect())); + let c = p.color; + let _ = writeln!( + u, + " color3f[] primvars:displayColor = [({}, {}, {})] (\n interpolation = \"constant\"\n )", + linear(c[0]), + linear(c[1]), + linear(c[2]) + ); + let _ = writeln!(u, " float[] primvars:displayOpacity = [{}] (\n interpolation = \"constant\"\n )", c[3]); + let _ = writeln!(u, " uniform token subdivisionScheme = \"none\"\n }}\n"); + } + let _ = writeln!(u, " def Scope \"Materials\"\n {{"); + for (k, c) in colors.iter().enumerate() { + let m = k + 1; + let _ = writeln!(u, " def Material \"Material_{m}\"\n {{"); + let _ = writeln!(u, " token outputs:surface.connect = \n"); + let _ = writeln!(u, " def Shader \"Surface\"\n {{"); + let _ = writeln!(u, " uniform token info:id = \"UsdPreviewSurface\""); + let _ = writeln!(u, " color3f inputs:diffuseColor = ({}, {}, {})", linear(c[0]), linear(c[1]), linear(c[2])); + let _ = writeln!(u, " float inputs:opacity = {}", c[3]); + let _ = writeln!(u, " float inputs:roughness = 1"); + let _ = writeln!(u, " token outputs:surface\n }}\n }}"); + } + let _ = writeln!(u, " }}\n}}"); + u +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/cmd/fileio/mesh3d/tests.rs b/crates/engine/src/cmd/fileio/mesh3d/tests.rs new file mode 100644 index 000000000..7c467fafa --- /dev/null +++ b/crates/engine/src/cmd/fileio/mesh3d/tests.rs @@ -0,0 +1,130 @@ +use serde_json::Value; +use vectorcraft_render::effects::{Mesh, SolidMesh}; + +use super::*; + +/// A 10 × 10 square facing the viewer: two triangles, each with corners of its own. +fn square(color: [f32; 4]) -> SolidMesh { + let [a, b, c, d] = [[-5.0, -5.0, 0.0], [5.0, -5.0, 0.0], [5.0, 5.0, 0.0], [-5.0, 5.0, 0.0]]; + let mesh = Mesh { positions: vec![a, b, c, a, c, d], normals: vec![[0.0, 0.0, 1.0]; 6], triangles: vec![[0, 1, 2], [3, 4, 5]] }; + SolidMesh { mesh, color } +} + +const RED: [f32; 4] = [1.0, 0.0, 0.0, 1.0]; +const BLUE: [f32; 4] = [0.0, 0.0, 1.0, 0.5]; + +fn text(files: &[(String, Vec)], name: &str) -> String { + let (_, bytes) = files.iter().find(|(n, _)| n == name).unwrap_or_else(|| panic!("no {name}")); + String::from_utf8(bytes.clone()).unwrap() +} + +#[test] +fn obj_numbers_corners_across_solids_and_names_its_materials() { + let files = mesh_files("Box", "obj", &[square(RED), square(BLUE), square(RED)]).unwrap(); + assert_eq!(files.iter().map(|(n, _)| n.as_str()).collect::>(), ["Box.obj", "Box.mtl"]); + let obj = text(&files, "Box.obj"); + assert!(obj.starts_with("mtllib Box.mtl\no Box\n")); + assert_eq!(obj.lines().filter(|l| l.starts_with("v ")).count(), 18); + assert_eq!(obj.lines().filter(|l| l.starts_with("vn ")).count(), 18); + // The third solid's last triangle uses corners 13 to 18; red solids share Material_1. + assert_eq!(obj.lines().rfind(|l| l.starts_with("f ")), Some("f 16//16 17//17 18//18")); + assert_eq!(obj.lines().filter(|l| l.starts_with("usemtl ")).collect::>(), ["usemtl Material_1", "usemtl Material_2", "usemtl Material_1"]); + let mtl = text(&files, "Box.mtl"); + assert_eq!(mtl.matches("newmtl").count(), 2); + assert!(mtl.contains("newmtl Material_2\nKd 0 0 1\nd 0.5\n"), "{mtl}"); +} + +#[test] +fn gltf_holds_its_buffer_and_every_accessor_fits_it() { + let files = mesh_files("Box", "gltf", &[square(RED), square(BLUE)]).unwrap(); + let g: Value = serde_json::from_str(&text(&files, "Box.gltf")).unwrap(); + assert_eq!(g["asset"]["version"], "2.0"); + let uri = g["buffers"][0]["uri"].as_str().unwrap(); + let data = vectorcraft_format::base64_decode(uri.strip_prefix("data:application/octet-stream;base64,").unwrap()).unwrap(); + assert_eq!(g["buffers"][0]["byteLength"].as_u64(), Some(data.len() as u64)); + for v in g["bufferViews"].as_array().unwrap() { + assert!(v["byteOffset"].as_u64().unwrap() + v["byteLength"].as_u64().unwrap() <= data.len() as u64); + assert_eq!(v["byteOffset"].as_u64().unwrap() % 4, 0); + } + let prims = g["meshes"][0]["primitives"].as_array().unwrap(); + assert_eq!(prims.len(), 2); + assert_eq!((prims[0]["material"].as_u64(), prims[1]["material"].as_u64()), (Some(0), Some(1))); + // Positions are meters: the 10 pt square is 10/72 inch across. + let pos = &g["accessors"][prims[0]["attributes"]["POSITION"].as_u64().unwrap() as usize]; + let width = pos["max"][0].as_f64().unwrap() - pos["min"][0].as_f64().unwrap(); + assert!((width - 10.0 * 0.0254 / 72.0).abs() < 1e-7, "{width}"); + let index = &g["accessors"][prims[0]["indices"].as_u64().unwrap() as usize]; + assert_eq!((index["componentType"].as_u64(), index["count"].as_u64()), (Some(5125), Some(6))); + // Translucent paint blends; colors are linear. + assert_eq!(g["materials"][1]["alphaMode"], "BLEND"); + assert!(g["materials"][0].get("alphaMode").is_none()); + assert_eq!(g["materials"][0]["doubleSided"], true); +} + +#[test] +fn usda_is_y_up_in_points_with_a_material_for_each_color() { + let files = mesh_files("My Box", "usda", &[square(RED), square([0.5, 0.5, 0.5, 1.0])]).unwrap(); + let u = text(&files, "My Box.usda"); + assert!(u.starts_with("#usda 1.0\n")); + assert!(u.contains("defaultPrim = \"My_Box\"") && u.contains("upAxis = \"Y\"") && u.contains("def Xform \"My_Box\""), "{u}"); + assert!(u.contains(&format!("metersPerUnit = {}", 0.0254 / 72.0))); + assert_eq!(u.matches("def Mesh").count(), 2); + assert_eq!(u.matches("def Material").count(), 2); + assert!(u.contains("int[] faceVertexCounts = [3, 3]")); + assert!(u.contains("rel material:binding = ")); + // sRGB 0.5 is about 0.214 linear. + assert!(u.contains("color3f inputs:diffuseColor = (0.21404"), "{u}"); + assert_eq!(u.matches('{').count(), u.matches('}').count()); +} + +#[test] +fn usdz_stores_the_layer_uncompressed_on_a_64_byte_boundary() { + let files = mesh_files("Box", "usdz", &[square(RED)]).unwrap(); + let (name, zip) = &files[0]; + assert_eq!(name, "Box.usdz"); + // The local header: 30 bytes, the name, then the extra field. + let u16_at = |at: usize| u16::from_le_bytes([zip[at], zip[at + 1]]) as usize; + assert_eq!(&zip[..4], &[0x50, 0x4b, 0x03, 0x04]); + assert_eq!(u16_at(8), 0, "stored, not deflated"); + let start = 30 + u16_at(26) + u16_at(28); + assert_eq!(start % 64, 0); + let layer = mesh_files("Box", "usda", &[square(RED)]).unwrap().remove(0).1; + assert_eq!(&zip[start..start + layer.len()], layer.as_slice()); + let archive = super::super::zip::Archive::open(zip, 8).unwrap(); + assert_eq!(archive.read("Box.usda", usize::MAX).unwrap(), Some(layer)); +} + +#[test] +fn an_obj_names_its_mtl_file_without_spaces_or_comment_marks() { + let files = mesh_files("Box #1", "obj", &[square(RED)]).unwrap(); + assert_eq!(files.iter().map(|(n, _)| n.as_str()).collect::>(), ["Box__1.obj", "Box__1.mtl"]); + assert!(text(&files, "Box__1.obj").starts_with("mtllib Box__1.mtl\n")); + assert_eq!(mesh_file_names("Box #1", "obj").unwrap(), ["Box__1.obj", "Box__1.mtl"]); + assert_eq!(mesh_file_names("Box #1", "usda").unwrap(), ["Box #1.usda"]); +} + +#[test] +fn obj_and_usda_keep_coordinates_a_32_bit_float_would_merge() { + let mut part = square(RED); + part.mesh.positions[0] = [20_000_000.0, 0.0, 0.0]; + part.mesh.positions[1] = [20_000_001.0, 0.0, 0.0]; + let obj = text(&mesh_files("Box", "obj", std::slice::from_ref(&part)).unwrap(), "Box.obj"); + assert!(obj.contains("v 20000000 0 0\nv 20000001 0 0\n"), "{obj}"); + let usda = text(&mesh_files("Box", "usda", &[part]).unwrap(), "Box.usda"); + assert!(usda.contains("(20000001, 0, 0)")); +} + +#[test] +fn an_unknown_format_is_refused() { + assert!(mesh_files("Box", "fbx", &[square(RED)]).is_err()); + assert!(mesh_file_names("Box", "").is_err()); + assert_eq!(mesh_file_names("Box", "obj").unwrap(), ["Box.obj", "Box.mtl"]); +} + +#[test] +fn prim_names_are_usd_identifiers() { + assert_eq!(prim_name("Logo 2"), "Logo_2"); + assert_eq!(prim_name("3D"), "_3D"); + assert_eq!(prim_name(""), "_"); + assert_eq!(prim_name("é/x"), "__x"); +} diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 11e878bc1..b69cfd177 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -1210,6 +1210,9 @@ fn items( let mut pieces = vec![]; for id in ids { let a = doc.asset(*id).ok_or_else(|| bad(C, format!("no asset {id} (see assets.list)")))?; + if a.format_3d.is_some() { + return Err(bad(C, format!("asset {id} is a 3D asset: assets.export3D writes it"))); + } let (d, r) = alone(&a.nodes, &a.name).ok_or_else(|| bad(C, format!("asset `{}` has no art to export", a.name)))?; pieces.push((a.name.clone(), d, Some(0), r)); } diff --git a/crates/engine/src/cmd/fileio/zip.rs b/crates/engine/src/cmd/fileio/zip.rs index 8279b368f..047fcb56e 100644 --- a/crates/engine/src/cmd/fileio/zip.rs +++ b/crates/engine/src/cmd/fileio/zip.rs @@ -23,6 +23,13 @@ pub const DEFLATE_CHUNK: usize = 1 << 20; /// The archive of `files` (`(name, bytes)`; `/` separates folders in a name), stored without /// compression. Fails past the limits of a ZIP without its 64-bit extension (65534 files, 4 GB; 65535 is the ZIP64 marker). pub fn store, B: AsRef<[u8]>>(files: &[(N, B)]) -> Result, String> { + store_aligned(files, 1) +} + +/// [`store`], with each file's data starting at a multiple of `align` bytes from the start of the +/// archive (USDZ asks for 64): a padding block in the local header's extra field (header id +/// 0x1986) moves it there. +pub fn store_aligned, B: AsRef<[u8]>>(files: &[(N, B)], align: usize) -> Result, String> { let too_big = || "too much to zip: at most 65534 files and 4 GB".to_string(); let count = u16::try_from(files.len()).ok().filter(|&c| c != u16::MAX).ok_or_else(too_big)?; let mut out = Vec::with_capacity(files.iter().map(|(n, b)| 76 + 2 * n.as_ref().len() + b.as_ref().len()).sum::() + 22); @@ -47,8 +54,21 @@ pub fn store, B: AsRef<[u8]>>(files: &[(N, B)]) -> Result, out.extend_from_slice(&0x0403_4b50u32.to_le_bytes()); out.extend_from_slice(&VERSION.to_le_bytes()); out.extend_from_slice(&shared); - out.extend_from_slice(&0u16.to_le_bytes()); // extra field length + // The data would start after the header (30 bytes) and the name; a padding block of at + // least its own 4-byte header moves it to the boundary. + let start = out.len() + 2 + name.len(); + let mut pad = if align > 1 { (align - start % align) % align } else { 0 }; + if pad > 0 && pad < 4 { + pad += align; + } + let pad_len = u16::try_from(pad).map_err(|_| format!("cannot align zip entries to {align} bytes"))?; + out.extend_from_slice(&pad_len.to_le_bytes()); // extra field length out.extend_from_slice(name); + if pad > 0 { + out.extend_from_slice(&0x1986u16.to_le_bytes()); + out.extend_from_slice(&(pad_len - 4).to_le_bytes()); + out.resize(out.len() + pad - 4, 0); + } out.extend_from_slice(bytes); // Central directory record. central.extend_from_slice(&0x0201_4b50u32.to_le_bytes()); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 2eb70a449..4676474b7 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -10,6 +10,7 @@ mod ai_open_path; mod appearance; mod areafit; mod assets; +mod assets_3d; mod attributes; mod bboxrotate; mod blendfidelity; diff --git a/crates/engine/src/tests/assets_3d.rs b/crates/engine/src/tests/assets_3d.rs new file mode 100644 index 000000000..cdef5b1df --- /dev/null +++ b/crates/engine/src/tests/assets_3d.rs @@ -0,0 +1,220 @@ +//! 3D assets: Export 3D Object collects objects with a 3D effect as 3D assets, their Format picks +//! the mesh file (USDA by default, USDZ, glTF or OBJ), and `assets.export3D` writes them, apart from +//! the picture assets `assets.export` writes. + +use serde_json::{Value, json}; +use vectorcraft_color::Paint; + +use crate::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + // Unstroked art: each object is one solid. + s.paint.stroke = Paint::None; + s +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +/// A rectangle with Extrude & Bevel, named `name`. +fn extruded(s: &mut Session, x: f64, name: &str) -> u64 { + let id = run(s, "shape.rectangle", json!({"x": x, "y": 10.0, "width": 40.0, "height": 30.0}))["id"].as_u64().unwrap(); + run(s, "select.set", json!({"ids": [id]})); + run(s, "effect.apply", json!({"effect": "threeD.extrude", "params": {"depth": 20.0}})); + run(s, "layer.setProps", json!({"id": id, "name": name})); + id +} + +fn undo_len(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +/// `(name, bytes)` of each file an export returned. +fn files(r: &Value) -> Vec<(String, Vec)> { + r["files"] + .as_array() + .unwrap() + .iter() + .map(|f| (f["name"].as_str().unwrap().to_string(), vectorcraft_format::base64_decode(f["dataBase64"].as_str().unwrap()).unwrap())) + .collect() +} + +#[test] +fn export_3d_object_collects_each_3d_object_as_a_usda_asset() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + let b = extruded(&mut s, 100.0, "Crate"); + run(&mut s, "select.set", json!({"ids": [a, b]})); + let before = undo_len(&s); + let r = run(&mut s, "assets.add3D", json!({})); + assert_eq!(r["added"], 2); + assert_eq!(undo_len(&s), before + 1, "one undo step for every object"); + let list = run(&mut s, "assets.list", json!({}))["assets"].as_array().unwrap().clone(); + assert_eq!( + list.iter().map(|a| (a["name"].clone(), a["format3D"].clone())).collect::>(), + [(json!("Box"), json!("usda")), (json!("Crate"), json!("usda"))] + ); + // Collected again: the same assets, and no undo step. + let again = run(&mut s, "assets.add3D", json!({"ids": [a]})); + assert_eq!((again["added"].clone(), again["assets"][0].clone()), (json!(0), r["assets"][0].clone())); + assert_eq!(undo_len(&s), before + 1); + // Collect for Export of the same object makes a picture asset beside it. + assert_eq!(run(&mut s, "assets.add", json!({"ids": [a]}))["added"], 1); + // Undo takes the 3D assets back out in one step. + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert!(s.doc().unwrap().doc.assets.is_empty()); +} + +#[test] +fn export_3d_object_refuses_objects_without_a_3d_effect_and_bad_formats() { + let mut s = session(); + let plain = run(&mut s, "shape.rectangle", json!({"x": 10.0, "y": 10.0, "width": 40.0, "height": 30.0}))["id"].as_u64().unwrap(); + let box3d = extruded(&mut s, 100.0, "Box"); + let before = undo_len(&s); + assert!(s.execute("assets.add3D", &json!({"ids": [box3d, plain]})).is_err(), "one object without a 3D effect refuses them all"); + assert!(s.execute("assets.add3D", &json!({"ids": [box3d], "format": "fbx"})).is_err()); + assert!(s.execute("assets.add3D", &json!({"ids": [box3d], "format": 3})).is_err()); + assert!(s.execute("assets.add3D", &json!({"ids": [999_999]})).is_err()); + run(&mut s, "select.set", json!({"ids": []})); + assert!(s.execute("assets.add3D", &json!({})).is_err(), "nothing selected"); + assert!(s.doc().unwrap().doc.assets.is_empty()); + assert_eq!(undo_len(&s), before); + run(&mut s, "select.set", json!({"ids": [box3d]})); + let r = run(&mut s, "assets.add3D", json!({"format": "gltf"})); + assert_eq!(run(&mut s, "assets.list", json!({}))["assets"][0]["format3D"], "gltf"); + assert_eq!(r["added"], 1); +} + +#[test] +fn the_format_is_set_per_asset_and_only_a_change_is_an_undo_step() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + let asset = run(&mut s, "assets.add3D", json!({"ids": [a]}))["assets"][0].as_u64().unwrap(); + let picture = run(&mut s, "assets.add", json!({"ids": [a]}))["assets"][0].as_u64().unwrap(); + let before = undo_len(&s); + assert_eq!(run(&mut s, "assets.set3DFormat", json!({"assets": [asset], "format": "usda"}))["changed"], 0); + assert_eq!(undo_len(&s), before, "no change, no undo step"); + assert!(s.execute("assets.set3DFormat", &json!({"assets": [asset], "format": "stl"})).is_err()); + assert!(s.execute("assets.set3DFormat", &json!({"assets": [asset]})).is_err(), "the format is required"); + assert!(s.execute("assets.set3DFormat", &json!({"assets": [picture], "format": "obj"})).is_err(), "a picture asset has no 3D format"); + assert!(s.execute("assets.set3DFormat", &json!({"assets": [12345], "format": "obj"})).is_err()); + assert_eq!(run(&mut s, "assets.set3DFormat", json!({"assets": [asset], "format": "obj"}))["changed"], 1); + assert_eq!(undo_len(&s), before + 1); + assert_eq!(s.doc().unwrap().doc.asset(asset).unwrap().format_3d.as_deref(), Some("obj")); +} + +#[test] +fn export_3d_writes_each_format() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + let asset = run(&mut s, "assets.add3D", json!({"ids": [a]}))["assets"][0].as_u64().unwrap(); + let cases = [("usda", vec!["Box.usda"]), ("usdz", vec!["Box.usdz"]), ("gltf", vec!["Box.gltf"]), ("obj", vec!["Box.obj", "Box.mtl"])]; + for (format, names) in cases { + run(&mut s, "assets.set3DFormat", json!({"assets": [asset], "format": format})); + let out = files(&run(&mut s, "assets.export3D", json!({}))); + assert_eq!(out.iter().map(|(n, _)| n.as_str()).collect::>(), names, "{format}"); + assert!(out.iter().all(|(_, b)| !b.is_empty()), "{format}"); + } + // The OBJ holds the box: a rectangle's front and back and four walls, two triangles each. + let out = files(&run(&mut s, "assets.export3D", json!({}))); + let obj = String::from_utf8(out[0].1.clone()).unwrap(); + assert_eq!(obj.lines().filter(|l| l.starts_with("f ")).count(), 12); +} + +#[test] +fn export_3d_numbers_files_that_would_share_a_name_and_cleans_path_characters() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + let b = extruded(&mut s, 100.0, "box"); + let c = extruded(&mut s, 200.0, "../a:b"); + run(&mut s, "assets.add3D", json!({"ids": [a, b, c]})); + let out = files(&run(&mut s, "assets.export3D", json!({}))); + assert_eq!(out.iter().map(|(n, _)| n.as_str()).collect::>(), ["Box.usda", "box-2.usda", "..-a-b.usda"]); +} + +#[test] +fn picture_and_3d_assets_export_apart() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + let three_d = run(&mut s, "assets.add3D", json!({"ids": [a]}))["assets"][0].as_u64().unwrap(); + // assets.export leaves 3D assets to assets.export3D. + let e = s.execute("assets.export", &json!({"formats": [{"format": "png", "scale": "1x"}]})).unwrap_err().to_string(); + assert!(e.contains("no assets yet"), "{e}"); + let e = s.execute("assets.export", &json!({"assets": [three_d]})).unwrap_err().to_string(); + assert!(e.contains("assets.export3D"), "{e}"); + let e = s.execute("document.exportForScreens", &json!({"assets": [three_d]})).unwrap_err().to_string(); + assert!(e.contains("assets.export3D"), "Export for Screens refuses it too: {e}"); + let picture = run(&mut s, "assets.add", json!({"ids": [a]}))["assets"][0].as_u64().unwrap(); + let pngs = files(&run(&mut s, "assets.export", json!({"formats": [{"format": "png", "scale": "1x"}]}))); + assert_eq!(pngs.len(), 1); + assert!(s.execute("assets.export3D", &json!({"assets": [picture]})).is_err()); +} + +#[test] +fn export_3d_needs_3d_assets() { + let mut s = session(); + assert!(s.execute("assets.export3D", &json!({})).is_err()); + assert!(s.execute("assets.export3D", &json!({"assets": [77]})).is_err()); + assert!(s.execute("assets.export3D", &json!({"assets": "all"})).is_err()); +} + +#[test] +fn bad_3d_params_are_refused() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + assert!(s.execute("assets.add3D", &json!({"ids": [a], "format": null})).is_err(), "a given format must be a format"); + run(&mut s, "assets.add3D", json!({"ids": [a]})); + assert!(s.execute("assets.export3D", &json!({"folder": 123})).is_err()); + assert!(s.execute("assets.export3D", &json!({"zip": "true"})).is_err()); +} + +#[test] +fn an_asset_whose_object_lost_its_effect_is_refused_at_export() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + run(&mut s, "assets.add3D", json!({"ids": [a]})); + run(&mut s, "select.set", json!({"ids": [a]})); + run(&mut s, "effect.remove", json!({"index": 0})); + let e = s.execute("assets.export3D", &json!({})).unwrap_err().to_string(); + assert!(e.contains("Box"), "{e}"); +} + +#[test] +fn export_3d_writes_into_a_folder_or_one_zip() { + let dir = std::env::temp_dir().join(format!("vc-assets-3d-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let folder = dir.to_string_lossy().replace('\\', "/"); + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + let asset = run(&mut s, "assets.add3D", json!({"ids": [a], "format": "obj"}))["assets"][0].as_u64().unwrap(); + let r = run(&mut s, "assets.export3D", json!({"folder": folder})); + assert_eq!(r["files"], json!([format!("{folder}/Box.obj"), format!("{folder}/Box.mtl")])); + assert!(dir.join("Box.obj").is_file() && dir.join("Box.mtl").is_file()); + let zip = run(&mut s, "assets.export3D", json!({"assets": [asset], "zip": true})); + assert_eq!(zip["files"], json!(["Box.obj", "Box.mtl"])); + assert!(zip["name"].as_str().unwrap().ends_with("-3D.zip"), "{}", zip["name"]); + // A picture ZIP and a 3D ZIP in one folder are two files. + run(&mut s, "assets.add", json!({"ids": [a]})); + let pictures = run(&mut s, "assets.export", json!({"formats": [{"format": "png", "scale": "1x"}], "folder": folder, "zip": true})); + let meshes = run(&mut s, "assets.export3D", json!({"folder": folder, "zip": true})); + let (p, m) = (pictures["path"].as_str().unwrap(), meshes["path"].as_str().unwrap()); + assert_ne!(p, m); + assert!(std::path::Path::new(p).is_file() && std::path::Path::new(m).is_file()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn three_d_assets_round_trip_through_the_native_format() { + let mut s = session(); + let a = extruded(&mut s, 10.0, "Box"); + run(&mut s, "assets.add3D", json!({"ids": [a], "format": "usdz"})); + let saved = s.doc().unwrap().doc.assets.clone(); + let b64 = run(&mut s, "document.serialize", json!({"format": "vectorcraft"}))["dataBase64"].as_str().unwrap().to_string(); + run(&mut s, "document.open", json!({"name": "assets3d.vectorcraft", "dataBase64": b64})); + assert_eq!(s.doc().unwrap().doc.assets, saved); + assert_eq!(saved[0].format_3d.as_deref(), Some("usdz")); +} diff --git a/crates/three-d/src/lib.rs b/crates/three-d/src/lib.rs index 43ec2d72a..ff21f37ba 100644 --- a/crates/three-d/src/lib.rs +++ b/crates/three-d/src/lib.rs @@ -15,6 +15,8 @@ pub use shading::{LightGradient, RgbGradient, ShadeGradient}; mod lights; pub use lights::{Light, Lights, MAX_LIGHTS}; mod caps; +mod mesh; +pub use mesh::{Mesh, extrude_mesh, inflate_meshes, plane_mesh, revolve_mesh}; use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect, Vec2, kurbo::ParamCurveDeriv}; @@ -447,7 +449,17 @@ fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Poi /// Evaluate a profile in document space. All subpaths share one axis from `bounds`. /// Invalid or excessively complex inputs return an error, never a partial surface. pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result, String> { + revolve_faces(path, bounds, options, None) +} + +/// [`revolve`], or with `mesh` the surface's faces before projection, kept there instead of drawn. +fn revolve_faces(path: &PathData, bounds: Rect, options: Revolve, mut mesh: Option<&mut Vec>) -> Result, String> { let o = options.sanitized(); + // A face kept for a mesh: its normal follows from its corners. + let keep = |mesh: &mut Vec, face: Vec, normal: V3| { + mesh.push((vec![face], normal)); + if mesh.len() > MAX_FACES { Err("Revolve surface is too complex; reduce Segments or simplify the profile".to_string()) } else { Ok(()) } + }; if ![bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { return Err("Revolve requires finite profile coordinates within 100 million points".into()); } @@ -543,6 +555,16 @@ pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result = profile.iter().map(|p| (p.x - axis, p.y)).collect(); + if let (false, Some(first), Some(last)) = (sp.closed, profile.first(), profile.last()) { + ring.extend([(0.0, last.y), (0.0, first.y)]); + } + let area: f64 = ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).map(|(p, q)| p.0 * q.1 - q.0 * p.1).sum(); + if area < 0.0 { -1.0 } else { 1.0 } + }; for (i, (pair, [ta, tb])) in profile.windows(2).zip(tangents).enumerate() { let [a, b] = pair else { continue }; if ![a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite() && v.abs() <= 1e8) { @@ -550,6 +572,14 @@ pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result, edges: &[(usize, &caps::RingArc, bool)]| { @@ -581,6 +611,17 @@ pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result = (0..=steps).map(|j| vertex(*end, j)).collect(); + if o.angle >= 360.0 { + cap.pop(); + } else { + cap.insert(0, o.rotate(V3(0.0, end.y - center_y, 0.0))); + } + keep(mesh, cap, o.rotate(V3(0.0, outward, 0.0)))?; + continue; + } // Keep the original tessellation work budget, even though each cap // now produces only one curved vector face. emit(ring.face(bounds, o, light, outward)?, steps)?; diff --git a/crates/three-d/src/mesh.rs b/crates/three-d/src/mesh.rs new file mode 100644 index 000000000..04b5db626 --- /dev/null +++ b/crates/three-d/src/mesh.rs @@ -0,0 +1,371 @@ +//! The solids of the 3D effects as triangle meshes, for files that other 3D apps open (OBJ, glTF, +//! USD). A mesh holds the faces the effect builds before they are projected onto the page: every +//! face, those turned away from the viewer included, at the turn the effect gives the art. +//! +//! Coordinates are points with y up and z toward the viewer, about the point the solid turns about +//! (the centre of the art's bounds; for Revolve, the axis at the profile's middle). Each face keeps +//! its own corners and normal, so the mesh is shaded flat. Planar faces with holes (caps and the +//! turned plane) are cut into triangles: each hole is joined to its outline by a bridge, then the +//! outline is cut by ear clipping. + +use vectorcraft_geom::{FillRule, PathData, Rect}; + +use crate::solid::{Face3, extrude_faces, inflate_faces, plane_faces}; +use crate::{Extrude, Inflate, Revolve, V3, View}; + +/// Most steps the triangulation of one solid may take: a face's ears and bridges each test the +/// face's other corners. +const MAX_WORK: usize = 400_000_000; + +/// A triangle mesh. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Mesh { + /// Corner positions in points: x right, y up, z toward the viewer. + pub positions: Vec<[f64; 3]>, + /// One unit normal for each position: the normal of the face the corner belongs to. + pub normals: Vec<[f64; 3]>, + /// Corner indices, counterclockwise seen from the side the normals point to. + pub triangles: Vec<[u32; 3]>, +} + +impl Mesh { + pub fn is_empty(&self) -> bool { + self.triangles.is_empty() + } + + /// The smallest and largest coordinate on each axis; `None` for an empty mesh. + pub fn bounds(&self) -> Option<([f64; 3], [f64; 3])> { + let first = *self.positions.first()?; + Some(self.positions.iter().fold((first, first), |(lo, hi), p| { + ([lo[0].min(p[0]), lo[1].min(p[1]), lo[2].min(p[2])], [hi[0].max(p[0]), hi[1].max(p[1]), hi[2].max(p[2])]) + })) + } + + /// The meshes of `parts` as one, in order. + pub fn join(parts: impl IntoIterator) -> Mesh { + let mut out = Mesh::default(); + for m in parts { + let base = out.positions.len() as u32; + out.positions.extend(m.positions); + out.normals.extend(m.normals); + out.triangles.extend(m.triangles.into_iter().map(|t| t.map(|i| i + base))); + } + out + } +} + +/// Extrude & Bevel's solid ([`crate::extrude`]) as a mesh. +pub fn extrude_mesh(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result { + extrude_faces(path, rule, bounds, options, view, true).and_then(|(_, faces)| mesh(&faces)) +} + +/// Rotate's plane ([`crate::plane`]) as a mesh. +pub fn plane_mesh(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result { + plane_faces(path, rule, bounds, view, true).and_then(|(_, faces)| mesh(&faces)) +} + +/// Inflate's solids for several shapes of one object ([`crate::inflate_together`]) as meshes, one +/// result for each shape, in order. Each mesh is cut as the iterator reaches it. +pub fn inflate_meshes( + shapes: &[(&PathData, FillRule)], + bounds: Rect, + options: Inflate, + view: View, +) -> impl Iterator> + use<> { + inflate_faces(shapes, bounds, options, view, true).into_iter().map(|r| r.and_then(|(_, faces)| mesh(&faces))) +} + +/// Revolve's surface ([`crate::revolve`]) as a mesh. As the effect draws it, a sweep of less than +/// 360° caps its ends with sectors and leaves its two cut sides open ([`Revolve::cap_ends`]). +pub fn revolve_mesh(path: &PathData, bounds: Rect, options: Revolve) -> Result { + let mut faces = vec![]; + crate::revolve_faces(path, bounds, options, Some(&mut faces))?; + mesh(&faces) +} + +/// The area-weighted normal of `contours` (Newell's method). +fn newell(contours: &[Vec]) -> V3 { + let mut n = V3::default(); + for c in contours { + for (a, b) in c.iter().zip(c.iter().cycle().skip(1)).take(c.len()) { + let x = a.cross(*b); + n = V3(n.0 + x.0, n.1 + x.1, n.2 + x.2); + } + } + n +} + +/// From the effects' view space (y down) to the mesh's (y up). +fn up(v: V3) -> [f64; 3] { + [v.0, -v.1, v.2] +} + +/// `faces` (view space) as one mesh, each cut into triangles turned to its normal (a face given no +/// normal takes its corners' own). +fn mesh(faces: &[Face3]) -> Result { + let mut out = Mesh::default(); + let mut work = 0usize; + for (contours, normal) in faces { + let geometric = newell(contours); + let n = if normal.dot(*normal) > 0.5 { *normal } else { geometric.unit() }; + if n.dot(n) < 0.5 { + continue; + } + let [nx, ny, nz] = up(n); + for [a, b, c] in triangulate(contours, geometric, &mut work)? { + let (pa, pb, pc) = (up(a), up(b), up(c)); + let cross = V3(pb[0] - pa[0], pb[1] - pa[1], pb[2] - pa[2]).cross(V3(pc[0] - pa[0], pc[1] - pa[1], pc[2] - pa[2])); + if cross.dot(cross) < 1e-24 { + continue; + } + let (pb, pc) = if cross.dot(V3(nx, ny, nz)) < 0.0 { (pc, pb) } else { (pb, pc) }; + let base = u32::try_from(out.positions.len()).map_err(|_| "The 3D object is too complex to export".to_string())?; + out.positions.extend([pa, pb, pc]); + out.normals.extend([[nx, ny, nz]; 3]); + out.triangles.push([base, base + 1, base + 2]); + } + } + Ok(out) +} + +/// A corner of a face: its position on the plane the face is cut in, and in 3D. +#[derive(Clone, Copy)] +struct Corner { + x: f64, + y: f64, + v: V3, +} + +fn area(ring: &[Corner]) -> f64 { + ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()).map(|(a, b)| a.x * b.y - b.x * a.y).sum::() / 2.0 +} + +fn inside(ring: &[Corner], x: f64, y: f64) -> bool { + let mut odd = false; + for (a, b) in ring.iter().zip(ring.iter().cycle().skip(1)).take(ring.len()) { + if (a.y > y) != (b.y > y) && x < a.x + (y - a.y) / (b.y - a.y) * (b.x - a.x) { + odd = !odd; + } + } + odd +} + +fn too_complex() -> String { + "The 3D object is too complex to export".to_string() +} + +fn crossing_hole() -> String { + "The 3D object has a hole that crosses its outline or another hole".to_string() +} + +/// Count `steps` of work against [`MAX_WORK`]. +fn spend(work: &mut usize, steps: usize) -> Result<(), String> { + *work = work.saturating_add(steps); + if *work > MAX_WORK { Err(too_complex()) } else { Ok(()) } +} + +/// The triangles covering one face: `contours` (outlines one way round, holes the other) on the +/// plane across `normal` (its Newell normal). +fn triangulate(contours: &[Vec], normal: V3, work: &mut usize) -> Result, String> { + // Cut the face on the coordinate plane it is least slanted to, turned so outlines run + // counterclockwise. + let (ax, ay, az) = (normal.0.abs(), normal.1.abs(), normal.2.abs()); + let flat = |v: V3| -> (f64, f64) { + if az >= ax && az >= ay { + if normal.2 >= 0.0 { (v.0, v.1) } else { (v.1, v.0) } + } else if ax >= ay { + if normal.0 >= 0.0 { (v.1, v.2) } else { (v.2, v.1) } + } else if normal.1 >= 0.0 { + (v.2, v.0) + } else { + (v.0, v.2) + } + }; + let mut outlines: Vec<(Vec, f64)> = vec![]; + let mut holes: Vec> = vec![]; + for c in contours { + let mut ring: Vec = c + .iter() + .map(|v| { + let (x, y) = flat(*v); + Corner { x, y, v: *v } + }) + .collect(); + ring.dedup_by(|a, b| (a.x - b.x).hypot(a.y - b.y) < 1e-9); + while ring.len() > 1 && ring.first().zip(ring.last()).is_some_and(|(a, b)| (a.x - b.x).hypot(a.y - b.y) < 1e-9) { + ring.pop(); + } + if ring.len() < 3 { + continue; + } + let a = area(&ring); + if a > 1e-12 { + outlines.push((ring, a)); + } else if a < -1e-12 { + holes.push(ring); + } + } + // Each hole goes to the smallest outline around it. + let mut held: Vec>> = vec![vec![]; outlines.len()]; + for hole in holes { + let corners: usize = outlines.iter().map(|(o, _)| o.len()).sum(); + spend(work, corners.saturating_mul(hole.len() + 1))?; + // A hole that crosses an outline can't be cut out of it; leaving it would fill it. + let edges = |r: &[Corner]| r.iter().zip(r.iter().cycle().skip(1)).take(r.len()).map(|(p, q)| ((p.x, p.y), (q.x, q.y))).collect::>(); + let sides = edges(&hole); + if outlines.iter().any(|(o, _)| edges(o).iter().any(|(c, d)| sides.iter().any(|(a, b)| crosses(*a, *b, *c, *d)))) { + return Err(crossing_hole()); + } + let Some(first) = hole.first() else { continue }; + let owner = + outlines.iter().enumerate().filter(|(_, (o, _))| inside(o, first.x, first.y)).min_by(|a, b| a.1.1.total_cmp(&b.1.1)).map(|(i, _)| i); + if let Some(i) = owner + && let Some(h) = held.get_mut(i) + { + h.push(hole); + } + } + let mut out = vec![]; + for ((outline, _), mut holes) in outlines.into_iter().zip(held) { + // The rightmost hole first, as each bridge then reaches the outline past the others. + holes.sort_by(|a, b| { + let right = |h: &[Corner]| h.iter().map(|c| c.x).fold(f64::NEG_INFINITY, f64::max); + right(b).total_cmp(&right(a)) + }); + let mut ring = outline; + for k in 0..holes.len() { + let (hole, rest) = holes[k..].split_first().ok_or_else(too_complex)?; + ring = bridge(ring, hole, rest, work)?; + } + clip(&ring, &mut out, work)?; + } + Ok(out) +} + +/// Do segments `a`–`b` and `c`–`d` cross (touching at a shared end does not count)? +fn crosses(a: (f64, f64), b: (f64, f64), c: (f64, f64), d: (f64, f64)) -> bool { + let same = |p: (f64, f64), q: (f64, f64)| (p.0 - q.0).hypot(p.1 - q.1) < 1e-9; + if same(a, c) || same(a, d) || same(b, c) || same(b, d) { + return false; + } + let side = |p: (f64, f64), q: (f64, f64), r: (f64, f64)| (q.0 - p.0) * (r.1 - p.1) - (q.1 - p.1) * (r.0 - p.0); + let (d1, d2, d3, d4) = (side(c, d, a), side(c, d, b), side(a, b, c), side(a, b, d)); + ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0)) && ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0)) +} + +/// Does point `p` lie on segment `a`–`b`, short of its ends? +fn touches(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> bool { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len = dx.hypot(dy); + if len < 1e-9 { + return false; + } + let along = ((p.0 - a.0) * dx + (p.1 - a.1) * dy) / len; + let off = ((p.1 - a.1) * dx - (p.0 - a.0) * dy).abs() / len; + off < 1e-9 && along > 1e-9 && along < len - 1e-9 +} + +/// `ring` with `hole` joined to it: from the hole's rightmost corner to the nearest corner of the +/// ring that a straight bridge reaches inside the face: it crosses no edge of the ring, the hole or +/// the `others`, passes through none of their other corners, and its middle is inside the ring and +/// outside the holes. The bridge runs there and back, so the result is one ring. +fn bridge(ring: Vec, hole: &[Corner], others: &[Vec], work: &mut usize) -> Result, String> { + let Some(m) = (0..hole.len()).max_by(|a, b| hole[*a].x.total_cmp(&hole[*b].x)) else { return Ok(ring) }; + let at = (hole[m].x, hole[m].y); + let mut order: Vec = (0..ring.len()).collect(); + order.sort_by(|a, b| { + let d = |i: usize| (ring[i].x - at.0).hypot(ring[i].y - at.1); + d(*a).total_cmp(&d(*b)) + }); + let edges = |r: &[Corner]| r.iter().zip(r.iter().cycle().skip(1)).take(r.len()).map(|(p, q)| ((p.x, p.y), (q.x, q.y))).collect::>(); + let mut walls = edges(&ring); + walls.extend(edges(hole)); + for o in others { + walls.extend(edges(o)); + } + let to = order.into_iter().find(|&i| { + let p = (ring[i].x, ring[i].y); + *work = work.saturating_add(2 * walls.len()); + if *work > MAX_WORK || walls.iter().any(|(c, d)| crosses(at, p, *c, *d) || touches(*c, at, p)) { + return false; + } + let mid = ((at.0 + p.0) / 2.0, (at.1 + p.1) / 2.0); + inside(&ring, mid.0, mid.1) && !inside(hole, mid.0, mid.1) && !others.iter().any(|o| inside(o, mid.0, mid.1)) + }); + if *work > MAX_WORK { + return Err(too_complex()); + } + // A hole no bridge reaches (one that crosses another hole) can't be cut out; leaving it would + // fill it. + let Some(i) = to else { return Err(crossing_hole()) }; + let mut out = Vec::with_capacity(ring.len() + hole.len() + 2); + out.extend_from_slice(&ring[..=i]); + out.extend(hole[m..].iter().chain(&hole[..=m]).copied()); + out.extend_from_slice(&ring[i..]); + Ok(out) +} + +/// Cut `ring` (counterclockwise, possibly joined to its holes by bridges) into triangles by ear +/// clipping. A corner in line with its neighbors goes without a triangle. If no ear is left (a +/// ring that crosses itself), the rest is cut as a fan. +fn clip(ring: &[Corner], out: &mut Vec<[V3; 3]>, work: &mut usize) -> Result<(), String> { + let n = ring.len(); + if n < 3 { + return Ok(()); + } + let mut next: Vec = (1..=n).map(|i| i % n).collect(); + let mut prev: Vec = (0..n).map(|i| (i + n - 1) % n).collect(); + let (mut left, mut i, mut tried) = (n, 0usize, 0usize); + let cross = |a: &Corner, b: &Corner, c: &Corner| (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); + let same = |a: &Corner, b: &Corner| (a.x - b.x).hypot(a.y - b.y) < 1e-12; + while left > 3 { + let (a, c) = (prev[i], next[i]); + let (pa, pb, pc) = (&ring[a], &ring[i], &ring[c]); + let turn = cross(pa, pb, pc); + let scale = (pb.x - pa.x).hypot(pb.y - pa.y) * (pc.x - pb.x).hypot(pc.y - pb.y); + let mut ear = false; + let drop = turn.abs() <= 1e-12 * scale.max(1e-300); + if !drop && turn > 0.0 { + spend(work, left)?; + ear = true; + let mut k = next[c]; + while k != a { + let p = &ring[k]; + if !same(p, pa) && !same(p, pb) && !same(p, pc) && cross(pa, pb, p) > 0.0 && cross(pb, pc, p) > 0.0 && cross(pc, pa, p) > 0.0 { + ear = false; + break; + } + k = next[k]; + } + } + if !ear && !drop && tried > left { + // No ear anywhere: cut what is left as a fan. + let start = i; + let mut k = next[start]; + while next[k] != start { + out.push([ring[start].v, ring[k].v, ring[next[k]].v]); + k = next[k]; + } + return Ok(()); + } + if ear || drop { + if ear { + out.push([pa.v, pb.v, pc.v]); + } + next[a] = c; + prev[c] = a; + left -= 1; + i = a; + tried = 0; + } else { + i = c; + tried += 1; + } + } + let (a, c) = (prev[i], next[i]); + out.push([ring[a].v, ring[i].v, ring[c].v]); + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/three-d/src/mesh/tests.rs b/crates/three-d/src/mesh/tests.rs new file mode 100644 index 000000000..4ee38fe1d --- /dev/null +++ b/crates/three-d/src/mesh/tests.rs @@ -0,0 +1,283 @@ +use std::collections::HashMap; + +use vectorcraft_geom::{BezPath, FillRule, PathData, Rect}; + +use super::*; +use crate::{Bevel, Edge}; + +fn square(b: &mut BezPath, x: f64, y: f64, s: f64, clockwise: bool) { + b.move_to((x, y)); + if clockwise { + b.line_to((x, y + s)); + b.line_to((x + s, y + s)); + b.line_to((x + s, y)); + } else { + b.line_to((x + s, y)); + b.line_to((x + s, y + s)); + b.line_to((x, y + s)); + } + b.close_path(); +} + +fn shape(b: &BezPath) -> (PathData, Rect) { + let p = PathData::from_bezpath(b); + let r = p.bounds().unwrap(); + (p, r) +} + +/// Straight on, without perspective. +fn front() -> View { + View { rotation_x: 0.0, rotation_y: 0.0, rotation_z: 0.0, perspective: 0.0, ..View::default() } +} + +fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} + +fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] { + [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] +} + +fn dot(a: [f64; 3], b: [f64; 3]) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +fn corners(m: &Mesh, t: [u32; 3]) -> [[f64; 3]; 3] { + t.map(|i| m.positions[i as usize]) +} + +/// The area of every triangle together. +fn area(m: &Mesh) -> f64 { + m.triangles + .iter() + .map(|t| { + let [a, b, c] = corners(m, *t); + dot(cross(sub(b, a), sub(c, a)), cross(sub(b, a), sub(c, a))).sqrt() / 2.0 + }) + .sum() +} + +/// Every edge of every triangle is met by an edge running the other way, as often: the surface is +/// closed and its triangles turn one way. +fn closed(m: &Mesh) -> bool { + let key = |p: [f64; 3]| p.map(|v| (v * 1e6).round() as i64); + let mut edges: HashMap<([i64; 3], [i64; 3]), i64> = HashMap::new(); + for t in &m.triangles { + let c = corners(m, *t).map(key); + for (a, b) in [(c[0], c[1]), (c[1], c[2]), (c[2], c[0])] { + *edges.entry((a, b)).or_default() += 1; + *edges.entry((b, a)).or_default() -= 1; + } + } + edges.values().all(|n| *n == 0) +} + +/// Every triangle of a solid convex about the origin turns its front away from the origin, and +/// agrees with its corners' normals. +fn outward(m: &Mesh) -> bool { + m.triangles.iter().all(|t| { + let [a, b, c] = corners(m, *t); + let n = cross(sub(b, a), sub(c, a)); + let centre = [(a[0] + b[0] + c[0]) / 3.0, (a[1] + b[1] + c[1]) / 3.0, (a[2] + b[2] + c[2]) / 3.0]; + dot(n, centre) > 0.0 && dot(n, m.normals[t[0] as usize]) > 0.0 + }) +} + +#[test] +fn an_extruded_square_is_a_closed_box_about_its_centre() { + let mut b = BezPath::new(); + square(&mut b, 200.0, 300.0, 100.0, false); + let (p, r) = shape(&b); + let m = extrude_mesh(&p, FillRule::NonZero, r, Extrude { depth: 50.0, ..Extrude::default() }, front()).unwrap(); + assert_eq!(m.triangles.len(), 12, "two triangles for each side of the box"); + assert_eq!(m.positions.len(), m.normals.len()); + assert_eq!(m.bounds(), Some(([-50.0, -50.0, -25.0], [50.0, 50.0, 25.0]))); + assert!((area(&m) - (2.0 * 100.0 * 100.0 + 4.0 * 100.0 * 50.0)).abs() < 1e-6, "{}", area(&m)); + assert!(closed(&m)); + assert!(outward(&m)); + assert!(m.normals.iter().all(|n| (dot(*n, *n) - 1.0).abs() < 1e-9)); +} + +#[test] +fn caps_with_holes_are_cut_around_them() { + // Two holes in one outline: each cap keeps the outline less both holes. + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 100.0, false); + square(&mut b, 10.0, 10.0, 30.0, true); + square(&mut b, 60.0, 50.0, 30.0, true); + let (p, r) = shape(&b); + let m = extrude_mesh(&p, FillRule::NonZero, r, Extrude { depth: 20.0, ..Extrude::default() }, front()).unwrap(); + let caps = 2.0 * (100.0 * 100.0 - 2.0 * 30.0 * 30.0); + let walls = 4.0 * 100.0 * 20.0 + 2.0 * 4.0 * 30.0 * 20.0; + assert!((area(&m) - (caps + walls)).abs() < 1e-6, "{} != {}", area(&m), caps + walls); + assert!(closed(&m)); +} + +#[test] +fn a_bridge_from_a_hole_never_runs_through_the_hole() { + // The hole's rightmost corner (60, 50) is nearest the outline's spike tip (10, 50), and the + // straight line to it runs through the hole and out at its corner (20, 50). + let poly = |b: &mut BezPath, pts: &[(f64, f64)]| { + for (k, p) in pts.iter().enumerate() { + if k == 0 { b.move_to(*p) } else { b.line_to(*p) } + } + b.close_path(); + }; + let mut b = BezPath::new(); + poly(&mut b, &[(0.0, 0.0), (200.0, 0.0), (200.0, 100.0), (0.0, 100.0), (0.0, 55.0), (10.0, 50.0), (0.0, 45.0)]); + let hole = [(60.0, 50.0), (30.0, 40.0), (20.0, 50.0), (30.0, 60.0)]; + poly(&mut b, &hole); + let (p, r) = shape(&b); + let m = plane_mesh(&p, FillRule::EvenOdd, r, front()).unwrap(); + let face = 200.0 * 100.0 - 50.0 - 400.0; + assert!((area(&m) - face).abs() < 1e-6, "{} != {face}", area(&m)); + // No triangle's centre is in the hole (page coordinates: the mesh is y up about (100, 50)). + let in_hole = |x: f64, y: f64| { + let mut odd = false; + for (k, a) in hole.iter().enumerate() { + let c = hole[(k + 1) % hole.len()]; + if (a.1 > y) != (c.1 > y) && x < a.0 + (y - a.1) / (c.1 - a.1) * (c.0 - a.0) { + odd = !odd; + } + } + odd + }; + for t in &m.triangles { + let [a, b, c] = corners(&m, *t); + let (x, y) = ((a[0] + b[0] + c[0]) / 3.0 + 100.0, 50.0 - (a[1] + b[1] + c[1]) / 3.0); + assert!(!in_hole(x, y), "a triangle at ({x}, {y})"); + } +} + +#[test] +fn a_hole_that_crosses_the_outline_is_refused_rather_than_filled() { + // The hole starts inside the square, but its rightmost corner (15, 5) lies outside it, so no + // bridge from there reaches the square through the face. + let ring = |pts: &[(f64, f64)]| pts.iter().map(|(x, y)| V3(*x, *y, 0.0)).collect::>(); + let outline = ring(&[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0)]); + let hole = ring(&[(5.0, 4.0), (5.0, 6.0), (15.0, 5.0)]); + let mut work = 0; + assert!(super::triangulate(std::slice::from_ref(&outline), V3(0.0, 0.0, 1.0), &mut work).is_ok()); + let r = super::triangulate(&[outline.clone(), hole], V3(0.0, 0.0, 1.0), &mut work); + assert!(r.as_ref().is_err_and(|e| e.contains("hole")), "{:?}", r.map(|t| t.len())); + // The same hole starting outside the square belongs to no outline, and is refused too. + let hole = ring(&[(15.0, 5.0), (5.0, 4.0), (5.0, 6.0)]); + let r = super::triangulate(&[outline, hole], V3(0.0, 0.0, 1.0), &mut work); + assert!(r.as_ref().is_err_and(|e| e.contains("hole")), "{:?}", r.map(|t| t.len())); +} + +#[test] +fn a_bridge_through_another_corner_touches_it() { + assert!(super::touches((5.0, 0.0), (0.0, 0.0), (10.0, 0.0))); + assert!(!super::touches((0.0, 0.0), (0.0, 0.0), (10.0, 0.0)), "an end is not a touch"); + assert!(!super::touches((5.0, 1.0), (0.0, 0.0), (10.0, 0.0))); +} + +#[test] +fn a_beveled_extrusion_stays_closed() { + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 100.0, false); + let (p, r) = shape(&b); + let bevel = Bevel { width: 6.0, height: 6.0, both_sides: true, ..Bevel::default() }; + let m = extrude_mesh(&p, FillRule::NonZero, r, Extrude { depth: 40.0, bevel: Some(bevel), ..Extrude::default() }, front()).unwrap(); + assert!(closed(&m)); + assert!(outward(&m)); +} + +#[test] +fn the_mesh_keeps_the_turn_of_the_view() { + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 100.0, false); + let (p, r) = shape(&b); + // Turned 90° about the vertical axis, the extrusion's depth lies along x. + let view = View { rotation_y: 90.0, ..front() }; + let m = extrude_mesh(&p, FillRule::NonZero, r, Extrude { depth: 20.0, ..Extrude::default() }, view).unwrap(); + let (lo, hi) = m.bounds().unwrap(); + assert!((hi[0] - lo[0] - 20.0).abs() < 1e-9 && (hi[2] - lo[2] - 100.0).abs() < 1e-9, "{lo:?} {hi:?}"); +} + +#[test] +fn a_turned_plane_keeps_its_area_less_its_hole() { + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 100.0, false); + square(&mut b, 25.0, 25.0, 50.0, true); + let (p, r) = shape(&b); + let view = View { rotation_x: 30.0, rotation_y: -40.0, ..front() }; + let m = plane_mesh(&p, FillRule::NonZero, r, view).unwrap(); + assert!((area(&m) - (100.0 * 100.0 - 50.0 * 50.0)).abs() < 1e-6, "{}", area(&m)); +} + +#[test] +fn y_points_up() { + // The art's top edge (y 0 on the page) is the mesh's highest. + let mut b = BezPath::new(); + b.move_to((0.0, 0.0)); + b.line_to((100.0, 0.0)); + b.line_to((50.0, 100.0)); + b.close_path(); + let (p, r) = shape(&b); + let m = plane_mesh(&p, FillRule::NonZero, r, front()).unwrap(); + let top = m.positions.iter().filter(|q| (q[1] - 50.0).abs() < 1e-9).count(); + assert!(top >= 2, "{:?}", m.positions); + assert!(m.normals.iter().all(|n| n[2] > 0.99), "a plane facing the viewer has its normal toward +z"); +} + +#[test] +fn a_revolved_profile_is_a_closed_cylinder() { + // A vertical line 10 pt from the axis, swept round with its ends capped: a closed cylinder. + let mut b = BezPath::new(); + b.move_to((20.0, 0.0)); + b.line_to((20.0, 100.0)); + let p = PathData::from_bezpath(&b); + let r = p.bounds().unwrap(); + let o = Revolve { offset: 10.0, edge: Edge::Left, segments: 64, ..Revolve::default() }; + let m = revolve_mesh(&p, r, o).unwrap(); + assert!(closed(&m)); + let exact = 2.0 * std::f64::consts::PI * 10.0 * 100.0 + 2.0 * std::f64::consts::PI * 100.0; + assert!((area(&m) - exact).abs() / exact < 0.01, "{} vs {exact}", area(&m)); + assert!(outward(&m)); +} + +#[test] +fn a_partial_revolve_caps_its_ends_with_sectors() { + let mut b = BezPath::new(); + b.move_to((20.0, 0.0)); + b.line_to((20.0, 100.0)); + let p = PathData::from_bezpath(&b); + let r = p.bounds().unwrap(); + let o = Revolve { angle: 90.0, offset: 10.0, segments: 64, ..Revolve::default() }; + let m = revolve_mesh(&p, r, o).unwrap(); + let exact = 0.25 * (2.0 * std::f64::consts::PI * 10.0 * 100.0 + 2.0 * std::f64::consts::PI * 100.0); + assert!((area(&m) - exact).abs() / exact < 0.01, "{} vs {exact}", area(&m)); +} + +#[test] +fn inflate_puffs_the_front_toward_the_viewer() { + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 100.0, false); + let (p, r) = shape(&b); + let m = inflate_meshes(&[(&p, FillRule::NonZero)], r, Inflate::default(), front()).last().unwrap().unwrap(); + assert!(!m.is_empty()); + let (lo, hi) = m.bounds().unwrap(); + assert!(hi[2] > 0.0 && lo[2] < hi[2], "the front puffs toward the viewer: {lo:?} {hi:?}"); +} + +#[test] +fn an_empty_shape_makes_an_empty_mesh() { + let p = PathData::new(vec![]); + let m = plane_mesh(&p, FillRule::NonZero, Rect::new(0.0, 0.0, 10.0, 10.0), front()).unwrap(); + assert!(m.is_empty()); + assert_eq!(m.bounds(), None); +} + +#[test] +fn join_numbers_each_parts_corners_after_the_last() { + let mut b = BezPath::new(); + square(&mut b, 0.0, 0.0, 10.0, false); + let (p, r) = shape(&b); + let one = plane_mesh(&p, FillRule::NonZero, r, front()).unwrap(); + let both = Mesh::join([one.clone(), one.clone()]); + assert_eq!(both.triangles.len(), 2 * one.triangles.len()); + assert!(both.triangles.iter().all(|t| t.iter().all(|i| (*i as usize) < both.positions.len()))); + assert_eq!(both.triangles[one.triangles.len()][0], one.triangles[0][0] + one.positions.len() as u32); +} diff --git a/crates/three-d/src/solid.rs b/crates/three-d/src/solid.rs index 043e34766..20beda4ec 100644 --- a/crates/three-d/src/solid.rs +++ b/crates/three-d/src/solid.rs @@ -20,7 +20,9 @@ mod rings; #[cfg(test)] use build::{Camera, cuts, edge_distance, newell, section}; +pub(crate) use build::{Face3, extrude_faces, plane_faces}; pub use build::{extrude, plane}; +pub(crate) use inflate::inflate_faces; pub use inflate::{inflate, inflate_together}; #[cfg(test)] use membrane::{Edges, Membrane, cell_triangles}; diff --git a/crates/three-d/src/solid/build.rs b/crates/three-d/src/solid/build.rs index c78f2a38b..7fbeb3e8c 100644 --- a/crates/three-d/src/solid/build.rs +++ b/crates/three-d/src/solid/build.rs @@ -41,6 +41,13 @@ impl Camera { } } +/// A face of a solid before projection: its contours in view space (holes running the other way) +/// and its outward normal. +pub(crate) type Face3 = (Vec>, V3); + +/// What a solid's builder makes: its facets, or with a mesh asked for, its faces before projection. +pub(crate) type Built = (Vec, Vec); + /// Facets being collected for one solid. pub(super) struct Builder<'a> { pub(super) camera: &'a Camera, @@ -48,6 +55,8 @@ pub(super) struct Builder<'a> { /// insides. pub(super) closed: bool, pub(super) facets: Vec, + /// With `Some`, every face goes here as it is, none hidden or projected, for a mesh. + pub(super) mesh: Option>, } /// Where a face goes in the order the facets are drawn, back to front. @@ -73,6 +82,13 @@ impl Builder<'_> { if count < 3 { return Ok(()); } + if let Some(mesh) = &mut self.mesh { + mesh.push((contours, n.unit())); + if mesh.len() > MAX_FACES { + return Err("The 3D surface is too complex; simplify the shape".into()); + } + return Ok(()); + } let centre = contours.iter().flatten().fold(V3::default(), |s, v| V3(s.0 + v.0, s.1 + v.1, s.2 + v.2)); let centre = V3(centre.0 / count as f64, centre.1 / count as f64, centre.2 / count as f64); let n = n.unit(); @@ -182,9 +198,15 @@ impl Builder<'_> { Ok(()) } - pub(super) fn finish(mut self) -> Vec { + /// How many faces the builder holds: facets, or faces kept for a mesh. + pub(super) fn len(&self) -> usize { + self.mesh.as_ref().map_or(self.facets.len(), Vec::len) + } + + /// The facets back to front, and the faces kept for a mesh (none without [`Builder::mesh`]). + pub(super) fn finish(mut self) -> Built { self.facets.sort_by(|a, b| a.depth.total_cmp(&b.depth)); - self.facets + (self.facets, self.mesh.unwrap_or_default()) } } @@ -370,15 +392,20 @@ fn steps(o: &Extrude) -> Vec { /// Extrude & Bevel: the shape's outline extruded backwards, with optional twist, taper, bevels /// and caps. pub fn extrude(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result, String> { + extrude_faces(path, rule, bounds, options, view, false).map(|(facets, _)| facets) +} + +/// [`extrude`], or with `mesh` the solid's faces before projection instead of its facets. +pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View, mesh: bool) -> Result { check_bounds(bounds)?; let o = options.sanitized(); // No depth: the flat shape, seen from either side. if o.depth <= 0.0 { - return plane(path, rule, bounds, view); + return plane_faces(path, rule, bounds, view, mesh); } let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?; let camera = Camera::new(view, bounds, o.depth); - let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![] }; + let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![], mesh: mesh.then(Vec::new) }; let steps = steps(&o); let z = |t: f64| o.depth / 2.0 - o.depth * t; let ring_at = @@ -428,10 +455,15 @@ pub fn extrude(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, /// Rotate: the flat art turned in 3D about the centre of `bounds` (which may be larger than the /// shape's own, so that every part of one object turns about the same point). pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result, String> { + plane_faces(path, rule, bounds, view, false).map(|(facets, _)| facets) +} + +/// [`plane`], or with `mesh` its face before projection instead of its facet. +pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: View, mesh: bool) -> Result { check_bounds(bounds)?; let rings = rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?; let camera = Camera::new(view, bounds, 0.0); - let mut b = Builder { camera: &camera, closed: false, facets: vec![] }; + let mut b = Builder { camera: &camera, closed: false, facets: vec![], mesh: mesh.then(Vec::new) }; let contours: Vec> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect(); // A plane is one flat face, seen from either side. Its normal is turned toward the viewer, so // it is drawn last, as a cap is, and planes drawn in one order keep the order they are given in. diff --git a/crates/three-d/src/solid/inflate.rs b/crates/three-d/src/solid/inflate.rs index d80fef185..9f1f5c3f7 100644 --- a/crates/three-d/src/solid/inflate.rs +++ b/crates/three-d/src/solid/inflate.rs @@ -1,4 +1,4 @@ -use super::build::{Builder, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward}; +use super::build::{Builder, Built, Camera, Order, Turn, check_bounds, cuts, fit, newell, outward}; use super::membrane::{Edges, MAX_MATERIAL_SEGMENTS, Membrane, cell_triangles, clip, filled, material}; use super::rings::{MAX_SOLID_POINTS, rings, signed_area}; use super::{Facet, FillRule, Inflate, MAX_FACES, PathData, Point, Rect, V3, Vec2, View}; @@ -51,6 +51,11 @@ pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate, /// on its own over the grid of `bounds`, and one eye sees them all, as far away as the highest /// puff needs with Perspective. One result for each shape, in order. pub fn inflate_together(shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View) -> Vec, String>> { + inflate_faces(shapes, bounds, options, view, false).into_iter().map(|r| r.map(|(facets, _)| facets)).collect() +} + +/// [`inflate_together`], or with `mesh` each solid's faces before projection instead of its facets. +pub(crate) fn inflate_faces(shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View, mesh: bool) -> Vec> { let o = options.sanitized(); let puffs: Vec, String>> = shapes.iter().map(|(path, rule)| check_bounds(bounds).and_then(|()| puff(path, *rule, bounds, &o))).collect(); @@ -58,16 +63,17 @@ pub fn inflate_together(shapes: &[(&PathData, FillRule)], bounds: Rect, options: puffs .into_iter() .map(|p| match p { - Ok(Some(p)) => inflated(p, bounds, o, view, reach), - Ok(None) => Ok(vec![]), + Ok(Some(p)) => inflated(p, bounds, o, view, reach, mesh), + Ok(None) => Ok((vec![], vec![])), Err(e) => Err(e), }) .collect() } /// The facets of one inflated shape, for a camera that allows for a puff `reach` high (or the -/// shape's own, if higher): `reach` is the highest puff of the shapes drawn together. -fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64) -> Result, String> { +/// shape's own, if higher): `reach` is the highest puff of the shapes drawn together. With `mesh`, +/// its faces before projection instead. +fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: bool) -> Result { let Puff { rings, edges, cell, nx, ny, m, height } = p; let outline: Vec<(Rect, vectorcraft_geom::SubPath)> = rings .iter() @@ -106,7 +112,7 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64) -> Result let smooth = view.shade && view.has_colored_lights(); let camera = Camera::new(view, bounds, o.depth + 2.0 * height.max(reach)); // Without its back cap the object is a shell open behind, its inside showing. - let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![] }; + let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![], mesh: mesh.then(Vec::new) }; let half = o.depth / 2.0; // The back: turned and scaled by the twist and taper, its puff too. let back_turn = Turn::new(bounds, o.twist, o.taper, 1.0); @@ -300,7 +306,7 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64) -> Result ring.iter().map(|p| camera.place(turn.apply(*p), half - o.depth * t)).collect() }; let mut pieces: Vec> = rings.iter().map(|r| if o.twist != 0.0 { cuts(&at(r, 0), &at(r, 1)) } else { vec![] }).collect(); - fit(&mut pieces, slices, MAX_FACES.saturating_sub(b.facets.len())); + fit(&mut pieces, slices, MAX_FACES.saturating_sub(b.len())); for (ring, pieces) in rings.iter().zip(&pieces) { for k in 0..slices { b.band(&at(ring, k), &at(ring, k + 1), pieces, |i| outward(ring, i, 0.0))?; diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index 24f2d7367..90c1ddd39 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -108,12 +108,13 @@ pub(crate) fn saved_rows(saved: &Map) -> (Vec, Option<&str /// Open the dialog on its Assets tab with `checked` assets checked (every asset when `None`). pub(crate) fn open_assets(app: &mut VectorcraftApp, checked: Option<&[u64]>) { open(app); + let shown: Vec = app.session.active().map(|st| picture_assets(st).iter().map(|a| a.id).collect()).unwrap_or_default(); if let Some(d) = app.ui.dialog.as_mut() { d.fields.insert("tab".into(), json!("assets")); if let Some(c) = checked { d.fields.insert("assets".into(), json!(c)); - // The list scrolls to the first of them once: new assets land at its end. - if let Some(first) = c.first() { + // The list scrolls to the first of them it shows, once: new assets land at its end. + if let Some(first) = c.iter().find(|id| shown.contains(id)) { d.fields.insert("__scrollTo".into(), json!(first)); } } @@ -128,7 +129,7 @@ pub fn open(app: &mut VectorcraftApp) { let (n, saved, assets) = app .session .active() - .map(|st| (st.doc.artboards.len(), st.doc.export_settings.clone(), st.doc.assets.iter().map(|a| a.id).collect::>())) + .map(|st| (st.doc.artboards.len(), st.doc.export_settings.clone(), picture_assets(st).iter().map(|a| a.id).collect::>())) .unwrap_or_default(); let get = |k: &str| saved.get(k).filter(|v| !v.is_null()); let flag = |k: &str, default: bool| get(k).and_then(Value::as_bool).unwrap_or(default); @@ -575,16 +576,23 @@ fn tiles( toggled } -/// The checked assets that are still in the document, in panel order. -fn checked_assets(app: &VectorcraftApp, d: &Dialog) -> Vec { - let on: Vec = d.fields.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); - app.session.active().map(|st| st.doc.assets.iter().map(|a| a.id).filter(|id| on.contains(id)).collect()).unwrap_or_default() +/// The document's assets exported as pictures, in panel order: 3D assets export as mesh files from +/// the Asset Export panel (`assets.export3D`). +fn picture_assets(st: &vectorcraft_engine::DocState) -> Vec<&vectorcraft_doc::ExportAsset> { + st.doc.assets.iter().filter(|a| a.format_3d.is_none()).collect() } -/// The Assets tab: each asset's thumbnail with its checkbox and name, in a grid or a list. +/// The checked picture assets that are still in the document, in panel order. +fn checked_assets(app: &VectorcraftApp, d: &Dialog) -> Vec { + let on: Vec = d.fields.get("assets").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect()).unwrap_or_default(); + app.session.active().map(|st| picture_assets(st).iter().map(|a| a.id).filter(|id| on.contains(id)).collect()).unwrap_or_default() +} + +/// The Assets tab: each picture asset's thumbnail with its checkbox and name, in a grid or a list. fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, list: bool) { let Some(st) = app.session.active() else { return }; - if st.doc.assets.is_empty() { + let shown = picture_assets(st); + if shown.is_empty() { ui.add_space(24.0); widgets::dim_label(ui, tl!("No assets yet. Collect art with Object › Collect for Export, or drag it into the Asset Export panel.")); return; @@ -592,10 +600,10 @@ fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, list: bool) { let mut on = checked_assets(app, d); let before = on.clone(); // Asked for until its row is in view: the dialog's first frame only measures it. - let scroll_to = d.fields.get("__scrollTo").and_then(Value::as_u64).filter(|id| st.doc.assets.iter().any(|a| a.id == *id)); + let scroll_to = d.fields.get("__scrollTo").and_then(Value::as_u64).filter(|id| shown.iter().any(|a| a.id == *id)); let mut in_view = scroll_to.is_none(); - let clicked = tiles(ui, "efs-assets", st.doc.assets.len(), list, |ui, i, r| { - let Some(a) = st.doc.assets.get(i) else { return (String::new(), String::new(), false) }; + let clicked = tiles(ui, "efs-assets", shown.len(), list, |ui, i, r| { + let Some(a) = shown.get(i) else { return (String::new(), String::new(), false) }; crate::panels::asset_export::paint_thumb(ui, st, a, r); if scroll_to == Some(a.id) { if ui.clip_rect().contains_rect(r) { @@ -607,7 +615,7 @@ fn assets(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, list: bool) { (a.name.clone(), a.name.clone(), on.contains(&a.id)) }); for i in clicked { - if let Some(a) = st.doc.assets.get(i) { + if let Some(a) = shown.get(i) { if on.contains(&a.id) { on.retain(|id| *id != a.id); } else { diff --git a/crates/ui-egui/src/dialogs/tests_screen_assets.rs b/crates/ui-egui/src/dialogs/tests_screen_assets.rs index 32f585e81..df218bd0a 100644 --- a/crates/ui-egui/src/dialogs/tests_screen_assets.rs +++ b/crates/ui-egui/src/dialogs/tests_screen_assets.rs @@ -122,6 +122,28 @@ fn the_assets_tab_exports_the_checked_assets_into_the_folder() { let _ = std::fs::remove_dir_all(dir); } +/// 3D assets export as mesh files from the Asset Export panel: the Assets tab lists and checks the +/// picture assets alone. +#[test] +fn the_assets_tab_leaves_3d_assets_out() { + let (mut app, _, [a, b]) = app(false); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "params": {}})).unwrap(); + let three_d = app.run("assets.add3D", json!({"ids": [a]})).unwrap()["assets"][0].as_u64().unwrap(); + let picture = app.run("assets.add", json!({"ids": [b], "name": "Flat"})).unwrap()["assets"][0].as_u64().unwrap(); + app.run("assets.rename", json!({"asset": three_d, "name": "Solid"})).unwrap(); + app.run("file.exportForScreens", json!({})).unwrap(); + assert_eq!(field(&app, "assets"), json!([picture])); + set(&mut app, "tab", json!("assets")); + let text = painted_text(&mut app, |app, ui| super::show(app, ui.ctx())); + assert!(text.contains("Flat") && !text.contains("Solid"), "{text}"); + // Opened from the panel with both checked, the tab still shows the picture asset alone. + open_export_for_screens_assets(&mut app, Some(&[three_d, picture])); + assert_eq!(field(&app, "__scrollTo"), json!(picture), "the list scrolls to the first asset it shows"); + let text = painted_text(&mut app, |app, ui| super::show(app, ui.ctx())); + assert!(text.contains("Flat") && !text.contains("Solid"), "{text}"); +} + #[test] fn an_empty_assets_tab_says_how_to_collect_art() { let (mut app, _, _) = app(false); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 638a5f102..6ca909b49 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -1057,6 +1057,9 @@ font-technology Variable Променливи Export As SVG… Експортиране като SVG… Export Asset Експортиране на актив Export Assets Експортиране на активи + Export 3D Object Експортиране на 3D обект + 3D Export Format Формат на 3D експортиране + Export 3D Assets Експортиране на 3D активи Export As… Експортиране като… Export CSS Експортиране на CSS Export CSS… Експортиране на CSS… diff --git a/crates/ui-egui/src/i18n/bs.tsv b/crates/ui-egui/src/i18n/bs.tsv index 70055e848..e159a9343 100644 --- a/crates/ui-egui/src/i18n/bs.tsv +++ b/crates/ui-egui/src/i18n/bs.tsv @@ -3152,6 +3152,9 @@ theme Appearance Izgled Asset Export Settings Postavke izvoza resursa Assets Resursi Export Assets Izvezi resurse + Export 3D Object Izvezi 3D objekat + 3D Export Format Format 3D izvoza + Export 3D Assets Izvezi 3D resurse Remove Asset Ukloni resurs Rename Asset Preimenuj resurs Fill Rule Pravilo ispune diff --git a/crates/ui-egui/src/i18n/ca.tsv b/crates/ui-egui/src/i18n/ca.tsv index aca952436..391ad658e 100644 --- a/crates/ui-egui/src/i18n/ca.tsv +++ b/crates/ui-egui/src/i18n/ca.tsv @@ -1465,6 +1465,14 @@ Export As SVG… Exporta com a SVG… Export Asset Exporta el recurs Export Assets Exporta els recursos + Export 3D Object Exporta l’objecte 3D + 3D Export Format Format d’exportació 3D + Export 3D Assets Exporta els recursos 3D +@msg {what} can't be exported in 3D: {e} no es pot exportar {what} en 3D: {e} +@msg asset {id} isn't a 3D asset el recurs {id} no és un recurs 3D +@msg asset {id} is a 3D asset: assets.export3D writes it el recurs {id} és un recurs 3D: l’escriu assets.export3D +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) encara no hi ha cap recurs 3D: recolliu primer un objecte amb un efecte 3D (assets.add3D) +@msg select an object with a 3D effect (or give its ids) seleccioneu un objecte amb un efecte 3D (o indiqueu-ne els id) Export As… Exporta com a… Export CSS Exporta CSS Export CSS… Exporta CSS… diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 8a4aa807a..8f2dcb816 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -3191,6 +3191,14 @@ theme Appearance Motiv Asset Export Settings Nastavení exportu prvků Assets Prvky Export Assets Exportovat prvky + Export 3D Object Exportovat 3D objekt + 3D Export Format Formát 3D exportu + Export 3D Assets Exportovat 3D prvky +@msg {what} can't be exported in 3D: {e} {what} nelze exportovat ve 3D: {e} +@msg asset {id} isn't a 3D asset prvek {id} není 3D prvek +@msg asset {id} is a 3D asset: assets.export3D writes it prvek {id} je 3D prvek: zapisuje jej assets.export3D +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) zatím žádné 3D prvky: nejprve shromážděte objekt s 3D efektem (assets.add3D) +@msg select an object with a 3D effect (or give its ids) vyberte objekt s 3D efektem (nebo zadejte jeho ID) Remove Asset Odebrat prvek Rename Asset Přejmenovat prvek Fill Rule Pravidlo výplně diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 29aa686dd..aabdbeb90 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2935,6 +2935,9 @@ theme Appearance Udseende Asset Export Settings Indstillinger for eksport af aktiver Assets Aktiver Export Assets Eksportér aktiver + Export 3D Object Eksportér 3D-objekt + 3D Export Format 3D-eksportformat + Export 3D Assets Eksportér 3D-aktiver Remove Asset Fjern aktiv Rename Asset Omdøb aktiv Fill Rule Udfyldningsregel diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b6c9220c1..9a8669767 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -1057,6 +1057,14 @@ font-technology Variable Variabel Export As SVG… Als SVG exportieren… Export Asset Asset exportieren Export Assets Assets exportieren + Export 3D Object 3D-Objekt exportieren + 3D Export Format 3D-Exportformat + Export 3D Assets 3D-Assets exportieren +@msg {what} can't be exported in 3D: {e} {what} kann nicht als 3D exportiert werden: {e} +@msg asset {id} isn't a 3D asset Asset {id} ist kein 3D-Asset +@msg asset {id} is a 3D asset: assets.export3D writes it Asset {id} ist ein 3D-Asset: assets.export3D schreibt es +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) noch keine 3D-Assets: Sammeln Sie zuerst ein Objekt mit 3D-Effekt (assets.add3D) +@msg select an object with a 3D effect (or give its ids) wählen Sie ein Objekt mit 3D-Effekt aus (oder geben Sie seine IDs an) Export As… Exportieren als… Export CSS CSS exportieren Export CSS… CSS exportieren… diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index 23d376654..2bab85fec 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -1030,6 +1030,14 @@ font-technology Variable Μεταβλητές Export As SVG… Εξαγωγή ως SVG… Export Asset Εξαγωγή στοιχείου Export Assets Εξαγωγή στοιχείων + Export 3D Object Εξαγωγή αντικειμένου 3D + 3D Export Format Μορφή εξαγωγής 3D + Export 3D Assets Εξαγωγή στοιχείων 3D +@msg {what} can't be exported in 3D: {e} το {what} δεν μπορεί να εξαχθεί σε 3D: {e} +@msg asset {id} isn't a 3D asset το στοιχείο {id} δεν είναι στοιχείο 3D +@msg asset {id} is a 3D asset: assets.export3D writes it το στοιχείο {id} είναι στοιχείο 3D: το γράφει η assets.export3D +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) δεν υπάρχουν ακόμη στοιχεία 3D: συγκεντρώστε πρώτα ένα αντικείμενο με εφέ 3D (assets.add3D) +@msg select an object with a 3D effect (or give its ids) επιλέξτε ένα αντικείμενο με εφέ 3D (ή δώστε τα αναγνωριστικά του) Export As… Εξαγωγή ως… Export CSS Εξαγωγή CSS Export CSS… Εξαγωγή CSS… diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 5741993ca..7a6ec6794 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1086,6 +1086,14 @@ font-technology Variable Variables Export As SVG… Exportar como SVG… Export Asset Exportar recurso Export Assets Exportar recursos + Export 3D Object Exportar objeto 3D + 3D Export Format Formato de exportación 3D + Export 3D Assets Exportar recursos 3D +@msg {what} can't be exported in 3D: {e} {what} no se puede exportar en 3D: {e} +@msg asset {id} isn't a 3D asset el recurso {id} no es un recurso 3D +@msg asset {id} is a 3D asset: assets.export3D writes it el recurso {id} es un recurso 3D: lo escribe assets.export3D +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) todavía no hay recursos 3D: reúna primero un objeto con un efecto 3D (assets.add3D) +@msg select an object with a 3D effect (or give its ids) seleccione un objeto con un efecto 3D (o indique sus id) Export As… Exportar como… Export CSS Exportar CSS Export CSS… Exportar CSS… diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index ec8f0c23f..eca3d4a46 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -3130,6 +3130,14 @@ theme Appearance Ulkoasu Asset Export Settings Resurssien vientiasetukset Assets Resurssit Export Assets Vie resurssit + Export 3D Object Vie 3D-objekti + 3D Export Format 3D-vientimuoto + Export 3D Assets Vie 3D-resurssit +@msg {what} can't be exported in 3D: {e} kohdetta {what} ei voi viedä 3D-muodossa: {e} +@msg asset {id} isn't a 3D asset resurssi {id} ei ole 3D-resurssi +@msg asset {id} is a 3D asset: assets.export3D writes it resurssi {id} on 3D-resurssi: assets.export3D kirjoittaa sen +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) ei vielä 3D-resursseja: kerää ensin objekti, jossa on 3D-tehoste (assets.add3D) +@msg select an object with a 3D effect (or give its ids) valitse objekti, jossa on 3D-tehoste (tai anna sen tunnukset) Remove Asset Poista resurssi Rename Asset Nimeä resurssi uudelleen Fill Rule Täyttösääntö diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index a6aada7f9..acecace4d 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -1081,6 +1081,14 @@ font-technology Variable Variables Export As SVG… Exporter au format SVG… Export Asset Exporter la ressource Export Assets Exporter les ressources + Export 3D Object Exporter l’objet 3D + 3D Export Format Format d’exportation 3D + Export 3D Assets Exporter les ressources 3D +@msg {what} can't be exported in 3D: {e} impossible d’exporter {what} en 3D : {e} +@msg asset {id} isn't a 3D asset la ressource {id} n’est pas une ressource 3D +@msg asset {id} is a 3D asset: assets.export3D writes it la ressource {id} est une ressource 3D : assets.export3D l’écrit +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) aucune ressource 3D pour l’instant : rassemblez d’abord un objet doté d’un effet 3D (assets.add3D) +@msg select an object with a 3D effect (or give its ids) sélectionnez un objet doté d’un effet 3D (ou indiquez ses identifiants) Export As… Exporter sous… Export CSS Exporter le CSS Export CSS… Exporter le CSS… diff --git a/crates/ui-egui/src/i18n/hi.tsv b/crates/ui-egui/src/i18n/hi.tsv index b458a309f..81481eb01 100644 --- a/crates/ui-egui/src/i18n/hi.tsv +++ b/crates/ui-egui/src/i18n/hi.tsv @@ -1629,6 +1629,14 @@ Export As… इस रूप में एक्सपोर्ट करें… Export Asset संपत्ति निर्यात करें Export Assets निर्यात परिसंपत्तियां + Export 3D Object 3D ऑब्जेक्ट निर्यात करें + 3D Export Format 3D निर्यात फ़ॉर्मेट + Export 3D Assets 3D परिसंपत्तियां निर्यात करें +@msg {what} can't be exported in 3D: {e} {what} को 3D में निर्यात नहीं किया जा सकता: {e} +@msg asset {id} isn't a 3D asset परिसंपत्ति {id} 3D परिसंपत्ति नहीं है +@msg asset {id} is a 3D asset: assets.export3D writes it परिसंपत्ति {id} एक 3D परिसंपत्ति है: इसे assets.export3D लिखता है +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) अभी तक कोई 3D परिसंपत्ति नहीं: पहले 3D प्रभाव वाला ऑब्जेक्ट इकट्ठा करें (assets.add3D) +@msg select an object with a 3D effect (or give its ids) 3D प्रभाव वाला ऑब्जेक्ट चुनें (या उसकी आईडी दें) Export CSS CSS निर्यात करें Export CSS… CSS निर्यात करें… Export Data Sets… डेटा सेट निर्यात करें… diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 5c1b1b253..a362a3b68 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -1368,6 +1368,14 @@ font-technology Variable Változó Export As SVG… Exportálás SVG-ként… Export Asset Elem exportálása Export Assets Elemek exportálása + Export 3D Object 3D objektum exportálása + 3D Export Format 3D exportálási formátum + Export 3D Assets 3D elemek exportálása +@msg {what} can't be exported in 3D: {e} {what} nem exportálható 3D-ben: {e} +@msg asset {id} isn't a 3D asset a(z) {id} elem nem 3D elem +@msg asset {id} is a 3D asset: assets.export3D writes it a(z) {id} elem 3D elem: az assets.export3D írja ki +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) még nincs 3D elem: előbb gyűjtsön össze egy 3D effektussal rendelkező objektumot (assets.add3D) +@msg select an object with a 3D effect (or give its ids) jelöljön ki egy 3D effektussal rendelkező objektumot (vagy adja meg az azonosítóit) Export As… Exportálás másként… Export CSS CSS exportálása Export CSS… CSS exportálása… diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index bce0c1acb..3c0dffa2e 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -2935,6 +2935,9 @@ theme Appearance Tampilan Asset Export Settings Pengaturan ekspor aset Assets Aset Export Assets Ekspor aset + Export 3D Object Ekspor objek 3D + 3D Export Format Format ekspor 3D + Export 3D Assets Ekspor aset 3D Remove Asset Hapus aset Rename Asset Ganti nama aset Fill Rule Aturan isian diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 490d0c646..1628344a1 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -1087,6 +1087,14 @@ font-technology Variable Variabili Export As SVG… Esporta come SVG… Export Asset Esporta risorsa Export Assets Esporta risorse + Export 3D Object Esporta oggetto 3D + 3D Export Format Formato di esportazione 3D + Export 3D Assets Esporta risorse 3D +@msg {what} can't be exported in 3D: {e} impossibile esportare {what} in 3D: {e} +@msg asset {id} isn't a 3D asset la risorsa {id} non è una risorsa 3D +@msg asset {id} is a 3D asset: assets.export3D writes it la risorsa {id} è una risorsa 3D: la scrive assets.export3D +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) ancora nessuna risorsa 3D: raccogli prima un oggetto con un effetto 3D (assets.add3D) +@msg select an object with a 3D effect (or give its ids) seleziona un oggetto con un effetto 3D (o indicane gli ID) Export As… Esporta come… Export CSS Esporta CSS Export CSS… Esporta CSS… diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index f2f438876..19e8a2803 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1589,6 +1589,14 @@ digits Malayalam マラヤーラム数字 PDF Print 印刷用 PDF Export As 形式を指定して書き出し Export Assets アセットを書き出し + Export 3D Object 3D オブジェクトを書き出し + 3D Export Format 3D 書き出し形式 + Export 3D Assets 3D アセットを書き出し +@msg {what} can't be exported in 3D: {e} {what} は 3D で書き出せません: {e} +@msg asset {id} isn't a 3D asset アセット {id} は 3D アセットではありません +@msg asset {id} is a 3D asset: assets.export3D writes it アセット {id} は 3D アセットです: assets.export3D で書き出します +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) 3D アセットがまだありません: まず 3D 効果のあるオブジェクトを追加してください (assets.add3D) +@msg select an object with a 3D effect (or give its ids) 3D 効果のあるオブジェクトを選択してください (または ID を指定) Export CSS CSS を書き出し Export CSS… CSS を書き出し… Export DXF DXF を書き出し diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index 7c893c418..bf7efc0e7 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -2752,6 +2752,14 @@ theme Appearance იერსახე Asset Export Settings რესურსის ექსპორტის პარამეტრები Assets რესურსები Export Assets რესურსების ექსპორტირება + Export 3D Object 3D ობიექტის ექსპორტირება + 3D Export Format 3D ექსპორტის ფორმატი + Export 3D Assets 3D რესურსების ექსპორტირება +@msg {what} can't be exported in 3D: {e} {what} 3D-ში ვერ ექსპორტირდება: {e} +@msg asset {id} isn't a 3D asset რესურსი {id} არ არის 3D რესურსი +@msg asset {id} is a 3D asset: assets.export3D writes it რესურსი {id} 3D რესურსია: მას assets.export3D წერს +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) 3D რესურსები ჯერ არ არის: ჯერ შეაგროვეთ 3D ეფექტის მქონე ობიექტი (assets.add3D) +@msg select an object with a 3D effect (or give its ids) აირჩიეთ 3D ეფექტის მქონე ობიექტი (ან მიუთითეთ მისი ID) Remove Asset რესურსის წაშლა Rename Asset რესურსის სახელის შეცვლა Fill Rule შევსების წესი diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 838bb7e58..21d443316 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -1579,6 +1579,14 @@ digits Malayalam 말라얄람어 PDF Print 인쇄용 PDF Export As 다른 형식으로 내보내기 Export Assets 에셋 내보내기 + Export 3D Object 3D 개체 내보내기 + 3D Export Format 3D 내보내기 형식 + Export 3D Assets 3D 에셋 내보내기 +@msg {what} can't be exported in 3D: {e} {what}은(는) 3D로 내보낼 수 없습니다: {e} +@msg asset {id} isn't a 3D asset 에셋 {id}은(는) 3D 에셋이 아닙니다 +@msg asset {id} is a 3D asset: assets.export3D writes it 에셋 {id}은(는) 3D 에셋입니다: assets.export3D로 내보냅니다 +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) 아직 3D 에셋이 없습니다: 먼저 3D 효과가 있는 개체를 수집하세요 (assets.add3D) +@msg select an object with a 3D effect (or give its ids) 3D 효과가 있는 개체를 선택하세요 (또는 ID를 지정하세요) Export CSS CSS 내보내기 Export CSS… CSS 내보내기… Export DXF DXF 내보내기 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index f846de4d6..08d36a234 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2949,6 +2949,9 @@ theme Appearance Vormgeving Asset Export Settings Exportinstellingen voor assets Assets Assets Export Assets Assets exporteren + Export 3D Object 3D-object exporteren + 3D Export Format 3D-exportindeling + Export 3D Assets 3D-assets exporteren Remove Asset Asset verwijderen Rename Asset Asset hernoemen Fill Rule Vulregel diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f8fa28e93..089cd17ec 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -3069,6 +3069,14 @@ theme Appearance Wygląd Asset Export Settings Ustawienia eksportu zasobów Assets Zasoby Export Assets Eksportuj zasoby + Export 3D Object Eksportuj obiekt 3D + 3D Export Format Format eksportu 3D + Export 3D Assets Eksportuj zasoby 3D +@msg {what} can't be exported in 3D: {e} nie można wyeksportować {what} w 3D: {e} +@msg asset {id} isn't a 3D asset zasób {id} nie jest zasobem 3D +@msg asset {id} is a 3D asset: assets.export3D writes it zasób {id} jest zasobem 3D: zapisuje go assets.export3D +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) brak zasobów 3D: najpierw zbierz obiekt z efektem 3D (assets.add3D) +@msg select an object with a 3D effect (or give its ids) zaznacz obiekt z efektem 3D (lub podaj jego identyfikatory) Remove Asset Usuń zasób Rename Asset Zmień nazwę zasobu Fill Rule Reguła wypełniania diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index df73efa99..17428e1cf 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -974,6 +974,14 @@ Export As PNG… Exportar Como PNG… Export As SVG… Exportar Como SVG… Export Assets Exportar Ativos + Export 3D Object Exportar Objeto 3D + 3D Export Format Formato de Exportação 3D + Export 3D Assets Exportar Ativos 3D +@msg {what} can't be exported in 3D: {e} não é possível exportar {what} em 3D: {e} +@msg asset {id} isn't a 3D asset o ativo {id} não é um ativo 3D +@msg asset {id} is a 3D asset: assets.export3D writes it o ativo {id} é um ativo 3D: assets.export3D o grava +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) ainda não há ativos 3D: colete primeiro um objeto com um efeito 3D (assets.add3D) +@msg select an object with a 3D effect (or give its ids) selecione um objeto com um efeito 3D (ou informe seus ids) Export As… Exportar Como… Export CSS Exportar CSS Export CSS… Exportar CSS… diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index c90226537..102848cbf 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1084,6 +1084,14 @@ font-technology Variable Вариативные Export As SVG… Экспортировать как SVG… Export Asset Экспортировать ресурс Export Assets Экспортировать ресурсы + Export 3D Object Экспортировать 3D-объект + 3D Export Format Формат 3D-экспорта + Export 3D Assets Экспортировать 3D-ресурсы +@msg {what} can't be exported in 3D: {e} {what} невозможно экспортировать в 3D: {e} +@msg asset {id} isn't a 3D asset ресурс {id} не является 3D-ресурсом +@msg asset {id} is a 3D asset: assets.export3D writes it ресурс {id} является 3D-ресурсом: его записывает assets.export3D +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) 3D-ресурсов пока нет: сначала соберите объект с 3D-эффектом (assets.add3D) +@msg select an object with a 3D effect (or give its ids) выберите объект с 3D-эффектом (или укажите его идентификаторы) Export As… Экспортировать как… Export CSS Экспортировать CSS Export CSS… Экспортировать CSS… diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 75ee16447..d1dd3ca1f 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -1065,6 +1065,14 @@ font-technology Variable Variabla Export As SVG… Exportera som SVG… Export Asset Exportera resurs Export Assets Exportera resurser + Export 3D Object Exportera 3D-objekt + 3D Export Format 3D-exportformat + Export 3D Assets Exportera 3D-resurser +@msg {what} can't be exported in 3D: {e} {what} kan inte exporteras i 3D: {e} +@msg asset {id} isn't a 3D asset resurs {id} är ingen 3D-resurs +@msg asset {id} is a 3D asset: assets.export3D writes it resurs {id} är en 3D-resurs: assets.export3D skriver den +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) inga 3D-resurser än: samla först ett objekt med en 3D-effekt (assets.add3D) +@msg select an object with a 3D effect (or give its ids) markera ett objekt med en 3D-effekt (eller ange dess id:n) Export As… Exportera som… Export CSS Exportera CSS Export CSS… Exportera CSS… diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 9d807d760..b70d49803 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1057,6 +1057,14 @@ font-technology Variable Değişken Export As SVG… SVG Olarak Dışa Aktar… Export Asset Varlığı Dışa Aktar Export Assets Varlıkları Dışa Aktar + Export 3D Object 3D Nesneyi Dışa Aktar + 3D Export Format 3D Dışa Aktarma Biçimi + Export 3D Assets 3D Varlıkları Dışa Aktar +@msg {what} can't be exported in 3D: {e} {what} 3D olarak dışa aktarılamıyor: {e} +@msg asset {id} isn't a 3D asset {id} varlığı bir 3D varlık değil +@msg asset {id} is a 3D asset: assets.export3D writes it {id} varlığı bir 3D varlık: onu assets.export3D yazar +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) henüz 3D varlık yok: önce 3D efekti olan bir nesne toplayın (assets.add3D) +@msg select an object with a 3D effect (or give its ids) 3D efekti olan bir nesne seçin (veya kimliklerini verin) Export As… Farklı Dışa Aktar… Export CSS CSS'yi dışa aktar Export CSS… CSS'yi dışa aktar… diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 6d7e38cfb..9c1fbf9ce 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1081,6 +1081,14 @@ font-technology Variable Варіативні Export As SVG… Експортувати як SVG… Export Asset Експортувати ресурс Export Assets Експортувати ресурси + Export 3D Object Експортувати 3D-об’єкт + 3D Export Format Формат 3D-експорту + Export 3D Assets Експортувати 3D-ресурси +@msg {what} can't be exported in 3D: {e} {what} неможливо експортувати в 3D: {e} +@msg asset {id} isn't a 3D asset ресурс {id} не є 3D-ресурсом +@msg asset {id} is a 3D asset: assets.export3D writes it ресурс {id} є 3D-ресурсом: його записує assets.export3D +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) 3D-ресурсів ще немає: спершу зберіть об’єкт із 3D-ефектом (assets.add3D) +@msg select an object with a 3D effect (or give its ids) виберіть об’єкт із 3D-ефектом (або вкажіть його ідентифікатори) Export As… Експортувати як… Export CSS Експортувати CSS Export CSS… Експортувати CSS… diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5d9150df2..d9239a386 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -1245,6 +1245,14 @@ font-technology Variable Biến thiên Export As SVG… Xuất thành SVG… Export Asset Xuất asset Export Assets Xuất asset + Export 3D Object Xuất đối tượng 3D + 3D Export Format Định dạng xuất 3D + Export 3D Assets Xuất asset 3D +@msg {what} can't be exported in 3D: {e} không thể xuất {what} dưới dạng 3D: {e} +@msg asset {id} isn't a 3D asset asset {id} không phải là asset 3D +@msg asset {id} is a 3D asset: assets.export3D writes it asset {id} là asset 3D: assets.export3D sẽ ghi nó +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) chưa có asset 3D nào: hãy thu thập trước một đối tượng có hiệu ứng 3D (assets.add3D) +@msg select an object with a 3D effect (or give its ids) hãy chọn một đối tượng có hiệu ứng 3D (hoặc cung cấp id của nó) Export As… Xuất thành… Export CSS Xuất CSS Export CSS… Xuất CSS… diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 849a626eb..cbfd6010d 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1027,6 +1027,14 @@ Export As SVG… 导出为 SVG… Export Asset 导出资源 Export Assets 导出资源 + Export 3D Object 导出 3D 对象 + 3D Export Format 3D 导出格式 + Export 3D Assets 导出 3D 资源 +@msg {what} can't be exported in 3D: {e} 无法将 {what} 导出为 3D:{e} +@msg asset {id} isn't a 3D asset 资源 {id} 不是 3D 资源 +@msg asset {id} is a 3D asset: assets.export3D writes it 资源 {id} 是 3D 资源:由 assets.export3D 写入 +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) 还没有 3D 资源:请先收集带有 3D 效果的对象 (assets.add3D) +@msg select an object with a 3D effect (or give its ids) 请选择带有 3D 效果的对象 (或提供其 ID) Export As… 导出为… Export CSS 导出 CSS Export CSS… 导出 CSS… diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 7a5f8aa43..86cf8db1e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1051,6 +1051,14 @@ font-technology Variable 可變 Export As SVG… 匯出為 SVG… Export Asset 匯出資產 Export Assets 匯出資產 + Export 3D Object 匯出 3D 物件 + 3D Export Format 3D 匯出格式 + Export 3D Assets 匯出 3D 資產 +@msg {what} can't be exported in 3D: {e} 無法將 {what} 匯出為 3D:{e} +@msg asset {id} isn't a 3D asset 資產 {id} 不是 3D 資產 +@msg asset {id} is a 3D asset: assets.export3D writes it 資產 {id} 是 3D 資產:由 assets.export3D 寫入 +@msg no 3D assets yet: collect an object with a 3D effect first (assets.add3D) 尚無 3D 資產:請先收集具有 3D 效果的物件 (assets.add3D) +@msg select an object with a 3D effect (or give its ids) 請選取具有 3D 效果的物件 (或提供其 ID) Export As… 匯出為… Export CSS 匯出 CSS Export CSS… 匯出 CSS… diff --git a/crates/ui-egui/src/io/export.rs b/crates/ui-egui/src/io/export.rs index 1b438adf9..52ce3677c 100644 --- a/crates/ui-egui/src/io/export.rs +++ b/crates/ui-egui/src/io/export.rs @@ -244,7 +244,8 @@ pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result let open_location = params.get("openLocation").and_then(Value::as_bool).unwrap_or(false); let folder = params.get("folder").and_then(Value::as_str).unwrap_or_default().to_string(); // Without a folder the files come back in the reply, which the caller waits for. - let in_background = app.session.prefs.background_export && app.services.write_shared.is_some() && !is_web(app) && !folder.is_empty(); + let in_background = + app.session.prefs.background_export && app.services.write_shared.is_some() && !is_web(app) && !folder.is_empty() && id != "assets.export3D"; if !in_background { // Made at once, after the exports and saves started before it. background::wait_all(app); diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 073b1130e..a29714b1a 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -347,10 +347,12 @@ fn open_object_dialog(app: &mut VectorcraftApp, id: &str, p: &Value) -> bool { } return true; } - // Export Assets, Remove, Rename and Asset Export Settings with no params, as the command - // palette runs them (#4864): the Asset Export panel, where each one is done. A panel that already - // shows stays open. - if matches!(id, "assets.export" | "assets.remove" | "assets.rename" | "assets.settings.set") && p.as_object().is_none_or(|o| o.is_empty()) { + // Export Assets, Remove, Rename, Asset Export Settings, Export 3D Assets and 3D Export Format + // with no params, as the command palette runs them (#4864): the Asset Export panel, where each + // one is done. A panel that already shows stays open. + if matches!(id, "assets.export" | "assets.remove" | "assets.rename" | "assets.settings.set" | "assets.export3D" | "assets.set3DFormat") + && p.as_object().is_none_or(|o| o.is_empty()) + { if let Err(e) = crate::panel_docking::reveal_panel(app, crate::panels::asset_export::ID) { app.status(e); } diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index fda2e1f7f..4f893e686 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -222,7 +222,7 @@ mod tests { /// Asset Export panel instead of failing on the missing params or exporting nowhere. #[test] fn a_pick_of_an_asset_export_command_opens_its_panel() { - let ids = ["assets.export", "assets.remove", "assets.rename", "assets.settings.set"]; + let ids = ["assets.export", "assets.remove", "assets.rename", "assets.settings.set", "assets.export3D", "assets.set3DFormat"]; for id in ids { assert!(items().iter().any(|(_, item, _)| item == id), "{id} is in the palette"); let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/panels/asset_export.rs b/crates/ui-egui/src/panels/asset_export.rs index 872bcc0d6..f5fc9b0b9 100644 --- a/crates/ui-egui/src/panels/asset_export.rs +++ b/crates/ui-egui/src/panels/asset_export.rs @@ -7,6 +7,10 @@ //! desktop with Background Export on, on the background thread); the web downloads them. The //! screen button opens Export for Screens on its Assets tab. //! +//! Export 3D Object (the panel menu) collects the selected objects with a 3D effect as 3D assets, +//! marked 3D on their tiles. While the panel's assets include 3D assets, a Format row picks their +//! mesh format (USDA, USDZ, glTF or OBJ), and Export writes them as mesh files beside the pictures. +//! //! Everything runs through the `assets.*` commands. use std::cell::RefCell; @@ -17,7 +21,7 @@ use egui::{Color32, Rect, Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::{Value, json}; use vectorcraft_doc::{ExportAsset, Node, NodeId}; use vectorcraft_engine::DocState; -use vectorcraft_engine::cmd::fileio::{SCREEN_SETTINGS_KEYS, screen_export_preset_list}; +use vectorcraft_engine::cmd::fileio::{FORMATS_3D, SCREEN_SETTINGS_KEYS, format_3d_label, screen_export_preset_list}; use super::{pstate, set_pstate}; use crate::dialogs::{ @@ -131,6 +135,11 @@ fn tiles(ui: &mut Ui, st: &DocState, sel: &[u64], ev: &mut Events) { let th = Rect::from_min_size(egui::pos2(r.center().x - THUMB / 2.0, r.top() + 4.0), vec2(THUMB, THUMB)); paint_thumb(ui, st, a, th); ui.painter().rect_stroke(th, 0.0, Stroke::new(if on { 2.0 } else { 1.0 }, if on { t.accent } else { t.border }), StrokeKind::Outside); + if a.format_3d.is_some() { + let badge = Rect::from_min_size(th.left_top() + vec2(2.0, 2.0), vec2(18.0, 12.0)); + ui.painter().rect_filled(badge, 2.0, t.accent); + ui.painter().text(badge.center(), egui::Align2::CENTER_CENTER, "3D", egui::FontId::proportional(9.0), Color32::WHITE); + } let name_rect = Rect::from_min_max(egui::pos2(r.left() + 2.0, th.bottom() + 3.0), egui::pos2(r.right() - 2.0, r.bottom() - 1.0)); match &renaming { Some((id, buf)) if *id == a.id => { @@ -214,6 +223,12 @@ fn export(app: &mut VectorcraftApp, ids: Vec, files_each: usize) { } fn export_now(app: &mut VectorcraftApp, ids: Vec, files_each: usize) { + // 3D assets are written as mesh files (`assets.export3D`), the others as pictures. + let Some(st) = app.session.active() else { return }; + let format = |id: &u64| st.doc.asset(*id).and_then(|a| a.format_3d.clone()); + let (three_d, ids): (Vec, Vec) = ids.into_iter().partition(|id| format(id).is_some()); + // An OBJ is two files. + let mesh_files: usize = three_d.iter().map(|id| if format(id).as_deref() == Some("obj") { 2 } else { 1 }).sum(); let mut p = json!({ "assets": ids }); // Match the shared dialog's TIFF defaults, retaining saved choices and other formats. if let Some(st) = app.session.active() @@ -251,7 +266,23 @@ fn export_now(app: &mut VectorcraftApp, ids: Vec, files_each: usize) { p["folder"] = json!(folder); p["openLocation"] = json!(saved.get("openLocation").and_then(Value::as_bool).unwrap_or(true)); } - if let Err(e) = io::export_files(app, "assets.export", p) { + let mut meshes = json!({ "assets": three_d }); + if io::is_web(app) { + meshes["zip"] = json!(mesh_files > 1); + } else { + meshes["folder"] = p["folder"].clone(); + meshes["openLocation"] = p["openLocation"].clone(); + } + // The meshes are written at once, before the pictures start on the background thread. When + // they fail, the pictures are not exported and the status bar shows the error. + if !three_d.is_empty() + && let Err(e) = io::export_files(app, "assets.export3D", meshes) + { + return app.status(e); + } + if !ids.is_empty() + && let Err(e) = io::export_files(app, "assets.export", p) + { app.status(e); } } @@ -264,6 +295,21 @@ enum Settings { Format(String), } +/// The Format of the 3D assets among `ids`: a row shown only when there are some, set to their +/// format (blank when they differ). Returns those assets and the format picked for them. +fn format_3d(ui: &mut Ui, st: &DocState, ids: &[u64]) -> Option<(Vec, &'static str)> { + let three_d: Vec<(u64, &str)> = ids.iter().filter_map(|id| Some((*id, st.doc.asset(*id)?.format_3d.as_deref()?))).collect(); + let first = three_d.first()?.1; + let current = if three_d.iter().all(|(_, f)| *f == first) { format_3d_label(first) } else { "" }; + let labels: Vec<&str> = FORMATS_3D.iter().map(|f| format_3d_label(f)).collect(); + let mut picked = None; + ui.horizontal(|ui| { + widgets::dim_label(ui, &format!("3D {}", tl!("Format:"))); + picked = widgets::dropdown_names(ui, "asset-export-3d-format", current, &labels, 90.0).and_then(|i| FORMATS_3D.get(i).copied()); + }); + picked.map(|f| (three_d.into_iter().map(|(id, _)| id).collect(), f)) +} + /// The document's shared export format rows. Returns the change asked for and how many files one asset makes. fn settings(ui: &mut Ui, st: &DocState) -> (Option, usize) { let (rows, preset) = screen_saved_rows(&st.doc.export_settings); @@ -316,6 +362,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.add_space(6.0); widgets::section_header(ui, tl!("Export Settings")); let (change, files_each) = settings(ui, st); + let all_ids: Vec = st.doc.assets.iter().map(|a| a.id).collect(); + let format_change = format_3d(ui, st, &sel_or_all(&sel, &all_ids)); let doc = st.uid; let mut save_name = pstate::>(&ctx, SAVING); let mut save_pick = None; @@ -392,6 +440,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if let Some(pick) = save_pick { preset(app, &ctx, doc, pick); } + if let Some((assets, format)) = format_change { + run(app, "assets.set3DFormat", json!({ "assets": assets, "format": format })); + } if add { collect(app, &ctx, None, alt); } @@ -525,6 +576,12 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Add Selected Artwork as Multiple Assets"), has_art, false) { collect(app, &ctx, None, false); } + if menu_item(ui, tl!("Export 3D Object"), has_art, false) + && let Some(r) = run(app, "assets.add3D", json!({})) + { + let ids: Vec = r["assets"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect(); + set_pstate(&ctx, SELECTED, ids); + } ui.separator(); if menu_item(ui, tl!("Select All Assets"), !all.is_empty(), false) { set_pstate(&ctx, SELECTED, all.clone()); diff --git a/crates/ui-egui/src/panels/tests_asset_export.rs b/crates/ui-egui/src/panels/tests_asset_export.rs index 3c686d335..ad8ec702c 100644 --- a/crates/ui-egui/src/panels/tests_asset_export.rs +++ b/crates/ui-egui/src/panels/tests_asset_export.rs @@ -743,3 +743,78 @@ fn tiff_saved_aliases_use_document_defaults_and_explicit_rgb() { } let _ = std::fs::remove_dir_all(&dir); } + +/// Export 3D Object in the panel menu collects the selected 3D object; its tile is marked 3D, the +/// Format row sets its mesh format, and Export writes it as a mesh file beside the pictures. +#[test] +fn a_3d_asset_takes_its_format_from_the_row_and_exports_as_a_mesh() { + let (mut app, log, [a, b]) = app(true); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "params": {"depth": 20}})).unwrap(); + app.run("layer.setProps", json!({"id": a, "name": "Box"})).unwrap(); + app.run("assets.add", json!({"ids": [b], "name": "Flat"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + // Without a 3D asset there is no 3D Format row. + frame(&ctx, &mut app, vec![], false); + assert!(!has(&frame(&ctx, &mut app, vec![], false), "3D Format:")); + menu_frame(&ctx, &mut app, vec![]); + let items = menu_frame(&ctx, &mut app, vec![]); + menu_frame(&ctx, &mut app, click(at(&items, " Export 3D Object"))); + let list = assets(&mut app); + assert_eq!((list[1]["name"].clone(), list[1]["format3D"].clone()), (json!("Box"), json!("usda"))); + let texts = frame(&ctx, &mut app, vec![], false); + let texts = if has(&texts, "3D Format:") { texts } else { frame(&ctx, &mut app, vec![], false) }; + assert!(has(&texts, "3D") && has(&texts, "3D Format:"), "{:?}", texts.iter().map(|t| &t.0).collect::>()); + frame(&ctx, &mut app, click(at(&texts, "USDA")), false); + let texts = frame(&ctx, &mut app, vec![], false); + frame(&ctx, &mut app, click(at(&texts, "OBJ")), false); + assert_eq!(assets(&mut app)[1]["format3D"], "obj"); + // The menu left the new 3D asset selected: Export writes its OBJ and MTL alone, as one zip. + let texts = frame(&ctx, &mut app, vec![], false); + frame(&ctx, &mut app, click(at(&texts, "Export 1 Selected")), false); + { + let log = log.borrow(); + let [(name, zip)] = log.as_slice() else { panic!("{:?} {}", log.iter().map(|f| &f.0).collect::>(), app.ui.status) }; + assert!(name.ends_with(".zip"), "{name}"); + let has_name = |n: &str| zip.windows(n.len()).any(|w| w == n.as_bytes()); + assert!(has_name("Box.obj") && has_name("Box.mtl")); + } + // Every asset: the 3D asset's files first, since they are written at once, then the picture + // asset as before. + log.borrow_mut().clear(); + menu_frame(&ctx, &mut app, vec![]); + let items = menu_frame(&ctx, &mut app, vec![]); + menu_frame(&ctx, &mut app, click(at(&items, " Select All Assets"))); + frame(&ctx, &mut app, vec![], false); + let texts = frame(&ctx, &mut app, vec![], false); + frame(&ctx, &mut app, click(at(&texts, "Export")), false); + let log = log.borrow(); + assert_eq!(log.iter().map(|f| f.0.ends_with(".zip")).collect::>(), [true, false], "{:?}", log.iter().map(|f| &f.0).collect::>()); + assert!(log[1].0.starts_with("Flat"), "{}", log[1].0); +} + +/// When the 3D assets fail to export, Export leaves the picture assets unexported and the status +/// bar keeps the 3D error. +#[test] +fn a_failed_3d_export_stops_the_picture_export() { + let (mut app, log, [a, b]) = app(true); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "params": {"depth": 20}})).unwrap(); + app.run("layer.setProps", json!({"id": a, "name": "Box"})).unwrap(); + app.run("assets.add3D", json!({"ids": [a]})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.run("effect.remove", json!({"index": 0})).unwrap(); + app.run("assets.add", json!({"ids": [b], "name": "Flat"})).unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&ctx, &mut app, vec![], false); + menu_frame(&ctx, &mut app, vec![]); + let items = menu_frame(&ctx, &mut app, vec![]); + menu_frame(&ctx, &mut app, click(at(&items, " Select All Assets"))); + frame(&ctx, &mut app, vec![], false); + let texts = frame(&ctx, &mut app, vec![], false); + frame(&ctx, &mut app, click(at(&texts, "Export")), false); + assert!(log.borrow().is_empty(), "{:?}", log.borrow().iter().map(|f| &f.0).collect::>()); + assert!(app.ui.status.contains("Box"), "{}", app.ui.status); +} diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 6fe07e086..ac80c1f6f 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -183,11 +183,11 @@ VectorCraft choices; an original approximation, never compared with another appl | Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; no materials, shadows or ray tracing | | 3D (Classic): Extrude & Bevel, Revolve, Rotate | none | | Turntable (30.0 beta) | none | -| Export 3D objects (OBJ, USDA, glTF) | none | +| Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object collects an object with Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, and Export writes its solids as a USDA (the default), USDZ, glTF or OBJ mesh with a material in each solid's paint color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`); no textures, map art or shading ([3D export help](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)) | The `three-d` crate (depends on `geom` and `pathops`, with no UI or GPU dependencies) builds, rotates, projects, shades and depth-sorts the surfaces; the effects crate turns them into art. What's left (materials and shadows, ray-traced rendering, -angular cut caps, exact visibility, 3D Classic, Turntable, 3D export) is 25–45 h. +angular cut caps, exact visibility, 3D Classic, Turntable) is 25–45 h. ## Revision history diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 9fc6094fd..7259b9775 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -21,7 +21,7 @@ from the public specifications, and users' own `.ai` files are used locally only | Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, artboards) or as legacy version 7 (layers and groups, one artboard per file) | | Formats opened or placed | ~30 | 29 | missing: FreeHand, CorelDRAW, DWG, HEIC/HEIF, PICT, PS | | Formats saved (Save As) | 6 (AI, AIT, PDF, EPS, SVG, SVGZ) | 6 + native | `.ai` written PDF-compatible (with an editing copy of the art: layers, groups, paths, colors, gradients, patterns, images, type, symbols, artboards, transparency, opacity masks, clipping) or as legacy version 7 (layers and groups, one artboard per file) | -| Formats exported | ~16 | 17 | missing: DWG, 3D (OBJ/USDA/glTF); extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD | +| Formats exported | ~16 | 18 | missing: DWG; extra: GIF/PNG-8 palette export, DXF up to 2018, layered PSD | | **Overall (weighted by use)** | | **~75%** | `.ai` and PDF dominate the weighting | EPS editing-data follow-up (2026-10-16): both mesh-corner layouts (with and without opacity) @@ -282,7 +282,7 @@ A Scatter brush in the editing data whose last 15 numbers start with 1 comes int | Word `.docx` | place | place (area type with the document's styles and its fonts, size, bold, italic, underline, strikethrough, caps, superscript and subscript, color, alignment, indents and paragraph spacing) | no | `tests/textimport.rs`, `place/docx.rs`; fuzzed (`text_import_fuzz.rs`); list numbers, pictures and text boxes are left out | | Word `.doc` | place | place (area type with the same formatting as `.docx`, from the style sheet's styles and direct formatting; Word 97 and later) | no | `tests/textimport.rs`, `place/doc.rs`; fuzzed (`text_import_fuzz.rs`); pictures, headers, footers, notes, comments and text boxes are left out with a warning; encrypted and fast-saved documents are refused | | CSS | export (CSS Properties) | — | yes | | -| OBJ / USDA / glTF | 3D export | — | no | needs 3D (gap G3) | +| OBJ / USDA / glTF | 3D export | — | yes, also USDZ (a 3D asset of Asset Export: `assets.add3D`, `assets.export3D`) | each solid of an object with Extrude & Bevel, Revolve, Inflate or Rotate is a triangle mesh of its faces before projection, at the object's 3D rotation, y up, with a two-sided material in its paint's color; glTF 2.0 is one `.gltf` with its buffer embedded, in meters; USDA and OBJ are in points (USDA says its `metersPerUnit`), OBJ with an `.mtl`; USDZ stores the USDA uncompressed on a 64-byte boundary; tests in `crates/three-d/src/mesh/tests.rs`, `crates/engine/src/cmd/fileio/mesh3d/tests.rs` and `crates/engine/src/tests/assets_3d.rs` | | Affinity `.af`, `.afdesign`, `.afpub` | no | yes (beyond Illustrator) | no (#665) | 51 pinned files, [affinity-validation.md](affinity-validation.md) | | Clipboard: PDF, SVG, PNG, EMF, text | yes | yes | yes | copied files paste as art; cells copied from a spreadsheet (tab-separated text) paste as a table of point type over gridlines, at most 1000 rows, 256 columns and 20,000 cells (#5296) | diff --git a/docs/gaps.md b/docs/gaps.md index 8369b2573..40626920f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -637,7 +637,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall Extrude & Bevel, Inflate and Rotate with Position presets ([three-d.md](three-d.md), #1407). Colored Inflate has automatic smooth vector lighting from shared membrane normals. White-light Inflate keeps its existing appearance. All four take one to ten colored directional lights, with neutral ambient applied once, a light list and independent previewing controls. Standard, Diffuse, Top Left and Right select original rigs, shared with commands; manual settings show Custom. - **Missing:** materials (base, Substance, graphics), shadows, ray-traced rendering, angular cut caps, exact visibility for intersecting and concave surfaces, 3D Classic, - Turntable, 3D export (OBJ, USDA, glTF). + Turntable, textures, map art and shading in 3D export. - **Impact:** specialist; Materials is the one menu stub left. - **Estimate:** 25–45 h; plan in `plan/` before coding. diff --git a/docs/mcp.md b/docs/mcp.md index 75572914c..891fd6e14 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5153,6 +5153,38 @@ renderer measures for what the art draws, its effects included. A layer id in `ids` (12 above) is the same as Collect for Export on that Layers row: every object under the layer becomes an asset (or one asset with `multiple: false`). +### 3D assets + +Asset Export's menu item Export 3D Object (`assets.add3D {ids?, format?}`) collects each selected object (or `ids`) +that carries Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, named after the object; an object already +collected as a 3D asset keeps it, and an object without such an effect is refused. A 3D asset's Format is `usda` (the +default), `usdz`, `gltf` or `obj`; the panel shows a Format row for the 3D assets it would export, and +`assets.set3DFormat {assets, format}` sets it (an undo step only when the format changes). `assets.list` gives each +asset's `format3D` (null for a picture asset). + +`assets.export3D {assets?, folder?, zip?}` writes each 3D asset (default: all of them) as a mesh file named after it: +`.usda`, `.usdz`, `.gltf` (glTF 2.0, its buffer embedded) or `.obj` with its `.mtl` (whitespace and `#` in an OBJ's +name become `_`). Every solid of the object (the +fill's, each stroke's, a group's members') is its own mesh, with a two-sided material in the color it is painted +(a gradient's stops averaged, a pattern gray). The mesh holds the solid's faces before they are projected onto the +page, those turned away from the viewer included, at the object's 3D rotation, y up, about the point the object turns +about: in points (USD's layer gives `metersPerUnit`), in meters in glTF. Files that would share a name get `-2`, `-3` and so on. +With `zip`, the files go into one ZIP named after the document with `-3D` added (`Untitled-1-3D.zip`), or +`3D Assets.zip` when the document's title gives no file name. +An object whose face has a hole that crosses the face's outline or another hole is refused, by `assets.add3D` and +`assets.export3D` alike, rather than exported with the hole filled. So is an object whose meshes would hold more than +2,000,000 triangles together, VectorCraft's own limit. +`assets.export` leaves 3D assets out and refuses one named in its `assets`. On the 3D export page +() +the 3D asset's Format is picked in Asset Export, with USDA first; the file layouts follow the glTF 2.0 specification +() and OpenUSD (). + +```json +{"name":"run_command","arguments":{"command":"assets.add3D","params":{"ids":[12],"format":"gltf"}}} +{"name":"run_command","arguments":{"command":"assets.set3DFormat","params":{"assets":[40],"format":"obj"}}} +{"name":"run_command","arguments":{"command":"assets.export3D","params":{"folder":"/tmp/meshes"}}} +``` + ## CSS Properties `css.selection {ids?, units?, position?, dimensions?, unnamed?, rasterize?}` returns the CSS web pages style the diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index c92cf09e1..43844d158 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -187,7 +187,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors | 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged | | Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (four generated libraries, User Defined, Other Library…, Save Brushes); remaining: Art brush Overlap, Pattern brush auto corners, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) | | Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, 3D Classic, Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, on-canvas Direct Selection of parts inside dynamic symbol instances and their `.ai` round trip, more built-in symbol libraries, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic symbols keep per-instance appearance linked, #4938) are done | 4–8 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged | @@ -195,7 +195,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, on-canvas Direct Selection of parts inside dynamic symbol instances and their `.ai` round trip, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic symbols keep per-instance appearance linked, #4938) are done | 4–8 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged | | Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) | | Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review | -| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, 3D Classic, Turntable views, 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | +| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, 3D Classic, Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged | | Raster effects (Effect Gallery) | 4 | 52% | the Effect Gallery's filter thumbnails and in-dialog preview area ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)) | 1–2 | estimated (raised from 49%: the Effect Gallery shipped, #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `dialogs::effect_gallery::tests::stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; depth estimated | @@ -204,6 +204,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de | Layers, artboards, document setup | 5 | 78% | Selected-artboard deletion is consistent across panel actions (all-selected retains artboard 1); history prunes removed indices while preserving valid multiselection; remaining: 30.0's artboard locking and colours and managing artboards from the canvas, Layers panel row context menus and Collect for Export (click-to-fit objects is done, #1227) | 5–8 | estimated (lowered from 82%: target moved); Artboard deletion keys keep artwork and the first of an all-selected set; Artboard Options' Show Center Mark and Show Cross Hairs draw on the canvas only (exports and print leave them out), regression-tested; Artboard Options' Show Video Safe Areas and Video Ruler Pixel Aspect Ratio (per artboard, native format) are done ([video display options](#evidence-what-ships-today-by-area)), score unchanged | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged); cached canvas dimensions match the renderer limit before cache-key creation, view construction and worker submission (regression evidence below) | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), no writing of Illustrator's editing data (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`; the distance from the path under Align to Path comes from VectorCraft's font metrics, and the fit under Gravity does not reproduce the file's layout), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | +| File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, textures, map art and shading in 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | | File formats | 6 | 75% | `.ai` files open with a flattened or wrong layer structure in real cases (#951), a saved `.ai`'s editing copy without live effects on objects other than paths, compound paths and groups that qualify (at most a fill and a stroke, a non-zero fill rule when filled, no large canvas) and whose appearance and effects the art-style writer supports, and with live objects as their art (#868), FreeHand/CorelDRAW/DWG/HEIC import, 3D export; see [file-format-parity.md](file-format-parity.md); EPS legacy mesh/opacity-mask and raster comparison follow-up verified | 10–20 | estimated (lowered from 85%: open issues); DXF control-point SPLINE import closure regression-tested (#1299 follow-up after #1320); PDF writer metadata, reader streaming and immutable backend checks in `crates/pdf/src/tests.rs`, `crates/pdf/src/tests/reader_streaming.rs` and `xtask/src/pdf_deps.rs`; PDF Place and clipboard paste retain live text (synthetic regressions); SVG CSS classes survive export and repeated import in all four styling modes (#2480); current-main integration retains event attributes and scripts; EPS ordinary Save retains the page frame and editable document (2026-10-26); `.ai`/EPS editing data checked against a user's files (2026-10-30): case setting (`ate::tests`, `tests_native::type_from_the_text_document_keeps_its_case_setting`), placed-file placements (`tests_native::a_small_placed_file_goes_where_it_draws_closer_to_the_page`), pages cropped to the art (`tests_native::type_on_the_copies_of_a_page_cropped_to_the_art_is_read_once`), mesh corner weights and state (`ai::mesh::tests::corners_with_handle_weights_of_zero_are_read`, `ai::tests::a_gradient_mesh_takes_the_transparency_written_after_it`), type on a path spacing (`tests_native::type_round_a_circle_is_spaced_as_the_file_lays_it_out`), blend state (`ai::tests::a_blends_transparency_follows_its_record`); scores and estimates unchanged. | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged); document-tab artwork hover/copy and lossless transfer tested (2026-10-22; score/hours unchanged); cached canvas reprojection preserves scale during resize, rotation and DPI changes (regression and contributor original-file evidence below; scores/hours unchanged) | | View & navigation | 3 | 70% | multiple windows and the rest of Arrange (Cascade, Float in Window, Float All in Windows) | 9–14 | estimated; Window › Arrange › Tile and Consolidate All Windows done (score/hours unchanged) | @@ -985,6 +986,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - Extrude & Bevel and Inflate treat strokes as outlined: each visible stroke builds a solid from its outline in its own paint beside the top fill's ([three-d.md](three-d.md#fills-and-strokes)). - Extrude & Bevel, Inflate and Rotate apply to type: it makes a solid of its glyph outlines, painted with its own fills and strokes or, when it has none, its characters' colour (the first run's), and stays type, so editing the text reshapes the solid (#4715). - Extrude & Bevel, Inflate and Rotate (Effect › 3D and Materials): live effects in the `three-d` crate with depth, twist, taper, solid or hollow caps, five original bevel shapes (width, height, repeat, spacing, inside, both sides), Inflate's volume, both sides, twist, taper and cap, Rotate's turned fills and strokes, Position presets, the rotation and light gizmos, SVG/PDF vector output and Expand Appearance; one 3D effect per object; covered by geometry, effect, canvas-versus-export, command and dialog tests ([three-d.md](three-d.md)). + - Export 3D Object (Asset Export's menu): an object with Extrude & Bevel, Revolve, Inflate or Rotate becomes a 3D asset whose Format is USDA (the default), USDZ, glTF or OBJ, and Export writes each of its solids as a mesh with a material in its paint's color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`; [export 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)). - Initial live Revolve (#605, #846): a portable geometry and shading crate (`three-d`), editable path and compound profiles, compact options with artboard rotation and light gizmos, SVG/PDF vector output and Expand Appearance that can trim covered faces. Open ends have continuous caps by default, shading is smooth with one to ten colored lights, and intersecting surfaces' visibility is approximate ([revolve.md](revolve.md)). - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups; several loose objects are grouped first, in one undo step), Color Adjustments (Brightness/Contrast, Curves, Levels, Hue/Saturation, Shift to Color, Temperature/Tint, on vectors, live type and embedded images), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters). Zig Zag spaces its ridges evenly along each segment, so lines and curves get a regular wave in Corner and Smooth (#1558). Warp › Arc (the effect and Envelope › Make with Warp) bends in the object's real proportions: a circular arc with concentric edges and no fold-over at 100%, so Arc on art that isn't square draws differently from before (#1581, fixes #1578). `effect.apply` and `effect.setParams` give an error for a misspelt or wrongly typed effect parameter instead of running the effect with its default (#1623, #1592). Roughen with Smooth keeps turn-backs smooth instead of making cusps that draw as spikes (#1646, #1643). Scribble draws one zig-zag per region, with no lines across holes and notches; convex shapes are unchanged (#1651, #1648). On the canvas, in Expand Appearance and in exports, Effect → Pathfinder uses the glyph outlines of type in the group, and results painted like the type keep its opacity and blend mode. - Cmd/Ctrl+4 (`pathfinder.repeat`) repeats the last successful Pathfinder panel operation, direct `object.pathfinder.*` command or `command.batch` step (including Actions playback) on the current selection; the panel menu names it. `action.batch`, Trap, compound-shape edits and live effects do not set history. Public references: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), [Pathfinder panel overview](https://helpx.adobe.com/illustrator/desktop/manage-objects/reshape-transform-objects/pathfinder-panel-overview.html).