Merge pull request #6174 from dotCooCoo/feat/3d-classic-extrude-bevel

3D (Classic): Extrude & Bevel (Classic) extrudes art with Wireframe, No Shading, Diffuse and Plastic surfaces
This commit is contained in:
BFlat
2026-11-09 08:53:49 +08:00
committed by GitHub
57 changed files with 1318 additions and 64 deletions
+9 -1
View File
@@ -1,4 +1,4 @@
use super::{CROP_MARKS, EXTRUDE, INFLATE, PATHFINDER_EFFECTS, RASTERIZE, REVOLVE, ROTATE, TRAP, Value, json, trap};
use super::{CLASSIC_EXTRUDE, CROP_MARKS, EXTRUDE, INFLATE, PATHFINDER_EFFECTS, RASTERIZE, REVOLVE, ROTATE, TRAP, Value, json, trap};
/// Catalogue entry for one effect.
#[derive(Clone, Debug, PartialEq)]
@@ -80,6 +80,7 @@ fn lengths_of(id: &str) -> Lengths {
"path.offsetPath" => always(&["offset"]),
"threeD.revolve" => always(&["offset"]),
"threeD.extrude" => always(&["depth", "bevelWidth", "bevelHeight"]),
"threeD.classicExtrude" => always(&["depth", "bevelHeight"]),
"threeD.inflate" => always(&["depth"]),
"path.outlineStroke" => always(&["width"]),
"stylize.roundCorners" | "stylize.feather" | "blur.gaussian" | "blur.smart" | "sharpen.unsharpMask" => always(&["radius"]),
@@ -199,6 +200,13 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
"{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
),
g(
CLASSIC_EXTRUDE,
"Extrude & Bevel (Classic)…",
&["Effect", "3D and Materials", "3D (Classic)"],
"{depth: pt (0..2000, 50), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelHeight: pt (1..100, 4; the bevel is as wide as it is tall), bevelInside: bool (false: added around the shape; true: carved into it), rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), surface: wireframe|none|diffuse|plastic (plastic; wireframe draws the edges of every face, hidden ones included, and fills none; none keeps the paint unlit; diffuse lights the faces softly; plastic adds a glossy highlight), lights: null or array of 1..10 objects (intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, behind: bool; overrides the single-light fields; the lights add up; color and softness are not used), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 100), ambient: % (0..100, 50; Ambient Light, every face whichever way it faces), highlightIntensity: % (0..100, 60; plastic only), highlightSize: % (0..100, 90; 100 broad, 0 a small spot; plastic only), blendSteps: integer (1..256, 25; how many levels the shading and the highlight are rounded to), shadingColor: black|custom|none (black; the color the faces darken toward; none keeps the paint and adds only the highlight), shadingCustom: #rrggbb (#000000; the color shadingColor custom darkens toward), drawHiddenFaces: bool (false; also draws the faces turned away, behind the others)}. The front face is the art, extruded backwards about the center of its bounds; a solid is built for the top fill and for each stroke, each in its own paint. The defaults are VectorCraft's own.",
json!({"depth":50.0,"cap":true,"bevel":"none","bevelHeight":4.0,"bevelInside":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"surface":"plastic","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":100.0,"ambient":50.0,"highlightIntensity":60.0,"highlightSize":90.0,"blendSteps":25,"shadingColor":"black","shadingCustom":"#000000","drawHiddenFaces":false}),
),
g(
"convertToShape.rectangle",
"Rectangle…",
+3 -2
View File
@@ -85,8 +85,9 @@ pub use reshape::{
};
pub use revolve::{REVOLVE, has_revolve, revolve_art, revolve_options, validate_revolve};
pub use solids::{
EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SOLIDS, SolidMesh, THREE_D_TYPES, extrude_options, has_solid, inflate_options, is_three_d, solid_art,
three_d_meshes, three_d_meshes_of, three_d_params_as, validate_solid, validate_three_d, view_options,
CLASSIC_EXTRUDE, EXTRUDE, INFLATE, ROTATE, ROTATION_PRESETS, SHADING_COLORS, SOLIDS, SolidMesh, THREE_D_TYPES, classic_extrude_options,
classic_shading, extrude_options, has_solid, inflate_options, is_three_d, solid_art, three_d_meshes, three_d_meshes_of, three_d_params_as,
validate_solid, validate_three_d, validate_three_d_params, view_options,
};
pub use stylize::{ScribbleSetting, scribble_setting_id, scribble_settings};
pub use svg_filter::SVG_FILTER;
+8 -2
View File
@@ -66,19 +66,25 @@ pub fn revolve_options(p: &Value) -> Revolve {
/// 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> {
crate::lighting::validate(p)?;
validate_revolve_params(p)?;
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());
}
vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ())
}
/// Check Revolve's parameter values `p` alone: its lights, `edge` and `cap`.
pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> {
crate::lighting::validate(p)?;
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());
}
if p.get("cap").is_some_and(|v| v.as_bool().is_none()) {
return Err("Revolve cap must be a boolean".into());
}
vectorcraft_three_d::revolve(&path, b, revolve_options(p)).map(|_| ())
Ok(())
}
fn profile(n: &Node) -> Option<PathData> {
+32 -8
View File
@@ -1,5 +1,5 @@
//! Extrude & Bevel, Inflate and Rotate as live, shaded vector art, shared by the canvas and every
//! exporter. Like Revolve, the source path stays editable and the effect draws its 3D faces.
//! Extrude & Bevel, Inflate, Rotate and Extrude & Bevel (Classic) as live, shaded vector art,
//! shared by the canvas and every exporter. Like Revolve, the source path stays editable and the effect draws its 3D faces.
use std::sync::Arc;
use serde_json::Value;
@@ -14,11 +14,14 @@ use vectorcraft_doc::color::Color;
use crate::util::{flag, num, text};
use crate::{GeomContext, apply_geometry_with, is_geometry};
mod classic;
pub use classic::{CLASSIC_EXTRUDE, SHADING_COLORS, classic_extrude_options, classic_shading};
pub const EXTRUDE: &str = "threeD.extrude";
pub const INFLATE: &str = "threeD.inflate";
pub const ROTATE: &str = "threeD.rotate";
/// The 3D effects this module draws (Revolve has its own).
pub const SOLIDS: [&str; 3] = [EXTRUDE, INFLATE, ROTATE];
pub const SOLIDS: [&str; 4] = [EXTRUDE, INFLATE, ROTATE, CLASSIC_EXTRUDE];
/// Is `id` one of the 3D and Materials effects (Revolve included)?
pub fn is_three_d(id: &str) -> bool {
@@ -119,6 +122,7 @@ pub fn extrude_options(p: &Value) -> Extrude {
inside: flag(p, "bevelInside", false),
both_sides: flag(p, "bevelBothSides", false),
}),
hidden: false,
}
}
@@ -172,21 +176,32 @@ fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) ->
let view = view_options(p);
match id {
EXTRUDE => vectorcraft_three_d::extrude(path, rule, bounds, extrude_options(p), view),
CLASSIC_EXTRUDE => vectorcraft_three_d::extrude(path, rule, bounds, classic_extrude_options(p), view),
INFLATE => vectorcraft_three_d::inflate(path, rule, bounds, inflate_options(p), view),
_ => vectorcraft_three_d::plane(path, rule, bounds, view),
}
}
/// Validate before a command changes the document; the evaluator bounds loaded files too.
pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
/// Check solid `id`'s parameter values `p` alone: the part of [`validate_solid`] that does not
/// need the object.
fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> {
if id == CLASSIC_EXTRUDE {
classic::validate(p)?;
}
crate::lighting::validate(p)?;
let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…'));
if let Some(v) = p.get("bevel")
&& !v.as_str().is_some_and(|s| s == "none" || BevelShape::parse(s).is_some())
{
let names: Vec<_> = BevelShape::ALL.iter().map(|b| b.id()).collect();
return Err(format!("bevel must be none or one of {}", names.join(", ")));
}
Ok(())
}
/// Validate before a command changes the document; the evaluator bounds loaded files too.
pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
validate_solid_params(id, p)?;
let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…'));
if is_container(n) {
let shapes = members(n, label)?;
let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?;
@@ -273,6 +288,13 @@ pub fn validate_three_d(n: &Node, id: &str, p: &Value) -> Result<(), String> {
if id == crate::REVOLVE { crate::validate_revolve(n, p) } else { validate_solid(n, id, p) }
}
/// Are 3D effect `id`'s parameter values `p` valid? The checks of [`validate_three_d`] that do not
/// build the art, for an edit that hides the effect: hidden, it draws nothing, so its object does
/// not need to fit.
pub fn validate_three_d_params(id: &str, p: &Value) -> Result<(), String> {
if id == crate::REVOLVE { crate::revolve::validate_revolve_params(p) } else { validate_solid_params(id, p) }
}
/// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and
/// blend mode (so overlapping facets don't add up their transparency).
fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: BlendMode, colored: bool) -> Node {
@@ -384,7 +406,8 @@ fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile]) {
fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>> {
let b = union_bounds(shapes)?;
let p = crate::merged_params(&fx.id, &fx.params);
let colored = view_options(&p).has_colored_lights();
let classic = fx.id == CLASSIC_EXTRUDE;
let colored = !classic && view_options(&p).has_colored_lights();
if fx.id == ROTATE {
// Every part turns about the centre of the object's own bounds (a group's members about
// the centre of theirs), so they stay together.
@@ -432,7 +455,8 @@ fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>>
.map(|(item, member, f)| {
let depths: Vec<f64> = f.iter().map(|f| f.depth).collect();
let (paint, opacity, blend) = paint_of(item, member);
(depths, surface(n, f, &paint, opacity, blend, colored))
let art = if classic { classic::surface(n, f, &paint, opacity, blend, &p) } else { surface(n, f, &paint, opacity, blend, colored) };
(depths, art)
})
.collect();
// One order takes a plain group of faces from each solid, one face for each facet. With
+220
View File
@@ -0,0 +1,220 @@
//! Extrude & Bevel (Classic): the solid Extrude & Bevel builds from a path, drawn with the classic
//! surfaces (Wireframe, No Shading, Diffuse and Plastic), their highlight, blend steps and shading
//! color.
use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, StrokeLayer};
use vectorcraft_geom::Rect;
use vectorcraft_three_d::{Bevel, BevelShape, ClassicShading, ClassicSurface, Extrude, Facet, Light};
use crate::util::{flag, num, text};
pub const CLASSIC_EXTRUDE: &str = "threeD.classicExtrude";
/// The width of a Wireframe edge, in points.
const WIRE: f64 = 0.5;
/// The Shading Color choices: black, a custom color, or none.
pub const SHADING_COLORS: [&str; 3] = ["black", "custom", "none"];
/// The surface settings of Extrude & Bevel (Classic)'s parameters.
pub fn classic_shading(p: &Value) -> ClassicShading {
let d = ClassicShading::default();
ClassicShading {
surface: ClassicSurface::parse(text(p, "surface", "plastic")).unwrap_or_default(),
ambient: num(p, "ambient", d.ambient * 100.0) / 100.0,
highlight_intensity: num(p, "highlightIntensity", d.highlight_intensity * 100.0) / 100.0,
highlight_size: num(p, "highlightSize", d.highlight_size * 100.0) / 100.0,
blend_steps: num(p, "blendSteps", f64::from(d.blend_steps)).round().clamp(1.0, 256.0) as u32,
}
}
/// The solid of Extrude & Bevel (Classic)'s parameters: its depth, cap and bevel (as tall as it is
/// wide, added around the shape or carved into it), with the faces turned away from the viewer kept
/// for Draw Hidden Faces and for the Wireframe surface.
pub fn classic_extrude_options(p: &Value) -> Extrude {
let height = num(p, "bevelHeight", 4.0);
Extrude {
depth: num(p, "depth", 50.0),
cap: flag(p, "cap", true),
bevel: BevelShape::parse(text(p, "bevel", "none")).map(|shape| Bevel {
shape,
width: height,
height,
inside: flag(p, "bevelInside", false),
..Bevel::default()
}),
hidden: flag(p, "drawHiddenFaces", false) || classic_shading(p).surface == ClassicSurface::Wireframe,
..Extrude::default()
}
}
/// The color faces darken toward: black, the custom color, or none (faces keep their paint and only
/// the highlight lightens them).
fn shading_color(p: &Value) -> Option<Color> {
match text(p, "shadingColor", "black") {
"none" => None,
"custom" => Some(p.get("shadingCustom").and_then(Value::as_str).and_then(parse_hex).unwrap_or(Color::BLACK)),
_ => Some(Color::BLACK),
}
}
fn parse_hex(s: &str) -> Option<Color> {
(s.len() == 7 && s.starts_with('#') && s.bytes().skip(1).all(|b| b.is_ascii_hexdigit())).then(|| Color::from_hex(s)).flatten()
}
/// The lights: the `lights` list when it is given, else the one light of `lightRotation`,
/// `lightHeight`, `lightBehind` and `lightIntensity`. Each is its direction toward the light in view
/// space and its intensity (0..1).
fn lights(p: &Value) -> Vec<([f64; 3], f64)> {
if let Some(list) = crate::lighting::lights(p) {
return list.iter().map(|l| (l.direction(), l.intensity)).collect();
}
let light = Light {
rotation: num(p, "lightRotation", vectorcraft_three_d::DEFAULT_LIGHT.0),
height: num(p, "lightHeight", vectorcraft_three_d::DEFAULT_LIGHT.1),
behind: flag(p, "lightBehind", false),
..Light::default()
};
vec![(light.direction(), num(p, "lightIntensity", 100.0) / 100.0)]
}
/// The numeric parameters: (key, lowest, highest, whole numbers only).
const RANGES: [(&str, f64, f64, bool); 13] = [
("depth", 0.0, 2000.0, false),
("bevelHeight", 1.0, 100.0, false),
("rotationX", -180.0, 180.0, false),
("rotationY", -180.0, 180.0, false),
("rotationZ", -180.0, 180.0, false),
("perspectiveAngle", 0.0, 160.0, false),
("lightRotation", -180.0, 180.0, false),
("lightHeight", 0.0, 90.0, false),
("lightIntensity", 0.0, 100.0, false),
("ambient", 0.0, 100.0, false),
("highlightIntensity", 0.0, 100.0, false),
("highlightSize", 0.0, 100.0, false),
("blendSteps", 1.0, 256.0, true),
];
const FLAGS: [&str; 4] = ["cap", "bevelInside", "lightBehind", "drawHiddenFaces"];
/// Check Extrude & Bevel (Classic)'s parameters `p` (its defaults filled in): every key is one of
/// its parameters, numbers are in range, flags are true or false, and `surface`, `shadingColor` and
/// `shadingCustom` name a surface, a shading color and a #rrggbb color.
pub fn validate(p: &Value) -> Result<(), String> {
let Some(map) = p.as_object() else { return Ok(()) };
let defaults = crate::default_params(CLASSIC_EXTRUDE).unwrap_or(Value::Null);
let known: Vec<&str> = defaults.as_object().map(|m| m.keys().map(String::as_str).collect()).unwrap_or_default();
for key in map.keys() {
if !known.contains(&key.as_str()) {
let keys = known.join(", ");
return Err(format!("unknown parameter `{key}`; the command takes: {keys}"));
}
}
for (key, min, max, whole) in RANGES {
let Some(v) = map.get(key) else { continue };
let Some(x) = v.as_f64() else { return Err(format!("`{key}` must be a number")) };
if whole && (x.fract() != 0.0 || !(min..=max).contains(&x)) {
return Err(format!("`{key}` must be a whole number between {min} and {max} (got {x})"));
}
if !(min..=max).contains(&x) {
return Err(format!("`{key}` must be between {min} and {max} (got {x})"));
}
}
for key in FLAGS {
if map.get(key).is_some_and(|v| !v.is_boolean()) {
return Err(format!("`{key}` must be true or false"));
}
}
let one_of = |key: &str, ids: &[&str]| -> Result<(), String> {
match map.get(key) {
Some(v) if !v.as_str().is_some_and(|s| ids.contains(&s)) => Err(format!("`{key}` must be one of {}", ids.join(", "))),
_ => Ok(()),
}
};
let surfaces: Vec<&str> = ClassicSurface::ALL.iter().map(|s| s.id()).collect();
one_of("surface", &surfaces)?;
one_of("shadingColor", &SHADING_COLORS)?;
let key = "shadingCustom";
if map.get(key).is_some_and(|v| !v.as_str().is_some_and(|s| parse_hex(s).is_some())) {
return Err(format!("`{key}` must be a #rrggbb color"));
}
Ok(())
}
/// `c` with tone `tone` (0 the shading color, 1 the color itself) and highlight `spec` (toward
/// white) on top.
fn shaded(c: Color, tone: f64, spec: f64, shade: Option<Color>) -> Color {
let base = c.to_rgb();
let dark = shade.map_or(base, |s| s.to_rgb());
let (tone, spec) = (tone as f32, spec as f32);
let [r, g, b] = [0, 1, 2].map(|i| {
let mixed = dark[i] + (base[i] - dark[i]) * tone;
(mixed + (1.0 - mixed) * spec).clamp(0.0, 1.0)
});
Color::rgb(r, g, b)
}
/// One solid's faces in paint `paint`, as a group with the paint's opacity and blend mode, one child
/// for each facet: the edges of the faces on Wireframe, else each face filled in its paint shaded by
/// its tone and highlight. A gradient with transparent stops is shaded opaque and then masked by
/// its own transparency, so the shading follows the gradient's coverage.
pub(super) fn surface(n: &Node, facets: Vec<Facet>, source: &Paint, opacity: f32, blend: BlendMode, p: &Value) -> Node {
let shading = classic_shading(p);
let lights = lights(p);
let shade = shading_color(p);
let wire = shading.surface == ClassicSurface::Wireframe;
let (paint, mask) = if wire { (source.clone(), None) } else { crate::lighting::opaque_paint(source) };
let paint = &paint;
let children = facets
.into_iter()
.map(|face| {
if wire {
let mut edge = StrokeLayer::new(paint.clone(), WIRE);
edge.join = vectorcraft_doc::LineJoin::Round;
return Arc::new(Node::path(n.id, face.path(), Appearance { items: vec![AppearanceItem::Stroke(edge)], ..Default::default() }));
}
let (tone, spec) = shading.tone(face.normal, &lights);
// A narrow stroke of the face's own paint closes the hairline seams between faces.
let seam = (face.thickness() / 2.0).clamp(0.05, 0.75);
let items = match paint.color() {
// Unshaded (No Shading, no shading color, or a face in full light) with no
// highlight: the paint itself, its color space and swatch kept.
Some(_) if (tone >= 1.0 || shade.is_none()) && spec <= 0.0 => {
vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(StrokeLayer::new(paint.clone(), seam))]
}
Some(c) => {
let fill = Paint::solid(shaded(c, tone, spec, shade));
vec![AppearanceItem::Fill(FillLayer::new(fill.clone())), AppearanceItem::Stroke(StrokeLayer::new(fill, seam))]
}
// Gradients and patterns keep their paint, darkened by a layer of the shading color
// and lightened by one of white. As with the other 3D effects' pattern shadow, the
// layers cover the whole face, transparent pattern cells included.
None => {
let mut items =
vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Stroke(StrokeLayer::new(paint.clone(), seam))];
for (color, share) in [(shade, 1.0 - tone), (Some(Color::WHITE), spec)] {
if let Some(color) = color.filter(|_| share > 0.0) {
let mut layer = FillLayer::new(Paint::solid(color));
layer.opacity = share as f32;
items.push(AppearanceItem::Fill(layer));
}
}
items
}
};
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;
if let Some(mask) = mask {
let bounds = group.visual_bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0);
let coverage = Node::path(n.id, vectorcraft_geom::shapes::rectangle(bounds), Appearance::basic(mask, Paint::None, 0.0));
group.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(coverage, true)));
}
group
}
+164 -2
View File
@@ -45,10 +45,15 @@ fn every_solid_draws_shaded_faces_and_leaves_the_source_editable() {
assert_eq!(n.path_data(), Some(&source), "{id}");
assert!(!faces(&art).is_empty(), "{id}");
// The faces are the fill's red or the stroke's black, shaded: never brighter, never another
// hue.
// hue. The classic Plastic surface, its default, also lightens a face toward white, which
// scales every channel's distance from white by the same factor and so keeps r - g to
// r - b at 6 to 7.
for f in faces(&art) {
let [r, g, b] = fill_rgb(f);
assert!(r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4 || id == ROTATE, "{id}: {r} {g} {b}");
let shaded = r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4;
let highlighted =
id == CLASSIC_EXTRUDE && [r, g, b].iter().all(|c| (0.0..=1.0).contains(c)) && ((r - g) * 7.0 - (r - b) * 6.0).abs() < 1e-4;
assert!(shaded || highlighted || id == ROTATE, "{id}: {r} {g} {b}");
}
// The art follows the source when it moves.
let before = art.visual_bounds().unwrap();
@@ -595,3 +600,160 @@ fn a_3d_effect_on_a_group_turns_every_member_about_one_shared_axis() {
assert!(err.contains("clipping groups"), "{err}");
assert!(solid_art(&g).is_none(), "drawn as the group it is");
}
/// The red square without its stroke, with Extrude & Bevel (Classic) and `params`.
fn classic(params: Value) -> Node {
let mut n = square_node();
n.appearance.items.retain(|i| matches!(i, AppearanceItem::Fill(_)));
n.appearance.effects.push(fx(CLASSIC_EXTRUDE, params));
n
}
fn classic_faces(params: Value) -> Vec<Node> {
let art = solid_art(&classic(params)).unwrap();
faces(&art).into_iter().cloned().collect()
}
fn close3(a: [f32; 3], b: [f32; 3]) -> bool {
a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-4)
}
#[test]
fn classic_surfaces_draw_edges_flat_paint_soft_light_and_a_highlight() {
// Wireframe: the edges of every face, the hidden ones too, in the paint; no fills.
let wire = classic_faces(json!({"surface": "wireframe"}));
let plastic = classic_faces(json!({}));
assert_eq!(plastic.len(), 3, "a box shows three faces");
assert_eq!(wire.len(), 6, "and has six");
for f in &wire {
let [AppearanceItem::Stroke(s)] = &f.appearance.items[..] else { panic!("a stroke alone: {:?}", f.appearance.items) };
assert_eq!(s.paint, Paint::solid(RED));
}
// No Shading: every face in the art's own paint, a CMYK color kept as it is.
assert!(classic_faces(json!({"surface": "none"})).iter().all(|f| close3(fill_rgb(f), RED.to_rgb())));
let cmyk = Paint::solid(Color::Cmyk { c: 0.1, m: 0.9, y: 0.8, k: 0.0 });
let mut n = classic(json!({"surface": "none"}));
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() {
f.paint = cmyk.clone();
}
for f in faces(&solid_art(&n).unwrap()) {
let AppearanceItem::Fill(fill) = &f.appearance.items[0] else { panic!("a fill first") };
assert_eq!(fill.paint, cmyk);
}
// Diffuse: the red darkened toward black by how far each face turns from the light, so the
// faces differ but keep their hue.
let diffuse: Vec<[f32; 3]> = classic_faces(json!({"surface": "diffuse"})).iter().map(fill_rgb).collect();
assert!(diffuse.iter().all(|[r, g, b]| *r <= 0.8 + 1e-6 && (g * 4.0 - r).abs() < 1e-4 && (b * 8.0 - r).abs() < 1e-4), "{diffuse:?}");
assert!(diffuse.windows(2).any(|w| !close3(w[0], w[1])), "{diffuse:?}");
// Plastic: the same faces, one of them lightened toward white by the highlight.
let shiny: Vec<[f32; 3]> =
classic_faces(json!({"highlightIntensity": 100, "highlightSize": 100, "lightHeight": 60})).iter().map(fill_rgb).collect();
let soft: Vec<[f32; 3]> = classic_faces(json!({"surface": "diffuse", "lightHeight": 60})).iter().map(fill_rgb).collect();
assert!(shiny.iter().zip(&soft).any(|(s, d)| s[1] > d[1] + 0.01), "{shiny:?} {soft:?}");
assert!(shiny.iter().zip(&soft).all(|(s, d)| s[1] >= d[1] - 1e-4));
// Without highlight, Plastic shades as Diffuse.
let dull: Vec<[f32; 3]> = classic_faces(json!({"highlightIntensity": 0, "lightHeight": 60})).iter().map(fill_rgb).collect();
assert!(dull.iter().zip(&soft).all(|(a, b)| close3(*a, *b)));
}
#[test]
fn classic_shading_color_blend_steps_and_lights() {
let base = json!({"surface": "diffuse", "ambient": 20});
let with = |extra: Value| {
let mut p = base.clone();
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
classic_faces(p).iter().map(fill_rgb).collect::<Vec<_>>()
};
// A custom shading color darkens toward that color; None keeps the paint.
assert!(with(json!({"shadingColor": "custom", "shadingCustom": "#0000ff"})).iter().any(|c| c[2] > c[0] / 8.0 + 0.01));
assert!(with(json!({"shadingColor": "none"})).iter().all(|c| close3(*c, RED.to_rgb())));
// One blend step: every face fully lit or fully in shadow.
let stepped = with(json!({"blendSteps": 1, "ambient": 0}));
assert!(stepped.iter().all(|c| close3(*c, RED.to_rgb()) || close3(*c, [0.0; 3])), "{stepped:?}");
// No light: the ambient share of the red. Two lights add up.
let dark = with(json!({"lightIntensity": 0, "blendSteps": 256}));
assert!(dark.iter().all(|c| (c[0] - 0.8 * 0.2).abs() < 0.01), "{dark:?}");
let one = with(json!({"lights": [{"intensity": 30}], "blendSteps": 256}));
let two = with(json!({"lights": [{"intensity": 30}, {"intensity": 30}], "blendSteps": 256}));
assert!(one.iter().zip(&two).all(|(a, b)| b[0] >= a[0] - 1e-4) && one.iter().zip(&two).any(|(a, b)| b[0] > a[0] + 0.01));
// Draw Hidden Faces keeps the faces turned away, drawn first.
assert_eq!(with(json!({"drawHiddenFaces": true})).len(), 6);
}
#[test]
fn classic_bevel_height_extent_and_cap_shape_the_solid() {
let bounds = |p: Value| solid_art(&classic(p)).unwrap().visual_bounds().unwrap();
let front = json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "bevel": "classic", "bevelHeight": 10});
let out = bounds(front.clone());
let mut inside = front.clone();
inside["bevelInside"] = json!(true);
// Straight on, a bevel added around the shape widens it; one carved into it keeps its size.
assert!(out.width() > 100.0 + 15.0, "{out:?}");
let carved = bounds(inside);
assert!((carved.width() - 100.0).abs() < 2.0, "{carved:?}");
// Hollow: no caps, so the walls seen from inside show too.
assert!(classic_faces(json!({"cap": false})).len() > classic_faces(json!({})).len());
// The depth sets how far back the solid goes.
let side = json!({"rotationX": 0, "rotationY": 90, "rotationZ": 0});
let mut deep = side.clone();
deep["depth"] = json!(120);
assert!((bounds(deep).width() - 120.0).abs() < 1.0);
}
/// A gradient fill with a transparent stop is shaded opaque and masked by its own transparency, so
/// the shading does not cover the transparent part; an opaque gradient needs no mask.
#[test]
fn classic_shading_keeps_a_gradients_transparency() {
use vectorcraft_doc::color::{Gradient, GradientKind, GradientPaint, GradientStop};
for (end, masked) in [(0.25, true), (1.0, false)] {
let mut stop = GradientStop::new(1.0, RED);
stop.opacity = end;
let gradient = GradientPaint::new(Gradient::new(GradientKind::Linear, vec![GradientStop::new(0.0, RED), stop]));
let mut n = classic(json!({}));
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first_mut() {
f.paint = Paint::Gradient(Box::new(gradient));
}
let art = solid_art(&n).unwrap();
let surface = &art.children().unwrap()[0];
assert_eq!(surface.extras().mask.is_some(), masked, "{end}");
let mut opaque = true;
surface.walk(&mut |c| {
for item in &c.appearance.items {
if let AppearanceItem::Fill(f) = item
&& let Paint::Gradient(g) = &f.paint
{
opaque &= g.gradient.stops.iter().all(|s| s.opacity == 1.0);
}
}
});
assert!(opaque, "{end}: the faces are drawn opaque");
}
}
#[test]
fn classic_parameters_are_checked() {
let n = classic(json!({}));
let check = |p: Value| validate_solid(&n, CLASSIC_EXTRUDE, &crate::merged_params(CLASSIC_EXTRUDE, &p));
assert!(check(json!({})).is_ok());
assert!(check(json!({"surface": "wireframe", "shadingColor": "custom", "shadingCustom": "#12aBcd", "blendSteps": 256, "bevel": "ogee"})).is_ok());
for (p, says) in [
(json!({"surface": "glossy"}), "`surface` must be one of wireframe, none, diffuse, plastic"),
(json!({"shadingColor": "red"}), "`shadingColor` must be one of black, custom, none"),
(json!({"shadingCustom": "blue"}), "`shadingCustom` must be a #rrggbb color"),
(json!({"shadingCustom": "#fff"}), "`shadingCustom` must be a #rrggbb color"),
(json!({"depth": 2001}), "`depth` must be between 0 and 2000 (got 2001)"),
(json!({"bevelHeight": 0}), "`bevelHeight` must be between 1 and 100 (got 0)"),
(json!({"ambient": -1}), "`ambient` must be between 0 and 100 (got -1)"),
(json!({"blendSteps": 2.5}), "`blendSteps` must be a whole number between 1 and 256 (got 2.5)"),
(json!({"blendSteps": 0}), "`blendSteps` must be a whole number between 1 and 256 (got 0)"),
(json!({"highlightSize": "big"}), "`highlightSize` must be a number"),
(json!({"cap": "yes"}), "`cap` must be true or false"),
(json!({"drawHiddenFaces": 1}), "`drawHiddenFaces` must be true or false"),
(json!({"bevel": "fancy"}), "bevel must be none or one of"),
(json!({"twist": 10}), "unknown parameter `twist`"),
(json!({"lights": []}), "lights must contain one to ten lights"),
] {
let err = check(p.clone()).unwrap_err();
assert!(err.starts_with(says), "{p}: {err}");
}
}
+17 -4
View File
@@ -78,7 +78,7 @@ pub fn specs() -> Vec<CommandSpec> {
"3D Lighting Preset",
[],
None,
"{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index; one undo step → {ids}",
"{index: int, preset: standard|diffuse|topLeft|right, item?: appearance item index|null (omitted: the active item, else the object's), ids?|id?: [..], target?: object|contents, aligned?: bool (index and item are rows of the first target's stack: each other target edits its equal one, as the Appearance panel does for a mixed selection)} replace an applied 3D effect's light rig and ambient with an original lighting preset; preserve shading, geometry and view; all targets must have a 3D effect at index other than Extrude & Bevel (Classic), which has no lighting presets; one undo step → {ids}",
has_doc,
lighting_preset
),
@@ -505,6 +505,13 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result<Value> {
if !effect.is_some_and(|e| effects::is_three_d(&e.id)) {
return Err(bad(C, format!("object {} needs a 3D effect at index {index}", id.0)));
}
// Extrude & Bevel (Classic) has no lighting presets; its lights are set in its own options.
if effect.is_some_and(|e| e.id == effects::CLASSIC_EXTRUDE) {
return Err(bad(
C,
format!("object {}: Extrude & Bevel (Classic) at index {index} has no lighting presets; set its lights with effect.setParams", id.0),
));
}
}
let mut options = p.clone();
let map = options.as_object_mut().ok_or_else(|| bad(C, "parameters must be an object"))?;
@@ -516,7 +523,7 @@ fn lighting_preset(s: &mut Session, p: &Value) -> Result<Value> {
/// `effect.setParams`: merges `params` into the effect at `index` of the targets' active item, and
/// sets its Warp style (`warpStyle`), its 3D type (`threeDType`) or visibility (`visible`), as one
/// undo step. They are checked as `effect.apply` checks them, and a visible 3D effect must still
/// fit. → `{ids}`.
/// fit; a hidden one has its given values checked. → `{ids}`.
fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "effect.setParams";
let index = index_param(p, "index", C)?;
@@ -574,7 +581,6 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
}
if let Some(e) = effect
&& effects::is_three_d(&e.id)
&& visible != Some(false)
{
let to = kind.unwrap_or(&e.id);
let mut prospective =
@@ -582,7 +588,14 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
if let (Some(cur), Value::Object(new)) = (prospective.as_object_mut(), effects::upgrade_params(to, &params)) {
cur.extend(new);
}
effects::validate_three_d(n, to, &prospective).map_err(|e| bad(C, e))?;
if visible != Some(false) {
effects::validate_three_d(n, to, &prospective).map_err(|e| bad(C, e))?;
} else {
// Hidden, the effect draws nothing, so its object need not fit. The values given are
// checked, and after a change of 3D type so is every value it keeps.
let checked = if to == e.id { effects::upgrade_params(to, &params) } else { prospective };
effects::validate_three_d_params(to, &checked).map_err(|e| bad(C, e))?;
}
}
}
let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), index, |fx, index| {
@@ -91,3 +91,27 @@ fn lighting_presets_reject_a_mixed_selection_before_changing_any_effect() {
assert_eq!(s.doc().unwrap().history.undo.len(), history);
assert!(!s.doc().unwrap().is_dirty());
}
#[test]
fn lighting_presets_refuse_extrude_and_bevel_classic_and_leave_a_mixed_selection_unchanged() {
let (mut s, first) = setup("threeD.rotate", Value::Null);
let classic = s.execute("shape.rectangle", &json!({"x":250,"y":100,"width":80,"height":80})).unwrap()["id"].as_u64().unwrap();
s.execute("effect.apply", &json!({"effect":"threeD.classicExtrude","item":null,"params":{"surface":"diffuse","lightRotation":40}})).unwrap();
for ids in [vec![classic], vec![first.0, classic]] {
for preset in LIGHTING_PRESETS {
s.doc_mut().unwrap().mark_saved();
let before = s.doc().unwrap().doc.clone();
let history = s.doc().unwrap().history.undo.len();
let p = json!({"ids":ids,"index":0,"item":null,"preset":preset.id});
let err = s.execute("effect.lightingPreset", &p).unwrap_err().to_string();
assert!(err.contains(&format!("object {classic}: Extrude & Bevel (Classic) at index 0 has no lighting presets")), "{err}");
assert_eq!(s.doc().unwrap().doc, before);
assert_eq!(s.doc().unwrap().history.undo.len(), history);
assert!(!s.doc().unwrap().is_dirty());
}
}
// Its lights are set with its own parameters instead.
s.execute("effect.setParams", &json!({"ids":[classic],"index":0,"item":null,"params":{"lightRotation":135,"lightHeight":45}})).unwrap();
let params = &s.doc().unwrap().doc.node(NodeId(classic)).unwrap().appearance.effects[0].params;
assert_eq!((params["lightRotation"].as_f64(), params["lightHeight"].as_f64()), (Some(135.0), Some(45.0)));
}
+50
View File
@@ -46,6 +46,56 @@ fn revolve_commands_keep_profile_live_edit_options_expand_and_undo() {
assert_eq!(effects_for_test(&s, id), before);
}
/// `effect.setParams` checks the values it is given for a 3D effect when the same edit hides it:
/// a bad value is refused and the document is left as it was, and good values are stored.
#[test]
fn hiding_a_3d_effect_still_checks_its_new_values() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let rect = |s: &mut Session, x: u32| {
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": 100, "width": 60, "height": 60})).unwrap()["id"].as_u64().unwrap())
};
let classic = rect(&mut s, 20);
s.execute("effect.apply", &json!({"effect": effects::CLASSIC_EXTRUDE, "ids": [classic.0], "item": null})).unwrap();
let extrude = rect(&mut s, 120);
s.execute("effect.apply", &json!({"effect": effects::EXTRUDE, "ids": [extrude.0], "item": null})).unwrap();
let profile = NodeId(s.execute("path.create", &json!({"d": "M 300 40 L 330 60 L 330 140 L 300 160"})).unwrap()["id"].as_u64().unwrap());
s.execute("effect.apply", &json!({"effect": effects::REVOLVE, "ids": [profile.0], "item": null})).unwrap();
for (id, params, why) in [
(classic, json!({"depth": 2001}), "`depth` must be between 0 and 2000"),
(classic, json!({"surface": "glossy"}), "`surface` must be one of"),
(classic, json!({"bevel": "wavy"}), "bevel must be none or one of"),
(extrude, json!({"lights": []}), "lights must contain one to ten lights"),
(extrude, json!({"lightColor": "red"}), "lightColor must be an opaque RGB color"),
(profile, json!({"edge": "top"}), "Revolve edge must be left or right"),
] {
let before = s.doc().unwrap().doc.clone();
let undo = s.doc().unwrap().history.undo.len();
let p = json!({"ids": [id.0], "index": 0, "item": null, "params": params, "visible": false});
let e = s.execute("effect.setParams", &p).unwrap_err().to_string();
assert!(e.contains(why), "{params}: {e}");
assert_eq!(s.doc().unwrap().doc, before, "{params}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{params}");
}
// A hidden switch of 3D type checks the values given with it for the new type.
let before = s.doc().unwrap().doc.clone();
let p = json!({"ids": [extrude.0], "index": 0, "item": null, "threeDType": "inflate", "params": {"lights": []}, "visible": false});
let e = s.execute("effect.setParams", &p).unwrap_err().to_string();
assert!(e.contains("lights must contain one to ten lights"), "{e}");
assert_eq!(s.doc().unwrap().doc, before);
// Hiding alone checks nothing.
s.execute("effect.setParams", &json!({"ids": [extrude.0], "index": 0, "item": null, "visible": false})).unwrap();
assert!(!node(&s, extrude).appearance.effects[0].visible);
s.execute("effect.setParams", &json!({"ids": [classic.0], "index": 0, "item": null, "params": {"depth": 2000}, "visible": false})).unwrap();
let e = &node(&s, classic).appearance.effects[0];
assert_eq!((e.params["depth"].as_f64(), e.visible), (Some(2000.0), false));
// A value the effect already holds is not checked again: a key it does not take, as a file
// can carry, does not stop a hidden edit of another value.
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(classic).unwrap().appearance.effects[0].params["extra"] = json!(1);
s.execute("effect.setParams", &json!({"ids": [classic.0], "index": 0, "item": null, "params": {"depth": 10}, "visible": false})).unwrap();
assert_eq!(node(&s, classic).appearance.effects[0].params["depth"].as_f64(), Some(10.0));
}
/// Extrude & Bevel, Inflate and Rotate: applied by command, saved and reopened, edited,
/// exported, expanded and undone; one 3D effect per object.
#[test]
+1 -1
View File
@@ -11,7 +11,7 @@ mod visibility;
pub use solid::{Bevel, BevelShape, Extrude, Facet, Inflate, View, extrude, inflate, inflate_together, plane};
pub use visibility::{VisibleFace, visible_faces};
mod shading;
pub use shading::{LightGradient, RgbGradient, ShadeGradient};
pub use shading::{ClassicShading, ClassicSurface, LightGradient, RgbGradient, ShadeGradient};
mod lights;
pub use lights::{Light, Lights, MAX_LIGHTS};
mod caps;
+93 -1
View File
@@ -1,7 +1,99 @@
//! Gouraud lighting represented by an ordinary vector linear gradient on each triangle.
//! Gouraud lighting represented by an ordinary vector linear gradient on each triangle, and the
//! flat surface shading of the classic 3D effects.
use vectorcraft_geom::{Point, Vec2};
/// The surface of a classic 3D effect: how its faces are drawn and lit.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ClassicSurface {
/// Only the outline of every face, hidden ones included; the faces are transparent.
Wireframe,
/// Every face in the art's own paint, unlit.
NoShading,
/// Soft light: each face as bright as it faces the light.
Diffuse,
/// Diffuse light plus a glossy highlight where a face mirrors the light toward the viewer.
#[default]
Plastic,
}
impl ClassicSurface {
pub const ALL: [ClassicSurface; 4] = [Self::Wireframe, Self::NoShading, Self::Diffuse, Self::Plastic];
pub fn id(self) -> &'static str {
match self {
Self::Wireframe => "wireframe",
Self::NoShading => "none",
Self::Diffuse => "diffuse",
Self::Plastic => "plastic",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|v| v.id() == s)
}
}
/// The surface settings of a classic 3D effect. Shares are 0..1.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ClassicShading {
pub surface: ClassicSurface,
/// Light every face gets, whichever way it faces.
pub ambient: f64,
/// How bright the highlight is at its center (Plastic only).
pub highlight_intensity: f64,
/// How wide the highlight spreads: 1 broad, 0 a pinpoint (Plastic only).
pub highlight_size: f64,
/// How many levels the shading and the highlight are rounded to, 1..256.
pub blend_steps: u32,
}
impl Default for ClassicShading {
fn default() -> Self {
Self { surface: ClassicSurface::Plastic, ambient: 0.5, highlight_intensity: 0.6, highlight_size: 0.9, blend_steps: 25 }
}
}
impl ClassicShading {
/// The shine exponent of the highlight: 2 at the largest size, 256 at the smallest.
fn shininess(self) -> f64 {
2f64.powf(1.0 + 7.0 * (1.0 - self.highlight_size.clamp(0.0, 1.0)))
}
/// `x` (0..1) rounded to the nearest of the blend steps' levels.
fn stepped(self, x: f64) -> f64 {
let steps = f64::from(self.blend_steps.clamp(1, 256));
((x.clamp(0.0, 1.0) * steps).round() / steps).clamp(0.0, 1.0)
}
/// The tone (0 in full shadow, 1 fully lit) and the highlight (0 none, 1 white) of a face with
/// view-space unit normal `normal`, seen from the front and lit by `lights`: each a unit
/// direction toward the light and its intensity (0..1). The lights add up. Wireframe and No
/// Shading are unlit: tone 1, no highlight.
pub fn tone(self, normal: [f64; 3], lights: &[([f64; 3], f64)]) -> (f64, f64) {
if matches!(self.surface, ClassicSurface::Wireframe | ClassicSurface::NoShading) {
return (1.0, 0.0);
}
let dot = |a: [f64; 3], b: [f64; 3]| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
let (mut tone, mut spec) = (self.ambient.clamp(0.0, 1.0), 0.0);
for &(light, intensity) in lights {
let intensity = if intensity.is_finite() { intensity.clamp(0.0, 1.0) } else { 0.0 };
let lit = dot(normal, light).max(0.0);
tone += intensity * lit;
// The halfway direction between the light and the eye in front of the page.
let half = [light[0], light[1], light[2] + 1.0];
let length = dot(half, half).sqrt();
if self.surface == ClassicSurface::Plastic && length > 1e-12 && lit > 0.0 {
spec += intensity * (dot(normal, half) / length).max(0.0).powf(self.shininess()) * self.highlight_intensity.clamp(0.0, 1.0);
}
}
if !tone.is_finite() || !spec.is_finite() {
return (1.0, 0.0);
}
(self.stepped(tone), self.stepped(spec))
}
}
/// Document-space gradient placement and the brightness at its two ends. Clipping a
/// face must keep this placement unchanged, so newly cut edges retain their lighting.
#[derive(Clone, Copy, Debug, PartialEq)]
+7 -1
View File
@@ -205,11 +205,14 @@ pub struct Extrude {
/// Closed ends (a solid) or open ones (a hollow tube).
pub cap: bool,
pub bevel: Option<Bevel>,
/// Keep the faces of a closed solid that are turned away from the viewer (Draw Hidden Faces,
/// and the classic Wireframe surface), drawn behind the faces turned toward it.
pub hidden: bool,
}
impl Default for Extrude {
fn default() -> Self {
Self { depth: 50.0, twist: 0.0, taper: 1.0, cap: true, bevel: None }
Self { depth: 50.0, twist: 0.0, taper: 1.0, cap: true, bevel: None, hidden: false }
}
}
@@ -243,6 +246,9 @@ pub struct Facet {
pub depth: f64,
pub brightness: f64,
pub illumination: [f64; 3],
/// The face's unit normal in view space (x right, y down, z toward the viewer), turned to the
/// side the viewer sees.
pub normal: [f64; 3],
/// Smooth lighting on an inflated triangle, before clipping individual RGB channels.
pub rgb_gradient: Option<crate::RgbGradient>,
/// The contours in view space, before projection, for tests that measure depths.
+7 -3
View File
@@ -54,6 +54,9 @@ pub(super) struct Builder<'a> {
/// A closed solid hides the faces turned away from the viewer; an open one shows their
/// insides.
pub(super) closed: bool,
/// A closed solid keeps the faces turned away from the viewer too, with their outward normals,
/// drawn behind the others.
pub(super) hidden: bool,
pub(super) facets: Vec<Facet>,
/// With `Some`, every face goes here as it is, none hidden or projected, for a mesh.
pub(super) mesh: Option<Vec<Face3>>,
@@ -99,7 +102,7 @@ impl Builder<'_> {
// leave gaps.
let (n, behind) = if facing > 0.0 {
(n, false)
} else if self.closed && facing > -0.02 {
} else if self.closed && (facing > -0.02 || self.hidden) {
(n, true)
} else if self.closed {
return Ok(());
@@ -127,6 +130,7 @@ impl Builder<'_> {
depth,
brightness: self.camera.brightness(n),
illumination: crate::illumination(&self.camera.view, n, self.camera.light),
normal: [n.0, n.1, n.2],
rgb_gradient: None,
#[cfg(test)]
view,
@@ -405,7 +409,7 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio
}
let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_SOLID_POINTS)?;
let camera = Camera::new(view, bounds, o.depth);
let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![], mesh: mesh.then(Vec::new) };
let mut b = Builder { camera: &camera, closed: o.cap, hidden: o.hidden, facets: vec![], mesh: mesh.then(Vec::new) };
let steps = steps(&o);
let z = |t: f64| o.depth / 2.0 - o.depth * t;
let ring_at =
@@ -463,7 +467,7 @@ pub(crate) fn plane_faces(path: &PathData, rule: FillRule, bounds: Rect, view: V
check_bounds(bounds)?;
let rings = solid_rings(path, rule, bounds.width().max(bounds.height()), MAX_PLANE_POINTS)?;
let camera = Camera::new(view, bounds, 0.0);
let mut b = Builder { camera: &camera, closed: false, facets: vec![], mesh: mesh.then(Vec::new) };
let mut b = Builder { camera: &camera, closed: false, hidden: false, facets: vec![], mesh: mesh.then(Vec::new) };
let contours: Vec<Vec<V3>> = rings.iter().map(|r| r.iter().map(|p| camera.place(*p, 0.0)).collect()).collect();
// A plane is one flat face, seen from either side. Its normal is turned toward the viewer, so
// it is drawn last, as a cap is, and planes drawn in one order keep the order they are given in.
+1 -1
View File
@@ -125,7 +125,7 @@ fn inflated(p: Puff, bounds: Rect, o: Inflate, view: View, reach: f64, mesh: boo
let smooth = view.shade && view.has_colored_lights();
let camera = Camera::new(view, bounds, o.depth + 2.0 * height.max(reach));
// Without its back cap the object is a shell open behind, its inside showing.
let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![], mesh: mesh.then(Vec::new) };
let mut b = Builder { camera: &camera, closed: o.cap, hidden: false, facets: vec![], mesh: mesh.then(Vec::new) };
let half = o.depth / 2.0;
// The back: turned and scaled by the twist and taper, its puff too.
let back_turn = Turn::new(bounds, o.twist, o.taper, 1.0);
+23
View File
@@ -76,6 +76,28 @@ fn no_depth_is_the_flat_shape_and_no_cap_shows_the_inside() {
assert!(hollow.iter().all(|f| f.contours.len() == 1 && f.contours[0].len() == 4));
}
#[test]
fn hidden_faces_draw_every_face_of_the_solid_behind_the_visible_ones() {
let (p, b) = shape(&square(0.0, 0.0, 100.0));
let v = View { rotation_x: 20.0, rotation_y: 30.0, ..front() };
let shown = extrude(&p, FillRule::EvenOdd, b, Extrude::default(), v).unwrap();
let all = extrude(&p, FillRule::EvenOdd, b, Extrude { hidden: true, ..Extrude::default() }, v).unwrap();
// A box: the three faces turned to the viewer, then the three turned away, drawn first.
assert_eq!(shown.len(), 3);
assert_eq!(all.len(), 6);
assert!(all.windows(2).all(|w| w[0].depth <= w[1].depth));
// The faces turned away keep their outward normals, so a light behind the solid lights them.
assert!(all[..3].iter().all(|f| f.normal[2] < 0.0), "{:?}", all.iter().map(|f| f.normal).collect::<Vec<_>>());
for f in shown.iter().chain(&all) {
let [x, y, z] = f.normal;
assert!(close(x.hypot(y).hypot(z), 1.0, 1e-9), "{:?}", f.normal);
}
// The visible faces keep their normals and their place at the front.
for (front, kept) in shown.iter().rev().zip(all.iter().rev()) {
assert_eq!(front.normal, kept.normal);
}
}
#[test]
fn taper_shrinks_the_back_and_twist_turns_it() {
let (p, b) = shape(&square(0.0, 0.0, 100.0));
@@ -232,6 +254,7 @@ fn malformed_and_degenerate_input_is_bounded() {
taper: -3.0,
cap: true,
bevel: Some(Bevel { width: f64::NAN, height: 1e12, repeat: usize::MAX, space: 7.0, ..Bevel::default() }),
hidden: false,
};
let f = extrude(&p, FillRule::EvenOdd, b, wild, View { rotation_x: f64::NAN, perspective: 900.0, ..front() }).unwrap();
assert!(f.iter().flat_map(|f| f.contours.iter().flatten()).all(|q| q.x.is_finite() && q.y.is_finite()));
+1
View File
@@ -1,6 +1,7 @@
use super::*;
mod caps;
mod classic;
mod lighting;
mod multiple_lights;
mod smooth;
+96
View File
@@ -0,0 +1,96 @@
use crate::*;
const TOWARD: [f64; 3] = [0.0, 0.0, 1.0];
fn close(a: f64, b: f64) -> bool {
(a - b).abs() < 1e-9
}
fn one(light: [f64; 3], intensity: f64) -> [([f64; 3], f64); 1] {
[(light, intensity)]
}
#[test]
fn wireframe_and_no_shading_leave_every_face_unlit() {
for surface in [ClassicSurface::Wireframe, ClassicSurface::NoShading] {
let s = ClassicShading { surface, ..ClassicShading::default() };
for normal in [TOWARD, [1.0, 0.0, 0.0], [0.0, 0.0, -1.0]] {
assert_eq!(s.tone(normal, &one(TOWARD, 1.0)), (1.0, 0.0), "{surface:?}");
}
}
}
#[test]
fn diffuse_adds_the_light_to_the_ambient_and_has_no_highlight() {
let s = ClassicShading { surface: ClassicSurface::Diffuse, ambient: 0.3, blend_steps: 256, ..ClassicShading::default() };
let (lit, spec) = s.tone(TOWARD, &one(TOWARD, 0.4));
assert!((lit - 0.7).abs() < 1.0 / 256.0, "{lit}");
assert_eq!(spec, 0.0);
// A face turned away from the light gets the ambient light only.
let (away, _) = s.tone(TOWARD, &one([1.0, 0.0, 0.0], 0.4));
assert!((away - 0.3).abs() < 1.0 / 256.0, "{away}");
// A face at 60° to the light gets half of it.
let (half, _) = s.tone(TOWARD, &one([3f64.sqrt() / 2.0, 0.0, 0.5], 0.4));
assert!((half - 0.5).abs() < 1.0 / 256.0, "{half}");
// The tone stays within full shadow and full light.
assert_eq!(ClassicShading { ambient: 1.0, ..s }.tone(TOWARD, &one(TOWARD, 1.0)).0, 1.0);
// Without lights a face gets the ambient light only.
assert!((s.tone(TOWARD, &[]).0 - 0.3).abs() < 1.0 / 256.0);
}
#[test]
fn lights_add_up() {
let s = ClassicShading { surface: ClassicSurface::Diffuse, ambient: 0.0, blend_steps: 256, ..ClassicShading::default() };
let left = [-0.6, 0.0, 0.8];
let right = [0.6, 0.0, 0.8];
let both = s.tone(TOWARD, &[(left, 0.3), (right, 0.3)]).0;
let single = s.tone(TOWARD, &one(left, 0.3)).0;
assert!((both - 2.0 * single).abs() < 2.0 / 256.0, "{both} {single}");
// A light behind the object lights the faces turned away from the viewer only.
assert_eq!(s.tone(TOWARD, &one([0.0, 0.0, -1.0], 1.0)).0, 0.0);
assert!(s.tone([0.0, 0.0, -1.0], &one([0.0, 0.0, -1.0], 1.0)).0 > 0.99);
}
#[test]
fn plastic_highlight_peaks_where_a_face_mirrors_the_light_to_the_eye() {
let s = ClassicShading { highlight_intensity: 0.8, blend_steps: 256, ..ClassicShading::default() };
let light = one([0.6, 0.0, 0.8], 1.0);
// The face halfway between the light and the eye mirrors the light straight to the viewer.
let length = 0.6f64.hypot(1.8);
let half = [0.6 / length, 0.0, 1.8 / length];
let (_, peak) = s.tone(half, &light);
assert!((peak - 0.8).abs() < 1.0 / 256.0, "{peak}");
let (_, aside) = s.tone([-0.6, 0.0, 0.8], &light);
assert!(aside < peak / 4.0, "{aside}");
// A smaller highlight falls off faster.
let tilted = [0.3, 0.0, 0.954];
let broad = s.tone(tilted, &light).1;
let narrow = ClassicShading { highlight_size: 0.1, ..s }.tone(tilted, &light).1;
assert!(narrow < broad, "{narrow} {broad}");
// No highlight on a face the light does not reach, without Highlight Intensity, or on Diffuse.
assert_eq!(s.tone(TOWARD, &one([0.0, 0.0, -1.0], 1.0)).1, 0.0);
assert_eq!(ClassicShading { highlight_intensity: 0.0, ..s }.tone(half, &light).1, 0.0);
assert_eq!(ClassicShading { surface: ClassicSurface::Diffuse, ..s }.tone(half, &light).1, 0.0);
}
#[test]
fn blend_steps_round_the_shading_to_that_many_levels() {
let s = ClassicShading { surface: ClassicSurface::Diffuse, ambient: 0.0, blend_steps: 2, ..ClassicShading::default() };
let tones: Vec<f64> = (0..=20).map(|k| f64::from(k) / 20.0).map(|c| s.tone(TOWARD, &one([(1.0 - c * c).sqrt(), 0.0, c], 0.5)).0).collect();
assert!(tones.iter().all(|t| close(*t, 0.0) || close(*t, 0.5)), "{tones:?}");
assert!(tones.iter().any(|t| close(*t, 0.0)) && tones.iter().any(|t| close(*t, 0.5)), "{tones:?}");
// Out-of-range step counts and intensities are kept within their ranges.
for steps in [0, 100_000] {
let t = ClassicShading { blend_steps: steps, ..s }.tone(TOWARD, &one(TOWARD, f64::NAN)).0;
assert!((0.0..=1.0).contains(&t));
}
}
#[test]
fn surfaces_round_trip_through_their_ids() {
for surface in ClassicSurface::ALL {
assert_eq!(ClassicSurface::parse(surface.id()), Some(surface));
}
assert_eq!(ClassicSurface::parse("glossy"), None);
assert_eq!(ClassicSurface::default(), ClassicSurface::Plastic);
}
+1
View File
@@ -126,6 +126,7 @@ pub mod symbolism_options;
pub mod tate_chu_yoko;
mod text_export;
pub mod text_import;
mod three_d_classic;
mod tiff_bmp_tga;
pub mod tile_edge_color;
mod tools;
+11 -3
View File
@@ -350,15 +350,20 @@ fn choices(effect: &str, key: &str) -> Option<form::Choices> {
/// The parameters whose dialogs list their values in their own bodies, as (label, value), each
/// label as that dialog passes it to its dropdown or radio buttons, which translate it into the UI
/// language: Revolve's edge, Extrude & Bevel's bevel (Rounded translated in the "3d" context
/// first), every Warp style's orientation, Rasterize's Color Model (its `document` value labeled
/// language: Revolve's edge, the bevel of both Extrude & Bevels (Rounded translated in the "3d"
/// context first), Extrude & Bevel (Classic)'s surface and shading color, every Warp style's
/// orientation, Rasterize's Color Model (its `document` value labeled
/// CMYK when `cmyk`, for a document in CMYK, and RGB otherwise), Background and Anti-aliasing, and
/// the channel of Levels and Curves.
pub(super) fn own_choices(effect: &str, key: &str, cmyk: bool) -> Option<Vec<(&'static str, &'static str)>> {
let c3 = |s: &'static str| crate::i18n::tr_ctx(crate::i18n::current(), "3d", s);
Some(match (effect, key) {
(vectorcraft_effects::REVOLVE, "edge") => vec![(tl!("Left"), "left"), (tl!("Right"), "right")],
(vectorcraft_effects::EXTRUDE, "bevel") => vec![
(vectorcraft_effects::CLASSIC_EXTRUDE, "surface") => {
vec![(tl!("Wireframe"), "wireframe"), (tl!("No Shading"), "none"), (tl!("Diffuse"), "diffuse"), (tl!("Plastic"), "plastic")]
}
(vectorcraft_effects::CLASSIC_EXTRUDE, "shadingColor") => vec![(tl!("Black"), "black"), (tl!("Custom"), "custom"), (tl!("None"), "none")],
(vectorcraft_effects::EXTRUDE | vectorcraft_effects::CLASSIC_EXTRUDE, "bevel") => vec![
(tl!("None"), "none"),
(tl!("Classic"), "classic"),
(c3("Rounded"), "rounded"),
@@ -919,6 +924,9 @@ mod tests {
for (effect, key) in [
(vectorcraft_effects::REVOLVE, "edge"),
(vectorcraft_effects::EXTRUDE, "bevel"),
(vectorcraft_effects::CLASSIC_EXTRUDE, "bevel"),
(vectorcraft_effects::CLASSIC_EXTRUDE, "surface"),
(vectorcraft_effects::CLASSIC_EXTRUDE, "shadingColor"),
("warp.arc", "orientation"),
("warp.twist", "orientation"),
(vectorcraft_effects::RASTERIZE, "background"),
+27 -19
View File
@@ -1,11 +1,12 @@
//! Compact controls for the 3D and Materials effects: Extrude & Bevel, Revolve, Inflate and Rotate.
//! The object and the rotation and light gizmos live on the artboard. All document changes run
//! through the effect dialog's existing preview/commit commands.
//! Compact controls for the 3D and Materials effects: Extrude & Bevel, Revolve, Inflate and Rotate,
//! and Extrude & Bevel (Classic), whose own controls are in `three_d_classic`. The object and the
//! rotation and light gizmos 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::{EXTRUDE, INFLATE, REVOLVE, ROTATION_PRESETS};
use vectorcraft_effects::{CLASSIC_EXTRUDE, EXTRUDE, INFLATE, REVOLVE, ROTATION_PRESETS};
use crate::state::Dialog;
use crate::theme::Tokens;
@@ -140,14 +141,15 @@ pub(crate) fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog)
}
/// `s` as the 3D effects mean it (a solid's cap, not a stroke's; a bevelled edge, not a join).
fn c3(s: &'static str) -> &'static str {
pub(super) fn c3(s: &'static str) -> &'static str {
crate::i18n::tr_ctx(crate::i18n::current(), "3d", s)
}
/// Extrude & Bevel and Inflate: Object, Rotation and Lighting tabs (Rotate has no Object tab).
/// The rotation gizmo shows on the first two, the light gizmo on Lighting.
/// Extrude & Bevel, Inflate and Extrude & Bevel (Classic): Object, Rotation and Lighting tabs
/// (Rotate has no Object tab). The rotation gizmo shows on the first two, the light gizmo on
/// Lighting.
fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: &str) {
let object = id == EXTRUDE || id == INFLATE;
let object = id == EXTRUDE || id == INFLATE || id == CLASSIC_EXTRUDE;
let mut tabs = vec![];
if object {
tabs.push(tl!("Object"));
@@ -167,10 +169,16 @@ fn solid_controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog, id: &
}
ui.add_space(8.0);
if super::revolve_gizmo::lighting(d) {
lighting_controls(ui, d);
if id == CLASSIC_EXTRUDE {
super::three_d_classic::surface_controls(ui, d);
} else {
lighting_controls(ui, d);
}
} else if object && d.str("__tab") != "rotation" {
let unit = app.session.general_unit();
if id == EXTRUDE {
if id == CLASSIC_EXTRUDE {
super::three_d_classic::object_controls(ui, d, unit);
} else if id == EXTRUDE {
extrude_controls(ui, d, unit);
} else {
heading(ui, tl!("Object"));
@@ -249,7 +257,7 @@ fn view_controls(ui: &mut egui::Ui, d: &mut Dialog) {
}
/// A dropdown of `options` (value, label) for `key`, with its reset dot.
fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, options: &[(Value, &str)]) {
pub(super) fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, options: &[(Value, &str)]) {
let id = d.str("__effect");
let default = vectorcraft_effects::default_params(&id).and_then(|p| p.get(key).cloned()).unwrap_or(Value::Null);
// A mixed setting (null) shows its default, with its reset dot filled.
@@ -283,7 +291,7 @@ fn flag(d: &Dialog, key: &str, default: bool) -> Option<bool> {
}
/// A checkbox for `key`, with its reset dot. A mixed setting shows a dash, and a click turns it on.
fn toggle(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, default: bool) {
pub(super) fn toggle(ui: &mut egui::Ui, d: &mut Dialog, key: &str, name: &str, default: bool) {
ui.horizontal(|ui| {
let on = flag(d, key, default);
if widgets::check3(ui, name, on, ui.is_enabled()) {
@@ -386,11 +394,11 @@ pub(super) fn light_options(d: &Dialog) -> vectorcraft_three_d::Revolve {
vectorcraft_effects::revolve_options(&super::form::params(&selected_light(d)))
}
fn light_record(d: &Dialog) -> Value {
pub(super) fn light_record(d: &Dialog) -> Value {
Value::Object(LIGHT_KEYS.iter().filter_map(|(key, field)| d.fields.get(*field).map(|v| ((*key).into(), v.clone()))).collect())
}
fn store_selected(d: &mut Dialog, selected: &Dialog) {
pub(super) fn store_selected(d: &mut Dialog, selected: &Dialog) {
if let Some(list) = explicit_lights(d) {
let index = light_index(d, list.len());
let mut list = list.clone();
@@ -413,7 +421,7 @@ pub(super) fn set_light_value(d: &mut Dialog, key: &str, value: Value) {
store_selected(d, &selected);
}
fn light_list(ui: &mut egui::Ui, d: &mut Dialog) {
pub(super) fn light_list(ui: &mut egui::Ui, d: &mut Dialog) {
let mut list = explicit_lights(d).cloned().unwrap_or_else(|| vec![light_record(d)]);
let mut index = light_index(d, list.len());
let names: Vec<_> = (1..=list.len()).map(|i| format!("{} {i}", c3("Light"))).collect();
@@ -483,12 +491,12 @@ fn light_color(ui: &mut egui::Ui, d: &mut Dialog) {
});
}
fn heading(ui: &mut egui::Ui, text: &str) {
pub(super) 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) {
pub(super) 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);
@@ -501,7 +509,7 @@ fn rail_width(ui: &egui::Ui) -> f32 {
/// 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>) {
pub(super) 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(&d.str("__effect")).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));
@@ -591,7 +599,7 @@ fn dot(ui: &egui::Ui, p: Pos2, changed: bool) {
}
}
fn reset_dot(ui: &mut egui::Ui, key: &str, changed: bool, default: &str) -> bool {
pub(super) 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::unique(("revolve-reset", key)), Sense::click());
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Reset to default")));
@@ -297,6 +297,68 @@ fn the_other_3d_effects_show_their_options_and_position_presets() {
}
}
/// Extrude & Bevel (Classic): its extrude and bevel options on the Object tab, Position on the
/// Rotation tab, and the surface with its lights, highlight, blend steps and shading color on the
/// Lighting tab; OK keeps the edit as one undo step.
#[test]
fn classic_extrude_bevel_shows_its_options_and_applies_as_one_step() {
use vectorcraft_effects::CLASSIC_EXTRUDE;
let mut h = Harness::with("M100 100 L200 100 L200 200 L100 200 Z", CLASSIC_EXTRUDE, vec2(1400.0, 1000.0));
let before = h.app.session.doc().unwrap().doc.clone();
h.settle();
for text in ["Depth", "Cap", "Bevel Shape", "Height", "Bevel Inside"] {
h.rect(text);
}
assert!(!h.texts.iter().any(|(t, _)| t == "Twist" || t == "Taper" || t == "Width"), "the newer effect's options stay out");
assert!(!h.ctx.read_response(Id::unique(("revolve-reset", "bevelHeight"))).unwrap().enabled(), "no bevel: its height is off");
h.app.ui.dialog.as_mut().unwrap().fields.insert("__tab".into(), json!("rotation"));
h.settle();
for text in ["Presets", "Rotation X", "Perspective", "Off-Axis Front"] {
h.rect(text);
}
h.click(h.rect("Lighting").center());
h.settle();
for text in [
"Surface",
"Plastic",
"Light Intensity",
"Ambient Light",
"Highlight Intensity",
"Highlight Size",
"Blend Steps",
"Shading Color",
"Draw Hidden Faces",
] {
h.rect(text);
}
assert!(h.ctx.read_response(Id::unique(("revolve-reset", "highlightSize"))).unwrap().enabled());
// The light gizmo is on for a lit surface.
assert_eq!(dialogs::revolve_gizmo::hint(h.app.ui.dialog.as_ref().unwrap()), "Drag the light, or its rings. Shift snaps to 15°.");
// Diffuse has no highlight; Wireframe and No Shading take no light.
h.app.ui.dialog.as_mut().unwrap().fields.insert("surface".into(), json!("diffuse"));
h.settle();
assert!(!h.ctx.read_response(Id::unique(("revolve-reset", "highlightSize"))).unwrap().enabled());
assert!(h.ctx.read_response(Id::unique(("revolve-reset", "ambient"))).unwrap().enabled());
h.app.ui.dialog.as_mut().unwrap().fields.insert("surface".into(), json!("wireframe"));
h.settle();
assert!(!h.ctx.read_response(Id::unique(("revolve-reset", "ambient"))).unwrap().enabled());
assert!(h.ctx.read_response(Id::unique(("revolve-reset", "drawHiddenFaces"))).unwrap().enabled());
assert_eq!(dialogs::revolve_gizmo::hint(h.app.ui.dialog.as_ref().unwrap()), "Drag values or labels. Double-click a value to type.");
// A custom shading color shows its color row.
let d = h.app.ui.dialog.as_mut().unwrap();
d.fields.insert("surface".into(), json!("diffuse"));
d.fields.insert("shadingColor".into(), json!("custom"));
h.settle();
h.rect("#000000");
dialogs::confirm(&mut h.app).unwrap();
let state = h.app.session.doc().unwrap();
let fx = &state.doc.node(state.selection.objects[0]).unwrap().appearance.effects[0];
assert_eq!(fx.id, CLASSIC_EXTRUDE);
assert_eq!((fx.params["surface"].clone(), fx.params["shadingColor"].clone()), (json!("diffuse"), json!("custom")));
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc().unwrap().doc, before);
}
#[test]
fn extrude_bevel_options_enable_with_a_bevel_shape() {
let mut h = Harness::with("M100 100 L200 100 L200 200 L100 200 Z", vectorcraft_effects::EXTRUDE, vec2(1400.0, 1000.0));
+12 -2
View File
@@ -20,8 +20,18 @@ pub(crate) fn lighting(d: &Dialog) -> bool {
d.str("__gizmo") == "light"
}
/// Does the effect take light: Shading on, or for Extrude & Bevel (Classic) a Diffuse or Plastic
/// surface?
fn lit(d: &Dialog) -> bool {
if d.str("__effect") == vectorcraft_effects::CLASSIC_EXTRUDE {
matches!(d.str("surface").as_str(), "diffuse" | "plastic")
} else {
d.bool("shade")
}
}
pub(crate) fn hint(d: &Dialog) -> &'static str {
if !d.bool("preview") || (lighting(d) && !d.bool("shade")) {
if !d.bool("preview") || (lighting(d) && !lit(d)) {
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°.")
@@ -190,7 +200,7 @@ fn snap_height(height: f64, snap: bool) -> f64 {
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")) {
if !d.bool("preview") || (light_mode && !lit(d)) {
clear(ctx);
return;
}
@@ -0,0 +1,85 @@
//! Extrude & Bevel (Classic)'s own controls in the compact 3D dialog: the extrude and bevel options
//! on the Object tab, and the surface with its lights, highlight, blend steps and shading color on
//! the Lighting tab. Position (presets, rotation, perspective) is the Rotation tab every 3D effect
//! shares.
use serde_json::{Value, json};
use vectorcraft_doc::Unit;
use vectorcraft_effects::CLASSIC_EXTRUDE;
use super::revolve::{c3, choice, heading, label, light_list, light_record, number, reset_dot, selected_light, store_selected, toggle};
use crate::state::Dialog;
/// Depth and cap, then the bevel: its shape, its height and whether it is added around the shape
/// or carved into it.
pub(super) fn object_controls(ui: &mut egui::Ui, d: &mut Dialog, unit: Unit) {
heading(ui, tl!("Object"));
number(ui, d, "depth", tl!("Depth"), (0.0, 2000.0), "", 1, Some(unit));
choice(ui, d, "cap", c3("Cap"), &[(json!(true), c3("Solid")), (json!(false), c3("Hollow"))]);
ui.add_space(10.0);
heading(ui, c3("Bevel"));
choice(ui, d, "bevel", tl!("Bevel Shape"), &options("bevel"));
ui.add_enabled_ui(d.str("bevel") != "none", |ui| {
number(ui, d, "bevelHeight", tl!("Height"), (1.0, 100.0), "", 1, Some(unit));
toggle(ui, d, "bevelInside", tl!("Bevel Inside"), false);
});
}
/// The surface, then for Diffuse and Plastic the lights, Ambient Light, the highlight (Plastic
/// only), Blend Steps and Shading Color; Draw Hidden Faces last.
pub(super) fn surface_controls(ui: &mut egui::Ui, d: &mut Dialog) {
choice(ui, d, "surface", tl!("Surface"), &options("surface"));
let surface = d.str("surface");
ui.add_enabled_ui(surface == "diffuse" || surface == "plastic", |ui| {
light_list(ui, d);
let mut selected = selected_light(d);
let before = light_record(&selected);
number(ui, &mut selected, "lightIntensity", tl!("Light Intensity"), (0.0, 100.0), "%", 1, None);
number(ui, &mut selected, "lightRotation", tl!("Rotation"), (-180.0, 180.0), "°", 1, None);
number(ui, &mut selected, "lightHeight", tl!("Height"), (0.0, 90.0), "°", 1, None);
toggle(ui, &mut selected, "lightBehind", tl!("Light behind the object"), false);
if light_record(&selected) != before {
store_selected(d, &selected);
}
number(ui, d, "ambient", tl!("Ambient Light"), (0.0, 100.0), "%", 1, None);
ui.add_enabled_ui(surface == "plastic", |ui| {
number(ui, d, "highlightIntensity", tl!("Highlight Intensity"), (0.0, 100.0), "%", 1, None);
number(ui, d, "highlightSize", tl!("Highlight Size"), (0.0, 100.0), "%", 1, None);
});
number(ui, d, "blendSteps", tl!("Blend Steps"), (1.0, 256.0), "", 0, None);
choice(ui, d, "shadingColor", tl!("Shading Color"), &options("shadingColor"));
if d.str("shadingColor") == "custom" {
shading_custom(ui, d);
}
});
toggle(ui, d, "drawHiddenFaces", tl!("Draw Hidden Faces"), false);
}
/// The values of Extrude & Bevel (Classic)'s `key` as the dropdowns list them, (value, label).
fn options(key: &str) -> Vec<(Value, &'static str)> {
super::effect::own_choices(CLASSIC_EXTRUDE, key, false).unwrap_or_default().into_iter().map(|(label, value)| (json!(value), label)).collect()
}
/// The custom shading color: a color button, its hex value and its reset dot.
fn shading_custom(ui: &mut egui::Ui, d: &mut Dialog) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 6.0;
label(ui, tl!("Color"));
let color = d
.fields
.get("shadingCustom")
.and_then(Value::as_str)
.filter(|s| s.len() == 7 && s.starts_with('#'))
.and_then(vectorcraft_color::Color::from_hex)
.unwrap_or(vectorcraft_color::Color::BLACK);
let mut rgb = color.to_rgb_uncalibrated().map(|c| (c * 255.0).round() as u8);
if ui.color_edit_button_srgb(&mut rgb).on_hover_text(tl!("Shading Color")).changed() {
d.fields.insert("shadingCustom".into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
}
ui.label(format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2]));
ui.add_space((ui.available_width() - 24.0).max(0.0));
if reset_dot(ui, "shadingCustom", rgb != [0; 3], "#000000") {
d.fields.insert("shadingCustom".into(), json!("#000000"));
}
});
}
+11
View File
@@ -1102,6 +1102,17 @@ font-technology Variable Променливи
Exporting {} Експортиране на {}
Export… Експортиране…
Extrude & Bevel… Избутване и фасетка…
3D (Classic) 3D (класически)
Extrude & Bevel (Classic)… Избутване и фасетка (класически)…
Surface Повърхност
Wireframe Телена рамка
No Shading Без засенчване
Plastic Пластмаса
Highlight Intensity Интензивност на отблясъка
Highlight Size Размер на отблясъка
Blend Steps Стъпки на преливане
Shading Color Цвят на засенчването
Draw Hidden Faces Изчертаване на скритите страни
Eyedropper Пипета
Eyedropper Applies: Пипетата прилага:
Eyedropper Options Опции за пипетата
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -466,6 +466,17 @@
Inflate Napuhni
Select objects, then pick an object type to give them a 3D effect. Odaberite objekte, zatim odaberite vrstu objekta da im date 3D efekat.
Extrude & Bevel… Istiskivanje i zakošenje…
3D (Classic) 3D (klasično)
Extrude & Bevel (Classic)… Istiskivanje i zakošenje (klasično)…
Surface Površina
Wireframe Žičani model
No Shading Bez sjenčenja
Plastic Plastika
Highlight Intensity Intenzitet odsjaja
Highlight Size Veličina odsjaja
Blend Steps Koraci prelaza
Shading Color Boja sjenčenja
Draw Hidden Faces Crtaj skrivene strane
Revolve… Rotaciono tijelo…
Inflate… Napuhni…
Materials… Materijali…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -1518,6 +1518,17 @@ antiAlias Sharp Nítid
Exporting {} S’està exportant {}
Export… Exporta…
Extrude & Bevel… Extrudeix i bisella…
3D (Classic) 3D (clàssic)
Extrude & Bevel (Classic)… Extrudeix i bisella (clàssic)…
Surface Superfície
Wireframe Filferro
No Shading Sense ombra
Plastic Plàstic
Highlight Intensity Intensitat de la brillantor
Highlight Size Mida de la brillantor
Blend Steps Passos de fusió
Shading Color Color de l’ombra
Draw Hidden Faces Dibuixa les cares ocultes
Eyedropper Comptagotes
Eyedropper Applies: El comptagotes aplica:
Eyedropper Options Opcions del comptagotes
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -475,6 +475,17 @@
Inflate Nafouknutí
Select objects, then pick an object type to give them a 3D effect. Vyberte objekty a pak zvolte typ objektu, abyste jim dali 3D efekt.
Extrude & Bevel… Vysunutí a zkosení…
3D (Classic) 3D (klasické)
Extrude & Bevel (Classic)… Vysunutí a zkosení (klasické)…
Surface Povrch
Wireframe Drátový model
No Shading Bez stínování
Plastic Plast
Highlight Intensity Intenzita odlesku
Highlight Size Velikost odlesku
Blend Steps Kroky prolnutí
Shading Color Barva stínování
Draw Hidden Faces Kreslit skryté stěny
Revolve… Rotační těleso…
Inflate… Nafouknutí…
Materials… Materiály…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -433,6 +433,17 @@
Inflate Pust op
Select objects, then pick an object type to give them a 3D effect. Markér objekter, og vælg derefter en objekttype for at give dem en 3D-effekt.
Extrude & Bevel… Ekstrudér og facet…
3D (Classic) 3D (klassisk)
Extrude & Bevel (Classic)… Ekstrudér og facet (klassisk)…
Surface Overflade
Wireframe Trådmodel
No Shading Ingen skyggelægning
Plastic Plastik
Highlight Intensity Højlysintensitet
Highlight Size Højlysstørrelse
Blend Steps Overgangstrin
Shading Color Skyggefarve
Draw Hidden Faces Tegn skjulte flader
Revolve… Omdrej…
Inflate… Pust op…
Materials… Materialer…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -1107,6 +1107,17 @@ font-technology Variable Variabel
Exporting {} {} wird exportiert
Export… Exportieren…
Extrude & Bevel… Extrudieren und abgeflachte Kante…
3D (Classic) 3D (klassisch)
Extrude & Bevel (Classic)… Extrudieren und abgeflachte Kante (klassisch)…
Surface Oberfläche
Wireframe Drahtmodell
No Shading Keine Schattierung
Plastic Kunststoff
Highlight Intensity Glanzpunktintensität
Highlight Size Glanzpunktgröße
Blend Steps Angleichungsstufen
Shading Color Schattierungsfarbe
Draw Hidden Faces Verdeckte Flächen zeichnen
Eyedropper Pipette
Sample Color from Screen Farbe vom Bildschirm aufnehmen
This system has no screen color sampler Dieses System hat keine Farbaufnahme vom Bildschirm
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -1080,6 +1080,17 @@ font-technology Variable Μεταβλητές
Exporting {} Εξαγωγή {}
Export… Εξαγωγή…
Extrude & Bevel… Εξώθηση και λοξότμηση…
3D (Classic) 3D (κλασικό)
Extrude & Bevel (Classic)… Εξώθηση και λοξότμηση (κλασικό)…
Surface Επιφάνεια
Wireframe Συρματόπλεγμα
No Shading Χωρίς σκίαση
Plastic Πλαστικό
Highlight Intensity Ένταση ανταύγειας
Highlight Size Μέγεθος ανταύγειας
Blend Steps Βήματα ανάμειξης
Shading Color Χρώμα σκίασης
Draw Hidden Faces Σχεδίαση κρυφών εδρών
Eyedropper Σταγονόμετρο (Eyedropper Tool)
Eyedropper Applies: Το σταγονόμετρο εφαρμόζει:
Eyedropper Options Επιλογές σταγονόμετρου
Can't render this file because it is too large.
+11
View File
@@ -1136,6 +1136,17 @@ font-technology Variable Variables
Exporting {} Exportando {}
Export… Exportar…
Extrude & Bevel… Extrusión y biselado…
3D (Classic) 3D (clásico)
Extrude & Bevel (Classic)… Extrusión y biselado (clásico)…
Surface Superficie
Wireframe Malla
No Shading Sin sombreado
Plastic Plástico
Highlight Intensity Intensidad del brillo
Highlight Size Tamaño del brillo
Blend Steps Pasos de fusión
Shading Color Color de sombreado
Draw Hidden Faces Dibujar caras ocultas
Eyedropper Cuentagotas
Sample Color from Screen Tomar color de la pantalla
This system has no screen color sampler Este sistema no tiene muestreador de color de la pantalla
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -472,6 +472,17 @@
Inflate Täytä ilmalla
Select objects, then pick an object type to give them a 3D effect. Valitse objektit ja valitse sitten objektityyppi, niin niille tulee 3D-tehoste.
Extrude & Bevel… Pursota ja viistä…
3D (Classic) 3D (perinteinen)
Extrude & Bevel (Classic)… Pursota ja viistä (perinteinen)…
Surface Pinta
Wireframe Rautalanka
No Shading Ei varjostusta
Plastic Muovi
Highlight Intensity Kiillon voimakkuus
Highlight Size Kiillon koko
Blend Steps Sulautusvaiheet
Shading Color Varjostusväri
Draw Hidden Faces Piirrä piilotetut pinnat
Revolve… Pyöräytä…
Inflate… Täytä ilmalla…
Materials… Materiaalit…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -1131,6 +1131,17 @@ font-technology Variable Variables
Exporting {} Exportation de {}
Export… Exporter…
Extrude & Bevel… Extrusion et biseautage…
3D (Classic) 3D (classique)
Extrude & Bevel (Classic)… Extrusion et biseautage (classique)…
Surface Surface
Wireframe Fil de fer
No Shading Sans ombrage
Plastic Plastique
Highlight Intensity Intensité de la brillance
Highlight Size Taille de la brillance
Blend Steps Étapes de transition
Shading Color Couleur d’ombrage
Draw Hidden Faces Dessiner les faces cachées
Eyedropper Pipette
Sample Color from Screen Prélever une couleur à l’écran
This system has no screen color sampler Ce système n’a pas d’outil de prélèvement de couleur à l’écran
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -1688,6 +1688,17 @@ antiAlias Sharp शार्प
Extension Lines एक्सटेंशन लाइन
Extension: विस्तार:
Extrude & Bevel… एक्सट्रूडेड और बेवेल…
3D (Classic) 3D (क्लासिक)
Extrude & Bevel (Classic)… एक्सट्रूडेड और बेवेल (क्लासिक)…
Surface सतह
Wireframe वायरफ़्रेम
No Shading कोई शेडिंग नहीं
Plastic प्लास्टिक
Highlight Intensity हाइलाइट तीव्रता
Highlight Size हाइलाइट आकार
Blend Steps ब्लेंड चरण
Shading Color शेडिंग रंग
Draw Hidden Faces छिपे हुए फलक बनाएँ
Eyedropper आईड्रॉपर
Eyedropper Applies: आईड्रॉपपर लागू होता है:
Eyedropper Options आईड्रॉपर ऑप्शन्स
Can't render this file because it is too large.
+11
View File
@@ -1420,6 +1420,17 @@ font-technology Variable Változó
Exporting {} Exportálás: {}
Export… Exportálás…
Extrude & Bevel… Kihúzás és élletörés…
3D (Classic) 3D (klasszikus)
Extrude & Bevel (Classic)… Kihúzás és élletörés (klasszikus)…
Surface Felület
Wireframe Drótváz
No Shading Nincs árnyékolás
Plastic Műanyag
Highlight Intensity Csillanás erőssége
Highlight Size Csillanás mérete
Blend Steps Átmenetlépések
Shading Color Árnyékolás színe
Draw Hidden Faces Rejtett lapok rajzolása
Eyedropper Pipetta
Sample Color from Screen Szín mintavétele a képernyőről
This system has no screen color sampler Ezen a rendszeren nincs képernyős színmintavevő
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -433,6 +433,17 @@
Inflate Gelembungkan
Select objects, then pick an object type to give them a 3D effect. Pilih objek, lalu pilih jenis objek untuk memberinya efek 3D.
Extrude & Bevel… Ekstrusi & bevel…
3D (Classic) 3D (klasik)
Extrude & Bevel (Classic)… Ekstrusi & bevel (klasik)…
Surface Permukaan
Wireframe Kerangka kawat
No Shading Tanpa pengarsiran
Plastic Plastik
Highlight Intensity Intensitas kilau
Highlight Size Ukuran kilau
Blend Steps Langkah campuran
Shading Color Warna pengarsiran
Draw Hidden Faces Gambar sisi tersembunyi
Revolve… Revolusi…
Inflate… Gelembungkan…
Materials… Material…
1 # Indonesian (id) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
+11
View File
@@ -1137,6 +1137,17 @@ font-technology Variable Variabili
Exporting {} Esportazione di {} in corso
Export… Esporta…
Extrude & Bevel… Estrusione e smusso…
3D (Classic) 3D (classico)
Extrude & Bevel (Classic)… Estrusione e smusso (classico)…
Surface Superficie
Wireframe Reticolo
No Shading Nessuna ombreggiatura
Plastic Plastica
Highlight Intensity Intensità lucentezza
Highlight Size Dimensione lucentezza
Blend Steps Passaggi di fusione
Shading Color Colore ombreggiatura
Draw Hidden Faces Disegna facce nascoste
Eyedropper Contagocce
Sample Color from Screen Campiona colore dallo schermo
This system has no screen color sampler Questo sistema non ha un campionatore di colore dello schermo
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -506,6 +506,17 @@ digits Malayalam マラヤーラム数字
Inflate 膨張
Select objects, then pick an object type to give them a 3D effect. オブジェクトを選択してから、オブジェクトの種類を選んで 3D 効果を適用してください。
Extrude & Bevel… 押し出しとベベル…
3D (Classic) 3D(クラシック)
Extrude & Bevel (Classic)… 押し出しとベベル(クラシック)…
Surface 表面
Wireframe ワイヤーフレーム
No Shading 陰影なし
Plastic プラスチック
Highlight Intensity ハイライトの強さ
Highlight Size ハイライトのサイズ
Blend Steps ブレンドの階調
Shading Color 陰影のカラー
Draw Hidden Faces 隠れた面を描画
Revolve… 回転体…
Inflate… 膨張…
Materials… マテリアル…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -398,6 +398,17 @@
Inflate გაბერვა
Select objects, then pick an object type to give them a 3D effect. აირჩიეთ ობიექტები, შემდეგ აირჩიეთ ობიექტის ტიპი, რომ მათ 3D ეფექტი მიეცეთ.
Extrude & Bevel… ამოწევა და წაკვეთა…
3D (Classic) 3D (კლასიკური)
Extrude & Bevel (Classic)… ამოწევა და წაკვეთა (კლასიკური)…
Surface ზედაპირი
Wireframe მავთულის ჩარჩო
No Shading დაჩრდილვის გარეშე
Plastic პლასტიკური
Highlight Intensity ელვარების ინტენსივობა
Highlight Size ელვარების ზომა
Blend Steps შერევის საფეხურები
Shading Color დაჩრდილვის ფერი
Draw Hidden Faces დამალული წახნაგების დახატვა
Revolve… ბრუნვა…
Inflate… გაბერვა…
Materials… მასალები…
Can't render this file because it is too large.
+11
View File
@@ -499,6 +499,17 @@ digits Malayalam 말라얄람어
Inflate 부풀리기
Select objects, then pick an object type to give them a 3D effect. 개체를 선택한 다음 개체 유형을 골라 3D 효과를 적용하십시오.
Extrude & Bevel… 돌출 및 경사…
3D (Classic) 3D(클래식)
Extrude & Bevel (Classic)… 돌출 및 경사(클래식)…
Surface 표면
Wireframe 와이어프레임
No Shading 셰이딩 없음
Plastic 플라스틱
Highlight Intensity 하이라이트 강도
Highlight Size 하이라이트 크기
Blend Steps 혼합 단계
Shading Color 셰이딩 색상
Draw Hidden Faces 숨겨진 면 그리기
Revolve… 회전체…
Inflate… 부풀리기…
Materials… 재질…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -447,6 +447,17 @@
Inflate Opblazen
Select objects, then pick an object type to give them a 3D effect. Selecteer objecten en kies dan een objecttype om ze een 3D-effect te geven.
Extrude & Bevel… Extruderen en afschuinen…
3D (Classic) 3D (klassiek)
Extrude & Bevel (Classic)… Extruderen en afschuinen (klassiek)…
Surface Oppervlak
Wireframe Draadmodel
No Shading Geen arcering
Plastic Plastic
Highlight Intensity Glansintensiteit
Highlight Size Glansgrootte
Blend Steps Overvloeistappen
Shading Color Arceringskleur
Draw Hidden Faces Verborgen vlakken tekenen
Revolve… Draaien…
Inflate… Opblazen…
Materials… Materialen…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -434,6 +434,17 @@
Inflate Nadmuchanie
Select objects, then pick an object type to give them a 3D effect. Zaznacz obiekty, a następnie wybierz typ obiektu, aby nadać im efekt 3D.
Extrude & Bevel… Wyciągnięcie i fazowanie…
3D (Classic) 3D (klasyczne)
Extrude & Bevel (Classic)… Wyciągnięcie i fazowanie (klasyczne)…
Surface Powierzchnia
Wireframe Szkielet
No Shading Bez cieniowania
Plastic Plastik
Highlight Intensity Intensywność połysku
Highlight Size Rozmiar połysku
Blend Steps Kroki przejścia
Shading Color Kolor cieniowania
Draw Hidden Faces Rysuj ukryte ściany
Revolve… Obrót…
Inflate… Nadmuchanie…
Materials… Materiały…
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -1029,6 +1029,17 @@ antiAlias Sharp Nítido
Export: Exportar:
Export… Exportar…
Extrude & Bevel… Extrusão e Bisel…
3D (Classic) 3D (Clássico)
Extrude & Bevel (Classic)… Extrusão e Bisel (Clássico)…
Surface Superfície
Wireframe Aramado
No Shading Sem Sombreamento
Plastic Plástico
Highlight Intensity Intensidade do Realce
Highlight Size Tamanho do Realce
Blend Steps Etapas de Mesclagem
Shading Color Cor do Sombreamento
Draw Hidden Faces Desenhar Faces Ocultas
Eyedropper Conta-gotas
Eyedropper Applies: O Conta-gotas Aplica:
Eyedropper Options Opções do Conta-gotas
Can't render this file because it contains an unexpected character in line 6 and column 47.
+11
View File
@@ -1134,6 +1134,17 @@ font-technology Variable Вариативные
Exporting {} Экспорт {}
Export… Экспорт…
Extrude & Bevel… Выдавливание и фаска…
3D (Classic) 3D (классические)
Extrude & Bevel (Classic)… Выдавливание и фаска (классическое)…
Surface Поверхность
Wireframe Каркас
No Shading Без затенения
Plastic Пластик
Highlight Intensity Интенсивность блика
Highlight Size Размер блика
Blend Steps Шаги перехода
Shading Color Цвет затенения
Draw Hidden Faces Рисовать скрытые грани
Eyedropper Пипетка
Sample Color from Screen Взять цвет с экрана
This system has no screen color sampler В этой системе нет средства выбора цвета с экрана
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -1115,6 +1115,17 @@ font-technology Variable Variabla
Exporting {} Exporterar {}
Export… Exportera…
Extrude & Bevel… Extrudera och fasa…
3D (Classic) 3D (klassisk)
Extrude & Bevel (Classic)… Extrudera och fasa (klassisk)…
Surface Yta
Wireframe Trådram
No Shading Ingen skuggning
Plastic Plast
Highlight Intensity Högdagerintensitet
Highlight Size Högdagerstorlek
Blend Steps Övergångssteg
Shading Color Skuggningsfärg
Draw Hidden Faces Rita dolda ytor
Eyedropper Pipett
Eyedropper Applies: Pipetten tillämpar:
Eyedropper Options Pipettalternativ
Can't render this file because it contains an unexpected character in line 5 and column 25.
+11
View File
@@ -1107,6 +1107,17 @@ font-technology Variable Değişken
Exporting {} {} dışa aktarılıyor
Export… İhracat…
Extrude & Bevel… Ekstrüzyon ve Eğim…
3D (Classic) 3D (Klasik)
Extrude & Bevel (Classic)… Ekstrüzyon ve Eğim (Klasik)…
Surface Yüzey
Wireframe Tel Kafes
No Shading Gölgeleme Yok
Plastic Plastik
Highlight Intensity Parlama Yoğunluğu
Highlight Size Parlama Boyutu
Blend Steps Karışım Adımları
Shading Color Gölgeleme Rengi
Draw Hidden Faces Gizli Yüzleri Çiz
Eyedropper Damlalık
Sample Color from Screen Ekrandan Örnek Renk
This system has no screen color sampler Bu sistemin ekran rengi örnekleyicisi yok
Can't render this file because it contains an unexpected character in line 5 and column 25.
+11
View File
@@ -1131,6 +1131,17 @@ font-technology Variable Варіативні
Exporting {} Експорт {}
Export… Експорт…
Extrude & Bevel… Видавлювання та фаска…
3D (Classic) 3D (класичні)
Extrude & Bevel (Classic)… Видавлювання та фаска (класичне)…
Surface Поверхня
Wireframe Каркас
No Shading Без затінення
Plastic Пластик
Highlight Intensity Інтенсивність відблиску
Highlight Size Розмір відблиску
Blend Steps Кроки переходу
Shading Color Колір затінення
Draw Hidden Faces Малювати приховані грані
Eyedropper Піпетка
Sample Color from Screen Взяти колір з екрана
This system has no screen color sampler У цій системі немає засобу вибору кольору з екрана
Can't render this file because it contains an unexpected character in line 182 and column 5.
+11
View File
@@ -1297,6 +1297,17 @@ font-technology Variable Biến thiên
Exporting {} Đang xuất {}
Export… Xuất…
Extrude & Bevel… Kéo dài & vát…
3D (Classic) 3D (cổ điển)
Extrude & Bevel (Classic)… Kéo dài & vát (cổ điển)…
Surface Bề mặt
Wireframe Khung dây
No Shading Không tô bóng
Plastic Nhựa
Highlight Intensity Cường độ điểm sáng
Highlight Size Kích thước điểm sáng
Blend Steps Số bước hòa trộn
Shading Color Màu tô bóng
Draw Hidden Faces Vẽ các mặt ẩn
Eyedropper Ống hút màu
Sample Color from Screen Lấy mẫu màu từ màn hình
This system has no screen color sampler Hệ thống này không có công cụ lấy mẫu màu trên màn hình
Can't render this file because it contains an unexpected character in line 204 and column 5.
+11
View File
@@ -1077,6 +1077,17 @@ antiAlias Sharp 锐利
Exporting {} 正在导出 {}
Export… 导出…
Extrude & Bevel… 突出与斜角…
3D (Classic) 3D (经典)
Extrude & Bevel (Classic)… 突出与斜角 (经典)…
Surface 表面
Wireframe 线框
No Shading 无底纹
Plastic 塑料
Highlight Intensity 高光强度
Highlight Size 高光大小
Blend Steps 混合步骤
Shading Color 底纹颜色
Draw Hidden Faces 绘制隐藏表面
Eyedropper 吸管
Sample Color from Screen 从屏幕取样颜色
This system has no screen color sampler 此系统没有屏幕取色工具
Can't render this file because it contains an unexpected character in line 2 and column 73.
+11
View File
@@ -1101,6 +1101,17 @@ font-technology Variable 可變
Exporting {} 正在匯出 {}
Export… 匯出…
Extrude & Bevel… 突出與斜角…
3D (Classic) 3D (經典)
Extrude & Bevel (Classic)… 突出與斜角 (經典)…
Surface 表面
Wireframe 線框
No Shading 無網底
Plastic 塑膠
Highlight Intensity 亮部強度
Highlight Size 亮部大小
Blend Steps 漸變階數
Shading Color 網底顏色
Draw Hidden Faces 繪製隱藏表面
Eyedropper 滴管
Sample Color from Screen 從螢幕取樣顏色
This system has no screen color sampler 此系統沒有螢幕取色工具
Can't render this file because it contains an unexpected character in line 6 and column 47.
+10 -1
View File
@@ -108,6 +108,9 @@ pub(super) fn font_items(english_names: bool) -> Vec<Item> {
items.3.clone()
}
/// The catalogue's menu path of the effects in 3D and Materials › 3D (Classic).
const CLASSIC_3D: &[&str] = &["Effect", "3D and Materials", "3D (Classic)"];
/// The raster effects' submenus of the Effect menu (the Photoshop-style effects, below the
/// vector effects): (submenu, the catalogue's menu path of its effects).
/// The raster Stylize submenu shares its path with the vector one and lists only the effects at
@@ -194,6 +197,10 @@ pub(super) fn effect_menu() -> Vec<Item> {
_ => 9,
});
items.push(todo("Materials…"));
let classic: Vec<Item> = cat.iter().filter(|e| e.menu == CLASSIC_3D).map(item).collect();
if !classic.is_empty() {
items.push(sub("3D (Classic)", classic));
}
}
out.push(sub(sub_name, items));
}
@@ -214,7 +221,9 @@ pub(super) fn effect_menu() -> Vec<Item> {
out.extend(raster);
}
// Anything not placed above (future effects) still shows up.
let placed = |e: &vectorcraft_effects::EffectInfo| top_level(e) || in_vector_menu(e) || RASTER_MENUS.iter().any(|(_, path)| e.menu == *path);
let placed = |e: &vectorcraft_effects::EffectInfo| {
top_level(e) || in_vector_menu(e) || e.menu == CLASSIC_3D || RASTER_MENUS.iter().any(|(_, path)| e.menu == *path)
};
for e in cat.iter().filter(|e| !placed(e)) {
out.push(Item::Cmd(e.label, "effect.dialog", json!({ "effect": e.id })));
}
+16
View File
@@ -377,6 +377,22 @@ fn saved_views_store_and_restore_the_view() {
assert!(!menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("view.goto2")));
}
/// Effect › 3D and Materials › 3D (Classic) › Extrude & Bevel (Classic)… opens its effect dialog,
/// after Materials…, and nowhere else.
#[test]
fn classic_extrude_bevel_is_in_the_3d_classic_submenu() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let entries = menu_entries(&app);
let classic: Vec<_> = entries.iter().filter(|e| e.params.get("effect").and_then(Value::as_str) == Some("threeD.classicExtrude")).collect();
assert_eq!(classic.len(), 1, "listed once");
let e = classic[0];
assert_eq!(e.path, ["Effect", "3D and Materials", "3D (Classic)"]);
assert_eq!((e.label.as_str(), e.command.as_deref()), ("Extrude & Bevel (Classic)…", Some("effect.dialog")));
let at = |label: &str| entries.iter().position(|x| x.label == label && x.path.get(1).is_some_and(|p| p == "3D and Materials"));
assert!(at("Materials…").unwrap() < at("Extrude & Bevel (Classic)…").unwrap());
}
/// A document's 25 views are all listed at the bottom of the View menu, by name, and the last
/// one brings back its zoom, centre and rotation after the view changed.
#[test]
+3 -3
View File
@@ -13,7 +13,7 @@ Part of [target-app-parity.md](target-app-parity.md); open work is in [gaps.md](
|---|---:|---:|---:|---:|
| Illustrator effects (vector and stylize) | 50 | 50 | 100% | — |
| Photoshop effects (raster, Effect Gallery) | 57 | 57 | 100% | 1–2 (Effect Gallery thumbnails and in-dialog preview) |
| 3D and Materials | 4 (+3 Classic) | 4 (initial: Extrude & Bevel, Revolve, Inflate, Rotate) | ~25% | 25–45 |
| 3D and Materials | 4 (+3 Classic) | 4 (+1 Classic; initial: Extrude & Bevel, Revolve, Inflate, Rotate, Extrude & Bevel (Classic)) | ~25% | 25–45 |
| Beyond Illustrator: Effect › Color Adjustments (6), effect plug-ins (WebAssembly) | — | 6 + plug-ins | — | — |
## Illustrator effects
@@ -181,13 +181,13 @@ VectorCraft choices; an original approximation, never compared with another appl
| Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) |
| Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) |
| Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; no materials, shadows or ray tracing |
| 3D (Classic): Extrude & Bevel, Revolve, Rotate | none |
| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic), Rotate (Classic) and Map Art: none |
| Turntable (30.0 beta) | none |
| Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object collects an object with Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, and Export writes its solids as a USDA (the default), USDZ, glTF or OBJ mesh with a material in each solid's paint color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`); no textures, map art or shading ([3D export help](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)) |
The `three-d` crate (depends on `geom` and `pathops`, with no UI or GPU dependencies) builds, rotates, projects, shades and depth-sorts the surfaces;
the effects crate turns them into art. What's left (materials and shadows, ray-traced rendering,
angular cut caps, exact visibility, 3D Classic, Turntable) is 25–45 h.
angular cut caps, exact visibility, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable) is 25–45 h.
## Revision history
+2 -2
View File
@@ -641,8 +641,8 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
- **Done:** an initial live Revolve in the `three-d` crate (#846, from the contributor who offered in #605), with automatic smooth vector shading, default-on circular caps and spike-free continuous rims;
Extrude & Bevel, Inflate and Rotate with Position presets ([three-d.md](three-d.md), #1407). Colored Inflate has automatic smooth vector lighting from shared membrane normals. White-light Inflate keeps its existing appearance. All four take one to ten colored directional lights, with neutral ambient applied once, a light list and independent previewing controls. Standard, Diffuse, Top Left and Right select original rigs, shared with commands; manual settings show Custom.
- **Missing:** materials (base, Substance, graphics), shadows,
ray-traced rendering, angular cut caps, exact visibility for intersecting and concave surfaces, 3D Classic,
Turntable, textures, map art and shading in 3D export.
ray-traced rendering, angular cut caps, exact visibility for intersecting and concave surfaces, the rest of 3D
Classic (Revolve, Rotate, Map Art), Turntable, textures, map art and shading in 3D export.
- **Impact:** specialist; Materials is the one menu stub left.
- **Estimate:** 25–45 h; plan in `plan/` before coding.
+30 -2
View File
@@ -2348,8 +2348,9 @@ See [Revolve](revolve.md) for the scope and limitations.
`effect.lightingPreset {index: int, preset: "standard" | "diffuse" | "topLeft" | "right", item?, ids?, target?}`
replaces the lighting rig and ambient of an existing Revolve, Extrude & Bevel, Inflate or Rotate
effect. `item: null` addresses object-level effects; an item index addresses a fill/stroke's
effects. All targets must have a 3D effect at `index`, otherwise nothing changes. The operation
is one undo step and preserves Shading, geometry, source paint and the view.
effects. All targets must have a 3D effect at `index`, otherwise nothing changes. Extrude & Bevel
(Classic) has no lighting presets: a target with it at `index` is an error, nothing changes, and
`effect.setParams` sets its lights. The operation is one undo step and preserves Shading, geometry, source paint and the view.
The four original rigs use one white front light at 80% intensity. Standard keeps the existing
default; Diffuse uses 100% Softness and 35% Ambient Light, Top Left uses Rotation 135 and Height
@@ -2391,6 +2392,33 @@ All three take `rotationX/Y/Z` (degrees, -180..180, default the Off-Axis Front p
`perspectiveAngle` and the lighting parameters as Revolve. See [three-d.md](three-d.md) for the Position presets,
the scope and what differs from the public documentation.
## Extrude & Bevel (Classic)
`effect.apply {effect: "threeD.classicExtrude", params: {...}, ids?, item?}` is Effect › 3D and Materials ›
3D (Classic) › Extrude & Bevel (Classic)…. It builds the same solid as `threeD.extrude` and draws its faces with the
classic surfaces. Parameters: `depth` (points, 0..2000, default 50), `cap` (true solid, false hollow), `bevel` (`none`,
`classic`, `rounded`, `cove`, `step`, `ogee`), `bevelHeight` (points, 1..100, default 4; the bevel is as wide as it
is tall), `bevelInside` (false adds the bevel around the shape, true carves it into the shape), `rotationX/Y/Z` and
`perspectiveAngle` as above, and the surface:
- `surface`: `wireframe` (the edges of every face, hidden faces included, with no fill), `none` (the art's own paint,
unlit), `diffuse` (soft light) or `plastic` (soft light plus a highlight; the default).
- `lightRotation`, `lightHeight`, `lightBehind` and `lightIntensity` (0..100%, default 100) place one light, or
`lights` holds one to ten (`intensity`, `rotation`, `height`, `behind`; their light adds up, and `color` and
`softness` are not used). `ambient` (0..100%, default 50) lights every face.
- `highlightIntensity` (0..100%, default 60) and `highlightSize` (0..100%, default 90; 100 is broad) shape the Plastic
highlight. `blendSteps` (whole number, 1..256, default 25) is how many levels the shading is rounded to.
- `shadingColor`: `black` (the default), `custom` (darkens toward `shadingCustom`, a `#rrggbb` color) or `none` (the
paint is kept and only the highlight lightens it). `drawHiddenFaces` also draws the faces turned away from the
viewer, behind the others.
The defaults are VectorCraft's own. An unknown parameter, a number out of range, a flag that is not true or false, or
an unknown surface or shading color is refused with an error and the document is not changed.
```json
{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"threeD.classicExtrude","item":null,"params":{"depth":40,"bevel":"rounded","bevelHeight":6,"surface":"diffuse","blendSteps":64}}}}
```
## Edit Colors and Recolor Artwork
+4 -4
View File
@@ -201,7 +201,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
| Colour, swatches, gradients, patterns, mesh, recolor | 7 | 92% | 30.0's gradient additions are done (dither, perceptual blending, presets); freeform gradients in SVG/PDF, mixed spot/process gradients in SVG, PDF gradients mixing a spot color with RGB or Lab stops, and dithered spot gradient fills and Perceptual spot gradients with RGB or Lab stops other than white or more than 4,096 stops on their curve, which PDF export writes as images in process colors | 4–7 | estimated; Recolor model selection is available through the engine and MCP (#2520), and the dialog's color sliders (HSB, RGB, CMYK, Lab, Tint for a spot color) edit the selected row's new color in its mode, and the Edit tab's Global Adjust sliders (Saturation, Brightness, Temperature, Luminosity, -100% to 100%) change every recolored color at once (lowered from 96%: target moved); unpainted zero-width stroke restoration fixed (#1253), score and hours unchanged; Eyedropper source reads regression-tested (#1251); screen-bounded Recolor Edit chip list (#3416), score and hours unchanged |
| Strokes, brushes, width profiles | 5 | 88% | Shift-double-click adjoining adjustment is implemented; brush libraries are done (four generated libraries, User Defined, Other Library…, Save Brushes); remaining: Art brush Overlap, Pattern brush auto corners, pen pressure on macOS, tilt/bearing/rotation | 8–15 | estimated; width-point creation and adjoining adjustment regression-tested; dialog layout reviewed; evidence below; finite-arithmetic follow-up regressions and full CI passed (evidence below) |
| Appearance, transparency, graphic styles, masks | 5 | 97% | raster effect reach in preview bounds | 1–2 | estimated; Transform typing, sliders, preview, and undo are covered in `crates/ui-egui/src/panels/tests_appearance.rs` |
| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, 3D Classic, Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) |
| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, painter visibility); materials, light presets, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) |
| Type core | 9 | 80% | Auto-leading style resets are implemented; Hyphenation… and Justification… options are done (#2310, #2755), and a paragraph's Auto Leading also sets the Character panel's Leading, the Alt+↑/↓ leading nudge and Fill with Placeholder Text; remaining: Optical Margin Alignment, Middle Eastern features beyond bidi layout, optional group font-size preservation; transformed character controls fixed (#1034), see [type-parity.md](type-parity.md) | 14–19 | estimated; eleven-language pattern hyphenation in both composers, with grapheme and shaping-cluster protection (#4947, `hyphen::tests` and `layout::wrapping_tests`); Sanskrit patterns pending an allowlisted source; recent-font groups in font pickers (#3515); full-name font matches regression-tested (#1258); mixed paragraph alignment indication implemented; headless regressions, full integration CI and visual review pass; localized legacy family-name fallback with loaded/catalog regressions (#2208); Fractional Widths per type object regression-tested; color emoji fonts with bitmap glyphs (`sbix`, `CBDT`) draw and export; threading ports: every selected area type and type on a path shows its in and out ports (empty, arrow, red "+"), and the Selection tool threads from either, the in port into a frame before it, and a double-click on a threaded port breaks the thread there (`threads::tests::break_splits_the_thread_and_the_story_flows_into_the_first_part`, `select::tests::a_double_click_on_a_threaded_port_breaks_the_thread`, `canvas::tests::a_double_click_on_a_threaded_out_port_breaks_the_thread`) (#2572; `select::tests::a_click_on_the_in_port_threads_into_a_frame_before_it`, `text::tests::selected_type_shows_its_ports_in_each_state`, `threads::tests::add_before_threads_the_story_from_the_new_frame_into_the_clicked_one`, live screenshot); the Glyphs panel highlights and scrolls to the character selected with the Type tool (#4908, `panels::glyphs` tests); the OpenType panel lists only `ss01`–`ss20` sets present in the selected font and uses its custom names when supplied; Type-range desktop Copy publishes styled art and keeps font family, size and color on canvas Paste after editing (#5636) |
| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (ruby, mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, on-canvas Direct Selection of parts inside dynamic symbol instances and their `.ai` round trip, more built-in symbol libraries, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic symbols keep per-instance appearance linked, #4938) are done | 4–8 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged |
@@ -209,7 +209,7 @@ dominate; 3D, graphs and automation are specialist). They sum to 105. Feature de
| Symbols, blends, envelopes, Repeat, perspective | 5 | 87% | 30.7's Blend panel with ease in/out, on-canvas Direct Selection of parts inside dynamic symbol instances and their `.ai` round trip, symbol libraries, Start Global Edit, envelope Anti-Alias/Preserve Shape; Symbol Options › Registration, Export Type (Graphic / Movie Clip) and 9-slice guides (drawn and draggable during symbol editing, #5280) and Symbol Type (dynamic symbols keep per-instance appearance linked, #4938) are done | 4–8 | estimated; Blend panel Type and Ramp control step easing with a separate color Shift (#2539); Blend Options retains spacing and orientation; polynomial curves are original approximations; interactive selected mirror axis implemented; bounding-box handles take priority over the axis line (focused regression and native checks passed; full CI blocked by an unrelated text test); #2544 spine Anchor Point conversion and picking regression-tested; scores and hours unchanged |
| Image Trace, graphs, image tools | 3 | 75% | graph Column designs, Image Trace Create Strokes depth (Create Object Mosaic is done; junction-returning piece survival has source-generated regressions in `image-trace-quality.md`; simpler contour walk, identical output; an edge's sub-pixel shift is the same from either side; notches read only their own piece; collapsed loops keep few segments, so Paths is monotone on the code-drawn sweep) | 5–8 | estimated (raised from 72%); quick presets reveal adjustment controls, Properties reopens them; Live trace result bounds and selection highlights exclude the retained bitmap except in source-image views (scores/hours unchanged) |
| Live vector effects | 5 | 85% | Outline Object, SVG Filters | 4–8 | measured count 49/50 (Pathfinder 13/13 with Trap), depth estimated; Warp Options controls verified by focused checks and original-image review |
| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, 3D Classic, Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) |
| 3D and Materials (and Turntable) | 4 | ~25% | live Revolve with automatic smooth vector shading and default-on continuous circular caps, Extrude & Bevel (twist, taper, caps, five bevel shapes), Inflate and Rotate with Position presets (colored Inflate uses smooth membrane lighting; white-light Inflate retains its existing shading; Extrude & Bevel and Rotate retain planar shading; one to ten colored lights, shared neutral ambient, original lighting presets, painter visibility); materials, shadows, ray tracing, the rest of 3D Classic (Revolve, Rotate, Map Art), Turntable views, textures, map art and shading in 3D export | 30–55 | estimated (Extrude & Bevel, Inflate and Rotate added 2026-10-12) |
| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), variable font axes, tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged |
| Type advanced (CJK, spelling, Touch Type, Retype) | 4 | 50% | CJK vertical composition (mojikumi), tab leaders, spell check, Touch Type, Retype | 20–28 | estimated; No Break keeps marked characters on one line; upright fallback for fullwidth `!` / `?` regression-tested; score and hours unchanged |
| Raster effects (Effect Gallery) | 4 | 52% | the Effect Gallery's filter thumbnails and in-dialog preview area ([Filter Gallery overview](https://helpx.adobe.com/photoshop/desktop/effects-filters/get-started-with-filters/filter-gallery.html)) | 1–2 | estimated (raised from 49%: the Effect Gallery shipped, #2883, six categories, per-filter settings, ordered stack, live canvas preview, one undo step, `dialogs::effect_gallery::tests::stack_ok_is_one_undo_step`, `stack_preview_is_one_interaction`); measured count 57/57 (Artistic complete with Colored Pencil, Fresco, Paint Daubs, Palette Knife, Rough Pastels and Underpainting; Sketch complete with Torn Edges and Conté Crayon, `pixel::tests_sketch::conte_crayon_*`); texture redraw distances optimized (#1257), pixels verified; depth estimated |
@@ -474,10 +474,10 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Strokes, brushes, width profiles | 5 | 90% | the Art brush's Overlap, auto-generated Pattern brush corners and new tile art in Pattern Brush Options (Brush Options for every brush type done), more built-in brush libraries (four generated in code are done), pen pressure on macOS (the windowing layer reports none there), brushes driven by pen tilt, bearing and barrel rotation (6D art pens) | 8–15 |
| Appearance, transparency, graphic styles, masks | 5 | 98% | raster effect reach in preview bounds (flattener presets apply in PDF, EPS and print) | 1–2 |
| Live vector effects | 5 | 85% | Outline Object, Pathfinder Hard/Soft Mix and Trap (since done: #3061, #3063, #3785), SVG Filters, Transform Patterns and Scale Strokes & Effects on a fill's or stroke's own Transform effect | 6–10 |
| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, 3D Classic and more complete visibility remain | 25–45 |
| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting; materials, light presets, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 |
| Type advanced | 4 | 50% | CJK composition for vertical type (ruby, mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 |
| Raster effects (Effect Gallery, Document Raster Effects Settings) | 4 | 35% | ~36 Photoshop-style filters (Artistic, Brush Strokes, Sketch, Stylize) and Texturizer's Load Texture (since done, see the area table above, as is the Effect Gallery, #2883); Document Raster Effects Settings, raster effects in PDF, the pixel-filter pipeline, Radial Blur, Smart Blur (Normal, Edge Only and Overlay Edge), Unsharp Mask, Glowing Edges, Distort (Diffuse Glow, Glass, Ocean Ripple), Pixelate (Color Halftone, Crystallize, Mezzotint, Pointillize), Texture (Craquelure, Grain, Mosaic Tiles, Patchwork, Stained Glass, Texturizer) and Video (De-Interlace, NTSC Colors) are done | 8–14 |
| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, 3D Classic and more complete visibility remain | 25–45 |
| 3D and Materials | 4 | ~25% | Initial Revolve, Extrude & Bevel, Inflate and Rotate, with simple lighting and four original lighting presets; materials, shadows, ray tracing, Revolve (Classic), Rotate (Classic), Map Art and more complete visibility remain | 25–45 |
| Type advanced | 4 | 50% | CJK composition for vertical type (mojikumi beyond Line-end Punctuation Half Width — the other sets (and their paragraph-start bracket and indent rules), custom kinsoku sets (beyond Hard, Soft and None) and the Mojikumi Settings dialog —, manual tate-chu-yoko is done, its up/down settings remain; No Break is done), tab leaders, spell check (open dictionary), Touch Type, Retype; vertical point/area/path type and Type Orientation have initial support (beyond parity, not scored: inline symbols in text) | 20–28 |
| Type core | 9 | 80% | Middle Eastern features beyond bidi layout and Paragraph Direction (the Middle Eastern composers, editable bidirectional SVG text, a split caret where directions meet); Every-line Composer fidelity for justified and ragged text (available per object; mixed type sizes in a paragraph still break single-line in non-rectangular area type and with text wrap), Optical Margin Alignment, paragraph spacing limit for vertically justified area type, hidden characters, paragraph attributes in the Type tool's copy and paste (the characters keep their styles, the paragraphs take the destination's), mixed-value display in the Paragraph panel for a selection across differing paragraphs, partial selection of type-area anchors by marquee, optional font-size preservation during group scaling (scaled character controls fixed in #1034). Hyphenation and Justification options, Fractional Widths, System Layout, styled Type-range canvas Paste (#5636), and pattern hyphenation for eleven Indian languages with grapheme/cluster protection (#4947) are done; Sanskrit automatic patterns remain pending an allowlisted source | 14–19 |
| Symbols, blends, envelopes, Repeat, perspective | 5 | 90% | more built-in symbol libraries (original), dynamic symbols, envelope Anti-Alias/Preserve Shape output and warp point editing, brush interpolation in blends (M8 fidelity pass done); Symbol Options › Registration and Export Type are done; symbol editing shows draggable 9-slice guides (#5280), while guides outside the artwork remain unsupported | 4–7 |
+2 -2
View File
@@ -239,8 +239,8 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used.
counted on its own), Rotate (which only turns the outline) 65,536. The limit is counted on the shape as drawn and,
when its contours meet, again on the merged outline, which can have more points (bars crossing in a grid trace
every hole between them); past it, the shape is too complex for the effect.
- **3D (Classic)** (the track cube, surface types, blend steps, map art), Turntable and 3D export are separate,
unbuilt items.
- **3D (Classic)**: Extrude & Bevel (Classic) is built, with its surface types and blend steps. Revolve (Classic),
Rotate (Classic), the track cube, map art, Turntable and 3D export are separate, unbuilt items.
Release measurements and a repeatable CLI scene are in [3D lighting performance](3d-lighting-performance.md).