Merge pull request #1670 from AyberkA0/feat/3d-documented-lighting

3D and Materials: the light takes the documented settings
This commit is contained in:
BFlat authored and GitHub committed 2026-10-14 05:37:56 +07:00
commit 449b0d4056
33 files changed
+380 -173

No files matched your search

+1
View File
@@ -132,6 +132,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| 2026-10-13 | The interface is available in Polish (`pl`): menus, panels, dialogs and messages, with one/few/many plurals; AI-drafted, native review pending (#1498). Native documents with a lot of path data (traced or generated art, in layers, symbols or patterns) are saved as compact JSON, about a fifth of the size; small documents stay pretty-printed (#1492). Paste takes a PDF or EPS another app copied as editable vector art: from the macOS pasteboard (with SVG), EPS on Windows beside PDF, and `.eps` files copied in a file manager, with the new command `clipboard.importEps` (#1538, fixes #1499). Opening or placing a file with an image too large to decode says so, and the Links panel shows it on the row (#1540, part of #1479). Import: a multi-artboard `.ai` with notes beside its artboards opens from its editing data, a page's art past its artboard no longer lying over the page before it (#1528); a `.ai` gradient whose stops are listed upwards keeps each midpoint on the stop that starts its part of the ramp (#1530); SVG text on a path follows basic shapes (circle, ellipse, rect, line, polyline, polygon) as their SVG 2 equivalent paths (#1531, fixes #1527); PDFs whose alpha soft masks are drawn as images import with the mask instead of empty art (#1551); EPS and PostScript type set in Type 42 (TrueType) fonts imports at its size instead of 1000 times too large (#1553); PDF and EPS import keep a clipping path whose bounding box covers the page but whose shape doesn't (#1544, fixes #1542). Make Compound Path on a group (such as outlined type) keeps the paint of its backmost path instead of drawing nothing, and compound paths joined into one keep their holes (#1537). Live Corners: a round corner next to a curved side curves smoothly instead of sticking out of the shape, and the Smooth tool smooths only the stretch of path under the drag (#1547, fixes #1512). Join merges the selected anchor points that lie on one another, as after Average, into one anchor (#1582, fixes #1533). Guides: Lock Guides also locks the objects made into guides: they can't be selected, moved or deleted (#1543); they are drawn in the guide colour and style of Preferences, and a dragged selection snaps to ruler guides with Smart Guides on (#1583, fixes #1534). Edit › Keyboard Shortcuts lists Edit › Duplicate and the other commands that are in no menu but run without parameters (panel-menu commands such as Break Link to Symbol), so they can be given shortcuts (#1549, #1580, fixes #1539). Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536). Fonts: the font scan on Linux also reads the folders fontconfig's configuration lists, such as TeX fonts under `/usr/share/texmf/fonts` (#1548, fixes #1529; not verified on Linux). |
| 2026-10-13 | Image Trace's colour presets trace flat art with its own colours however many are asked for (noise no longer shatters it: 16 Colors 70 → 4 paths on the harness's flat art; no slivers of in-between colour along anti-aliased edges), and the photo presets trace a photo into smooth tones without specks or hairlines (High Fidelity Photo 3,653 → 121 paths, colour error 2.30 → 1.75; 16 megapixels traced where 4 were refused) ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)). |
| 2026-10-13 | Image Trace traces anti-aliased and blurred images to a fraction of a pixel in Black and White, Grayscale and Color, with corners put back, straight edges as lines and round outlines as circles; a harness measures every preset before and after ([`docs/image-trace-quality.md`](docs/image-trace-quality.md)): outlines 0.31 → 0.09 px off on average (Default), 0.27 → 0.06 (Black and White Logo). |
| 2026-10-13 | Effect › 3D and Materials: the light takes the documented Intensity, Rotation, Height, Softness and Ambient Light (to 200 %) and can shine from behind the object, for every 3D type; effects saved with the earlier direction are lit exactly as before. |
| 2026-10-13 | Effect › 3D and Materials: Perspective is the documented 0°–160° angle for every 3D type, Revolve included; effects saved with the earlier 0–100 % strength look exactly as before. |
| 2026-10-13 | Effect › 3D and Materials: Inflate takes Twist, Taper and Cap (Hollow leaves the back open), as the 3D docs list for every type. |
| 2026-10-12 | Effect › 3D and Materials: Extrude & Bevel (depth, twist, taper, solid or hollow caps, five bevel shapes with repeat, spacing, inside and both sides), Inflate (volume, both sides) and Rotate, with Position presets and the rotation and light gizmos; live, exported as vector faces, expandable (#1407). |
+29 -12
View File
@@ -200,29 +200,29 @@ pub fn effect_catalog() -> Vec<EffectInfo> {
REVOLVE,
"Revolve…",
&["Effect", "3D and Materials"],
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), 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), segments: integer (8..128, 64), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Open paths create uncapped surfaces; intersecting profiles use approximate painter visibility.",
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0,"expandVisibleOnly":true}),
"{angle: degrees (0..360, 360), offset: pt (0..100000, 0), edge: left|right, rotationX: degrees (0), rotationY: degrees (0), rotationZ: degrees (0), 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), segments: integer (8..128, 64), shade: bool (true), 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), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Open paths create uncapped surfaces; intersecting profiles use approximate painter visibility.",
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspectiveAngle":0.0,"segments":64,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"expandVisibleOnly":true}),
),
g(
EXTRUDE,
"Extrude & Bevel…",
&["Effect", "3D and Materials"],
"{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), 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), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
"{depth: pt (0..2000, 50), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: hollow), bevel: none|classic|rounded|cove|step|ogee (none), bevelWidth: pt (0..1000, 4), bevelHeight: pt (0..1000, 4), bevelRepeat: integer (1..32, 1), bevelSpace: % (0..90, 0; flat between repeats), bevelInside: bool (false: added around the shape; true: carved into it), bevelBothSides: bool (false), 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), 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)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
),
g(
INFLATE,
"Inflate…",
&["Effect", "3D and Materials"],
"{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), 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), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. Painted with the top fill (else the top stroke).",
json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
"{depth: pt (0..2000, 0; flat depth between front and back), twist: degrees (0..360, 0; the back turned against the front), taper: % (1..100, 100; the back's size), cap: bool (true: solid; false: no back, a shell open behind), volume: % (0..100, 50; 50 rounds the edge like a half sphere), bothSides: bool (false: flat back), 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), 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)}. Painted with the top fill (else the top stroke).",
json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
),
g(
ROTATE,
"Rotate…",
&["Effect", "3D and Materials"],
"{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), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25)}. 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,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0}),
"{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), 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,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
),
g(
"convertToShape.rectangle",
@@ -657,21 +657,38 @@ pub fn merged_params(id: &str, params: &Value) -> Value {
Value::Object(out)
}
/// `params` for effect `id` in their current form: the 3D effects took Perspective as a 0–100 %
/// strength, `perspective`, before the documented 0°–160° angle, `perspectiveAngle`. A strength
/// (from a file or a script of then) becomes the angle that shows exactly the same view, unless an
/// angle is given too, which wins.
/// `params` for effect `id` in their current form. The 3D effects took Perspective as a 0–100 %
/// strength, `perspective`, before the documented 0°–160° angle, `perspectiveAngle`, and the light
/// as `lightAzimuth` and `lightElevation` before the documented Rotation and Height
/// (`lightRotation`, `lightHeight`, `lightBehind`). Earlier values (from a file or a script of
/// then) become the current ones that show exactly the same, unless those are given too, which win.
pub fn upgrade_params(id: &str, params: &Value) -> Value {
match params {
Value::Object(m) => {
let mut m = m.clone();
upgrade_perspective(id, &mut m);
upgrade_light(id, &mut m);
Value::Object(m)
}
other => other.clone(),
}
}
fn upgrade_light(id: &str, m: &mut Map<String, Value>) {
if !is_three_d(id) {
return;
}
let (azimuth, elevation) = (m.remove("lightAzimuth"), m.remove("lightElevation"));
if azimuth.is_none() && elevation.is_none() || m.contains_key("lightRotation") || m.contains_key("lightHeight") {
return;
}
let degrees = |v: Option<Value>, default: f64| v.and_then(|v| v.as_f64()).unwrap_or(default);
let (rotation, height, behind) = vectorcraft_three_d::light_from_azimuth_elevation(degrees(azimuth, -45.0), degrees(elevation, 45.0));
m.insert("lightRotation".into(), json!(rotation));
m.insert("lightHeight".into(), json!(height));
m.insert("lightBehind".into(), json!(behind));
}
fn upgrade_perspective(id: &str, m: &mut Map<String, Value>) {
if !is_three_d(id) {
return;
+4 -2
View File
@@ -31,9 +31,11 @@ pub fn revolve_options(p: &Value) -> Revolve {
rotation_z: num(p, "rotationZ", 0.0),
perspective: num(p, "perspectiveAngle", 0.0),
segments: num(p, "segments", 64.0).clamp(8.0, 128.0) as usize,
light_azimuth: num(p, "lightAzimuth", -45.0),
light_elevation: num(p, "lightElevation", 45.0),
light_rotation: num(p, "lightRotation", vectorcraft_three_d::DEFAULT_LIGHT.0),
light_height: num(p, "lightHeight", vectorcraft_three_d::DEFAULT_LIGHT.1),
light_behind: flag(p, "lightBehind", false),
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
light_softness: num(p, "lightSoftness", 0.0) / 100.0,
ambient: num(p, "ambient", 25.0) / 100.0,
shade: flag(p, "shade", true),
}
+4 -2
View File
@@ -62,9 +62,11 @@ pub fn view_options(p: &Value) -> View {
rotation_y: num(p, "rotationY", ROTATION_PRESETS[6].1[1]),
rotation_z: num(p, "rotationZ", ROTATION_PRESETS[6].1[2]),
perspective: num(p, "perspectiveAngle", 0.0),
light_azimuth: num(p, "lightAzimuth", -45.0),
light_elevation: num(p, "lightElevation", 45.0),
light_rotation: num(p, "lightRotation", vectorcraft_three_d::DEFAULT_LIGHT.0),
light_height: num(p, "lightHeight", vectorcraft_three_d::DEFAULT_LIGHT.1),
light_behind: flag(p, "lightBehind", false),
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
light_softness: num(p, "lightSoftness", 0.0) / 100.0,
ambient: num(p, "ambient", 25.0) / 100.0,
shade: flag(p, "shade", true),
}
+42
View File
@@ -178,6 +178,48 @@ fn an_earlier_perspective_strength_draws_as_its_angle() {
}
}
/// Effects from before Rotation and Height set the light by azimuth and elevation: each 3D effect
/// shades its faces exactly as with the Rotation, Height and side that stand for it.
#[test]
fn an_earlier_light_direction_shades_as_its_rotation_and_height() {
fn colours(n: &Node, out: &mut Vec<[f32; 3]>) {
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first()
&& let Some(c) = f.paint.color()
{
out.push(c.to_rgb());
}
for c in n.children().into_iter().flatten() {
colours(c, out);
}
}
for id in SOLIDS.iter().copied().chain([crate::REVOLVE]) {
for (azimuth, elevation) in [(-45.0, 45.0), (150.0, -30.0), (60.0, 80.0), (-120.0, 10.0)] {
let with = |light: Value| {
let mut p = json!({"rotationX": -25, "rotationY": 35, "rotationZ": 0, "depth": 60, "lightSoftness": 0});
p.as_object_mut().unwrap().extend(light.as_object().unwrap().clone());
let mut n = square_node();
n.appearance.effects.push(Effect { id: id.into(), params: p, visible: true });
let art = if id == crate::REVOLVE { crate::revolve_art(&n) } else { solid_art(&n) }.unwrap();
let mut out = vec![];
colours(&art, &mut out);
out
};
let (r, h, behind) = vectorcraft_three_d::light_from_azimuth_elevation(azimuth, elevation);
let old = with(json!({"lightAzimuth": azimuth, "lightElevation": elevation}));
let new = with(json!({"lightRotation": r, "lightHeight": h, "lightBehind": behind}));
assert!(!old.is_empty() && old.len() == new.len(), "{id}");
for (a, b) in old.iter().zip(&new) {
assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6), "{id} {azimuth} {elevation}: {a:?} against {b:?}");
}
}
let merged = crate::merged_params(id, &json!({"lightAzimuth": 90, "lightElevation": 0}));
assert!(
merged.get("lightAzimuth").is_none() && merged["lightHeight"].as_f64().unwrap().abs() < 1e-9 && merged["lightBehind"] == json!(false),
"{id}: {merged}"
);
}
}
#[test]
fn effects_before_it_shape_the_solid_and_effects_after_it_bend_the_result() {
let mut n = square_node();
+22 -2
View File
@@ -327,8 +327,8 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
if !cur.is_object() {
*cur = effects::merged_params(&e.id, &Value::Null);
}
// Earlier parameters (a Perspective strength) take their current form first, so the
// new values replace them and the rest is kept.
// Earlier parameters (a Perspective strength, a light by azimuth and elevation) take
// their current form first, so the new values replace them and the rest is kept.
*cur = effects::upgrade_params(&e.id, cur);
if let Value::Object(m) = cur {
m.extend(new);
@@ -627,6 +627,26 @@ mod tests {
assert_eq!(params(&s), json!({"depth": 50, "perspectiveAngle": 120}));
}
/// Scripts from before Rotation and Height give the light by `lightAzimuth` and
/// `lightElevation`: stored as the same light; a later Height keeps that light's Rotation.
#[test]
fn an_earlier_light_direction_is_set_as_rotation_and_height() {
let (mut s, id) = session_with_rect();
s.execute(
"effect.apply",
&json!({"effect": effects::ROTATE, "ids": [id.0], "item": null, "params": {"lightAzimuth": 180, "lightElevation": 0}}),
)
.unwrap();
let params = |s: &Session| node(s, id).appearance.effects[0].params.clone();
// Straight from behind the object, level with it.
assert!(params(&s).get("lightAzimuth").is_none() && params(&s).get("lightElevation").is_none(), "{}", params(&s));
assert_eq!((params(&s)["lightHeight"].as_f64().unwrap().round(), &params(&s)["lightBehind"]), (90.0, &json!(true)));
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params =
json!({"lightAzimuth": 90, "lightElevation": 0});
s.execute("effect.setParams", &json!({"index": 0, "params": {"lightHeight": 60}, "ids": [id.0], "item": null})).unwrap();
assert_eq!(params(&s), json!({"lightRotation": 0.0, "lightHeight": 60, "lightBehind": false}));
}
fn effects_for_test(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
effects::revolve_art(&node(s, id)).unwrap()
}
+141 -52
View File
@@ -44,7 +44,7 @@ pub enum Edge {
Right,
}
/// All angles are degrees; offset is in document points, lighting values are 0..1.
/// All angles are degrees; offset is in document points, lighting values are 0..1 (ambient 0..2).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Revolve {
pub angle: f64,
@@ -57,13 +57,25 @@ pub struct Revolve {
pub perspective: f64,
/// Radial subdivisions for a full turn, clamped to 8..128.
pub segments: usize,
pub light_azimuth: f64,
pub light_elevation: f64,
/// The light's Rotation, −180° to 180°: round the object on the page, 0° on its right and
/// counterclockwise.
pub light_rotation: f64,
/// The light's Height, 0° to 90°: from beside the object (0°) to straight in front of it.
pub light_height: f64,
/// The light is behind the object (Move light to the back of object) rather than in front.
pub light_behind: bool,
pub light_intensity: f64,
/// Softness, 0 (a sharp fall-off) to 1: how far round the object the light reaches.
pub light_softness: f64,
/// Ambient Light, 0 to 2.
pub ambient: f64,
pub shade: bool,
}
/// The default light's Rotation and Height: the light of the earlier default direction (45° to the
/// left of the eye and 45° up), to the left of and above the object, 30° in front of it.
pub const DEFAULT_LIGHT: (f64, f64) = (125.264_389_682_754_65, 30.0);
impl Default for Revolve {
fn default() -> Self {
Self {
@@ -75,9 +87,11 @@ impl Default for Revolve {
rotation_z: 0.0,
perspective: 0.0,
segments: 64,
light_azimuth: -45.0,
light_elevation: 45.0,
light_rotation: DEFAULT_LIGHT.0,
light_height: DEFAULT_LIGHT.1,
light_behind: false,
light_intensity: 0.8,
light_softness: 0.0,
ambient: 0.25,
shade: true,
}
@@ -111,6 +125,32 @@ pub fn perspective_from_strength(strength: f64) -> f64 {
2.0 * (3f64.sqrt() * s / 4.0).asin().to_degrees()
}
/// The Rotation, Height and side of the light an earlier effect set by its azimuth (degrees round
/// the vertical axis, 0° at the eye) and elevation (degrees up): the same direction, so it lights
/// the object exactly as before.
pub fn light_from_azimuth_elevation(azimuth: f64, elevation: f64) -> (f64, f64, bool) {
let (azimuth, elevation) = (finite(azimuth, -45.0, -360.0, 360.0), finite(elevation, 45.0, -90.0, 90.0));
let (sl, cl) = elevation.to_radians().sin_cos();
let (sa, ca) = azimuth.to_radians().sin_cos();
let [x, y, z] = [cl * sa, -sl, cl * ca];
// Straight in front or behind, any Rotation is the same light.
let rotation = if x.hypot(y) < 1e-12 { 0.0 } else { (-y).atan2(x).to_degrees() + 0.0 };
(rotation, z.abs().min(1.0).asin().to_degrees(), z < 0.0)
}
/// How bright a face with unit normal `n` is under the light `l` (both in view space): the ambient
/// light plus the light's intensity where it reaches. Without softness the light falls off with
/// the cosine to nothing at 90°; softness wraps it further round the object, at 100 % to nothing
/// only straight away from it. Never brighter than the art's own colour.
fn brightness(o: &Revolve, n: V3, l: V3) -> f64 {
if !o.shade {
return 1.0;
}
let s = o.light_softness;
let lit = ((n.dot(l) + s) / (1.0 + s)).max(0.0);
(o.ambient + o.light_intensity * lit).clamp(0.0, 1.0)
}
fn finite(v: f64, fallback: f64, min: f64, max: f64) -> f64 {
if v.is_finite() { v.clamp(min, max) } else { fallback }
}
@@ -165,39 +205,31 @@ impl Revolve {
/// Directional light in view space, shared by shading and interactive guides.
pub fn light_direction(self) -> [f64; 3] {
let o = self.sanitized();
let (sl, cl) = o.light_elevation.to_radians().sin_cos();
let (sa, ca) = o.light_azimuth.to_radians().sin_cos();
[cl * sa, -sl, cl * ca]
let (sr, cr) = o.light_rotation.to_radians().sin_cos();
let (sh, ch) = o.light_height.to_radians().sin_cos();
[ch * cr, -ch * sr, if o.light_behind { -sh } else { sh }]
}
/// Light-direction orbits as (centre, cosine basis, sine basis), in view space.
/// The first changes azimuth at the current elevation. The second changes elevation
/// at the current azimuth. The meridian's valid parameter range is -90..90 degrees.
/// The first changes the Rotation at the current Height: a circle on the page. The second
/// changes the Height at the current Rotation; its parameter runs 0..90 degrees.
pub fn light_orbits(self) -> [[[f64; 3]; 3]; 2] {
let o = self.sanitized();
let (se, ce) = o.light_elevation.to_radians().sin_cos();
let (sa, ca) = o.light_azimuth.to_radians().sin_cos();
[[[0.0, -se, 0.0], [0.0, 0.0, ce], [ce, 0.0, 0.0]], [[0.0, 0.0, 0.0], [sa, 0.0, ca], [0.0, -1.0, 0.0]]]
let (sr, cr) = o.light_rotation.to_radians().sin_cos();
let (sh, ch) = o.light_height.to_radians().sin_cos();
let side = if o.light_behind { -1.0 } else { 1.0 };
[[[0.0, 0.0, side * sh], [ch, 0.0, 0.0], [0.0, -ch, 0.0]], [[0.0, 0.0, 0.0], [cr, -sr, 0.0], [0.0, 0.0, side]]]
}
/// Move a light on its current hemisphere, preserving the azimuth at the poles.
/// A light behind the object stays behind when its projected handle is dragged.
/// The Rotation and Height of a light dragged to `point` on the page (in units of the light
/// guide's radius, y down), on the side it is already on: the further out, the lower. At the
/// centre the Rotation is kept.
pub fn light_at_on_side(self, point: Point) -> [f64; 2] {
let o = self.sanitized();
let point = Point::new(finite(point.x, 0.0, -1e8, 1e8), finite(point.y, 0.0, -1e8, 1e8));
let length = point.x.hypot(point.y).max(1.0);
let (x, y) = (point.x / length, point.y / length);
let z = (1.0 - x * x - y * y).max(0.0).sqrt().copysign(o.light_direction()[2]);
let azimuth = if x.hypot(z) < 1e-9 { o.light_azimuth } else { x.atan2(z).to_degrees() };
[azimuth, (-y).clamp(-1.0, 1.0).asin().to_degrees()]
}
/// A dragged light position on the front hemisphere, in normalized view coordinates.
pub fn light_at(point: Point) -> [f64; 2] {
let length = point.x.hypot(point.y).max(1.0);
let (x, y) = (point.x / length, point.y / length);
let z = (1.0 - x * x - y * y).max(0.0).sqrt();
[x.atan2(z).to_degrees(), (-y).clamp(-1.0, 1.0).asin().to_degrees()]
let r = point.x.hypot(point.y);
let rotation = if r < 1e-9 { o.light_rotation } else { (-point.y).atan2(point.x).to_degrees() };
[rotation, r.min(1.0).acos().to_degrees()]
}
fn sanitized(self) -> Self {
@@ -209,10 +241,11 @@ impl Revolve {
rotation_z: finite(self.rotation_z, 0.0, -360.0, 360.0),
perspective: finite(self.perspective, 0.0, 0.0, MAX_PERSPECTIVE),
segments: self.segments.clamp(8, 128),
light_azimuth: finite(self.light_azimuth, -45.0, -360.0, 360.0),
light_elevation: finite(self.light_elevation, 45.0, -90.0, 90.0),
light_rotation: (finite(self.light_rotation, DEFAULT_LIGHT.0, -1e6, 1e6) + 180.0).rem_euclid(360.0) - 180.0,
light_height: finite(self.light_height, DEFAULT_LIGHT.1, 0.0, 90.0),
light_intensity: finite(self.light_intensity, 0.8, 0.0, 1.0),
ambient: finite(self.ambient, 0.25, 0.0, 1.0),
light_softness: finite(self.light_softness, 0.0, 0.0, 1.0),
ambient: finite(self.ambient, 0.25, 0.0, 2.0),
..self
}
}
@@ -316,7 +349,7 @@ pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result<Vec<Fa
if normal.2 < 0.0 {
normal = V3(-normal.0, -normal.1, -normal.2);
}
let brightness = if o.shade { (o.ambient + o.light_intensity * normal.dot(light).max(0.0)).clamp(0.0, 1.0) } else { 1.0 };
let brightness = brightness(&o, normal, light);
let points: Vec<_> = vertices
.iter()
.map(|v| {
@@ -352,26 +385,82 @@ mod tests {
use super::*;
#[test]
fn light_orbits_and_handle_projection_match_the_shading_direction_on_both_sides() {
for azimuth in [-170.0f64, -45.0, 0.0, 83.0, 167.0] {
for elevation in [-85.0f64, -30.0, 0.0, 45.0, 90.0] {
let o = Revolve { light_azimuth: azimuth, light_elevation: elevation, ..Default::default() };
let direction = o.light_direction();
for ([center, u, v], angle) in o.light_orbits().into_iter().zip([azimuth, elevation]) {
let (s, c) = angle.to_radians().sin_cos();
for i in 0..3 {
assert!((center[i] + u[i] * c + v[i] * s - direction[i]).abs() < 1e-12);
for rotation in [-170.0f64, -45.0, 0.0, 83.0, 167.0] {
for height in [0.0f64, 15.0, 45.0, 80.0] {
for behind in [false, true] {
let o = Revolve { light_rotation: rotation, light_height: height, light_behind: behind, ..Default::default() };
let direction = o.light_direction();
assert!((direction[0].hypot(direction[1]).hypot(direction[2]) - 1.0).abs() < 1e-12);
assert_eq!(direction[2] < 0.0, behind && height > 0.0);
for ([center, u, v], angle) in o.light_orbits().into_iter().zip([rotation, height]) {
let (s, c) = angle.to_radians().sin_cos();
for i in 0..3 {
assert!((center[i] + u[i] * c + v[i] * s - direction[i]).abs() < 1e-12);
}
}
let [r, h] = o.light_at_on_side(Point::new(direction[0], direction[1]));
assert!((r - rotation).abs() < 1e-9 && (h - height).abs() < 1e-6, "{rotation}, {height} -> {r}, {h}");
}
let [a, e] = o.light_at_on_side(Point::new(direction[0], direction[1]));
assert!((a - azimuth).abs() < 1e-9 && (e - elevation).abs() < 1e-9, "{azimuth}, {elevation} -> {a}, {e}");
}
}
let o = Revolve { light_azimuth: 135.0, ..Default::default() };
let [azimuth, elevation] = o.light_at_on_side(Point::new(0.3, -0.4));
let [x, y, z] = Revolve { light_azimuth: azimuth, light_elevation: elevation, ..o }.light_direction();
assert!((x - 0.3).abs() < 1e-12 && (y + 0.4).abs() < 1e-12 && z < 0.0);
let [a, e] = o.light_at_on_side(Point::new(f64::NAN, f64::INFINITY));
assert!(a.is_finite() && e.is_finite());
// Dragged to the centre: straight in front, the Rotation kept; past the rim: at 0°.
let o = Revolve { light_rotation: 135.0, ..Default::default() };
assert_eq!(o.light_at_on_side(Point::new(0.0, 0.0)), [135.0, 90.0]);
let [r, h] = o.light_at_on_side(Point::new(0.0, -3.0));
assert!((r - 90.0).abs() < 1e-12 && h == 0.0);
let [r, h] = o.light_at_on_side(Point::new(f64::NAN, f64::INFINITY));
assert!(r.is_finite() && h.is_finite());
}
/// An effect from before Rotation and Height (azimuth and elevation) gets the same light:
/// the same direction, so the same shading.
#[test]
fn an_earlier_azimuth_and_elevation_give_the_same_light() {
let old = |azimuth: f64, elevation: f64| {
let (sl, cl) = elevation.to_radians().sin_cos();
let (sa, ca) = azimuth.to_radians().sin_cos();
[cl * sa, -sl, cl * ca]
};
for azimuth in [-360.0f64, -170.0, -45.0, 0.0, 90.0, 135.0, 179.0] {
for elevation in [-90.0f64, -60.0, -1.0, 0.0, 30.0, 45.0, 89.0, 90.0] {
let (light_rotation, light_height, light_behind) = light_from_azimuth_elevation(azimuth, elevation);
assert!((-180.0..=180.0).contains(&light_rotation) && (0.0..=90.0).contains(&light_height));
let new = Revolve { light_rotation, light_height, light_behind, ..Default::default() }.light_direction();
let want = old(azimuth, elevation);
for i in 0..3 {
assert!((new[i] - want[i]).abs() < 1e-12, "{azimuth}, {elevation}: {new:?} against {want:?}");
}
}
}
// The default light is the earlier default's.
let (r, h, behind) = light_from_azimuth_elevation(-45.0, 45.0);
assert!((r - DEFAULT_LIGHT.0).abs() < 1e-12 && (h - DEFAULT_LIGHT.1).abs() < 1e-12 && !behind, "{r} {h}");
}
/// Softness wraps the light further round the object; without it, the earlier shading. Ambient
/// Light runs to 200 %, never past the art's own colour.
#[test]
fn softness_wraps_the_light_and_ambient_reaches_200_percent() {
let o = Revolve::default().sanitized();
let l = V3(0.0, 0.0, 1.0);
let n = |deg: f64| {
let (s, c) = deg.to_radians().sin_cos();
V3(s, 0.0, c)
};
for deg in [0.0, 30.0, 60.0, 89.0, 120.0] {
let lambert = (o.ambient + o.light_intensity * n(deg).dot(l).max(0.0)).clamp(0.0, 1.0);
assert!((brightness(&o, n(deg), l) - lambert).abs() < 1e-12);
}
let soft = Revolve { light_softness: 1.0, ..o };
assert!(brightness(&soft, n(120.0), l) > brightness(&o, n(120.0), l) + 0.1, "lit past 90°");
assert!((brightness(&soft, n(180.0), l) - o.ambient).abs() < 1e-12, "nothing straight away");
assert!((brightness(&soft, n(0.0), l) - brightness(&o, n(0.0), l)).abs() < 1e-12, "the same facing the light");
let bright = Revolve { ambient: 2.0, ..o };
assert_eq!(brightness(&bright, n(180.0), l), 1.0);
assert_eq!(
Revolve { ambient: 5.0, light_softness: -1.0, light_height: 120.0, ..o }.sanitized(),
Revolve { ambient: 2.0, light_softness: 0.0, light_height: 90.0, ..o }
);
}
#[test]
@@ -403,11 +492,11 @@ mod tests {
let o = Revolve { rotation_x: 0.0, rotation_y: 0.0, rotation_z: 0.0, offset: 10.0, ..Default::default() };
assert_eq!(o.view_axes(), [Point::new(1.0, 0.0), Point::new(0.0, 1.0), Point::new(0.0, 0.0)]);
assert_eq!(o.projected_axis(Rect::new(20.0, 30.0, 80.0, 100.0)), [Point::new(10.0, 30.0), Point::new(10.0, 100.0)]);
let [azimuth, elevation] = Revolve::light_at(Point::new(0.3, -0.4));
let [x, y, z] = Revolve { light_azimuth: azimuth, light_elevation: elevation, ..o }.light_direction();
let [light_rotation, light_height] = o.light_at_on_side(Point::new(0.3, -0.4));
let [x, y, z] = Revolve { light_rotation, light_height, ..o }.light_direction();
assert!((x - 0.3).abs() < 1e-9 && (y + 0.4).abs() < 1e-9 && z > 0.0);
let [azimuth, elevation] = Revolve::light_at(Point::new(2.0, -2.0));
assert!(azimuth.is_finite() && elevation.is_finite() && elevation.abs() <= 90.0);
let [rotation, height] = o.light_at_on_side(Point::new(2.0, -2.0));
assert!(rotation.is_finite() && height == 0.0);
}
fn cylinder() -> (PathData, Rect) {
let mut bp = BezPath::new();
+13 -7
View File
@@ -21,9 +21,11 @@ pub struct View {
pub rotation_z: f64,
/// Perspective, 0° (none) to 160°, as Revolve's.
pub perspective: f64,
pub light_azimuth: f64,
pub light_elevation: f64,
pub light_rotation: f64,
pub light_height: f64,
pub light_behind: bool,
pub light_intensity: f64,
pub light_softness: f64,
pub ambient: f64,
pub shade: bool,
}
@@ -36,9 +38,11 @@ impl Default for View {
rotation_y: r.rotation_y,
rotation_z: r.rotation_z,
perspective: r.perspective,
light_azimuth: r.light_azimuth,
light_elevation: r.light_elevation,
light_rotation: r.light_rotation,
light_height: r.light_height,
light_behind: r.light_behind,
light_intensity: r.light_intensity,
light_softness: r.light_softness,
ambient: r.ambient,
shade: r.shade,
}
@@ -53,9 +57,11 @@ impl View {
rotation_y: self.rotation_y,
rotation_z: self.rotation_z,
perspective: self.perspective,
light_azimuth: self.light_azimuth,
light_elevation: self.light_elevation,
light_rotation: self.light_rotation,
light_height: self.light_height,
light_behind: self.light_behind,
light_intensity: self.light_intensity,
light_softness: self.light_softness,
ambient: self.ambient,
shade: self.shade,
..Revolve::default()
@@ -421,7 +427,7 @@ impl Camera {
n.dot(to_eye)
}
fn brightness(&self, n: V3) -> f64 {
if self.view.shade { (self.view.ambient + self.view.light_intensity * n.dot(self.light).max(0.0)).clamp(0.0, 1.0) } else { 1.0 }
crate::brightness(&self.view, n, self.light)
}
}
+6 -3
View File
@@ -283,10 +283,13 @@ fn lighting_controls(ui: &mut egui::Ui, d: &mut Dialog) {
}
});
ui.add_enabled_ui(d.bool("shade"), |ui| {
number(ui, d, "lightAzimuth", tl!("Direction"), (-360.0, 360.0), "°", 1, None);
number(ui, d, "lightElevation", tl!("Elevation"), (-90.0, 90.0), "°", 1, None);
// The documented Light Settings, in their order (one white light: no Color yet).
number(ui, d, "lightIntensity", tl!("Intensity"), (0.0, 100.0), "%", 1, None);
number(ui, d, "ambient", tl!("Ambient"), (0.0, 100.0), "%", 1, None);
number(ui, d, "lightRotation", tl!("Rotation"), (-180.0, 180.0), "°", 1, None);
number(ui, d, "lightHeight", tl!("Height"), (0.0, 90.0), "°", 1, None);
number(ui, d, "lightSoftness", tl!("Softness"), (0.0, 100.0), "%", 1, None);
number(ui, d, "ambient", tl!("Ambient Light"), (0.0, 200.0), "%", 1, None);
toggle(ui, d, "lightBehind", tl!("Light behind the object"), false);
});
}
+17 -16
View File
@@ -143,7 +143,7 @@ impl Geometry {
let mut best = None;
let mut distance = 9.0;
for (axis, ring) in self.light_rings(options).iter().enumerate() {
let (start, span) = if axis == 0 { (0.0, std::f32::consts::TAU) } else { (-std::f32::consts::FRAC_PI_2, std::f32::consts::PI) };
let (start, span) = if axis == 0 { (0.0, std::f32::consts::TAU) } else { (0.0, std::f32::consts::FRAC_PI_2) };
for i in 0..SAMPLES {
let a = start + i as f32 * span / SAMPLES as f32;
let (from, to) = (self.center + ring.point(a), self.center + ring.point(a + span / SAMPLES as f32));
@@ -182,6 +182,11 @@ fn wrap(n: f64, snap: bool) -> f64 {
(n + 180.0).rem_euclid(360.0) - 180.0
}
/// A Height, 0° to 90°, on 15° steps with Shift.
fn snap_height(height: f64, snap: bool) -> f64 {
(if snap { (height / 15.0).round() * 15.0 } else { height }).clamp(0.0, 90.0)
}
pub(super) fn show(app: &VectorcraftApp, ctx: &egui::Context, d: &mut Dialog) {
let light_mode = lighting(d);
if !d.bool("preview") || (light_mode && !d.bool("shade")) {
@@ -265,24 +270,18 @@ pub(super) fn show(app: &VectorcraftApp, ctx: &egui::Context, d: &mut Dialog) {
let start = revolve_options(&serde_json::Value::Object(current.fields.clone()));
let projected = light_position(geometry, start) + (p - current.origin);
let point = geometry.xf.delta_to_doc(projected - geometry.center) * (geometry.xf.zoom / f64::from(geometry.radius * 1.4));
let [azimuth, elevation] = start.light_at_on_side(Point::new(point.x, point.y));
d.fields.insert("lightAzimuth".into(), json!(wrap(azimuth, snap)));
d.fields.insert(
"lightElevation".into(),
json!((if snap { (elevation / 15.0).round() * 15.0 } else { elevation }).clamp(-90.0, 90.0)),
);
let [rotation, height] = start.light_at_on_side(Point::new(point.x, point.y));
d.fields.insert("lightRotation".into(), json!(wrap(rotation, snap)));
d.fields.insert("lightHeight".into(), json!(snap_height(height, snap)));
}
Mode::LightRing { axis } => {
let delta = geometry.xf.delta_to_doc(p - current.origin) * geometry.xf.zoom;
let start = revolve_options(&serde_json::Value::Object(current.fields.clone()));
if *axis == 0 {
d.fields.insert("lightAzimuth".into(), json!(wrap(start.light_azimuth + delta.x * 0.6, snap)));
// Right turns the light counterclockwise, as along the ring's foot.
d.fields.insert("lightRotation".into(), json!(wrap(start.light_rotation + delta.x * 0.6, snap)));
} else {
let elevation = start.light_elevation - delta.y * 0.6;
d.fields.insert(
"lightElevation".into(),
json!((if snap { (elevation / 15.0).round() * 15.0 } else { elevation }).clamp(-90.0, 90.0)),
);
d.fields.insert("lightHeight".into(), json!(snap_height(start.light_height - delta.y * 0.6, snap)));
}
}
}
@@ -371,7 +370,7 @@ fn paint_light(ui: &egui::Ui, d: &Dialog, g: Geometry, active: Option<usize>, dr
painter.circle_stroke(g.center, radius, Stroke::new(1.0, t.text_dim.gamma_multiply(0.3)));
let labels = g.light_labels();
for (axis, ring) in g.light_rings(options).iter().enumerate() {
let (start, span) = if axis == 0 { (0.0, std::f32::consts::TAU) } else { (-std::f32::consts::FRAC_PI_2, std::f32::consts::PI) };
let (start, span) = if axis == 0 { (0.0, std::f32::consts::TAU) } else { (0.0, std::f32::consts::FRAC_PI_2) };
for i in 0..SAMPLES {
let a = start + i as f32 * span / SAMPLES as f32;
let behind = ring.depth[0] * a.cos() + ring.depth[1] * a.sin() < -0.05;
@@ -387,7 +386,7 @@ fn paint_light(ui: &egui::Ui, d: &Dialog, g: Geometry, active: Option<usize>, dr
}
let label = labels[axis];
painter.rect_filled(label, 4.0, if active == Some(axis) { t.hover } else { t.panel });
let text = if axis == 0 { tl!("Direction") } else { tl!("Elevation") };
let text = if axis == 0 { tl!("Rotation") } else { tl!("Height") };
painter.text(label.center(), Align2::CENTER_CENTER, text, FontId::proportional(11.0), t.warning);
}
let p = light_position(g, options);
@@ -415,7 +414,9 @@ fn paint_light(ui: &egui::Ui, d: &Dialog, g: Geometry, active: Option<usize>, dr
painter.rect_filled(Rect::from_min_size(name_pos - vec2(4.0, 2.0), galley.size() + vec2(8.0, 4.0)), 3.0, t.panel);
painter.galley(name_pos, galley, t.warning);
if drag.is_some() {
let text = format!("{} {:.1}° {} {:.1}°", tl!("Direction"), d.f64("lightAzimuth", -45.0), tl!("Elevation"), d.f64("lightElevation", 45.0));
let [rotation, height] = [("lightRotation", vectorcraft_three_d::DEFAULT_LIGHT.0), ("lightHeight", vectorcraft_three_d::DEFAULT_LIGHT.1)]
.map(|(k, v)| d.f64(k, v));
let text = format!("{} {rotation:.1}° {} {height:.1}°", tl!("Rotation"), tl!("Height"));
let galley = painter.layout_no_wrap(text, FontId::proportional(11.5), t.text_strong);
let pos = g.center + vec2(0.0, radius + 56.0);
painter.rect_filled(Rect::from_center_size(pos, galley.size() + vec2(12.0, 6.0)), 4.0, t.panel);
+5 -5
View File
@@ -152,15 +152,15 @@ fn lighting_tab_shows_light_fields_and_keeps_the_dialog_compact() {
h.settle();
h.click(h.rect("Lighting").center());
assert_eq!(h.app.ui.dialog.as_ref().unwrap().str("__gizmo"), "light");
for label in ["Shading", "Direction", "Elevation", "Intensity", "Ambient"] {
for label in ["Shading", "Intensity", "Height", "Softness", "Ambient Light", "Light behind the object"] {
assert!(Rect::from_min_size(Pos2::ZERO, h.screen).contains_rect(h.rect(label)));
}
let panel = h.ctx.memory(|m| m.area_rect(Id::new(("dialog", "effect")).with("revolve"))).unwrap();
assert!(panel.width() < 420.0 && panel.height() < 460.0, "compact lighting controls: {panel:?}");
h.drag(h.rect("Direction").center(), vec2(24.0, 0.0));
assert_ne!(h.value("lightAzimuth"), -45.0);
h.click(h.response(("revolve-reset", "lightAzimuth")).center());
assert_eq!(h.value("lightAzimuth"), -45.0);
h.drag(h.rect("Height").center(), vec2(24.0, 0.0));
assert_ne!(h.value("lightHeight"), 30.0);
h.click(h.response(("revolve-reset", "lightHeight")).center());
assert_eq!(h.value("lightHeight"), 30.0);
h.click(h.rect("Rotation").center());
assert_eq!(h.app.ui.dialog.as_ref().unwrap().str("__gizmo"), "rotation");
assert!(!h.rect("Rotation X").is_negative());
@@ -150,8 +150,8 @@ fn free_orbit_and_light_drag_update_the_canvas_and_escape_restores_only_the_gest
let p = light + vec2(20.0, 25.0);
h.frame(vec![Event::PointerMoved(p)], Modifiers::NONE);
h.release(p, Modifiers::NONE);
assert_ne!(h.value("lightAzimuth"), -45.0);
assert_ne!(h.value("lightElevation"), 45.0);
assert_ne!(h.value("lightRotation"), before["lightRotation"].as_f64().unwrap());
assert_ne!(h.value("lightHeight"), 30.0);
h.frame(vec![escape()], Modifiers::NONE);
assert!(h.app.ui.dialog.is_none() && !h.app.session.in_interaction());
}
@@ -176,7 +176,7 @@ fn preview_off_has_no_gizmo_and_edge_on_rings_remain_finite() {
#[test]
fn light_rings_adjust_only_their_angle_and_snap_without_rotating_the_object() {
for (axis, key) in ["lightAzimuth", "lightElevation"].iter().enumerate() {
for (axis, key) in ["lightRotation", "lightHeight"].iter().enumerate() {
let mut h = Harness::new();
h.app.ui.dialog.as_mut().unwrap().fields.insert("__gizmo".into(), json!("light"));
h.settle();
@@ -191,7 +191,7 @@ fn light_rings_adjust_only_their_angle_and_snap_without_rotating_the_object() {
for unchanged in ["rotationX", "rotationY", "rotationZ", "lightIntensity", "ambient"] {
assert_eq!(h.value(unchanged), before[unchanged].as_f64().unwrap());
}
let other = if axis == 0 { "lightElevation" } else { "lightAzimuth" };
let other = if axis == 0 { "lightHeight" } else { "lightRotation" };
assert_eq!(h.value(other), before[other].as_f64().unwrap());
let state = h.app.session.active().unwrap();
let node = state.doc.node(state.selection.objects[0]).unwrap();
@@ -205,7 +205,7 @@ fn light_handle_preserves_the_back_side_and_escape_cancel_and_undo_restore_chang
let original = h.app.session.active().unwrap().interaction.as_ref().unwrap().doc.clone();
let d = h.app.ui.dialog.as_mut().unwrap();
d.fields.insert("__gizmo".into(), json!("light"));
d.fields.insert("lightAzimuth".into(), json!(135.0));
d.fields.insert("lightBehind".into(), json!(true));
h.settle();
let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap());
let light = light_position(h.geometry(), revolve_options(&before));
@@ -213,12 +213,12 @@ fn light_handle_preserves_the_back_side_and_escape_cancel_and_undo_restore_chang
let to = light + vec2(-15.0, 12.0);
h.frame(vec![Event::PointerMoved(to)], Modifiers::NONE);
assert!(revolve_options(&super::super::form::params(h.app.ui.dialog.as_ref().unwrap())).light_direction()[2] < 0.0);
assert_ne!(h.value("lightElevation"), 45.0);
assert_ne!(h.value("lightHeight"), 30.0);
h.frame(vec![escape()], Modifiers::NONE);
h.release(to, Modifiers::NONE);
assert_eq!(super::super::form::params(h.app.ui.dialog.as_ref().unwrap()), before);
let state = h.app.session.active().unwrap();
assert_eq!(state.doc.node(state.selection.objects[0]).unwrap().appearance.effects[0].params["lightAzimuth"], before["lightAzimuth"]);
assert_eq!(state.doc.node(state.selection.objects[0]).unwrap().appearance.effects[0].params["lightHeight"], before["lightHeight"]);
// A second gesture is kept by OK, and the whole dialog is one undo step.
let from = h.geometry().light_labels()[0].center();
h.start(from, Modifiers::NONE);
@@ -230,7 +230,7 @@ fn light_handle_preserves_the_back_side_and_escape_cancel_and_undo_restore_chang
}
#[test]
fn light_elevation_is_bounded_and_shading_off_disables_the_light_gizmo() {
fn light_height_is_bounded_and_shading_off_disables_the_light_gizmo() {
let mut h = Harness::new();
h.app.ui.dialog.as_mut().unwrap().fields.insert("__gizmo".into(), json!("light"));
h.settle();
@@ -238,7 +238,7 @@ fn light_elevation_is_bounded_and_shading_off_disables_the_light_gizmo() {
h.start(from, Modifiers::NONE);
h.frame(vec![Event::PointerMoved(from - vec2(0.0, 400.0))], Modifiers::NONE);
h.release(from - vec2(0.0, 400.0), Modifiers::NONE);
assert_eq!(h.value("lightElevation"), 90.0);
assert_eq!(h.value("lightHeight"), 90.0);
h.app.ui.dialog.as_mut().unwrap().fields.insert("shade".into(), json!(false));
h.settle();
let before = super::super::form::params(h.app.ui.dialog.as_ref().unwrap());
+3 -3
View File
@@ -754,10 +754,7 @@
Rotation Y Otočení Y
Rotation Z Otočení Z
Shading Stínování
Direction Směr
Elevation Výška světla
Intensity Intenzita
Ambient Okolní osvětlení
Segments Segmenty
Show axis Zobrazit osu
Reset all Obnovit vše
@@ -4747,3 +4744,6 @@ mark-style Japanese Japonské
@msg unknown parameter `{key}`; the command takes: {keys} neznámý parametr `{key}`; příkaz přijímá: {keys}
Load EPS into Clipboard Načíst EPS do schránky
@msg an image of {w} × {h} pixels is too large to draw obraz o velikosti {w} × {h} obrazových bodů je příliš velký na vykreslení
Softness Měkkost
Ambient Light Okolní světlo
Light behind the object Světlo za objektem
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -3
View File
@@ -4151,13 +4151,10 @@ theme Appearance Erscheinungsbild
@msg {key} must be a finite coordinate {key} muss eine endliche Koordinate sein
@msg invalid restored panel group Ungültige wiederhergestellte Bedienfeldgruppe
# 3D and Materials › Revolve: its options dialog and gizmos.
Ambient Umgebungslicht
Axis side Achsenseite
Direction Richtung
Drag rings to rotate. Shift snaps to 15°. Ziehen Sie die Ringe, um zu drehen. Mit Shift in 15°-Schritten.
Drag the light, or its rings. Shift snaps to 15°. Ziehen Sie das Licht oder seine Ringe. Mit Shift in 15°-Schritten.
Drag values or labels. Double-click a value to type. Ziehen Sie Werte oder Beschriftungen. Doppelklicken Sie auf einen Wert, um ihn einzugeben.
Elevation Höhe
Intensity Intensität
Keep visible surfaces only when expanding Beim Umwandeln nur sichtbare Flächen behalten
Lighting Beleuchtung
@@ -4271,3 +4268,6 @@ theme Appearance Erscheinungsbild
@msg unknown parameter `{key}`; the command takes: {keys} unbekannter Parameter `{key}`; der Befehl akzeptiert: {keys}
Load EPS into Clipboard EPS in die Zwischenablage laden
@msg an image of {w} × {h} pixels is too large to draw ein Bild mit {w} × {h} Pixeln ist zu groß, um gezeichnet zu werden
Softness Weichheit
Ambient Light Umgebungslicht
Light behind the object Licht hinter dem Objekt
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -3
View File
@@ -4200,9 +4200,6 @@ axis Both Ambos
Rotation Y Rotación Y
Rotation Z Rotación Z
Shading Sombreado
Direction Dirección
Elevation Elevación
Ambient Luz ambiental
Segments Segmentos
Show axis Mostrar eje
Reset all Restablecer todo
@@ -4688,3 +4685,6 @@ mark-style Japanese Japonesas
@msg unknown parameter `{key}`; the command takes: {keys} parámetro desconocido `{key}`; el comando acepta: {keys}
Load EPS into Clipboard Cargar EPS en el portapapeles
@msg an image of {w} × {h} pixels is too large to draw una imagen de {w} × {h} píxeles es demasiado grande para dibujarse
Softness Suavidad
Ambient Light Luz ambiental
Light behind the object Luz detrás del objeto
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -3
View File
@@ -4210,9 +4210,6 @@ effect Soft Doux
Rotation Y Rotation Y
Rotation Z Rotation Z
Shading Ombrage
Direction Direction
Elevation Élévation
Ambient Lumière ambiante
Segments Segments
Show axis Afficher l’axe
Reset all Tout réinitialiser
@@ -4747,3 +4744,6 @@ mark-style Japanese Japonais
theme Appearance Apparence
Load EPS into Clipboard Charger un EPS dans le presse-papiers
@msg an image of {w} × {h} pixels is too large to draw une image de {w} × {h} pixels est trop grande pour être dessinée
Softness Douceur
Ambient Light Lumière ambiante
Light behind the object Lumière derrière l'objet
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -3
View File
@@ -252,7 +252,6 @@
Alt+Drag Alt+húzás
Alt: Helyettesítő szöveg:
Alter Paths for Appearance Útvonalak módosítása a megjelenés szerint
Ambient Környezeti fény
Amount Mérték
Amount: Mérték:
Analogous Analóg
@@ -971,7 +970,6 @@
Dimensions Méretek
Direct Selection Közvetlen kijelölés
Direct Selection Tool Közvetlen kijelölés
Direction Irány
Direction Balance Irányegyensúly
Direction Handles Iránykezelő fogantyúk
Direction: Irány:
@@ -1152,7 +1150,6 @@
Effective PPI Tényleges PPI
Effects Effektusok
Effect… Effektus…
Elevation Emelkedési szög
Eliminate Eltávolítás
Ellipse Ellipszis
Ellipse Properties: Ellipszis tulajdonságai:
@@ -4714,3 +4711,6 @@ imageTrace Outline Körvonal
@msg unknown parameter `{key}`; the command takes: {keys} ismeretlen paraméter: `{key}`; a parancs ezeket fogadja el: {keys}
Load EPS into Clipboard EPS betöltése a vágólapra
@msg an image of {w} × {h} pixels is too large to draw egy {w} × {h} pixeles kép túl nagy a kirajzoláshoz
Softness Lágyság
Ambient Light Környezeti fény
Light behind the object Fény az objektum mögött
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -3
View File
@@ -4202,9 +4202,6 @@ effect Regular Normale
Rotation Y Rotazione Y
Rotation Z Rotazione Z
Shading Ombreggiatura
Direction Direzione
Elevation Elevazione
Ambient Luce ambientale
Segments Segmenti
Show axis Mostra asse
Reset all Ripristina tutto
@@ -4316,3 +4313,6 @@ lighting Light Luce
@msg unknown parameter `{key}`; the command takes: {keys} parametro sconosciuto `{key}`; il comando accetta: {keys}
Load EPS into Clipboard Carica EPS negli appunti
@msg an image of {w} × {h} pixels is too large to draw un'immagine di {w} × {h} pixel è troppo grande per essere disegnata
Softness Morbidezza
Ambient Light Luce ambientale
Light behind the object Luce dietro l'oggetto
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -3
View File
@@ -4196,9 +4196,6 @@ effect Vertical 垂直
Rotation Y Y 軸回転
Rotation Z Z 軸回転
Shading 陰影
Direction 方向
Elevation 仰角
Ambient 環境光
Segments 分割数
Show axis 軸を表示
Reset all すべてリセット
@@ -4310,3 +4307,6 @@ lighting Light ライト
@msg unknown parameter `{key}`; the command takes: {keys} `{key}` は不明なパラメーターです。このコマンドで使えるパラメーター: {keys}
Load EPS into Clipboard EPS をクリップボードに読み込む
@msg an image of {w} × {h} pixels is too large to draw {w} × {h} ピクセルの画像は大きすぎて描画できません
Softness 柔らかさ
Ambient Light 環境光
Light behind the object オブジェクトの背後のライト
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -3
View File
@@ -4168,9 +4168,6 @@ effect Vertical 세로
Rotation Y Y 회전
Rotation Z Z 회전
Shading 셰이딩
Direction 방향
Elevation 고도
Ambient 주변광
Segments 세그먼트
Show axis 축 표시
Reset all 모두 재설정
@@ -4280,3 +4277,6 @@ lighting Light 광원
@msg unknown parameter `{key}`; the command takes: {keys} 알 수 없는 매개변수 `{key}`: 이 명령이 받는 매개변수: {keys}
Load EPS into Clipboard EPS를 클립보드로 불러오기
@msg an image of {w} × {h} pixels is too large to draw {w} × {h}픽셀 이미지는 너무 커서 그릴 수 없습니다
Softness 부드러움
Ambient Light 주변광
Light behind the object 개체 뒤의 조명
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -3
View File
@@ -730,10 +730,7 @@
Rotation Y Obrót Y
Rotation Z Obrót Z
Shading Cieniowanie
Direction Kierunek
Elevation Wysokość
Intensity Natężenie
Ambient Otoczenie
Segments Segmenty
Show axis Pokaż oś
Reset all Resetuj wszystko
@@ -4589,3 +4586,6 @@ mark-style Japanese Japońskie
@msg unknown parameter `{key}`; the command takes: {keys} nieznany parametr `{key}`; polecenie przyjmuje: {keys}
Load EPS into Clipboard Wczytaj EPS do schowka
@msg an image of {w} × {h} pixels is too large to draw obraz o wymiarach {w} × {h} pikseli jest zbyt duży, aby go narysować
Softness Miękkość
Ambient Light Światło otoczenia
Light behind the object Światło za obiektem
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -3
View File
@@ -2840,10 +2840,7 @@ imageTrace Outline Contorno
Rotation Y Rotação Y
Rotation Z Rotação Z
Shading Sombreamento
Direction Direção
Elevation Elevação
Intensity Intensidade
Ambient Luz ambiente
Segments Segmentos
Show axis Mostrar eixo
Reset all Redefinir tudo
@@ -3751,3 +3748,6 @@ axis Both Ambos
@msg unknown parameter `{key}`; the command takes no parameters parâmetro desconhecido `{key}`; o comando não tem parâmetros
@msg unknown parameter `{key}`; the command takes: {keys} parâmetro desconhecido `{key}`; o comando aceita: {keys}
Load EPS into Clipboard Carregar EPS na Área de Transferência
Softness Suavidade
Ambient Light Luz ambiente
Light behind the object Luz atrás do objeto
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3 -3
View File
@@ -4198,9 +4198,6 @@ axis Both Оба
Rotation Y Поворот Y
Rotation Z Поворот Z
Shading Затенение
Direction Направление
Elevation Высота
Ambient Фоновое освещение
Segments Сегменты
Show axis Показать ось
Reset all Сбросить всё
@@ -4312,3 +4309,6 @@ lighting Light Свет
@msg unknown parameter `{key}`; the command takes: {keys} неизвестный параметр `{key}`; команда принимает: {keys}
Load EPS into Clipboard Загрузить EPS в буфер обмена
@msg an image of {w} × {h} pixels is too large to draw изображение размером {w} × {h} пикселей слишком велико для отрисовки
Softness Мягкость
Ambient Light Окружающий свет
Light behind the object Свет позади объекта
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -3
View File
@@ -4182,9 +4182,6 @@ axis Both Обидві
Rotation Y Обертання Y
Rotation Z Обертання Z
Shading Затінення
Direction Напрямок
Elevation Висота
Ambient Навколишнє освітлення
Segments Сегменти
Show axis Показати вісь
Reset all Скинути все
@@ -4296,3 +4293,6 @@ lighting Light Світло
@msg unknown parameter `{key}`; the command takes: {keys} невідомий параметр `{key}`; команда приймає: {keys}
Load EPS into Clipboard Завантажити EPS у буфер обміну
@msg an image of {w} × {h} pixels is too large to draw зображення розміром {w} × {h} пікселів завелике для відображення
Softness М'якість
Ambient Light Навколишнє світло
Light behind the object Світло позаду об'єкта
Can't render this file because it contains an unexpected character in line 713 and column 40.
+3 -3
View File
@@ -191,7 +191,6 @@
Alt Alt
Alt: Alt:
Alter Paths for Appearance Sửa đường dẫn cho diện mạo
Ambient Ánh sáng môi trường
Amount Mức độ
Amount: Mức độ:
Anchor Point Điểm neo
@@ -864,7 +863,6 @@
Dim Images to: Làm mờ ảnh đến:
Dimensions Kích thước
Direct Selection Tool Công cụ Chọn trực tiếp
Direction Hướng
Direction Balance Cân bằng hướng
Direction Handles Tay nắm hướng
Direction: Hướng:
@@ -1035,7 +1033,6 @@
Effective PPI PPI hiệu dụng
Effects Hiệu ứng
Effect… Hiệu ứng…
Elevation Độ cao
Eliminate Loại bỏ
Ellipse Hình elip
Ellipse Properties: Thuộc tính hình elip:
@@ -4298,3 +4295,6 @@ theme Appearance Giao diện
@msg {tool} isn't in a toolbar group of several tools {tool} không nằm trong nhóm thanh công cụ có nhiều công cụ
Load EPS into Clipboard Tải EPS vào bảng tạm
@msg an image of {w} × {h} pixels is too large to draw ảnh {w} × {h} điểm ảnh quá lớn nên không thể vẽ
Softness Độ mềm
Ambient Light Ánh sáng môi trường
Light behind the object Ánh sáng phía sau đối tượng
Can't render this file because it contains an unexpected character in line 3641 and column 10.
+3 -3
View File
@@ -3796,9 +3796,6 @@ effect Vertical 垂直
Rotation Y Y 轴旋转
Rotation Z Z 轴旋转
Shading 明暗处理
Direction 方向
Elevation 仰角
Ambient 环境光
Segments 分段数
Show axis 显示轴
Reset all 全部重置
@@ -3904,3 +3901,6 @@ lighting Light 光源
@msg unknown parameter `{key}`; the command takes: {keys} 未知参数 `{key}`;该命令接受:{keys}
Load EPS into Clipboard 将 EPS 载入剪贴板
@msg an image of {w} × {h} pixels is too large to draw {w} × {h} 像素的图像过大,无法绘制
Softness 柔和度
Ambient Light 环境光
Light behind the object 光源位于对象后方
Can't render this file because it contains an unexpected character in line 2 and column 73.
+3 -3
View File
@@ -3577,9 +3577,6 @@ effect Soft 柔和
Rotation Y Y 軸旋轉
Rotation Z Z 軸旋轉
Shading 明暗處理
Direction 方向
Elevation 仰角
Ambient 環境光
Segments 分段數
Show axis 顯示軸
Reset all 全部重設
@@ -3685,3 +3682,6 @@ lighting Light 光源
@msg unknown parameter `{key}`; the command takes: {keys} 未知的參數 `{key}`;此指令接受:{keys}
Load EPS into Clipboard 將 EPS 載入剪貼簿
@msg an image of {w} × {h} pixels is too large to draw {w} × {h} 像素的影像過大,無法繪製
Softness 柔和度
Ambient Light 環境光
Light behind the object 光源位於物件後方
Can't render this file because it contains an unexpected character in line 6 and column 47.
+3 -2
View File
@@ -1,6 +1,6 @@
# Effects parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (raster effects after a pattern, radial or freeform gradient fill export at their place and size, #1536; score unchanged; 3D and Materials on outlined type and other shapes of many small curves, #1481; Twist 0° to 360°, #1524; twisted walls keep their faces, #1525; score unchanged; 3D and Materials: Inflate takes Twist, Taper and Cap; score unchanged; 3D and Materials: Perspective as the documented 0°–160° angle) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (raster effects after a pattern, radial or freeform gradient fill export at their place and size, #1536; score unchanged; 3D and Materials on outlined type and other shapes of many small curves, #1481; Twist 0° to 360°, #1524; twisted walls keep their faces, #1525; score unchanged; 3D and Materials: Inflate takes Twist, Taper and Cap; score unchanged; 3D and Materials: Perspective as the documented 0°–160° angle; 3D and Materials: the documented light settings) · **Target:** Adobe Illustrator 2026 (30.x)
Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue
(`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it
@@ -86,7 +86,7 @@ not an accuracy guarantee for arbitrary artwork. Tests: `pixel::tests::film_grai
|---|---|
| Revolve | initial: live, editable profile, rotation and light gizmos, flat shading, 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, Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) |
| Materials panel, lighting, ray-traced render | one directional light with flat shading only; no materials, several lights, light colour, shadows or ray tracing |
| Materials panel, lighting, ray-traced render | one directional light with the documented Intensity, Rotation, Height, Softness and Ambient Light, flat shading; no materials, several lights, light colour or presets, shadows or ray tracing |
| 3D (Classic): Extrude & Bevel, Revolve, Rotate | none |
| Turntable (30.0 beta) | none |
| Export 3D objects (OBJ, USDA, glTF) | none |
@@ -99,6 +99,7 @@ Revolve caps, 3D Classic, Turntable, 3D export) is 25–45 h.
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | 3D lighting takes the documented Intensity, Rotation, Height, Softness and Ambient Light (to 200 %), and a light behind the object; earlier lights read as the same ([three-d.md](three-d.md#lighting)); 3D stays ~25%, 25–45 h |
| 2026-10-13 | minor | 3D Perspective is the documented 0°–160° angle, earlier strengths read as the same view ([three-d.md](three-d.md#perspective)); 3D stays ~25%, 25–45 h |
| 2026-10-13 | minor | Raster effects (blur, shadow, glow, grain) drawn after a pattern, radial or freeform gradient fill export at their place and size (#1541, fixes #1536); scores unchanged (estimated) |
| 2026-10-13 | minor | 3D and Materials: outlined words and other shapes of many small curves take Rotate, Extrude & Bevel and Inflate (curves are sampled by a tolerance; Rotate takes up to 65,536 outline points; #1481); Extrude & Bevel's Twist runs 0° to 360° as the public panel reference gives it, a negative value drawing untwisted (#1524); a twisted extrusion keeps its walls, which were dropped from about 45° (#1525); details in [three-d.md](three-d.md); 3D stays ~25%, 25–45 h (estimated) |
+1 -1
View File
@@ -175,7 +175,7 @@ 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);
Extrude & Bevel, Inflate and Rotate with Position presets ([three-d.md](three-d.md), #1407).
- **Missing:** materials (base, Substance, graphics), several lights, light colour and softness, shadows,
- **Missing:** materials (base, Substance, graphics), several lights, light colour and presets, shadows,
ray-traced rendering, Revolve caps, exact visibility for intersecting and concave surfaces, 3D Classic,
Turntable, 3D export (OBJ, USDA, glTF).
- **Impact:** specialist; Materials is the one menu stub left.
+7 -5
View File
@@ -1024,11 +1024,13 @@ SVG/PDF exports bake the same faces the canvas draws. Only one Revolve is suppor
Parameters: `angle` (degrees, 0..360, default 360), `offset` (points, 0..100000, default 0),
`edge` (`"left"` or `"right"`), `rotationX/Y/Z` (degrees, defaults 0/0/0), `perspectiveAngle`
(degrees, 0..160, default 0; an earlier `perspective`, 0..100%, is read as the angle that shows the same view), `segments` (8..128, default 64), `shade` (default true), `lightAzimuth`
(degrees, default -45), `lightElevation` (degrees, default 45), `lightIntensity` (0..100%, default 80),
`ambient` (0..100%, default 25), and `expandVisibleOnly` (boolean, default true). A vertical-line profile needs a positive offset to have a radius.
(degrees, 0..160, default 0; an earlier `perspective`, 0..100%, is read as the angle that shows the same view), `segments` (8..128, default 64), `shade` (default true), `lightIntensity` (0..100%, default 80),
`lightRotation` (degrees, -180..180, default 125.26; 0 on the object's right, counterclockwise), `lightHeight`
(degrees, 0..90, default 30; 90 straight in front), `lightBehind` (default false), `lightSoftness` (0..100%,
default 0), `ambient` (Ambient Light, 0..200%, default 25; an earlier `lightAzimuth` and `lightElevation` are read
as the same light), and `expandVisibleOnly` (boolean, default true). A vertical-line profile needs a positive offset to have a radius.
Open profiles have no end caps. Visibility of intersecting surfaces is approximate.
The dialog's Lighting tab manipulates the same `lightAzimuth` and `lightElevation` parameters;
The dialog's Lighting tab manipulates the same `lightRotation` and `lightHeight` parameters;
its guide visibility and tab choice are UI state, excluded from saved effect parameters.
`expandVisibleOnly` is saved with the effect. It trims covered portions of opaque solid surfaces only
when running `effect.expandAppearance`, preserving visible interiors through openings. Set it to false
@@ -1038,7 +1040,7 @@ See [Revolve](revolve.md) for the scope and limitations.
```json
{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"threeD.revolve","item":null,"params":{"angle":360,"offset":20,"edge":"left","rotationX":0,"rotationY":0,"rotationZ":0}}}}
{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"angle":180,"lightAzimuth":30}}}}
{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"angle":180,"lightRotation":30}}}}
{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"expandVisibleOnly":true}}}}
{"name":"run_command","arguments":{"command":"effect.expandAppearance","params":{}}}
```
+7 -5
View File
@@ -10,7 +10,8 @@ line, give it a nonzero offset. A vertical line at zero offset lies on the axis
**Revolve angle** sweeps 0 to 360 degrees. **Rotation X/Y/Z** rotates the viewing direction, with all three axes defaulting to 0 degrees. **Perspective**
runs 0° to 160°, as documented: 0° is an orthographic view, and wider angles bring the eye nearer (see
[three-d.md](three-d.md#perspective)). **Segments** controls radial resolution (8 to 128 for a full turn).
Lighting is a directional light with azimuth, elevation, intensity and ambient contribution.
Lighting is one directional light with the documented Light Settings (see [three-d.md](three-d.md#lighting)):
**Intensity**, **Rotation**, **Height**, **Softness** and **Ambient Light**, and **Light behind the object**.
Turn **Shading** off to keep the source paint without lighting.
The dialog has **Rotation** and **Lighting** tabs. Rotation groups the profile and view controls. Drag a value or its label
@@ -27,12 +28,13 @@ keeps the settings open. Escape again or Cancel restores the document before the
The gizmo appears while editing Revolve; normal source-path selection returns when the dialog closes.
Select the **Lighting** tab to use the light gizmo on the artboard. Drag the sun handle to move
its direction on the current hemisphere. A hollow sun marks a back light. Drag the **Direction**
ring or its label horizontally to adjust azimuth; drag the **Elevation** arc or its label vertically
to adjust elevation. The labels remain usable when a ring is viewed edge-on or the light is at a pole.
the light on its side of the object: round the centre for its Rotation, towards the centre for a
greater Height. A hollow sun marks a light behind the object. Drag the **Rotation** ring or its label
horizontally to turn the light round the object; drag the **Height** line or its label vertically to
raise or lower it. The labels remain usable when the ring shrinks to a point (Height 90°).
Hold Shift to snap to 15-degree increments. Light gestures update their matching fields without
rotating the object, and keep the same temporary preview, Escape, Cancel and undo behaviour.
Light intensity and ambient contribution remain available as draggable fields and sliders.
Intensity, Softness and Ambient Light are draggable fields and sliders.
**Shading** off hides the light gizmo and disables lighting controls. Switch to **Rotation** to return
to the object rings. **Show axis** in **Advanced** displays the source's vertical axis on the object.
+1
View File
@@ -543,6 +543,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | 3D and Materials: the documented light settings, Intensity, Rotation, Height, Softness and Ambient Light ([three-d.md](three-d.md#lighting)); scores unchanged (3D ~25%) |
| 2026-10-13 | minor | 3D and Materials: Perspective is the documented 0°–160° angle ([three-d.md](three-d.md#perspective)); scores unchanged (3D ~25%) |
| 2026-10-13 | minor | Control transport deadlines (#1726): idle and partial clients and blocked replies release connection slots; parity scores and estimates unchanged. |
| 2026-10-13 | minor | Colour: the Color panel keeps the mode picked last between sessions and shows the defaults for new art in it when nothing is selected; a selected colour still shows in its own model (fixes #1675); scores and hours unchanged (estimated) |
+23 -5
View File
@@ -10,8 +10,8 @@ Select a path or compound path, then choose **Effect › 3D and Materials › Ex
- **Object** (Extrude & Bevel and Inflate): the shape's own options, below.
- **Rotation**: a **Presets** menu, **Rotation X/Y/Z** (−180° to 180°) and **Perspective** (0° to 160°, below). The rotation rings
on the artboard turn the object about the centre of its bounds; Shift snaps to 15°.
- **Lighting**: **Shading**, the light's **Direction** and **Elevation** (or drag the light on the artboard),
**Intensity** and **Ambient**, as for Revolve.
- **Lighting**: **Shading** and the light's settings (below), as for Revolve; or drag the light on the
artboard.
Preview changes are temporary until OK; Cancel restores the document; OK is one undo step. An object takes one
3D effect: edit its options in the Appearance panel, or remove it before applying another.
@@ -81,13 +81,31 @@ that shows exactly the same view: the strength put the eye at 4 × that size / s
sin(angle / 2) = √3 × strength / 4, and 100 % is about 51.3°. An angle given as well wins; the dialog shows and
saves the angle.
## Lighting
One white directional light, with the public panel options' Light Settings in their order (Color aside):
| Option | |
|---|---|
| Intensity | 0–100 %, default 80 %: the light's brightness. |
| Rotation | −180° to 180°: round the object on the page. Our reading: 0° is on the object's right, counterclockwise, as Illustrator's other angles. |
| Height | 0° to 90°: from beside the object (0°) to straight in front of it (90°). |
| Softness | 0–100 %, default 0 %: how far round the object the light reaches. Our reading: at 0 % the light falls off with the angle to nothing at 90° (as before), at 100 % it reaches nothing only straight away from the light. |
| Ambient Light | 0–200 %, default 25 %: light from everywhere. Faces are never brighter than the art's own colour, so past 100 % (less the light's share) it adds nothing more. |
| Light behind the object | The documented "Move selected light to the back of object": the light shines from behind. |
The documentation gives no defaults; ours keep the earlier light: Rotation 125.26° and Height 30° are the direction
the default light had (45° left of the eye and 45° up). Effects saved before (and scripts) give the light by
`lightAzimuth` and `lightElevation`; those are read as the Rotation, Height and side of the same direction, so the
shading is exactly as before.
## Parameters (`effect.apply`, `effect.setParams`)
`threeD.extrude`: `depth`, `twist`, `taper` (%), `cap`, `bevel` (`none`, `classic`, `rounded`, `cove`, `step`,
`ogee`), `bevelWidth`, `bevelHeight`, `bevelRepeat`, `bevelSpace` (%), `bevelInside`, `bevelBothSides`.
`threeD.inflate`: `depth`, `volume` (%), `bothSides`. All three take the view and lighting parameters Revolve
uses: `rotationX`, `rotationY`, `rotationZ`, `perspectiveAngle` (degrees), `shade`, `lightAzimuth`, `lightElevation`,
`lightIntensity` (%), `ambient` (%). Lengths are points and scale with the object (Scale Strokes & Effects).
uses: `rotationX`, `rotationY`, `rotationZ`, `perspectiveAngle` (degrees), `shade`, `lightIntensity` (%), `lightRotation`,
`lightHeight` (degrees), `lightSoftness` (%), `ambient` (%, to 200), `lightBehind`. Lengths are points and scale with the object (Scale Strokes & Effects).
Commands validate the shape and the parameters before the document changes.
## What differs from the public documentation
@@ -97,7 +115,7 @@ and [Create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effe
pages, and the archived Extrude & Bevel option list (depth 0–2,000, cap on/off, bevel height, bevel extent in or
out, perspective). No Adobe software, assets, presets or bevel shapes were used.
- **Materials** (base, Adobe Substance and graphics), **several lights**, light colour and softness, **shadows**
- **Materials** (base, Adobe Substance and graphics), **several lights**, light colour, light presets, **shadows**
and **ray-traced rendering** are not built: shading is one directional light, flat per face, painted with the
object's top fill (else its top stroke) for Extrude & Bevel and Inflate.
- The bevel shapes and their names are our own; custom bevel paths are not supported.