mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
3D and Materials: Perspective runs 0° to 160°, as documented
The public 3D and Materials panel options give Perspective as 0° to 160°
("how close or far the object appears"); it was a 0–100 % strength, for
Revolve, Extrude & Bevel, Inflate and Rotate alike.
- The eye stands where a sphere about the object's centre, √3 times its
half size (the box around it fits inside), fills that angle of the
view: 0° is none, wider brings the eye nearer, and at 160° it is just
outside the sphere, never inside the object. The documentation doesn't
say how the angle places the eye; docs/three-d.md says this is our
reading.
- `perspectiveAngle` (degrees) replaces `perspective` (%). A strength from
an earlier file or script is read as the angle that shows exactly the
same view: sin(angle / 2) = √3 × strength / 4, 100 % about 51.3°. An
angle given too wins. effect.setParams turns a stored strength into the
angle before applying new values. The parameter description names
`perspective` as the earlier form, so effect.apply and effect.setParams
still take it under the key check of #1592. Old and new code draw the four effects identically at 0, 25,
60 and 100 % (bounds equal to 1e-9).
- The dialogs' Perspective fields run 0–160°.
# Conflicts:
# docs/target-app-parity.md
This commit is contained in:
1 parent
a3357121fb
commit
39db6abfce
16 files changed
+231
-49
No files matched your search
@@ -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: 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). |
|
||||
| 2026-10-12 | `convert --editing-data` (`editingData: "always"`) opens a `.ai` from its editing data even where its page, which can be older, draws differently, with a note. |
|
||||
|
||||
+39
-13
@@ -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), perspective: % (0..100, 0), segments: integer (8..128, 64), shade: bool (true), lightAzimuth: degrees (-45), lightElevation: degrees (45), lightIntensity: % (80), ambient: % (25), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Open paths create uncapped surfaces; intersecting profiles use approximate painter visibility.",
|
||||
json!({"angle":360.0,"offset":0.0,"edge":"left","rotationX":0.0,"rotationY":0.0,"rotationZ":0.0,"perspective":0.0,"segments":64,"shade":true,"lightAzimuth":-45.0,"lightElevation":45.0,"lightIntensity":80.0,"ambient":25.0,"expandVisibleOnly":true}),
|
||||
"{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}),
|
||||
),
|
||||
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), perspective: % (0..100, 0), 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,"perspective":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), 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}),
|
||||
),
|
||||
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), perspective: % (0..100, 0), 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,"perspective":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), 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}),
|
||||
),
|
||||
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), perspective: % (0..100, 0), 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,"perspective":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), 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}),
|
||||
),
|
||||
g(
|
||||
"convertToShape.rectangle",
|
||||
@@ -642,21 +642,47 @@ pub fn default_params(id: &str) -> Option<Value> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `params` layered over the defaults of `id` (unknown keys are kept).
|
||||
/// `params` layered over the defaults of `id` (unknown keys are kept), with the 3D effects'
|
||||
/// earlier `perspective` read as `perspectiveAngle` (see [`upgrade_params`]).
|
||||
pub fn merged_params(id: &str, params: &Value) -> Value {
|
||||
let mut out = match catalog_index().get(id).map(|e| &e.defaults) {
|
||||
Some(Value::Object(m)) => m.clone(),
|
||||
Some(_) => Map::new(),
|
||||
None => vectorcraft_plugins::effect::installed(id).map(|p| p.manifest().defaults()).unwrap_or_default(),
|
||||
};
|
||||
if let Value::Object(p) = params {
|
||||
for (k, v) in p {
|
||||
out.insert(k.clone(), v.clone());
|
||||
}
|
||||
// Upgraded before the defaults are under it: a default angle is no angle given.
|
||||
if let Value::Object(p) = upgrade_params(id, params) {
|
||||
out.extend(p);
|
||||
}
|
||||
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.
|
||||
pub fn upgrade_params(id: &str, params: &Value) -> Value {
|
||||
match params {
|
||||
Value::Object(m) => {
|
||||
let mut m = m.clone();
|
||||
upgrade_perspective(id, &mut m);
|
||||
Value::Object(m)
|
||||
}
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn upgrade_perspective(id: &str, m: &mut Map<String, Value>) {
|
||||
if !is_three_d(id) {
|
||||
return;
|
||||
}
|
||||
let Some(strength) = m.remove("perspective") else { return };
|
||||
if !m.contains_key("perspectiveAngle") {
|
||||
let angle = vectorcraft_three_d::perspective_from_strength(strength.as_f64().unwrap_or(0.0) / 100.0);
|
||||
m.insert("perspectiveAngle".into(), json!(angle));
|
||||
}
|
||||
}
|
||||
|
||||
/// A new effect record with default parameters (overridden by `params`).
|
||||
pub fn new_effect(id: &str, params: &Value) -> Option<Effect> {
|
||||
if !catalog_index().contains_key(id) {
|
||||
|
||||
@@ -29,7 +29,7 @@ pub fn revolve_options(p: &Value) -> Revolve {
|
||||
rotation_x: num(p, "rotationX", 0.0),
|
||||
rotation_y: num(p, "rotationY", 0.0),
|
||||
rotation_z: num(p, "rotationZ", 0.0),
|
||||
perspective: num(p, "perspective", 0.0) / 100.0,
|
||||
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),
|
||||
|
||||
@@ -61,7 +61,7 @@ pub fn view_options(p: &Value) -> View {
|
||||
rotation_x: num(p, "rotationX", ROTATION_PRESETS[6].1[0]),
|
||||
rotation_y: num(p, "rotationY", ROTATION_PRESETS[6].1[1]),
|
||||
rotation_z: num(p, "rotationZ", ROTATION_PRESETS[6].1[2]),
|
||||
perspective: num(p, "perspective", 0.0) / 100.0,
|
||||
perspective: num(p, "perspectiveAngle", 0.0),
|
||||
light_azimuth: num(p, "lightAzimuth", -45.0),
|
||||
light_elevation: num(p, "lightElevation", 45.0),
|
||||
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
|
||||
|
||||
@@ -146,11 +146,43 @@ fn a_twisted_tapered_hollow_inflate_exports_and_expands() {
|
||||
assert!(ids.len() > 20, "{} nodes", ids.len());
|
||||
}
|
||||
|
||||
/// Files and scripts from before the Perspective angle give `perspective` as a 0–100 % strength:
|
||||
/// each 3D effect draws it exactly as the angle that stands for it, an angle given too wins, and
|
||||
/// the current parameters carry only the angle.
|
||||
#[test]
|
||||
fn an_earlier_perspective_strength_draws_as_its_angle() {
|
||||
let art = |n: &Node, id: &str| if id == crate::REVOLVE { crate::revolve_art(n) } else { solid_art(n) };
|
||||
for id in SOLIDS.iter().copied().chain([crate::REVOLVE]) {
|
||||
let view = |extra: Value| {
|
||||
let mut p = json!({"rotationX": -25, "rotationY": 35, "rotationZ": 0, "depth": 80});
|
||||
p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
p
|
||||
};
|
||||
let with = |params: Value| {
|
||||
let mut n = square_node();
|
||||
// As read from a file: the stored parameters, not a fresh effect's.
|
||||
n.appearance.effects.push(Effect { id: id.into(), params, visible: true });
|
||||
art(&n, id).unwrap().visual_bounds().unwrap()
|
||||
};
|
||||
let angle = vectorcraft_three_d::perspective_from_strength(0.6);
|
||||
let old = with(view(json!({"perspective": 60})));
|
||||
let new = with(view(json!({"perspectiveAngle": angle})));
|
||||
let none = with(view(json!({})));
|
||||
for (a, b) in [(old.x0, new.x0), (old.y0, new.y0), (old.x1, new.x1), (old.y1, new.y1)] {
|
||||
assert!((a - b).abs() < 1e-9, "{id}: {old:?} against {new:?}, none {none:?}");
|
||||
}
|
||||
assert!((old.width() - none.width()).abs() > 1.0 || (old.height() - none.height()).abs() > 1.0, "{id}: perspective shows");
|
||||
assert_eq!(with(view(json!({"perspective": 60, "perspectiveAngle": 0}))), none, "{id}: the angle wins");
|
||||
let merged = crate::merged_params(id, &view(json!({"perspective": 60})));
|
||||
assert!(merged.get("perspective").is_none() && (merged["perspectiveAngle"].as_f64().unwrap() - angle).abs() < 1e-12, "{id}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_before_it_shape_the_solid_and_effects_after_it_bend_the_result() {
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(fx("path.offsetPath", json!({"offset": 20})));
|
||||
n.appearance.effects.push(fx(EXTRUDE, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "perspective": 0})));
|
||||
n.appearance.effects.push(fx(EXTRUDE, json!({"rotationX": 0, "rotationY": 0, "rotationZ": 0, "perspectiveAngle": 0})));
|
||||
// Straight on, the extrusion shows its front: the offset square.
|
||||
let b = solid_art(&n).unwrap().visual_bounds().unwrap();
|
||||
assert!((b.width() - 140.0 - 0.75).abs() < 1e-6, "{b:?}");
|
||||
|
||||
@@ -311,8 +311,8 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& visible != Some(false)
|
||||
{
|
||||
let mut prospective = effects::merged_params(&e.id, &e.params);
|
||||
if let (Some(cur), Some(new)) = (prospective.as_object_mut(), params.as_object()) {
|
||||
cur.extend(new.clone());
|
||||
if let (Some(cur), Value::Object(new)) = (prospective.as_object_mut(), effects::upgrade_params(&e.id, ¶ms)) {
|
||||
cur.extend(new);
|
||||
}
|
||||
if e.id == effects::REVOLVE {
|
||||
effects::validate_revolve(n, &prospective).map_err(|e| bad(C, e))?;
|
||||
@@ -323,14 +323,15 @@ fn set_params(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let ids = edit_effects(s, p, item, C, "Effect Options", &format!("{C}: no effect at index {index}"), |fx| {
|
||||
let Some(e) = fx.get_mut(index) else { return false };
|
||||
if let (Value::Object(new), cur) = (¶ms, &mut e.params) {
|
||||
if let (Value::Object(new), cur) = (effects::upgrade_params(&e.id, ¶ms), &mut e.params) {
|
||||
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.
|
||||
*cur = effects::upgrade_params(&e.id, cur);
|
||||
if let Value::Object(m) = cur {
|
||||
for (k, v) in new {
|
||||
m.insert(k.clone(), v.clone());
|
||||
}
|
||||
m.extend(new);
|
||||
}
|
||||
}
|
||||
if let Some(v) = visible {
|
||||
@@ -602,6 +603,30 @@ mod tests {
|
||||
assert!(err.to_string().contains("requires a path"), "{err}");
|
||||
}
|
||||
|
||||
/// Scripts from before the Perspective angle give `perspective` as a 0–100 % strength: it is
|
||||
/// stored as the angle that shows the same view, and a later angle replaces an earlier strength.
|
||||
#[test]
|
||||
fn an_earlier_perspective_strength_is_set_as_its_angle() {
|
||||
let (mut s, id) = session_with_rect();
|
||||
s.execute("effect.apply", &json!({"effect": effects::EXTRUDE, "ids": [id.0], "item": null, "params": {"perspective": 60}})).unwrap();
|
||||
let params = |s: &Session| node(s, id).appearance.effects[0].params.clone();
|
||||
let angle_of = |pct: f64| effects::merged_params(effects::EXTRUDE, &json!({"perspective": pct}))["perspectiveAngle"].as_f64().unwrap();
|
||||
let angle = angle_of(60.0);
|
||||
assert!((angle - 30.12).abs() < 0.01, "{angle}");
|
||||
assert!(
|
||||
params(&s).get("perspective").is_none() && (params(&s)["perspectiveAngle"].as_f64().unwrap() - angle).abs() < 1e-12,
|
||||
"{}",
|
||||
params(&s)
|
||||
);
|
||||
s.execute("effect.setParams", &json!({"index": 0, "params": {"perspective": 100}, "ids": [id.0], "item": null})).unwrap();
|
||||
assert!((params(&s)["perspectiveAngle"].as_f64().unwrap() - angle_of(100.0)).abs() < 1e-12);
|
||||
// A file of then: the strength stored; setting an angle replaces it.
|
||||
std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc).node_mut(id).unwrap().appearance.effects[0].params =
|
||||
json!({"depth": 50, "perspective": 60});
|
||||
s.execute("effect.setParams", &json!({"index": 0, "params": {"perspectiveAngle": 120}, "ids": [id.0], "item": null})).unwrap();
|
||||
assert_eq!(params(&s), json!({"depth": 50, "perspectiveAngle": 120}));
|
||||
}
|
||||
|
||||
fn effects_for_test(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
|
||||
effects::revolve_art(&node(s, id)).unwrap()
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ pub struct Revolve {
|
||||
pub rotation_x: f64,
|
||||
pub rotation_y: f64,
|
||||
pub rotation_z: f64,
|
||||
/// Perspective strength, 0 for orthographic projection, up to 1.
|
||||
/// Perspective, 0° (none) to 160°: see [`eye_distance`].
|
||||
pub perspective: f64,
|
||||
/// Radial subdivisions for a full turn, clamped to 8..128.
|
||||
pub segments: usize,
|
||||
@@ -84,6 +84,33 @@ impl Default for Revolve {
|
||||
}
|
||||
}
|
||||
|
||||
/// The widest documented Perspective.
|
||||
pub const MAX_PERSPECTIVE: f64 = 160.0;
|
||||
|
||||
/// How far the eye is from the centre of an object of half size `size`, for a Perspective of
|
||||
/// `angle` degrees: from where a sphere of √3 × `size` about the centre (the box around the object
|
||||
/// fits inside it) fills that angle of the view. 0° is no perspective (`None`, the eye infinitely
|
||||
/// far); at 160° the eye is just outside the sphere, so never inside the object.
|
||||
fn eye_distance(angle: f64, size: f64) -> Option<f64> {
|
||||
let angle = finite(angle, 0.0, 0.0, MAX_PERSPECTIVE);
|
||||
(angle > 0.0).then(|| 3f64.sqrt() * size / (angle / 2.0).to_radians().sin())
|
||||
}
|
||||
|
||||
/// The scale of a view-space point `z` in front of the object's centre plane, for an eye at
|
||||
/// `eye`: 1 without perspective. Parts reaching past the eye's sphere (a wide outward bevel at a
|
||||
/// wide angle) are held just in front of the eye rather than turned inside out.
|
||||
fn perspective_scale(eye: Option<f64>, z: f64) -> f64 {
|
||||
eye.map_or(1.0, |e| e / (e - z).max(e * 1e-3))
|
||||
}
|
||||
|
||||
/// The Perspective angle that shows exactly what the earlier 0–1 perspective strength did (files
|
||||
/// from before the angle). The strength put the eye at 4 × the object's half size / strength, so
|
||||
/// sin(angle / 2) = √3 × strength / 4: full strength is about 51°.
|
||||
pub fn perspective_from_strength(strength: f64) -> f64 {
|
||||
let s = finite(strength, 0.0, 0.0, 1.0);
|
||||
2.0 * (3f64.sqrt() * s / 4.0).asin().to_degrees()
|
||||
}
|
||||
|
||||
fn finite(v: f64, fallback: f64, min: f64, max: f64) -> f64 {
|
||||
if v.is_finite() { v.clamp(min, max) } else { fallback }
|
||||
}
|
||||
@@ -127,10 +154,10 @@ impl Revolve {
|
||||
};
|
||||
let center_y = bounds.center().y;
|
||||
let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs());
|
||||
let distance = 4.0 * radius.max(bounds.height().abs()).max(1.0);
|
||||
let eye = eye_distance(o.perspective, radius.max(bounds.height().abs()).max(1.0));
|
||||
[bounds.y0, bounds.y1].map(|y| {
|
||||
let v = o.rotate(V3(0.0, y - center_y, 0.0));
|
||||
let factor = distance / (distance - v.2 * o.perspective);
|
||||
let factor = perspective_scale(eye, v.2);
|
||||
Point::new(axis + v.0 * factor, center_y + v.1 * factor)
|
||||
})
|
||||
}
|
||||
@@ -180,7 +207,7 @@ impl Revolve {
|
||||
rotation_x: finite(self.rotation_x, 0.0, -360.0, 360.0),
|
||||
rotation_y: finite(self.rotation_y, 0.0, -360.0, 360.0),
|
||||
rotation_z: finite(self.rotation_z, 0.0, -360.0, 360.0),
|
||||
perspective: finite(self.perspective, 0.0, 0.0, 1.0),
|
||||
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),
|
||||
@@ -234,7 +261,7 @@ pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result<Vec<Fa
|
||||
};
|
||||
let center_y = (bounds.y0 + bounds.y1) * 0.5;
|
||||
let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs());
|
||||
let distance = 4.0 * radius.max(bounds.height().abs()).max(1.0);
|
||||
let eye = eye_distance(o.perspective, radius.max(bounds.height().abs()).max(1.0));
|
||||
let steps = ((o.segments as f64 * o.angle / 360.0).ceil() as usize).max(1);
|
||||
let sweep = o.angle.to_radians();
|
||||
let [lx, ly, lz] = o.light_direction();
|
||||
@@ -293,7 +320,7 @@ pub fn revolve(path: &PathData, bounds: Rect, options: Revolve) -> Result<Vec<Fa
|
||||
let points: Vec<_> = vertices
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let factor = distance / (distance - v.2 * o.perspective);
|
||||
let factor = perspective_scale(eye, v.2);
|
||||
Point::new(axis + v.0 * factor, center_y + v.1 * factor)
|
||||
})
|
||||
.collect();
|
||||
@@ -405,12 +432,44 @@ mod tests {
|
||||
for o in [
|
||||
Revolve { edge: Edge::Right, offset: 20.0, ..Default::default() },
|
||||
Revolve { angle: 120.0, ..Default::default() },
|
||||
Revolve { rotation_z: 60.0, perspective: 0.8, ..Default::default() },
|
||||
Revolve { rotation_z: 60.0, perspective: 40.0, ..Default::default() },
|
||||
] {
|
||||
assert_ne!(base, revolve(&p, b, o).unwrap());
|
||||
}
|
||||
assert!(revolve(&p, b, Revolve { angle: 0.0, ..Default::default() }).unwrap().is_empty());
|
||||
}
|
||||
/// The angle that stands for an earlier perspective strength puts the eye where that strength
|
||||
/// did (4 × the half size / strength), so files from before the angle look as they did.
|
||||
#[test]
|
||||
fn the_angle_for_an_earlier_strength_puts_the_eye_where_it_was() {
|
||||
for strength in [0.05, 0.3, 0.6, 1.0] {
|
||||
for size in [1.0, 37.5, 900.0] {
|
||||
let eye = eye_distance(perspective_from_strength(strength), size).unwrap();
|
||||
assert!((eye - 4.0 * size / strength).abs() < 1e-9 * eye, "{strength} {size}: {eye}");
|
||||
}
|
||||
}
|
||||
assert_eq!(perspective_from_strength(0.0), 0.0);
|
||||
assert!((perspective_from_strength(1.0) - 51.318).abs() < 1e-3, "{}", perspective_from_strength(1.0));
|
||||
}
|
||||
|
||||
/// 0° to 160°: none at 0°, beyond either end held there, and at 160° the eye is still outside
|
||||
/// the sphere the object's box fits in.
|
||||
#[test]
|
||||
fn perspective_runs_0_to_160_degrees_with_the_eye_outside_the_object() {
|
||||
assert_eq!(eye_distance(0.0, 50.0), None);
|
||||
assert_eq!(eye_distance(-20.0, 50.0), None);
|
||||
assert_eq!(eye_distance(f64::NAN, 50.0), None);
|
||||
assert_eq!(eye_distance(500.0, 50.0), eye_distance(160.0, 50.0));
|
||||
let widest = eye_distance(160.0, 50.0).unwrap();
|
||||
assert!(widest > 3f64.sqrt() * 50.0 && widest < 1.02 * 3f64.sqrt() * 50.0, "{widest}");
|
||||
// Wider is closer.
|
||||
let eyes: Vec<f64> = [10.0, 45.0, 90.0, 160.0].iter().map(|a| eye_distance(*a, 50.0).unwrap()).collect();
|
||||
assert!(eyes.windows(2).all(|w| w[0] > w[1]), "{eyes:?}");
|
||||
let (p, b) = cylinder();
|
||||
let wide = revolve(&p, b, Revolve { rotation_x: -30.0, perspective: 160.0, ..Default::default() }).unwrap();
|
||||
assert!(wide.iter().flat_map(|f| &f.points).all(|q| q.x.is_finite() && q.y.is_finite()));
|
||||
assert_eq!(wide, revolve(&p, b, Revolve { rotation_x: -30.0, perspective: 400.0, ..Default::default() }).unwrap());
|
||||
}
|
||||
#[test]
|
||||
fn malformed_and_degenerate_inputs_are_bounded() {
|
||||
let (p, b) = cylinder();
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect};
|
||||
|
||||
use crate::{MAX_FACES, Revolve, V3};
|
||||
use crate::{MAX_FACES, Revolve, V3, eye_distance, perspective_scale};
|
||||
|
||||
/// The view shared by every 3D effect: rotation, perspective and one directional light.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -19,7 +19,7 @@ pub struct View {
|
||||
pub rotation_x: f64,
|
||||
pub rotation_y: f64,
|
||||
pub rotation_z: f64,
|
||||
/// Perspective strength, 0 for orthographic projection, up to 1.
|
||||
/// Perspective, 0° (none) to 160°, as Revolve's.
|
||||
pub perspective: f64,
|
||||
pub light_azimuth: f64,
|
||||
pub light_elevation: f64,
|
||||
@@ -389,7 +389,10 @@ fn inset(ring: &[Point], w: f64) -> Vec<Point> {
|
||||
struct Camera {
|
||||
view: Revolve,
|
||||
origin: Point,
|
||||
distance: f64,
|
||||
/// The eye's distance in front of the centre; `None` without perspective.
|
||||
eye: Option<f64>,
|
||||
/// Deeper than any part of the object: faces drawn first go this far behind it.
|
||||
far: f64,
|
||||
light: V3,
|
||||
}
|
||||
|
||||
@@ -398,7 +401,7 @@ impl Camera {
|
||||
let view = view.revolve();
|
||||
let [lx, ly, lz] = view.light_direction();
|
||||
let size = (bounds.width() / 2.0).max(bounds.height() / 2.0).max(depth / 2.0).max(1.0);
|
||||
Self { view, origin: bounds.center(), distance: 4.0 * size, light: V3(lx, ly, lz) }
|
||||
Self { view, origin: bounds.center(), eye: eye_distance(view.perspective, size), far: 16.0 * size, light: V3(lx, ly, lz) }
|
||||
}
|
||||
fn rotate(&self, v: V3) -> V3 {
|
||||
self.view.rotate(v)
|
||||
@@ -408,13 +411,13 @@ impl Camera {
|
||||
self.rotate(V3(p.x - self.origin.x, p.y - self.origin.y, z))
|
||||
}
|
||||
fn project(&self, v: V3) -> Point {
|
||||
let f = self.distance / (self.distance - v.2 * self.view.perspective);
|
||||
let f = perspective_scale(self.eye, v.2);
|
||||
Point::new(self.origin.x + v.0 * f, self.origin.y + v.1 * f)
|
||||
}
|
||||
/// The cosine between a face's outward unit normal `n` and the direction from `at` (both in
|
||||
/// view space) to the eye: positive when the face is turned towards the viewer.
|
||||
fn facing(&self, at: V3, n: V3) -> f64 {
|
||||
let to_eye = if self.view.perspective > 0.0 { V3(0.0, 0.0, self.distance / self.view.perspective).sub(at).unit() } else { V3(0.0, 0.0, 1.0) };
|
||||
let to_eye = self.eye.map_or(V3(0.0, 0.0, 1.0), |e| V3(0.0, 0.0, e).sub(at).unit());
|
||||
n.dot(to_eye)
|
||||
}
|
||||
fn brightness(&self, n: V3) -> f64 {
|
||||
@@ -461,7 +464,7 @@ impl Builder<'_> {
|
||||
if contours.iter().map(|c| signed_area(c).abs()).sum::<f64>() < 1e-10 {
|
||||
return Ok(());
|
||||
}
|
||||
let depth = if behind { centre.2 - 4.0 * self.camera.distance } else { centre.2 };
|
||||
let depth = if behind { centre.2 - self.camera.far } else { centre.2 };
|
||||
self.facets.push(Facet { contours, depth, brightness: self.camera.brightness(n) });
|
||||
if self.facets.len() > MAX_FACES {
|
||||
return Err("The 3D surface is too complex; simplify the shape".into());
|
||||
|
||||
@@ -216,7 +216,7 @@ fn malformed_and_degenerate_input_is_bounded() {
|
||||
cap: true,
|
||||
bevel: Some(Bevel { width: f64::NAN, height: 1e12, repeat: usize::MAX, space: 7.0, ..Bevel::default() }),
|
||||
};
|
||||
let f = extrude(&p, b, wild, View { rotation_x: f64::NAN, perspective: 9.0, ..front() }).unwrap();
|
||||
let f = extrude(&p, 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()));
|
||||
assert!(
|
||||
inflate(&p, b, Inflate { depth: -5.0, volume: f64::NAN, both_sides: true, twist: f64::INFINITY, taper: f64::NAN, cap: true }, front())
|
||||
@@ -243,11 +243,30 @@ fn perspective_makes_the_near_face_larger() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let o = Extrude { depth: 100.0, ..Extrude::default() };
|
||||
let flat = extent(&extrude(&p, b, o, front()).unwrap());
|
||||
let deep = extent(&extrude(&p, b, o, View { perspective: 0.6, ..front() }).unwrap());
|
||||
let deep = extent(&extrude(&p, b, o, View { perspective: 30.0, ..front() }).unwrap());
|
||||
assert!(deep.width() > flat.width() + 1.0);
|
||||
assert!(close(deep.center().x, flat.center().x, 1e-6));
|
||||
}
|
||||
|
||||
/// The wider the Perspective, the nearer the eye: seen straight on, the front of a cube is drawn
|
||||
/// larger and larger, up to 160°, where the eye is still outside the cube.
|
||||
#[test]
|
||||
fn a_wider_perspective_brings_the_eye_nearer() {
|
||||
let (p, b) = shape(&square(0.0, 0.0, 100.0));
|
||||
let o = Extrude { depth: 100.0, ..Extrude::default() };
|
||||
let mut last = 100.0;
|
||||
for angle in [20.0, 60.0, 110.0, 160.0] {
|
||||
// The front face alone shows: its width is the cube's on the page.
|
||||
let width = extent(&extrude(&p, b, o, View { perspective: angle, ..front() }).unwrap()).width();
|
||||
assert!(width > last, "{angle}°: {width} after {last}");
|
||||
last = width;
|
||||
}
|
||||
// At 160° the eye is about 87.9 pt in front of the cube's centre (√3 × 50 / sin 80°), so the
|
||||
// front face, 50 pt nearer than the centre, is drawn about 2.3 times its size.
|
||||
let eye = 3f64.sqrt() * 50.0 / 80f64.to_radians().sin();
|
||||
assert!(close(last, 100.0 * eye / (eye - 50.0), 1e-6), "{last}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_inflate_membrane_matches_known_solutions() {
|
||||
// ∇²φ = −1, zero on the outline: a disc of radius r peaks at r²/4, a square of side l at
|
||||
|
||||
@@ -290,7 +290,7 @@ mod tests {
|
||||
rotation_x: -32.0,
|
||||
rotation_y: 24.0,
|
||||
rotation_z: 19.0,
|
||||
perspective: 0.7,
|
||||
perspective: crate::perspective_from_strength(0.7),
|
||||
..Default::default()
|
||||
};
|
||||
let faces = revolve(&path, path.bounds().unwrap(), options).unwrap();
|
||||
|
||||
@@ -95,7 +95,7 @@ fn controls(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
for (key, name) in [("rotationX", tl!("Rotation X")), ("rotationY", tl!("Rotation Y")), ("rotationZ", tl!("Rotation Z"))] {
|
||||
number(ui, d, key, name, (-360.0, 360.0), "°", 1, None);
|
||||
}
|
||||
number(ui, d, "perspective", tl!("Perspective"), (0.0, 100.0), "%", 1, None);
|
||||
number(ui, d, "perspectiveAngle", tl!("Perspective"), (0.0, 160.0), "°", 1, None);
|
||||
ui.add_space(6.0);
|
||||
egui::CollapsingHeader::new(tl!("Advanced")).id_salt("revolve-advanced").show(ui, |ui| {
|
||||
number(ui, d, "segments", tl!("Segments"), (8.0, 128.0), "", 0, None);
|
||||
@@ -231,7 +231,7 @@ fn view_controls(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
for (key, name) in [("rotationX", tl!("Rotation X")), ("rotationY", tl!("Rotation Y")), ("rotationZ", tl!("Rotation Z"))] {
|
||||
number(ui, d, key, name, (-180.0, 180.0), "°", 2, None);
|
||||
}
|
||||
number(ui, d, "perspective", tl!("Perspective"), (0.0, 100.0), "%", 1, None);
|
||||
number(ui, d, "perspectiveAngle", tl!("Perspective"), (0.0, 160.0), "°", 1, None);
|
||||
}
|
||||
|
||||
/// A dropdown of `options` (value, label) for `key`, with its reset dot.
|
||||
|
||||
@@ -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) · **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) · **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
|
||||
@@ -99,6 +99,7 @@ Revolve caps, 3D Classic, Turntable, 3D export) is 25–45 h.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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) |
|
||||
| 2026-10-13 | minor | Inflate takes Twist, Taper and Cap ([three-d.md](three-d.md)); 3D stays ~25%, 25–45 h: materials, lights and shadows are the bulk of what remains |
|
||||
|
||||
+3
-3
@@ -1023,8 +1023,8 @@ or compound path. `effect.dialog` opens compact options with artboard rotation a
|
||||
SVG/PDF exports bake the same faces the canvas draws. Only one Revolve is supported per profile.
|
||||
|
||||
Parameters: `angle` (degrees, 0..360, default 360), `offset` (points, 0..100000, default 0),
|
||||
`edge` (`"left"` or `"right"`), `rotationX/Y/Z` (degrees, defaults 0/0/0), `perspective`
|
||||
(0..100%, default 0), `segments` (8..128, default 64), `shade` (default true), `lightAzimuth`
|
||||
`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.
|
||||
Open profiles have no end caps. Visibility of intersecting surfaces is approximate.
|
||||
@@ -1055,7 +1055,7 @@ and `bevelHeight` (points, default 4), `bevelRepeat` (1..32), `bevelSpace` (0..9
|
||||
`bevelBothSides`. `threeD.inflate`: `depth` (points, default 0), `twist` (degrees, 0..360, 0), `taper` (1..100%, 100), `cap` (true
|
||||
solid, false open behind), `volume` (0..100%, default 50), `bothSides`.
|
||||
All three take `rotationX/Y/Z` (degrees, -180..180, default the Off-Axis Front position -21.88/-23.4/9.06),
|
||||
`perspective` and the lighting parameters as Revolve. See [three-d.md](three-d.md) for the Position presets,
|
||||
`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.
|
||||
|
||||
|
||||
|
||||
+2
-1
@@ -8,7 +8,8 @@ edge of the profile's bounds; **Offset** moves the axis outward. For a cylinder
|
||||
line, give it a nonzero offset. A vertical line at zero offset lies on the axis and has no surface.
|
||||
|
||||
**Revolve angle** sweeps 0 to 360 degrees. **Rotation X/Y/Z** rotates the viewing direction, with all three axes defaulting to 0 degrees. **Perspective**
|
||||
is zero for an orthographic view. **Segments** controls radial resolution (8 to 128 for a full turn).
|
||||
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.
|
||||
Turn **Shading** off to keep the source paint without lighting.
|
||||
|
||||
|
||||
@@ -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: 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) |
|
||||
| 2026-10-13 | minor | Image Trace, measured in `image-trace-quality.md`: shared edges fitted once (#1535), 16 megapixel photos in 0.9 GB (#1546), thin lines (#1563), Ignore Color and two presets (#1574), upright snapping (#1593), stroked centre lines (#1595), corners and Shapes (#1609), Auto Grouping (#1618), Gradients (#1627). Score and hours unchanged (estimated) |
|
||||
|
||||
+17
-3
@@ -8,7 +8,7 @@ Select a path or compound path, then choose **Effect › 3D and Materials › Ex
|
||||
**Rotate…**. The options dialog opens beside the artboard with tabs:
|
||||
|
||||
- **Object** (Extrude & Bevel and Inflate): the shape's own options, below.
|
||||
- **Rotation**: a **Presets** menu, **Rotation X/Y/Z** (−180° to 180°) and **Perspective**. The rotation rings
|
||||
- **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.
|
||||
@@ -66,12 +66,27 @@ of an isometric view (each visible axis foreshortened equally). These are our ow
|
||||
each view; the dialog shows a preset's name when the angles match it and **Custom** otherwise. The new effects
|
||||
open at Off-Axis Front.
|
||||
|
||||
## Perspective
|
||||
|
||||
**Perspective** runs 0° to 160°, as the public panel options give it ("how close or far the object appears"),
|
||||
for Revolve too. The documentation doesn't say how the angle places the eye; our reading: the eye stands where a
|
||||
sphere about the object's centre fills that angle of the view, the sphere's radius √3 times the object's size
|
||||
(for Extrude & Bevel, Inflate and Rotate half its largest side, depth included; for Revolve the larger of its
|
||||
radius and its height, the sizes the earlier strength used), so the object fits inside it. 0° is no
|
||||
perspective; wider angles bring the eye nearer, and at 160° it is just outside that sphere, so never inside the
|
||||
object. Default 0°, as before.
|
||||
|
||||
Effects saved before the angle (and scripts) give `perspective` as a 0–100 % strength. It is read as the angle
|
||||
that shows exactly the same view: the strength put the eye at 4 × that size / strength, so
|
||||
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.
|
||||
|
||||
## 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`, `perspective` (%), `shade`, `lightAzimuth`, `lightElevation`,
|
||||
uses: `rotationX`, `rotationY`, `rotationZ`, `perspectiveAngle` (degrees), `shade`, `lightAzimuth`, `lightElevation`,
|
||||
`lightIntensity` (%), `ambient` (%). Lengths are points and scale with the object (Scale Strokes & Effects).
|
||||
Commands validate the shape and the parameters before the document changes.
|
||||
|
||||
@@ -85,7 +100,6 @@ 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**
|
||||
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.
|
||||
- **Perspective** is a 0–100 % strength, as Revolve's, not a 0–160° lens angle.
|
||||
- The bevel shapes and their names are our own; custom bevel paths are not supported.
|
||||
- Visibility is a painter's algorithm: faces of a closed solid turned away are left out, the rest drawn back to
|
||||
front. Concave solids seen at steep angles can overlap wrongly; open (hollow) solids show both sides.
|
||||
|
||||
Reference in new issue
Block a user