Merge pull request #846 from alpha-rock/codex/3d-effects
Add live Revolve effect with artboard rotation and lighting gizmos
This commit is contained in:
commit
7989d1345f
|
|
@ -4449,6 +4449,7 @@ dependencies = [
|
|||
"vectorcraft-pathops",
|
||||
"vectorcraft-plugins",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-three-d",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -4704,6 +4705,13 @@ dependencies = [
|
|||
"write-fonts",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-three-d"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"vectorcraft-geom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vectorcraft-tools"
|
||||
version = "0.8.0"
|
||||
|
|
@ -4758,6 +4766,7 @@ dependencies = [
|
|||
"vectorcraft-svg",
|
||||
"vectorcraft-testkit",
|
||||
"vectorcraft-text",
|
||||
"vectorcraft-three-d",
|
||||
"vectorcraft-tools",
|
||||
"wat",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ vectorcraft-brush = { path = "crates/brush" }
|
|||
vectorcraft-trace = { path = "crates/trace" }
|
||||
vectorcraft-text = { path = "crates/text" }
|
||||
vectorcraft-effects = { path = "crates/effects" }
|
||||
vectorcraft-three-d = { path = "crates/three-d" }
|
||||
vectorcraft-render = { path = "crates/render" }
|
||||
vectorcraft-svg = { path = "crates/svg" }
|
||||
vectorcraft-pdf = { path = "crates/pdf" }
|
||||
|
|
|
|||
|
|
@ -218,7 +218,8 @@ The scores are
|
|||
self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-05) explains how far to trust them.
|
||||
|
||||
**What's missing:**
|
||||
- 3D and Materials;
|
||||
|
||||
- 3D and Materials beyond the initial live Revolve (Extrude & Bevel, Inflate, Rotate, materials);
|
||||
- the Photoshop-style raster effects (Effect Gallery);
|
||||
- CJK composition for vertical type (vertical type itself has initial support, with a Japanese interface);
|
||||
- scripting, and the rest of Variables (image and graph kinds, dataset import);
|
||||
|
|
@ -229,7 +230,7 @@ self-assessed, so the [honest assessment](ROADMAP.md#honest-assessment-2026-10-0
|
|||
advanced type, then hardening and packaging for 1.0. The prioritized list is in
|
||||
[Where we're lacking](ROADMAP.md#where-were-lacking-in-priority-order).
|
||||
|
||||
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}`
|
||||
**Workspace:** `crates/{geom, color, doc, pathops, text, effects, three-d, trace, brush, render, svg, pdf, eps, cad, metafile, format, tools, engine, ui-egui, mcp, testkit}`
|
||||
and `apps/{vectorcraft, vectorcraft-cli, vectorcraft-web}`. The egui frontend is its own crate, so
|
||||
the UI can be swapped without touching the engine.
|
||||
|
||||
|
|
|
|||
15
ROADMAP.md
15
ROADMAP.md
File diff suppressed because one or more lines are too long
|
|
@ -10,6 +10,7 @@ workspace = true
|
|||
|
||||
[dependencies]
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-three-d = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
vectorcraft-pathops = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -39,7 +39,8 @@ fn clear_item_effects(item: &mut AppearanceItem) {
|
|||
|
||||
/// Does anything in `n`'s subtree need baking?
|
||||
pub fn needs_bake(n: &Node) -> bool {
|
||||
has_adjustment(n)
|
||||
crate::has_revolve(n)
|
||||
|| has_adjustment(n)
|
||||
|| has_crop_marks(n)
|
||||
|| has_pathfinder(n)
|
||||
|| has_own_paint(n)
|
||||
|
|
@ -92,6 +93,10 @@ fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
|
|||
if let Some(m) = crop_marks_art(n) {
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
if let Some(m) = crate::revolve_art(n) {
|
||||
let m = evaluate_container(&m).unwrap_or(m);
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
// Type, images, symbol instances and live objects: reshaped through their outlines.
|
||||
if needs_outline(n) && (has_geometry(&n.appearance.effects) || n.projection().is_some()) {
|
||||
let symbol = match &n.kind {
|
||||
|
|
@ -226,6 +231,10 @@ pub type StrokeArt<'a> = &'a mut dyn FnMut(&mut Document, &PathData, FillRule, &
|
|||
/// without transparency or effects of its own takes `n`'s place itself. Hidden fills, strokes and
|
||||
/// effects are dropped. `None` for other kinds of objects.
|
||||
pub fn expand_leaf(d: &mut Document, n: &Node, stroke_art: StrokeArt) -> Option<Node> {
|
||||
if let Some(m) = crate::revolve::expand_revolve_art(n) {
|
||||
let m = evaluate_container(&m).unwrap_or(m);
|
||||
return Some(bake_pieces(d, m));
|
||||
}
|
||||
let (g, rule, ctx, mut m) = leaf_base(n)?;
|
||||
m.appearance.effects.retain(|e| e.visible);
|
||||
let mut pieces = vec![];
|
||||
|
|
@ -289,6 +298,9 @@ pub fn expand_art(d: &mut Document, n: &mut Node, stroke_art: StrokeArt) {
|
|||
/// the members of a group keeping `n`'s id, transparency, opacity mask and raster effects. `None`
|
||||
/// when it has neither a stroke to outline nor a geometry effect, so bending first changes nothing.
|
||||
pub fn bake_appearance(n: &Node) -> Option<Node> {
|
||||
if let Some(m) = crate::revolve_art(n) {
|
||||
return Some(m);
|
||||
}
|
||||
if !matches!(n.kind, NodeKind::Path { clipping: false, guide: false, .. } | NodeKind::Compound { .. }) {
|
||||
return None;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ mod marks;
|
|||
pub mod pixel;
|
||||
mod raster;
|
||||
mod reshape;
|
||||
mod revolve;
|
||||
pub mod stroke;
|
||||
mod stylize;
|
||||
mod util;
|
||||
|
|
@ -68,6 +69,7 @@ pub use marks::{CROP_MARKS, crop_marks_art, has_crop_marks};
|
|||
pub use pixel::{PIXEL_EFFECTS, PixelFx, PixelSpace};
|
||||
pub use raster::{RasterFx, outset, raster_effects};
|
||||
pub use reshape::{expand_outlined, needs_outline, outline_art, outline_text, reshape};
|
||||
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
|
||||
pub use warp::{WarpStyle, warp_point};
|
||||
|
||||
/// Catalogue entry for one effect.
|
||||
|
|
@ -122,6 +124,7 @@ fn lengths_of(id: &str) -> Lengths {
|
|||
"distort.tweak" => Lengths { always: &[], absolute: &["h", "v"] },
|
||||
"distort.transform" => always(&["moveH", "moveV"]),
|
||||
"path.offsetPath" => always(&["offset"]),
|
||||
"threeD.revolve" => always(&["offset"]),
|
||||
"path.outlineStroke" => always(&["width"]),
|
||||
"stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]),
|
||||
"stylize.scribble" => always(&["overlap", "strokeWidth", "spacing", "variation"]),
|
||||
|
|
@ -179,6 +182,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
|
|||
let g = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: false, lengths: lengths_of(id) };
|
||||
let r = |id, label, menu, params, defaults| EffectInfo { id, label, menu, params, defaults, raster: true, lengths: lengths_of(id) };
|
||||
let mut v = vec![
|
||||
g(
|
||||
REVOLVE,
|
||||
"Revolve…",
|
||||
&["Effect", "3D and Materials"],
|
||||
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), perspective: % (0..100, 0), segments: integer (8..128, 64), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Open paths create uncapped surfaces; intersecting profiles use approximate painter visibility.",
|
||||
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspective":0.0,"segments":64,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0,"expandVisibleOnly":true}),
|
||||
),
|
||||
g(
|
||||
"convertToShape.rectangle",
|
||||
"Rectangle…",
|
||||
|
|
@ -589,6 +599,7 @@ pub fn is_geometry(id: &str) -> bool {
|
|||
&& !is_pathfinder(id)
|
||||
&& !is_adjustment(id)
|
||||
&& id != CROP_MARKS
|
||||
&& id != REVOLVE
|
||||
&& (catalog_index().contains_key(id) || vectorcraft_plugins::effect::plugin_id(id).is_some())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,319 @@
|
|||
//! Revolve as live, shaded vector art, shared by the canvas and every exporter.
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{PathData, Rect};
|
||||
use vectorcraft_three_d::{Edge, Revolve};
|
||||
|
||||
use crate::util::{flag, num, text};
|
||||
use crate::{GeomContext, apply_geometry_with, is_geometry, merged_params};
|
||||
|
||||
pub const REVOLVE: &str = "threeD.revolve";
|
||||
|
||||
fn visible(fx: &[Effect]) -> bool {
|
||||
fx.iter().any(|e| e.visible && e.id == REVOLVE)
|
||||
}
|
||||
|
||||
pub fn has_revolve(n: &Node) -> bool {
|
||||
visible(&n.appearance.effects) || n.appearance.items.iter().any(|i| i.visible() && visible(i.effects()))
|
||||
}
|
||||
|
||||
/// Shared document-parameter conversion for rendering and interactive preview guides.
|
||||
pub fn revolve_options(p: &Value) -> Revolve {
|
||||
Revolve {
|
||||
angle: num(p, "angle", 360.0),
|
||||
offset: num(p, "offset", 0.0),
|
||||
edge: if text(p, "edge", "left") == "right" { Edge::Right } else { Edge::Left },
|
||||
rotation_x: num(p, "rotationX", 0.0),
|
||||
rotation_y: num(p, "rotationY", 0.0),
|
||||
rotation_z: num(p, "rotationZ", 0.0),
|
||||
perspective: num(p, "perspective", 0.0) / 100.0,
|
||||
segments: num(p, "segments", 64.0).clamp(8.0, 128.0) as usize,
|
||||
light_azimuth: num(p, "lightAzimuth", -45.0),
|
||||
light_elevation: num(p, "lightElevation", 45.0),
|
||||
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
|
||||
ambient: num(p, "ambient", 25.0) / 100.0,
|
||||
shade: flag(p, "shade", true),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate before a command mutates the document. Loading external files is still bounded
|
||||
/// by the evaluator; unsupported source kinds remain unchanged when rendered.
|
||||
pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> {
|
||||
let path = profile(n).ok_or("Revolve currently requires a path or compound path. Select a profile drawn with the Pen tool.")?;
|
||||
let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?;
|
||||
if !path.subpaths.iter().any(|sp| sp.anchors.len() >= 2) {
|
||||
return Err("Revolve requires at least two anchors".into());
|
||||
}
|
||||
if p.get("edge").is_some_and(|v| v.as_str() != Some("left") && v.as_str() != Some("right")) {
|
||||
return Err("Revolve edge must be left or right".into());
|
||||
}
|
||||
vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ())
|
||||
}
|
||||
|
||||
fn profile(n: &Node) -> Option<PathData> {
|
||||
match &n.kind {
|
||||
NodeKind::Path { path, guide: false, clipping: false, .. } => Some(path.clone()),
|
||||
NodeKind::Compound { children, .. } => {
|
||||
Some(PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
|
||||
apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn surface(
|
||||
n: &Node,
|
||||
path: &PathData,
|
||||
fx: &Effect,
|
||||
paint: Paint,
|
||||
opacity: f32,
|
||||
blend: vectorcraft_doc::color::BlendMode,
|
||||
expand: bool,
|
||||
) -> Option<Node> {
|
||||
let b = path.bounds()?;
|
||||
// A loaded file or a profile edited past the limits keeps drawing its source path instead
|
||||
// of crashing the frame. Commands validate the same evaluator and report its errors.
|
||||
let faces = vectorcraft_three_d::revolve(path, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?;
|
||||
// Visibility is an expansion option, never a change to live geometry or export baking.
|
||||
// Keep all faces for paints whose coverage we cannot prove opaque. A bounded visibility
|
||||
// failure also retains the complete surface rather than losing any of the user's art.
|
||||
let visible = (expand
|
||||
&& flag(&fx.params, "expandVisibleOnly", true)
|
||||
&& paint.color().is_some()
|
||||
&& opacity == 1.0
|
||||
&& blend == Default::default()
|
||||
&& n.has_default_transparency())
|
||||
.then(|| vectorcraft_three_d::visible_faces(&faces).ok())
|
||||
.flatten();
|
||||
let faces = visible.unwrap_or_else(|| {
|
||||
faces.into_iter().map(|f| vectorcraft_three_d::VisibleFace { path: f.path(), brightness: f.brightness, depth: f.depth }).collect()
|
||||
});
|
||||
let children = faces
|
||||
.into_iter()
|
||||
.map(|face| {
|
||||
let mut fill = FillLayer::new(paint.clone());
|
||||
let mut items = vec![];
|
||||
if let Some(c) = paint.color() {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
let s = face.brightness as f32;
|
||||
fill.paint = Paint::solid(Color::rgb(r * s, g * s, b * s));
|
||||
items.push(AppearanceItem::Fill(fill));
|
||||
} else {
|
||||
items.push(AppearanceItem::Fill(fill));
|
||||
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
|
||||
shadow.opacity = (1.0 - face.brightness) as f32;
|
||||
items.push(AppearanceItem::Fill(shadow));
|
||||
}
|
||||
// Independently antialiased adjacent polygons otherwise leave a hairline grid.
|
||||
// Matching narrow edge paint overlaps their coverage in the canvas and exporters.
|
||||
// Keep opacity on the surface group so these edges never accumulate transparency.
|
||||
let items = items
|
||||
.into_iter()
|
||||
.flat_map(|item| {
|
||||
let AppearanceItem::Fill(fill) = &item else { return vec![item] };
|
||||
let mut edge = StrokeLayer::new(fill.paint.clone(), 0.75);
|
||||
edge.opacity = fill.opacity;
|
||||
vec![item, AppearanceItem::Stroke(edge)]
|
||||
})
|
||||
.collect();
|
||||
Arc::new(Node::path(n.id, face.path, Appearance { items, ..Default::default() }))
|
||||
})
|
||||
.collect();
|
||||
let mut group = Node::group(n.id, children);
|
||||
group.opacity = opacity;
|
||||
group.blend = blend;
|
||||
Some(group)
|
||||
}
|
||||
|
||||
fn paint_of(item: &AppearanceItem) -> (Paint, f32, vectorcraft_doc::color::BlendMode) {
|
||||
match item {
|
||||
AppearanceItem::Fill(f) => (f.paint.clone(), f.opacity, f.blend),
|
||||
AppearanceItem::Stroke(s) => (s.paint.clone(), s.opacity, s.blend),
|
||||
}
|
||||
}
|
||||
|
||||
/// Evaluated art keeps the source's identity and transparency. Each item's opacity belongs
|
||||
/// to its entire surface, not to each face, so overlapping faces don't accumulate alpha.
|
||||
pub fn revolve_art(n: &Node) -> Option<Node> {
|
||||
art(n, false)
|
||||
}
|
||||
|
||||
/// Revolve's own Expand Appearance path. The shared command and UI remain unchanged.
|
||||
pub(crate) fn expand_revolve_art(n: &Node) -> Option<Node> {
|
||||
art(n, true)
|
||||
}
|
||||
|
||||
fn art(n: &Node, expand: bool) -> Option<Node> {
|
||||
if !has_revolve(n) {
|
||||
return None;
|
||||
}
|
||||
let path = profile(n)?;
|
||||
let ctx = GeomContext::of(n);
|
||||
let mut children = vec![];
|
||||
let mut remaining = n.appearance.effects.clone();
|
||||
if let Some(index) = n.appearance.effects.iter().position(|e| e.visible && e.id == REVOLVE) {
|
||||
let fx = n.appearance.effects.get(index)?;
|
||||
let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx);
|
||||
// An object's surface uses its top visible fill, falling back to its stroke.
|
||||
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)))?;
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
|
||||
children.push(Arc::new(surface(n, &path, fx, paint, opacity, blend, expand)?));
|
||||
// Later geometry acts on the projected art as a whole through container evaluation.
|
||||
remaining = n
|
||||
.appearance
|
||||
.effects
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, e)| e.id != REVOLVE && (!is_geometry(&e.id) || *i > index))
|
||||
.map(|(_, e)| e.clone())
|
||||
.collect();
|
||||
} else {
|
||||
let path = geometry(&path, &n.appearance.effects, &ctx);
|
||||
remaining.retain(|e| !is_geometry(&e.id));
|
||||
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 fx = item.effects().get(index)?;
|
||||
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item));
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
|
||||
let mut art = surface(n, &p, fx, paint, opacity, blend, expand)?;
|
||||
art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| e.id != REVOLVE).cloned().collect();
|
||||
children.push(Arc::new(art));
|
||||
} else {
|
||||
children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![item.clone()], ..Default::default() })));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut art = n.clone();
|
||||
art.kind = NodeKind::Group { children, clip: false };
|
||||
art.appearance = Appearance { effects: remaining, ..Default::default() };
|
||||
Some(art)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::{Document, NodeId};
|
||||
use vectorcraft_geom::{Affine, BezPath};
|
||||
fn profile_node() -> Node {
|
||||
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));
|
||||
Node::path(NodeId(1), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(Color::rgb(0.8, 0.2, 0.1)), Paint::None, 0.0))
|
||||
}
|
||||
fn fx() -> Effect {
|
||||
crate::new_effect(REVOLVE, &json!({})).unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn source_stays_editable_surface_moves_and_hidden_effect_restores_it() {
|
||||
let mut n = profile_node();
|
||||
let before = n.path_data().unwrap().clone();
|
||||
n.appearance.effects.push(fx());
|
||||
let a = revolve_art(&n).unwrap();
|
||||
assert!(!a.children().unwrap().is_empty());
|
||||
assert_eq!(n.path_data().unwrap(), &before);
|
||||
let b = a.visual_bounds().unwrap();
|
||||
n.transform(Affine::translate((80.0, 20.0)), false);
|
||||
let moved = revolve_art(&n).unwrap().visual_bounds().unwrap();
|
||||
assert!((moved.x0 - b.x0 - 80.0).abs() < 1e-6 && (moved.y0 - b.y0 - 20.0).abs() < 1e-6);
|
||||
n.appearance.effects[0].visible = false;
|
||||
assert!(revolve_art(&n).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn item_effect_and_transparency_are_scoped_to_whole_surface() {
|
||||
let mut n = profile_node();
|
||||
n.opacity = 0.6;
|
||||
n.appearance.items[0].effects_mut().push(fx());
|
||||
if let AppearanceItem::Fill(f) = &mut n.appearance.items[0] {
|
||||
f.opacity = 0.4;
|
||||
}
|
||||
let a = revolve_art(&n).unwrap();
|
||||
assert_eq!(a.opacity, 0.6);
|
||||
let surface = &a.children().unwrap()[0];
|
||||
assert_eq!(surface.opacity, 0.4);
|
||||
assert!(surface.children().unwrap().iter().all(|c| c.opacity == 1.0 && !has_revolve(c)));
|
||||
}
|
||||
#[test]
|
||||
fn export_bakes_faces_with_unique_ids_and_no_revolve_effects() {
|
||||
let mut d = Document::new(300.0, 300.0);
|
||||
let mut n = profile_node();
|
||||
n.id = d.alloc_id();
|
||||
n.appearance.effects.push(fx());
|
||||
let mut stroke = |_: &mut Document, _: &PathData, _: vectorcraft_geom::FillRule, _: &vectorcraft_doc::StrokeLayer| None;
|
||||
let art = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap();
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
fn visit(n: &Node, ids: &mut std::collections::HashSet<NodeId>) {
|
||||
assert!(ids.insert(n.id));
|
||||
assert!(!has_revolve(n));
|
||||
for c in n.children().into_iter().flatten() {
|
||||
visit(c, ids);
|
||||
}
|
||||
}
|
||||
visit(&art, &mut ids);
|
||||
assert!(ids.len() > 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_surface_option_only_affects_expansion_and_can_keep_all_faces() {
|
||||
let mut n = profile_node();
|
||||
n.appearance.effects.push(fx());
|
||||
let source = n.path_data().unwrap().clone();
|
||||
let live = revolve_art(&n).unwrap();
|
||||
let expanded = expand_revolve_art(&n).unwrap();
|
||||
let count = |n: &Node| n.children().unwrap()[0].children().unwrap().len();
|
||||
assert!(count(&expanded) < count(&live) * 3 / 4, "hidden back surfaces are removed");
|
||||
assert_eq!(crate::bake_appearance(&n), Some(live.clone()), "other baking retains the full surface");
|
||||
n.appearance.effects[0].params["expandVisibleOnly"] = json!(false);
|
||||
assert_eq!(revolve_art(&n), Some(live.clone()), "the live view ignores the expansion setting");
|
||||
assert_eq!(expand_revolve_art(&n), Some(live));
|
||||
assert_eq!(n.path_data(), Some(&source));
|
||||
n.appearance.effects[0].params["expandVisibleOnly"] = json!(true);
|
||||
assert_eq!(expand_revolve_art(&n), Some(expanded.clone()));
|
||||
// Per-item Revolve uses the same option, without trimming another item's artwork.
|
||||
let effect = n.appearance.effects.remove(0);
|
||||
n.appearance.items[0].effects_mut().push(effect);
|
||||
assert_eq!(expand_revolve_art(&n).unwrap().children().unwrap()[0], expanded.children().unwrap()[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparency_unknown_paints_and_later_distortion_keep_complete_surfaces() {
|
||||
let mut n = profile_node();
|
||||
n.appearance.effects.push(fx());
|
||||
for change in 0..4 {
|
||||
let mut sample = n.clone();
|
||||
match change {
|
||||
0 => sample.opacity = 0.5,
|
||||
1 => {
|
||||
if let AppearanceItem::Fill(f) = &mut sample.appearance.items[0] {
|
||||
f.opacity = 0.4;
|
||||
}
|
||||
}
|
||||
2 => sample.blend = vectorcraft_doc::color::BlendMode::Multiply,
|
||||
_ => sample.appearance.effects.push(crate::new_effect("distort.twist", &json!({"angle":45})).unwrap()),
|
||||
}
|
||||
assert_eq!(expand_revolve_art(&sample), revolve_art(&sample), "preserve surface for case {change}");
|
||||
}
|
||||
let mut sample = n;
|
||||
if let AppearanceItem::Fill(f) = &mut sample.appearance.items[0] {
|
||||
f.paint = Paint::Pattern { pattern: "unknown transparency".into(), xf: Affine::IDENTITY };
|
||||
}
|
||||
assert_eq!(expand_revolve_art(&sample), revolve_art(&sample));
|
||||
}
|
||||
}
|
||||
|
|
@ -107,6 +107,7 @@ fn edit_effects(
|
|||
let Some(n) = d.node_mut(*id) else { continue };
|
||||
let index = item.effects_item(&n.appearance, cmd)?;
|
||||
if n.appearance.effects_mut(index).is_some_and(&mut f) {
|
||||
check_revolve_count(n, cmd)?;
|
||||
done.push(*id);
|
||||
}
|
||||
}
|
||||
|
|
@ -117,6 +118,14 @@ fn edit_effects(
|
|||
})
|
||||
}
|
||||
|
||||
fn check_revolve_count(n: &Node, cmd: &str) -> Result<()> {
|
||||
let count = n.appearance.effects.iter().chain(n.appearance.items.iter().flat_map(|i| i.effects())).filter(|e| e.id == effects::REVOLVE).count();
|
||||
if count > 1 {
|
||||
return Err(bad(cmd, "A profile supports one Revolve effect. Edit the existing effect's options instead."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "effect.apply";
|
||||
let id = str_param(p, "effect").or_else(|| str_param(p, "id")).ok_or_else(|| bad(C, "missing `effect` id"))?;
|
||||
|
|
@ -140,6 +149,15 @@ pub(crate) fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
return Ok(json!({ "ids": [gid.0], "index": 0, "item": null, "grouped": true }));
|
||||
}
|
||||
let item = item_target(s, p, C)?;
|
||||
if id == effects::REVOLVE {
|
||||
for id in appearance_targets(s, p)? {
|
||||
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
effects::validate_revolve(n, &effect.params).map_err(|e| bad(C, e))?;
|
||||
if effects::has_revolve(n) {
|
||||
return Err(bad(C, "A profile supports one Revolve effect. Edit the existing effect's options instead."));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut index = 0;
|
||||
let ids = edit_effects(s, p, item, C, &label, "Apply Effect: select objects", |fx| {
|
||||
fx.push(effect.clone());
|
||||
|
|
@ -237,6 +255,7 @@ fn move_effect(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
let Some(fx) = n.appearance.effects_mut(di) else { continue };
|
||||
let at = to.min(fx.len());
|
||||
fx.insert(at, e);
|
||||
check_revolve_count(n, C)?;
|
||||
landed.get_or_insert((at, di));
|
||||
done.push(*id);
|
||||
}
|
||||
|
|
@ -258,6 +277,20 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
}
|
||||
let visible = p.get("visible").and_then(Value::as_bool);
|
||||
let item = item_target(s, p, C)?;
|
||||
for id in appearance_targets(s, p)? {
|
||||
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
|
||||
let target = item.effects_item(&n.appearance, C)?;
|
||||
if let Some(e) = n.appearance.effects_at(target).and_then(|fx| fx.get(index))
|
||||
&& e.id == effects::REVOLVE
|
||||
&& visible != Some(false)
|
||||
{
|
||||
let mut prospective = effects::merged_params(&e.id, &e.params);
|
||||
if let (Some(cur), Some(new)) = (prospective.as_object_mut(), params.as_object()) {
|
||||
cur.extend(new.clone());
|
||||
}
|
||||
effects::validate_revolve(n, &prospective).map_err(|e| bad(C, e))?;
|
||||
}
|
||||
}
|
||||
let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), |fx| {
|
||||
let Some(e) = fx.get_mut(index) else { return false };
|
||||
if let (Value::Object(new), cur) = (¶ms, &mut e.params) {
|
||||
|
|
@ -454,6 +487,7 @@ fn expand_appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
mod tests {
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_render::effects;
|
||||
|
||||
use crate::{NodeId, Session};
|
||||
|
||||
|
|
@ -468,6 +502,71 @@ mod tests {
|
|||
s.doc().unwrap().doc.node(id).cloned().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_commands_keep_profile_live_edit_options_expand_and_undo() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":300,"height":300})).unwrap();
|
||||
let r = s.execute("path.create", &json!({"d":"M 150 40 L 180 60 L 180 140 L 150 160"})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
let source = node(&s, id).path_data().unwrap().clone();
|
||||
s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).unwrap();
|
||||
let before = effects_for_test(&s, id);
|
||||
assert_eq!(node(&s, id).path_data(), Some(&source));
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
|
||||
assert_eq!(restored.node(id).unwrap().path_data(), Some(&source));
|
||||
assert_eq!(effects::revolve_art(restored.node(id).unwrap()).unwrap(), before);
|
||||
let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap();
|
||||
assert!(bytes.starts_with(b"%PDF"));
|
||||
assert!(s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).is_err());
|
||||
assert!(s.execute("effect.duplicate", &json!({"index":0,"ids":[id.0],"item":null})).is_err());
|
||||
s.execute("effect.setParams", &json!({"index":0,"params":{"angle":180,"offset":10},"ids":[id.0],"item":null})).unwrap();
|
||||
assert_ne!(effects_for_test(&s, id), before);
|
||||
assert!(s.execute("effect.setParams", &json!({"index":0,"params":{"edge":"invalid"},"ids":[id.0],"item":null})).is_err());
|
||||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||||
assert!(matches!(node(&s, id).kind, NodeKind::Group { .. }));
|
||||
assert!(!effects::has_revolve(&node(&s, id)));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id).path_data(), Some(&source));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(effects_for_test(&s, id), before);
|
||||
}
|
||||
|
||||
fn effects_for_test(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
|
||||
effects::revolve_art(&node(s, id)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_visibility_parameter_saves_expands_and_undoes_without_other_appearance_changes() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":300,"height":300})).unwrap();
|
||||
let r = s.execute("path.create", &json!({"d":"M 150 40 L 180 60 L 180 140 L 150 160"})).unwrap();
|
||||
let id = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("effect.apply", &json!({"effect":"threeD.revolve","ids":[id.0],"item":null})).unwrap();
|
||||
let live = node(&s, id);
|
||||
let restored = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||||
assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["expandVisibleOnly"], json!(true));
|
||||
let count = |n: &vectorcraft_doc::Node| n.children().unwrap()[0].children().unwrap().len();
|
||||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||||
let visible = count(&node(&s, id));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id), live);
|
||||
s.execute("effect.setParams", &json!({"index":0,"item":null,"ids":[id.0],"params":{"expandVisibleOnly":false}})).unwrap();
|
||||
s.execute("effect.expandAppearance", &json!({"ids":[id.0]})).unwrap();
|
||||
assert!(count(&node(&s, id)) > visible);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id).appearance.effects[0].params["expandVisibleOnly"], json!(false));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(node(&s, id), live);
|
||||
// The shared command still expands ordinary multiple-fill artwork as before.
|
||||
let r = s.execute("shape.rectangle", &json!({"x":10,"y":10,"width":20,"height":20})).unwrap();
|
||||
let ordinary = NodeId(r["id"].as_u64().unwrap());
|
||||
s.execute("effect.apply", &json!({"effect":"distort.twist","ids":[ordinary.0],"params":{"angle":15}})).unwrap();
|
||||
s.execute("effect.expandAppearance", &json!({"ids":[ordinary.0]})).unwrap();
|
||||
assert!(node(&s, ordinary).appearance.effects.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_uses_catalog_defaults_and_undoes() {
|
||||
let (mut s, id) = session_with_rect();
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ pub(crate) fn has_object_fx(n: &Node) -> bool {
|
|||
|| effects::has_container_appearance(n)
|
||||
|| effects::has_crop_marks(n)
|
||||
|| effects::has_adjustment(n)
|
||||
|| effects::has_revolve(n)
|
||||
}
|
||||
|
||||
/// Is `n` a group or layer (whose evaluated art keeps its knockout setting)?
|
||||
|
|
@ -123,6 +124,12 @@ fn item_effects(item: &AppearanceItem) -> &[Effect] {
|
|||
|
||||
/// Visual bounds including geometry effects, stroke outsets and shadows/glows.
|
||||
pub(crate) fn visual_bounds(n: &Node) -> Option<Rect> {
|
||||
if let Some(art) = effects::revolve_art(n) {
|
||||
return cull_bounds(&art).map(|b| {
|
||||
let o = effects::outset(&n.appearance.effects, b);
|
||||
b.inflate(o, o)
|
||||
});
|
||||
}
|
||||
let bp = node_bezpath(n)?;
|
||||
let ctx = GeomContext::of(n);
|
||||
let g = effected_path(n, &bp);
|
||||
|
|
@ -408,12 +415,13 @@ impl Renderer {
|
|||
_ => None,
|
||||
};
|
||||
let container = is_container(a);
|
||||
let art = match effects::crop_marks_art(a).or_else(|| effects::reshape(a, symbol.as_ref())) {
|
||||
let art = match effects::crop_marks_art(a).or_else(|| effects::revolve_art(a)).or_else(|| effects::reshape(a, symbol.as_ref())) {
|
||||
Some(r) => r,
|
||||
None if container => effects::evaluate_container(a).unwrap_or_else(|| (**a).clone()),
|
||||
None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_) | NodeKind::Path { .. } | NodeKind::Compound { .. }) => (**a).clone(),
|
||||
None => effects::outline_art(a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])),
|
||||
};
|
||||
let art = if effects::has_revolve(a) { effects::evaluate_container(&art).unwrap_or(art) } else { art };
|
||||
let mut art = self.adjusted_art(doc, art, effects::color_map(&a.appearance.effects).as_ref());
|
||||
// A path's geometry effects still apply when it is drawn.
|
||||
let path = matches!(art.kind, NodeKind::Path { .. } | NodeKind::Compound { .. });
|
||||
|
|
|
|||
|
|
@ -40,6 +40,69 @@ fn lum(p: [u8; 4]) -> u32 {
|
|||
|
||||
const WHITE: u32 = 765;
|
||||
|
||||
#[test]
|
||||
fn revolve_canvas_matches_baked_art_and_shades_its_surface() {
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
bp.move_to((50.0, 15.0));
|
||||
bp.line_to((75.0, 30.0));
|
||||
bp.line_to((75.0, 75.0));
|
||||
bp.line_to((50.0, 90.0));
|
||||
let mut n = Node::path(
|
||||
NodeId(0),
|
||||
vectorcraft_geom::PathData::from_bezpath(&bp),
|
||||
Appearance::basic(Paint::solid(Color::rgb(0.9, 0.2, 0.1)), Paint::None, 0.0),
|
||||
);
|
||||
n.appearance.effects.push(fx("threeD.revolve", json!({"rotationX":0,"rotationY":0})));
|
||||
let d = doc_with(Document::new(100.0, 100.0), n);
|
||||
let live = render(&d);
|
||||
let baked = vectorcraft_effects::bake_document(&d).unwrap();
|
||||
let exported = render(&baked);
|
||||
assert_eq!(live.pixels, exported.pixels, "canvas and baked/exported faces agree");
|
||||
let reds: Vec<_> =
|
||||
live.pixels.as_chunks::<4>().0.iter().filter(|p| p[0] as u16 > p[1] as u16 * 2 && p[0] as u16 > p[2] as u16 * 2).map(|p| p[0]).collect();
|
||||
assert!(reds.len() > 1500, "a filled surface, rather than the open source path");
|
||||
assert!(reds.iter().max().unwrap() - reds.iter().min().unwrap() > 80, "lighting varies across the surface");
|
||||
assert_eq!(live.pixels, render_threads(&d, 4).pixels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_visible_surface_expansion_preserves_the_view_and_transparent_art() {
|
||||
let mut bp = vectorcraft_geom::BezPath::new();
|
||||
bp.move_to((50.0, 15.0));
|
||||
bp.curve_to((80.0, 25.0), (80.0, 75.0), (50.0, 90.0));
|
||||
let mut n = Node::path(
|
||||
NodeId(0),
|
||||
vectorcraft_geom::PathData::from_bezpath(&bp),
|
||||
Appearance::basic(Paint::solid(Color::rgb(0.2, 0.55, 0.9)), Paint::None, 0.0),
|
||||
);
|
||||
for (angle, rotation, perspective, opacity) in
|
||||
[(360.0, 0.0, 0.0, 1.0), (360.0, -35.0, 55.0, 1.0), (150.0, -28.0, 40.0, 1.0), (360.0, -35.0, 55.0, 0.5)]
|
||||
{
|
||||
n.opacity = opacity;
|
||||
n.appearance.effects = vec![fx(
|
||||
"threeD.revolve",
|
||||
json!({"angle":angle,"rotationX":rotation,"rotationY":25,"perspective":perspective,"segments":32,"expandVisibleOnly":true}),
|
||||
)];
|
||||
let d = doc_with(Document::new(100.0, 100.0), n.clone());
|
||||
let live = render(&d);
|
||||
let mut expanded = d.clone();
|
||||
let id = expanded.layers[0].children().unwrap()[0].id;
|
||||
let source = expanded.node(id).unwrap().clone();
|
||||
let mut stroke = |_: &mut Document, _: &vectorcraft_geom::PathData, _: vectorcraft_geom::FillRule, _: &vectorcraft_doc::StrokeLayer| None;
|
||||
let art = vectorcraft_effects::expand_leaf(&mut expanded, &source, &mut stroke).unwrap();
|
||||
*expanded.node_mut(id).unwrap() = art;
|
||||
let flat = render(&expanded);
|
||||
let difference: u64 = live.pixels.iter().zip(&flat.pixels).map(|(a, b)| a.abs_diff(*b) as u64).sum();
|
||||
let average = difference as f64 / live.pixels.len() as f64;
|
||||
assert!(average < 1.0, "appearance differs by {average} channel levels: angle {angle}, rotation {rotation}, opacity {opacity}");
|
||||
if opacity < 1.0 {
|
||||
assert_eq!(live.pixels, flat.pixels);
|
||||
}
|
||||
// Export baking keeps every face, even when the expansion checkbox is checked.
|
||||
assert_eq!(live.pixels, render(&vectorcraft_effects::bake_document(&d).unwrap()).pixels);
|
||||
}
|
||||
}
|
||||
|
||||
/// A 30×30 red image placed at (20, 20).
|
||||
fn image_doc(effects: Vec<Effect>) -> Document {
|
||||
let mut d = Document::new(100.0, 100.0);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,23 @@ fn fx(id: &str) -> Effect {
|
|||
Effect { id: id.into(), params: json!({}), visible: true }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_exports_shaded_vector_faces_and_keeps_the_source_live() {
|
||||
let bp = vectorcraft_geom::BezPath::from_svg("M100 30 L130 50 L130 140 L100 160").unwrap();
|
||||
let mut n = Node::path(
|
||||
NodeId(0),
|
||||
vectorcraft_geom::PathData::from_bezpath(&bp),
|
||||
Appearance::basic(Paint::solid(Color::rgb(0.8, 0.2, 0.1)), Paint::None, 0.0),
|
||||
);
|
||||
n.appearance.effects.push(fx("threeD.revolve"));
|
||||
let d = doc_with(n);
|
||||
let before = d.clone();
|
||||
let svg = export(&d, &ExportOptions::default());
|
||||
assert!(svg.matches("<path").count() > 50, "Revolve should export vector surfaces");
|
||||
assert!(!svg.contains("<image"), "no raster fallback");
|
||||
assert_eq!(d, before, "export leaves source editable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_strokes_own_glow_filters_the_stroke_element_only() {
|
||||
let mut n = Node::path(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
[package]
|
||||
name = "vectorcraft-three-d"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Portable surface-of-revolution geometry and projected vector shading"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
vectorcraft-geom = { workspace = true }
|
||||
|
|
@ -0,0 +1,419 @@
|
|||
//! A portable surface-of-revolution evaluator. No windowing, GPU or document dependencies.
|
||||
//!
|
||||
//! Open profiles generate uncapped surfaces. Faces are projected and sorted back to front,
|
||||
//! then flat shaded. This vector preview uses a painter's algorithm, not a depth buffer:
|
||||
//! intersecting profiles can have ambiguous visibility and are not solid modelling inputs.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod visibility;
|
||||
pub use visibility::{VisibleFace, visible_faces};
|
||||
|
||||
use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect};
|
||||
|
||||
/// Resource limits shared by desktop, CLI and browser evaluation.
|
||||
const MAX_PROFILE_POINTS: usize = 1024;
|
||||
const MAX_FACES: usize = 32768;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct V3(f64, f64, f64);
|
||||
|
||||
impl V3 {
|
||||
fn sub(self, b: Self) -> Self {
|
||||
Self(self.0 - b.0, self.1 - b.1, self.2 - b.2)
|
||||
}
|
||||
fn cross(self, b: Self) -> Self {
|
||||
Self(self.1 * b.2 - self.2 * b.1, self.2 * b.0 - self.0 * b.2, self.0 * b.1 - self.1 * b.0)
|
||||
}
|
||||
fn dot(self, b: Self) -> f64 {
|
||||
self.0 * b.0 + self.1 * b.1 + self.2 * b.2
|
||||
}
|
||||
fn unit(self) -> Self {
|
||||
let l = self.dot(self).sqrt();
|
||||
if l < 1e-12 { Self::default() } else { Self(self.0 / l, self.1 / l, self.2 / l) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The vertical axis is offset outward from the selected edge of the profile's bounds.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Edge {
|
||||
#[default]
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
/// All angles are degrees; offset is in document points, lighting values are 0..1.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Revolve {
|
||||
pub angle: f64,
|
||||
pub offset: f64,
|
||||
pub edge: Edge,
|
||||
pub rotation_x: f64,
|
||||
pub rotation_y: f64,
|
||||
pub rotation_z: f64,
|
||||
/// Perspective strength, 0 for orthographic projection, up to 1.
|
||||
pub perspective: f64,
|
||||
/// Radial subdivisions for a full turn, clamped to 8..128.
|
||||
pub segments: usize,
|
||||
pub light_azimuth: f64,
|
||||
pub light_elevation: f64,
|
||||
pub light_intensity: f64,
|
||||
pub ambient: f64,
|
||||
pub shade: bool,
|
||||
}
|
||||
|
||||
impl Default for Revolve {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
angle: 360.0,
|
||||
offset: 0.0,
|
||||
edge: Edge::Left,
|
||||
rotation_x: 0.0,
|
||||
rotation_y: 0.0,
|
||||
rotation_z: 0.0,
|
||||
perspective: 0.0,
|
||||
segments: 64,
|
||||
light_azimuth: -45.0,
|
||||
light_elevation: 45.0,
|
||||
light_intensity: 0.8,
|
||||
ambient: 0.25,
|
||||
shade: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finite(v: f64, fallback: f64, min: f64, max: f64) -> f64 {
|
||||
if v.is_finite() { v.clamp(min, max) } else { fallback }
|
||||
}
|
||||
|
||||
impl Revolve {
|
||||
/// Unit bases of the rings that adjust the Euler X, Y and Z view parameters.
|
||||
/// X turns before Y and Z, Y before Z, and Z turns in the screen plane.
|
||||
/// Each pair has a cross product pointing along that parameter's rotation axis.
|
||||
pub fn rotation_planes(self) -> [[[f64; 3]; 2]; 3] {
|
||||
let o = self.sanitized();
|
||||
let x_frame = Self { rotation_x: 0.0, ..o };
|
||||
let y_frame = Self { rotation_x: 0.0, rotation_y: 0.0, ..o };
|
||||
[
|
||||
[x_frame.rotate(V3(0.0, 1.0, 0.0)), x_frame.rotate(V3(0.0, 0.0, 1.0))],
|
||||
[y_frame.rotate(V3(1.0, 0.0, 0.0)), y_frame.rotate(V3(0.0, 0.0, -1.0))],
|
||||
[V3(1.0, 0.0, 0.0), V3(0.0, 1.0, 0.0)],
|
||||
]
|
||||
.map(|plane| plane.map(|v| [v.0, v.1, v.2]))
|
||||
}
|
||||
|
||||
/// The fixed centre of view rotation, in document coordinates.
|
||||
pub fn rotation_origin(self, bounds: Rect) -> Point {
|
||||
let o = self.sanitized();
|
||||
Point::new(if o.edge == Edge::Left { bounds.x0 - o.offset } else { bounds.x1 + o.offset }, bounds.center().y)
|
||||
}
|
||||
|
||||
/// Rotated unit axes for a view-orientation guide (screen Y points down).
|
||||
pub fn view_axes(self) -> [Point; 3] {
|
||||
[V3(1.0, 0.0, 0.0), V3(0.0, 1.0, 0.0), V3(0.0, 0.0, 1.0)].map(|v| {
|
||||
let p = self.sanitized().rotate(v);
|
||||
Point::new(p.0, p.1)
|
||||
})
|
||||
}
|
||||
|
||||
/// The same projected vertical axis used by the surface evaluator.
|
||||
pub fn projected_axis(self, bounds: Rect) -> [Point; 2] {
|
||||
let o = self.sanitized();
|
||||
let axis = match o.edge {
|
||||
Edge::Left => bounds.x0 - o.offset,
|
||||
Edge::Right => bounds.x1 + o.offset,
|
||||
};
|
||||
let center_y = bounds.center().y;
|
||||
let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs());
|
||||
let distance = 4.0 * radius.max(bounds.height().abs()).max(1.0);
|
||||
[bounds.y0, bounds.y1].map(|y| {
|
||||
let v = o.rotate(V3(0.0, y - center_y, 0.0));
|
||||
let factor = distance / (distance - v.2 * o.perspective);
|
||||
Point::new(axis + v.0 * factor, center_y + v.1 * factor)
|
||||
})
|
||||
}
|
||||
|
||||
/// Directional light in view space, shared by shading and interactive guides.
|
||||
pub fn light_direction(self) -> [f64; 3] {
|
||||
let o = self.sanitized();
|
||||
let (sl, cl) = o.light_elevation.to_radians().sin_cos();
|
||||
let (sa, ca) = o.light_azimuth.to_radians().sin_cos();
|
||||
[cl * sa, -sl, cl * ca]
|
||||
}
|
||||
|
||||
/// Light-direction orbits as (centre, cosine basis, sine basis), in view space.
|
||||
/// The first changes azimuth at the current elevation. The second changes elevation
|
||||
/// at the current azimuth. The meridian's valid parameter range is -90..90 degrees.
|
||||
pub fn light_orbits(self) -> [[[f64; 3]; 3]; 2] {
|
||||
let o = self.sanitized();
|
||||
let (se, ce) = o.light_elevation.to_radians().sin_cos();
|
||||
let (sa, ca) = o.light_azimuth.to_radians().sin_cos();
|
||||
[[[0.0, -se, 0.0], [0.0, 0.0, ce], [ce, 0.0, 0.0]], [[0.0, 0.0, 0.0], [sa, 0.0, ca], [0.0, -1.0, 0.0]]]
|
||||
}
|
||||
|
||||
/// Move a light on its current hemisphere, preserving the azimuth at the poles.
|
||||
/// A light behind the object stays behind when its projected handle is dragged.
|
||||
pub fn light_at_on_side(self, point: Point) -> [f64; 2] {
|
||||
let o = self.sanitized();
|
||||
let point = Point::new(finite(point.x, 0.0, -1e8, 1e8), finite(point.y, 0.0, -1e8, 1e8));
|
||||
let length = point.x.hypot(point.y).max(1.0);
|
||||
let (x, y) = (point.x / length, point.y / length);
|
||||
let z = (1.0 - x * x - y * y).max(0.0).sqrt().copysign(o.light_direction()[2]);
|
||||
let azimuth = if x.hypot(z) < 1e-9 { o.light_azimuth } else { x.atan2(z).to_degrees() };
|
||||
[azimuth, (-y).clamp(-1.0, 1.0).asin().to_degrees()]
|
||||
}
|
||||
|
||||
/// A dragged light position on the front hemisphere, in normalized view coordinates.
|
||||
pub fn light_at(point: Point) -> [f64; 2] {
|
||||
let length = point.x.hypot(point.y).max(1.0);
|
||||
let (x, y) = (point.x / length, point.y / length);
|
||||
let z = (1.0 - x * x - y * y).max(0.0).sqrt();
|
||||
[x.atan2(z).to_degrees(), (-y).clamp(-1.0, 1.0).asin().to_degrees()]
|
||||
}
|
||||
|
||||
fn sanitized(self) -> Self {
|
||||
Self {
|
||||
angle: finite(self.angle, 360.0, 0.0, 360.0),
|
||||
offset: finite(self.offset, 0.0, 0.0, 1e5),
|
||||
rotation_x: finite(self.rotation_x, 0.0, -360.0, 360.0),
|
||||
rotation_y: finite(self.rotation_y, 0.0, -360.0, 360.0),
|
||||
rotation_z: finite(self.rotation_z, 0.0, -360.0, 360.0),
|
||||
perspective: finite(self.perspective, 0.0, 0.0, 1.0),
|
||||
segments: self.segments.clamp(8, 128),
|
||||
light_azimuth: finite(self.light_azimuth, -45.0, -360.0, 360.0),
|
||||
light_elevation: finite(self.light_elevation, 45.0, -90.0, 90.0),
|
||||
light_intensity: finite(self.light_intensity, 0.8, 0.0, 1.0),
|
||||
ambient: finite(self.ambient, 0.25, 0.0, 1.0),
|
||||
..self
|
||||
}
|
||||
}
|
||||
fn rotate(self, v: V3) -> V3 {
|
||||
let (sx, cx) = self.rotation_x.to_radians().sin_cos();
|
||||
let (sy, cy) = self.rotation_y.to_radians().sin_cos();
|
||||
let (sz, cz) = self.rotation_z.to_radians().sin_cos();
|
||||
let v = V3(v.0, cx * v.1 - sx * v.2, sx * v.1 + cx * v.2);
|
||||
let v = V3(cy * v.0 + sy * v.2, v.1, -sy * v.0 + cy * v.2);
|
||||
V3(cz * v.0 - sz * v.1, sz * v.0 + cz * v.1, v.2)
|
||||
}
|
||||
}
|
||||
|
||||
/// One opaque vector polygon, already projected. Brightness multiplies the source colour.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Face {
|
||||
pub points: Vec<Point>,
|
||||
pub depth: f64,
|
||||
pub brightness: f64,
|
||||
}
|
||||
|
||||
impl Face {
|
||||
pub fn path(&self) -> PathData {
|
||||
let mut bp = BezPath::new();
|
||||
if let Some(first) = self.points.first() {
|
||||
bp.move_to(*first);
|
||||
for p in self.points.iter().skip(1) {
|
||||
bp.line_to(*p);
|
||||
}
|
||||
bp.close_path();
|
||||
}
|
||||
PathData::from_bezpath(&bp)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Vec<Face>, String> {
|
||||
let o = options.sanitized();
|
||||
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());
|
||||
}
|
||||
let axis = match o.edge {
|
||||
Edge::Left => bounds.x0 - o.offset,
|
||||
Edge::Right => bounds.x1 + o.offset,
|
||||
};
|
||||
let center_y = (bounds.y0 + bounds.y1) * 0.5;
|
||||
let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs());
|
||||
let distance = 4.0 * radius.max(bounds.height().abs()).max(1.0);
|
||||
let steps = ((o.segments as f64 * o.angle / 360.0).ceil() as usize).max(1);
|
||||
let sweep = o.angle.to_radians();
|
||||
let [lx, ly, lz] = o.light_direction();
|
||||
let light = V3(lx, ly, lz);
|
||||
let mut faces = Vec::new();
|
||||
let mut count = 0;
|
||||
for sp in &path.subpaths {
|
||||
let mut profile = Vec::new();
|
||||
if let Some(first) = sp.anchors.first() {
|
||||
profile.push(first.p);
|
||||
count += 1;
|
||||
if count > MAX_PROFILE_POINTS {
|
||||
return Err("Revolve profile is too complex".into());
|
||||
}
|
||||
}
|
||||
let segments = if sp.closed { sp.anchors.len() } else { sp.anchors.len().saturating_sub(1) };
|
||||
// Bound work before sampling any data. Curves get sixteen samples, straight edges one.
|
||||
if segments > MAX_PROFILE_POINTS {
|
||||
return Err("Revolve profile is too complex".into());
|
||||
}
|
||||
for i in 0..segments {
|
||||
let curve = sp.segment(i);
|
||||
let samples = if sp.segment_is_line(i) { 1 } else { 16 };
|
||||
for k in 1..=samples {
|
||||
profile.push(curve.eval(k as f64 / samples as f64));
|
||||
count += 1;
|
||||
if count > MAX_PROFILE_POINTS {
|
||||
return Err("Revolve profile is too complex".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
for pair in profile.windows(2) {
|
||||
let [a, b] = pair else { continue };
|
||||
if ![a.x, a.y, b.x, b.y].iter().all(|v| v.is_finite() && v.abs() <= 1e8) {
|
||||
return Err("Revolve profile contains invalid coordinates".into());
|
||||
}
|
||||
for j in 0..steps {
|
||||
let vertex = |p: Point, step: usize| {
|
||||
let (s, c) = (sweep * step as f64 / steps as f64).sin_cos();
|
||||
o.rotate(V3((p.x - axis) * c, p.y - center_y, (p.x - axis) * s))
|
||||
};
|
||||
let vertices = [vertex(*a, j), vertex(*b, j), vertex(*b, j + 1), vertex(*a, j + 1)];
|
||||
// The diagonal gives a nonzero normal even when one profile endpoint is on-axis.
|
||||
let mut normal = vertices[1].sub(vertices[0]).cross(vertices[2].sub(vertices[0])).unit();
|
||||
if normal.dot(normal) < 0.5 {
|
||||
normal = vertices[2].sub(vertices[0]).cross(vertices[3].sub(vertices[0])).unit();
|
||||
}
|
||||
if normal.dot(normal) < 0.5 {
|
||||
continue;
|
||||
}
|
||||
// Profiles may run up or down. Shade the visible side of this two-sided surface.
|
||||
if normal.2 < 0.0 {
|
||||
normal = V3(-normal.0, -normal.1, -normal.2);
|
||||
}
|
||||
let brightness = if o.shade { (o.ambient + o.light_intensity * normal.dot(light).max(0.0)).clamp(0.0, 1.0) } else { 1.0 };
|
||||
let points: Vec<_> = vertices
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let factor = distance / (distance - v.2 * o.perspective);
|
||||
Point::new(axis + v.0 * factor, center_y + v.1 * factor)
|
||||
})
|
||||
.collect();
|
||||
if points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) {
|
||||
return Err("Revolve projection is not finite".into());
|
||||
}
|
||||
let area = points
|
||||
.iter()
|
||||
.zip(points.iter().cycle().skip(1))
|
||||
.take(4)
|
||||
.map(|(a, b)| (a.x - axis) * (b.y - center_y) - (b.x - axis) * (a.y - center_y))
|
||||
.sum::<f64>();
|
||||
if area.abs() < 1e-10 {
|
||||
continue;
|
||||
}
|
||||
faces.push(Face { points, depth: vertices.iter().map(|v| v.2).sum::<f64>() / 4.0, brightness });
|
||||
if faces.len() > MAX_FACES {
|
||||
return Err("Revolve surface is too complex; reduce Segments or simplify the profile".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
faces.sort_by(|a, b| a.depth.total_cmp(&b.depth));
|
||||
Ok(faces)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn light_orbits_and_handle_projection_match_the_shading_direction_on_both_sides() {
|
||||
for azimuth in [-170.0f64, -45.0, 0.0, 83.0, 167.0] {
|
||||
for elevation in [-85.0f64, -30.0, 0.0, 45.0, 90.0] {
|
||||
let o = Revolve { light_azimuth: azimuth, light_elevation: elevation, ..Default::default() };
|
||||
let direction = o.light_direction();
|
||||
for ([center, u, v], angle) in o.light_orbits().into_iter().zip([azimuth, elevation]) {
|
||||
let (s, c) = angle.to_radians().sin_cos();
|
||||
for i in 0..3 {
|
||||
assert!((center[i] + u[i] * c + v[i] * s - direction[i]).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
let [a, e] = o.light_at_on_side(Point::new(direction[0], direction[1]));
|
||||
assert!((a - azimuth).abs() < 1e-9 && (e - elevation).abs() < 1e-9, "{azimuth}, {elevation} -> {a}, {e}");
|
||||
}
|
||||
}
|
||||
let o = Revolve { light_azimuth: 135.0, ..Default::default() };
|
||||
let [azimuth, elevation] = o.light_at_on_side(Point::new(0.3, -0.4));
|
||||
let [x, y, z] = Revolve { light_azimuth: azimuth, light_elevation: elevation, ..o }.light_direction();
|
||||
assert!((x - 0.3).abs() < 1e-12 && (y + 0.4).abs() < 1e-12 && z < 0.0);
|
||||
let [a, e] = o.light_at_on_side(Point::new(f64::NAN, f64::INFINITY));
|
||||
assert!(a.is_finite() && e.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_axes_match_euler_parameter_rotation_and_origin_stays_fixed() {
|
||||
let o = Revolve { rotation_x: 27.0, rotation_y: -39.0, rotation_z: 53.0, ..Default::default() };
|
||||
let seed = V3(0.3, -0.5, 0.7);
|
||||
let before = o.rotate(seed);
|
||||
let planes = o.rotation_planes();
|
||||
for (axis, [u, v]) in planes.iter().enumerate() {
|
||||
let normal = V3(u[0], u[1], u[2]).cross(V3(v[0], v[1], v[2]));
|
||||
assert!((normal.dot(normal) - 1.0).abs() < 1e-12);
|
||||
let mut changed = o;
|
||||
match axis {
|
||||
0 => changed.rotation_x += 0.0001,
|
||||
1 => changed.rotation_y += 0.0001,
|
||||
_ => changed.rotation_z += 0.0001,
|
||||
}
|
||||
let actual = changed.rotate(seed).sub(before);
|
||||
let expected = normal.cross(before);
|
||||
let step = 0.0001f64.to_radians();
|
||||
for (a, b) in [(actual.0 / step, expected.0), (actual.1 / step, expected.1), (actual.2 / step, expected.2)] {
|
||||
assert!((a - b).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
assert_eq!(Revolve { edge: Edge::Right, offset: 12.0, ..o }.rotation_origin(Rect::new(20.0, 30.0, 80.0, 100.0)), Point::new(92.0, 65.0));
|
||||
}
|
||||
#[test]
|
||||
fn guides_match_projection_and_dragged_light_matches_shading_direction() {
|
||||
let o = Revolve { rotation_x: 0.0, rotation_y: 0.0, rotation_z: 0.0, offset: 10.0, ..Default::default() };
|
||||
assert_eq!(o.view_axes(), [Point::new(1.0, 0.0), Point::new(0.0, 1.0), Point::new(0.0, 0.0)]);
|
||||
assert_eq!(o.projected_axis(Rect::new(20.0, 30.0, 80.0, 100.0)), [Point::new(10.0, 30.0), Point::new(10.0, 100.0)]);
|
||||
let [azimuth, elevation] = Revolve::light_at(Point::new(0.3, -0.4));
|
||||
let [x, y, z] = Revolve { light_azimuth: azimuth, light_elevation: elevation, ..o }.light_direction();
|
||||
assert!((x - 0.3).abs() < 1e-9 && (y + 0.4).abs() < 1e-9 && z > 0.0);
|
||||
let [azimuth, elevation] = Revolve::light_at(Point::new(2.0, -2.0));
|
||||
assert!(azimuth.is_finite() && elevation.is_finite() && elevation.abs() <= 90.0);
|
||||
}
|
||||
fn cylinder() -> (PathData, Rect) {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((40.0, 10.0));
|
||||
bp.line_to((40.0, 90.0));
|
||||
(PathData::from_bezpath(&bp), Rect::new(0.0, 10.0, 40.0, 90.0))
|
||||
}
|
||||
#[test]
|
||||
fn cylinder_has_expected_silhouette_depth_order_and_shading() {
|
||||
let (p, b) = cylinder();
|
||||
let faces = revolve(&p, b, Revolve { rotation_x: 0.0, rotation_y: 0.0, ..Default::default() }).unwrap();
|
||||
let points: Vec<_> = faces.iter().flat_map(|f| &f.points).collect();
|
||||
assert!((points.iter().map(|p| p.x).fold(f64::INFINITY, f64::min) + 40.0).abs() < 1e-6);
|
||||
assert!((points.iter().map(|p| p.x).fold(f64::NEG_INFINITY, f64::max) - 40.0).abs() < 1e-6);
|
||||
assert!(faces.windows(2).all(|w| w[0].depth <= w[1].depth));
|
||||
assert!(faces.iter().any(|f| f.brightness > 0.8) && faces.iter().any(|f| f.brightness < 0.4));
|
||||
}
|
||||
#[test]
|
||||
fn edge_offset_sweep_and_rotation_change_the_surface() {
|
||||
let (p, b) = cylinder();
|
||||
let base = revolve(&p, b, Revolve::default()).unwrap();
|
||||
for o in [
|
||||
Revolve { edge: Edge::Right, offset: 20.0, ..Default::default() },
|
||||
Revolve { angle: 120.0, ..Default::default() },
|
||||
Revolve { rotation_z: 60.0, perspective: 0.8, ..Default::default() },
|
||||
] {
|
||||
assert_ne!(base, revolve(&p, b, o).unwrap());
|
||||
}
|
||||
assert!(revolve(&p, b, Revolve { angle: 0.0, ..Default::default() }).unwrap().is_empty());
|
||||
}
|
||||
#[test]
|
||||
fn malformed_and_degenerate_inputs_are_bounded() {
|
||||
let (p, b) = cylinder();
|
||||
let f = revolve(&p, b, Revolve { segments: usize::MAX, angle: f64::NAN, offset: f64::INFINITY, ..Default::default() }).unwrap();
|
||||
assert!(f.len() <= 128);
|
||||
assert!(revolve(&p, Rect::new(f64::NAN, 0.0, 1.0, 1.0), Revolve::default()).is_err());
|
||||
assert!(revolve(&PathData::default(), b, Revolve::default()).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,325 @@
|
|||
//! Vector visibility in the evaluator's paint order. Convex projected faces are cut by the
|
||||
//! nearer faces covering them, irrespective of their winding or which side faces the camera.
|
||||
//! This preserves interiors visible through open ends and partial sweeps.
|
||||
|
||||
use vectorcraft_geom::{PathData, Point, Rect, SubPath};
|
||||
|
||||
use crate::{Face, MAX_FACES};
|
||||
|
||||
const GRID: usize = 32;
|
||||
const MAX_GRID_REFS: usize = 2_000_000;
|
||||
const MAX_WORK: usize = 16_000_000;
|
||||
const MAX_FRAGMENTS: usize = 256;
|
||||
const MAX_OUTPUT_POINTS: usize = 262_144;
|
||||
|
||||
/// The visible portions of one source face, with its original shading and paint order.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct VisibleFace {
|
||||
pub path: PathData,
|
||||
pub brightness: f64,
|
||||
pub depth: f64,
|
||||
}
|
||||
|
||||
struct Polygon {
|
||||
points: Vec<Point>,
|
||||
bounds: Rect,
|
||||
}
|
||||
|
||||
fn bounds(points: &[Point]) -> Rect {
|
||||
let Some(first) = points.first() else { return Rect::ZERO };
|
||||
points.iter().fold(Rect::from_points(*first, *first), |b, p| b.union_pt(*p))
|
||||
}
|
||||
|
||||
fn area(points: &[Point]) -> f64 {
|
||||
let Some(origin) = points.first() else { return 0.0 };
|
||||
points.iter().zip(points.iter().cycle().skip(1)).take(points.len()).map(|(a, b)| (*a - *origin).cross(*b - *origin)).sum::<f64>() / 2.0
|
||||
}
|
||||
|
||||
fn clean(mut points: Vec<Point>, epsilon: f64) -> Vec<Point> {
|
||||
points.dedup_by(|a, b| a.distance(*b) <= epsilon);
|
||||
if points.len() > 1 && points.first().zip(points.last()).is_some_and(|(a, b)| a.distance(*b) <= epsilon) {
|
||||
points.pop();
|
||||
}
|
||||
if points.len() < 3 || area(&points).abs() <= epsilon * epsilon {
|
||||
return vec![];
|
||||
}
|
||||
if area(&points) < 0.0 {
|
||||
points.reverse();
|
||||
}
|
||||
points
|
||||
}
|
||||
|
||||
fn overlaps(a: Rect, b: Rect) -> bool {
|
||||
a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1
|
||||
}
|
||||
|
||||
/// Split a convex polygon at an occluder's edge. Boundary vertices belong to both halves,
|
||||
/// so independently clipped neighbours share precisely the same intersection positions.
|
||||
fn split(points: &[Point], a: Point, b: Point, epsilon: f64) -> (Vec<Point>, Vec<Point>) {
|
||||
let edge = b - a;
|
||||
let mut inside = vec![];
|
||||
let mut outside = vec![];
|
||||
for (p, q) in points.iter().zip(points.iter().cycle().skip(1)).take(points.len()) {
|
||||
let dp = edge.cross(*p - a);
|
||||
let dq = edge.cross(*q - a);
|
||||
if dp >= 0.0 {
|
||||
inside.push(*p);
|
||||
}
|
||||
if dp <= 0.0 {
|
||||
outside.push(*p);
|
||||
}
|
||||
if (dp > 0.0 && dq < 0.0) || (dp < 0.0 && dq > 0.0) {
|
||||
let intersection = p.lerp(*q, dp / (dp - dq));
|
||||
inside.push(intersection);
|
||||
outside.push(intersection);
|
||||
}
|
||||
}
|
||||
(clean(inside, epsilon), clean(outside, epsilon))
|
||||
}
|
||||
|
||||
fn subtract(points: Vec<Point>, cover: &Polygon, epsilon: f64, work: &mut usize) -> Result<Vec<Vec<Point>>, String> {
|
||||
if !overlaps(bounds(&points), cover.bounds) {
|
||||
return Ok(vec![points]);
|
||||
}
|
||||
let mut remaining = points;
|
||||
let mut visible = vec![];
|
||||
for (a, b) in cover.points.iter().zip(cover.points.iter().cycle().skip(1)).take(cover.points.len()) {
|
||||
*work += remaining.len();
|
||||
if *work > MAX_WORK {
|
||||
return Err("Revolve visibility exceeds its geometry work limit".into());
|
||||
}
|
||||
let (inside, outside) = split(&remaining, *a, *b, epsilon);
|
||||
if !outside.is_empty() {
|
||||
visible.push(outside);
|
||||
}
|
||||
remaining = inside;
|
||||
if remaining.is_empty() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(visible)
|
||||
}
|
||||
|
||||
struct Index {
|
||||
cells: Vec<Vec<usize>>,
|
||||
bounds: Rect,
|
||||
refs: usize,
|
||||
}
|
||||
|
||||
impl Index {
|
||||
fn tiles(&self, b: Rect) -> impl Iterator<Item = usize> {
|
||||
let cell = |v: f64, lo: f64, size: f64| (((v - lo) / size * GRID as f64).floor() as usize).min(GRID - 1);
|
||||
let x0 = cell(b.x0, self.bounds.x0, self.bounds.width());
|
||||
let x1 = cell(b.x1, self.bounds.x0, self.bounds.width());
|
||||
let y0 = cell(b.y0, self.bounds.y0, self.bounds.height());
|
||||
let y1 = cell(b.y1, self.bounds.y0, self.bounds.height());
|
||||
(y0..=y1).flat_map(move |y| (x0..=x1).map(move |x| y * GRID + x))
|
||||
}
|
||||
|
||||
fn insert(&mut self, b: Rect, face: usize) -> Result<(), String> {
|
||||
let tiles: Vec<_> = self.tiles(b).collect();
|
||||
self.refs += tiles.len();
|
||||
if self.refs > MAX_GRID_REFS {
|
||||
return Err("Revolve visibility exceeds its spatial index limit".into());
|
||||
}
|
||||
for tile in tiles {
|
||||
if let Some(cell) = self.cells.get_mut(tile) {
|
||||
cell.push(face);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove covered portions of opaque faces in back-to-front paint order. This follows the
|
||||
/// live evaluator's painter visibility, including its approximation for intersecting surfaces.
|
||||
/// Resource limits return an error; callers can retain the original complete surface safely.
|
||||
pub fn visible_faces(faces: &[Face]) -> Result<Vec<VisibleFace>, String> {
|
||||
if faces.len() > MAX_FACES {
|
||||
return Err("Too many Revolve faces for visibility evaluation".into());
|
||||
}
|
||||
if faces.iter().any(|f| {
|
||||
f.points.len() > 4
|
||||
|| !f.brightness.is_finite()
|
||||
|| !f.depth.is_finite()
|
||||
|| f.points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite() || p.x.abs().max(p.y.abs()) > 1e8)
|
||||
}) {
|
||||
return Err("Revolve visibility requires finite projected convex faces".into());
|
||||
}
|
||||
let all = faces.iter().flat_map(|f| &f.points).copied().collect::<Vec<_>>();
|
||||
let total = bounds(&all);
|
||||
if total.width() <= 0.0 || total.height() <= 0.0 {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let epsilon = (total.width().max(total.height()) * 1e-10).clamp(1e-8, 1e-3);
|
||||
let polygons: Vec<_> = faces
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let points = clean(f.points.clone(), epsilon);
|
||||
Polygon { bounds: bounds(&points), points }
|
||||
})
|
||||
.collect();
|
||||
// Generated quads are convex. Validate the public helper rather than accepting concave
|
||||
// callers and silently deleting geometry they can see.
|
||||
for p in &polygons {
|
||||
if p.points.iter().enumerate().any(|(i, a)| {
|
||||
let Some(b) = p.points.get((i + 1) % p.points.len()) else { return false };
|
||||
let Some(c) = p.points.get((i + 2) % p.points.len()) else { return false };
|
||||
(*b - *a).cross(*c - *b) < -epsilon * b.distance(*a).max(1.0)
|
||||
}) {
|
||||
return Err("Revolve visibility requires convex projected faces".into());
|
||||
}
|
||||
}
|
||||
let mut index = Index { cells: vec![vec![]; GRID * GRID], bounds: total, refs: 0 };
|
||||
let mut seen = vec![usize::MAX; faces.len()];
|
||||
let (mut out, mut work, mut output_points) = (vec![], 0, 0);
|
||||
for (source, (face, polygon)) in faces.iter().zip(&polygons).enumerate().rev() {
|
||||
if polygon.points.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut fragments = vec![polygon.points.clone()];
|
||||
'covers: for tile in index.tiles(polygon.bounds) {
|
||||
for &candidate in index.cells.get(tile).into_iter().flatten() {
|
||||
work += 1;
|
||||
if work > MAX_WORK {
|
||||
return Err("Revolve visibility exceeds its geometry work limit".into());
|
||||
}
|
||||
if let Some(mark) = seen.get_mut(candidate)
|
||||
&& *mark != source
|
||||
{
|
||||
*mark = source;
|
||||
if let Some(cover) = polygons.get(candidate)
|
||||
&& overlaps(polygon.bounds, cover.bounds)
|
||||
{
|
||||
let mut remaining = vec![];
|
||||
for fragment in fragments {
|
||||
remaining.extend(subtract(fragment, cover, epsilon, &mut work)?);
|
||||
if remaining.len() > MAX_FRAGMENTS {
|
||||
return Err("Revolve visibility creates too many fragments".into());
|
||||
}
|
||||
}
|
||||
fragments = remaining;
|
||||
if fragments.is_empty() {
|
||||
break 'covers;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if fragments.is_empty() {
|
||||
continue;
|
||||
}
|
||||
output_points += fragments.iter().map(Vec::len).sum::<usize>();
|
||||
if output_points > MAX_OUTPUT_POINTS {
|
||||
return Err("Revolve visibility creates too many vector anchors".into());
|
||||
}
|
||||
let path = PathData::new(fragments.into_iter().map(|p| SubPath::polyline(&p, true)).collect());
|
||||
out.push(VisibleFace { path, brightness: face.brightness, depth: face.depth });
|
||||
// A completely covered face need not occupy the grid. Its coverage already belongs
|
||||
// to nearer faces; this keeps repeated overlapping surfaces bounded in practice.
|
||||
index.insert(polygon.bounds, source)?;
|
||||
}
|
||||
out.reverse();
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Edge, Revolve, revolve};
|
||||
use vectorcraft_geom::{BezPath, Shape};
|
||||
|
||||
fn face(rect: Rect, brightness: f64) -> Face {
|
||||
Face {
|
||||
points: vec![Point::new(rect.x0, rect.y0), Point::new(rect.x1, rect.y0), Point::new(rect.x1, rect.y1), Point::new(rect.x0, rect.y1)],
|
||||
depth: brightness,
|
||||
brightness,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covered_faces_disappear_and_partial_faces_are_trimmed_in_paint_order() {
|
||||
let faces =
|
||||
vec![face(Rect::new(2.0, 2.0, 8.0, 8.0), 0.1), face(Rect::new(0.0, 0.0, 10.0, 10.0), 0.2), face(Rect::new(5.0, 0.0, 15.0, 10.0), 0.8)];
|
||||
let visible = visible_faces(&faces).unwrap();
|
||||
assert_eq!(visible.len(), 2);
|
||||
assert_eq!(visible[0].brightness, 0.2);
|
||||
assert_eq!(visible[0].path.bounds(), Some(Rect::new(0.0, 0.0, 5.0, 10.0)));
|
||||
assert!((visible[0].path.to_bezpath().area() - 50.0).abs() < 1e-8);
|
||||
assert!((visible[1].path.to_bezpath().area() - 100.0).abs() < 1e-8);
|
||||
// Winding and equal depth do not determine visibility; actual paint order does.
|
||||
let mut flipped = faces.clone();
|
||||
for f in &mut flipped {
|
||||
f.points.reverse();
|
||||
f.depth = 0.0;
|
||||
}
|
||||
let other = visible_faces(&flipped).unwrap();
|
||||
assert_eq!(visible.iter().map(|f| f.brightness).collect::<Vec<_>>(), other.iter().map(|f| f.brightness).collect::<Vec<_>>());
|
||||
assert_eq!(visible[0].path.bounds(), other[0].path.bounds());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_interior_surface_remains_visible_through_an_opening() {
|
||||
let faces = vec![
|
||||
face(Rect::new(0.0, 0.0, 10.0, 10.0), 0.1),
|
||||
face(Rect::new(0.0, 0.0, 10.0, 3.0), 0.2),
|
||||
face(Rect::new(0.0, 7.0, 10.0, 10.0), 0.3),
|
||||
face(Rect::new(0.0, 3.0, 3.0, 7.0), 0.4),
|
||||
face(Rect::new(7.0, 3.0, 10.0, 7.0), 0.5),
|
||||
];
|
||||
let visible = visible_faces(&faces).unwrap();
|
||||
assert_eq!(visible[0].brightness, 0.1);
|
||||
assert_eq!(visible[0].path.bounds(), Some(Rect::new(3.0, 3.0, 7.0, 7.0)));
|
||||
assert_eq!(visible[0].path.to_bezpath().winding(Point::new(5.0, 5.0)), 1);
|
||||
assert_eq!(visible[0].path.to_bezpath().winding(Point::new(1.0, 1.0)), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolved_open_profiles_match_visible_paint_for_both_axis_sides_and_perspective() {
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to((40.0, 10.0));
|
||||
bp.curve_to((75.0, 20.0), (75.0, 80.0), (40.0, 90.0));
|
||||
let path = PathData::from_bezpath(&bp);
|
||||
for edge in [Edge::Left, Edge::Right] {
|
||||
for angle in [155.0, 360.0] {
|
||||
let options = Revolve {
|
||||
edge,
|
||||
angle,
|
||||
offset: 8.0,
|
||||
segments: 32,
|
||||
rotation_x: -32.0,
|
||||
rotation_y: 24.0,
|
||||
rotation_z: 19.0,
|
||||
perspective: 0.7,
|
||||
..Default::default()
|
||||
};
|
||||
let faces = revolve(&path, path.bounds().unwrap(), options).unwrap();
|
||||
let visible = visible_faces(&faces).unwrap();
|
||||
assert!(visible.len() <= faces.len());
|
||||
let all: Vec<_> = faces.iter().map(|f| (f.path().to_bezpath(), f.brightness)).collect();
|
||||
let cut: Vec<_> = visible.iter().map(|f| (f.path.to_bezpath(), f.brightness)).collect();
|
||||
let b = bounds(&faces.iter().flat_map(|f| &f.points).copied().collect::<Vec<_>>());
|
||||
for x in 0..48 {
|
||||
for y in 0..48 {
|
||||
let p = Point::new(b.x0 + b.width() * (x as f64 + 0.337) / 48.0, b.y0 + b.height() * (y as f64 + 0.413) / 48.0);
|
||||
let expected = all.iter().rev().find(|(bp, _)| bp.winding(p) != 0).map(|(_, value)| value);
|
||||
let actual = cut.iter().rev().find(|(bp, _)| bp.winding(p) != 0).map(|(_, value)| value);
|
||||
assert_eq!(actual, expected, "{edge:?}, {angle}, {p:?}");
|
||||
assert!(cut.iter().filter(|(bp, _)| bp.winding(p) != 0).count() <= 1, "expanded surfaces do not cover hidden regions");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_is_bounded_for_repeated_faces_and_rejects_invalid_input() {
|
||||
let faces = vec![face(Rect::new(0.0, 0.0, 10.0, 10.0), 0.5); MAX_FACES];
|
||||
assert_eq!(visible_faces(&faces).unwrap().len(), 1);
|
||||
assert!(visible_faces(&vec![faces[0].clone(); MAX_FACES + 1]).is_err());
|
||||
let mut bad = faces[0].clone();
|
||||
bad.points[0].x = f64::NAN;
|
||||
assert!(visible_faces(&[bad]).is_err());
|
||||
assert!(visible_faces(&[]).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@ vectorcraft-tools = { workspace = true }
|
|||
vectorcraft-engine = { workspace = true }
|
||||
vectorcraft-svg = { workspace = true }
|
||||
vectorcraft-effects = { workspace = true }
|
||||
vectorcraft-three-d = { workspace = true }
|
||||
vectorcraft-plugins = { workspace = true }
|
||||
vectorcraft-pdf = { workspace = true }
|
||||
vectorcraft-cad = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -175,7 +175,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
let resp = ui.interact(rect, egui::Id::new("canvas"), Sense::click_and_drag());
|
||||
// A press on the canvas while the context menu is open only closes it (a drag too, which egui
|
||||
// alone would leave open); the tool doesn't get it.
|
||||
if resp.context_menu_opened() && resp.hovered() && ui.input(|i| i.pointer.any_pressed()) {
|
||||
if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) {
|
||||
// The artboard's 3D gizmo owns these gestures while its options are open.
|
||||
// Do not let a ring drag also move or deselect the editable source path.
|
||||
} else if resp.context_menu_opened() && resp.hovered() && ui.input(|i| i.pointer.any_pressed()) {
|
||||
egui::Popup::close_all(ui.ctx());
|
||||
} else {
|
||||
handle_input(app, ui, &resp, rect);
|
||||
|
|
@ -401,7 +404,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
}
|
||||
|
||||
// Selection visuals and tool overlays.
|
||||
if app.ui.view.edges {
|
||||
let revolve_edit = app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active);
|
||||
if app.ui.view.edges && !revolve_edit {
|
||||
hover_highlight(app, &painter, &xf);
|
||||
selection_overlay(app, &painter, &xf);
|
||||
if app.ui.view.text_threads {
|
||||
|
|
@ -413,7 +417,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
}
|
||||
let view_info = app.view_info();
|
||||
// View → Hide Gradient Annotator hides the Gradient tool's annotator.
|
||||
if app.session.tool_id() != "gradient" || app.ui.view.gradient_annotator {
|
||||
if !revolve_edit && (app.session.tool_id() != "gradient" || app.ui.view.gradient_annotator) {
|
||||
let overlays = app.session.overlays(view_info);
|
||||
draw_overlays(&painter, &xf, &overlays, &t, HandleLook::of(&app.session.prefs));
|
||||
}
|
||||
|
|
@ -423,7 +427,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
if app.ui.view.rulers && app.ui.screen_mode < 3 {
|
||||
rulers(ui, full, &xf, app.hover_doc, app.session.general_unit(), &t);
|
||||
}
|
||||
if app.ui.task_bar && !app.session.tool_busy() && app.ui.screen_mode < 3 {
|
||||
if app.ui.task_bar && !revolve_edit && !app.session.tool_busy() && app.ui.screen_mode < 3 {
|
||||
task_bar(app, ui, &xf);
|
||||
}
|
||||
if app.ui.screen_mode < 3 {
|
||||
|
|
|
|||
|
|
@ -809,10 +809,14 @@ pub fn hint_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, "?", theme::semibold(11.0), t.text);
|
||||
ui.add_space(6.0);
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
let tool = app.session.tool_id();
|
||||
// The Zoom tool's drag zooms to an area, or with Animated Zoom zooms as it goes.
|
||||
let hint = if tool == "zoom" && crate::canvas::animated_zoom(&app.session.prefs) { ANIMATED_ZOOM_HINT } else { tool };
|
||||
for (txt, bold) in hint_segments(hint) {
|
||||
let segments = if let Some(d) = app.ui.dialog.as_ref().filter(|d| crate::dialogs::revolve_gizmo::active(d)) {
|
||||
vec![(crate::dialogs::revolve_gizmo::hint(d).into(), false)]
|
||||
} else {
|
||||
let tool = app.session.tool_id();
|
||||
let hint = if tool == "zoom" && crate::canvas::animated_zoom(&app.session.prefs) { ANIMATED_ZOOM_HINT } else { tool };
|
||||
hint_segments(hint)
|
||||
};
|
||||
for (txt, bold) in segments {
|
||||
let font = if bold { theme::semibold(12.5) } else { egui::FontId::proportional(12.5) };
|
||||
job.append(&txt, 0.0, egui::TextFormat { font_id: font, color: if bold { t.text_strong } else { t.text }, ..Default::default() });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,6 +127,12 @@ fn confirm_exists(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String>
|
|||
|
||||
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
|
||||
let (id, relative) = (d.str("__effect"), d.bool("relative"));
|
||||
if id == vectorcraft_effects::REVOLVE {
|
||||
super::revolve::body(app, ui, d);
|
||||
let (cmd, params) = command(d);
|
||||
form::preview(app, ui, d, &d.str("__label"), cmd, params);
|
||||
return false;
|
||||
}
|
||||
// Plug-in effects get fields from their parameter schema; colour adjustments their sliders.
|
||||
let changed = match vectorcraft_plugins::effect::installed(&id) {
|
||||
Some(plugin) => form::schema_fields(ui, d, &plugin.manifest().params),
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ pub mod raster_effects;
|
|||
pub mod rearrange_artboards;
|
||||
pub mod recolor;
|
||||
mod recovery;
|
||||
mod revolve;
|
||||
pub(crate) mod revolve_gizmo;
|
||||
pub mod saturate;
|
||||
mod save_changes;
|
||||
pub mod save_for_web;
|
||||
|
|
@ -355,6 +357,7 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
|||
// its first field is to take the keyboard focus (`focus_id`, until a field takes it).
|
||||
let (shown_id, focus_id) = (egui::Id::new("dialog-shown"), egui::Id::new("dialog-focus-pending"));
|
||||
let Some(mut d) = app.ui.dialog.clone() else {
|
||||
revolve_gizmo::clear(ctx);
|
||||
app.ui.dialog_file = None;
|
||||
ctx.data_mut(|m| {
|
||||
m.remove::<String>(shown_id);
|
||||
|
|
@ -362,6 +365,9 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
|||
});
|
||||
return;
|
||||
};
|
||||
if !revolve_gizmo::active(&d) {
|
||||
revolve_gizmo::clear(ctx);
|
||||
}
|
||||
let focus_first = ctx.data_mut(|m| {
|
||||
if m.get_temp::<String>(shown_id).as_deref() != Some(d.kind.as_str()) {
|
||||
m.insert_temp(shown_id, d.kind.clone());
|
||||
|
|
@ -377,7 +383,14 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
|||
let mut cancel = false;
|
||||
let mut discard = false;
|
||||
let heading = (spec.heading)(&d);
|
||||
modal::show(ctx, &heading, egui::Id::new(("dialog", d.kind.as_str())), -40.0, MARGIN, |ui| {
|
||||
let revolves = revolve_gizmo::active(&d);
|
||||
if revolves {
|
||||
revolve_gizmo::show(app, ctx, &mut d);
|
||||
}
|
||||
let initial = if revolves { egui::vec2((ctx.content_rect().width() / 2.0 - 226.0).max(0.0), -40.0) } else { egui::vec2(0.0, -40.0) };
|
||||
let id = egui::Id::new(("dialog", d.kind.as_str()));
|
||||
let id = if revolves { id.with("revolve") } else { id };
|
||||
modal::show_at(ctx, &heading, id, initial, MARGIN, |ui| {
|
||||
// Never wider than the window (a large UI scale in a small window): the text wraps.
|
||||
let room = (ctx.content_rect().width() - 2.0 * (f32::from(MARGIN) + EDGE_GAP)).max(EDGE_GAP);
|
||||
ui.set_min_width(spec.min_width.min(room));
|
||||
|
|
|
|||
|
|
@ -11,6 +11,11 @@ use crate::theme::{self, Tokens};
|
|||
/// screen's centre until it is moved, with `margin` inside its frame. `add` draws the contents and
|
||||
/// should start with [`heading`] (or [`drag_band`]) so the window can be moved.
|
||||
pub(crate) fn show<R>(ctx: &egui::Context, title: &str, id: Id, nudge: f32, margin: i8, add: impl FnOnce(&mut egui::Ui) -> R) -> Option<R> {
|
||||
show_at(ctx, title, id, egui::vec2(0.0, nudge), margin, add)
|
||||
}
|
||||
|
||||
/// A modal that starts at a chosen offset, then keeps the user's dragged position.
|
||||
pub(crate) fn show_at<R>(ctx: &egui::Context, title: &str, id: Id, initial: Vec2, margin: i8, add: impl FnOnce(&mut egui::Ui) -> R) -> Option<R> {
|
||||
let t = Tokens::get(ctx);
|
||||
let dim = backdrop();
|
||||
egui::Area::new(dim.id).order(dim.order).fixed_pos(egui::Pos2::ZERO).show(ctx, |ui| {
|
||||
|
|
@ -23,7 +28,7 @@ pub(crate) fn show<R>(ctx: &egui::Context, title: &str, id: Id, nudge: f32, marg
|
|||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.title_bar(false)
|
||||
.anchor(egui::Align2::CENTER_CENTER, offset(ctx, id, nudge))
|
||||
.anchor(egui::Align2::CENTER_CENTER, offset(ctx, id, initial))
|
||||
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(margin)))
|
||||
.show(ctx, add)
|
||||
.and_then(|r| r.inner)
|
||||
|
|
@ -67,9 +72,9 @@ fn handle(id: Id) -> Id {
|
|||
/// its [`drag_band`] is dragged, where it sat when the drag began plus how far the pointer has moved
|
||||
/// since the press, kept on screen (applied before the window places itself, so it follows the
|
||||
/// pointer without a frame's lag).
|
||||
fn offset(ctx: &egui::Context, id: Id, nudge: f32) -> Vec2 {
|
||||
fn offset(ctx: &egui::Context, id: Id, initial: Vec2) -> Vec2 {
|
||||
let (key, start_key) = (id.with("modal-offset"), id.with("modal-drag-start"));
|
||||
let offset = ctx.data(|m| m.get_temp::<Vec2>(key)).unwrap_or(egui::vec2(0.0, nudge));
|
||||
let offset = ctx.data(|m| m.get_temp::<Vec2>(key)).unwrap_or(initial);
|
||||
let dragged = ctx.read_response(handle(id)).is_some_and(|r| r.dragged());
|
||||
let moved = ctx.input(|i| Some(i.pointer.latest_pos()? - i.pointer.press_origin()?));
|
||||
let (true, Some(moved), Some(rect)) = (dragged, moved, ctx.memory(|m| m.area_rect(id))) else {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,233 @@
|
|||
//! Compact Revolve controls. The object and rotation gizmo live on the artboard.
|
||||
//! All document changes run through the effect dialog's existing preview/commit commands.
|
||||
|
||||
use egui::{Align2, FontId, Id, Pos2, Sense, Stroke, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::Unit;
|
||||
use vectorcraft_effects::REVOLVE;
|
||||
|
||||
use crate::state::Dialog;
|
||||
use crate::theme::Tokens;
|
||||
use crate::{VectorcraftApp, scrub, widgets};
|
||||
|
||||
const LABEL_W: f32 = 102.0;
|
||||
const VALUE_W: f32 = 74.0;
|
||||
const SIDE_W: f32 = 354.0;
|
||||
|
||||
/// Sized to the screen, with scrolling inside the body so OK and Cancel stay reachable.
|
||||
pub(super) fn body(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
// Values start in drag mode; clicking or Tab enters their text editor.
|
||||
ui.data_mut(|m| m.remove::<bool>(widgets::dialog_focus_flag()));
|
||||
let screen = ui.ctx().content_rect();
|
||||
let width = (screen.width() - 64.0).clamp(240.0, SIDE_W);
|
||||
let height = (screen.height() - 240.0).clamp(120.0, 340.0);
|
||||
ui.set_width(width);
|
||||
egui::ScrollArea::vertical().id_salt("revolve-body").max_height(height).auto_shrink([false, true]).show(ui, |ui| {
|
||||
controls(app, ui, d);
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.allocate_ui_with_layout(vec2((width - 100.0).max(80.0), 32.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.add(egui::Label::new(egui::RichText::new(super::revolve_gizmo::hint(d)).size(11.0).color(t.text_dim)).wrap());
|
||||
});
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let defaults = vectorcraft_effects::default_params(REVOLVE).unwrap_or(json!({}));
|
||||
let changed = defaults.as_object().is_some_and(|m| {
|
||||
m.iter().any(|(k, v)| v.as_f64().map_or_else(|| d.fields.get(k) != Some(v), |default| (d.f64(k, default) - default).abs() > 1e-6))
|
||||
});
|
||||
let (r, _) = ui.allocate_exact_size(vec2(88.0, 24.0), Sense::hover());
|
||||
let response = ui.interact(r, Id::new("revolve-reset-all"), Sense::click());
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Reset all")));
|
||||
if response.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
ui.painter().text(r.left_center() + vec2(4.0, 0.0), Align2::LEFT_CENTER, tl!("Reset all"), FontId::proportional(11.5), t.text);
|
||||
dot(ui, r.right_center() - vec2(10.0, 0.0), changed);
|
||||
if response.on_hover_text(tl!("Reset all Revolve settings to their defaults.")).clicked()
|
||||
&& let Some(defaults) = defaults.as_object()
|
||||
{
|
||||
d.fields.extend(defaults.clone());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.spacing_mut().item_spacing.y = 4.0;
|
||||
let lighting = super::revolve_gizmo::lighting(d);
|
||||
if let Some(tab) = widgets::tab_bar(ui, &[tl!("Rotation"), tl!("Lighting")], usize::from(lighting)) {
|
||||
d.fields.insert("__gizmo".into(), json!(if tab == 1 { "light" } else { "rotation" }));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
if super::revolve_gizmo::lighting(d) {
|
||||
lighting_controls(ui, d);
|
||||
return;
|
||||
}
|
||||
let unit = app.session.general_unit();
|
||||
heading(ui, tl!("Shape"));
|
||||
number(ui, d, "angle", tl!("Revolve angle"), (0.0, 360.0), "°", 1, None);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
label(ui, tl!("Axis side"));
|
||||
let edge = d.str("edge");
|
||||
let current = if edge == "right" { tl!("Right") } else { tl!("Left") };
|
||||
let width = VALUE_W + rail_width(ui) + 6.0;
|
||||
if let Some(k) = widgets::dropdown(ui, "revolve-edge", current, &[tl!("Left"), tl!("Right")], width) {
|
||||
d.fields.insert("edge".into(), json!(if k == 0 { "left" } else { "right" }));
|
||||
}
|
||||
if reset_dot(ui, "edge", edge != "left", tl!("Left")) {
|
||||
d.fields.insert("edge".into(), json!("left"));
|
||||
}
|
||||
});
|
||||
number(ui, d, "offset", tl!("Offset"), (0.0, 100000.0), "", 1, Some(unit));
|
||||
ui.add_space(10.0);
|
||||
heading(ui, tl!("View"));
|
||||
for (key, name) in [("rotationX", tl!("Rotation X")), ("rotationY", tl!("Rotation Y")), ("rotationZ", tl!("Rotation Z"))] {
|
||||
number(ui, d, key, name, (-360.0, 360.0), "°", 1, None);
|
||||
}
|
||||
number(ui, d, "perspective", tl!("Perspective"), (0.0, 100.0), "%", 1, None);
|
||||
ui.add_space(6.0);
|
||||
egui::CollapsingHeader::new(tl!("Advanced")).id_salt("revolve-advanced").show(ui, |ui| {
|
||||
number(ui, d, "segments", tl!("Segments"), (8.0, 128.0), "", 0, None);
|
||||
super::form::check(ui, d, "__showAxis", tl!("Show axis"));
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
let mut visible = d.fields.get("expandVisibleOnly").and_then(Value::as_bool).unwrap_or(true);
|
||||
let response = ui.allocate_ui_with_layout(
|
||||
vec2((ui.available_width() - 30.0).max(100.0), 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.checkbox(&mut visible, egui::RichText::new(tl!("Keep visible surfaces only when expanding")).size(12.0))
|
||||
},
|
||||
).inner;
|
||||
if response.on_hover_text(tl!("Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces.")).changed() {
|
||||
d.fields.insert("expandVisibleOnly".into(), json!(visible));
|
||||
}
|
||||
if reset_dot(ui, "expandVisibleOnly", !visible, "true") {
|
||||
d.fields.insert("expandVisibleOnly".into(), json!(true));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn lighting_controls(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.horizontal(|ui| {
|
||||
let mut shade = d.bool("shade");
|
||||
if ui.checkbox(&mut shade, tl!("Shading")).changed() {
|
||||
d.fields.insert("shade".into(), json!(shade));
|
||||
}
|
||||
ui.add_space((ui.available_width() - 24.0).max(0.0));
|
||||
if reset_dot(ui, "shade", !shade, "true") {
|
||||
d.fields.insert("shade".into(), json!(true));
|
||||
}
|
||||
});
|
||||
ui.add_enabled_ui(d.bool("shade"), |ui| {
|
||||
number(ui, d, "lightAzimuth", tl!("Direction"), (-360.0, 360.0), "°", 1, None);
|
||||
number(ui, d, "lightElevation", tl!("Elevation"), (-90.0, 90.0), "°", 1, None);
|
||||
number(ui, d, "lightIntensity", tl!("Intensity"), (0.0, 100.0), "%", 1, None);
|
||||
number(ui, d, "ambient", tl!("Ambient"), (0.0, 100.0), "%", 1, None);
|
||||
});
|
||||
}
|
||||
|
||||
fn heading(ui: &mut egui::Ui, text: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(egui::RichText::new(text).font(crate::theme::semibold(13.0)).color(t.text));
|
||||
}
|
||||
|
||||
fn label(ui: &mut egui::Ui, name: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(LABEL_W, 24.0), Sense::hover());
|
||||
ui.put(r, egui::Label::new(egui::RichText::new(name).size(12.5).color(t.text)).halign(egui::Align::Min).truncate()).on_hover_text(name);
|
||||
scrub::note_label(ui, r);
|
||||
}
|
||||
|
||||
fn rail_width(ui: &egui::Ui) -> f32 {
|
||||
(ui.available_width() - VALUE_W - 24.0 - 18.0).clamp(40.0, 134.0)
|
||||
}
|
||||
|
||||
/// A value can be typed, dragged, scrubbed by its label, or moved on its slider.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn number(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, range: (f64, f64), suffix: &str, decimals: usize, unit: Option<Unit>) {
|
||||
let default = vectorcraft_effects::default_params(REVOLVE).and_then(|p| p.get(key).and_then(Value::as_f64)).unwrap_or(0.0);
|
||||
let from = |n| unit.map_or(n, |u| u.from_pt(n));
|
||||
let to = |n| unit.map_or(n, |u| u.to_pt(n));
|
||||
let typed = unit.map(|u| widgets::typed_unit(ui.ctx(), u));
|
||||
let mut value = from(d.f64(key, default)).clamp(from(range.0), from(range.1));
|
||||
let suffix = unit.map_or_else(|| suffix.to_string(), |u| format!(" {}", u.suffix()));
|
||||
let mut next = None;
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
label(ui, name);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let response = ui
|
||||
.scope(|ui| {
|
||||
ui.visuals_mut().widgets.inactive.weak_bg_fill = t.input;
|
||||
ui.visuals_mut().widgets.inactive.bg_stroke = Stroke::new(1.0, t.input_border);
|
||||
ui.add_sized(
|
||||
[VALUE_W, 24.0],
|
||||
egui::DragValue::new(&mut value)
|
||||
.range(from(range.0)..=from(range.1))
|
||||
.speed(if decimals == 0 { 1.0 } else { 0.25 })
|
||||
.max_decimals(decimals)
|
||||
.suffix(&suffix)
|
||||
.update_while_editing(false)
|
||||
.custom_parser(|s| {
|
||||
unit.map_or_else(
|
||||
|| vectorcraft_doc::parse_number(&s.replace(['°', '%'], "")),
|
||||
|u| typed.unwrap_or(u).parse(s).map(|v| u.from_pt(v)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
.inner;
|
||||
response.clone().on_hover_text(tl!("Drag values or labels. Double-click a value to type."));
|
||||
if response.changed() {
|
||||
next = Some(value);
|
||||
}
|
||||
if let Some(n) = scrub::field(ui, response.id, response.rect, &value.to_string(), Some(value), decimals as i32) {
|
||||
next = Some(n);
|
||||
}
|
||||
// Offset's rail covers a useful working range; larger values can still be typed.
|
||||
let rail_max = if key == "offset" { from(500.0) } else { from(range.1) };
|
||||
let rail_min = from(range.0);
|
||||
let rail = rail_width(ui) + VALUE_W;
|
||||
let slider =
|
||||
widgets::color_slider(ui, ("revolve-slider", key), ((value - rail_min) / (rail_max - rail_min)).clamp(0.0, 1.0) as f32, rail, &|_| {
|
||||
t.input_border
|
||||
});
|
||||
if let (Some(n), _) = slider {
|
||||
next = Some(rail_min + f64::from(n) * (rail_max - rail_min));
|
||||
}
|
||||
if reset_dot(ui, key, (to(value) - default).abs() > 1e-6, &format!("{}{}", from(default), suffix)) {
|
||||
next = Some(from(default));
|
||||
}
|
||||
});
|
||||
if let Some(n) = next.filter(|n| n.is_finite()) {
|
||||
let factor = 10f64.powi(decimals as i32);
|
||||
let n = (n * factor).round() / factor;
|
||||
d.fields.insert(key.into(), json!(to(n.clamp(from(range.0), from(range.1)))));
|
||||
}
|
||||
}
|
||||
|
||||
fn dot(ui: &egui::Ui, p: Pos2, changed: bool) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
if changed {
|
||||
ui.painter().circle_filled(p, 3.0, if ui.is_enabled() { t.accent } else { t.text_disabled });
|
||||
} else {
|
||||
ui.painter().circle_stroke(p, 3.0, Stroke::new(1.0, t.text_disabled));
|
||||
}
|
||||
}
|
||||
|
||||
fn reset_dot(ui: &mut egui::Ui, key: &str, changed: bool, default: &str) -> bool {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::hover());
|
||||
let response = ui.interact(r, Id::new(("revolve-reset", key)), Sense::click());
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!("Reset to default")));
|
||||
if response.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, Tokens::get(ui.ctx()).hover);
|
||||
}
|
||||
dot(ui, r.center(), changed);
|
||||
response.on_hover_text(format!("{}: {default}", tl!("Reset to default"))).clicked()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests_revolve.rs"]
|
||||
mod tests;
|
||||
|
|
@ -0,0 +1,425 @@
|
|||
//! Artboard rotation and light controls for the active Revolve dialog. Gestures edit fields;
|
||||
//! the existing effect preview applies commands and owns OK, Cancel and undo.
|
||||
use egui::{Align2, FontId, Id, Pos2, Rect, Sense, Stroke, Vec2, vec2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_effects::{REVOLVE, revolve_options};
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_three_d::Revolve;
|
||||
|
||||
use crate::{VectorcraftApp, canvas::Xf, state::Dialog, theme::Tokens};
|
||||
|
||||
const KEYS: [&str; 3] = ["rotationX", "rotationY", "rotationZ"];
|
||||
const SAMPLES: usize = 96;
|
||||
|
||||
pub(crate) fn active(d: &Dialog) -> bool {
|
||||
d.kind == "effect" && d.str("__effect") == REVOLVE
|
||||
}
|
||||
|
||||
pub(crate) fn lighting(d: &Dialog) -> bool {
|
||||
d.str("__gizmo") == "light"
|
||||
}
|
||||
|
||||
pub(crate) fn hint(d: &Dialog) -> &'static str {
|
||||
if !d.bool("preview") || (lighting(d) && !d.bool("shade")) {
|
||||
tl!("Drag values or labels. Double-click a value to type.")
|
||||
} else if lighting(d) {
|
||||
tl!("Drag the light, or its rings. Shift snaps to 15°.")
|
||||
} else {
|
||||
tl!("Drag rings to rotate. Shift snaps to 15°.")
|
||||
}
|
||||
}
|
||||
|
||||
fn drag_id() -> Id {
|
||||
Id::new("revolve-gizmo-drag")
|
||||
}
|
||||
|
||||
pub(crate) fn dragging(ctx: &egui::Context) -> bool {
|
||||
ctx.data(|m| m.get_temp::<Drag>(drag_id())).is_some()
|
||||
}
|
||||
|
||||
pub(super) fn clear(ctx: &egui::Context) {
|
||||
ctx.data_mut(|m| m.remove::<Drag>(drag_id()));
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Ring {
|
||||
center: Vec2,
|
||||
u: Vec2,
|
||||
v: Vec2,
|
||||
depth: [f32; 2],
|
||||
}
|
||||
|
||||
impl Ring {
|
||||
fn point(self, angle: f32) -> Vec2 {
|
||||
self.center + self.u * angle.cos() + self.v * angle.sin()
|
||||
}
|
||||
fn tangent(self, angle: f32) -> Vec2 {
|
||||
-self.u * angle.sin() + self.v * angle.cos()
|
||||
}
|
||||
fn phase(self, p: Vec2) -> Option<f32> {
|
||||
let p = p - self.center;
|
||||
let det = self.u.x * self.v.y - self.u.y * self.v.x;
|
||||
// Near edge-on, an ellipse's inverse magnifies tiny pointer movements.
|
||||
if det.abs() < self.u.length().max(self.v.length()).powi(2) * 0.15 {
|
||||
return None;
|
||||
}
|
||||
let x = (p.x * self.v.y - p.y * self.v.x) / det;
|
||||
let y = (p.y * self.u.x - p.x * self.u.y) / det;
|
||||
(x.hypot(y) > 0.1).then(|| y.atan2(x))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Geometry {
|
||||
center: Pos2,
|
||||
radius: f32,
|
||||
rings: [Ring; 3],
|
||||
xf: Xf,
|
||||
bounds: vectorcraft_geom::Rect,
|
||||
}
|
||||
|
||||
impl Geometry {
|
||||
fn new(app: &VectorcraftApp, d: &Dialog) -> Option<Self> {
|
||||
let state = app.session.active()?;
|
||||
let node = state.doc.node(*state.selection.objects.first()?)?;
|
||||
let bounds = node.geometric_bounds()?;
|
||||
let xf = Xf::new(app.canvas_rect?, app.view()?);
|
||||
let options = revolve_options(&super::form::params(d));
|
||||
let center = xf.to_screen(options.rotation_origin(bounds));
|
||||
if !center.x.is_finite() || !center.y.is_finite() || !xf.rect.contains(center) {
|
||||
return None;
|
||||
}
|
||||
let radius = (bounds.height().abs() * xf.zoom * 0.24).clamp(76.0, 116.0) as f32;
|
||||
let (s, c) = (xf.rot as f32).sin_cos();
|
||||
let screen = |p: [f64; 3], scale: f32| vec2(c * p[0] as f32 - s * p[1] as f32, s * p[0] as f32 + c * p[1] as f32) * radius * scale;
|
||||
let rings = std::array::from_fn(|i| {
|
||||
let [u, v] = options.rotation_planes()[i];
|
||||
let scale = if i == 2 { 1.12 } else { 1.0 };
|
||||
Ring { center: Vec2::ZERO, u: screen(u, scale), v: screen(v, scale), depth: [u[2] as f32, v[2] as f32] }
|
||||
});
|
||||
Some(Self { center, radius, rings, xf, bounds })
|
||||
}
|
||||
|
||||
fn hit(self, p: Pos2) -> Option<(usize, f32)> {
|
||||
let mut best = None;
|
||||
let mut distance = 9.0;
|
||||
for (axis, ring) in self.rings.iter().enumerate() {
|
||||
for i in 0..SAMPLES {
|
||||
let a = i as f32 * std::f32::consts::TAU / SAMPLES as f32;
|
||||
let b = a + std::f32::consts::TAU / SAMPLES as f32;
|
||||
let (start, end) = (self.center + ring.point(a), self.center + ring.point(b));
|
||||
let line = end - start;
|
||||
let t = ((p - start).dot(line) / line.length_sq().max(1e-6)).clamp(0.0, 1.0);
|
||||
let d = p.distance(start + line * t);
|
||||
if d < distance {
|
||||
distance = d;
|
||||
best = Some((axis, a + (b - a) * t));
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn light_rings(self, options: Revolve) -> [Ring; 2] {
|
||||
let (s, c) = (self.xf.rot as f32).sin_cos();
|
||||
let screen = |p: [f64; 3]| vec2(c * p[0] as f32 - s * p[1] as f32, s * p[0] as f32 + c * p[1] as f32) * self.radius * 1.4;
|
||||
options.light_orbits().map(|[center, u, v]| Ring { center: screen(center), u: screen(u), v: screen(v), depth: [u[2] as f32, v[2] as f32] })
|
||||
}
|
||||
|
||||
fn light_labels(self) -> [Rect; 2] {
|
||||
[
|
||||
Rect::from_center_size(self.center + vec2(0.0, self.radius * 1.4 + 24.0), vec2(88.0, 26.0)),
|
||||
Rect::from_center_size(self.center - vec2(self.radius * 1.4 + 32.0, 0.0), vec2(78.0, 26.0)),
|
||||
]
|
||||
}
|
||||
|
||||
fn light_hit(self, p: Pos2, options: Revolve) -> Option<usize> {
|
||||
if let Some(axis) = self.light_labels().iter().position(|r| r.contains(p)) {
|
||||
return Some(axis);
|
||||
}
|
||||
let mut best = None;
|
||||
let mut distance = 9.0;
|
||||
for (axis, ring) in self.light_rings(options).iter().enumerate() {
|
||||
let (start, span) = if axis == 0 { (0.0, std::f32::consts::TAU) } else { (-std::f32::consts::FRAC_PI_2, std::f32::consts::PI) };
|
||||
for i in 0..SAMPLES {
|
||||
let a = start + i as f32 * span / SAMPLES as f32;
|
||||
let (from, to) = (self.center + ring.point(a), self.center + ring.point(a + span / SAMPLES as f32));
|
||||
let line = to - from;
|
||||
let t = ((p - from).dot(line) / line.length_sq().max(1e-6)).clamp(0.0, 1.0);
|
||||
let d = p.distance(from + line * t);
|
||||
if d < distance {
|
||||
distance = d;
|
||||
best = Some(axis);
|
||||
}
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Mode {
|
||||
Ring { axis: usize, ring: Ring, phase: f32, degrees: f64 },
|
||||
Orbit,
|
||||
Light,
|
||||
LightRing { axis: usize },
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Drag {
|
||||
mode: Mode,
|
||||
geometry: Geometry,
|
||||
last: Pos2,
|
||||
origin: Pos2,
|
||||
fields: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
fn wrap(n: f64, snap: bool) -> f64 {
|
||||
let n = if snap { (n / 15.0).round() * 15.0 } else { n };
|
||||
(n + 180.0).rem_euclid(360.0) - 180.0
|
||||
}
|
||||
|
||||
pub(super) fn show(app: &VectorcraftApp, ctx: &egui::Context, d: &mut Dialog) {
|
||||
let light_mode = lighting(d);
|
||||
if !d.bool("preview") || (light_mode && !d.bool("shade")) {
|
||||
clear(ctx);
|
||||
return;
|
||||
}
|
||||
let Some(geometry) = Geometry::new(app, d) else { return };
|
||||
let geometry = ctx.data(|m| m.get_temp::<Drag>(drag_id())).map_or(geometry, |drag| drag.geometry);
|
||||
let mut rect = Rect::from_center_size(geometry.center, Vec2::splat(geometry.radius * 3.0 + 48.0));
|
||||
if light_mode {
|
||||
for label in geometry.light_labels() {
|
||||
rect = rect.union(label.expand(2.0));
|
||||
}
|
||||
}
|
||||
egui::Area::new(Id::new("revolve-gizmo-area")).order(egui::Order::Foreground).fixed_pos(rect.min).movable(false).show(ctx, |ui| {
|
||||
ui.set_clip_rect(geometry.xf.rect);
|
||||
let response = ui.allocate_rect(rect.intersect(geometry.xf.rect), Sense::drag());
|
||||
let options = revolve_options(&super::form::params(d));
|
||||
let light = light_position(geometry, options);
|
||||
let hovered = response
|
||||
.hovered()
|
||||
.then(|| response.hover_pos())
|
||||
.flatten()
|
||||
.and_then(|p| if light_mode { geometry.light_hit(p, options).map(|axis| (axis, 0.0)) } else { geometry.hit(p) });
|
||||
if response.drag_started()
|
||||
&& let Some(origin) = ui.input(|i| i.pointer.press_origin())
|
||||
{
|
||||
let mode = if light_mode {
|
||||
if origin.distance(light) <= 18.0 {
|
||||
Mode::Light
|
||||
} else if let Some(axis) = geometry.light_hit(origin, options) {
|
||||
Mode::LightRing { axis }
|
||||
} else {
|
||||
Mode::Light
|
||||
}
|
||||
} else if origin.distance(geometry.center) <= 14.0 {
|
||||
Mode::Orbit
|
||||
} else if let Some((axis, phase)) = geometry.hit(origin) {
|
||||
Mode::Ring { axis, ring: geometry.rings[axis], phase, degrees: 0.0 }
|
||||
} else {
|
||||
Mode::Orbit
|
||||
};
|
||||
ui.ctx().memory_mut(|m| m.stop_text_input());
|
||||
ui.data_mut(|m| m.insert_temp(drag_id(), Drag { mode, geometry, last: origin, origin, fields: d.fields.clone() }));
|
||||
}
|
||||
let mut drag = ui.data(|m| m.get_temp::<Drag>(drag_id()));
|
||||
if let Some(current) = &mut drag {
|
||||
if ui.input_mut(|i| i.consume_key(i.modifiers, egui::Key::Escape)) {
|
||||
d.fields = current.fields.clone();
|
||||
d.fields.remove(super::form::PREVIEWED);
|
||||
ui.data_mut(|m| m.remove::<Drag>(drag_id()));
|
||||
drag = None;
|
||||
} else if let Some(p) = response.interact_pointer_pos() {
|
||||
let snap = ui.input(|i| i.modifiers.shift);
|
||||
match &mut current.mode {
|
||||
Mode::Ring { axis, ring, phase, degrees } => {
|
||||
let step = if let Some(next) = ring.phase(p - geometry.center) {
|
||||
let step = (next - *phase + std::f32::consts::PI).rem_euclid(std::f32::consts::TAU) - std::f32::consts::PI;
|
||||
*phase = next;
|
||||
step
|
||||
} else {
|
||||
let tangent = ring.tangent(*phase);
|
||||
let step = (p - current.last).dot(tangent) / tangent.length_sq().max(144.0);
|
||||
*phase += step;
|
||||
step
|
||||
};
|
||||
*degrees += f64::from(step.to_degrees());
|
||||
if let Some(key) = KEYS.get(*axis) {
|
||||
let start = current.fields.get(*key).and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
d.fields.insert((*key).into(), json!(wrap(start + *degrees, snap)));
|
||||
}
|
||||
}
|
||||
Mode::Orbit => {
|
||||
let delta = geometry.xf.delta_to_doc(p - current.origin) * geometry.xf.zoom;
|
||||
for (key, delta) in [("rotationX", delta.y * 0.5), ("rotationY", delta.x * 0.5)] {
|
||||
let start = current.fields.get(key).and_then(|v| v.as_f64()).unwrap_or(0.0);
|
||||
d.fields.insert(key.into(), json!(wrap(start + delta, snap)));
|
||||
}
|
||||
}
|
||||
Mode::Light => {
|
||||
let start = revolve_options(&serde_json::Value::Object(current.fields.clone()));
|
||||
let projected = light_position(geometry, start) + (p - current.origin);
|
||||
let point = geometry.xf.delta_to_doc(projected - geometry.center) * (geometry.xf.zoom / f64::from(geometry.radius * 1.4));
|
||||
let [azimuth, elevation] = start.light_at_on_side(Point::new(point.x, point.y));
|
||||
d.fields.insert("lightAzimuth".into(), json!(wrap(azimuth, snap)));
|
||||
d.fields.insert(
|
||||
"lightElevation".into(),
|
||||
json!((if snap { (elevation / 15.0).round() * 15.0 } else { elevation }).clamp(-90.0, 90.0)),
|
||||
);
|
||||
}
|
||||
Mode::LightRing { axis } => {
|
||||
let delta = geometry.xf.delta_to_doc(p - current.origin) * geometry.xf.zoom;
|
||||
let start = revolve_options(&serde_json::Value::Object(current.fields.clone()));
|
||||
if *axis == 0 {
|
||||
d.fields.insert("lightAzimuth".into(), json!(wrap(start.light_azimuth + delta.x * 0.6, snap)));
|
||||
} else {
|
||||
let elevation = start.light_elevation - delta.y * 0.6;
|
||||
d.fields.insert(
|
||||
"lightElevation".into(),
|
||||
json!((if snap { (elevation / 15.0).round() * 15.0 } else { elevation }).clamp(-90.0, 90.0)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
current.last = p;
|
||||
ui.data_mut(|m| m.insert_temp(drag_id(), current.clone()));
|
||||
}
|
||||
}
|
||||
if response.drag_stopped() || !ui.input(|i| i.pointer.primary_down()) {
|
||||
ui.data_mut(|m| m.remove::<Drag>(drag_id()));
|
||||
}
|
||||
if response.hovered() || drag.is_some() {
|
||||
ui.ctx().set_cursor_icon(if drag.is_some() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });
|
||||
}
|
||||
let active = match drag.as_ref().map(|d| d.mode) {
|
||||
Some(Mode::Ring { axis, .. }) => Some(axis),
|
||||
Some(Mode::LightRing { axis }) => Some(axis),
|
||||
_ => hovered.map(|h| h.0),
|
||||
};
|
||||
paint(ui, d, Geometry::new(app, d).unwrap_or(geometry), active, drag.as_ref());
|
||||
response.on_hover_text(hint(d));
|
||||
});
|
||||
}
|
||||
|
||||
fn light_position(g: Geometry, options: Revolve) -> Pos2 {
|
||||
let [x, y, _] = options.light_direction();
|
||||
let (s, c) = (g.xf.rot as f32).sin_cos();
|
||||
g.center + vec2(c * x as f32 - s * y as f32, s * x as f32 + c * y as f32) * g.radius * 1.4
|
||||
}
|
||||
|
||||
fn paint(ui: &egui::Ui, d: &Dialog, g: Geometry, active: Option<usize>, drag: Option<&Drag>) {
|
||||
if lighting(d) {
|
||||
paint_light(ui, d, g, active, drag);
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let painter = ui.painter();
|
||||
let colors = [t.axis_x, t.axis_y, t.axis_z];
|
||||
for (axis, ring) in g.rings.iter().enumerate() {
|
||||
let color = if active == Some(axis) { t.warning } else { colors[axis] };
|
||||
for i in 0..SAMPLES {
|
||||
let angle = i as f32 * std::f32::consts::TAU / SAMPLES as f32;
|
||||
let behind = ring.depth[0] * angle.cos() + ring.depth[1] * angle.sin() < -0.05;
|
||||
if behind && i % 3 == 0 {
|
||||
continue;
|
||||
}
|
||||
let points = [g.center + ring.point(angle), g.center + ring.point(angle + std::f32::consts::TAU / SAMPLES as f32)];
|
||||
painter.line_segment(points, Stroke::new(4.0, t.input.gamma_multiply(0.55)));
|
||||
painter.line_segment(
|
||||
points,
|
||||
Stroke::new(if active == Some(axis) { 2.5 } else { 1.75 }, color.gamma_multiply(if behind { 0.55 } else { 1.0 })),
|
||||
);
|
||||
}
|
||||
// Letters make the axes distinguishable without relying on colour alone.
|
||||
let phase = match axis {
|
||||
0 => 0.75,
|
||||
1 => 2.25,
|
||||
_ => 4.7,
|
||||
};
|
||||
let p = g.center + ring.point(phase);
|
||||
painter.circle_filled(p, 9.0, t.panel);
|
||||
painter.text(p, Align2::CENTER_CENTER, ["X", "Y", "Z"][axis], FontId::proportional(11.0), color);
|
||||
}
|
||||
painter.circle(g.center, 8.0, t.panel.gamma_multiply(0.8), Stroke::new(1.5, t.text_dim));
|
||||
painter.line_segment([g.center - vec2(4.0, 0.0), g.center + vec2(4.0, 0.0)], Stroke::new(1.0, t.text_dim));
|
||||
painter.line_segment([g.center - vec2(0.0, 4.0), g.center + vec2(0.0, 4.0)], Stroke::new(1.0, t.text_dim));
|
||||
let options = revolve_options(&super::form::params(d));
|
||||
if d.bool("__showAxis") {
|
||||
painter.line_segment(options.projected_axis(g.bounds).map(|p| g.xf.to_screen(p)), Stroke::new(1.0, t.warning));
|
||||
}
|
||||
if let Some(Drag { mode: Mode::Ring { axis, .. }, .. }) = drag
|
||||
&& let Some(key) = KEYS.get(*axis)
|
||||
{
|
||||
let text = format!("{} {:.1}°", ["X", "Y", "Z"].get(*axis).unwrap_or(&""), d.f64(key, 0.0));
|
||||
let pos = g.center + vec2(0.0, g.radius * 1.12 + 18.0);
|
||||
let galley = painter.layout_no_wrap(text, FontId::proportional(12.0), t.text_strong);
|
||||
painter.rect_filled(Rect::from_center_size(pos, galley.size() + vec2(14.0, 8.0)), 4.0, t.panel);
|
||||
painter.galley(pos - galley.size() * 0.5, galley, t.text_strong);
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_light(ui: &egui::Ui, d: &Dialog, g: Geometry, active: Option<usize>, drag: Option<&Drag>) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let painter = ui.painter();
|
||||
let options = revolve_options(&super::form::params(d));
|
||||
let radius = g.radius * 1.4;
|
||||
painter.circle_stroke(g.center, radius, Stroke::new(1.0, t.text_dim.gamma_multiply(0.3)));
|
||||
let labels = g.light_labels();
|
||||
for (axis, ring) in g.light_rings(options).iter().enumerate() {
|
||||
let (start, span) = if axis == 0 { (0.0, std::f32::consts::TAU) } else { (-std::f32::consts::FRAC_PI_2, std::f32::consts::PI) };
|
||||
for i in 0..SAMPLES {
|
||||
let a = start + i as f32 * span / SAMPLES as f32;
|
||||
let behind = ring.depth[0] * a.cos() + ring.depth[1] * a.sin() < -0.05;
|
||||
if behind && i % 3 == 0 {
|
||||
continue;
|
||||
}
|
||||
let points = [g.center + ring.point(a), g.center + ring.point(a + span / SAMPLES as f32)];
|
||||
painter.line_segment(points, Stroke::new(4.0, t.panel.gamma_multiply(0.4)));
|
||||
painter.line_segment(
|
||||
points,
|
||||
Stroke::new(if active == Some(axis) { 2.5 } else { 1.5 }, t.warning.gamma_multiply(if behind { 0.45 } else { 0.85 })),
|
||||
);
|
||||
}
|
||||
let label = labels[axis];
|
||||
painter.rect_filled(label, 4.0, if active == Some(axis) { t.hover } else { t.panel });
|
||||
let text = if axis == 0 { tl!("Direction") } else { tl!("Elevation") };
|
||||
painter.text(label.center(), Align2::CENTER_CENTER, text, FontId::proportional(11.0), t.warning);
|
||||
}
|
||||
let p = light_position(g, options);
|
||||
let delta = g.center - p;
|
||||
if delta.length() > 24.0 {
|
||||
let direction = delta.normalized();
|
||||
let normal = vec2(-direction.y, direction.x);
|
||||
let tip = g.center - direction * 12.0;
|
||||
painter.line_segment([p, tip], Stroke::new(1.25, t.warning.gamma_multiply(0.6)));
|
||||
painter.line_segment([tip, tip - direction * 8.0 + normal * 4.0], Stroke::new(1.25, t.warning));
|
||||
painter.line_segment([tip, tip - direction * 8.0 - normal * 4.0], Stroke::new(1.25, t.warning));
|
||||
}
|
||||
painter.circle(g.center, 4.0, t.panel, Stroke::new(1.0, t.text_dim));
|
||||
// An original sun handle. A back light is hollow; the guide's back half is dashed.
|
||||
let front = options.light_direction()[2] >= 0.0;
|
||||
painter.circle(p, 9.0, if front { t.warning } else { t.panel }, Stroke::new(2.0, t.warning));
|
||||
for i in 0..8 {
|
||||
let angle = i as f32 * std::f32::consts::TAU / 8.0;
|
||||
let direction = vec2(angle.cos(), angle.sin());
|
||||
painter.line_segment([p + direction * 13.0, p + direction * 17.0], Stroke::new(1.5, t.warning));
|
||||
}
|
||||
let name = crate::i18n::tr_ctx(crate::i18n::current(), "lighting", "Light");
|
||||
let galley = painter.layout_no_wrap(name.to_string(), FontId::proportional(11.0), t.warning);
|
||||
let name_pos = p - vec2(galley.size().x * 0.5, 25.0 + galley.size().y);
|
||||
painter.rect_filled(Rect::from_min_size(name_pos - vec2(4.0, 2.0), galley.size() + vec2(8.0, 4.0)), 3.0, t.panel);
|
||||
painter.galley(name_pos, galley, t.warning);
|
||||
if drag.is_some() {
|
||||
let text = format!("{} {:.1}° {} {:.1}°", tl!("Direction"), d.f64("lightAzimuth", -45.0), tl!("Elevation"), d.f64("lightElevation", 45.0));
|
||||
let galley = painter.layout_no_wrap(text, FontId::proportional(11.5), t.text_strong);
|
||||
let pos = g.center + vec2(0.0, radius + 56.0);
|
||||
painter.rect_filled(Rect::from_center_size(pos, galley.size() + vec2(12.0, 6.0)), 4.0, t.panel);
|
||||
painter.galley(pos - galley.size() * 0.5, galley, t.text_strong);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests_revolve_gizmo.rs"]
|
||||
mod tests;
|
||||
|
|
@ -228,6 +228,26 @@ fn effect_dialog_applies_to_its_appearance_item() {
|
|||
assert!(n.appearance.effects.is_empty() && n.appearance.items[0].effects().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revolve_dialog_preview_cancel_confirm_and_undo() {
|
||||
let mut app = app();
|
||||
app.run("path.create", json!({"d":"M150 40 L180 60 L180 140 L150 160"})).unwrap();
|
||||
app.run("select.all", json!({})).unwrap();
|
||||
let before = app.session.doc().unwrap().doc.clone();
|
||||
app.run("effect.dialog", json!({"effect":"threeD.revolve","item":null})).unwrap();
|
||||
frame(&mut app, Default::default());
|
||||
assert!(app.session.in_interaction());
|
||||
cancel(&mut app);
|
||||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||||
app.run("effect.dialog", json!({"effect":"threeD.revolve","item":null})).unwrap();
|
||||
app.ui.dialog.as_mut().unwrap().fields.insert("angle".into(), json!(180));
|
||||
frame(&mut app, Default::default());
|
||||
confirm(&mut app).unwrap();
|
||||
assert_ne!(app.session.doc().unwrap().doc, before);
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dialogs_are_as_tall_as_their_content() {
|
||||
// The save prompt, shown after a taller dialog, is as tall as its text and buttons: no empty
|
||||
|
|
|
|||
|
|
@ -0,0 +1,201 @@
|
|||
//! Real pointer interaction with Revolve's compact controls.
|
||||
use super::*;
|
||||
use crate::{dialogs, theme};
|
||||
use egui::{Event, PointerButton, Rect, Vec2, pos2};
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
struct Harness {
|
||||
app: VectorcraftApp,
|
||||
ctx: egui::Context,
|
||||
screen: Vec2,
|
||||
texts: Vec<(String, Rect)>,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
fn new(screen: Vec2) -> Self {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width":300,"height":300})).unwrap();
|
||||
app.run("path.create", json!({"d":"M150 40 L180 60 L180 140 L150 160"})).unwrap();
|
||||
app.run("paint.setFill", json!({"color":"#338ad6"})).unwrap();
|
||||
app.run("effect.dialog", json!({"effect":REVOLVE,"item":null})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, Default::default());
|
||||
Self { app, ctx, screen, texts: vec![] }
|
||||
}
|
||||
|
||||
fn frame(&mut self, events: Vec<Event>) {
|
||||
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, self.screen)), events, ..Default::default() };
|
||||
let mut out = self.ctx.run_ui(input, |ui| dialogs::show(&mut self.app, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
fn collect(shape: &egui::Shape, clip: Rect, texts: &mut Vec<(String, Rect)>) {
|
||||
match shape {
|
||||
egui::Shape::Text(t) if clip.contains_rect(t.visual_bounding_rect()) => {
|
||||
texts.push((t.galley.text().to_string(), t.visual_bounding_rect()))
|
||||
}
|
||||
egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| collect(s, clip, texts)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.texts.clear();
|
||||
out.shapes.iter().for_each(|s| collect(&s.shape, s.clip_rect, &mut self.texts));
|
||||
}
|
||||
|
||||
fn settle(&mut self) {
|
||||
for _ in 0..5 {
|
||||
self.frame(vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
fn rect(&self, text: &str) -> Rect {
|
||||
self.texts.iter().find(|(s, _)| s == text).map(|(_, r)| *r).unwrap_or_else(|| panic!("missing text {text:?}"))
|
||||
}
|
||||
|
||||
fn response(&self, id: impl std::hash::Hash + std::fmt::Debug) -> Rect {
|
||||
self.ctx.read_response(Id::new(id)).unwrap().rect
|
||||
}
|
||||
|
||||
fn click(&mut self, p: Pos2) {
|
||||
self.frame(vec![Event::PointerMoved(p), button(p, true)]);
|
||||
self.frame(vec![button(p, false)]);
|
||||
self.settle();
|
||||
}
|
||||
|
||||
fn drag(&mut self, p: Pos2, delta: Vec2) {
|
||||
self.frame(vec![Event::PointerMoved(p), button(p, true)]);
|
||||
for i in 1..=5 {
|
||||
self.frame(vec![Event::PointerMoved(p + delta * (i as f32 / 5.0))]);
|
||||
}
|
||||
self.frame(vec![button(p + delta, false)]);
|
||||
self.settle();
|
||||
}
|
||||
|
||||
fn value(&self, key: &str) -> f64 {
|
||||
self.app.ui.dialog.as_ref().unwrap().f64(key, 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn button(pos: Pos2, pressed: bool) -> Event {
|
||||
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values_labels_sliders_and_reset_dots_change_the_live_effect_in_one_undo_step() {
|
||||
let mut h = Harness::new(vec2(1280.0, 900.0));
|
||||
let before = h.app.session.doc().unwrap().doc.clone();
|
||||
h.settle();
|
||||
assert!(!h.rect("Advanced").is_negative(), "Advanced is visible without scrolling in a normal window");
|
||||
let panel = h.ctx.memory(|m| m.area_rect(Id::new(("dialog", "effect")).with("revolve"))).unwrap();
|
||||
assert!(panel.width() < 420.0 && panel.height() < 540.0, "compact controls: {panel:?}");
|
||||
let label = h.rect("Rotation X");
|
||||
h.drag(pos2(label.left() + LABEL_W + 6.0 + VALUE_W / 2.0, label.center().y), vec2(40.0, 0.0));
|
||||
assert!(h.value("rotationX") > 0.0, "the value itself drags");
|
||||
let label = h.rect("Rotation Y");
|
||||
h.drag(label.center(), vec2(40.0, 0.0));
|
||||
assert!(h.value("rotationY") > 0.0, "the label scrubs");
|
||||
let label = h.rect("Revolve angle");
|
||||
h.click(pos2(label.left() + LABEL_W + VALUE_W + 24.0, label.center().y));
|
||||
assert!(h.value("angle") < 180.0, "the slider changes the sweep");
|
||||
h.click(h.response(("revolve-reset", "rotationX")).center());
|
||||
assert_eq!(h.value("rotationX"), 0.0);
|
||||
assert!(h.value("angle") < 180.0, "a dot resets only its own setting");
|
||||
let node = h.app.session.doc().unwrap().doc.node(h.app.session.doc().unwrap().selection.objects[0]).unwrap();
|
||||
assert_eq!(node.appearance.effects[0].params["angle"], json!(h.value("angle")));
|
||||
h.click(h.response("revolve-reset-all").center());
|
||||
let defaults = vectorcraft_effects::default_params(REVOLVE).unwrap();
|
||||
let dialog = h.app.ui.dialog.as_ref().unwrap();
|
||||
for (key, value) in defaults.as_object().unwrap() {
|
||||
assert_eq!(dialog.fields.get(key), Some(value), "reset all: {key}");
|
||||
}
|
||||
dialogs::confirm(&mut h.app).unwrap();
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_off_changes_fields_without_changing_the_document() {
|
||||
let mut h = Harness::new(vec2(1280.0, 900.0));
|
||||
h.app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(false));
|
||||
let before = h.app.session.doc().unwrap().doc.clone();
|
||||
h.settle();
|
||||
h.drag(h.rect("Rotation Y").center(), vec2(40.0, 0.0));
|
||||
assert!(h.value("rotationY") > 0.0);
|
||||
assert_eq!(h.app.session.doc().unwrap().doc, before);
|
||||
dialogs::cancel(&mut h.app);
|
||||
assert_eq!(h.app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_driven_fields_preview_and_small_windows_keep_the_buttons_reachable() {
|
||||
let mut h = Harness::new(vec2(640.0, 480.0));
|
||||
h.settle();
|
||||
let dialog = h.app.ui.dialog.as_mut().unwrap();
|
||||
dialog.fields.insert("angle".into(), json!(180));
|
||||
h.settle();
|
||||
let state = h.app.session.doc().unwrap();
|
||||
let node = state.doc.node(state.selection.objects[0]).unwrap();
|
||||
assert_eq!(node.appearance.effects[0].params["angle"], json!(180));
|
||||
let screen = Rect::from_min_size(Pos2::ZERO, h.screen);
|
||||
assert!(screen.contains_rect(h.rect("OK")) && screen.contains_rect(h.rect("Cancel")));
|
||||
dialogs::cancel(&mut h.app);
|
||||
assert!(!h.app.session.in_interaction());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lighting_tab_shows_light_fields_and_keeps_the_dialog_compact() {
|
||||
let mut h = Harness::new(vec2(1280.0, 900.0));
|
||||
h.settle();
|
||||
h.click(h.rect("Lighting").center());
|
||||
assert_eq!(h.app.ui.dialog.as_ref().unwrap().str("__gizmo"), "light");
|
||||
for label in ["Shading", "Direction", "Elevation", "Intensity", "Ambient"] {
|
||||
assert!(Rect::from_min_size(Pos2::ZERO, h.screen).contains_rect(h.rect(label)));
|
||||
}
|
||||
let panel = h.ctx.memory(|m| m.area_rect(Id::new(("dialog", "effect")).with("revolve"))).unwrap();
|
||||
assert!(panel.width() < 420.0 && panel.height() < 460.0, "compact lighting controls: {panel:?}");
|
||||
h.drag(h.rect("Direction").center(), vec2(24.0, 0.0));
|
||||
assert_ne!(h.value("lightAzimuth"), -45.0);
|
||||
h.click(h.response(("revolve-reset", "lightAzimuth")).center());
|
||||
assert_eq!(h.value("lightAzimuth"), -45.0);
|
||||
h.click(h.rect("Rotation").center());
|
||||
assert_eq!(h.app.ui.dialog.as_ref().unwrap().str("__gizmo"), "rotation");
|
||||
assert!(!h.rect("Rotation X").is_negative());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_visibility_option_is_saved_resettable_and_leaves_preview_geometry_complete() {
|
||||
let mut h = Harness::new(vec2(1280.0, 1000.0));
|
||||
h.settle();
|
||||
h.click(h.rect("Advanced").center());
|
||||
let panel = h.ctx.memory(|m| m.area_rect(Id::new(("dialog", "effect")).with("revolve"))).unwrap();
|
||||
h.frame(vec![
|
||||
Event::PointerMoved(panel.center()),
|
||||
Event::MouseWheel {
|
||||
unit: egui::MouseWheelUnit::Point,
|
||||
delta: vec2(0.0, -220.0),
|
||||
phase: egui::TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
},
|
||||
]);
|
||||
h.settle();
|
||||
let before = vectorcraft_effects::revolve_art({
|
||||
let state = h.app.session.doc().unwrap();
|
||||
state.doc.node(state.selection.objects[0]).unwrap()
|
||||
})
|
||||
.unwrap();
|
||||
let checkbox = h.rect("Keep visible surfaces only when expanding");
|
||||
h.click(checkbox.center());
|
||||
assert!(!h.app.ui.dialog.as_ref().unwrap().bool("expandVisibleOnly"));
|
||||
let state = h.app.session.doc().unwrap();
|
||||
let node = state.doc.node(state.selection.objects[0]).unwrap();
|
||||
assert_eq!(node.appearance.effects[0].params["expandVisibleOnly"], json!(false));
|
||||
assert_eq!(vectorcraft_effects::revolve_art(node), Some(before));
|
||||
h.click(h.response(("revolve-reset", "expandVisibleOnly")).center());
|
||||
assert!(h.app.ui.dialog.as_ref().unwrap().bool("expandVisibleOnly"));
|
||||
h.click(h.rect("Keep visible surfaces only when expanding").center());
|
||||
h.click(h.response("revolve-reset-all").center());
|
||||
assert!(h.app.ui.dialog.as_ref().unwrap().bool("expandVisibleOnly"));
|
||||
dialogs::confirm(&mut h.app).unwrap();
|
||||
let state = h.app.session.doc().unwrap();
|
||||
let loaded = vectorcraft_format::load(&vectorcraft_format::save(&state.doc, false)).unwrap();
|
||||
assert_eq!(loaded.node(state.selection.objects[0]).unwrap().appearance.effects[0].params["expandVisibleOnly"], json!(true));
|
||||
}
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
//! Pointer gestures exercise the real artboard overlay and command preview, including rollback.
|
||||
use super::*;
|
||||
use crate::{dialogs, theme};
|
||||
use egui::{Event, Modifiers, PointerButton};
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
struct Harness {
|
||||
app: VectorcraftApp,
|
||||
ctx: egui::Context,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
fn new() -> Self {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width":300,"height":300})).unwrap();
|
||||
app.run("path.create", json!({"d":"M150 40 L180 60 L180 140 L150 160"})).unwrap();
|
||||
app.run("paint.setFill", json!({"color":"#338ad6"})).unwrap();
|
||||
app.run("effect.dialog", json!({"effect":REVOLVE,"item":null})).unwrap();
|
||||
app.canvas_rect = Some(Rect::from_min_size(Pos2::ZERO, vec2(1280.0, 900.0)));
|
||||
let v = app.view_mut().unwrap();
|
||||
v.center = Point::new(270.0, 150.0);
|
||||
v.zoom = 2.0;
|
||||
v.fitted = true;
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, Default::default());
|
||||
let mut h = Self { app, ctx };
|
||||
h.settle();
|
||||
h
|
||||
}
|
||||
|
||||
fn frame(&mut self, events: Vec<Event>, modifiers: Modifiers) {
|
||||
let events = std::iter::once(Event::ModifiersChanged(modifiers)).chain(events).collect();
|
||||
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1280.0, 900.0))), events, ..Default::default() };
|
||||
let mut out = self.ctx.run_ui(input, |ui| {
|
||||
crate::shortcuts::handle(&mut self.app, ui.ctx());
|
||||
egui::CentralPanel::default().show(ui, |ui| crate::canvas::show(&mut self.app, ui));
|
||||
dialogs::show(&mut self.app, ui.ctx());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
fn settle(&mut self) {
|
||||
for _ in 0..5 {
|
||||
self.frame(vec![], Modifiers::NONE);
|
||||
}
|
||||
}
|
||||
|
||||
fn geometry(&self) -> Geometry {
|
||||
Geometry::new(&self.app, self.app.ui.dialog.as_ref().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn value(&self, key: &str) -> f64 {
|
||||
self.app.ui.dialog.as_ref().unwrap().f64(key, 0.0)
|
||||
}
|
||||
|
||||
fn start(&mut self, p: Pos2, modifiers: Modifiers) {
|
||||
self.frame(vec![Event::PointerMoved(p)], modifiers);
|
||||
self.frame(vec![button(p, true, modifiers)], modifiers);
|
||||
}
|
||||
|
||||
fn release(&mut self, p: Pos2, modifiers: Modifiers) {
|
||||
self.frame(vec![button(p, false, modifiers)], modifiers);
|
||||
self.settle();
|
||||
}
|
||||
|
||||
fn ring_drag(&mut self, axis: usize, modifiers: Modifiers) {
|
||||
let g = self.geometry();
|
||||
let ring = g.rings[axis];
|
||||
// Choose a visible point belonging only to this ring, away from crossings.
|
||||
let phase = (0..SAMPLES)
|
||||
.map(|i| i as f32 * std::f32::consts::TAU / SAMPLES as f32)
|
||||
.find(|a| g.hit(g.center + ring.point(*a)).is_some_and(|h| h.0 == axis) && ring.tangent(*a).length() > 40.0)
|
||||
.unwrap();
|
||||
let from = g.center + ring.point(phase);
|
||||
self.start(from, modifiers);
|
||||
let mut p = from;
|
||||
for i in 1..=8 {
|
||||
p = g.center + ring.point(phase + i as f32 * 0.045);
|
||||
self.frame(vec![Event::PointerMoved(p)], modifiers);
|
||||
}
|
||||
self.release(p, modifiers);
|
||||
}
|
||||
}
|
||||
|
||||
fn button(pos: Pos2, pressed: bool, modifiers: Modifiers) -> Event {
|
||||
Event::PointerButton { pos, pressed, button: PointerButton::Primary, modifiers }
|
||||
}
|
||||
fn escape() -> Event {
|
||||
Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_ring_changes_only_its_rotation_field_and_cancel_restores_the_source() {
|
||||
for (axis, key) in KEYS.iter().enumerate() {
|
||||
let mut h = Harness::new();
|
||||
let before = h.app.session.active().unwrap().interaction.as_ref().unwrap().doc.clone();
|
||||
let original = KEYS.map(|key| h.value(key));
|
||||
h.ring_drag(axis, Modifiers::NONE);
|
||||
for (k, key) in KEYS.iter().enumerate() {
|
||||
if k == axis {
|
||||
assert!(h.value(key) > original[k] + 10.0, "axis {axis}, {}", h.value(key));
|
||||
} else {
|
||||
assert_eq!(h.value(key), original[k]);
|
||||
}
|
||||
}
|
||||
let state = h.app.session.active().unwrap();
|
||||
let node = state.doc.node(state.selection.objects[0]).unwrap();
|
||||
assert_eq!(node.path_data(), before.node(node.id).unwrap().path_data(), "ring drags must not move source anchors");
|
||||
assert_eq!(node.appearance.effects[0].params[*key], json!(h.value(key)));
|
||||
dialogs::cancel(&mut h.app);
|
||||
assert_eq!(h.app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ring_drag_snaps_and_confirm_is_one_undo_step() {
|
||||
let mut h = Harness::new();
|
||||
let before = h.app.session.active().unwrap().interaction.as_ref().unwrap().doc.clone();
|
||||
h.ring_drag(2, Modifiers::SHIFT);
|
||||
assert!(h.value("rotationZ") > 0.0);
|
||||
assert_eq!(h.value("rotationZ") % 15.0, 0.0);
|
||||
dialogs::confirm(&mut h.app).unwrap();
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_orbit_and_light_drag_update_the_canvas_and_escape_restores_only_the_gesture() {
|
||||
let mut h = Harness::new();
|
||||
let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap());
|
||||
let center = h.geometry().center;
|
||||
h.start(center, Modifiers::NONE);
|
||||
let p = center + vec2(50.0, 30.0);
|
||||
h.frame(vec![Event::PointerMoved(p)], Modifiers::NONE);
|
||||
assert_ne!(h.value("rotationX"), before["rotationX"].as_f64().unwrap());
|
||||
assert_ne!(h.value("rotationY"), before["rotationY"].as_f64().unwrap());
|
||||
h.frame(vec![escape()], Modifiers::NONE);
|
||||
h.release(p, Modifiers::NONE);
|
||||
assert!(h.app.ui.dialog.is_some());
|
||||
assert_eq!(super::super::form::params(h.app.ui.dialog.as_ref().unwrap()), before);
|
||||
let state = h.app.session.active().unwrap();
|
||||
let node = state.doc.node(state.selection.objects[0]).unwrap();
|
||||
assert_eq!(node.appearance.effects[0].params["rotationX"], before["rotationX"]);
|
||||
assert_eq!(node.appearance.effects[0].params["rotationY"], before["rotationY"]);
|
||||
h.app.ui.dialog.as_mut().unwrap().fields.insert("__gizmo".into(), json!("light"));
|
||||
h.settle();
|
||||
let light = light_position(h.geometry(), revolve_options(&before));
|
||||
h.start(light, Modifiers::NONE);
|
||||
let p = light + vec2(20.0, 25.0);
|
||||
h.frame(vec![Event::PointerMoved(p)], Modifiers::NONE);
|
||||
h.release(p, Modifiers::NONE);
|
||||
assert_ne!(h.value("lightAzimuth"), -45.0);
|
||||
assert_ne!(h.value("lightElevation"), 45.0);
|
||||
h.frame(vec![escape()], Modifiers::NONE);
|
||||
assert!(h.app.ui.dialog.is_none() && !h.app.session.in_interaction());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_off_has_no_gizmo_and_edge_on_rings_remain_finite() {
|
||||
let mut h = Harness::new();
|
||||
let d = h.app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("rotationX".into(), json!(0.0));
|
||||
d.fields.insert("rotationY".into(), json!(0.0));
|
||||
d.fields.insert("rotationZ".into(), json!(0.0));
|
||||
h.settle();
|
||||
h.ring_drag(0, Modifiers::NONE);
|
||||
assert!(h.value("rotationX").is_finite() && h.value("rotationX").abs() > 1.0);
|
||||
h.app.ui.dialog.as_mut().unwrap().fields.insert("preview".into(), json!(false));
|
||||
h.settle();
|
||||
assert!(!h.app.session.in_interaction());
|
||||
let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap());
|
||||
h.ring_drag(2, Modifiers::NONE);
|
||||
assert_eq!(super::super::form::params(h.app.ui.dialog.as_ref().unwrap()), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_rings_adjust_only_their_angle_and_snap_without_rotating_the_object() {
|
||||
for (axis, key) in ["lightAzimuth", "lightElevation"].iter().enumerate() {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.dialog.as_mut().unwrap().fields.insert("__gizmo".into(), json!("light"));
|
||||
h.settle();
|
||||
let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap());
|
||||
let from = h.geometry().light_labels()[axis].center();
|
||||
h.start(from, Modifiers::SHIFT);
|
||||
let delta = if axis == 0 { vec2(68.0, 0.0) } else { vec2(0.0, -43.0) };
|
||||
h.frame(vec![Event::PointerMoved(from + delta)], Modifiers::SHIFT);
|
||||
h.release(from + delta, Modifiers::SHIFT);
|
||||
assert_ne!(h.value(key), before[*key].as_f64().unwrap());
|
||||
assert_eq!(h.value(key) % 15.0, 0.0);
|
||||
for unchanged in ["rotationX", "rotationY", "rotationZ", "lightIntensity", "ambient"] {
|
||||
assert_eq!(h.value(unchanged), before[unchanged].as_f64().unwrap());
|
||||
}
|
||||
let other = if axis == 0 { "lightElevation" } else { "lightAzimuth" };
|
||||
assert_eq!(h.value(other), before[other].as_f64().unwrap());
|
||||
let state = h.app.session.active().unwrap();
|
||||
let node = state.doc.node(state.selection.objects[0]).unwrap();
|
||||
assert_eq!(node.appearance.effects[0].params[*key], json!(h.value(key)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_handle_preserves_the_back_side_and_escape_cancel_and_undo_restore_changes() {
|
||||
let mut h = Harness::new();
|
||||
let original = h.app.session.active().unwrap().interaction.as_ref().unwrap().doc.clone();
|
||||
let d = h.app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("__gizmo".into(), json!("light"));
|
||||
d.fields.insert("lightAzimuth".into(), json!(135.0));
|
||||
h.settle();
|
||||
let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap());
|
||||
let light = light_position(h.geometry(), revolve_options(&before));
|
||||
h.start(light, Modifiers::NONE);
|
||||
let to = light + vec2(-15.0, 12.0);
|
||||
h.frame(vec![Event::PointerMoved(to)], Modifiers::NONE);
|
||||
assert!(revolve_options(&super::super::form::params(h.app.ui.dialog.as_ref().unwrap())).light_direction()[2] < 0.0);
|
||||
assert_ne!(h.value("lightElevation"), 45.0);
|
||||
h.frame(vec![escape()], Modifiers::NONE);
|
||||
h.release(to, Modifiers::NONE);
|
||||
assert_eq!(super::super::form::params(h.app.ui.dialog.as_ref().unwrap()), before);
|
||||
let state = h.app.session.active().unwrap();
|
||||
assert_eq!(state.doc.node(state.selection.objects[0]).unwrap().appearance.effects[0].params["lightAzimuth"], before["lightAzimuth"]);
|
||||
// A second gesture is kept by OK, and the whole dialog is one undo step.
|
||||
let from = h.geometry().light_labels()[0].center();
|
||||
h.start(from, Modifiers::NONE);
|
||||
h.frame(vec![Event::PointerMoved(from + vec2(35.0, 0.0))], Modifiers::NONE);
|
||||
h.release(from + vec2(35.0, 0.0), Modifiers::NONE);
|
||||
dialogs::confirm(&mut h.app).unwrap();
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc().unwrap().doc, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_elevation_is_bounded_and_shading_off_disables_the_light_gizmo() {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.dialog.as_mut().unwrap().fields.insert("__gizmo".into(), json!("light"));
|
||||
h.settle();
|
||||
let from = h.geometry().light_labels()[1].center();
|
||||
h.start(from, Modifiers::NONE);
|
||||
h.frame(vec![Event::PointerMoved(from - vec2(0.0, 400.0))], Modifiers::NONE);
|
||||
h.release(from - vec2(0.0, 400.0), Modifiers::NONE);
|
||||
assert_eq!(h.value("lightElevation"), 90.0);
|
||||
h.app.ui.dialog.as_mut().unwrap().fields.insert("shade".into(), json!(false));
|
||||
h.settle();
|
||||
let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap());
|
||||
let from = h.geometry().light_labels()[0].center();
|
||||
h.start(from, Modifiers::NONE);
|
||||
h.frame(vec![Event::PointerMoved(from + vec2(40.0, 0.0))], Modifiers::NONE);
|
||||
h.release(from + vec2(40.0, 0.0), Modifiers::NONE);
|
||||
assert_eq!(super::super::form::params(h.app.ui.dialog.as_ref().unwrap()), before);
|
||||
}
|
||||
|
|
@ -694,3 +694,32 @@
|
|||
Ocean Ripple… Vlnky na hladině…
|
||||
Rename Data Set Přejmenovat sadu dat
|
||||
Rename Variable Přejmenovat proměnnou
|
||||
|
||||
# Interactive Revolve controls.
|
||||
Lighting Osvětlení
|
||||
Revolve angle Úhel otočení
|
||||
Axis side Strana osy
|
||||
Rotation X Otočení X
|
||||
Rotation Y Otočení Y
|
||||
Rotation Z Otočení Z
|
||||
Shading Stínování
|
||||
Direction Směr
|
||||
Elevation Výška světla
|
||||
Intensity Intenzita
|
||||
Ambient Okolní osvětlení
|
||||
Segments Segmenty
|
||||
Show axis Zobrazit osu
|
||||
Reset all Obnovit vše
|
||||
Reset to default Obnovit výchozí hodnotu
|
||||
Reset all Revolve settings to their defaults. Obnovit výchozí hodnoty všech nastavení rotačního tělesa.
|
||||
Drag values or labels. Double-click a value to type. Upravte hodnoty přetažením čísel nebo popisků. Dvojím kliknutím na hodnotu ji zadáte.
|
||||
Drag rings to rotate. Shift snaps to 15°. Otáčejte přetažením prstenců. Shift nastaví kroky po 15°.
|
||||
Drag the light, or its rings. Shift snaps to 15°. Přetáhněte světlo nebo jeho prstence. Shift nastaví kroky po 15°.
|
||||
lighting Light Světlo
|
||||
Keep visible surfaces only when expanding Při rozšíření ponechat pouze viditelné povrchy
|
||||
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Při rozšíření vzhledu oříznout zakrytou geometrii. Živý náhled zachová celý povrch. Průhledné výplně a povrchy překračující limity výpočtu viditelnosti zachovají všechny plochy.
|
||||
Left Vlevo
|
||||
Right Vpravo
|
||||
Offset Odsazení
|
||||
Rotation Otočení
|
||||
Cancel Zrušit
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3994,3 +3994,25 @@ theme Appearance Erscheinungsbild
|
|||
Define a variable first Definieren Sie zuerst eine Variable
|
||||
Text Text
|
||||
Visibility Sichtbarkeit
|
||||
# 3D and Materials › Revolve: its options dialog and gizmos.
|
||||
Ambient Umgebungslicht
|
||||
Axis side Achsenseite
|
||||
Direction Richtung
|
||||
Drag rings to rotate. Shift snaps to 15°. Ziehen Sie die Ringe, um zu drehen. Mit Shift in 15°-Schritten.
|
||||
Drag the light, or its rings. Shift snaps to 15°. Ziehen Sie das Licht oder seine Ringe. Mit Shift in 15°-Schritten.
|
||||
Drag values or labels. Double-click a value to type. Ziehen Sie Werte oder Beschriftungen. Doppelklicken Sie auf einen Wert, um ihn einzugeben.
|
||||
Elevation Höhe
|
||||
Intensity Intensität
|
||||
Keep visible surfaces only when expanding Beim Umwandeln nur sichtbare Flächen behalten
|
||||
Lighting Beleuchtung
|
||||
Reset all Alle zurücksetzen
|
||||
Reset all Revolve settings to their defaults. Alle Einstellungen für „Kreiseln“ auf ihre Standardwerte zurücksetzen.
|
||||
Reset to default Auf Standard zurücksetzen
|
||||
Revolve angle Kreiselwinkel
|
||||
Rotation X Drehung X
|
||||
Rotation Y Drehung Y
|
||||
Rotation Z Drehung Z
|
||||
Segments Segmente
|
||||
Shading Schattierung
|
||||
Show axis Achse einblenden
|
||||
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Entfernt bei „Aussehen umwandeln“ verdeckte Geometrie. Die Live-Vorschau behält die ganze Oberfläche. Transparente Farben und Oberflächen jenseits der Sichtbarkeitsgrenzen behalten alle Flächen.
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -4037,3 +4037,28 @@ axis Both Ambos
|
|||
Define a variable first Defina primero una variable
|
||||
Text Texto
|
||||
Visibility Visibilidad
|
||||
|
||||
# Revolve profile guidance.
|
||||
|
||||
# Interactive Revolve controls.
|
||||
Lighting Iluminación
|
||||
Revolve angle Ángulo de revolución
|
||||
Axis side Lado del eje
|
||||
Rotation X Rotación X
|
||||
Rotation Y Rotación Y
|
||||
Rotation Z Rotación Z
|
||||
Shading Sombreado
|
||||
Direction Dirección
|
||||
Elevation Elevación
|
||||
Ambient Luz ambiental
|
||||
Segments Segmentos
|
||||
Show axis Mostrar eje
|
||||
Reset all Restablecer todo
|
||||
Reset to default Restablecer valor predeterminado
|
||||
Reset all Revolve settings to their defaults. Restablece todos los ajustes de revolución a sus valores predeterminados.
|
||||
Drag values or labels. Double-click a value to type. Arrastra los valores o las etiquetas. Haz doble clic en un valor para escribir.
|
||||
Drag rings to rotate. Shift snaps to 15°. Arrastra los anillos para girar. Shift ajusta a pasos de 15°.
|
||||
Drag the light, or its rings. Shift snaps to 15°. Arrastra la luz o sus anillos. Shift ajusta a pasos de 15°.
|
||||
lighting Light Luz
|
||||
Keep visible surfaces only when expanding Conservar solo superficies visibles al expandir
|
||||
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Recorta la geometría oculta al expandir la apariencia. La vista previa conserva toda la superficie. Las pinturas transparentes y las superficies que superan los límites de visibilidad conservan todas las caras.
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -4049,3 +4049,26 @@ effect Soft Doux
|
|||
Define a variable first Définissez d’abord une variable
|
||||
Text Texte
|
||||
Visibility Visibilité
|
||||
|
||||
# Interactive Revolve controls.
|
||||
Lighting Éclairage
|
||||
Revolve angle Angle de révolution
|
||||
Axis side Côté de l’axe
|
||||
Rotation X Rotation X
|
||||
Rotation Y Rotation Y
|
||||
Rotation Z Rotation Z
|
||||
Shading Ombrage
|
||||
Direction Direction
|
||||
Elevation Élévation
|
||||
Ambient Lumière ambiante
|
||||
Segments Segments
|
||||
Show axis Afficher l’axe
|
||||
Reset all Tout réinitialiser
|
||||
Reset to default Valeur par défaut
|
||||
Reset all Revolve settings to their defaults. Rétablir les valeurs par défaut de tous les réglages de révolution.
|
||||
Drag values or labels. Double-click a value to type. Faites glisser les valeurs ou les libellés. Double-cliquez sur une valeur pour la saisir.
|
||||
Drag rings to rotate. Shift snaps to 15°. Faites glisser les anneaux pour tourner. Maj contraint par pas de 15°.
|
||||
Drag the light, or its rings. Shift snaps to 15°. Faites glisser la lumière ou ses anneaux. Maj contraint par pas de 15°.
|
||||
Keep visible surfaces only when expanding Conserver uniquement les surfaces visibles lors de la décomposition
|
||||
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Découpe la géométrie masquée lors de la décomposition de l’aspect. L’aperçu conserve toute la surface. Les peintures transparentes et les surfaces dépassant les limites de visibilité conservent toutes les faces.
|
||||
lighting Light Lumière
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -4039,3 +4039,28 @@ effect Regular Normale
|
|||
Define a variable first Definisci prima una variabile
|
||||
Text Testo
|
||||
Visibility Visibilità
|
||||
|
||||
# Revolve profile guidance.
|
||||
|
||||
# Interactive Revolve controls.
|
||||
Lighting Illuminazione
|
||||
Revolve angle Angolo di rivoluzione
|
||||
Axis side Lato dell’asse
|
||||
Rotation X Rotazione X
|
||||
Rotation Y Rotazione Y
|
||||
Rotation Z Rotazione Z
|
||||
Shading Ombreggiatura
|
||||
Direction Direzione
|
||||
Elevation Elevazione
|
||||
Ambient Luce ambientale
|
||||
Segments Segmenti
|
||||
Show axis Mostra asse
|
||||
Reset all Ripristina tutto
|
||||
Reset to default Ripristina valore predefinito
|
||||
Reset all Revolve settings to their defaults. Ripristina tutte le impostazioni di rivoluzione ai valori predefiniti.
|
||||
Drag values or labels. Double-click a value to type. Trascina i valori o le etichette. Fai doppio clic su un valore per digitarlo.
|
||||
Drag rings to rotate. Shift snaps to 15°. Trascina gli anelli per ruotare. Shift usa incrementi di 15°.
|
||||
Drag the light, or its rings. Shift snaps to 15°. Trascina la luce o i suoi anelli. Shift usa incrementi di 15°.
|
||||
lighting Light Luce
|
||||
Keep visible surfaces only when expanding Mantieni solo le superfici visibili durante l’espansione
|
||||
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Ritaglia la geometria coperta durante Espandi aspetto. L’anteprima mantiene la superficie completa. Le tinte trasparenti e le superfici che superano i limiti di visibilità mantengono tutte le facce.
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -4032,3 +4032,28 @@ effect Vertical 垂直
|
|||
Define a variable first 先に変数を定義してください
|
||||
Text テキスト
|
||||
Visibility 表示状態
|
||||
|
||||
# Revolve profile guidance.
|
||||
|
||||
# Interactive Revolve controls.
|
||||
Lighting 照明
|
||||
Revolve angle 回転体の角度
|
||||
Axis side 軸の位置
|
||||
Rotation X X 軸回転
|
||||
Rotation Y Y 軸回転
|
||||
Rotation Z Z 軸回転
|
||||
Shading 陰影
|
||||
Direction 方向
|
||||
Elevation 仰角
|
||||
Ambient 環境光
|
||||
Segments 分割数
|
||||
Show axis 軸を表示
|
||||
Reset all すべてリセット
|
||||
Reset to default 初期値に戻す
|
||||
Reset all Revolve settings to their defaults. 回転体のすべての設定を初期値に戻します。
|
||||
Drag values or labels. Double-click a value to type. 数値やラベルをドラッグして調整。数値をダブルクリックすると入力できます。
|
||||
Drag rings to rotate. Shift snaps to 15°. リングをドラッグして回転。Shiftで15°刻みに調整。
|
||||
Drag the light, or its rings. Shift snaps to 15°. ライトやリングをドラッグ。Shiftで15°刻みに調整。
|
||||
lighting Light ライト
|
||||
Keep visible surfaces only when expanding アピアランス分割時に見える面だけを保持
|
||||
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. アピアランスの分割時に隠れた部分を除去します。プレビューでは面全体を保持します。透明なペイントや可視性処理の制限を超える面は、すべての面を保持します。
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -2754,3 +2754,30 @@
|
|||
Ocean Ripple… Ondulação oceânica…
|
||||
Rename Data Set Renomear conjunto de dados
|
||||
Rename Variable Renomear variável
|
||||
|
||||
# Interactive Revolve controls.
|
||||
Lighting Iluminação
|
||||
Revolve angle Ângulo de giro
|
||||
Axis side Lado do eixo
|
||||
Rotation X Rotação X
|
||||
Rotation Y Rotação Y
|
||||
Rotation Z Rotação Z
|
||||
Shading Sombreamento
|
||||
Direction Direção
|
||||
Elevation Elevação
|
||||
Intensity Intensidade
|
||||
Ambient Luz ambiente
|
||||
Segments Segmentos
|
||||
Show axis Mostrar eixo
|
||||
Reset all Redefinir tudo
|
||||
Reset to default Restaurar padrão
|
||||
Reset all Revolve settings to their defaults. Restaurar os valores padrão de todas as configurações de revolução.
|
||||
Drag values or labels. Double-click a value to type. Arraste os valores ou rótulos. Clique duas vezes em um valor para digitá-lo.
|
||||
Drag rings to rotate. Shift snaps to 15°. Arraste os anéis para girar. Shift ajusta em incrementos de 15°.
|
||||
Drag the light, or its rings. Shift snaps to 15°. Arraste a luz ou seus anéis. Shift ajusta em incrementos de 15°.
|
||||
lighting Light Luz
|
||||
Keep visible surfaces only when expanding Manter apenas as superfícies visíveis ao expandir
|
||||
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Recortar a geometria coberta ao expandir a aparência. A visualização ao vivo mantém toda a superfície. Pinturas transparentes e superfícies que excedem os limites de visibilidade mantêm todas as faces.
|
||||
Left Esquerda
|
||||
Right Direita
|
||||
Offset Deslocamento
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -4035,3 +4035,28 @@ axis Both Оба
|
|||
Define a variable first Сначала определите переменную
|
||||
Text Текст
|
||||
Visibility Видимость
|
||||
|
||||
# Revolve profile guidance.
|
||||
|
||||
# Interactive Revolve controls.
|
||||
Lighting Освещение
|
||||
Revolve angle Угол вращения профиля
|
||||
Axis side Сторона оси
|
||||
Rotation X Поворот X
|
||||
Rotation Y Поворот Y
|
||||
Rotation Z Поворот Z
|
||||
Shading Затенение
|
||||
Direction Направление
|
||||
Elevation Высота
|
||||
Ambient Фоновое освещение
|
||||
Segments Сегменты
|
||||
Show axis Показать ось
|
||||
Reset all Сбросить всё
|
||||
Reset to default Вернуть значение по умолчанию
|
||||
Reset all Revolve settings to their defaults. Возвращает все настройки вращения профиля к значениям по умолчанию.
|
||||
Drag values or labels. Double-click a value to type. Перетаскивайте значения или подписи. Для ввода дважды щёлкните значение.
|
||||
Drag rings to rotate. Shift snaps to 15°. Перетаскивайте кольца для вращения. Shift задаёт шаг 15°.
|
||||
Drag the light, or its rings. Shift snaps to 15°. Перетаскивайте источник света или кольца. Shift задаёт шаг 15°.
|
||||
lighting Light Свет
|
||||
Keep visible surfaces only when expanding При разборе сохранять только видимые поверхности
|
||||
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Удаляет скрытую геометрию при разборе оформления. Предпросмотр сохраняет всю поверхность. Прозрачные краски и поверхности, превышающие ограничения видимости, сохраняют все грани.
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -4020,3 +4020,26 @@ axis Both Обидві
|
|||
Define a variable first Спочатку визначте змінну
|
||||
Text Текст
|
||||
Visibility Видимість
|
||||
|
||||
# Interactive Revolve controls.
|
||||
Lighting Освітлення
|
||||
Revolve angle Кут обертання
|
||||
Axis side Бік осі
|
||||
Rotation X Обертання X
|
||||
Rotation Y Обертання Y
|
||||
Rotation Z Обертання Z
|
||||
Shading Затінення
|
||||
Direction Напрямок
|
||||
Elevation Висота
|
||||
Ambient Навколишнє освітлення
|
||||
Segments Сегменти
|
||||
Show axis Показати вісь
|
||||
Reset all Скинути все
|
||||
Reset to default Відновити типове значення
|
||||
Reset all Revolve settings to their defaults. Відновити типові значення всіх параметрів обертання.
|
||||
Drag values or labels. Double-click a value to type. Перетягуйте значення або підписи. Двічі клацніть значення, щоб увести його.
|
||||
Drag rings to rotate. Shift snaps to 15°. Перетягуйте кільця для обертання. Shift задає крок 15°.
|
||||
Drag the light, or its rings. Shift snaps to 15°. Перетягуйте джерело світла або його кільця. Shift задає крок 15°.
|
||||
Keep visible surfaces only when expanding Під час розгортання зберігати лише видимі поверхні
|
||||
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. Обрізає приховану геометрію під час розгортання оформлення. Попередній перегляд зберігає всю поверхню. Прозорі заливки та поверхні, що перевищують обмеження видимості, зберігають усі грані.
|
||||
lighting Light Світло
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 13.
|
|
|
@ -3668,3 +3668,28 @@ effect Vertical 垂直
|
|||
Define a variable first 请先定义一个变量
|
||||
Text 文本
|
||||
Visibility 可见性
|
||||
|
||||
# Revolve profile guidance.
|
||||
|
||||
# Interactive Revolve controls.
|
||||
Lighting 照明
|
||||
Revolve angle 旋转成形角度
|
||||
Axis side 轴的位置
|
||||
Rotation X X 轴旋转
|
||||
Rotation Y Y 轴旋转
|
||||
Rotation Z Z 轴旋转
|
||||
Shading 明暗处理
|
||||
Direction 方向
|
||||
Elevation 仰角
|
||||
Ambient 环境光
|
||||
Segments 分段数
|
||||
Show axis 显示轴
|
||||
Reset all 全部重置
|
||||
Reset to default 重置为默认值
|
||||
Reset all Revolve settings to their defaults. 将所有旋转成形设置重置为默认值。
|
||||
Drag values or labels. Double-click a value to type. 拖动数值或标签。双击数值即可输入。
|
||||
Drag rings to rotate. Shift snaps to 15°. 拖动圆环旋转。按住 Shift 以 15° 为步长调整。
|
||||
Drag the light, or its rings. Shift snaps to 15°. 拖动光源或圆环。按住 Shift 以 15° 为步长调整。
|
||||
lighting Light 光源
|
||||
Keep visible surfaces only when expanding 扩展时仅保留可见表面
|
||||
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. 扩展外观时裁剪被遮挡的几何区域。实时预览保留完整表面。透明绘制或超出可见性处理限制的表面保留所有面。
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3449,3 +3449,28 @@ effect Soft 柔和
|
|||
Define a variable first 請先定義一個變數
|
||||
Text 文字
|
||||
Visibility 可見性
|
||||
|
||||
# Revolve profile guidance.
|
||||
|
||||
# Interactive Revolve controls.
|
||||
Lighting 照明
|
||||
Revolve angle 旋轉成形角度
|
||||
Axis side 軸的位置
|
||||
Rotation X X 軸旋轉
|
||||
Rotation Y Y 軸旋轉
|
||||
Rotation Z Z 軸旋轉
|
||||
Shading 明暗處理
|
||||
Direction 方向
|
||||
Elevation 仰角
|
||||
Ambient 環境光
|
||||
Segments 分段數
|
||||
Show axis 顯示軸
|
||||
Reset all 全部重設
|
||||
Reset to default 重設為預設值
|
||||
Reset all Revolve settings to their defaults. 將所有旋轉成形設定重設為預設值。
|
||||
Drag values or labels. Double-click a value to type. 拖曳數值或標籤。按兩下數值即可輸入。
|
||||
Drag rings to rotate. Shift snaps to 15°. 拖曳圓環旋轉。按住 Shift 以 15° 為間距調整。
|
||||
Drag the light, or its rings. Shift snaps to 15°. 拖曳光源或圓環。按住 Shift 以 15° 為間距調整。
|
||||
lighting Light 光源
|
||||
Keep visible surfaces only when expanding 展開時僅保留可見表面
|
||||
Trim covered geometry on Expand Appearance. Live preview keeps the full surface. Transparent paints and surfaces exceeding visibility limits retain all faces. 展開外觀時裁剪被遮擋的幾何區域。即時預覽保留完整表面。透明繪製或超出可見性處理限制的表面保留所有面。
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -3362,6 +3362,10 @@ fn effect_menu() -> Vec<Item> {
|
|||
};
|
||||
out.push(sub(sub_name, placeholder));
|
||||
} else {
|
||||
let mut items = items;
|
||||
if sub_name == "3D and Materials" {
|
||||
items.extend([todo("Extrude & Bevel…"), todo("Inflate…"), todo("Rotate…"), todo("Materials…")]);
|
||||
}
|
||||
out.push(sub(sub_name, items));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -213,11 +213,15 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
|||
return;
|
||||
}
|
||||
if app.ui.dialog.is_some() || app.ui.palette_open {
|
||||
if app.ui.dialog.as_ref().is_some_and(crate::dialogs::revolve_gizmo::active) && crate::dialogs::revolve_gizmo::dragging(ctx) {
|
||||
// Escape first restores this rotation gesture. The settings stay open.
|
||||
return;
|
||||
}
|
||||
if crate::shortcut_editor::is_recording(app) {
|
||||
return;
|
||||
}
|
||||
if ctx.input(|i| i.key_pressed(Key::Escape)) {
|
||||
app.ui.dialog = None;
|
||||
crate::dialogs::cancel(app);
|
||||
app.ui.palette_open = false;
|
||||
}
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -89,6 +89,10 @@ pub struct Tokens {
|
|||
pub error: Color32,
|
||||
/// Warning marks (a modified linked file).
|
||||
pub warning: Color32,
|
||||
/// Rotation gizmo axes, also labelled X, Y and Z.
|
||||
pub axis_x: Color32,
|
||||
pub axis_y: Color32,
|
||||
pub axis_z: Color32,
|
||||
}
|
||||
|
||||
impl Tokens {
|
||||
|
|
@ -133,6 +137,9 @@ impl Tokens {
|
|||
bleed: hex(0xf03030),
|
||||
error: hex(0xe34850),
|
||||
warning: hex(0xf0a330),
|
||||
axis_x: hex(0xef6058),
|
||||
axis_y: hex(0x45af63),
|
||||
axis_z: hex(0x378ef0),
|
||||
};
|
||||
match b {
|
||||
Brightness::Dark => base,
|
||||
|
|
|
|||
28
docs/mcp.md
28
docs/mcp.md
|
|
@ -819,6 +819,34 @@ default) white ones knock out instead; `"discard"` writes none.
|
|||
{"name":"run_command","arguments":{"command":"view.overprintPreview","params":{"on":true}}}
|
||||
```
|
||||
|
||||
## Revolve (initial 3D effect)
|
||||
|
||||
`effect.apply {effect: "threeD.revolve", params: {...}, ids?, item?}` applies a live Revolve to a path
|
||||
or compound path. `effect.dialog` opens compact options with artboard rotation and light gizmos, `effect.setParams` edits it, and
|
||||
`effect.expandAppearance` bakes it into shaded vector faces. Native saves keep the editable source;
|
||||
SVG/PDF exports bake the same faces the canvas draws. Only one Revolve is supported per profile.
|
||||
|
||||
Parameters: `angle` (degrees, 0..360, default 360), `offset` (points, 0..100000, default 0),
|
||||
`edge` (`"left"` or `"right"`), `rotationX/Y/Z` (degrees, defaults 0/0/0), `perspective`
|
||||
(0..100%, default 0), `segments` (8..128, default 64), `shade` (default true), `lightAzimuth`
|
||||
(degrees, default -45), `lightElevation` (degrees, default 45), `lightIntensity` (0..100%, default 80),
|
||||
`ambient` (0..100%, default 25), and `expandVisibleOnly` (boolean, default true). A vertical-line profile needs a positive offset to have a radius.
|
||||
Open profiles have no end caps. Visibility of intersecting surfaces is approximate.
|
||||
The dialog's Lighting tab manipulates the same `lightAzimuth` and `lightElevation` parameters;
|
||||
its guide visibility and tab choice are UI state, excluded from saved effect parameters.
|
||||
`expandVisibleOnly` is saved with the effect. It trims covered portions of opaque solid surfaces only
|
||||
when running `effect.expandAppearance`, preserving visible interiors through openings. Set it to false
|
||||
to expand all faces. Live preview and SVG/PDF baking keep the full surface. Transparent/unknown paint,
|
||||
non-normal blending, later geometry effects or bounded visibility failures preserve all faces.
|
||||
See [Revolve](revolve.md) for the scope and limitations.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"threeD.revolve","item":null,"params":{"angle":360,"offset":20,"edge":"left","rotationX":0,"rotationY":0,"rotationZ":0}}}}
|
||||
{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"angle":180,"lightAzimuth":30}}}}
|
||||
{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"expandVisibleOnly":true}}}}
|
||||
{"name":"run_command","arguments":{"command":"effect.expandAppearance","params":{}}}
|
||||
```
|
||||
|
||||
## Edit Colors and Recolor Artwork
|
||||
|
||||
`edit.colors.invert`, `edit.colors.toCMYK`, `edit.colors.toGrayscale`, `edit.colors.toRGB`, `edit.colors.saturate`,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
# Revolve
|
||||
|
||||
This initial implementation follows the scope agreed in [issue #605](https://github.com/storytold/vectorcraft/issues/605#issuecomment-6070543567).
|
||||
|
||||
Draw an open profile with the Pen tool, select it, then choose **Effect > 3D and Materials > Revolve**.
|
||||
The profile's horizontal distance from a vertical axis is its radius. **Axis side** chooses the left or right
|
||||
edge of the profile's bounds; **Offset** moves the axis outward. For a cylinder made from a vertical
|
||||
line, give it a nonzero offset. A vertical line at zero offset lies on the axis and has no surface.
|
||||
|
||||
**Revolve angle** sweeps 0 to 360 degrees. **Rotation X/Y/Z** rotates the viewing direction, with all three axes defaulting to 0 degrees. **Perspective**
|
||||
is zero for an orthographic view. **Segments** controls radial resolution (8 to 128 for a full turn).
|
||||
Lighting is a directional light with azimuth, elevation, intensity and ambient contribution.
|
||||
Turn **Shading** off to keep the source paint without lighting.
|
||||
|
||||
The dialog has **Rotation** and **Lighting** tabs. Rotation groups the profile and view controls. Drag a value or its label
|
||||
to adjust it, use its slider, or double-click the value to type. Angles show degrees and lighting
|
||||
values show percentages. **Segments** is in the **Advanced** section. Lighting controls are disabled
|
||||
when **Shading** is off. A filled dot at the end of a row means the value differs from its default;
|
||||
click the dot to reset that value. **Reset all** restores every Revolve parameter to its default.
|
||||
|
||||
The compact settings dialog opens beside the artboard. With **Preview** enabled, the selected
|
||||
object has X (red), Y (green) and Z (blue) rotation rings directly on the artboard. Drag a ring to
|
||||
adjust that axis; drag inside the rings to rotate freely. Hold Shift to snap to 15-degree increments.
|
||||
The rings and the rotation fields stay in sync. Escape during a drag restores that gesture and
|
||||
keeps the settings open. Escape again or Cancel restores the document before the dialog opened.
|
||||
The gizmo appears while editing Revolve; normal source-path selection returns when the dialog closes.
|
||||
|
||||
Select the **Lighting** tab to use the light gizmo on the artboard. Drag the sun handle to move
|
||||
its direction on the current hemisphere. A hollow sun marks a back light. Drag the **Direction**
|
||||
ring or its label horizontally to adjust azimuth; drag the **Elevation** arc or its label vertically
|
||||
to adjust elevation. The labels remain usable when a ring is viewed edge-on or the light is at a pole.
|
||||
Hold Shift to snap to 15-degree increments. Light gestures update their matching fields without
|
||||
rotating the object, and keep the same temporary preview, Escape, Cancel and undo behaviour.
|
||||
Light intensity and ambient contribution remain available as draggable fields and sliders.
|
||||
|
||||
**Shading** off hides the light gizmo and disables lighting controls. Switch to **Rotation** to return
|
||||
to the object rings. **Show axis** in **Advanced** displays the source's vertical axis on the object.
|
||||
Turning **Preview** off restores the original artwork and hides artboard controls until Preview is enabled.
|
||||
In small windows, the controls scroll while OK and Cancel stay visible. Reset dots restore individual
|
||||
settings; **Reset all** restores parameters on both tabs.
|
||||
|
||||
Preview changes are temporary until OK. Cancel restores the document; OK creates one undo step.
|
||||
The source anchors remain editable. Appearance lists the effect for reopening its options.
|
||||
SVG/PDF export evaluates filled vector faces; native saves retain the profile and effect parameters.
|
||||
**Object > Expand Appearance** replaces the live effect with ordinary editable vector faces.
|
||||
|
||||
In **Advanced**, **Keep visible surfaces only when expanding** is on by default. Expansion removes
|
||||
completely covered faces and trims partially covered ones using the current rotation and perspective.
|
||||
Visible interior surfaces through open ends remain. Uncheck it to retain every projected face.
|
||||
The checkbox has its own reset dot and is included in **Reset all**. Its saved command parameter is
|
||||
`expandVisibleOnly`; it does not change the live preview or SVG/PDF export baking.
|
||||
|
||||
Trimming applies to opaque solid paint. Transparent objects, gradients, patterns, non-normal blending,
|
||||
later geometry effects and surfaces exceeding bounded visibility limits keep their complete geometry
|
||||
to preserve their appearance. Visibility follows the same painter order as the live effect, including
|
||||
its approximation for intersecting surfaces. Expansion changes are undoable with the standard command.
|
||||
|
||||
## Initial limits
|
||||
|
||||
- Paths and compound paths only. Type, images and groups must be converted to paths first.
|
||||
- Open profiles produce uncapped surfaces. Closed profile boundaries are swept as provided.
|
||||
- One Revolve per source object. Edit its options rather than applying another.
|
||||
- Object-level Revolve uses the top visible fill, falling back to a stroke. Per-item Revolve uses that item's paint.
|
||||
- Flat shading, one directional light, no textures, cast shadows, end caps or physical materials.
|
||||
- Faces use back-to-front depth sorting. Intersecting profiles can have incorrect visibility.
|
||||
- Curves use sixteen samples per segment; profiles are limited to 1,024 samples and surfaces to 32,768 faces.
|
||||
- Axis, bounds and ordinary selection handles continue to refer to the editable 2D source profile.
|
||||
- Extrude & Bevel, Inflate, Rotate and Materials remain unimplemented.
|
||||
|
||||
## Architecture
|
||||
|
||||
`vectorcraft-three-d` depends only on `vectorcraft-geom`. It creates a surface of revolution,
|
||||
applies Euler view rotation, projects it, shades faces and sorts their depth. It has no UI or GPU dependency.
|
||||
`vectorcraft-effects` turns those faces into document art. The renderer, export baking and
|
||||
Expand Appearance use the same evaluator. The standard `effect.apply`, `effect.setParams`,
|
||||
`effect.remove` and `effect.expandAppearance` commands expose it to the CLI, control channel and MCP.
|
||||
|
||||
Behaviour reference: [public 3D effects documentation](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html).
|
||||
This implementation uses original mathematics and source code, with no Adobe software, assets or presets.
|
||||
|
|
@ -41,6 +41,7 @@ pub const TABLE: &[(&str, Class)] = &[
|
|||
("trace", Class::Layer(2)),
|
||||
("text", Class::Layer(2)),
|
||||
("effects", Class::Layer(2)),
|
||||
("three-d", Class::Layer(2)),
|
||||
("render", Class::Layer(3)),
|
||||
("svg", Class::Layer(3)),
|
||||
("pdf", Class::Layer(3)),
|
||||
|
|
@ -78,6 +79,7 @@ pub const INTRA_LAYER_ORDER: &[&[&str]] = &[
|
|||
// Non-native art in Illustrator editing data is a PDF, drawn by the PDF importer.
|
||||
&["pdf", "eps"],
|
||||
&["plugins", "effects"],
|
||||
&["three-d", "effects"],
|
||||
];
|
||||
|
||||
fn intra_layer_allowed(from: &str, to: &str) -> bool {
|
||||
|
|
|
|||
Loading…
Reference in New Issue