diff --git a/crates/ui-egui/src/panels/asset_export.rs b/crates/ui-egui/src/panels/asset_export.rs index 39b3a0ea85..3fe249b7e3 100644 --- a/crates/ui-egui/src/panels/asset_export.rs +++ b/crates/ui-egui/src/panels/asset_export.rs @@ -7,7 +7,7 @@ //! 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, +//! Export 3D Object (the panel menu, and the 3D and Materials panel's button) 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. //! @@ -207,6 +207,16 @@ fn run(app: &mut VectorcraftApp, cmd: &str, params: Value) -> Option { app.run(cmd, params).map_err(|e| app.status(e)).ok() } +/// Export 3D Object: collects objects `ids` (else the selection) as 3D assets (`assets.add3D`), and +/// selects their assets in the panel, including those that were assets already. +pub(crate) fn add_3d(app: &mut VectorcraftApp, ctx: &egui::Context, ids: Option<&[u64]>) -> Result<(), String> { + let p = ids.map_or_else(|| json!({}), |ids| json!({ "ids": ids })); + let r = app.run("assets.add3D", p)?; + let assets: Vec = r["assets"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect(); + set_pstate(ctx, SELECTED, assets); + Ok(()) +} + /// Collect `ids` (else the selection) as assets (one of them all with `single`), and select them /// in the panel. fn collect(app: &mut VectorcraftApp, ctx: &egui::Context, ids: Option>, single: bool) { @@ -630,10 +640,9 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { 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 Err(e) = add_3d(app, &ctx, None) { - let ids: Vec = r["assets"].as_array().into_iter().flatten().filter_map(Value::as_u64).collect(); - set_pstate(&ctx, SELECTED, ids); + app.status(e); } ui.separator(); if menu_item(ui, tl!("Select All Assets"), !all.is_empty(), false) { diff --git a/crates/ui-egui/src/panels/tests_three_d.rs b/crates/ui-egui/src/panels/tests_three_d.rs index 6146bc4852..35e17797b1 100644 --- a/crates/ui-egui/src/panels/tests_three_d.rs +++ b/crates/ui-egui/src/panels/tests_three_d.rs @@ -1026,6 +1026,73 @@ fn the_arrow_keys_move_the_light_pressed_last() { assert_eq!(rotations, [json!(0.0), json!(-179.0)]); } +fn assets(app: &mut VectorcraftApp) -> Vec { + app.run("assets.list", json!({})).unwrap()["assets"].as_array().unwrap().clone() +} + +/// The panel always shows Export 3D Object; it adds only the selected objects that have a 3D +/// effect to Asset Export as 3D assets, in one undo step, selects their assets there and shows the +/// Asset Export panel. Run again, it records nothing and still selects them. +#[test] +fn export_3d_object_adds_the_selected_3d_objects_to_asset_export() { + let mut app = app(); + assert!(painted_text(&mut app, show).contains("Export 3D Object")); + let ctx = egui::Context::default(); + // Without a 3D object: an error, nothing recorded, no asset. + let history = undo_len(&app); + assert!(super::export_3d_object(&mut app, &ctx).is_err()); + assert_eq!((undo_len(&app), assets(&mut app).len()), (history, 0)); + let a = app.session.active().unwrap().selection.objects[0].0; + set_type(&mut app, "extrude").unwrap(); + app.run("layer.setProps", json!({"id": a, "name": "Box"})).unwrap(); + assert!(painted_text(&mut app, show).contains("Export 3D Object")); + let plain = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + // A hidden 3D effect draws nothing to export: the object is left out like one without. + let hidden = app.run("shape.rectangle", json!({"x": 100, "y": 250, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null})).unwrap(); + app.run("effect.setParams", json!({"index": 0, "item": null, "visible": false})).unwrap(); + assert!(super::export_3d_object(&mut app, &ctx).is_err()); + app.run("select.set", json!({"ids": [plain, a, hidden]})).unwrap(); + assert_eq!(super::selected_3d_objects(&app), [a]); + assert!(!crate::panel_docking::visible(&app.ui, crate::panels::asset_export::ID)); + let history = undo_len(&app); + super::export_3d_object(&mut app, &ctx).unwrap(); + assert_eq!(undo_len(&app), history + 1); + let list = assets(&mut app); + assert_eq!(list.len(), 1, "{list:?}"); + assert_eq!((list[0]["name"].clone(), list[0]["format3D"].clone()), (json!("Box"), json!("usda"))); + let asset = list[0]["id"].as_u64().unwrap(); + assert_eq!(super::pstate::>(&ctx, "asset-export-selected"), [asset]); + assert!(crate::panel_docking::visible(&app.ui, crate::panels::asset_export::ID)); + // Already an asset: no undo step, the same asset selected again, the panel left showing. + super::set_pstate(&ctx, "asset-export-selected", Vec::::new()); + super::export_3d_object(&mut app, &ctx).unwrap(); + assert_eq!((undo_len(&app), assets(&mut app).len()), (history + 1, 1)); + assert_eq!(super::pstate::>(&ctx, "asset-export-selected"), [asset]); + assert!(crate::panel_docking::visible(&app.ui, crate::panels::asset_export::ID)); + // Undo takes the asset away. + app.run("edit.undo", json!({})).unwrap(); + assert!(assets(&mut app).is_empty()); +} + +/// A selected 3D object inside another selected 3D object is not collected on its own: the outer +/// object's asset holds it. +#[test] +fn export_3d_object_leaves_out_a_3d_object_inside_another() { + let mut app = app(); + let inner = app.session.active().unwrap().selection.objects[0].0; + set_type(&mut app, "extrude").unwrap(); + let other = app.run("shape.rectangle", json!({"x": 250, "y": 100, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [inner, other]})).unwrap(); + let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [group]})).unwrap(); + app.run("effect.apply", json!({"effect": "threeD.extrude", "item": null})).unwrap(); + app.run("select.set", json!({"ids": [inner, group]})).unwrap(); + let selected: Vec = app.session.active().unwrap().selection.objects.iter().map(|id| id.0).collect(); + assert!(selected.contains(&inner) && selected.contains(&group), "{selected:?}"); + assert_eq!(super::selected_3d_objects(&app), [group]); +} + #[test] fn changed_settings_lists_only_new_values() { let before = json!({"depth": 50.0, "cap": true, "bevel": "none"}); diff --git a/crates/ui-egui/src/panels/three_d.rs b/crates/ui-egui/src/panels/three_d.rs index 231a755de2..2611824cda 100644 --- a/crates/ui-egui/src/panels/three_d.rs +++ b/crates/ui-egui/src/panels/three_d.rs @@ -4,11 +4,13 @@ //! dialogs' own Object, Rotation and Lighting controls and the panel's Materials tab //! ([`materials`]); each change runs `effect.setParams` as one undo step, and a drag previews and //! commits once, when the pointer is released. While the Materials tab is open, the artboard shows -//! a handle on each mapped graphic, which drags it over its face. +//! a handle on each mapped graphic, which drags it over its face. At the bottom, Export 3D Object +//! adds the selected objects that have a 3D effect to Asset Export as 3D assets (`assets.add3D`) +//! and shows that panel with their assets selected. use egui::Ui; use serde_json::{Map, Value, json}; -use vectorcraft_doc::{Effect, Node}; +use vectorcraft_doc::{Effect, Node, NodeId}; use vectorcraft_effects::{CLASSIC_EXTRUDE, CLASSIC_REVOLVE, EXTRUDE, INFLATE, THREE_D_TYPES, is_three_d}; use super::{live_run, pstate, set_pstate}; @@ -49,6 +51,13 @@ fn find(n: &Node) -> Option { in_list(&n.appearance.effects, None).or_else(|| n.appearance.items.iter().enumerate().find_map(|(k, item)| in_list(item.effects(), Some(k)))) } +/// Does object `n` have a visible 3D effect (where [`find`] looks, in a visible fill or stroke), +/// the kind Export 3D Object exports, without copying its settings? +fn has_three_d(n: &Node) -> bool { + let in_list = |fx: &[Effect]| fx.iter().any(|e| e.visible && is_three_d(&e.id)); + in_list(&n.appearance.effects) || n.appearance.items.iter().any(|item| item.visible() && in_list(item.effects())) +} + /// The panel's object type of effect `id` (`plane`, `extrude`, `revolve` or `inflate`). pub(crate) fn type_of(id: &str) -> Option<&'static str> { THREE_D_TYPES.iter().find(|(_, effect)| *effect == id).map(|(kind, _)| *kind) @@ -229,6 +238,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if let Some(action) = materials::library(app, ui, false) { run_action(app, &ctx, action); } + export_button(app, ui, &ctx); return; }; if takes_render(&t.effect) { @@ -271,6 +281,50 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if let Some(action) = action { run_action(app, &ctx, action); } + export_button(app, ui, &ctx); +} + +/// The Export 3D Object button, available while a selected object has a 3D effect. An error goes +/// to the status bar. +fn export_button(app: &mut VectorcraftApp, ui: &mut Ui, ctx: &egui::Context) { + let any = app.session.active().is_some_and(|st| st.selection.objects.iter().any(|id| st.doc.node(*id).is_some_and(has_three_d))); + ui.add_space(10.0); + if ui.add_enabled(any, egui::Button::new(tl!("Export 3D Object"))).clicked() { + finish(app, ctx); + if let Err(e) = export_3d_object(app, ctx) { + app.ui.status = e; + } + } +} + +/// The selected objects that have a 3D effect, in tree order, leaving out each one inside another +/// listed one (that one's asset holds it). +pub(crate) fn selected_3d_objects(app: &VectorcraftApp) -> Vec { + let Some(st) = app.session.active() else { return vec![] }; + let found = st.selection.objects.iter().copied().filter(|id| st.doc.node(*id).is_some_and(has_three_d)); + let mut paths: Vec<(Vec, NodeId)> = st.doc.index_paths(found).into_iter().map(|(id, path)| (path, id)).collect(); + paths.sort_by(|a, b| a.0.cmp(&b.0)); + let mut kept: Vec<(Vec, NodeId)> = Vec::with_capacity(paths.len()); + for (path, id) in paths { + if !kept.last().is_some_and(|(outer, _)| path.starts_with(outer)) { + kept.push((path, id)); + } + } + kept.into_iter().map(|(_, id)| id.0).collect() +} + +/// Export 3D Object: adds the selected objects that have a 3D effect to Asset Export as 3D assets +/// (`assets.add3D`, one undo step, none when each is an asset already), selects their assets there +/// and shows the Asset Export panel. Objects without a 3D effect in the selection are left out. +pub(crate) fn export_3d_object(app: &mut VectorcraftApp, ctx: &egui::Context) -> Result<(), String> { + use super::asset_export; + let ids = selected_3d_objects(app); + // Without one, `assets.add3D` on the selection says why nothing was added. + asset_export::add_3d(app, ctx, (!ids.is_empty()).then_some(ids.as_slice()))?; + if !crate::panel_docking::visible(&app.ui, asset_export::ID) { + app.run("window.panel", json!({"panel": asset_export::ID}))?; + } + Ok(()) } /// Does 3D effect `id` take render settings (`render`)? Extrude & Bevel, Revolve, Inflate and Rotate diff --git a/docs/effects-parity.md b/docs/effects-parity.md index 290148b89d..41ad38cfeb 100644 --- a/docs/effects-parity.md +++ b/docs/effects-parity.md @@ -183,7 +183,7 @@ 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, Render Settings › Wireframe, which draws the live effect as the edges of its faces, and Quick Actions › Expand as Wireframes ([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; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); the Materials tab's Graphics, document symbols mapped onto Extrude & Bevel's caps and onto a plane, moved with an artboard handle, and wrapped around Revolve's side ([three-d.md](three-d.md#graphics)); no base materials, graphics on Inflate or on Extrude & Bevel's walls and bevels, soft shadows or ray tracing | | 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, round seam joins, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic) initial: live, round seam joins, Angle, Cap, Offset from the left or right edge, Position presets and rotation gizmo, Perspective, the same four surfaces, lights, highlight, Blend Steps and Shading Color, groups and layers revolved about one shared axis (same page); Rotate (Classic) initial: live, round seam joins, every fill and stroke turned as one plane, Position presets and rotation gizmo, Perspective, No Shading and Diffuse surfaces with lights, Ambient Light, Blend Steps and Shading Color ([rotate objects in 3D](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/rotate-objects-in-3d.html)); Preserve Spot Colors in all three, unavailable with a Custom Shading Color ([Preserve Spot Color](https://help.adobe.com/archive/en_US/illustrator/cs5/illustrator_cs5_help.pdf#page=260)); Map Art: none | | Turntable (30.0 beta) | 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)) | +| Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object, and the 3D and Materials panel's Export 3D Object button (which then shows Asset Export with the new assets selected, [three-d.md](three-d.md#3d-and-materials-panel)), collect 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, diff --git a/docs/mcp.md b/docs/mcp.md index c38d6fc7a2..22f1a8d49e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8548,7 +8548,9 @@ 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`) +Asset Export's menu item Export 3D Object and the 3D and Materials panel's Export 3D Object button +(`assets.add3D {ids?, format?}`; the button passes the ids of the selected objects that have a 3D effect and then shows +the Asset Export panel, [three-d.md](three-d.md#3d-and-materials-panel)) collect 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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4c8b2d43f8..d719e7dc9f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -596,6 +596,7 @@ since 2026-10-01. - Window › 3D and Materials panel: `crates/ui-egui/src/panels/three_d.rs` applies a 3D effect by object type (Plane, Extrude, Revolve, Inflate), changes an applied one in place (`effect.setParams` `threeDType`) and edits it with the options dialog's Object, Rotation and Lighting controls, one undo step per change; `crates/engine/src/cmd/effectcmd/three_d_type_tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. No Materials tab. - 3D and Materials › Quick Actions › Expand as Wireframes (#6354): `effect.expandAsWireframes` replaces each selected 3D object with a group of 0.5 pt black stroked paths, the edges of the faces its effect draws, each edge once and joined end to end, curves kept, in one undo step ([three-d.md](three-d.md#3d-and-materials-panel)); `crates/effects/src/solids/wireframe/tests.rs`, `crates/engine/src/cmd/wireframe/tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. Scores and hours unchanged. - 3D and Materials › Render Settings › Wireframe: `render: {mode: "wireframe"}` on Extrude & Bevel, Revolve, Inflate and Rotate draws the live effect as the edges of its faces, 0.5 pt black strokes with no shadow, in every output and in Expand Appearance ([three-d.md](three-d.md#3d-and-materials-panel)); `crates/effects/src/solids/wireframe/tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. Scores and hours unchanged. +- 3D and Materials › Export 3D Object: the panel's button adds the selected objects that have a 3D effect to Asset Export as 3D assets (`assets.add3D`), one undo step and none when each is an asset already, selects their assets and shows the Asset Export panel ([three-d.md](three-d.md#3d-and-materials-panel)); `panels::three_d::tests::{export_3d_object_adds_the_selected_3d_objects_to_asset_export, export_3d_object_leaves_out_a_3d_object_inside_another}` cover it. Scores and hours unchanged. - 3D effects on clipping groups, blends and symbol instances (#6140, #5725): every 3D type builds from the art the object shows (the art inside the clipping path, the blend's keys and steps, the symbol's art where the instance places it), alone or in a group, on the canvas, in exports, Object › Rasterize and `assets.add3D`; an image is refused with a pointer to Object › Image Trace › Make. `crates/effects/src/solids/tests_sources.rs`, `crates/engine/src/cmd/effectcmd/three_d_source_tests.rs` and `fx::tests::a_symbol_instance_a_blend_and_a_clipping_group_draw_their_solids` cover it. Scores and estimates unchanged. - 3D effects, images a clip hides: an image in a clipping group is left out of the solid when its transformed frame lies wholly outside the area every clipping path around it fills together, holes included, instead of when only the boxes miss; an image the clips show part of is still refused ([the help page](https://helpx.adobe.com/illustrator/using/clipping-masks.html) on clipping paths hiding everything outside their shape; the omission rule is VectorCraft's own). `solids::tests_sources::an_image_inside_a_clips_box_but_outside_its_shape_is_left_out` covers it. Scores and estimates unchanged. diff --git a/docs/three-d.md b/docs/three-d.md index 2861bee0b7..171f97d360 100644 --- a/docs/three-d.md +++ b/docs/three-d.md @@ -79,6 +79,16 @@ pages describe it. own choice. The group keeps the object's place, id, name, opacity, blending mode, opacity mask, attributes and text wrap and is selected; objects without a visible 3D effect do not change. It is one undo step, and an error changes nothing when no selected object has a visible 3D effect. +- **Export 3D Object** is a button in the panel, as the public + [Export 3D vector artwork](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html) + page describes; VectorCraft places it at the bottom of the panel. It adds each selected object that has a visible + 3D effect to Asset Export as a 3D asset, the same as + Asset Export's own Export 3D Object (`assets.add3D` with those objects' ids), selects their assets in the Asset + Export panel and shows that panel, where the Format row and Export write the mesh files + ([3D assets in mcp.md](mcp.md#3d-assets)). Selected objects without a visible 3D effect are left out, and so is + a 3D object inside another selected 3D object, whose asset holds it. The button is unavailable while no selected + object has a visible 3D effect. Adding the assets is one undo step; when every one of the objects is an asset already, nothing is + recorded and their assets are selected again. - The Lighting tab also holds the Shadows switch and its settings ([Shadows](#shadows)). The Materials tab holds the Graphics ([Graphics](#graphics)); base materials and ray-traced rendering are not built (see below). The rotation and light rings on the artboard are part of the options dialog only. The panel's Lighting tab has the