Merge pull request #6754 from dotCooCoo/feat/3d-shadows

3D and Materials: shadows behind or below the object, with Distance and Bounds
This commit is contained in:
BFlat
2026-11-12 09:45:11 +07:00
committed by GitHub
46 changed files with 1402 additions and 52 deletions
+8 -8
View File
@@ -176,29 +176,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), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; 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,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"cap":true,"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), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across), cap: bool (true, close circular ends of open profiles; partial-sweep cut sides stay open), expandVisibleOnly: bool (true, trim covered opaque solid surfaces only on Expand Appearance)}. Shaded surfaces use smooth vector gradients automatically; 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,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0,"cap":true,"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), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. 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,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.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), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across)}. 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,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.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), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. 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,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.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), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across)}. 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,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.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), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
"{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth), shadow: bool (false; the first light casts a hard-edged shadow, black at 50% times the art's opacity), shadowPosition: behind|below (behind; behind: on a plane square to the view, below: on the floor under the object, turned with it), shadowDistance: % (0..100, 0; how far the plane is from the object, as a share of its largest size), shadowBounds: % (10..400, 100; how far the plane reaches: at 100 it is four times the object's largest size across)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0,"shadow":false,"shadowPosition":"behind","shadowDistance":0.0,"shadowBounds":100.0}),
),
g(
CLASSIC_EXTRUDE,
+78 -7
View File
@@ -112,7 +112,9 @@ pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Resul
let b = union_bounds(&shapes).ok_or(EMPTY)?;
let options = shared_options(&shapes, revolve_options(p))?;
let mut copies = 0;
return shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items), &mut copies));
shapes.iter().try_for_each(|s| check_surface(&s.path, b, options, surface_item(&s.items), &mut copies))?;
let casting = shapes.iter().filter(|s| surface_item(&s.items).is_some_and(|i| paint_of(i).1 * s.opacity > 0.0));
return check_shadow(p, casting.map(|s| &s.path), b, options);
}
let (path, _) = object_profile(n, "Revolve")?;
if !has_two_anchors(&path) {
@@ -121,7 +123,18 @@ pub fn validate_revolve_in(doc: Option<&Document>, n: &Node, p: &Value) -> Resul
let path = geometry(&path, before, &GeomContext::of(n));
let b = path.bounds().ok_or("Revolve requires a shape with at least two anchors")?;
let paints = solid_paints(n);
check_surface(&path, b, revolve_options(p), surface_item(&paints), &mut 0)
check_surface(&path, b, revolve_options(p), surface_item(&paints), &mut 0)?;
check_shadow(p, [&path], b, revolve_options(p))
}
/// With the shadow of Revolve parameters `p` on, an error when the surfaces of `paths` it is cast
/// from, about the axis of `b`, are past the limits together.
fn check_shadow<'a>(p: &Value, paths: impl IntoIterator<Item = &'a PathData>, b: Rect, options: Revolve) -> Result<(), String> {
if crate::solids::shadow::options(p).is_none() {
return Ok(());
}
let mut caster = vectorcraft_three_d::ShadowCaster::new();
paths.into_iter().try_for_each(|path| caster.revolve(path, vectorcraft_geom::FillRule::NonZero, b, options).map(|_| ()))
}
/// [`validate_revolve`] for a Revolve with parameters `p` at index `index` of the effects of
@@ -144,14 +157,16 @@ pub(crate) fn validate_item_revolve(n: &Node, p: &Value, target: usize, index: u
for (i, item) in n.appearance.items.iter().enumerate() {
let effects = item.effects();
let at = if i == target { Some(index) } else { effects.iter().position(|e| e.visible && e.id == REVOLVE) };
let options = match at.and_then(|at| effects.get(at)) {
_ if i == target => revolve_options(p),
Some(fx) if crate::paints(item) => revolve_options(&merged_params(REVOLVE, &fx.params)),
let params = match at.and_then(|at| effects.get(at)) {
_ if i == target => p.clone(),
Some(fx) if crate::paints(item) => merged_params(REVOLVE, &fx.params),
_ => continue,
};
let options = revolve_options(&params);
let path = geometry(&path, at.and_then(|at| effects.get(..at)).unwrap_or(effects), &ctx.item(item));
let b = path.bounds().ok_or(EMPTY)?;
check_surface(&path, b, options, crate::paints(item).then_some(item), &mut copies)?;
check_shadow(&params, [&path], b, options)?;
}
Ok(())
}
@@ -189,9 +204,10 @@ fn surface_item(items: &[AppearanceItem]) -> Option<&AppearanceItem> {
items.iter().rev().find(|i| matches!(i, AppearanceItem::Fill(_))).or_else(|| items.last())
}
/// Check Revolve's parameter values `p` alone: its lights, `edge` and `cap`.
/// Check Revolve's parameter values `p` alone: its lights, shadow, `edge` and `cap`.
pub(crate) fn validate_revolve_params(p: &Value) -> Result<(), String> {
crate::lighting::validate(p)?;
crate::solids::shadow::validate(p)?;
if p.get("edge").is_some_and(|v| v.as_str() != Some("left") && v.as_str() != Some("right")) {
return Err("Revolve edge must be left or right".into());
}
@@ -205,6 +221,43 @@ fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
apply_geometry_with(fx, path, path.bounds().unwrap_or(Rect::ZERO), ctx)
}
/// The shadow of Revolve `fx` cast by the surface of `path` in `item`'s paint about the axis of
/// `b`, revolved with `options`, as dark as `item` is opaque and in its blending mode; `None` with
/// the shadow off, when `item` is fully transparent or when it casts nothing, and an error when
/// the surface it is cast from is past the limits.
fn shadow_of(id: NodeId, fx: &Effect, path: &PathData, item: &AppearanceItem, b: Rect, options: Revolve) -> Result<Option<Node>, String> {
let Some(settings) = crate::solids::shadow::options(&merged_params(REVOLVE, &fx.params)) else { return Ok(None) };
let (_, opacity, blend) = paint_of(item);
if opacity <= 0.0 {
return Ok(None);
}
let mut caster = vectorcraft_three_d::ShadowCaster::new();
caster.revolve(path, vectorcraft_geom::FillRule::NonZero, b, options)?;
Ok(crate::solids::shadow::art(id, &caster, settings, crate::solids::shadow::Ink { opacity, blend }))
}
/// The shadow of group Revolve `fx` cast by the surfaces of the members `shapes` that have one,
/// about the axis of `b`, on one plane; each member's part is as dark as its surface is opaque,
/// in its paint's blending mode, and its opacity mask and blending mode apply to that part
/// ([`crate::solids::shadow::scoped_art`]). An error when the surfaces it is cast from are past the
/// limits.
fn group_shadow(id: NodeId, fx: &Effect, shapes: &[Profile], b: Rect, options: Revolve) -> Result<Vec<Arc<Node>>, String> {
let Some(settings) = crate::solids::shadow::options(&merged_params(REVOLVE, &fx.params)) else { return Ok(vec![]) };
let mut caster = vectorcraft_three_d::ShadowCaster::new();
let mut cast = vec![];
for s in shapes {
let Some((_, opacity, blend)) = surface_item(&s.items).map(paint_of) else { continue };
// A member whose surface is fully transparent casts nothing.
let ink = crate::solids::shadow::Ink { opacity: opacity * s.opacity, blend };
if ink.opacity <= 0.0 {
continue;
}
let faces = caster.revolve(&s.path, vectorcraft_geom::FillRule::NonZero, b, options)?;
cast.push((s.scopes.as_slice(), ink, faces));
}
Ok(crate::solids::shadow::scoped_art(id, &caster, settings, cast, crate::solids::has_masks(shapes)))
}
/// One object's surface of revolution about the axis of `b`, and each face's depth. `copies` counts
/// the bytes of the paint copies of every surface of the object.
#[allow(clippy::too_many_arguments)]
@@ -445,7 +498,13 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
let paints = solid_paints(n);
let item = surface_item(&paints)?;
let expand = expand && !n.appearance.effects.iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
children.push(Arc::new(object_surface(n, &path, fx, item, expand, &mut copies)?));
let surface = object_surface(n, &path, fx, item, expand, &mut copies)?;
if let Some(b) = path.bounds()
&& let Some(shadow) = shadow_of(n.id, fx, &path, item, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?
{
children.push(Arc::new(shadow));
}
children.push(Arc::new(surface));
// Later geometry acts on the projected art as a whole through container evaluation.
remaining = n
.appearance
@@ -458,6 +517,8 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
} else {
let path = geometry(&path, &n.appearance.effects, &ctx);
remaining.retain(|e| !is_geometry(&e.id));
// Every item's shadow goes behind every item's art.
let mut shadows = vec![];
for item in n.appearance.items.iter().filter(|i| crate::paints(i)) {
if let Some(index) = item.effects().iter().position(|e| e.visible && e.id == REVOLVE) {
let fx = item.effects().get(index)?;
@@ -465,11 +526,18 @@ fn art(doc: Option<&Document>, n: &Node, expand: bool) -> Option<Node> {
let expand = expand && !item.effects().iter().skip(index + 1).any(|e| e.visible && is_geometry(&e.id));
let mut art = object_surface(n, &p, fx, item, expand, &mut copies)?;
art.appearance.effects = item.effects().iter().skip(index + 1).filter(|e| e.id != REVOLVE).cloned().collect();
if let Some(b) = p.bounds()
&& let Some(mut shadow) = shadow_of(n.id, fx, &p, item, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?
{
shadow.appearance.effects = art.appearance.effects.clone();
shadows.push(Arc::new(shadow));
}
children.push(Arc::new(art));
} else {
children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![item.clone()], ..Default::default() })));
}
}
children.splice(0..0, shadows);
}
let mut art = n.clone();
art.kind = NodeKind::Group { children, clip: false };
@@ -704,6 +772,9 @@ fn group_art(n: &Node, expand: bool) -> Option<Node> {
scoped(n.id, runs.collect(), 0)
}
};
// One shadow for every member's surface, behind them all.
let mut children = children;
children.splice(0..0, group_shadow(n.id, fx, &shapes, b, options).ok()?);
let remaining =
n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect();
let mut art = n.clone();
+124 -13
View File
@@ -20,6 +20,8 @@ pub use classic::{CLASSIC_EXTRUDE, SHADING_COLORS, classic_extrude_options, clas
mod source;
pub(crate) use source::{resolve, resolve_for};
pub(crate) mod shadow;
pub const EXTRUDE: &str = "threeD.extrude";
pub const INFLATE: &str = "threeD.inflate";
pub const ROTATE: &str = "threeD.rotate";
@@ -192,6 +194,7 @@ fn validate_solid_params(id: &str, p: &Value) -> Result<(), String> {
classic::validate(p)?;
}
crate::lighting::validate(p)?;
shadow::validate(p)?;
if let Some(v) = p.get("bevel")
&& !v.as_str().is_some_and(|s| s == "none" || BevelShape::parse(s).is_some())
{
@@ -214,17 +217,31 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
/// [`validate_solid`] with the symbols of `doc`: a symbol instance, a blend and a clipping group
/// build from the art they show ([`resolve`]).
pub fn validate_solid_in(doc: Option<&Document>, n: &Node, id: &str, p: &Value) -> Result<(), String> {
validate_solid_for(doc, n, id, p, None)
}
/// [`validate_solid_in`] for the effect on the object (`item` `None`) or on its appearance item
/// `item`, whose shadow is cast by that item's solid alone.
fn validate_solid_for(doc: Option<&Document>, n: &Node, id: &str, p: &Value, item: Option<usize>) -> Result<(), String> {
validate_solid_params(id, p)?;
let label = label_of(id);
let n = &*source::resolve_for(doc, n, label, id == ROTATE)?;
if is_container(n) {
let shapes = members(n, label)?;
let b = union_bounds(&shapes).ok_or_else(|| format!("{label} requires a group holding a shape with at least two anchors"))?;
return shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ()));
shapes.iter().try_for_each(|s| facets(id, &s.path, s.rule, b, p).map(|_| ()))?;
return cast_shadow(id, p, &shapes).map(|_| ());
}
let (path, rule) = object_profile(n, label)?;
let b = path.bounds().ok_or_else(|| format!("{label} requires a shape with at least two anchors"))?;
facets(id, &path, rule, b, p).map(|_| ())
facets(id, &path, rule, b, p)?;
// With its shadow on, the shadow of the object's solids (or of the item's) must fit too.
let items = match item.and_then(|i| n.appearance.items.get(i)) {
Some(item) => vec![item.clone()],
None => solid_paints(n),
};
let shapes = [Profile { path, rule, items, opacity: 1.0, scopes: vec![] }];
cast_shadow(id, p, &shapes).map(|_| ())
}
/// [`profile`], or the error a command reports for object `n`: type with no outlines to build from
@@ -297,7 +314,7 @@ fn masked(id: NodeId, surfaces: Vec<(&[Scope], Arc<Node>)>) -> Vec<Arc<Node>> {
}
/// Does any of `shapes` sit in a scope with an opacity mask or a blending mode other than Normal?
fn has_masks(shapes: &[Profile]) -> bool {
pub(crate) fn has_masks(shapes: &[Profile]) -> bool {
shapes.iter().any(|s| s.scopes.iter().any(composites))
}
@@ -420,7 +437,8 @@ pub fn validate_three_d_at_in(doc: Option<&Document>, n: &Node, id: &str, p: &Va
Err("A 3D effect on a group or layer goes on the group itself, not on its fill or stroke".into())
}
Some((item, index)) if id == crate::REVOLVE => crate::revolve::validate_item_revolve(n, p, item, index),
_ => validate_three_d_in(doc, n, id, p),
Some((item, _)) => validate_solid_for(doc, n, id, p, Some(item)),
None => validate_three_d_in(doc, n, id, p),
}
}
@@ -551,6 +569,9 @@ pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile
}
}
/// A 3D effect's shadow and its surfaces ([`art_for`]).
type ShadowAndSurfaces = (Vec<Arc<Node>>, Vec<Arc<Node>>);
/// The 3D art of one effect `fx` on `path` (already through the effects before it).
/// Rotate turns every fill and stroke, each in its own paint. Extrude & Bevel and Inflate build a
/// solid from the path for the top visible fill and one from the outline of each visible stroke,
@@ -559,8 +580,87 @@ pub(crate) fn reshape_members(n: &Node, before: &[Effect], shapes: &mut [Profile
/// depths tie, as for the caps turned toward the viewer, in the order of the appearance);
/// otherwise each solid is a surface of its own, in that order. A member's opacity mask and
/// blending mode, and those of a group it is in, apply to the surfaces of the members it holds
/// ([`masked`]).
fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>> {
/// ([`masked`]). The art comes in two parts: the effect's shadow ([`cast_shadow`], none with it
/// off), to be drawn behind every surface of the object, and the surfaces.
fn art_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<ShadowAndSurfaces> {
let art = surfaces_for(n, fx, shapes)?;
// A shadow past the limits draws the source path, as a solid past them does; commands report
// the error ([`validate_solid_in`]).
let shadow = match cast_shadow(&fx.id, &crate::merged_params(&fx.id, &fx.params), shapes).ok()? {
Some((caster, settings, cast)) => shadow::scoped_art(n.id, &caster, settings, cast, has_masks(shapes)),
None => vec![],
};
Some((shadow, art))
}
/// What the shadow of the solids [`surfaces_for`] builds for effect `id` with parameters `p` on
/// `shapes` is cast from, on one plane: the faces of every solid, the shadow's settings and the
/// faces of each solid of visible art with its member's scopes and ink. `None` with the shadow
/// off, for Extrude & Bevel (Classic) or with no shapes. A stroke's solid that is not built (past
/// the limits) casts nothing, as it draws nothing; any other solid that is not built is an error.
/// When a member's opacity mask or blending mode applies to its solids ([`masked`]), it applies to
/// the part of the shadow they cast too ([`shadow::scoped_art`]).
fn cast_shadow<'a>(id: &str, p: &Value, shapes: &'a [Profile]) -> Result<Option<Cast<'a>>, String> {
if id == CLASSIC_EXTRUDE {
return Ok(None);
}
let (Some(settings), Some(b)) = (shadow::options(p), union_bounds(shapes)) else { return Ok(None) };
let view = view_options(p);
let mut caster = vectorcraft_three_d::ShadowCaster::new();
// The faces each solid of visible art added, with its member's scopes and its ink (opacity
// and blending mode), in order. A fully transparent fill or stroke (or member) casts nothing
// and does not place the plane.
let mut cast: Vec<shadow::Cast> = vec![];
let ink = |item: &AppearanceItem, member: f32| {
let (_, opacity, blend) = paint_of(item, member);
shadow::Ink { opacity, blend }
};
if id == ROTATE {
for s in shapes {
for item in s.items.iter().filter(|i| ink(i, s.opacity).opacity > 0.0) {
let (area, rule) = region(&s.path, s.rule, item);
let faces = caster.plane(&area, rule, b, view)?;
cast.push((s.scopes.as_slice(), ink(item, s.opacity), faces));
}
}
} else {
let (parts, bounds) = solid_parts(shapes, b);
let inks: Vec<shadow::Ink> = parts.iter().map(|(item, member, ..)| ink(item, *member)).collect();
// Inflate's eye is placed for every shape together, as the solids are.
let added: Vec<Result<std::ops::Range<usize>, String>> = if id == INFLATE {
let shapes: Vec<(&PathData, FillRule)> = parts.iter().map(|(_, _, area, rule, _)| (area, *rule)).collect();
let casts: Vec<bool> = inks.iter().map(|i| i.opacity > 0.0).collect();
caster.inflate(&shapes, &casts, bounds, inflate_options(p), view)
} else {
let mut added = vec![];
for ((_, _, area, rule, _), ink) in parts.iter().zip(&inks) {
added.push(if ink.opacity > 0.0 { caster.extrude(area, *rule, bounds, extrude_options(p), view) } else { Ok(0..0) });
}
added
};
for (((item, _, _, _, scopes), ink), faces) in parts.iter().zip(inks).zip(added) {
if ink.opacity <= 0.0 {
continue;
}
// A stroke whose own solid is past the limits is left out; past the shadow's limit for
// all the solids together, the shadow is refused.
let faces = match faces {
Ok(faces) => faces,
Err(e) if matches!(item, AppearanceItem::Stroke(_)) && e != vectorcraft_three_d::TOO_COMPLEX_SHADOW => continue,
Err(e) => return Err(e),
};
cast.push((*scopes, ink, faces));
}
}
Ok(Some((caster, settings, cast)))
}
/// The faces a shadow is cast from, its settings, and the faces of each solid of visible art with
/// its member's scopes and ink ([`cast_shadow`]).
type Cast<'a> = (vectorcraft_three_d::ShadowCaster, vectorcraft_three_d::Shadow, Vec<shadow::Cast<'a>>);
/// [`art_for`] without the shadow: the solids' surfaces.
fn surfaces_for(n: &Node, fx: &Effect, shapes: &[Profile]) -> Option<Vec<Arc<Node>>> {
let b = union_bounds(shapes)?;
let p = crate::merged_params(&fx.id, &fx.params);
let classic = fx.id == CLASSIC_EXTRUDE;
@@ -676,7 +776,8 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option<Node> {
let remaining: Vec<Effect>;
if let Some((index, fx)) = solid(&n.appearance.effects) {
let path = geometry(&path, n.appearance.effects.get(..index)?, &ctx);
children.extend(art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?);
let (shadow, surfaces) = art_for(n, fx, &[Profile { path, rule, items: paints, opacity: 1.0, scopes: vec![] }])?;
children.extend(shadow.into_iter().chain(surfaces));
// Later geometry effects act on the projected art as a whole.
remaining = n
.appearance
@@ -689,18 +790,24 @@ pub fn solid_art_in(doc: Option<&Document>, n: &Node) -> Option<Node> {
} else {
let path = geometry(&path, &n.appearance.effects, &ctx);
remaining = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect();
// Every item's shadow goes behind every item's art.
let mut shadows = vec![];
for item in &paints {
if let Some((index, fx)) = solid(item.effects()) {
let p = geometry(&path, item.effects().get(..index)?, &ctx.item(item));
for mut art in art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])? {
Arc::make_mut(&mut art).appearance.effects =
item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect();
children.push(art);
}
let (shadow, surfaces) = art_for(n, fx, &[Profile { path: p, rule, items: vec![item.clone()], opacity: 1.0, scopes: vec![] }])?;
let later: Vec<Effect> = item.effects().iter().skip(index + 1).filter(|e| !is_three_d(&e.id)).cloned().collect();
let with_later = |mut art: Arc<Node>| {
Arc::make_mut(&mut art).appearance.effects = later.clone();
art
};
shadows.extend(shadow.into_iter().map(with_later));
children.extend(surfaces.into_iter().map(with_later));
} else {
children.push(Arc::new(Node::path(n.id, path.clone(), Appearance { items: vec![item.clone()], ..Default::default() })));
}
}
children.splice(0..0, shadows);
}
let mut art = n.clone();
art.kind = NodeKind::Group { children, clip: false };
@@ -716,7 +823,8 @@ fn group_solid_art(n: &Node) -> Option<Node> {
let (index, fx) = solid(&n.appearance.effects)?;
let mut shapes = members(n, "").ok()?;
reshape_members(n, n.appearance.effects.get(..index)?, &mut shapes);
let children = art_for(n, fx, &shapes)?;
let (shadow, surfaces) = art_for(n, fx, &shapes)?;
let children = shadow.into_iter().chain(surfaces).collect();
let remaining =
n.appearance.effects.iter().enumerate().filter(|(i, e)| *i != index && (!is_geometry(&e.id) || *i > index)).map(|(_, e)| e.clone()).collect();
let mut art = n.clone();
@@ -906,3 +1014,6 @@ mod tests_mesh;
#[cfg(test)]
mod tests_sources;
#[cfg(test)]
mod tests_shadow;
+101
View File
@@ -0,0 +1,101 @@
//! The shadow of the 3D and Materials effects (Extrude & Bevel, Inflate, Rotate and Revolve): the
//! `shadow` switch with `shadowPosition`, `shadowDistance` and `shadowBounds`, and the shape it
//! draws behind the solids.
use std::ops::Range;
use std::sync::Arc;
use serde_json::Value;
use vectorcraft_doc::color::{BlendMode, Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeId};
use vectorcraft_three_d::{Shadow, ShadowCaster, ShadowPlane, ShadowPosition};
use super::Scope;
use crate::util::{flag, num, text};
/// The shadow's paint: black at this opacity. VectorCraft's own values.
const SHADOW_OPACITY: f32 = 0.5;
/// The shadow `p` asks for, or `None` with the switch off or when the first light lights nothing
/// (Shading is off or its intensity is 0), so that no shadow is built.
pub(crate) fn options(p: &Value) -> Option<Shadow> {
let intensity = crate::lighting::lights(p).and_then(|l| l.iter().next().map(|l| l.intensity)).unwrap_or_else(|| num(p, "lightIntensity", 80.0));
let lit = flag(p, "shade", true) && intensity > 0.0;
(lit && flag(p, "shadow", false)).then(|| Shadow {
position: ShadowPosition::parse(text(p, "shadowPosition", "behind")).unwrap_or_default(),
distance: num(p, "shadowDistance", 0.0) / 100.0,
bounds: num(p, "shadowBounds", 100.0) / 100.0,
})
}
/// Check the shadow settings in `p`: an error for a value out of its range or of the wrong kind.
pub(crate) fn validate(p: &Value) -> Result<(), String> {
if p.get("shadow").is_some_and(|v| !v.is_boolean()) {
return Err("shadow must be true or false".into());
}
if p.get("shadowPosition").is_some_and(|v| v.as_str().and_then(ShadowPosition::parse).is_none()) {
return Err("shadowPosition must be behind or below".into());
}
for (key, min, max) in [("shadowDistance", 0.0, 100.0), ("shadowBounds", 10.0, 400.0)] {
if p.get(key).is_some_and(|v| !v.as_f64().is_some_and(|n| n.is_finite() && (min..=max).contains(&n))) {
return Err(format!("{key} must be a number from {min} to {max}"));
}
}
Ok(())
}
/// The opacity and blending mode of the fill or stroke (and member) that casts part of a shadow.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Ink {
pub(crate) opacity: f32,
pub(crate) blend: BlendMode,
}
/// The shadow of the faces in `caster` as a path of object `id`, cast by art in `ink`: filled
/// black at [`SHADOW_OPACITY`] times its opacity, in its blending mode, with the nonzero rule;
/// `None` when it casts nothing.
pub(crate) fn art(id: NodeId, caster: &ShadowCaster, shadow: Shadow, ink: Ink) -> Option<Node> {
let all = 0..caster.len();
let all = std::slice::from_ref(&all);
piece(id, caster, &caster.shadow_plane(shadow, all)?, all, ink)
}
/// [`art`] of the faces in the ranges `faces` of `caster` only, on `plane`.
fn piece(id: NodeId, caster: &ShadowCaster, plane: &ShadowPlane, faces: &[Range<usize>], ink: Ink) -> Option<Node> {
let path = caster.path_on(plane, faces)?;
let mut fill = FillLayer::new(Paint::solid(Color::BLACK));
fill.opacity = SHADOW_OPACITY * ink.opacity.clamp(0.0, 1.0);
fill.blend = ink.blend;
Some(Node::path(id, path, Appearance { items: vec![AppearanceItem::Fill(fill)], ..Default::default() }))
}
/// Whether `a` and `b` are the same objects' scopes (each member holds its own copies).
fn same_scopes(a: &[Scope], b: &[Scope]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| a.key == b.key)
}
/// The faces one solid added to a caster, with the scopes of its member and the ink of its art.
pub(crate) type Cast<'a> = (&'a [Scope], Ink, Range<usize>);
/// The faces of the solids that cast one piece of a shadow, with their scopes and ink.
type Run<'a> = (&'a [Scope], Ink, Vec<Range<usize>>);
/// The shadow `cast` casts on one plane: the faces of `caster` that the solids of visible art
/// added, each range with the scopes of the member that cast it and the ink of the art, in order.
/// Each run of solids in the same ink casts a piece in that ink ([`art`]); with `masks` (a member's
/// opacity mask or blending mode applies to its solids) the solids of a run also share their scopes,
/// and each piece is drawn in their opacity masks and blending modes ([`super::masked`]).
pub(crate) fn scoped_art(id: NodeId, caster: &ShadowCaster, shadow: Shadow, cast: Vec<Cast<'_>>, masks: bool) -> Vec<Arc<Node>> {
let placing: Vec<Range<usize>> = cast.iter().map(|(.., faces)| faces.clone()).collect();
let Some(plane) = caster.shadow_plane(shadow, &placing) else { return vec![] };
// Consecutive solids in the same ink (and with masks, the same scopes) share a piece; the pieces
// keep the solids' order.
let mut runs: Vec<Run> = vec![];
for (scopes, ink, faces) in cast {
match runs.last_mut() {
Some(run) if run.1 == ink && (!masks || same_scopes(run.0, scopes)) => run.2.push(faces),
_ => runs.push((scopes, ink, vec![faces])),
}
}
let pieces = runs.into_iter().filter_map(|(scopes, ink, faces)| Some((scopes, Arc::new(piece(id, caster, &plane, &faces, ink)?))));
if masks { super::masked(id, pieces.collect()) } else { pieces.map(|(_, piece)| piece).collect() }
}
+225
View File
@@ -0,0 +1,225 @@
use serde_json::json;
use vectorcraft_doc::color::Color;
use vectorcraft_doc::{Document, NodeId};
use vectorcraft_geom::{Affine, BezPath};
use super::*;
const RED: Color = Color::Rgb { r: 0.8, g: 0.2, b: 0.1 };
/// The 3D and Materials effects that cast shadows.
const CASTING: [&str; 4] = [EXTRUDE, INFLATE, ROTATE, crate::REVOLVE];
fn square_node(id: u64, dx: f64) -> Node {
let mut b = BezPath::new();
b.move_to((100.0 + dx, 100.0));
b.line_to((200.0 + dx, 100.0));
b.line_to((200.0 + dx, 200.0));
b.line_to((100.0 + dx, 200.0));
b.close_path();
Node::path(NodeId(id), PathData::from_bezpath(&b), Appearance::basic(Paint::solid(RED), Paint::None, 0.0))
}
fn art(n: &Node, id: &str) -> Option<Node> {
if id == crate::REVOLVE { crate::revolve_art(n) } else { solid_art(n) }
}
/// The opacity of shadow `n`: a path filled black and nothing else; `None` for any other node.
fn shadow_opacity(n: &Node) -> Option<f32> {
let [AppearanceItem::Fill(fill)] = n.appearance.items.as_slice() else { return None };
(matches!(n.kind, NodeKind::Path { .. }) && fill.paint.color() == Some(Color::BLACK)).then_some(fill.opacity)
}
/// Whether `n` is the shadow of opaque art: a path filled black at 50 %, nothing else.
fn is_shadow(n: &Node) -> bool {
shadow_opacity(n) == Some(0.5)
}
fn shadows(n: &Node) -> usize {
let mut count = 0;
n.walk(&mut |c| count += usize::from(is_shadow(c)));
count
}
#[test]
fn each_3d_effect_draws_its_shadow_behind_its_solid_when_shadows_are_on() {
for id in CASTING {
let mut n = square_node(1, 0.0);
n.appearance.effects.push(crate::new_effect(id, &json!({})).unwrap());
let plain = art(&n, id).unwrap_or_else(|| panic!("{id}"));
assert_eq!(shadows(&plain), 0, "{id}: off by default");
n.appearance.effects[0].params = json!({"shadow": true, "shadowDistance": 50});
let lit = art(&n, id).unwrap_or_else(|| panic!("{id}"));
let children = lit.children().unwrap();
assert!(is_shadow(&children[0]), "{id}: the shadow is drawn first");
assert_eq!(shadows(&lit), 1, "{id}");
// The default light is up and to the left: the shadow on a plane away from the object
// reaches past the solid down and right.
let shadow = children[0].visual_bounds().unwrap();
let solid = children[1..].iter().filter_map(|c| c.visual_bounds()).reduce(|a, b| a.union(b)).unwrap();
assert!(shadow.x1 > solid.x1 && shadow.y1 > solid.y1, "{id}: {shadow:?} {solid:?}");
// The art's bounds take the shadow in.
assert!(lit.visual_bounds().unwrap().contains_rect(shadow), "{id}");
}
}
#[test]
fn the_shadow_moves_with_its_settings() {
for id in CASTING {
let bounds = |params: Value| {
let mut n = square_node(1, 0.0);
n.appearance.effects.push(crate::new_effect(id, &params).unwrap());
let lit = art(&n, id).unwrap();
let shadow = &lit.children().unwrap()[0];
assert!(is_shadow(shadow), "{id} {params}");
shadow.visual_bounds().unwrap()
};
let near = bounds(json!({"shadow": true}));
let far = bounds(json!({"shadow": true, "shadowDistance": 100}));
assert!(far.x1 > near.x1 + 1.0, "{id}: {near:?} {far:?}");
// With the light straight in front, the outline falls about the plane's center; at 10 %
// the plane cuts it to a square 0.4 times the object's size across.
let tight = bounds(json!({"shadow": true, "shadowBounds": 10, "lightHeight": 90}));
let wide = bounds(json!({"shadow": true, "shadowBounds": 400, "lightHeight": 90}));
assert!(wide.width() > tight.width() * 2.0, "{id}: {tight:?} {wide:?}");
let below = bounds(json!({"shadow": true, "shadowPosition": "below", "rotationX": -30}));
assert_ne!(below, near, "{id}");
}
}
#[test]
fn shadow_settings_out_of_range_or_of_the_wrong_kind_are_refused() {
for id in CASTING {
for (params, error) in [
(json!({"shadow": "yes"}), "shadow must be true or false"),
(json!({"shadowPosition": "under"}), "shadowPosition must be behind or below"),
(json!({"shadowPosition": 1}), "shadowPosition must be behind or below"),
(json!({"shadowDistance": 101}), "shadowDistance must be a number from 0 to 100"),
(json!({"shadowDistance": -1}), "shadowDistance must be a number from 0 to 100"),
(json!({"shadowBounds": 5}), "shadowBounds must be a number from 10 to 400"),
(json!({"shadowBounds": "wide"}), "shadowBounds must be a number from 10 to 400"),
] {
let err = validate_three_d_params(id, &params).unwrap_err();
assert!(err.contains(error), "{id} {params}: {err}");
}
let ok = json!({"shadow": true, "shadowPosition": "below", "shadowDistance": 100, "shadowBounds": 10});
assert!(validate_three_d_params(id, &ok).is_ok(), "{id}");
}
}
#[test]
fn a_group_casts_one_shadow_for_all_its_members() {
for id in CASTING {
let mut g = Node::group(NodeId(9), vec![Arc::new(square_node(1, 0.0)), Arc::new(square_node(2, 200.0))]);
g.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true, "lightHeight": 90})).unwrap());
let lit = art(&g, id).unwrap_or_else(|| panic!("{id}"));
assert_eq!(shadows(&lit), 1, "{id}");
let shadow = lit.children().unwrap()[0].clone();
assert!(is_shadow(&shadow), "{id}");
// With the light straight in front, the shadow behind spans both members.
let b = shadow.visual_bounds().unwrap();
assert!(b.x0 < 200.0 && b.x1 > 300.0, "{id}: {b:?}");
}
}
/// A member's opacity mask applies to the part of the shadow it casts, as to its solid: that part
/// is drawn in the mask, the other member's outside it, both on one plane.
#[test]
fn a_masked_member_casts_its_part_of_the_shadow_in_its_mask() {
for id in CASTING {
let mut masked = square_node(2, 200.0);
let mut cover = square_node(3, 200.0);
cover.appearance.items = Appearance::basic(Paint::solid(Color::WHITE), Paint::None, 0.0).items;
masked.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(cover, true)));
let mut g = Node::group(NodeId(9), vec![Arc::new(square_node(1, 0.0)), Arc::new(masked)]);
g.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true})).unwrap());
let lit = art(&g, id).unwrap_or_else(|| panic!("{id}"));
assert_eq!(shadows(&lit), 2, "{id}: one piece for each member");
let mut in_mask = 0;
lit.walk(&mut |c| {
if c.extras().mask.is_some() {
c.walk(&mut |s| in_mask += usize::from(is_shadow(s)));
}
});
assert_eq!(in_mask, 1, "{id}");
}
}
#[test]
fn transparent_art_and_a_light_that_lights_nothing_cast_no_shadow() {
for id in CASTING {
let mut n = square_node(1, 0.0);
n.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true, "shade": false})).unwrap());
assert_eq!(shadows(&art(&n, id).unwrap()), 0, "{id}: Shading off");
n.appearance.effects[0].params = json!({"shadow": true, "lightIntensity": 0});
assert_eq!(shadows(&art(&n, id).unwrap()), 0, "{id}: no intensity");
// A member at 0 % opacity casts nothing; the other member still casts its shadow.
let mut clear = square_node(2, 200.0);
clear.opacity = 0.0;
let mut g = Node::group(NodeId(9), vec![Arc::new(square_node(1, 0.0)), Arc::new(clear)]);
g.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true, "lightHeight": 90})).unwrap());
let lit = art(&g, id).unwrap_or_else(|| panic!("{id}"));
let shadow = lit.children().unwrap()[0].clone();
assert!(is_shadow(&shadow), "{id}");
let b = shadow.visual_bounds().unwrap();
assert!(b.x1 < 300.0, "{id}: only the visible member casts: {b:?}");
// Art at 50 % casts a shadow half as dark.
let mut half = square_node(1, 0.0);
for item in &mut half.appearance.items {
if let AppearanceItem::Fill(fill) = item {
fill.opacity = 0.5;
}
}
half.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true})).unwrap());
let lit = art(&half, id).unwrap();
assert_eq!(shadow_opacity(&lit.children().unwrap()[0]), Some(0.25), "{id}");
// Art in a blending mode casts its shadow in that mode.
for item in &mut half.appearance.items {
if let AppearanceItem::Fill(fill) = item {
fill.blend = BlendMode::Screen;
}
}
let lit = art(&half, id).unwrap();
let AppearanceItem::Fill(fill) = &lit.children().unwrap()[0].appearance.items[0] else { panic!("{id}: a fill") };
assert_eq!(fill.blend, BlendMode::Screen, "{id}");
}
}
/// With a 3D effect on each of two fills, both fills' shadows are drawn behind both fills' solids.
#[test]
fn the_shadows_of_every_fill_go_behind_every_fill_s_solid() {
for id in CASTING {
let mut n = square_node(1, 0.0);
let fill = n.appearance.items.iter().find(|i| matches!(i, AppearanceItem::Fill(_))).unwrap().clone();
n.appearance.items = vec![fill.clone(), fill];
for item in &mut n.appearance.items {
item.effects_mut().push(crate::new_effect(id, &json!({"shadow": true})).unwrap());
}
let lit = art(&n, id).unwrap_or_else(|| panic!("{id}"));
let children = lit.children().unwrap();
assert!(children.len() >= 4, "{id}");
assert!(is_shadow(&children[0]) && is_shadow(&children[1]), "{id}: both shadows first");
assert!(children[2..].iter().all(|c| !is_shadow(c)), "{id}");
}
}
#[test]
fn expand_appearance_keeps_the_shadow_as_a_path() {
for id in CASTING {
let mut d = Document::new(400.0, 400.0);
let mut n = square_node(1, 0.0);
n.id = d.alloc_id();
n.appearance.effects.push(crate::new_effect(id, &json!({"shadow": true})).unwrap());
let mut stroke = |_: &mut Document, _: &PathData, _: FillRule, _: &StrokeLayer| None;
let expanded = crate::expand_leaf(&mut d, &n, &mut stroke).unwrap_or_else(|| panic!("{id}"));
assert_eq!(shadows(&expanded), 1, "{id}");
let mut effects = 0;
expanded.walk(&mut |c| effects += c.appearance.effects.len());
assert_eq!(effects, 0, "{id}");
// It moves with the object, as the solid does.
let before = art(&n, id).unwrap().children().unwrap()[0].visual_bounds().unwrap();
n.transform(Affine::translate((40.0, 0.0)), false);
let after = art(&n, id).unwrap().children().unwrap()[0].visual_bounds().unwrap();
assert!((after.x0 - before.x0 - 40.0).abs() < 1e-6, "{id}: {before:?} {after:?}");
}
}
+53
View File
@@ -881,3 +881,56 @@ fn move_takes_several_effects_that_land_together_in_order() {
assert!(s.execute("effect.move", &json!({"from": [0, 9], "to": 0, "fromItem": null, "ids": [id.0]})).is_err());
assert_eq!(node(&s, id).appearance, before);
}
/// A 3D effect's shadow is set with `effect.setParams` in one undo step, saved in the native file,
/// drawn in exports and kept as a path by Expand Appearance; settings out of range are refused
/// without an undo step.
#[test]
fn a_3d_shadow_is_one_undo_step_saved_exported_and_expanded() {
let (mut s, id) = session_with_rect();
s.execute("effect.apply", &json!({"effect": effects::EXTRUDE, "ids": [id.0], "item": null})).unwrap();
let undo = s.doc().unwrap().history.undo.len();
for (params, why) in [
(json!({"shadowBounds": 401}), "shadowBounds must be a number from 10 to 400"),
(json!({"shadowPosition": "left"}), "shadowPosition must be behind or below"),
] {
let before = s.doc().unwrap().doc.clone();
let e = s.execute("effect.setParams", &json!({"ids": [id.0], "index": 0, "item": null, "params": params})).unwrap_err().to_string();
assert!(e.contains(why), "{params}: {e}");
assert_eq!(s.doc().unwrap().doc, before);
}
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
let params = json!({"shadow": true, "shadowPosition": "below", "shadowDistance": 20, "shadowBounds": 150});
s.execute("effect.setParams", &json!({"ids": [id.0], "index": 0, "item": null, "params": params})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
let art = effects::solid_art(&node(&s, id)).unwrap();
let shadow = art.children().unwrap()[0].clone();
assert!(matches!(shadow.kind, NodeKind::Path { .. }));
// Saved and opened again, the effect keeps its shadow settings and draws the same shadow.
let doc = &s.doc().unwrap().doc;
let restored = vectorcraft_format::load(&vectorcraft_format::save(doc, false)).unwrap();
let fx = &restored.node(id).unwrap().appearance.effects[0];
for (key, value) in params.as_object().unwrap() {
assert_eq!(fx.params.get(key), Some(value), "{key}");
}
assert_eq!(effects::solid_art(restored.node(id).unwrap()).unwrap(), art);
// SVG export draws it: one more shape than without the shadow.
let shapes = |s: &mut Session| {
let svg = s.execute("document.serialize", &json!({"format": "svg"})).unwrap();
let svg = svg["data"].as_str().or_else(|| svg["text"].as_str()).map(str::to_string).unwrap_or_else(|| svg.to_string());
vectorcraft_testkit::svg_shapes(&svg)
};
let with = shapes(&mut s);
s.execute("edit.undo", &json!({})).unwrap();
let without = shapes(&mut s);
assert_eq!(with, without + 1);
s.execute("edit.redo", &json!({})).unwrap();
s.execute("effect.expandAppearance", &json!({"ids": [id.0]})).unwrap();
let mut shadows = 0;
s.doc().unwrap().doc.walk(|n| {
if n.path_data().is_some() && n.path_data() == shadow.path_data() && n.appearance.items == shadow.appearance.items {
shadows += 1;
}
});
assert_eq!(shadows, 1);
}
+16 -7
View File
@@ -17,6 +17,8 @@ pub use lights::{Light, Lights, MAX_LIGHTS};
mod caps;
mod mesh;
pub use mesh::{Mesh, extrude_mesh, inflate_meshes, plane_mesh, revolve_mesh};
mod shadow;
pub use shadow::{MAX_SHADOW_FACES, Shadow, ShadowCaster, ShadowPlane, ShadowPosition, TOO_COMPLEX_SHADOW};
use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect, Shape, Vec2, kurbo::ParamCurveDeriv};
@@ -672,6 +674,19 @@ impl HoleSearch {
}
}
/// How sanitized Revolve options `o` project a profile with `bounds`: about the axis at the
/// profile's middle height, with the eye as far as the swept radius or the height needs.
fn revolve_projection(bounds: Rect, o: Revolve) -> shadow::Projection {
let axis = match o.edge {
Edge::Left => bounds.x0 - o.offset,
Edge::Right => bounds.x1 + o.offset,
};
let center_y = (bounds.y0 + bounds.y1) * 0.5;
let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs());
let eye = eye_distance(o.perspective, radius.max(bounds.height().abs()).max(1.0));
shadow::Projection { view: o, origin: Point::new(axis, center_y), eye }
}
/// [`revolve`], or with `mesh` the surface's faces before projection, kept there instead of drawn,
/// each turned out of the region `rule` fills.
fn revolve_faces(
@@ -697,13 +712,7 @@ fn revolve_faces(
if ![bounds.x0, bounds.y0, bounds.x1, bounds.y1].iter().all(|v| v.is_finite() && v.abs() <= 1e8) {
return Err("Revolve requires finite profile coordinates within 100 million points".into());
}
let axis = match o.edge {
Edge::Left => bounds.x0 - o.offset,
Edge::Right => bounds.x1 + o.offset,
};
let center_y = (bounds.y0 + bounds.y1) * 0.5;
let radius = (bounds.x0 - axis).abs().max((bounds.x1 - axis).abs());
let eye = eye_distance(o.perspective, radius.max(bounds.height().abs()).max(1.0));
let shadow::Projection { origin: Point { x: axis, y: center_y }, eye, .. } = revolve_projection(bounds, o);
let steps = o.steps();
let sweep = o.angle.to_radians();
let smooth = o.shade && o.smooth_shading;
+3 -3
View File
@@ -56,12 +56,12 @@ impl Mesh {
/// Extrude & Bevel's solid ([`crate::extrude`]) as a mesh.
pub fn extrude_mesh(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result<Mesh, String> {
extrude_faces(path, rule, bounds, options, view, true).and_then(|(_, faces)| mesh(&faces))
extrude_faces(path, rule, bounds, options, view, true).and_then(|(_, faces, _)| mesh(&faces))
}
/// Rotate's plane ([`crate::plane`]) as a mesh.
pub fn plane_mesh(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result<Mesh, String> {
plane_faces(path, rule, bounds, view, true).and_then(|(_, faces)| mesh(&faces))
plane_faces(path, rule, bounds, view, true).and_then(|(_, faces, _)| mesh(&faces))
}
/// Inflate's solids for several shapes of one object ([`crate::inflate_together`]) as meshes, one
@@ -72,7 +72,7 @@ pub fn inflate_meshes(
options: Inflate,
view: View,
) -> impl Iterator<Item = Result<Mesh, String>> + use<> {
inflate_faces(shapes, bounds, options, view, true).map(|r| r.and_then(|(_, faces)| mesh(&faces)))
inflate_faces(shapes, bounds, options, view, true).map(|r| r.and_then(|(_, faces, _)| mesh(&faces)))
}
/// Revolve's surface ([`crate::revolve`]) as a mesh. As the effect draws it, a sweep of less than
+343
View File
@@ -0,0 +1,343 @@
//! The shadow a 3D effect's solid casts: its faces projected along the first light onto a plane
//! behind the object (square to the view) or below it (under the object's own vertical axis, turned
//! with it), as one hard-edged vector shape.
//!
//! Every face is projected whole, so the shape is the union of the projected faces: each contour is
//! turned so that its face winds the same way, and the shape is filled with the nonzero rule. The
//! plane ends at a square about the foot of the object's center, so a light that grazes the plane
//! does not cast a shadow without end.
use std::ops::Range;
use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect};
use crate::solid::{Face3, extrude_faces, inflate_faces, plane_faces};
use crate::{Extrude, Inflate, MAX_SOLID_FACES, Revolve, V3, View, finite, perspective_scale};
/// Where the shadow falls.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ShadowPosition {
/// On a plane behind the object, square to the view.
#[default]
Behind,
/// On a plane below the object, square to its vertical axis and turned with it.
Below,
}
impl ShadowPosition {
pub const ALL: [ShadowPosition; 2] = [Self::Behind, Self::Below];
pub fn id(self) -> &'static str {
match self {
Self::Behind => "behind",
Self::Below => "below",
}
}
pub fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|p| p.id() == s)
}
}
/// A shadow's settings.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Shadow {
pub position: ShadowPosition,
/// How far the plane is from the object, as a share (0..1) of the object's largest size.
pub distance: f64,
/// How far the plane reaches, as a share (0.1..4): at 1 the plane is a square four times the
/// object's largest size across.
pub bounds: f64,
}
impl Default for Shadow {
fn default() -> Self {
Self { position: ShadowPosition::Behind, distance: 0.0, bounds: 1.0 }
}
}
impl Shadow {
fn sanitized(self) -> Self {
Self { distance: finite(self.distance, 0.0, 0.0, 1.0), bounds: finite(self.bounds, 1.0, 0.1, 4.0), ..self }
}
}
/// The rotation and projection a solid is drawn with.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Projection {
/// The sanitized view: its rotation, perspective and lights.
pub(crate) view: Revolve,
/// Where the view's origin is on the page.
pub(crate) origin: Point,
/// The eye's distance in front of the origin; `None` without perspective.
pub(crate) eye: Option<f64>,
}
impl Projection {
fn project(&self, v: V3) -> Point {
let f = perspective_scale(self.eye, v.2);
Point::new(self.origin.x + v.0 * f, self.origin.y + v.1 * f)
}
/// The unit direction toward the first light, in view space; `None` when it lights nothing:
/// Shading is off or its intensity is 0.
fn light(&self) -> Option<V3> {
let first = self.view.lights.and_then(|l| l.iter().next().copied());
let intensity = first.map_or(self.view.light_intensity, |l| l.intensity);
if !self.view.shade || intensity <= 0.0 {
return None;
}
let [x, y, z] = first.map_or_else(|| self.view.light_direction(), |l| l.direction());
Some(V3(x, y, z).unit())
}
}
/// The light must reach the plane at least this steeply (its cosine to the plane's normal).
const MIN_LIGHT: f64 = 1e-3;
/// Most faces one object's shadow is cast from, its solids together: as many as two of the largest
/// solids.
pub const MAX_SHADOW_FACES: usize = 2 * MAX_SOLID_FACES;
/// The error a solid added past [`MAX_SHADOW_FACES`] returns.
pub const TOO_COMPLEX_SHADOW: &str = "The 3D shadow is too complex; turn Shadows off or simplify the art";
/// The plane a shadow falls on ([`ShadowCaster::shadow_plane`]), placed once for the pieces of one
/// object's shadow ([`ShadowCaster::path_on`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ShadowPlane {
projection: Projection,
/// The unit direction toward the light, and its cosine to the plane's normal.
light: V3,
reach: f64,
/// The plane's unit normal toward the object, and two unit axes along it.
n: V3,
u: V3,
w: V3,
/// Where the plane is along its normal, and the foot of the object's center on it.
level: f64,
foot: V3,
/// Half the side of the square the plane ends at.
half: f64,
}
/// The faces of one object's solids, gathered to cast one shadow. The solids of one object share
/// their bounds and view, so they share a projection: the first solid's is kept. Each solid added
/// returns the range of its faces, so that the shadow of some of them can be drawn on the plane
/// of others ([`ShadowCaster::shadow_plane`], [`ShadowCaster::path_on`]).
#[derive(Clone, Debug, Default)]
pub struct ShadowCaster {
faces: Vec<Face3>,
projection: Option<Projection>,
}
impl ShadowCaster {
pub fn new() -> Self {
Self::default()
}
/// How many faces have been gathered.
pub fn len(&self) -> usize {
self.faces.len()
}
pub fn is_empty(&self) -> bool {
self.faces.is_empty()
}
/// Keep `faces`, or an error when they would take the faces gathered past [`MAX_SHADOW_FACES`].
fn add(&mut self, faces: Vec<Face3>, projection: Option<Projection>) -> Result<Range<usize>, String> {
let start = self.faces.len();
if start.saturating_add(faces.len()) > MAX_SHADOW_FACES {
return Err(TOO_COMPLEX_SHADOW.into());
}
if !faces.is_empty() && self.projection.is_none() {
self.projection = projection;
}
self.faces.extend(faces);
Ok(start..self.faces.len())
}
/// Add Extrude & Bevel's solid ([`crate::extrude`]).
pub fn extrude(&mut self, path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result<Range<usize>, String> {
let (_, faces, projection) = extrude_faces(path, rule, bounds, options, view, true)?;
self.add(faces, projection)
}
/// Add Rotate's plane ([`crate::plane`]).
pub fn plane(&mut self, path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result<Range<usize>, String> {
let (_, faces, projection) = plane_faces(path, rule, bounds, view, true)?;
self.add(faces, projection)
}
/// Add Inflate's solids for several shapes of one object ([`crate::inflate_together`]), each
/// shape whose entry in `casts` is true: the eye is placed for every shape, as for the solids
/// drawn, but the faces of the others are not kept (their range is empty). A shape that does
/// not inflate is left out, as its solid is; the result says which, in order.
pub fn inflate(
&mut self,
shapes: &[(&PathData, FillRule)],
casts: &[bool],
bounds: Rect,
options: Inflate,
view: View,
) -> Vec<Result<Range<usize>, String>> {
inflate_faces(shapes, bounds, options, view, true)
.enumerate()
.map(|(i, r)| {
let (_, faces, projection) = r?;
if casts.get(i).copied().unwrap_or(true) { self.add(faces, projection) } else { Ok(self.faces.len()..self.faces.len()) }
})
.collect()
}
/// Add Revolve's surface ([`crate::revolve`]).
pub fn revolve(&mut self, path: &PathData, rule: FillRule, bounds: Rect, options: Revolve) -> Result<Range<usize>, String> {
let mut faces = vec![];
crate::revolve_faces(path, rule, bounds, options, Some(&mut faces))?;
let o = options.sanitized();
self.add(faces, Some(crate::revolve_projection(bounds, o)))
}
/// The shadow of the faces gathered, on the page, filled with the nonzero rule: `None` when
/// there are none, when the first light lights nothing (Shading is off or its intensity is 0)
/// or does not reach the plane (it is behind the object for a shadow behind it, or below the
/// plane for one below), or when nothing of the shadow falls within the plane.
pub fn path(&self, shadow: Shadow) -> Option<PathData> {
let all = 0..self.faces.len();
let all = std::slice::from_ref(&all);
self.path_on(&self.shadow_plane(shadow, all)?, all)
}
/// The faces in the ranges `faces`, from the faces gathered.
fn within(&self, faces: &[Range<usize>]) -> impl Iterator<Item = &Face3> {
faces.iter().flat_map(|r| self.faces.get(r.clone()).unwrap_or_default())
}
/// The plane `shadow` falls on for the faces in the ranges `faces` (its distance, size and
/// center follow from them), for [`ShadowCaster::path_on`]: `None` when there are none or when
/// the first light lights nothing or does not reach the plane.
pub fn shadow_plane(&self, shadow: Shadow, faces: &[Range<usize>]) -> Option<ShadowPlane> {
let p = self.projection?;
let light = p.light()?;
let s = shadow.sanitized();
let corners = || self.within(faces).flat_map(|(contours, _)| contours.iter().flatten());
let first = *corners().next()?;
let (lo, hi) = corners()
.fold((first, first), |(lo, hi), v| (V3(lo.0.min(v.0), lo.1.min(v.1), lo.2.min(v.2)), V3(hi.0.max(v.0), hi.1.max(v.1), hi.2.max(v.2))));
let size = (hi.0 - lo.0).max(hi.1 - lo.1).max(hi.2 - lo.2).max(1e-9);
// The plane's normal toward the object, and two axes along it.
let (n, u, w) = match s.position {
ShadowPosition::Behind => (V3(0.0, 0.0, 1.0), V3(1.0, 0.0, 0.0), V3(0.0, 1.0, 0.0)),
ShadowPosition::Below => {
let r = |v: V3| p.view.rotate(v).unit();
(r(V3(0.0, -1.0, 0.0)), r(V3(1.0, 0.0, 0.0)), r(V3(0.0, 0.0, 1.0)))
}
};
let reach = n.dot(light);
if !reach.is_finite() || reach < MIN_LIGHT {
return None;
}
// The plane: every corner at least `distance` × size in front of it.
let level = corners().map(|v| n.dot(*v)).fold(f64::INFINITY, f64::min) - s.distance * size;
let centre = V3((lo.0 + hi.0) / 2.0, (lo.1 + hi.1) / 2.0, (lo.2 + hi.2) / 2.0);
let foot = add(centre, scale(n, level - n.dot(centre)));
Some(ShadowPlane { projection: p, light, reach, n, u, w, level, foot, half: 2.0 * s.bounds * size })
}
/// The shadow the faces in the ranges `faces` cast on `plane`, on the page, filled with the
/// nonzero rule; `None` when nothing of it falls within the plane.
pub fn path_on(&self, plane: &ShadowPlane, faces: &[Range<usize>]) -> Option<PathData> {
let &ShadowPlane { projection: p, light, reach, n, u, w, level, foot, half } = plane;
// A corner's shadow, in the plane's axes about the foot.
let cast = |v: V3| {
let q = add(v, scale(light, -(n.dot(v) - level) / reach));
let d = q.sub(foot);
Point::new(u.dot(d), w.dot(d))
};
let to_page = |q: Point| p.project(add(foot, add(scale(u, q.x), scale(w, q.y))));
let mut bp = BezPath::new();
for (contours, _) in self.within(faces) {
let flat: Vec<Vec<Point>> = contours.iter().map(|c| c.iter().map(|v| cast(*v)).collect()).collect();
let turn: f64 = flat.iter().map(|c| signed_area(c)).sum();
// A face edge-on to the light casts nothing.
if flat_area(turn, flat.iter().flatten()) {
continue;
}
for c in &flat {
let mut clipped = clip(c, half);
if turn < 0.0 {
clipped.reverse();
}
// A plane seen edge-on draws nothing.
let page: Vec<Point> = clipped.into_iter().map(to_page).collect();
if flat_area(signed_area(&page), &page) {
continue;
}
let Some((start, rest)) = page.split_first() else { continue };
bp.move_to(*start);
for q in rest {
bp.line_to(*q);
}
bp.close_path();
}
}
let path = PathData::from_bezpath(&bp);
(!path.subpaths.is_empty()).then_some(path)
}
}
/// Whether `area`, the signed area of a polygon with corners `points`, is within rounding of
/// nothing for its size (the square of its bounds' diagonal), or not a number, so the polygon draws
/// nothing. A long, narrow polygon keeps an area well above that.
fn flat_area<'a>(area: f64, points: impl IntoIterator<Item = &'a Point>) -> bool {
let mut points = points.into_iter();
let Some(first) = points.next() else { return true };
let bounds = points.fold(Rect::from_points(*first, *first), |b, p| b.union_pt(*p));
let size = bounds.width().powi(2) + bounds.height().powi(2);
!area.is_finite() || !size.is_finite() || area.abs() <= 1e-14 * size
}
fn add(a: V3, b: V3) -> V3 {
V3(a.0 + b.0, a.1 + b.1, a.2 + b.2)
}
fn scale(a: V3, k: f64) -> V3 {
V3(a.0 * k, a.1 * k, a.2 * k)
}
/// Twice the signed area of polygon `c` (positive counterclockwise with y up).
fn signed_area(c: &[Point]) -> f64 {
c.iter().zip(c.iter().cycle().skip(1)).take(c.len()).map(|(a, b)| a.x * b.y - b.x * a.y).sum::<f64>() / 2.0
}
/// Polygon `c` cut to the square from -`half` to `half` on both axes, edge by edge (the square is
/// convex, so each contour's winding inside it is kept).
fn clip(c: &[Point], half: f64) -> Vec<Point> {
let mut out: Vec<Point> = c.to_vec();
// Each side: the axis it cuts across (x or y) and whether it is the upper one.
for (across_x, upper) in [(true, false), (true, true), (false, false), (false, true)] {
let coord = |p: Point| if across_x { p.x } else { p.y };
let limit = if upper { half } else { -half };
let inside = |p: Point| if upper { coord(p) <= limit } else { coord(p) >= limit };
let input = std::mem::take(&mut out);
let Some(&last) = input.last() else { break };
let mut prev = last;
for &cur in &input {
let (a, b) = (inside(prev), inside(cur));
if a != b {
let (pc, cc) = (coord(prev), coord(cur));
let t = (limit - pc) / (cc - pc);
out.push(prev.lerp(cur, t));
}
if b {
out.push(cur);
}
prev = cur;
}
}
out
}
#[cfg(test)]
mod tests;
+187
View File
@@ -0,0 +1,187 @@
use vectorcraft_geom::{BezPath, FillRule, PathData, Point, Rect, Shape};
use super::*;
use crate::{Extrude, Inflate, Light, Lights, Revolve, View};
fn square(x: f64, y: f64, s: f64) -> PathData {
let mut b = BezPath::new();
b.move_to((x, y));
b.line_to((x + s, y));
b.line_to((x + s, y + s));
b.line_to((x, y + s));
b.close_path();
PathData::from_bezpath(&b)
}
/// The view straight on: no rotation, the default light.
fn front() -> View {
View { rotation_x: 0.0, rotation_y: 0.0, rotation_z: 0.0, ..View::default() }
}
fn extruded(view: View, shadow: Shadow) -> Option<PathData> {
let path = square(100.0, 100.0, 100.0);
let mut caster = ShadowCaster::new();
caster.extrude(&path, FillRule::NonZero, Rect::new(100.0, 100.0, 200.0, 200.0), Extrude::default(), view).unwrap();
caster.path(shadow)
}
/// The area the shadow covers, sampled on a grid over `r` with the nonzero rule.
fn covered(path: &PathData, r: Rect, steps: usize) -> f64 {
let bp = path.to_bezpath();
let (dx, dy) = (r.width() / steps as f64, r.height() / steps as f64);
let mut hits = 0;
for i in 0..steps {
for j in 0..steps {
let p = Point::new(r.x0 + (i as f64 + 0.5) * dx, r.y0 + (j as f64 + 0.5) * dy);
if bp.winding(p) != 0 {
hits += 1;
}
}
}
hits as f64 * dx * dy
}
#[test]
fn a_light_straight_in_front_casts_the_silhouette_behind_the_object() {
let view = View { light_height: 90.0, ..front() };
let path = extruded(view, Shadow::default()).expect("a shadow");
let b = path.bounds().unwrap();
for (got, want) in [(b.x0, 100.0), (b.y0, 100.0), (b.x1, 200.0), (b.y1, 200.0)] {
assert!((got - want).abs() < 1e-6, "{b:?}");
}
// Every face of the box projects into the square, and the nonzero union fills it whole.
let area = covered(&path, Rect::new(90.0, 90.0, 210.0, 210.0), 120);
assert!((area - 10_000.0).abs() < 300.0, "{area}");
}
#[test]
fn the_shadow_behind_falls_away_from_the_light_and_moves_with_the_distance() {
// The default light is up and to the left, in front: the shadow falls down and to the right.
let near = extruded(front(), Shadow::default()).unwrap().bounds().unwrap();
assert!(near.x1 > 200.0 + 1.0 && near.y1 > 200.0 + 1.0, "{near:?}");
assert!(near.x0 >= 100.0 - 1e-6 && near.y0 >= 100.0 - 1e-6, "{near:?}");
let far = extruded(front(), Shadow { distance: 1.0, ..Shadow::default() }).unwrap().bounds().unwrap();
assert!(far.x1 > near.x1 + 10.0 && far.y1 > near.y1 + 10.0, "{near:?} {far:?}");
}
#[test]
fn a_light_behind_the_object_casts_no_shadow_behind_it() {
assert!(extruded(View { light_behind: true, ..front() }, Shadow::default()).is_none());
let behind = Light { behind: true, ..Light::default() };
let lights = Lights::new(&[behind, Light::default()]).unwrap();
// The first light casts the shadow.
assert!(extruded(View { lights: Some(lights), ..front() }, Shadow::default()).is_none());
let lights = Lights::new(&[Light::default(), behind]).unwrap();
assert!(extruded(View { lights: Some(lights), ..front() }, Shadow::default()).is_some());
}
#[test]
fn a_light_that_lights_nothing_casts_no_shadow() {
assert!(extruded(View { light_intensity: 0.0, ..front() }, Shadow::default()).is_none());
assert!(extruded(View { shade: false, ..front() }, Shadow::default()).is_none());
let dark = Light { intensity: 0.0, ..Light::default() };
let lights = Lights::new(&[dark, Light::default()]).unwrap();
assert!(extruded(View { lights: Some(lights), ..front() }, Shadow::default()).is_none());
}
#[test]
fn the_shadow_below_lies_under_the_object_on_its_floor() {
let below = Shadow { position: ShadowPosition::Below, ..Shadow::default() };
// Seen level, the floor is edge-on: nothing to draw.
assert!(extruded(front(), below).is_none());
// Seen from above (the effects' default Off-Axis Front view), the floor shows.
let off_axis = View { rotation_x: -21.88, rotation_y: -23.4, rotation_z: 9.06, ..View::default() };
assert!(extruded(off_axis, below).is_some());
// Seen from straight above, the floor is square to the view, under the object: with the light
// straight in front too, the shadow is the object's outline, as the one behind it is.
let top = View { rotation_x: -90.0, light_height: 90.0, ..front() };
let under = extruded(top, below).unwrap().bounds().unwrap();
let back = extruded(top, Shadow::default()).unwrap().bounds().unwrap();
for (a, b) in [(under.x0, back.x0), (under.y0, back.y0), (under.x1, back.x1), (under.y1, back.y1)] {
assert!((a - b).abs() < 1e-6, "{under:?} {back:?}");
}
// A light from below the floor casts nothing on it.
let from_below = View { light_rotation: -90.0, light_height: 0.0, ..off_axis };
assert!(extruded(from_below, below).is_none());
}
#[test]
fn shadow_bounds_limit_the_plane_the_shadow_falls_on() {
// A low light casts a long shadow; the plane cuts it.
let low = View { light_height: 5.0, ..front() };
let small = extruded(low, Shadow { bounds: 0.1, ..Shadow::default() }).unwrap().bounds().unwrap();
let large = extruded(low, Shadow { bounds: 4.0, ..Shadow::default() }).unwrap().bounds().unwrap();
// At 10 % the square reaches 0.2 × the size (100) from the foot of the center (150, 150).
for v in [small.x0, small.y0, small.x1, small.y1] {
assert!((130.0 - 1e-6..=170.0 + 1e-6).contains(&v), "{small:?}");
}
assert!(large.width() > small.width() * 5.0, "{small:?} {large:?}");
for v in [large.x0, large.y0, large.x1, large.y1] {
assert!((-650.0 - 1e-6..=950.0 + 1e-6).contains(&v), "{large:?}");
}
}
#[test]
fn bad_settings_are_sanitized_and_an_empty_caster_casts_nothing() {
assert!(ShadowCaster::new().path(Shadow::default()).is_none());
let wild = Shadow { distance: f64::NAN, bounds: f64::INFINITY, ..Shadow::default() };
assert_eq!(wild.sanitized(), Shadow { bounds: 1.0, ..Shadow::default() });
assert_eq!(Shadow { distance: -3.0, bounds: 99.0, ..Shadow::default() }.sanitized(), Shadow { bounds: 4.0, ..Shadow::default() });
assert_eq!(ShadowPosition::parse("below"), Some(ShadowPosition::Below));
assert_eq!(ShadowPosition::parse("under"), None);
}
#[test]
fn the_faces_one_shadow_is_cast_from_are_bounded() {
// An open profile of 500 points revolves into about 64,000 faces at 128 segments: five fit,
// the sixth does not.
let mut b = BezPath::new();
b.move_to((110.0, 0.0));
for i in 1..500 {
b.line_to((110.0 + f64::from(i % 2) * 5.0, f64::from(i)));
}
let path = PathData::from_bezpath(&b);
let bounds = path.bounds().unwrap();
let options = Revolve { segments: 128, offset: 10.0, ..Revolve::default() };
let mut caster = ShadowCaster::new();
for _ in 0..5 {
let faces = caster.revolve(&path, FillRule::NonZero, bounds, options).unwrap();
assert!(faces.len() > 60_000, "{faces:?}");
}
let err = caster.revolve(&path, FillRule::NonZero, bounds, options).unwrap_err();
assert!(err.contains("too complex"), "{err}");
assert!(caster.len() <= MAX_SHADOW_FACES);
assert!(caster.path(Shadow::default()).is_some());
}
#[test]
fn a_small_solid_beside_a_huge_one_keeps_its_shadow() {
let bounds = Rect::new(0.0, 0.0, 1_000_010.0, 10.0);
let view = View { light_height: 90.0, ..front() };
let mut caster = ShadowCaster::new();
let small = caster.extrude(&square(0.0, 0.0, 10.0), FillRule::NonZero, bounds, Extrude::default(), view).unwrap();
let huge = caster.extrude(&square(1_000_000.0, 0.0, 10.0), FillRule::NonZero, bounds, Extrude::default(), view).unwrap();
let all = [small.clone(), huge];
let plane = caster.shadow_plane(Shadow { bounds: 4.0, ..Shadow::default() }, &all).unwrap();
let path = caster.path_on(&plane, std::slice::from_ref(&small)).expect("the small solid's shadow");
let b = path.bounds().unwrap();
assert!((b.width() - 10.0).abs() < 1e-3 && (b.height() - 10.0).abs() < 1e-3, "{b:?}");
}
#[test]
fn every_effect_casts_a_shadow() {
let path = square(100.0, 100.0, 100.0);
let bounds = Rect::new(100.0, 100.0, 200.0, 200.0);
let mut plane = ShadowCaster::new();
plane.plane(&path, FillRule::NonZero, bounds, View::default()).unwrap();
assert!(plane.path(Shadow::default()).is_some());
let mut inflate = ShadowCaster::new();
let results = inflate.inflate(&[(&path, FillRule::NonZero)], &[true], bounds, Inflate::default(), View::default());
assert!(results.iter().all(Result::is_ok));
assert!(inflate.path(Shadow::default()).is_some());
let mut revolve = ShadowCaster::new();
revolve.revolve(&path, FillRule::NonZero, bounds, Revolve::default()).unwrap();
let b = revolve.path(Shadow::default()).unwrap().bounds().unwrap();
// The surface turns about the square's left edge, reaching 100 to either side of it.
assert!(b.x1 > 100.0 && b.x0 < 100.0 + 1e-6, "{b:?}");
}
+11 -5
View File
@@ -9,6 +9,7 @@ use super::{
Bevel, Extrude, Facet, FillRule, MAX_FACES, MAX_SOLID_FACES, ParamCurve, PathData, Point, Rect, Revolve, V3, View, eye_distance,
perspective_scale,
};
use crate::shadow::Projection;
/// Turns 3D points into document points and shades faces for one view.
pub(super) struct Camera {
@@ -48,14 +49,19 @@ impl Camera {
pub(super) fn brightness(&self, n: V3) -> f64 {
crate::brightness(&self.view, n, self.light)
}
/// The rotation and projection this camera draws with, for a shadow cast from its faces.
pub(super) fn projection(&self) -> Projection {
Projection { view: self.view, origin: self.origin, eye: self.eye }
}
}
/// A face of a solid before projection: its contours in view space (holes running the other way)
/// and its outward normal.
pub(crate) type Face3 = (Vec<Vec<V3>>, V3);
/// What a solid's builder makes: its facets, or with a mesh asked for, its faces before projection.
pub(crate) type Built = (Vec<Facet>, Vec<Face3>);
/// What a solid's builder makes: its facets, or with a mesh asked for, its faces before projection,
/// and the projection it used (`None` when nothing was built).
pub(crate) type Built = (Vec<Facet>, Vec<Face3>, Option<Projection>);
/// Facets being collected for one solid.
pub(super) struct Builder<'a> {
@@ -250,7 +256,7 @@ impl Builder<'_> {
/// The facets back to front, and the faces kept for a mesh (none without [`Builder::mesh`]).
pub(super) fn finish(mut self) -> Built {
self.facets.sort_by(|a, b| a.depth.total_cmp(&b.depth));
(self.facets, self.mesh.unwrap_or_default())
(self.facets, self.mesh.unwrap_or_default(), Some(self.camera.projection()))
}
}
@@ -492,7 +498,7 @@ fn steps(o: &Extrude) -> Vec<Step> {
/// Extrude & Bevel: the shape's outline extruded backwards, with optional twist, taper, bevels
/// and caps.
pub fn extrude(path: &PathData, rule: FillRule, bounds: Rect, options: Extrude, view: View) -> Result<Vec<Facet>, String> {
extrude_faces(path, rule, bounds, options, view, false).map(|(facets, _)| facets)
extrude_faces(path, rule, bounds, options, view, false).map(|(facets, ..)| facets)
}
/// [`extrude`], or with `mesh` the solid's faces before projection instead of its facets.
@@ -602,7 +608,7 @@ pub(crate) fn extrude_faces(path: &PathData, rule: FillRule, bounds: Rect, optio
/// Rotate: the flat art turned in 3D about the centre of `bounds` (which may be larger than the
/// shape's own, so that every part of one object turns about the same point).
pub fn plane(path: &PathData, rule: FillRule, bounds: Rect, view: View) -> Result<Vec<Facet>, String> {
plane_faces(path, rule, bounds, view, false).map(|(facets, _)| facets)
plane_faces(path, rule, bounds, view, false).map(|(facets, ..)| facets)
}
/// [`plane`], or with `mesh` its face before projection instead of its facet.
+2 -2
View File
@@ -62,7 +62,7 @@ pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate,
/// on its own over the grid of `bounds`, and one eye sees them all, as far away as the highest
/// puff needs with Perspective. One result for each shape, in order.
pub fn inflate_together(shapes: &[(&PathData, FillRule)], bounds: Rect, options: Inflate, view: View) -> Vec<Result<Vec<Facet>, String>> {
inflate_faces(shapes, bounds, options, view, false).map(|r| r.map(|(facets, _)| facets)).collect()
inflate_faces(shapes, bounds, options, view, false).map(|r| r.map(|(facets, ..)| facets)).collect()
}
/// [`inflate_together`], or with `mesh` each solid's faces before projection instead of its facets.
@@ -81,7 +81,7 @@ pub(crate) fn inflate_faces(
let reach = puffs.iter().flatten().flatten().map(|p| p.height).fold(0.0, f64::max);
puffs.into_iter().map(move |p| match p {
Ok(Some(p)) => inflated(p, bounds, o, view, reach, mesh),
Ok(None) => Ok((vec![], vec![])),
Ok(None) => Ok((vec![], vec![], None)),
Err(e) => Err(e),
})
}
+6
View File
@@ -391,6 +391,12 @@ pub(super) fn own_choices(effect: &str, key: &str, cmyk: bool) -> Option<Vec<(&'
let c3 = |s: &'static str| crate::i18n::tr_ctx(crate::i18n::current(), "3d", s);
Some(match (effect, key) {
(vectorcraft_effects::REVOLVE, "edge") => vec![(tl!("Left"), "left"), (tl!("Right"), "right")],
(
vectorcraft_effects::REVOLVE | vectorcraft_effects::EXTRUDE | vectorcraft_effects::INFLATE | vectorcraft_effects::ROTATE,
"shadowPosition",
) => {
vec![(tl!("Behind Object"), "behind"), (tl!("Below Object"), "below")]
}
(vectorcraft_effects::CLASSIC_EXTRUDE, "surface") => {
vec![(tl!("Wireframe"), "wireframe"), (tl!("No Shading"), "none"), (tl!("Diffuse"), "diffuse"), (tl!("Plastic"), "plastic")]
}
+21
View File
@@ -333,6 +333,27 @@ fn lighting_controls(ui: &mut egui::Ui, d: &mut Dialog) {
store_selected(d, &selected);
}
});
shadow_controls(ui, d);
}
/// The Shadows switch, and below it while the switch is on or mixed, the shadow's Position,
/// Distance from Object and Shadow Bounds. The first light casts the shadow, so they are
/// unavailable while Shading is off.
fn shadow_controls(ui: &mut egui::Ui, d: &mut Dialog) {
ui.add_enabled_ui(flag(d, "shade", true) != Some(false), |ui| {
toggle(ui, d, "shadow", tl!("Shadows"), false);
if flag(d, "shadow", false) == Some(false) {
return;
}
let positions: Vec<(Value, &str)> = super::effect::own_choices(&d.str("__effect"), "shadowPosition", false)
.unwrap_or_default()
.into_iter()
.map(|(label, value)| (json!(value), label))
.collect();
choice(ui, d, "shadowPosition", tl!("Position"), &positions);
number(ui, d, "shadowDistance", tl!("Distance from Object"), (0.0, 100.0), "%", 1, None);
number(ui, d, "shadowBounds", tl!("Shadow Bounds"), (10.0, 400.0), "%", 1, None);
});
}
fn lighting_presets(ui: &mut egui::Ui, d: &mut Dialog) {
+21 -1
View File
@@ -148,6 +148,24 @@ fn command_driven_fields_preview_and_small_windows_keep_the_buttons_reachable()
assert!(!h.app.session.in_interaction());
}
#[test]
fn the_shadows_switch_shows_its_settings_and_turns_the_shadow_on_in_one_undo_step() {
let mut h = Harness::with("M100 100 L200 100 L200 200 L100 200 Z", EXTRUDE, vec2(1280.0, 900.0));
let before = h.app.session.doc().unwrap().doc.clone();
h.settle();
h.click(h.rect("Lighting").center());
assert!(!h.texts.iter().any(|(s, _)| s == "Shadow Bounds"), "the settings show only while shadows are on");
h.click(h.rect("Shadows").center());
assert_eq!(h.app.ui.dialog.as_ref().unwrap().fields.get("shadow"), Some(&json!(true)));
assert!(h.texts.iter().any(|(s, _)| s == "Distance from Object"));
let state = h.app.session.doc().unwrap();
let node = state.doc.node(state.selection.objects[0]).unwrap();
assert_eq!(node.appearance.effects[0].params["shadow"], json!(true), "the preview draws the shadow");
dialogs::confirm(&mut h.app).unwrap();
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(h.app.session.doc().unwrap().doc, before);
}
#[test]
fn lighting_tab_shows_light_fields_and_keeps_the_dialog_compact() {
let mut h = Harness::new(vec2(1280.0, 900.0));
@@ -158,7 +176,9 @@ fn lighting_tab_shows_light_fields_and_keeps_the_dialog_compact() {
assert!(Rect::from_min_size(Pos2::ZERO, h.screen).contains_rect(h.rect(label)));
}
let panel = h.ctx.memory(|m| m.area_rect(Id::unique(("dialog", "effect")).with("revolve"))).unwrap();
assert!(panel.width() < 420.0 && panel.height() < 570.0, "compact lighting controls with light management: {panel:?}");
// The Shadows switch adds one 28-point row to the 570-point bound; its settings show only
// while it is on.
assert!(panel.width() < 420.0 && panel.height() < 600.0, "compact lighting controls with light management: {panel:?}");
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());
+5
View File
@@ -5512,6 +5512,11 @@ theme Appearance Външен вид
Softness Мекота
Ambient Light Околна светлина
Light behind the object Светлина зад обекта
Shadows Сенки
Distance from Object Разстояние от обекта
Shadow Bounds Граници на сянката
Behind Object Зад обекта
Below Object Под обекта
3D Lighting Preset Предварителна настройка за 3D осветление
Standard Стандартно
Diffuse Дифузно
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5596,6 +5596,11 @@ mark-style Japanese Japanski
Softness Mekoća
Ambient Light Ambijentalno svjetlo
Light behind the object Svjetlo iza objekta
Shadows Sjene
Distance from Object Udaljenost od objekta
Shadow Bounds Granice sjene
Behind Object Iza objekta
Below Object Ispod objekta
3D Lighting Preset Predložak 3D osvjetljenja
Standard Standardno
Diffuse Difuzno
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -2266,6 +2266,11 @@ digits Malayalam Malaiàlam
Ligatures are off while the text is tracked. Les lligadures estan desactivades mentre el text té espaiat.
Light Clar
Light behind the object Llum darrere de l’objecte
Shadows Ombres
Distance from Object Distància de l’objecte
Shadow Bounds Límits de l’ombra
Behind Object Darrere de l’objecte
Below Object Sota l’objecte
Light Blue Blau clar
Light Red Vermell clar
Light Direction Direcció de la llum
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5727,6 +5727,11 @@ mark-style Japanese Japonské
Softness Měkkost
Ambient Light Okolní světlo
Light behind the object Světlo za objektem
Shadows Stíny
Distance from Object Vzdálenost od objektu
Shadow Bounds Hranice stínu
Behind Object Za objektem
Below Object Pod objektem
3D Lighting Preset Předvolba 3D osvětlení
Standard Standardní
Diffuse Rozptýlené
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5248,6 +5248,11 @@ mark-style Japanese Japansk
Softness Blødhed
Ambient Light Omgivelseslys
Light behind the object Lys bag objektet
Shadows Skygger
Distance from Object Afstand fra objektet
Shadow Bounds Skyggegrænser
Behind Object Bag objektet
Below Object Under objektet
3D Lighting Preset Forudindstilling for 3D-belysning
Standard Standard
Diffuse Diffust
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5302,6 +5302,11 @@ effect Soft Weich
Softness Weichheit
Ambient Light Umgebungslicht
Light behind the object Licht hinter dem Objekt
Shadows Schatten
Distance from Object Abstand vom Objekt
Shadow Bounds Schattenbegrenzung
Behind Object Hinter dem Objekt
Below Object Unter dem Objekt
3D Lighting Preset 3D-Beleuchtungsvorgabe
Standard Standard
Diffuse Diffus
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5406,6 +5406,11 @@ digits Malayalam Μαλαγιάλαμ
to set spacing and orientation για ορισμό απόστασης και προσανατολισμού
: :
Light behind the object Φως πίσω από το αντικείμενο
Shadows Σκιές
Distance from Object Απόσταση από το αντικείμενο
Shadow Bounds Όρια σκιάς
Behind Object Πίσω από το αντικείμενο
Below Object Κάτω από το αντικείμενο
3D Lighting Preset Προεπιλογή φωτισμού 3D
Standard Τυπικός
Diffuse Διάχυτος
Can't render this file because it is too large.
+5
View File
@@ -5668,6 +5668,11 @@ mark-style Japanese Japonesas
Softness Suavidad
Ambient Light Luz ambiental
Light behind the object Luz detrás del objeto
Shadows Sombras
Distance from Object Distancia del objeto
Shadow Bounds Límites de la sombra
Behind Object Detrás del objeto
Below Object Debajo del objeto
3D Lighting Preset Ajuste preestablecido de iluminación 3D
Standard Estándar
Diffuse Difusa
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5609,6 +5609,11 @@ mark-style Japanese Japanilainen
Softness Pehmeys
Ambient Light Ympäristövalo
Light behind the object Valo objektin takana
Shadows Varjot
Distance from Object Etäisyys objektista
Shadow Bounds Varjon rajat
Behind Object Objektin takana
Below Object Objektin alla
3D Lighting Preset 3D-valaistuksen esiasetus
Standard Vakio
Diffuse Hajavalo
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5723,6 +5723,11 @@ theme Appearance Apparence
Softness Douceur
Ambient Light Lumière ambiante
Light behind the object Lumière derrière l'objet
Shadows Ombres
Distance from Object Distance de l'objet
Shadow Bounds Limites de l'ombre
Behind Object Derrière l'objet
Below Object Sous l'objet
3D Lighting Preset Préréglage d’éclairage 3D
Standard Standard
Diffuse Diffus
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -2485,6 +2485,11 @@ antiAlias Sharp शार्प
Ligatures are off while the text is tracked. टेक्स्ट पर ट्रैकिंग लागू होने पर लिगेचर ऑफ़ रहते हैं।
Light लाइट
Light behind the object ऑब्जेक्ट के पीछे प्रकाश
Shadows छायाएँ
Distance from Object ऑब्जेक्ट से दूरी
Shadow Bounds छाया सीमाएँ
Behind Object ऑब्जेक्ट के पीछे
Below Object ऑब्जेक्ट के नीचे
Light Blue हल्का नीला
Light Direction लाइट की दिशा
Light Gray हल्का ग्रे
Can't render this file because it is too large.
+5
View File
@@ -5710,6 +5710,11 @@ imageTrace Outline Körvonal
Softness Lágyság
Ambient Light Környezeti fény
Light behind the object Fény az objektum mögött
Shadows Árnyékok
Distance from Object Távolság az objektumtól
Shadow Bounds Árnyék határai
Behind Object Az objektum mögött
Below Object Az objektum alatt
3D Lighting Preset 3D világítási készlet
Standard Normál
Diffuse Szórt
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5248,6 +5248,11 @@ mark-style Japanese Jepang
Softness Kelembutan
Ambient Light Cahaya ambient
Light behind the object Cahaya di belakang objek
Shadows Bayangan
Distance from Object Jarak dari objek
Shadow Bounds Batas bayangan
Behind Object Di belakang objek
Below Object Di bawah objek
3D Lighting Preset Preset Pencahayaan 3D
Standard Standar
Diffuse Difus
Can't render this file because it contains an unexpected character in line 2338 and column 5.
+5
View File
@@ -5337,6 +5337,11 @@ lighting Light Luce
Softness Morbidezza
Ambient Light Luce ambientale
Light behind the object Luce dietro l'oggetto
Shadows Ombre
Distance from Object Distanza dall'oggetto
Shadow Bounds Limiti dell'ombra
Behind Object Dietro l'oggetto
Below Object Sotto l'oggetto
3D Lighting Preset Predefinito illuminazione 3D
Standard Standard
Diffuse Diffusa
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5379,6 +5379,11 @@ lighting Light ライト
Softness 柔らかさ
Ambient Light 環境光
Light behind the object オブジェクトの背後のライト
Shadows シャドウ
Distance from Object オブジェクトからの距離
Shadow Bounds シャドウの範囲
Behind Object オブジェクトの背後
Below Object オブジェクトの下
3D Lighting Preset 3D ライティングプリセット
Standard 標準
Diffuse 拡散
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5289,6 +5289,11 @@ imageTrace Outline კონტური
In the File ფაილში
Keys კლავიშები
Light behind the object სინათლე ობიექტის უკან
Shadows ჩრდილები
Distance from Object მანძილი ობიექტიდან
Shadow Bounds ჩრდილის საზღვრები
Behind Object ობიექტის უკან
Below Object ობიექტის ქვეშ
3D Lighting Preset 3D განათების წინასწარი პარამეტრი
Standard სტანდარტული
Diffuse დიფუზური
Can't render this file because it is too large.
+5
View File
@@ -5298,6 +5298,11 @@ lighting Light 광원
Softness 부드러움
Ambient Light 주변광
Light behind the object 개체 뒤의 조명
Shadows 그림자
Distance from Object 개체로부터의 거리
Shadow Bounds 그림자 범위
Behind Object 개체 뒤
Below Object 개체 아래
3D Lighting Preset 3D 조명 사전 설정
Standard 표준
Diffuse 확산
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5260,6 +5260,11 @@ mark-style Japanese Japans
Softness Zachtheid
Ambient Light Omgevingslicht
Light behind the object Licht achter het object
Shadows Schaduwen
Distance from Object Afstand tot object
Shadow Bounds Schaduwgrenzen
Behind Object Achter object
Below Object Onder object
3D Lighting Preset Voorinstelling 3D-belichting
Standard Standaard
Diffuse Diffuus
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5566,6 +5566,11 @@ mark-style Japanese Japońskie
Softness Miękkość
Ambient Light Światło otoczenia
Light behind the object Światło za obiektem
Shadows Cienie
Distance from Object Odległość od obiektu
Shadow Bounds Granice cienia
Behind Object Za obiektem
Below Object Pod obiektem
3D Lighting Preset Ustawienie oświetlenia 3D
Standard Standardowe
Diffuse Rozproszone
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -4586,6 +4586,11 @@ axis Both Ambos
Softness Suavidade
Ambient Light Luz ambiente
Light behind the object Luz atrás do objeto
Shadows Sombras
Distance from Object Distância do objeto
Shadow Bounds Limites da sombra
Behind Object Atrás do objeto
Below Object Abaixo do objeto
3D Lighting Preset Predefinição de iluminação 3D
Standard Padrão
Diffuse Difusa
Can't render this file because it contains an unexpected character in line 6 and column 47.
+5
View File
@@ -5331,6 +5331,11 @@ lighting Light Свет
Softness Мягкость
Ambient Light Окружающий свет
Light behind the object Свет позади объекта
Shadows Тени
Distance from Object Расстояние от объекта
Shadow Bounds Границы тени
Behind Object Позади объекта
Below Object Под объектом
3D Lighting Preset Предустановка 3D освещения
Standard Стандартное
Diffuse Рассеянное
Can't render this file because it is too large.
+5
View File
@@ -5625,6 +5625,11 @@ theme Appearance Utseende
Softness Mjukhet
Ambient Light Omgivande ljus
Light behind the object Ljus bakom objektet
Shadows Skuggor
Distance from Object Avstånd från objektet
Shadow Bounds Skuggans gränser
Behind Object Bakom objektet
Below Object Under objektet
3D Lighting Preset Förinställning för 3D-belysning
Standard Standard
Diffuse Diffust
Can't render this file because it contains an unexpected character in line 5 and column 25.
+5
View File
@@ -5415,6 +5415,11 @@ theme Appearance Görünüm
Softness Yumuşaklık
Ambient Light Ortam Işığı
Light behind the object Nesnenin arkasındaki ışık
Shadows Gölgeler
Distance from Object Nesneden uzaklık
Shadow Bounds Gölge sınırları
Behind Object Nesnenin arkasında
Below Object Nesnenin altında
Registration: Kayıt:
Dimension Boyut
Drawing : real size — 1:10 labels 1 unit on the page as 10. Çizim : Gerçek boyut — 1:10 etiketler sayfada 10 adet olarak 1 adettir.
Can't render this file because it contains an unexpected character in line 5 and column 25.
+5
View File
@@ -5317,6 +5317,11 @@ lighting Light Світло
Softness М'якість
Ambient Light Навколишнє світло
Light behind the object Світло позаду об'єкта
Shadows Тіні
Distance from Object Відстань від об'єкта
Shadow Bounds Межі тіні
Behind Object Позаду об'єкта
Below Object Під об'єктом
3D Lighting Preset Попереднє налаштування 3D освітлення
Standard Стандартне
Diffuse Розсіяне
Can't render this file because it contains an unexpected character in line 182 and column 5.
+5
View File
@@ -5320,6 +5320,11 @@ theme Appearance Giao diện
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
Shadows Bóng đổ
Distance from Object Khoảng cách từ đối tượng
Shadow Bounds Giới hạn bóng
Behind Object Phía sau đối tượng
Below Object Bên dưới đối tượng
3D Lighting Preset Thiết lập sẵn ánh sáng 3D
Standard Tiêu chuẩn
Diffuse Khuếch tán
Can't render this file because it contains an unexpected character in line 204 and column 5.
+5
View File
@@ -5599,6 +5599,11 @@ lighting Light 光源
Softness 柔和度
Ambient Light 环境光
Light behind the object 光源位于对象后方
Shadows 阴影
Distance from Object 与对象的距离
Shadow Bounds 阴影边界
Behind Object 对象后方
Below Object 对象下方
3D Lighting Preset 3D 光照预设
Standard 标准
Diffuse 漫射
Can't render this file because it contains an unexpected character in line 2 and column 73.
+5
View File
@@ -5420,6 +5420,11 @@ lighting Light 光源
Softness 柔和度
Ambient Light 環境光
Light behind the object 光源位於物件後方
Shadows 陰影
Distance from Object 與物件的距離
Shadow Bounds 陰影邊界
Behind Object 物件後方
Below Object 物件下方
3D Lighting Preset 3D 光照預設
Standard 標準
Diffuse 漫射
Can't render this file because it contains an unexpected character in line 6 and column 47.
+1 -1
View File
@@ -180,7 +180,7 @@ VectorCraft choices; an original approximation, never compared with another appl
|---|---|
| Revolve | initial: live, editable profile, rotation and light gizmos, default-on Cap with continuous curved rims, smooth vector shading and round seam joins, SVG/PDF output, Expand Appearance ([revolve.md](revolve.md), #846) |
| Extrude & Bevel, Inflate, Rotate | initial: live, depth, twist, taper, solid or hollow caps, five original bevel shapes, Inflate volume, both sides, twist, taper and cap, Rotate of every fill and stroke, groups and layers turned about one shared axis (#4506), contours that cross, touch or overlap merged first ([three-d.md](three-d.md)), Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) |
| Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; no materials, shadows or ray tracing |
| Materials panel, lighting, ray-traced render | Window › 3D and Materials panel with the object types (Plane, Extrude, Revolve, Inflate) and the Object, Rotation and Lighting settings of the applied effect ([three-d.md](three-d.md#3d-and-materials-panel)); one to ten directional lights with per-light Color (opaque RGB, white by default), Intensity, Rotation, Height, Softness and neutral Ambient Light, Revolve and colored Inflate use smooth vector shading; Extrude & Bevel and Rotate retain planar shading; original Standard, Diffuse, Top Left and Right lighting rigs; hard-edged shadows behind or below the object from the first light ([three-d.md](three-d.md#shadows)); no materials, soft shadows or ray tracing |
| 3D (Classic): Extrude & Bevel, Revolve, Rotate | Extrude & Bevel (Classic) initial: live, Extrude Depth, Cap, five original bevel shapes with Height and Bevel Extent In or Out, Position presets and rotation gizmo, Perspective, Wireframe, No Shading, Diffuse and Plastic surfaces with one to ten lights, Light Intensity, Ambient Light, Highlight Intensity and Size, Blend Steps, Black, Custom or no Shading Color, Draw Hidden Faces ([create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/create-3d-objects.html)); Revolve (Classic), Rotate (Classic) and Map Art: none |
| Turntable (30.0 beta) | none |
| Export 3D objects (OBJ, USDA, glTF) | initial: Asset Export's Export 3D Object collects an object with Extrude & Bevel, Revolve, Inflate or Rotate as a 3D asset, and Export writes its solids as a USDA (the default), USDZ, glTF or OBJ mesh with a material in each solid's paint color (`assets.add3D`, `assets.set3DFormat`, `assets.export3D`); no textures, map art or shading ([3D export help](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/export-3d-vector-artwork.html)) |
+26
View File
@@ -3051,6 +3051,32 @@ The document stores their numeric settings; manual edits remain possible through
{"name":"run_command","arguments":{"command":"effect.lightingPreset","params":{"index":0,"item":null,"preset":"topLeft"}}}
```
## 3D shadows
Revolve, Extrude & Bevel, Inflate and Rotate take `shadow` (boolean, default false), `shadowPosition` (`"behind"`
or `"below"`, default `"behind"`), `shadowDistance` (0..100%, default 0) and `shadowBounds` (10..400%, default 100),
set with `effect.apply` or `effect.setParams` in one undo step. With `shadow` on, the first light casts a
hard-edged shadow of the solid, drawn behind the solid as one path filled black at 50% opacity times the opacity
of the art that casts it, in that art's blending mode. `"behind"` puts it
on a plane square to the view behind the object; `"below"` puts it on the floor under the object, square to the
object's own vertical axis and turned with it, so with no X rotation the floor is seen edge-on and draws nothing.
`shadowDistance` moves the plane away from the object by that share of the object's largest size. `shadowBounds`
limits the plane to a square about the foot of the object's center, four times the object's largest size across
at 100%. No shadow is drawn when `shade` is false, when the first light's intensity is 0, or when it does not
reach the plane (a light behind the object for a shadow behind it); a fill, stroke or group member at 0% opacity
casts none. Values out of range, another `shadowPosition` and a `shadow` that is not a boolean are refused before
the document changes. A shadow is cast from at most 327,680 faces, the solids of one object together (VectorCraft's
own limit); past that, a command is refused with "The 3D shadow is too complex; turn Shadows off or simplify the
art", and a loaded file draws the source path, as it does for a solid past its limits. The color, the opacity and how Distance and Bounds scale are VectorCraft's own; the public
documentation names the controls and their ranges
([3D and Materials panel options](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/3d-materials-panel-options.html)).
The settings are saved in the native file; SVG and PDF exports draw the shadow, and `effect.expandAppearance` keeps
it as a path. Extrude & Bevel (Classic) has no shadow. See [three-d.md](three-d.md#shadows).
```json
{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"shadow":true,"shadowPosition":"below","shadowDistance":10,"shadowBounds":150}}}}
```
## 3D object type
`effect.setParams {index: int, threeDType: "plane" | "extrude" | "revolve" | "inflate", params?, item?, ids?, aligned?}`
+1
View File
@@ -558,6 +558,7 @@ since 2026-10-01.
- **Panel collapse controls (#5925, in part):** The small » in a floating tab-group header moves that group into the side icon rail, reusing the existing rail or creating one. An opened rail panel has the same control to fold its flyout back into the rail, including after a tab switch. Both controls preserve the tab group and its menu; other floating groups and full-width docked headers stay as they were. Pointer regressions in `crates/ui-egui/src/panel_docking/tests.rs` cover grouped tabs, reopening, separate groups, a missing rail, the menu and flyout closure after switching tabs; `native_tests.rs` covers the native-window click and removal of the empty palette. Scores and estimates unchanged.
- 3D lighting presets: `crates/effects/src/lighting_presets.rs`, `crates/engine/src/cmd/effectcmd/lighting_presets_tests.rs` and the pointer tests in `crates/ui-egui/src/dialogs/revolve/tests.rs` cover recognition, atomic targeting, native round trips, preview, Cancel, reset and Undo.
- 3D shadows: Revolve, Extrude & Bevel, Inflate and Rotate take a Shadows switch with Position (Behind Object or Below Object), Distance from Object (0–100 %) and Shadow Bounds (10–400 %) on the Lighting tab ([three-d.md](three-d.md#shadows)); the first light casts one hard-edged vector shadow, drawn behind the solids, in exports and kept by Expand Appearance. Evidence: `crates/three-d/src/shadow/tests.rs`, `crates/effects/src/solids/tests_shadow.rs`, `effectcmd::tests::a_3d_shadow_is_one_undo_step_saved_exported_and_expanded`. Softness and the shadows of lights after the first are not built. Scores unchanged.
- Window › 3D and Materials panel: `crates/ui-egui/src/panels/three_d.rs` applies a 3D effect by object type (Plane, Extrude, Revolve, Inflate), changes an applied one in place (`effect.setParams` `threeDType`) and edits it with the options dialog's Object, Rotation and Lighting controls, one undo step per change; `crates/engine/src/cmd/effectcmd/three_d_type_tests.rs` and `crates/ui-egui/src/panels/tests_three_d.rs` cover it. No Materials tab.
- 3D effects on clipping groups, blends and symbol instances (#6140, #5725): every 3D type builds from the art the object shows (the art inside the clipping path, the blend's keys and steps, the symbol's art where the instance places it), alone or in a group, on the canvas, in exports, Object › Rasterize and `assets.add3D`; an image is refused with a pointer to Object › Image Trace › Make. `crates/effects/src/solids/tests_sources.rs`, `crates/engine/src/cmd/effectcmd/three_d_source_tests.rs` and `fx::tests::a_symbol_instance_a_blend_and_a_clipping_group_draw_their_solids` cover it. Scores and estimates unchanged.
+40 -5
View File
@@ -51,8 +51,9 @@ pages describe it.
unavailable while a setting of a single light differs between the selected effects.
- Each change is one undo step. Dragging a value or a slider previews it on the artboard and records one step
when the pointer is released; a typed value is recorded when it is entered. Drawing the panel records nothing.
- The Materials tab, shadows and ray-traced rendering are not built (see below). The rotation and light rings on
the artboard are part of the options dialog only.
- The Lighting tab also holds the Shadows switch and its settings ([Shadows](#shadows)). The Materials tab and
ray-traced rendering are not built (see below). The rotation and light rings on the artboard are part of the
options dialog only.
## Extrude & Bevel
@@ -242,6 +243,39 @@ the default light had (45° left of the eye and 45° up). Effects saved before (
`lightAzimuth` and `lightElevation`; those are read as the Rotation, Height and side of the same direction, so the
shading is exactly as before.
## Shadows
The public [3D and Materials panel options](https://helpx.adobe.com/illustrator/desktop/special-effects-styles/create-3d-graphics/3d-materials-panel-options.html)
page lists shadow settings that apply while shadows are on: a Position behind or below the object, a Distance
from Object from 0 to 100 % and Shadow Bounds from 10 to 400 %. The Lighting tab of Revolve, Extrude & Bevel,
Inflate and Rotate has a **Shadows** switch with those three settings below it.
| Option | |
|---|---|
| Shadows | Off by default. On, the first light casts a hard-edged shadow of the solid, drawn behind it. |
| Position | **Behind Object** (the default): on a plane square to the view, behind the object. **Below Object**: on the floor under the object, square to its own vertical axis and turned with it. |
| Distance from Object | 0–100 %, default 0 %: how far the plane is from the object, as a share of the object's largest size. |
| Shadow Bounds | 10–400 %, default 100 %: how far the plane reaches. At 100 % it is a square four times the object's largest size across, about the foot of the object's center; the shadow is cut at its edges. |
Our reading:
- The shadow is one path, filled black at 50 % opacity with the nonzero rule: every face of every solid of the
object (its fill and its strokes, or the members of a group) projected along the first light onto the plane.
Art that is partly transparent casts its part at 50 % times its opacity, and art with a blending mode casts
its part in that mode, each as a path of its own. A member whose
opacity mask or blending mode applies to its solids casts its part in that mask and mode too. The color, the
opacity and how Distance and Bounds scale are our own.
- No shadow is drawn while Shading is off (the Shadows settings are then unavailable), when the first light's
Intensity is 0 %, or when it does not reach the plane: a light behind the object casts none behind it, and a
light from under the floor casts none below it. A fill, stroke or group member at 0 % opacity casts none. With no X rotation the floor is seen edge-on, so a
shadow below draws nothing until the object is tilted (the default Off-Axis Front position shows it).
- Softness is not applied to the shadow, and only the first light casts one.
- A shadow is cast from at most 327,680 faces, the solids of one object together (our own limit). Past it, a
command is refused with "The 3D shadow is too complex; turn Shadows off or simplify the art", and a loaded
file draws the source path.
- Canvas, SVG and PDF exports draw the shadow, the art's bounds take it in, and Expand Appearance keeps it as a
path below the faces. Extrude & Bevel (Classic) has no shadow.
## Parameters (`effect.apply`, `effect.setParams`)
`threeD.extrude`: `depth`, `twist`, `taper` (%), `cap`, `bevel` (`none`, `classic`, `rounded`, `cove`, `step`,
@@ -249,7 +283,7 @@ shading is exactly as before.
`threeD.inflate`: `depth`, `volume` (%), `bothSides`. All three take the view and lighting parameters Revolve
uses: `rotationX`, `rotationY`, `rotationZ`, `perspectiveAngle` (degrees), `shade`, `lightColor` (`#rrggbb`, default `#ffffff`), `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. All four also take `lights`: null (the default, use the earlier single-light fields), or an array of one to ten objects. Each object takes `color` (opaque `#rrggbb`, white), `intensity` (0..100%, 80), `rotation` (-180..180 degrees, 125.26), `height` (0..90 degrees, 30), `softness` (0..100%, 0), and `behind` (boolean, false). Omitted settings use those defaults. An explicit list replaces the earlier single light; `ambient` and `shade` remain shared. Set `lights` to null to return to the single-light fields. Invalid lists, counts, keys and values are rejected before editing; malformed loaded lists fall back to the earlier light safely.
Commands validate the shape and the parameters before the document changes. All four also take `lights`: null (the default, use the earlier single-light fields), or an array of one to ten objects. Each object takes `color` (opaque `#rrggbb`, white), `intensity` (0..100%, 80), `rotation` (-180..180 degrees, 125.26), `height` (0..90 degrees, 30), `softness` (0..100%, 0), and `behind` (boolean, false). Omitted settings use those defaults. An explicit list replaces the earlier single light; `ambient` and `shade` remain shared. Set `lights` to null to return to the single-light fields. Invalid lists, counts, keys and values are rejected before editing; malformed loaded lists fall back to the earlier light safely. All four take `shadow` (boolean, false), `shadowPosition` (`behind` or `below`), `shadowDistance` (0..100%, 0) and `shadowBounds` (10..400%, 100) ([Shadows](#shadows)); values out of range are rejected before editing.
## What differs from the public documentation
@@ -258,9 +292,10 @@ 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), **shadows**
- **Materials** (base, Adobe Substance and graphics), soft shadows, shadows of lights after the first,
and **ray-traced rendering** are not built: shading uses directional lights, in the paint of the fill or
stroke the solid is built for ([Fills and strokes](#fills-and-strokes)).
stroke the solid is built for ([Fills and strokes](#fills-and-strokes)), and the shadow is hard-edged
([Shadows](#shadows)).
- 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, and the rest are drawn
back to front, with a flat cap or flat back turned toward the viewer drawn after all of them. Concave solids