mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 01:37:02 -06:00
M9: add multiple colored lights to 3D effects
Add opaque RGB light color and up to ten independently adjustable lights to
Revolve, Extrude & Bevel, Inflate and Rotate. Keep light selection, Add/Delete,
selected-light gizmos and preview transactions on the existing command path.
Preserve legacy white-light Inflate polygons, paint composition and seam joins,
including the 0.05 seam minimum. Use shared smooth normals for colored Inflate,
and batch solid-fill RGB channel paths behind one surface clip. Keep source
paths editable and SVG/PDF output vector artwork.
Rebase onto main f7de3d6a, retaining both sides of progress docs and all 22
catalogs. Shade freeform points once through the shared paint mapper. Document
release measurements and a reproducible CLI fixture, including the remaining
source-gradient cost and the one-light timing tradeoff.
Validation: full cargo xtask ci passed all 10 steps; 7,978 tests passed, zero
failed and 11 ignored. Four legacy white-light PNGs match unchanged main byte
for byte. Three paired release bench/perf repetitions completed without busy
warnings; all six standard perf runs passed every budget. Two-light solid
Inflate improved from 136.2 to 96.8 ms/frame at 1200x1200, default eight threads.
This commit is contained in:
+4
-1
@@ -9,7 +9,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~13 points (ready for real work ~62% → ~75%, and reliable `.ai` exchange) and ~170–270 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; about 145 community PRs of 2026-10-17: image-decode overflow guards, opaque floating panels, MCP export cancel and progress, editable Illustrator EPS import, scoped `doc_inspect`, Dutch, Danish and Indonesian, Simplified Chinese messages, screen-reader names; headline numbers unchanged; about 160 community PRs of 2026-10-15/16: PDF import crash fixes, Dimension tool, legacy `.ai` save, Intertwine, web pen input, Image Trace fixes, `.ai`/EPS/PDF import fidelity, Bulgarian, Greek, Swedish and Finnish catalogs; headline numbers unchanged) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
VectorCraft is a clean-room, open-source, pure-Rust reimplementation of the Adobe Illustrator workflow. It runs
|
||||
on macOS, Windows, Linux, FreeBSD and the web (WASM), and agents can drive all of it over MCP, the CLI and a JSON
|
||||
@@ -179,6 +179,9 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-17 | minor | Colored Inflate: shared membrane normals and smooth RGB vector ramps replace flat polygon bands; channel groups are batched per solid-fill surface. White-light Inflate retains its original polygons, paint composition and seam joins; the 0.05 seam minimum is restored. Scores and hour estimates unchanged. |
|
||||
| 2026-10-16 | minor | 3D lighting: one to ten directional lights, a selectable light list, Add Light and Delete Light, independent settings and artboard gestures; ambient applied once and smooth RGB vector export; scores and hour estimates unchanged |
|
||||
| 2026-10-16 | minor | 3D lighting: opaque RGB Color for the shared directional light, with neutral ambient, preview/reset controls and smooth vector Revolve shading; scores and hour estimates unchanged |
|
||||
| 2026-10-14 | minor | G11: open-path anchor Delete removes adjoining segments without bridging the gap; explicit Remove Anchors keeps its reconnect/refit behavior. Scores and hours unchanged. |
|
||||
| 2026-10-14 | minor | Document fonts register every explicitly supplied craft-fonts family after the existing Japanese, Arabic and Thai prefix; optional empty input remains supported. Headline scores and estimates are unchanged. |
|
||||
| 2026-10-14 | minor | Progress log rows for the later merges of 2026-10-14 (#1630–#1875 as listed there); headline numbers unchanged. |
|
||||
|
||||
@@ -37,6 +37,7 @@ mod bake;
|
||||
mod clip;
|
||||
mod distort;
|
||||
mod group;
|
||||
mod lighting;
|
||||
mod live;
|
||||
mod marks;
|
||||
pub mod pixel;
|
||||
@@ -203,29 +204,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), 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,"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), 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}),
|
||||
),
|
||||
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), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. The front face is the art, extruded backwards about the centre of its bounds; painted with the top fill (else the top stroke).",
|
||||
json!({"depth":50.0,"twist":0.0,"taper":100.0,"cap":true,"bevel":"none","bevelWidth":4.0,"bevelHeight":4.0,"bevelRepeat":1,"bevelSpace":0.0,"bevelInside":false,"bevelBothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
|
||||
"{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}),
|
||||
),
|
||||
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), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Painted with the top fill (else the top stroke).",
|
||||
json!({"depth":0.0,"twist":0.0,"taper":100.0,"cap":true,"volume":50.0,"bothSides":false,"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
|
||||
"{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}),
|
||||
),
|
||||
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), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
|
||||
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
|
||||
"{rotationX: degrees (-180..180, -21.88), rotationY: degrees (-180..180, -23.4), rotationZ: degrees (-180..180, 9.06; the defaults are the Off-Axis Front position), perspectiveAngle: degrees (0..160, 0; 0 is none, wider brings the eye nearer), perspective: % (0..100; the earlier form, read as the perspectiveAngle that shows the same view), shade: bool (true), lightColor: #rrggbb (white, #ffffff; legacy directional light color), lights: null or array of 1..10 objects (color: #rrggbb, intensity: % 0..100, rotation: degrees -180..180, height: degrees 0..90, softness: % 0..100, behind: bool; overrides the single-light fields; ambient is shared), lightRotation: degrees (-180..180, 125.26; round the object on the page, 0 on its right, counterclockwise), lightHeight: degrees (0..90, 30; 0 beside the object, 90 straight in front), lightBehind: bool (false; true lights it from behind), lightIntensity: % (0..100, 80), lightSoftness: % (0..100, 0; how far round the object the light reaches), ambient: % (0..200, 25; Ambient Light), lightAzimuth: degrees (the earlier form: with lightElevation, read as the lightRotation, lightHeight and lightBehind of the same light), lightElevation: degrees (the earlier form, with lightAzimuth)}. Turns the flat art in 3D about the centre of its bounds; every fill and stroke keeps its paint, shaded as one plane.",
|
||||
json!({"rotationX":-21.88,"rotationY":-23.4,"rotationZ":9.06,"perspectiveAngle":0.0,"shade":true,"lightColor":"#ffffff","lights":null,"lightRotation":125.26438968275465,"lightHeight":30.0,"lightBehind":false,"lightIntensity":80.0,"lightSoftness":0.0,"ambient":25.0}),
|
||||
),
|
||||
g(
|
||||
"convertToShape.rectangle",
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
//! Shared conversion and vector paint shading for the 3D effects' directional lights.
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{Color, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, FillLayer};
|
||||
|
||||
fn parse(v: &Value) -> Option<Color> {
|
||||
let s = v.as_str()?;
|
||||
// Match the command's opaque RGB contract, before any parsing or allocation.
|
||||
(s.len() == 7 && s.starts_with('#') && s.as_bytes().iter().skip(1).all(u8::is_ascii_hexdigit)).then(|| Color::from_hex(s)).flatten()
|
||||
}
|
||||
|
||||
pub(crate) fn light_color(p: &Value) -> [f64; 3] {
|
||||
p.get("lightColor").and_then(parse).unwrap_or(Color::WHITE).to_rgb_uncalibrated().map(f64::from)
|
||||
}
|
||||
|
||||
pub(crate) fn validate(p: &Value) -> Result<(), String> {
|
||||
if p.get("lightColor").is_some_and(|v| parse(v).is_none()) {
|
||||
return Err("lightColor must be an opaque RGB color in #rrggbb form".into());
|
||||
}
|
||||
if let Some(list) = p.get("lights").filter(|v| !v.is_null()) {
|
||||
parse_lights(list)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn lights(p: &Value) -> Option<vectorcraft_three_d::Lights> {
|
||||
p.get("lights").filter(|v| !v.is_null()).and_then(|v| parse_lights(v).ok())
|
||||
}
|
||||
|
||||
fn parse_lights(v: &Value) -> Result<vectorcraft_three_d::Lights, String> {
|
||||
use vectorcraft_three_d::{Light, Lights, MAX_LIGHTS};
|
||||
let list = v.as_array().filter(|v| !v.is_empty() && v.len() <= MAX_LIGHTS).ok_or("lights must contain one to ten lights")?;
|
||||
let mut out = Vec::with_capacity(list.len());
|
||||
for item in list {
|
||||
let item = item.as_object().ok_or("each light must be an object")?;
|
||||
if item.keys().any(|key| !["color", "intensity", "rotation", "height", "softness", "behind"].contains(&key.as_str())) {
|
||||
return Err("unknown light setting: use color, intensity, rotation, height, softness or behind".into());
|
||||
}
|
||||
let defaults = Light::default();
|
||||
let number = |key: &str, default: f64, min: f64, max: f64| -> Result<f64, String> {
|
||||
match item.get(key) {
|
||||
None => Ok(default),
|
||||
Some(v) => v
|
||||
.as_f64()
|
||||
.filter(|n| n.is_finite() && *n >= min && *n <= max)
|
||||
.ok_or_else(|| format!("light {key} must be a number from {min} to {max}")),
|
||||
}
|
||||
};
|
||||
let color = match item.get("color") {
|
||||
None => [1.0; 3],
|
||||
Some(v) => parse(v).ok_or("light color must use opaque #rrggbb")?.to_rgb_uncalibrated().map(f64::from),
|
||||
};
|
||||
let behind = item.get("behind").map_or(Ok(false), |v| v.as_bool().ok_or("light behind must be true or false"))?;
|
||||
out.push(Light {
|
||||
color,
|
||||
behind,
|
||||
intensity: number("intensity", defaults.intensity * 100.0, 0.0, 100.0)? / 100.0,
|
||||
rotation: number("rotation", defaults.rotation, -180.0, 180.0)?,
|
||||
height: number("height", defaults.height, 0.0, 90.0)?,
|
||||
softness: number("softness", defaults.softness * 100.0, 0.0, 100.0)? / 100.0,
|
||||
});
|
||||
}
|
||||
Lights::new(&out)
|
||||
}
|
||||
|
||||
pub(crate) fn shade(c: Color, rgb: [f64; 3]) -> Color {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
Color::rgb(r * rgb[0] as f32, g * rgb[1] as f32, b * rgb[2] as f32)
|
||||
}
|
||||
|
||||
pub(crate) fn smooth_paint(g: vectorcraft_three_d::LightGradient, color: Color) -> Paint {
|
||||
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
let stops = g.stops().into_iter().map(|(at, rgb)| GradientStop::new(at, shade(color, rgb))).collect();
|
||||
let mut p = GradientPaint::new(Gradient { kind: GradientKind::Linear, stops });
|
||||
p.geom = Some(GradientGeom { start: g.diffuse.start, end: g.diffuse.end, aspect: 1.0, focal: None });
|
||||
Paint::Gradient(Box::new(p))
|
||||
}
|
||||
|
||||
/// Multiply two independently placed vector gradients before applying the face's coverage
|
||||
/// once. Separately antialiasing the source and light along each triangle edge leaves seams.
|
||||
pub(crate) fn gradient_art(
|
||||
id: vectorcraft_doc::NodeId,
|
||||
path: &vectorcraft_geom::PathData,
|
||||
paint: &Paint,
|
||||
light: vectorcraft_three_d::LightGradient,
|
||||
) -> vectorcraft_doc::Node {
|
||||
composed_art(id, path, paint, Ramp::Single(light), 0.75)
|
||||
}
|
||||
|
||||
pub(crate) fn rgb_gradient_art(
|
||||
id: vectorcraft_doc::NodeId,
|
||||
path: &vectorcraft_geom::PathData,
|
||||
paint: &Paint,
|
||||
light: vectorcraft_three_d::RgbGradient,
|
||||
) -> vectorcraft_doc::Node {
|
||||
composed_art(id, path, paint, Ramp::Rgb(light), 0.75)
|
||||
}
|
||||
|
||||
/// Smooth Inflate triangles keep the seam within their narrow projected footprint.
|
||||
pub(crate) fn facet_art(
|
||||
id: vectorcraft_doc::NodeId,
|
||||
path: &vectorcraft_geom::PathData,
|
||||
paint: &Paint,
|
||||
light: vectorcraft_three_d::RgbGradient,
|
||||
seam: f64,
|
||||
) -> vectorcraft_doc::Node {
|
||||
let [r, g, b] = light.channels;
|
||||
if r == g && g == b {
|
||||
let light = vectorcraft_three_d::LightGradient { diffuse: r, ambient: 0.0, color: [1.0; 3] };
|
||||
if let Some(color) = paint.color() {
|
||||
let paint = smooth_paint(light, color);
|
||||
let mut edge = vectorcraft_doc::StrokeLayer::new(paint.clone(), seam);
|
||||
edge.join = vectorcraft_doc::LineJoin::Round;
|
||||
return vectorcraft_doc::Node::path(
|
||||
id,
|
||||
path.clone(),
|
||||
vectorcraft_doc::Appearance {
|
||||
items: vec![AppearanceItem::Fill(FillLayer::new(paint)), AppearanceItem::Stroke(edge)],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
return composed_art(id, path, paint, Ramp::Single(light), seam);
|
||||
}
|
||||
composed_art(id, path, paint, Ramp::Rgb(light), seam)
|
||||
}
|
||||
|
||||
/// Composite smooth solid-fill facets with one clip and three channel groups per surface.
|
||||
/// Black channel backdrops keep shared edges opaque before the final coverage is applied.
|
||||
pub(crate) fn solid_surface(id: vectorcraft_doc::NodeId, facets: &[vectorcraft_three_d::Facet], paint: &Paint) -> vectorcraft_doc::Node {
|
||||
use kurbo::Shape;
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_doc::color::BlendMode;
|
||||
use vectorcraft_doc::{Appearance, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{BezPath, PathData, Rect, shapes};
|
||||
|
||||
let mut boundary = BezPath::new();
|
||||
let mut bounds = None;
|
||||
for face in facets {
|
||||
let path = face.path();
|
||||
if let Some(b) = path.bounds() {
|
||||
bounds = Some(bounds.map_or(b, |r: Rect| r.union(b)));
|
||||
}
|
||||
let mut contour = path.to_bezpath();
|
||||
if contour.area() < 0.0 {
|
||||
contour = contour.reverse_subpaths();
|
||||
}
|
||||
boundary.extend(contour.iter());
|
||||
}
|
||||
let clip = Node::path(id, PathData::from_bezpath(&boundary), Appearance::default());
|
||||
let bounds = bounds.unwrap_or(Rect::ZERO).inflate(1.0, 1.0);
|
||||
let cover = |paint| Node::path(id, shapes::rectangle(bounds), Appearance::basic(paint, Paint::None, 0.0));
|
||||
let channels = (0..3)
|
||||
.map(|channel| {
|
||||
let mut color = [0.0; 3];
|
||||
if let Some(value) = color.get_mut(channel) {
|
||||
*value = 1.0;
|
||||
}
|
||||
let mut children = vec![Arc::new(cover(Paint::solid(Color::BLACK)))];
|
||||
children.extend(facets.iter().map(|face| {
|
||||
let paint = if let Some(ramp) = face.rgb_gradient {
|
||||
smooth_paint(vectorcraft_three_d::LightGradient { diffuse: ramp.channels[channel], ambient: 0.0, color }, Color::WHITE)
|
||||
} else {
|
||||
Paint::solid(shade(Color::WHITE, color.map(|c| c * face.illumination[channel])))
|
||||
};
|
||||
let mut edge = StrokeLayer::new(paint.clone(), (face.thickness() / 2.0).clamp(0.05, 0.75));
|
||||
edge.join = vectorcraft_doc::LineJoin::Round;
|
||||
Arc::new(Node::path(
|
||||
id,
|
||||
face.path(),
|
||||
Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(paint)), AppearanceItem::Stroke(edge)], ..Default::default() },
|
||||
))
|
||||
}));
|
||||
let mut group = Node::group(id, children);
|
||||
group.isolate = true;
|
||||
group.blend = BlendMode::Screen;
|
||||
Arc::new(group)
|
||||
})
|
||||
.collect();
|
||||
let mut light = Node::group(id, channels);
|
||||
light.isolate = true;
|
||||
light.blend = BlendMode::Multiply;
|
||||
let mut colors = Node::group(id, vec![Arc::new(cover(paint.clone())), Arc::new(light)]);
|
||||
colors.isolate = true;
|
||||
Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(colors)], clip: true })
|
||||
}
|
||||
|
||||
enum Ramp {
|
||||
Single(vectorcraft_three_d::LightGradient),
|
||||
Rgb(vectorcraft_three_d::RgbGradient),
|
||||
}
|
||||
|
||||
/// Separate a gradient's color and coverage so a tessellated surface can apply its opacity
|
||||
/// once, instead of building opacity at the overlapping seam strokes of adjacent faces.
|
||||
pub(crate) fn opaque_paint(paint: &Paint) -> (Paint, Option<Paint>) {
|
||||
let mut base = paint.clone();
|
||||
let mut mask = paint.clone();
|
||||
let mut transparent = false;
|
||||
if let (Paint::Gradient(b), Paint::Gradient(m)) = (&mut base, &mut mask) {
|
||||
b.swatch = None;
|
||||
m.swatch = None;
|
||||
for (b, m) in b.gradient.stops.iter_mut().zip(&mut m.gradient.stops) {
|
||||
transparent |= b.opacity != 1.0;
|
||||
b.opacity = 1.0;
|
||||
m.color = Color::WHITE;
|
||||
m.swatch = None;
|
||||
m.tint = 1.0;
|
||||
}
|
||||
if let (Some(b), Some(m)) = (&mut b.freeform, &mut m.freeform) {
|
||||
for (b, m) in b.points.iter_mut().zip(&mut m.points) {
|
||||
transparent |= b.opacity != 1.0;
|
||||
b.opacity = 1.0;
|
||||
m.color = Color::WHITE;
|
||||
}
|
||||
}
|
||||
}
|
||||
(base, transparent.then_some(mask))
|
||||
}
|
||||
|
||||
fn composed_art(id: vectorcraft_doc::NodeId, path: &vectorcraft_geom::PathData, paint: &Paint, light: Ramp, seam: f64) -> vectorcraft_doc::Node {
|
||||
use kurbo::Shape;
|
||||
use std::sync::Arc;
|
||||
use vectorcraft_doc::color::BlendMode;
|
||||
use vectorcraft_doc::{Appearance, Node, NodeKind, OpacityMask};
|
||||
use vectorcraft_geom::{Rect, shapes};
|
||||
let (base, mask) = opaque_paint(paint);
|
||||
// Clip once to the union of this single-contour face and its round seam stroke.
|
||||
// Align their winding so the nonzero fill adds coverage instead of cancelling it.
|
||||
let mut boundary = path.to_bezpath();
|
||||
let mut footprint = kurbo::stroke(boundary.iter(), &kurbo::Stroke::new(seam).with_join(kurbo::Join::Round), &kurbo::StrokeOpts::default(), 0.05);
|
||||
if boundary.area() * footprint.area() < 0.0 {
|
||||
boundary = boundary.reverse_subpaths();
|
||||
}
|
||||
footprint.extend(boundary.iter());
|
||||
let clip = Node::path(id, vectorcraft_geom::PathData::from_bezpath(&footprint), Appearance::default());
|
||||
// Opaque rectangles cover the complete seam stroke, without separately antialiasing
|
||||
// either ramp. Opaque sources need only vector clipping, not full-frame opacity masks.
|
||||
let bounds = path.bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0);
|
||||
let cover = |paint| Node::path(id, shapes::rectangle(bounds), Appearance::basic(paint, Paint::None, 0.0));
|
||||
let mut illumination = match light {
|
||||
Ramp::Single(light) => cover(smooth_paint(light, Color::WHITE)),
|
||||
Ramp::Rgb(light) => {
|
||||
let channels = light
|
||||
.channels
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(channel, gradient)| {
|
||||
let mut color = [0.0; 3];
|
||||
if let Some(value) = color.get_mut(channel) {
|
||||
*value = 1.0;
|
||||
}
|
||||
let mut layer = cover(smooth_paint(vectorcraft_three_d::LightGradient { diffuse: *gradient, ambient: 0.0, color }, Color::WHITE));
|
||||
// Screen adds disjoint R, G and B channels exactly; no light is clamped
|
||||
// separately. Each ramp already holds the sum of all directional lights.
|
||||
layer.blend = BlendMode::Screen;
|
||||
Arc::new(layer)
|
||||
})
|
||||
.collect();
|
||||
let mut group = Node::group(id, channels);
|
||||
group.isolate = true;
|
||||
group
|
||||
}
|
||||
};
|
||||
illumination.blend = BlendMode::Multiply;
|
||||
let mut colors = Node::group(id, vec![Arc::new(cover(base)), Arc::new(illumination)]);
|
||||
colors.isolate = true;
|
||||
// Keep isolation inside the clip: the clip then applies to the finished colors,
|
||||
// rather than separately antialiasing each of the two gradient drawings.
|
||||
let mut face = Node::new(id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(colors)], clip: true });
|
||||
if let Some(mask) = mask {
|
||||
face.extras_mut().mask = Some(Box::new(OpacityMask::new(cover(mask), true)));
|
||||
}
|
||||
face
|
||||
}
|
||||
|
||||
pub(crate) fn fills(paint: &Paint, rgb: [f64; 3], brightness: f64, colored: bool) -> Vec<AppearanceItem> {
|
||||
let shade = |c: Color| shade(c, rgb);
|
||||
if let Some(c) = paint.color() {
|
||||
return vec![AppearanceItem::Fill(FillLayer::new(Paint::solid(shade(c))))];
|
||||
}
|
||||
// Keep the white-light gradient treatment identical to existing documents. Colored light
|
||||
// shades the ramp itself, keeping placement, stops, midpoint and opacity as vector data.
|
||||
if matches!(paint, Paint::Gradient(_)) && colored {
|
||||
let mut shaded = paint.clone();
|
||||
vectorcraft_doc::recolor::map_paint(&mut shaded, &shade);
|
||||
if let Paint::Gradient(g) = &mut shaded {
|
||||
g.swatch = None;
|
||||
}
|
||||
return vec![AppearanceItem::Fill(FillLayer::new(shaded))];
|
||||
}
|
||||
// Pattern tiles remain referenced artwork. Retain their existing neutral shadow
|
||||
// approximation; tinting arbitrary pattern contents needs document-level evaluation.
|
||||
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
|
||||
shadow.opacity = (1.0 - brightness) as f32;
|
||||
vec![AppearanceItem::Fill(FillLayer::new(paint.clone())), AppearanceItem::Fill(shadow)]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::color::{Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop};
|
||||
use vectorcraft_geom::Point;
|
||||
|
||||
#[test]
|
||||
fn command_contract_is_strict_and_loaded_invalid_colors_fall_back_to_white() {
|
||||
assert_eq!(light_color(&json!({})), [1.0; 3]);
|
||||
assert_eq!(light_color(&json!({"lightColor":"#FF0000"})), [1.0, 0.0, 0.0]);
|
||||
for v in [json!("#ff0"), json!("#ffffff80"), json!("blue"), json!("#gg0000"), json!(null), json!([1, 0, 0])] {
|
||||
let p = json!({"lightColor":v});
|
||||
assert!(validate(&p).is_err(), "{p}");
|
||||
assert_eq!(light_color(&p), [1.0; 3]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_loaded_lists_keep_the_legacy_light_and_valid_lists_override_it() {
|
||||
for value in [json!([]), json!(vec![json!({}); 11]), json!([false]), json!([{"height": 91}]), json!([{"color":"#ff0"}])] {
|
||||
let p = json!({"lights": value, "lightColor":"#ff0000"});
|
||||
assert!(lights(&p).is_none());
|
||||
assert_eq!(light_color(&p), [1.0, 0.0, 0.0]);
|
||||
assert!(validate(&p).is_err());
|
||||
}
|
||||
let p = json!({"lights":[{}, {"color":"#0000ff","intensity":40}], "lightColor":"#ff0000"});
|
||||
let list = lights(&p).unwrap();
|
||||
assert_eq!(list.iter().count(), 2);
|
||||
assert_eq!(*list.iter().next().unwrap(), vectorcraft_three_d::Light::default());
|
||||
assert_eq!(list.iter().nth(1).unwrap().color, [0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colored_gradient_keeps_placement_alpha_and_midpoints() {
|
||||
let mut stop = GradientStop::new(0.0, Color::WHITE);
|
||||
stop.opacity = 0.4;
|
||||
stop.midpoint = 0.3;
|
||||
let g = GradientPaint {
|
||||
gradient: Gradient { kind: GradientKind::Linear, stops: vec![stop, GradientStop::new(1.0, Color::BLACK)] },
|
||||
geom: Some(GradientGeom { start: Point::new(20.0, 30.0), end: Point::new(100.0, 30.0), aspect: 1.0, focal: None }),
|
||||
angle: 0.0,
|
||||
swatch: None,
|
||||
freeform: None,
|
||||
};
|
||||
let paint = Paint::Gradient(Box::new(g.clone()));
|
||||
let items = fills(&paint, [1.0, 0.25, 0.25], 1.0, true);
|
||||
let Paint::Gradient(shaded) = items[0].paint() else { panic!("vector gradient") };
|
||||
assert_eq!(shaded.geom, g.geom);
|
||||
assert_eq!(shaded.gradient.stops[0].color.to_rgb(), [1.0, 0.25, 0.25]);
|
||||
assert_eq!(shaded.gradient.stops[0].opacity, 0.4);
|
||||
assert_eq!(shaded.gradient.stops[0].midpoint, 0.3);
|
||||
assert_eq!(shaded.gradient.stops[1].color, Color::BLACK);
|
||||
assert_eq!(paint, Paint::Gradient(Box::new(g)));
|
||||
let white = fills(&paint, [0.4; 3], 0.4, false);
|
||||
assert_eq!(white[0].paint(), &paint, "white retains the previous gradient treatment");
|
||||
assert_eq!(white.len(), 2);
|
||||
let unlit = fills(&paint, [0.25; 3], 0.25, true);
|
||||
assert_eq!(unlit.len(), 1, "neutral ambient under colored light still tints once");
|
||||
let mut freeform = if let Paint::Gradient(g) = paint { g } else { panic!("gradient") };
|
||||
freeform.gradient.kind = GradientKind::Freeform;
|
||||
freeform.gradient.stops = vec![GradientStop::new(0.0, Color::BLACK)];
|
||||
freeform.swatch = Some("Original ramp".into());
|
||||
freeform.freeform = Some(vectorcraft_doc::color::Freeform {
|
||||
points: vec![vectorcraft_doc::color::FreeformPoint::new(Point::new(12.0, 18.0), Color::WHITE)],
|
||||
..Default::default()
|
||||
});
|
||||
let original = Paint::Gradient(freeform);
|
||||
let items = fills(&original, [1.0, 0.25, 0.25], 1.0, true);
|
||||
let Paint::Gradient(shaded) = items[0].paint() else { panic!("freeform gradient") };
|
||||
let point = shaded.freeform.as_ref().unwrap().points[0];
|
||||
assert_eq!(point.at, Point::new(12.0, 18.0));
|
||||
assert_eq!(point.color.to_rgb(), [1.0, 0.25, 0.25]);
|
||||
assert_eq!(shaded.swatch, None, "a changed freeform point detaches the gradient swatch");
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,8 @@ pub fn revolve_options(p: &Value) -> Revolve {
|
||||
light_rotation: num(p, "lightRotation", vectorcraft_three_d::DEFAULT_LIGHT.0),
|
||||
light_height: num(p, "lightHeight", vectorcraft_three_d::DEFAULT_LIGHT.1),
|
||||
light_behind: flag(p, "lightBehind", false),
|
||||
light_color: crate::lighting::light_color(p),
|
||||
lights: crate::lighting::lights(p),
|
||||
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
|
||||
light_softness: num(p, "lightSoftness", 0.0) / 100.0,
|
||||
ambient: num(p, "ambient", 25.0) / 100.0,
|
||||
@@ -46,6 +48,7 @@ pub fn revolve_options(p: &Value) -> Revolve {
|
||||
/// Validate before a command mutates the document. Loading external files is still bounded
|
||||
/// by the evaluator; unsupported source kinds remain unchanged when rendered.
|
||||
pub fn validate_revolve(n: &Node, p: &Value) -> Result<(), String> {
|
||||
crate::lighting::validate(p)?;
|
||||
let path = profile(n).ok_or("Revolve currently requires a path or compound path. Select a profile drawn with the Pen tool.")?;
|
||||
let b = path.bounds().ok_or("Revolve requires a profile with at least two anchors")?;
|
||||
if !path.subpaths.iter().any(|sp| sp.anchors.len() >= 2) {
|
||||
@@ -87,7 +90,9 @@ fn surface(
|
||||
let b = path.bounds()?;
|
||||
// A loaded file or a profile edited past the limits keeps drawing its source path instead
|
||||
// of crashing the frame. Commands validate the same evaluator and report its errors.
|
||||
let faces = vectorcraft_three_d::revolve(path, b, revolve_options(&merged_params(REVOLVE, &fx.params))).ok()?;
|
||||
let options = revolve_options(&merged_params(REVOLVE, &fx.params));
|
||||
let colored = options.light_color != [1.0; 3] || options.lights.is_some();
|
||||
let faces = vectorcraft_three_d::revolve(path, b, options).ok()?;
|
||||
// Visibility is an expansion option, never a change to live geometry or export baking.
|
||||
// Keep all faces for paints whose coverage we cannot prove opaque. A bounded visibility
|
||||
// failure also retains the complete surface rather than losing any of the user's art.
|
||||
@@ -102,19 +107,42 @@ fn surface(
|
||||
let faces = visible.unwrap_or_else(|| {
|
||||
faces
|
||||
.into_iter()
|
||||
.map(|f| vectorcraft_three_d::VisibleFace { path: f.path(), brightness: f.brightness, depth: f.depth, gradient: f.gradient })
|
||||
.map(|f| vectorcraft_three_d::VisibleFace {
|
||||
path: f.path(),
|
||||
brightness: f.brightness,
|
||||
depth: f.depth,
|
||||
gradient: f.gradient,
|
||||
illumination: f.illumination,
|
||||
light_gradient: f.light_gradient,
|
||||
rgb_gradient: f.rgb_gradient,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let children = faces
|
||||
.into_iter()
|
||||
.map(|face| {
|
||||
if let Some(g) = face.rgb_gradient
|
||||
&& (paint.color().is_some() || matches!(paint, Paint::Gradient(_)))
|
||||
{
|
||||
return Arc::new(crate::lighting::rgb_gradient_art(n.id, &face.path, &paint, g));
|
||||
}
|
||||
if let (true, Paint::Gradient(_), Some(g)) = (colored, &paint, face.light_gradient) {
|
||||
return Arc::new(crate::lighting::gradient_art(n.id, &face.path, &paint, g));
|
||||
}
|
||||
let mut fill = FillLayer::new(paint.clone());
|
||||
let mut items = vec![];
|
||||
if let Some(c) = paint.color() {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
let color = |s: f64| Color::rgb(r * s as f32, g * s as f32, b * s as f32);
|
||||
fill.paint = face.gradient.map_or_else(|| Paint::solid(color(face.brightness)), |g| shading_paint(g, color, |_| 1.0));
|
||||
fill.paint = if colored {
|
||||
face.light_gradient
|
||||
.map_or_else(|| Paint::solid(crate::lighting::shade(c, face.illumination)), |g| crate::lighting::smooth_paint(g, c))
|
||||
} else {
|
||||
face.gradient.map_or_else(|| Paint::solid(color(face.brightness)), |g| shading_paint(g, color, |_| 1.0))
|
||||
};
|
||||
items.push(AppearanceItem::Fill(fill));
|
||||
} else if colored && matches!(paint, Paint::Gradient(_)) {
|
||||
items = crate::lighting::fills(&paint, face.illumination, face.brightness, true);
|
||||
} else {
|
||||
items.push(AppearanceItem::Fill(fill));
|
||||
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::color::{BlendMode, Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, FillLayer, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_doc::color::{BlendMode, Paint};
|
||||
use vectorcraft_doc::{Appearance, AppearanceItem, Effect, Node, NodeKind, StrokeLayer};
|
||||
use vectorcraft_geom::{FillRule, PathData, Rect};
|
||||
use vectorcraft_three_d::{Bevel, BevelShape, Extrude, Facet, Inflate, View};
|
||||
|
||||
#[cfg(test)]
|
||||
use vectorcraft_doc::color::Color;
|
||||
|
||||
use crate::util::{flag, num, text};
|
||||
use crate::{GeomContext, apply_geometry_with, is_geometry};
|
||||
|
||||
@@ -65,6 +68,8 @@ pub fn view_options(p: &Value) -> View {
|
||||
light_rotation: num(p, "lightRotation", vectorcraft_three_d::DEFAULT_LIGHT.0),
|
||||
light_height: num(p, "lightHeight", vectorcraft_three_d::DEFAULT_LIGHT.1),
|
||||
light_behind: flag(p, "lightBehind", false),
|
||||
light_color: crate::lighting::light_color(p),
|
||||
lights: crate::lighting::lights(p),
|
||||
light_intensity: num(p, "lightIntensity", 80.0) / 100.0,
|
||||
light_softness: num(p, "lightSoftness", 0.0) / 100.0,
|
||||
ambient: num(p, "ambient", 25.0) / 100.0,
|
||||
@@ -124,6 +129,7 @@ fn facets(id: &str, path: &PathData, rule: FillRule, bounds: Rect, p: &Value) ->
|
||||
|
||||
/// Validate before a command changes the document; the evaluator bounds loaded files too.
|
||||
pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
|
||||
crate::lighting::validate(p)?;
|
||||
let label = crate::effect_info(id).map_or("This effect", |e| e.label.trim_end_matches('…'));
|
||||
let (path, rule) =
|
||||
profile(n).ok_or_else(|| format!("{label} currently requires a path or compound path. Convert type, images and groups to paths first."))?;
|
||||
@@ -139,32 +145,38 @@ pub fn validate_solid(n: &Node, id: &str, p: &Value) -> Result<(), String> {
|
||||
|
||||
/// One surface: each facet a path shaded from `paint`, as a group with the paint's opacity and
|
||||
/// blend mode (so overlapping facets don't add up their transparency).
|
||||
fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: BlendMode) -> Node {
|
||||
fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: BlendMode, colored: bool) -> Node {
|
||||
if colored && paint.color().is_some() && facets.iter().any(|face| face.rgb_gradient.is_some()) {
|
||||
let mut group = crate::lighting::solid_surface(n.id, &facets, paint);
|
||||
group.opacity = opacity;
|
||||
group.blend = blend;
|
||||
return group;
|
||||
}
|
||||
// Preserve existing white-light paints, alpha composition and seam joins exactly.
|
||||
let (opaque, mask) = if colored { crate::lighting::opaque_paint(paint) } else { (paint.clone(), None) };
|
||||
let paint = &opaque;
|
||||
let children = facets
|
||||
.into_iter()
|
||||
.map(|face| {
|
||||
let mut items = vec![];
|
||||
if let Some(c) = paint.color() {
|
||||
let [r, g, b] = c.to_rgb();
|
||||
let s = face.brightness as f32;
|
||||
items.push(AppearanceItem::Fill(FillLayer::new(Paint::solid(Color::rgb(r * s, g * s, b * s)))));
|
||||
} else {
|
||||
// Gradients and patterns keep their paint, darkened by a black layer.
|
||||
items.push(AppearanceItem::Fill(FillLayer::new(paint.clone())));
|
||||
let mut shadow = FillLayer::new(Paint::solid(Color::BLACK));
|
||||
shadow.opacity = (1.0 - face.brightness) as f32;
|
||||
items.push(AppearanceItem::Fill(shadow));
|
||||
}
|
||||
// Adjacent facets, antialiased on their own, would leave hairline seams: a narrow
|
||||
// stroke of the same paint closes them. No wider than the facet is thick, though: seen
|
||||
// at a grazing angle a facet can be thinner than the stroke, and its stroke would then
|
||||
// paint over its neighbours.
|
||||
let seam = (face.thickness() / 2.0).clamp(0.05, 0.75);
|
||||
if let Some(g) = face.rgb_gradient
|
||||
&& (paint.color().is_some() || matches!(paint, Paint::Gradient(_)))
|
||||
{
|
||||
return Arc::new(crate::lighting::facet_art(n.id, &face.path(), paint, g, seam));
|
||||
}
|
||||
let items = crate::lighting::fills(paint, face.illumination, face.brightness, colored);
|
||||
let items = items
|
||||
.into_iter()
|
||||
.flat_map(|item| {
|
||||
let AppearanceItem::Fill(fill) = &item else { return vec![item] };
|
||||
let mut edge = StrokeLayer::new(fill.paint.clone(), seam);
|
||||
if colored {
|
||||
edge.join = vectorcraft_doc::LineJoin::Round;
|
||||
}
|
||||
edge.opacity = fill.opacity;
|
||||
vec![item, AppearanceItem::Stroke(edge)]
|
||||
})
|
||||
@@ -175,6 +187,11 @@ fn surface(n: &Node, facets: Vec<Facet>, paint: &Paint, opacity: f32, blend: Ble
|
||||
let mut group = Node::group(n.id, children);
|
||||
group.opacity = opacity;
|
||||
group.blend = blend;
|
||||
if let Some(mask) = mask {
|
||||
let bounds = group.visual_bounds().unwrap_or(Rect::ZERO).inflate(1.0, 1.0);
|
||||
let coverage = Node::path(n.id, vectorcraft_geom::shapes::rectangle(bounds), Appearance::basic(mask, Paint::None, 0.0));
|
||||
group.extras_mut().mask = Some(Box::new(vectorcraft_doc::OpacityMask::new(coverage, true)));
|
||||
}
|
||||
group
|
||||
}
|
||||
|
||||
@@ -200,6 +217,7 @@ fn region(path: &PathData, rule: FillRule, item: &AppearanceItem) -> (PathData,
|
||||
fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&AppearanceItem]) -> Option<Vec<Arc<Node>>> {
|
||||
let b = path.bounds()?;
|
||||
let p = crate::merged_params(&fx.id, &fx.params);
|
||||
let colored = view_options(&p).has_colored_lights();
|
||||
if fx.id == ROTATE {
|
||||
// Every part turns about the centre of the object's own bounds, so they stay together.
|
||||
let mut out = vec![];
|
||||
@@ -207,7 +225,7 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
|
||||
let (paint, opacity, blend) = paint_of(item);
|
||||
let (area, rule) = region(path, rule, item);
|
||||
let f = facets(ROTATE, &area, rule, b, &p).ok()?;
|
||||
out.push(Arc::new(surface(n, f, &paint, opacity, blend)));
|
||||
out.push(Arc::new(surface(n, f, &paint, opacity, blend, colored)));
|
||||
}
|
||||
return Some(out);
|
||||
}
|
||||
@@ -216,7 +234,7 @@ fn art_for(n: &Node, fx: &Effect, path: &PathData, rule: FillRule, items: &[&App
|
||||
// A loaded file or a shape edited past the limits keeps drawing its source path instead of
|
||||
// failing the frame; commands validate the same evaluator and report its errors.
|
||||
let f = facets(&fx.id, path, rule, b, &p).ok()?;
|
||||
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend))])
|
||||
Some(vec![Arc::new(surface(n, f, &paint, opacity, blend, colored))])
|
||||
}
|
||||
|
||||
fn geometry(path: &PathData, fx: &[Effect], ctx: &GeomContext) -> PathData {
|
||||
|
||||
@@ -27,7 +27,10 @@ fn faces(art: &Node) -> Vec<&Node> {
|
||||
|
||||
fn fill_rgb(n: &Node) -> [f32; 3] {
|
||||
match &n.appearance.items[0] {
|
||||
AppearanceItem::Fill(f) => f.paint.color().unwrap().to_rgb(),
|
||||
AppearanceItem::Fill(f) => match &f.paint {
|
||||
Paint::Gradient(g) => g.gradient.sample(0.5).0.to_rgb(),
|
||||
p => p.color().unwrap().to_rgb(),
|
||||
},
|
||||
_ => panic!("a fill first"),
|
||||
}
|
||||
}
|
||||
@@ -183,10 +186,12 @@ fn an_earlier_perspective_strength_draws_as_its_angle() {
|
||||
#[test]
|
||||
fn an_earlier_light_direction_shades_as_its_rotation_and_height() {
|
||||
fn colours(n: &Node, out: &mut Vec<[f32; 3]>) {
|
||||
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first()
|
||||
&& let Some(c) = f.paint.color()
|
||||
{
|
||||
out.push(c.to_rgb());
|
||||
if let Some(AppearanceItem::Fill(f)) = n.appearance.items.first() {
|
||||
if let Paint::Gradient(g) = &f.paint {
|
||||
out.extend(g.gradient.stops.iter().map(|s| s.color.to_rgb()));
|
||||
} else if let Some(c) = f.paint.color() {
|
||||
out.push(c.to_rgb());
|
||||
}
|
||||
}
|
||||
for c in n.children().into_iter().flatten() {
|
||||
colours(c, out);
|
||||
@@ -265,3 +270,52 @@ fn inflate_reads_twist_taper_and_cap() {
|
||||
assert_eq!((d.twist, d.taper, d.cap), (0.0, 1.0, true));
|
||||
assert_eq!(crate::default_params(INFLATE).unwrap()["cap"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_white_inflate_keeps_polygon_faces_and_its_seam_range() {
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(fx(INFLATE, json!({})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
let original = faces(&art);
|
||||
assert!(original.len() > 1000);
|
||||
assert!(original.iter().any(|f| f.path_data().unwrap().subpaths.iter().any(|p| p.anchors.len() > 3)), "legacy cells remain polygons");
|
||||
for face in original {
|
||||
assert!(matches!(face.kind, NodeKind::Path { .. }));
|
||||
let AppearanceItem::Stroke(stroke) = &face.appearance.items[1] else { panic!("seam") };
|
||||
assert!((0.05..=0.75).contains(&stroke.width));
|
||||
assert_eq!(stroke.join, vectorcraft_doc::LineJoin::Miter);
|
||||
}
|
||||
// A tiny shape exercises the lower bound, which otherwise disappears at grazing angles.
|
||||
n.transform(Affine::scale(0.001), false);
|
||||
let art = solid_art(&n).unwrap();
|
||||
assert!(faces(&art).iter().any(|face| matches!(&face.appearance.items[1], AppearanceItem::Stroke(s) if s.width == 0.05)));
|
||||
// An explicit white light overrides a stale legacy color without switching shading paths.
|
||||
n.appearance.effects[0].params = json!({"lightColor":"#ff0000","lights":[{}]});
|
||||
let art = solid_art(&n).unwrap();
|
||||
assert!(faces(&art).iter().all(|face| matches!(face.kind, NodeKind::Path { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colored_solid_inflate_batches_channels_and_clips_once_per_surface() {
|
||||
let mut n = square_node();
|
||||
n.appearance.effects.push(fx(INFLATE, json!({"lights":[{"color":"#ff0000"},{"color":"#0000ff"}]})));
|
||||
let art = solid_art(&n).unwrap();
|
||||
fn count(n: &Node) -> (usize, usize, usize) {
|
||||
let (mut groups, mut clips, mut paths) = match &n.kind {
|
||||
NodeKind::Group { clip, .. } => (1, usize::from(*clip), 0),
|
||||
NodeKind::Path { .. } => (0, 0, 1),
|
||||
_ => (0, 0, 0),
|
||||
};
|
||||
for child in n.children().into_iter().flatten() {
|
||||
let (g, c, p) = count(child);
|
||||
groups += g;
|
||||
clips += c;
|
||||
paths += p;
|
||||
}
|
||||
(groups, clips, paths)
|
||||
}
|
||||
let (groups, clips, paths) = count(&art);
|
||||
assert!(paths > 1000, "exercise a tessellated surface");
|
||||
assert_eq!(clips, 1);
|
||||
assert_eq!(groups, 7, "group count must not grow with triangle count");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,505 @@
|
||||
//! Colored 3D lighting remains a live command and survives native and vector output.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, Node, NodeId};
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
const EFFECTS: [&str; 4] = ["threeD.revolve", "threeD.extrude", "threeD.inflate", "threeD.rotate"];
|
||||
|
||||
fn setup(effect: &str) -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":400,"height":400})).unwrap();
|
||||
let r = s.execute("path.create", &json!({"d":"M100 100L200 100L200 200L100 200Z"})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color":"#ffffff"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none":true})).unwrap();
|
||||
let p = json!({"effect":effect,"item":null,"params":{"rotationX":0,"rotationY":0,"rotationZ":0,"lightHeight":90}});
|
||||
s.check_params("effect.apply", &p).unwrap();
|
||||
s.execute("effect.apply", &p).unwrap();
|
||||
(s, NodeId(r["id"].as_u64().unwrap()))
|
||||
}
|
||||
|
||||
fn doc(s: &Session) -> &Document {
|
||||
&s.active().unwrap().doc
|
||||
}
|
||||
fn params(s: &Session, id: NodeId) -> &Value {
|
||||
&doc(s).node(id).unwrap().appearance.effects[0].params
|
||||
}
|
||||
fn contains_color(n: &Node, hex: &str) -> bool {
|
||||
n.appearance.items.iter().any(|i| i.paint().color().is_some_and(|c| c.to_hex() == hex))
|
||||
|| n.children().is_some_and(|ch| ch.iter().any(|n| contains_color(n, hex)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_four_light_colors_save_reopen_undo_and_expand_without_editing_source() {
|
||||
for effect in EFFECTS {
|
||||
let (mut s, id) = setup(effect);
|
||||
let original = doc(&s).clone();
|
||||
let path = doc(&s).node(id).unwrap().path_data().unwrap().clone();
|
||||
assert_eq!(params(&s, id)["lightColor"], json!("#ffffff"));
|
||||
let change = json!({"index":0,"item":null,"params":{"lightColor":"#ff0000"}});
|
||||
s.check_params("effect.setParams", &change).unwrap();
|
||||
s.execute("effect.setParams", &change).unwrap();
|
||||
assert_eq!(doc(&s).node(id).unwrap().path_data(), Some(&path));
|
||||
let red = doc(&s).clone();
|
||||
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&red, false)).unwrap();
|
||||
assert_eq!(reopened.node(id).unwrap().appearance.effects[0].params["lightColor"], json!("#ff0000"));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(doc(&s), &original);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(doc(&s), &red);
|
||||
let svg = s.execute("document.serialize", &json!({"format":"svg"})).unwrap();
|
||||
let text = svg["text"].as_str().unwrap();
|
||||
assert!(text.contains("<path") && !text.contains("<image"), "{effect}");
|
||||
if effect == "threeD.rotate" {
|
||||
assert!(text.contains("#ff4040"), "front plane is white under red light and neutral 25% ambient: {text}");
|
||||
}
|
||||
let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap();
|
||||
assert!(!bytes.windows(b"/Subtype /Image".len()).any(|s| s == b"/Subtype /Image"), "{effect}");
|
||||
assert_eq!(doc(&s), &red, "exports leave the live document intact");
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
assert!(doc(&s).node(id).unwrap().appearance.effects.is_empty());
|
||||
if effect == "threeD.rotate" {
|
||||
assert!(contains_color(doc(&s).node(id).unwrap(), "#ff4040"));
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(doc(&s), &red);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_light_color_is_rejected_without_mutating_any_effect() {
|
||||
for effect in EFFECTS {
|
||||
let (mut s, _) = setup(effect);
|
||||
let before = doc(&s).clone();
|
||||
for color in [json!("#ff0"), json!("#ffffff00"), json!("invalid"), json!(42), json!(null), json!([1, 0, 0])] {
|
||||
let p = json!({"index":0,"item":null,"params":{"lightColor":color}});
|
||||
assert!(s.execute("effect.setParams", &p).is_err(), "{effect}: {p}");
|
||||
assert_eq!(doc(&s), &before);
|
||||
}
|
||||
let p = json!({"index":0,"params":{"lightColour":"#ff0000"}});
|
||||
assert!(s.check_params("effect.setParams", &p).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn capped_profile() -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":400,"height":400})).unwrap();
|
||||
let r = s.execute("path.create", &json!({"d":"M200 120L200 280"})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color":"#ffffff"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none":true})).unwrap();
|
||||
s.execute(
|
||||
"effect.apply",
|
||||
&json!({"effect":"threeD.revolve","item":null,"params":{
|
||||
"offset":70,"rotationX":-25,"segments":24,"ambient":20,"lightIntensity":65,"lightColor":"#ff0000"
|
||||
}}),
|
||||
)
|
||||
.unwrap();
|
||||
(s, NodeId(r["id"].as_u64().unwrap()))
|
||||
}
|
||||
|
||||
fn render(d: &Document) -> vectorcraft_render::Rendered {
|
||||
vectorcraft_render::Renderer::new().render(
|
||||
d,
|
||||
400,
|
||||
400,
|
||||
kurbo::Affine::IDENTITY,
|
||||
&vectorcraft_render::RenderOptions { background: None, artboards: false, ..Default::default() },
|
||||
)
|
||||
}
|
||||
|
||||
fn vector_exports(s: &mut Session) -> String {
|
||||
let svg = s.execute("document.serialize", &json!({"format":"svg"})).unwrap()["text"].as_str().unwrap().to_owned();
|
||||
assert!(svg.contains("<linearGradient") && !svg.contains("<image"));
|
||||
assert!(
|
||||
svg.split(" d=\"").skip(1).any(|d| d.split('"').next().is_some_and(|d| d.contains('C'))),
|
||||
"continuous curved cap boundaries survive export"
|
||||
);
|
||||
let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap();
|
||||
assert!(!bytes.windows(b"/Subtype /Image".len()).any(|s| s == b"/Subtype /Image"));
|
||||
svg
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colored_revolve_smooth_shading_and_default_caps_work_together_and_expand() {
|
||||
let (mut s, id) = capped_profile();
|
||||
assert_eq!(params(&s, id)["cap"], true);
|
||||
assert!(params(&s, id).get("smoothShading").is_none());
|
||||
let before = render(doc(&s));
|
||||
let source = doc(&s).node(id).unwrap().path_data().unwrap().clone();
|
||||
vector_exports(&mut s);
|
||||
s.execute("effect.setParams", &json!({"index":0,"item":null,"params":{"cap":false}})).unwrap();
|
||||
assert_ne!(render(doc(&s)).pixels, before.pixels, "Cap changes the open profile's ends");
|
||||
s.execute("effect.setParams", &json!({"index":0,"item":null,"params":{"cap":true}})).unwrap();
|
||||
assert_eq!(doc(&s).node(id).unwrap().path_data(), Some(&source));
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
let expanded = render(doc(&s));
|
||||
let mut shades = std::collections::BTreeSet::new();
|
||||
let mut samples = 0;
|
||||
for y in (4..396).step_by(3) {
|
||||
for x in (4..396).step_by(3) {
|
||||
let p = before.pixel(x, y);
|
||||
let q = expanded.pixel(x, y);
|
||||
if p[3] == 255 && q[3] == 255 {
|
||||
assert!(p[1].abs_diff(51) <= 1 && p[2].abs_diff(51) <= 1, "neutral ambient {p:?}");
|
||||
assert!(p.into_iter().zip(q).all(|(a, b)| a.abs_diff(b) <= 3), "expand ({x},{y}): {p:?} vs {q:?}");
|
||||
shades.insert(p[0]);
|
||||
samples += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(samples > 500 && shades.len() > 40, "smooth colored shading at only 24 segments");
|
||||
vector_exports(&mut s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colored_smooth_source_gradients_keep_alpha_and_vector_output() {
|
||||
for opacity in [1.0, 0.45] {
|
||||
let (mut s, id) = capped_profile();
|
||||
let solid = render(doc(&s));
|
||||
s.execute(
|
||||
"paint.setFill",
|
||||
&json!({"gradient":{"kind":"linear","start":[80,200],"end":[240,200],"stops":[
|
||||
{"offset":0,"color":"#ffffff","opacity":opacity,"midpoint":0.3},
|
||||
{"offset":1,"color":"#808080","opacity":if opacity == 1.0 { 1.0 } else { opacity*0.8 }}
|
||||
]}}),
|
||||
)
|
||||
.unwrap();
|
||||
let red = render(doc(&s));
|
||||
if opacity == 1.0 {
|
||||
let vectorcraft_doc::color::Paint::Gradient(source) = doc(&s).node(id).unwrap().appearance.items[0].paint() else {
|
||||
panic!("source gradient")
|
||||
};
|
||||
let mut checked = 0;
|
||||
// Compare the body away from the cap/body antialiasing boundary. This crosses
|
||||
// every radial seam and the shared triangle diagonals in the smooth surface.
|
||||
for y in (170..230).step_by(2) {
|
||||
for x in (65..195).step_by(2) {
|
||||
let white = solid.pixel(x, y);
|
||||
if white[3] == 255 && solid.pixel(x - 1, y)[3] == 255 && solid.pixel(x + 1, y)[3] == 255 {
|
||||
let p = red.pixel(x, y);
|
||||
let t = source.geom.unwrap().param_at(source.gradient.kind, kurbo::Point::new(x as f64 + 0.5, y as f64 + 0.5));
|
||||
let (color, _) = source.gradient.sample(t as f32);
|
||||
let expected: Vec<_> = white[..3].iter().zip(color.to_rgb()).map(|(v, c)| (*v as f32 * c).round() as u8).collect();
|
||||
assert_eq!(p[3], 255, "source and light share one antialiased coverage");
|
||||
assert!(
|
||||
p.into_iter().zip(&expected).all(|(a, b)| a.abs_diff(*b) <= 3),
|
||||
"gradient seam at ({x},{y}): {p:?}, expected {expected:?}, solid {white:?}"
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(checked > 1000);
|
||||
}
|
||||
s.execute("effect.setParams", &json!({"index":0,"item":null,"params":{"lightColor":"#0000ff"}})).unwrap();
|
||||
let blue = render(doc(&s));
|
||||
let mut translucent = 0;
|
||||
for y in (4..396).step_by(7) {
|
||||
for x in (4..396).step_by(7) {
|
||||
let r = red.pixel(x, y);
|
||||
let b = blue.pixel(x, y);
|
||||
assert_eq!(r[3], b[3], "light color preserves source coverage");
|
||||
assert!(
|
||||
r[0].abs_diff(b[2]) <= 2 && r[1].abs_diff(b[1]) <= 2 && r[2].abs_diff(b[0]) <= 2,
|
||||
"RGB channels at ({x},{y}): {r:?} vs {b:?}"
|
||||
);
|
||||
translucent += usize::from(r[3] > 30 && r[3] < 245);
|
||||
}
|
||||
}
|
||||
if opacity < 1.0 {
|
||||
assert!(translucent > 100, "gradient stop opacity is preserved");
|
||||
}
|
||||
let svg = vector_exports(&mut s);
|
||||
assert!(svg.contains("multiply"));
|
||||
if opacity < 1.0 {
|
||||
assert!(svg.contains("<mask"));
|
||||
} else {
|
||||
assert!(!svg.contains("<mask"), "opaque faces use vector clipping without opacity-mask buffers");
|
||||
}
|
||||
let imported = vectorcraft_svg::import(&svg).unwrap();
|
||||
let svg_image = render(&imported);
|
||||
let mut checked = 0;
|
||||
for y in (110..290).step_by(11) {
|
||||
for x in (80..225).step_by(11) {
|
||||
let p = blue.pixel(x, y);
|
||||
let q = svg_image.pixel(x, y);
|
||||
if p[3] > 50 {
|
||||
assert!(p.into_iter().zip(q).all(|(a, b)| a.abs_diff(b) <= 8), "SVG ({x},{y}): {p:?} vs {q:?}");
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(checked > 50);
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
assert_eq!(render(doc(&s)).pixels, blue.pixels, "gradient expansion keeps the live composition");
|
||||
vector_exports(&mut s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_lights_survive_commands_history_native_save_and_vector_expansion() {
|
||||
let lights = json!([
|
||||
{"color":"#ff0000","intensity":60,"height":90,"rotation":0,"softness":0,"behind":false},
|
||||
{"color":"#0000ff","intensity":40,"height":90,"rotation":0,"softness":0,"behind":false}
|
||||
]);
|
||||
for effect in EFFECTS {
|
||||
let (mut s, id) = setup(effect);
|
||||
let before = doc(&s).clone();
|
||||
let source = doc(&s).node(id).unwrap().path_data().unwrap().clone();
|
||||
let change = json!({"index":0,"item":null,"params":{"ambient":20,"lights":lights}});
|
||||
s.check_params("effect.setParams", &change).unwrap();
|
||||
s.execute("effect.setParams", &change).unwrap();
|
||||
let lit = doc(&s).clone();
|
||||
assert_eq!(params(&s, id)["lights"], lights);
|
||||
let restored = vectorcraft_format::load(&vectorcraft_format::save(&lit, false)).unwrap();
|
||||
assert_eq!(restored.node(id).unwrap().appearance.effects[0].params["lights"], lights);
|
||||
assert_eq!(doc(&s).node(id).unwrap().path_data(), Some(&source));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(doc(&s), &before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(doc(&s), &lit);
|
||||
let live = render(doc(&s));
|
||||
if effect == "threeD.rotate" {
|
||||
assert_eq!(live.pixel(150, 150), [204, 51, 153, 255]);
|
||||
}
|
||||
let svg = s.execute("document.serialize", &json!({"format":"svg"})).unwrap()["text"].as_str().unwrap().to_owned();
|
||||
assert!(!svg.contains("<image"));
|
||||
let pdf = s.execute("document.serialize", &json!({"format":"pdf"})).unwrap();
|
||||
let bytes = vectorcraft_format::base64_decode(pdf["dataBase64"].as_str().unwrap()).unwrap();
|
||||
assert!(!bytes.windows(b"/Subtype /Image".len()).any(|s| s == b"/Subtype /Image"));
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
let expanded = render(doc(&s));
|
||||
let mut checked = 0;
|
||||
for y in (100..200).step_by(4) {
|
||||
for x in (100..200).step_by(4) {
|
||||
let p = live.pixel(x, y);
|
||||
let q = expanded.pixel(x, y);
|
||||
if p[3] == 255 && q[3] == 255 {
|
||||
assert!(p.into_iter().zip(q).all(|(a, b)| a.abs_diff(b) <= 3), "{effect}: {p:?} vs {q:?}");
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(checked > 100, "{effect}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_light_lists_are_rejected_atomically_and_null_restores_legacy_controls() {
|
||||
for effect in EFFECTS {
|
||||
let (mut s, id) = setup(effect);
|
||||
let before = doc(&s).clone();
|
||||
for lights in [
|
||||
json!([]),
|
||||
json!(vec![json!({}); 11]),
|
||||
json!(false),
|
||||
json!([null]),
|
||||
json!([{"color":"#f00"}]),
|
||||
json!([{"height":91}]),
|
||||
json!([{"intensity":-1}]),
|
||||
json!([{"softness":"soft"}]),
|
||||
json!([{"behind":1}]),
|
||||
json!([{"colour":"#ffffff"}]),
|
||||
] {
|
||||
let change = json!({"index":0,"item":null,"params":{"lights":lights}});
|
||||
assert!(s.execute("effect.setParams", &change).is_err(), "{effect}: {lights}");
|
||||
assert_eq!(doc(&s), &before);
|
||||
}
|
||||
s.execute("effect.setParams", &json!({"index":0,"item":null,"params":{"lights":[{},{}]}})).unwrap();
|
||||
s.execute("effect.setParams", &json!({"index":0,"item":null,"params":{"lights":null}})).unwrap();
|
||||
assert!(params(&s, id)["lights"].is_null());
|
||||
assert_eq!(render(doc(&s)).pixels, render(&before).pixels);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_multi_light_color_varies_in_two_directions_and_stays_vector_with_source_gradients() {
|
||||
let (mut s, id) = capped_profile();
|
||||
s.execute(
|
||||
"effect.setParams",
|
||||
&json!({"index":0,"item":null,"params":{"ambient":10,"lights":[
|
||||
{"color":"#ff0000","rotation":0,"height":25,"intensity":70},
|
||||
{"color":"#0000ff","rotation":180,"height":50,"intensity":65}
|
||||
]}}),
|
||||
)
|
||||
.unwrap();
|
||||
let solid = render(doc(&s));
|
||||
assert!(solid.pixels.as_chunks::<4>().0.iter().any(|p| u16::from(p[0]) > u16::from(p[2]) + 20 && p[3] == 255));
|
||||
assert!(solid.pixels.as_chunks::<4>().0.iter().any(|p| u16::from(p[2]) > u16::from(p[0]) + 20 && p[3] == 255));
|
||||
for opacity in [1.0, 0.5] {
|
||||
s.execute(
|
||||
"paint.setFill",
|
||||
&json!({"gradient":{"kind":"linear","start":[80,200],"end":[240,200],"stops":[
|
||||
{"offset":0,"color":"#ffffff","opacity":opacity},{"offset":1,"color":"#808080","opacity":opacity}
|
||||
]}}),
|
||||
)
|
||||
.unwrap();
|
||||
let live = render(doc(&s));
|
||||
if opacity == 1.0 {
|
||||
for y in (175..225).step_by(5) {
|
||||
for x in (75..190).step_by(5) {
|
||||
let a = solid.pixel(x, y);
|
||||
let b = live.pixel(x, y);
|
||||
if a[3] == 255 {
|
||||
let factor = 1.0 - ((f64::from(x) + 0.5 - 80.0) / 160.0).clamp(0.0, 1.0) * (1.0 - 128.0 / 255.0);
|
||||
for c in 0..3 {
|
||||
let expected = (f64::from(a[c]) * factor).round() as u8;
|
||||
assert!(b[c].abs_diff(expected) <= 4, "({x},{y}) {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
assert!(live.pixels.as_chunks::<4>().0.iter().filter(|p| p[3] > 30 && p[3] < 220).count() > 1000);
|
||||
}
|
||||
let svg = vector_exports(&mut s);
|
||||
assert!(svg.contains("screen") && svg.contains("multiply"));
|
||||
let imported = vectorcraft_svg::import(&svg).unwrap();
|
||||
let roundtrip = render(&imported);
|
||||
for y in (150..250).step_by(13) {
|
||||
for x in (90..185).step_by(13) {
|
||||
let a = live.pixel(x, y);
|
||||
let b = roundtrip.pixel(x, y);
|
||||
assert!(a.into_iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 8), "SVG {a:?} {b:?}");
|
||||
}
|
||||
}
|
||||
let before = doc(&s).clone();
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
assert!(doc(&s).node(id).unwrap().appearance.effects.is_empty());
|
||||
if opacity < 1.0 {
|
||||
assert_eq!(live.pixels, render(doc(&s)).pixels);
|
||||
}
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(doc(&s), &before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inflate_lighting_has_no_polygon_steps_and_stays_vector_after_expansion() {
|
||||
// Check the export pipeline's continuous shading. The 8-bit canvas is also reviewed
|
||||
// natively at high zoom; it can quantize a short gradient ramp differently at 1:1.
|
||||
let render = |d: &Document| {
|
||||
vectorcraft_render::Renderer::new().render(
|
||||
d,
|
||||
400,
|
||||
400,
|
||||
kurbo::Affine::IDENTITY,
|
||||
&vectorcraft_render::RenderOptions { background: None, artboards: false, precise: true, supersample: true, ..Default::default() },
|
||||
)
|
||||
};
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":400,"height":400})).unwrap();
|
||||
s.execute("path.create", &json!({"d":"M300 200C300 255.228475 255.228475 300 200 300C144.771525 300 100 255.228475 100 200C100 144.771525 144.771525 100 200 100C255.228475 100 300 144.771525 300 200Z"})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color":"#ffffff"})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none":true})).unwrap();
|
||||
s.execute(
|
||||
"effect.apply",
|
||||
&json!({"effect":"threeD.inflate","item":null,"params":{
|
||||
"rotationX":0,"rotationY":0,"rotationZ":0,"ambient":10,"volume":50,"lights":[
|
||||
{"color":"#ff0000","rotation":135,"height":35,"intensity":70},
|
||||
{"color":"#0000ff","rotation":-35,"height":25,"intensity":65}
|
||||
]
|
||||
}}),
|
||||
)
|
||||
.unwrap();
|
||||
let live = render(doc(&s));
|
||||
for y in [175, 200, 225] {
|
||||
for x in 145..255 {
|
||||
let a = live.pixel(x, y);
|
||||
let b = live.pixel(x + 1, y);
|
||||
assert_eq!(a[3], 255, "interior seam at ({x},{y})");
|
||||
assert!(a.into_iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 3), "polygon band at ({x},{y}): {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
let svg = s.execute("document.serialize", &json!({"format":"svg"})).unwrap()["text"].as_str().unwrap().to_owned();
|
||||
assert!(svg.contains("linearGradient") && svg.contains("screen") && !svg.contains("<image"));
|
||||
let roundtrip = render(&vectorcraft_svg::import(&svg).unwrap());
|
||||
for y in (110..290).step_by(9) {
|
||||
for x in (110..290).step_by(9) {
|
||||
let a = live.pixel(x, y);
|
||||
let b = roundtrip.pixel(x, y);
|
||||
assert!(a.into_iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 6), "batched SVG ({x},{y}): {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
let before = doc(&s).clone();
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
assert_eq!(render(doc(&s)).pixels, live.pixels);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(doc(&s), &before);
|
||||
// A colored source gradient still multiplies the light, keeping stop transparency.
|
||||
s.execute(
|
||||
"paint.setFill",
|
||||
&json!({"gradient":{"kind":"linear","start":[100,200],"end":[300,200],"stops":[
|
||||
{"offset":0,"color":"#ffffff","opacity":0.6},{"offset":1,"color":"#808080","opacity":0.6}
|
||||
]}}),
|
||||
)
|
||||
.unwrap();
|
||||
let live = render(doc(&s));
|
||||
assert!((150..=156).contains(&live.pixel(200, 200)[3]), "surface opacity is applied once: {:?}", live.pixel(200, 200));
|
||||
let svg = vector_exports(&mut s);
|
||||
assert!(svg.contains("linearGradient") && svg.contains("screen") && svg.contains("multiply"));
|
||||
let imported = vectorcraft_svg::import(&svg).unwrap();
|
||||
let roundtrip = render(&imported);
|
||||
for y in (155..245).step_by(9) {
|
||||
for x in (155..245).step_by(9) {
|
||||
let a = live.pixel(x, y);
|
||||
let b = roundtrip.pixel(x, y);
|
||||
assert!(a.into_iter().zip(b).all(|(a, b)| a.abs_diff(b) <= 6), "SVG ({x},{y}): {a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
let before = doc(&s).clone();
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
assert_eq!(render(doc(&s)).pixels, live.pixels);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(doc(&s), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_gradient_opacity_is_applied_once_even_without_shading() {
|
||||
for effect in &EFFECTS[1..] {
|
||||
let (mut s, _) = setup(effect);
|
||||
s.execute("effect.setParams", &json!({"index":0,"item":null,"params":{"shade":false,"lightColor":"#ff8040"}})).unwrap();
|
||||
let opaque = render(doc(&s));
|
||||
s.execute(
|
||||
"paint.setFill",
|
||||
&json!({"gradient":{"kind":"linear","start":[100,150],"end":[200,150],"stops":[
|
||||
{"offset":0,"color":"#ffffff","opacity":0.6},{"offset":1,"color":"#808080","opacity":0.6}
|
||||
]}}),
|
||||
)
|
||||
.unwrap();
|
||||
let image = render(doc(&s));
|
||||
for y in (120..180).step_by(5) {
|
||||
for x in (120..180).step_by(5) {
|
||||
let alpha = image.pixel(x, y)[3];
|
||||
// Preserve the canvas pipeline's edge coverage, then apply source opacity
|
||||
// once. Subpixel seams can have less than full coverage in the 8-bit canvas.
|
||||
let expected = (f32::from(opaque.pixel(x, y)[3]) * 0.6).round() as u8;
|
||||
assert!(alpha.abs_diff(expected) <= 3, "{effect} ({x},{y}): alpha {alpha}, expected {expected}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn batched_colored_inflate_preserves_compound_holes_and_surface_opacity() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width":400,"height":400})).unwrap();
|
||||
s.execute("path.create", &json!({"d":"M80 80H320V320H80ZM150 150V250H250V150Z"})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color":"#ffffff"})).unwrap();
|
||||
s.execute("transparency.set", &json!({"item":0,"opacity":60})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none":true})).unwrap();
|
||||
s.execute(
|
||||
"effect.apply",
|
||||
&json!({"effect":"threeD.inflate","item":null,"params":{
|
||||
"rotationX":0,"rotationY":0,"rotationZ":0,"lights":[{"color":"#ff0000"},{"color":"#0000ff"}]
|
||||
}}),
|
||||
)
|
||||
.unwrap();
|
||||
let image = render(doc(&s));
|
||||
assert_eq!(image.pixel(200, 200)[3], 0, "the compound coverage clip preserves holes");
|
||||
assert!((150..=156).contains(&image.pixel(110, 200)[3]), "surface opacity applies once: {:?}", image.pixel(110, 200));
|
||||
s.execute("effect.expandAppearance", &json!({})).unwrap();
|
||||
assert_eq!(image.pixels, render(doc(&s)).pixels);
|
||||
}
|
||||
@@ -11,8 +11,14 @@ mod visibility;
|
||||
pub use solid::{Bevel, BevelShape, Extrude, Facet, Inflate, View, extrude, inflate, plane};
|
||||
pub use visibility::{VisibleFace, visible_faces};
|
||||
mod shading;
|
||||
pub use shading::ShadeGradient;
|
||||
pub use shading::{LightGradient, RgbGradient, ShadeGradient};
|
||||
mod lights;
|
||||
pub use lights::{Light, Lights, MAX_LIGHTS};
|
||||
mod caps;
|
||||
#[cfg(test)]
|
||||
mod tests_lighting;
|
||||
#[cfg(test)]
|
||||
mod tests_multiple_lights;
|
||||
|
||||
use vectorcraft_geom::{BezPath, ParamCurve, PathData, Point, Rect, Vec2, kurbo::ParamCurveDeriv};
|
||||
|
||||
@@ -67,9 +73,13 @@ pub struct Revolve {
|
||||
pub light_height: f64,
|
||||
/// The light is behind the object (Move light to the back of object) rather than in front.
|
||||
pub light_behind: bool,
|
||||
/// Opaque RGB light color, each channel 0..1. Ambient light stays neutral.
|
||||
pub light_color: [f64; 3],
|
||||
pub light_intensity: f64,
|
||||
/// Softness, 0 (a sharp fall-off) to 1: how far round the object the light reaches.
|
||||
pub light_softness: f64,
|
||||
/// Explicit light list. None retains the earlier single-light parameters exactly.
|
||||
pub lights: Option<Lights>,
|
||||
/// Ambient Light, 0 to 2.
|
||||
pub ambient: f64,
|
||||
pub shade: bool,
|
||||
@@ -98,8 +108,10 @@ impl Default for Revolve {
|
||||
light_rotation: DEFAULT_LIGHT.0,
|
||||
light_height: DEFAULT_LIGHT.1,
|
||||
light_behind: false,
|
||||
light_color: [1.0; 3],
|
||||
light_intensity: 0.8,
|
||||
light_softness: 0.0,
|
||||
lights: None,
|
||||
ambient: 0.25,
|
||||
shade: true,
|
||||
smooth_shading: true,
|
||||
@@ -156,11 +168,48 @@ fn brightness(o: &Revolve, n: V3, l: V3) -> f64 {
|
||||
if !o.shade {
|
||||
return 1.0;
|
||||
}
|
||||
if o.lights.is_some() {
|
||||
let rgb = illumination(o, n, l);
|
||||
return (rgb[0] + rgb[1] + rgb[2]) / 3.0;
|
||||
}
|
||||
let s = o.light_softness;
|
||||
let lit = ((n.dot(l) + s) / (1.0 + s)).max(0.0);
|
||||
(o.ambient + o.light_intensity * lit).clamp(0.0, 1.0)
|
||||
}
|
||||
|
||||
fn direct_light(o: &Revolve, n: V3, l: V3) -> f64 {
|
||||
o.light_intensity * ((n.dot(l) + o.light_softness) / (1.0 + o.light_softness)).max(0.0)
|
||||
}
|
||||
|
||||
fn illumination(o: &Revolve, n: V3, l: V3) -> [f64; 3] {
|
||||
if !o.shade {
|
||||
return [1.0; 3];
|
||||
}
|
||||
if o.lights.is_some() {
|
||||
return unclipped_illumination(o, n, l).map(|v| v.clamp(0.0, 1.0));
|
||||
}
|
||||
let direct = direct_light(o, n, l);
|
||||
o.light_color.map(|c| (o.ambient + direct * c).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
fn unclipped_illumination(o: &Revolve, n: V3, legacy_light: V3) -> [f64; 3] {
|
||||
let mut rgb = [o.ambient; 3];
|
||||
if let Some(lights) = o.lights {
|
||||
for light in lights.iter() {
|
||||
let direct = light.direct(n);
|
||||
for (out, color) in rgb.iter_mut().zip(light.color) {
|
||||
*out += direct * color;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let direct = direct_light(o, n, legacy_light);
|
||||
for (out, color) in rgb.iter_mut().zip(o.light_color) {
|
||||
*out += direct * color;
|
||||
}
|
||||
}
|
||||
rgb
|
||||
}
|
||||
|
||||
fn finite(v: f64, fallback: f64, min: f64, max: f64) -> f64 {
|
||||
if v.is_finite() { v.clamp(min, max) } else { fallback }
|
||||
}
|
||||
@@ -255,6 +304,7 @@ impl Revolve {
|
||||
light_height: finite(self.light_height, DEFAULT_LIGHT.1, 0.0, 90.0),
|
||||
light_intensity: finite(self.light_intensity, 0.8, 0.0, 1.0),
|
||||
light_softness: finite(self.light_softness, 0.0, 0.0, 1.0),
|
||||
light_color: self.light_color.map(|c| finite(c, 1.0, 0.0, 1.0)),
|
||||
ambient: finite(self.ambient, 0.25, 0.0, 2.0),
|
||||
..self
|
||||
}
|
||||
@@ -277,6 +327,10 @@ pub struct Face {
|
||||
pub depth: f64,
|
||||
pub brightness: f64,
|
||||
pub gradient: Option<ShadeGradient>,
|
||||
/// RGB illumination and its unclipped smooth ramp, independent of the legacy white ramp.
|
||||
pub illumination: [f64; 3],
|
||||
pub light_gradient: Option<LightGradient>,
|
||||
pub rgb_gradient: Option<RgbGradient>,
|
||||
/// Curved vector boundary; points remain a bounded visibility approximation.
|
||||
pub outline: Option<PathData>,
|
||||
/// A single endpoint disk or sector, with its axis centre first in points.
|
||||
@@ -356,11 +410,38 @@ fn project_face(vertices: &[V3], normals: Option<&[V3]>, o: Revolve, origin: Poi
|
||||
} else {
|
||||
(flat_brightness, None)
|
||||
};
|
||||
let (illumination, light_gradient) = if let Some(normals) = normals {
|
||||
let normals: Vec<_> = normals.iter().map(|n| if n.dot(normal) < 0.0 { V3(-n.0, -n.1, -n.2) } else { *n }).collect();
|
||||
let mut rgb = [0.0; 3];
|
||||
for n in &normals {
|
||||
for (out, value) in rgb.iter_mut().zip(illumination(&o, *n, light)) {
|
||||
*out += value;
|
||||
}
|
||||
}
|
||||
let rgb = if normals.is_empty() { illumination(&o, normal, light) } else { rgb.map(|v| v / normals.len() as f64) };
|
||||
let ramp = if o.shade && o.lights.is_none() && o.light_color != [1.0; 3] {
|
||||
let values: Vec<_> = normals.iter().map(|n| direct_light(&o, *n, light)).collect();
|
||||
ShadeGradient::triangle(&points, &values).map(|diffuse| LightGradient { diffuse, ambient: o.ambient, color: o.light_color })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(rgb, ramp)
|
||||
} else {
|
||||
(illumination(&o, normal, light), None)
|
||||
};
|
||||
let rgb_gradient = normals.filter(|_| o.shade && o.lights.is_some()).and_then(|normals| {
|
||||
let values: Vec<_> =
|
||||
normals.iter().map(|n| unclipped_illumination(&o, if n.dot(normal) < 0.0 { V3(-n.0, -n.1, -n.2) } else { *n }, light)).collect();
|
||||
RgbGradient::triangle(&points, &values)
|
||||
});
|
||||
Ok(Some(Face {
|
||||
points,
|
||||
depth: vertices.iter().map(|v| v.2).sum::<f64>() / vertices.len() as f64,
|
||||
brightness,
|
||||
gradient,
|
||||
illumination,
|
||||
light_gradient,
|
||||
rgb_gradient,
|
||||
outline: None,
|
||||
cap: false,
|
||||
visibility_pieces: None,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Bounded directional lights, shared by all four effects and independent of the UI.
|
||||
use crate::{DEFAULT_LIGHT, V3, finite};
|
||||
|
||||
pub const MAX_LIGHTS: usize = 10;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Light {
|
||||
pub rotation: f64,
|
||||
pub height: f64,
|
||||
pub behind: bool,
|
||||
pub color: [f64; 3],
|
||||
pub intensity: f64,
|
||||
pub softness: f64,
|
||||
}
|
||||
|
||||
impl Default for Light {
|
||||
fn default() -> Self {
|
||||
Self { rotation: DEFAULT_LIGHT.0, height: DEFAULT_LIGHT.1, behind: false, color: [1.0; 3], intensity: 0.8, softness: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
impl Light {
|
||||
pub(crate) fn sanitized(self) -> Self {
|
||||
Self {
|
||||
rotation: (finite(self.rotation, DEFAULT_LIGHT.0, -1e6, 1e6) + 180.0).rem_euclid(360.0) - 180.0,
|
||||
height: finite(self.height, DEFAULT_LIGHT.1, 0.0, 90.0),
|
||||
color: self.color.map(|c| finite(c, 1.0, 0.0, 1.0)),
|
||||
intensity: finite(self.intensity, 0.8, 0.0, 1.0),
|
||||
softness: finite(self.softness, 0.0, 0.0, 1.0),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn direction(self) -> [f64; 3] {
|
||||
let light = self.sanitized();
|
||||
let (sr, cr) = light.rotation.to_radians().sin_cos();
|
||||
let (sh, ch) = light.height.to_radians().sin_cos();
|
||||
[ch * cr, -ch * sr, if light.behind { -sh } else { sh }]
|
||||
}
|
||||
|
||||
pub(crate) fn direct(self, normal: V3) -> f64 {
|
||||
let [x, y, z] = self.direction();
|
||||
self.intensity * ((normal.dot(V3(x, y, z)) + self.softness) / (1.0 + self.softness)).max(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// A fixed capacity avoids unbounded work from files or command input. Construction validates
|
||||
/// the count and sanitizes every light, including callers of the portable geometry API.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Lights {
|
||||
entries: [Option<Light>; MAX_LIGHTS],
|
||||
}
|
||||
|
||||
impl Lights {
|
||||
pub fn new(lights: &[Light]) -> Result<Self, String> {
|
||||
if lights.is_empty() || lights.len() > MAX_LIGHTS {
|
||||
return Err("3D effects require one to ten lights".into());
|
||||
}
|
||||
let mut entries = [None; MAX_LIGHTS];
|
||||
for (slot, light) in entries.iter_mut().zip(lights) {
|
||||
*slot = Some(light.sanitized());
|
||||
}
|
||||
Ok(Self { entries })
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = &Light> {
|
||||
self.entries.iter().flatten()
|
||||
}
|
||||
}
|
||||
@@ -41,3 +41,83 @@ impl ShadeGradient {
|
||||
self.brightness[0] + (self.brightness[1] - self.brightness[0]) * t
|
||||
}
|
||||
}
|
||||
|
||||
/// A directional-light ramp before channel clipping. Keep ambient light neutral and add
|
||||
/// stops where individual RGB channels saturate, so colored highlights retain their hue.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct LightGradient {
|
||||
pub diffuse: ShadeGradient,
|
||||
pub ambient: f64,
|
||||
pub color: [f64; 3],
|
||||
}
|
||||
|
||||
impl LightGradient {
|
||||
pub fn at(self, p: Point) -> [f64; 3] {
|
||||
self.factors(self.diffuse.at(p))
|
||||
}
|
||||
|
||||
fn factors(self, direct: f64) -> [f64; 3] {
|
||||
self.color.map(|c| (self.ambient + direct * c).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// At most five ordinary linear-gradient stops, including channel clipping points.
|
||||
pub fn stops(self) -> Vec<(f32, [f64; 3])> {
|
||||
let [lo, hi] = self.diffuse.brightness;
|
||||
let span = hi - lo;
|
||||
let mut positions = vec![0.0f64, 1.0];
|
||||
for c in self.color {
|
||||
if c > 0.0 && span > 0.0 {
|
||||
let t = ((1.0 - self.ambient) / c - lo) / span;
|
||||
if t > 0.0 && t < 1.0 {
|
||||
positions.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
positions.sort_by(f64::total_cmp);
|
||||
positions.dedup_by(|a, b| (*a - *b).abs() < 1e-10);
|
||||
positions.into_iter().map(|t| (t as f32, self.factors(lo + span * t))).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn is_finite(self) -> bool {
|
||||
[
|
||||
self.diffuse.start.x,
|
||||
self.diffuse.start.y,
|
||||
self.diffuse.end.x,
|
||||
self.diffuse.end.y,
|
||||
self.diffuse.brightness[0],
|
||||
self.diffuse.brightness[1],
|
||||
self.ambient,
|
||||
]
|
||||
.into_iter()
|
||||
.chain(self.color)
|
||||
.all(f64::is_finite)
|
||||
}
|
||||
}
|
||||
|
||||
/// Independent affine RGB channels. Different light directions need different gradient axes;
|
||||
/// interpolating a single luminance ramp would erase their color across a smooth surface.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct RgbGradient {
|
||||
pub channels: [ShadeGradient; 3],
|
||||
}
|
||||
|
||||
impl RgbGradient {
|
||||
pub(crate) fn triangle(points: &[Point], values: &[[f64; 3]]) -> Option<Self> {
|
||||
let [p, _, _] = points else { return None };
|
||||
let [first, _, _] = values else { return None };
|
||||
Some(Self {
|
||||
channels: std::array::from_fn(|channel| {
|
||||
let values: Vec<_> = values.iter().map(|v| v[channel]).collect();
|
||||
ShadeGradient::triangle(points, &values).unwrap_or(ShadeGradient { start: *p, end: *p, brightness: [first[channel]; 2] })
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn at(self, p: Point) -> [f64; 3] {
|
||||
self.channels.map(|g| g.at(p).clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
pub(crate) fn is_finite(self) -> bool {
|
||||
self.channels.iter().all(|g| [g.start.x, g.start.y, g.end.x, g.end.y, g.brightness[0], g.brightness[1]].into_iter().all(f64::is_finite))
|
||||
}
|
||||
}
|
||||
|
||||
+182
-10
@@ -3,17 +3,17 @@
|
||||
//! The 2D art is the front of the solid: its outline is extruded backwards along the view's
|
||||
//! z-axis, about the centre of its bounds, at the art's own position and size. Planar faces (caps
|
||||
//! and the rotated plane) are drawn whole, holes included, so no triangulation is needed; curved
|
||||
//! faces (bevels, side walls under twist or taper, inflated surfaces) are cut into small facets
|
||||
//! and flat shaded, like Revolve.
|
||||
//! faces are cut into small facets. Colored Inflate interpolates shared membrane normals;
|
||||
//! bevels and side walls retain their planar lighting.
|
||||
//!
|
||||
//! Visibility is a painter's algorithm, as for Revolve: faces of a closed solid that face away
|
||||
//! from the viewer are left out, the rest are drawn back to front.
|
||||
|
||||
use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect};
|
||||
use vectorcraft_geom::{BezPath, FillRule, ParamCurve, PathData, Point, Rect, Vec2};
|
||||
|
||||
use crate::{MAX_FACES, Revolve, V3, eye_distance, perspective_scale};
|
||||
|
||||
/// The view shared by every 3D effect: rotation, perspective and one directional light.
|
||||
/// The view shared by every 3D effect: rotation, perspective and directional lighting.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct View {
|
||||
pub rotation_x: f64,
|
||||
@@ -24,8 +24,10 @@ pub struct View {
|
||||
pub light_rotation: f64,
|
||||
pub light_height: f64,
|
||||
pub light_behind: bool,
|
||||
pub light_color: [f64; 3],
|
||||
pub light_intensity: f64,
|
||||
pub light_softness: f64,
|
||||
pub lights: Option<crate::Lights>,
|
||||
pub ambient: f64,
|
||||
pub shade: bool,
|
||||
}
|
||||
@@ -41,8 +43,10 @@ impl Default for View {
|
||||
light_rotation: r.light_rotation,
|
||||
light_height: r.light_height,
|
||||
light_behind: r.light_behind,
|
||||
light_color: r.light_color,
|
||||
light_intensity: r.light_intensity,
|
||||
light_softness: r.light_softness,
|
||||
lights: r.lights,
|
||||
ambient: r.ambient,
|
||||
shade: r.shade,
|
||||
}
|
||||
@@ -50,6 +54,12 @@ impl Default for View {
|
||||
}
|
||||
|
||||
impl View {
|
||||
/// Whether the active light setup needs RGB shading rather than neutral brightness.
|
||||
pub fn has_colored_lights(self) -> bool {
|
||||
let view = self.revolve();
|
||||
view.lights.map_or(view.light_color != [1.0; 3], |lights| lights.iter().any(|light| light.color != [1.0; 3]))
|
||||
}
|
||||
|
||||
/// The same rotation, projection and lighting maths as Revolve's.
|
||||
fn revolve(self) -> Revolve {
|
||||
Revolve {
|
||||
@@ -60,8 +70,10 @@ impl View {
|
||||
light_rotation: self.light_rotation,
|
||||
light_height: self.light_height,
|
||||
light_behind: self.light_behind,
|
||||
light_color: self.light_color,
|
||||
light_intensity: self.light_intensity,
|
||||
light_softness: self.light_softness,
|
||||
lights: self.lights,
|
||||
ambient: self.ambient,
|
||||
shade: self.shade,
|
||||
..Revolve::default()
|
||||
@@ -205,12 +217,15 @@ impl Default for Inflate {
|
||||
}
|
||||
}
|
||||
|
||||
/// One projected, flat-shaded polygon. Holes are contours of their own (nonzero winding).
|
||||
/// One projected polygon. Holes are contours of their own (nonzero winding).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Facet {
|
||||
pub contours: Vec<Vec<Point>>,
|
||||
pub depth: f64,
|
||||
pub brightness: f64,
|
||||
pub illumination: [f64; 3],
|
||||
/// Smooth lighting on an inflated triangle, before clipping individual RGB channels.
|
||||
pub rgb_gradient: Option<crate::RgbGradient>,
|
||||
/// The contours in view space, before projection, for tests that measure depths.
|
||||
#[cfg(test)]
|
||||
view: Vec<Vec<V3>>,
|
||||
@@ -520,6 +535,8 @@ impl Builder<'_> {
|
||||
contours,
|
||||
depth,
|
||||
brightness: self.camera.brightness(n),
|
||||
illumination: crate::illumination(&self.camera.view, n, self.camera.light),
|
||||
rgb_gradient: None,
|
||||
#[cfg(test)]
|
||||
view,
|
||||
});
|
||||
@@ -529,6 +546,30 @@ impl Builder<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A planar surface triangle with shared, smooth outward normals. Culling and sorting
|
||||
/// still use its geometry, rather than the normals used only for lighting.
|
||||
fn smooth_triangle(&mut self, vertices: [V3; 3], normals: [V3; 3], back: bool) -> Result<(), String> {
|
||||
let contours = vec![vertices.to_vec()];
|
||||
let n = newell(&contours);
|
||||
let n = if back { V3(-n.0, -n.1, -n.2) } else { n };
|
||||
let centre = vertices.iter().fold(V3::default(), |s, v| V3(s.0 + v.0 / 3.0, s.1 + v.1 / 3.0, s.2 + v.2 / 3.0));
|
||||
let flip = !self.closed && self.camera.facing(centre, n.unit()) <= 0.0;
|
||||
let before = self.facets.len();
|
||||
self.face(contours, n, Order::Depth)?;
|
||||
if self.facets.len() == before || !self.camera.view.shade {
|
||||
return Ok(());
|
||||
}
|
||||
let values: Vec<_> = normals
|
||||
.into_iter()
|
||||
.map(|n| crate::unclipped_illumination(&self.camera.view, if flip { V3(-n.0, -n.1, -n.2) } else { n }, self.camera.light))
|
||||
.collect();
|
||||
if let Some(face) = self.facets.last_mut() {
|
||||
let points = face.contours.first().map(Vec::as_slice).unwrap_or_default();
|
||||
face.rgb_gradient = crate::RgbGradient::triangle(points, &values);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The band between two partner rings (same length), as one quad per edge, or for a twisted
|
||||
/// wall with `cuts` (see [`cuts`]) as `cuts[i]` quads along edge `i`. `outward` is the
|
||||
/// direction (before rotation) the band faces away from the solid, for orienting normals.
|
||||
@@ -844,6 +885,7 @@ fn edge_distance(rings: &[Vec<Point>], p: Point) -> f64 {
|
||||
/// found among the buckets around it rather than among all the edges.
|
||||
struct Edges {
|
||||
edges: Vec<(Point, Point)>,
|
||||
normals: Vec<(Vec2, Vec2)>,
|
||||
origin: Point,
|
||||
size: f64,
|
||||
n: usize,
|
||||
@@ -855,6 +897,20 @@ impl Edges {
|
||||
const N: usize = 64;
|
||||
let edges: Vec<(Point, Point)> =
|
||||
rings.iter().flat_map(|r| r.iter().zip(r.iter().cycle().skip(1)).take(r.len()).map(|(a, b)| (*a, *b))).collect();
|
||||
let normals = rings
|
||||
.iter()
|
||||
.flat_map(|r| {
|
||||
let normal = |i: usize| {
|
||||
let (x, y) = left(r[i], r[(i + 1) % r.len()]);
|
||||
Vec2::new(x, y)
|
||||
};
|
||||
(0..r.len()).map(move |i| {
|
||||
let n = normal(i);
|
||||
let at = |other: Vec2| if n.dot(other) > 0.7 { (n + other).normalize() } else { n };
|
||||
(at(normal((i + r.len() - 1) % r.len())), at(normal((i + 1) % r.len())))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let bounds = edges.iter().fold(None, |b: Option<Rect>, (a, c)| Some(b.map_or(Rect::from_points(*a, *c), |b| b.union_pt(*a).union_pt(*c))));
|
||||
let bounds = bounds.unwrap_or(Rect::ZERO);
|
||||
let size = (bounds.width().max(bounds.height()) / N as f64).max(1e-9);
|
||||
@@ -869,7 +925,7 @@ impl Edges {
|
||||
}
|
||||
}
|
||||
}
|
||||
Self { edges, origin: bounds.origin(), size, n: N, buckets }
|
||||
Self { edges, normals, origin: bounds.origin(), size, n: N, buckets }
|
||||
}
|
||||
|
||||
/// The bucket row or column of coordinate `v`, from the grid's origin `o` on that axis.
|
||||
@@ -928,15 +984,22 @@ impl Edges {
|
||||
/// The distance from `p` to the nearest edge: the buckets are searched in growing squares
|
||||
/// around `p`'s, until no unsearched bucket could hold a nearer edge.
|
||||
fn distance(&self, p: Point) -> f64 {
|
||||
self.closest(p).2
|
||||
}
|
||||
|
||||
/// Closest outline point, its inward normal and distance. Curved outline samples share
|
||||
/// corner normals, while a sharp source corner keeps each edge's own direction.
|
||||
fn closest(&self, p: Point) -> (Point, Vec2, f64) {
|
||||
let (fx, fy) = ((p.x - self.origin.x) / self.size, (p.y - self.origin.y) / self.size);
|
||||
if !fx.is_finite() || !fy.is_finite() {
|
||||
return f64::INFINITY;
|
||||
return (p, Vec2::ZERO, f64::INFINITY);
|
||||
}
|
||||
let n = self.n as isize;
|
||||
let (ci, cj) = ((fx.floor() as isize).clamp(0, n - 1), (fy.floor() as isize).clamp(0, n - 1));
|
||||
// How far `p` is outside the grid: the nearest point of the grid to it lies in `p`'s bucket.
|
||||
let outside = (fx.clamp(0.0, n as f64) - fx).hypot(fy.clamp(0.0, n as f64) - fy) * self.size;
|
||||
let mut best = f64::INFINITY;
|
||||
let mut nearest = (p, Vec2::ZERO);
|
||||
for r in 0..n {
|
||||
for j in (cj - r)..=(cj + r) {
|
||||
for i in (ci - r)..=(ci + r) {
|
||||
@@ -945,7 +1008,15 @@ impl Edges {
|
||||
}
|
||||
for k in &self.buckets[(j * n + i) as usize] {
|
||||
if let Some((a, b)) = self.edges.get(*k as usize) {
|
||||
best = best.min(segment_distance(p, *a, *b));
|
||||
let d = *b - *a;
|
||||
let t = if d.hypot2() < 1e-18 { 0.0 } else { ((p - *a).dot(d) / d.hypot2()).clamp(0.0, 1.0) };
|
||||
let q = Point::new(a.x + d.x * t, a.y + d.y * t);
|
||||
let distance = p.distance(q);
|
||||
if distance < best {
|
||||
best = distance;
|
||||
let n = self.normals.get(*k as usize).map_or(Vec2::ZERO, |(a, b)| *a * (1.0 - t) + *b * t);
|
||||
nearest = (q, if n.hypot2() > 0.0 { n.normalize() } else { n });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -956,7 +1027,7 @@ impl Edges {
|
||||
break;
|
||||
}
|
||||
}
|
||||
best
|
||||
(nearest.0, nearest.1, best)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1114,11 +1185,29 @@ impl Membrane {
|
||||
|
||||
/// φ at `p`, interpolated between the four nodes around it.
|
||||
fn at(&self, p: Point) -> f64 {
|
||||
self.interpolate(p, |i, j| self.phi.get(j * self.nx + i).copied().unwrap_or(0.0))
|
||||
}
|
||||
|
||||
/// Interpolate central node derivatives, rather than differentiating each bilinear cell.
|
||||
/// The latter jumps at every grid line and shows the solver's grid in the lighting.
|
||||
fn gradient(&self, p: Point) -> (f64, f64) {
|
||||
let value = |i, j| self.phi.get(j * self.nx + i).copied().unwrap_or(0.0);
|
||||
let dx = self.interpolate(p, |i, j| {
|
||||
let (a, b) = (i.saturating_sub(1), (i + 1).min(self.nx - 1));
|
||||
(value(b, j) - value(a, j)) / ((b - a).max(1) as f64 * self.step)
|
||||
});
|
||||
let dy = self.interpolate(p, |i, j| {
|
||||
let (a, b) = (j.saturating_sub(1), (j + 1).min(self.ny - 1));
|
||||
(value(i, b) - value(i, a)) / ((b - a).max(1) as f64 * self.step)
|
||||
});
|
||||
(dx, dy)
|
||||
}
|
||||
|
||||
fn interpolate(&self, p: Point, v: impl Fn(usize, usize) -> f64) -> f64 {
|
||||
let fx = ((p.x - self.origin.x) / self.step).clamp(0.0, (self.nx - 1) as f64);
|
||||
let fy = ((p.y - self.origin.y) / self.step).clamp(0.0, (self.ny - 1) as f64);
|
||||
let (i, j) = ((fx.floor() as usize).min(self.nx.saturating_sub(2)), (fy.floor() as usize).min(self.ny.saturating_sub(2)));
|
||||
let (tx, ty) = (fx - i as f64, fy - j as f64);
|
||||
let v = |i: usize, j: usize| self.phi.get(j * self.nx + i).copied().unwrap_or(0.0);
|
||||
if self.nx < 2 || self.ny < 2 {
|
||||
return v(0, 0);
|
||||
}
|
||||
@@ -1128,6 +1217,47 @@ impl Membrane {
|
||||
}
|
||||
}
|
||||
|
||||
/// A bounded ear clipping pass on a simple cell contour. Keep its collinear border nodes:
|
||||
/// those shared samples can have different heights once lifted onto the membrane. A tiny
|
||||
/// cell containing a complete hole, or a complex/degenerate contour, keeps its original fill.
|
||||
fn cell_triangles(pieces: &[Vec<Point>]) -> Option<Vec<[Point; 3]>> {
|
||||
let mut triangles = vec![];
|
||||
for ring in pieces {
|
||||
if ring.len() > 128 || signed_area(ring) <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let mut ring = ring.clone();
|
||||
ring.dedup_by(|a, b| a.distance(*b) < 1e-9);
|
||||
if ring.first().zip(ring.last()).is_some_and(|(a, b)| a.distance(*b) < 1e-9) {
|
||||
ring.pop();
|
||||
}
|
||||
while ring.len() > 3 {
|
||||
let ear = (0..ring.len()).find(|&i| {
|
||||
let n = ring.len();
|
||||
let (a, b, c) = (ring[(i + n - 1) % n], ring[i], ring[(i + 1) % n]);
|
||||
if (b - a).cross(c - a) <= 1e-12 {
|
||||
return false;
|
||||
}
|
||||
!ring.iter().enumerate().any(|(j, p)| {
|
||||
j != i
|
||||
&& j != (i + n - 1) % n
|
||||
&& j != (i + 1) % n
|
||||
&& (b - a).cross(*p - a) >= -1e-12
|
||||
&& (c - b).cross(*p - b) >= -1e-12
|
||||
&& (a - c).cross(*p - c) >= -1e-12
|
||||
})
|
||||
})?;
|
||||
let n = ring.len();
|
||||
triangles.push([ring[(ear + n - 1) % n], ring[ear], ring[(ear + 1) % n]]);
|
||||
ring.remove(ear);
|
||||
}
|
||||
if let [a, b, c] = ring.as_slice() {
|
||||
triangles.push([*a, *b, *c]);
|
||||
}
|
||||
}
|
||||
Some(triangles)
|
||||
}
|
||||
|
||||
/// The part of `outline` inside `cell`, as rings (holes running the other way).
|
||||
fn material(outline: &PathData, cell: Rect) -> Option<Vec<Vec<Point>>> {
|
||||
let r = PathData::single(vectorcraft_geom::SubPath::polyline(
|
||||
@@ -1167,6 +1297,24 @@ pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate,
|
||||
}
|
||||
height * (m.at(p).max(0.0) / m.peak).sqrt()
|
||||
};
|
||||
let normal = |p: Point| {
|
||||
let (dx, dy) = m.gradient(p);
|
||||
let (_, inward, distance) = edges.closest(p);
|
||||
let t = (distance / (2.0 * cell)).clamp(0.0, 1.0);
|
||||
let weight = (1.0 - t).powi(2) * (1.0 + 2.0 * t);
|
||||
let gradient = Vec2::new(dx, dy);
|
||||
let slope = gradient.dot(inward).max(0.0);
|
||||
// The zero boundary condition also fixes the tangential derivative at the outline.
|
||||
// Extrapolated outside solver nodes cannot supply it reliably. Blend to that known
|
||||
// direction and its limiting phi near the rim, without showing the grid in highlights.
|
||||
let gradient = gradient * (1.0 - weight) + inward * (slope * weight);
|
||||
let phi = m.at(p).max(0.0) * (1.0 - weight) + slope * distance * weight;
|
||||
// Multiplying the height-function normal by 2√(peak φ) avoids division by
|
||||
// zero at the rim, where the limiting normal is horizontal.
|
||||
let n = V3(-height * gradient.x, -height * gradient.y, 2.0 * (m.peak * phi).sqrt()).unit();
|
||||
if n.dot(n) > 0.5 { n } else { V3(0.0, 0.0, 1.0) }
|
||||
};
|
||||
let smooth = view.shade && view.has_colored_lights();
|
||||
let camera = Camera::new(view, bounds, o.depth + 2.0 * height);
|
||||
// Without its back cap the object is a shell open behind, its inside showing.
|
||||
let mut b = Builder { camera: &camera, closed: o.cap, facets: vec![] };
|
||||
@@ -1289,6 +1437,26 @@ pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate,
|
||||
out
|
||||
})
|
||||
.collect();
|
||||
if height > 0.0
|
||||
&& smooth
|
||||
&& let Some(triangles) = cell_triangles(&pieces)
|
||||
{
|
||||
for points in triangles {
|
||||
let front = points.map(|q| camera.place(q, half + lift(q)));
|
||||
b.smooth_triangle(front, points.map(|q| camera.rotate(normal(q))), false)?;
|
||||
if o.both_sides && o.cap {
|
||||
let back = points.map(|q| camera.place(back_turn.apply(q), -half - lift(q) * back_turn.scale));
|
||||
let normals = points.map(|q| {
|
||||
let n = normal(q);
|
||||
// A uniform taper scales all three coordinates equally;
|
||||
// twist turns the back normal about the shape's z-axis.
|
||||
camera.rotate(V3(back_turn.cos * n.0 - back_turn.sin * n.1, back_turn.sin * n.0 + back_turn.cos * n.1, -n.2))
|
||||
});
|
||||
b.smooth_triangle(back, normals, true)?;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let lifts: Vec<Vec<f64>> = pieces.iter().map(|p| p.iter().map(|q| lift(*q)).collect()).collect();
|
||||
// Each piece lifted onto the surface. Its normal is the lifted piece's own average
|
||||
// (Newell's), not the slope at one point: near the edge the surface turns
|
||||
@@ -1340,3 +1508,7 @@ pub fn inflate(path: &PathData, rule: FillRule, bounds: Rect, options: Inflate,
|
||||
#[cfg(test)]
|
||||
#[path = "tests_solid.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests_inflate_shading.rs"]
|
||||
mod tests_inflate_shading;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
use super::*;
|
||||
use crate::{Light, Lights};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use vectorcraft_geom::kurbo::{Circle, Shape};
|
||||
|
||||
#[test]
|
||||
fn explicit_single_lights_match_legacy_controls_and_white_keeps_polygon_shading() {
|
||||
let mut curve = BezPath::new();
|
||||
curve.extend(Circle::new((100.0, 100.0), 50.0).path_elements(0.01));
|
||||
let path = PathData::from_bezpath(&curve);
|
||||
let bounds = path.bounds().unwrap();
|
||||
for (color, softness, behind) in [([1.0; 3], 0.0, false), ([1.0, 0.3, 0.1], 0.6, false), ([0.1, 0.4, 1.0], 0.6, true)] {
|
||||
let view =
|
||||
View { light_color: color, light_softness: softness, light_behind: behind, rotation_x: -25.0, rotation_y: 35.0, ..Default::default() };
|
||||
let light = Light { color, softness, behind, ..Default::default() };
|
||||
let single = inflate(&path, FillRule::NonZero, bounds, Inflate::default(), view).unwrap();
|
||||
let explicit =
|
||||
inflate(&path, FillRule::NonZero, bounds, Inflate::default(), View { lights: Some(Lights::new(&[light]).unwrap()), ..view }).unwrap();
|
||||
assert_eq!(single.len(), explicit.len());
|
||||
let mut lit = false;
|
||||
for (a, b) in single.iter().zip(explicit) {
|
||||
assert_eq!(a.contours, b.contours);
|
||||
if color == [1.0; 3] {
|
||||
assert!(a.rgb_gradient.is_none() && b.rgb_gradient.is_none());
|
||||
assert!((a.brightness - b.brightness).abs() < 1e-10);
|
||||
lit |= a.brightness > view.ambient + 0.1;
|
||||
continue;
|
||||
}
|
||||
let (Some(a), Some(b)) = (a.rgb_gradient, b.rgb_gradient) else { continue };
|
||||
for p in [a.channels[0].start, a.channels[0].end] {
|
||||
let rgb = a.at(p);
|
||||
assert!(rgb.into_iter().zip(b.at(p)).all(|(a, b)| (a - b).abs() < 1e-10), "single light: {rgb:?}, explicit: {:?}", b.at(p));
|
||||
lit |= rgb.into_iter().any(|v| v > view.ambient + 0.1);
|
||||
}
|
||||
}
|
||||
assert!(lit, "directional illumination must reach the smooth surface");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inflated_disc_has_spherical_lighting_and_continuous_shared_edges() {
|
||||
let mut curve = BezPath::new();
|
||||
curve.extend(Circle::new((100.0, 100.0), 50.0).path_elements(0.01));
|
||||
let path = PathData::from_bezpath(&curve);
|
||||
let lights = Lights::new(&[
|
||||
Light { color: [1.0, 0.0, 0.0], rotation: 135.0, height: 35.0, ..Default::default() },
|
||||
Light { color: [0.0, 0.0, 1.0], rotation: -35.0, height: 25.0, ..Default::default() },
|
||||
])
|
||||
.unwrap();
|
||||
let view = View { lights: Some(lights), ambient: 0.1, ..Default::default() };
|
||||
let camera = Camera::new(view, path.bounds().unwrap(), 100.0);
|
||||
let faces = inflate(&path, FillRule::NonZero, path.bounds().unwrap(), Inflate::default(), view).unwrap();
|
||||
let mut vertices = BTreeMap::<(i64, i64), [f64; 3]>::new();
|
||||
let mut edges = BTreeMap::<[(i64, i64); 2], [f64; 3]>::new();
|
||||
let mut checked = 0;
|
||||
let mut rim = 0;
|
||||
let mut shared = 0;
|
||||
for face in faces {
|
||||
let ramp = face.rgb_gradient.unwrap_or_else(|| panic!("disc cells are smooth triangles: {:?}", face.contours));
|
||||
assert!(ramp.is_finite());
|
||||
let points = &face.contours[0];
|
||||
assert_eq!(points.len(), 3);
|
||||
let key = |p: Point| ((p.x * 1e7).round() as i64, (p.y * 1e7).round() as i64);
|
||||
for &p in points {
|
||||
let rgb = ramp.at(p);
|
||||
if let Some(previous) = vertices.insert(key(p), rgb) {
|
||||
assert!(rgb.into_iter().zip(previous).all(|(a, b)| (a - b).abs() < 1e-7), "vertex {p:?}");
|
||||
}
|
||||
let d = p - Point::new(100.0, 100.0);
|
||||
if d.hypot() < 35.0 {
|
||||
let normal = V3(d.x, d.y, (2500.0 - d.hypot2()).sqrt()).unit();
|
||||
let expected = crate::illumination(&camera.view, normal, camera.light);
|
||||
assert!(rgb.into_iter().zip(expected).all(|(a, b)| (a - b).abs() < 0.025), "{p:?}: {rgb:?} vs {expected:?}");
|
||||
checked += 1;
|
||||
}
|
||||
if (d.hypot() - 50.0).abs() < 0.01 {
|
||||
let expected = crate::illumination(&camera.view, V3(d.x, d.y, 0.0).unit(), camera.light);
|
||||
assert!(rgb.into_iter().zip(expected).all(|(a, b)| (a - b).abs() < 0.025), "rim normal {p:?}: {rgb:?} vs {expected:?}");
|
||||
rim += 1;
|
||||
}
|
||||
}
|
||||
for (a, b) in points.iter().zip(points.iter().cycle().skip(1)).take(3) {
|
||||
let mut edge = [key(*a), key(*b)];
|
||||
edge.sort();
|
||||
let rgb = ramp.at(a.lerp(*b, 0.5));
|
||||
if let Some(previous) = edges.insert(edge, rgb) {
|
||||
assert!(rgb.into_iter().zip(previous).all(|(a, b)| (a - b).abs() < 1e-7), "edge {edge:?}");
|
||||
shared += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(checked > 1000 && shared > 2000 && rim > 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triangulation_keeps_concave_cells_and_every_stitched_border_vertex() {
|
||||
let ring = vec![
|
||||
Point::new(0.0, 0.0),
|
||||
Point::new(1.0, 0.0),
|
||||
Point::new(1.0, 0.5),
|
||||
Point::new(1.0, 1.0),
|
||||
Point::new(0.5, 1.0),
|
||||
Point::new(0.5, 0.5),
|
||||
Point::new(0.0, 0.5),
|
||||
];
|
||||
let triangles = cell_triangles(std::slice::from_ref(&ring)).unwrap();
|
||||
let area: f64 = triangles.iter().map(|p| signed_area(p)).sum();
|
||||
assert!((area - signed_area(&ring)).abs() < 1e-12);
|
||||
let corners: BTreeSet<_> = triangles.iter().flatten().map(|p| (p.x.to_bits(), p.y.to_bits())).collect();
|
||||
assert!(ring.iter().all(|p| corners.contains(&(p.x.to_bits(), p.y.to_bits()))));
|
||||
let mut hole = ring.clone();
|
||||
hole.reverse();
|
||||
assert!(cell_triangles(&[ring, hole]).is_none(), "a hole keeps its original multi-contour fill");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puffed_backs_hollow_shells_and_perspective_keep_finite_smooth_ramps() {
|
||||
let path = PathData::from_bezpath(&BezPath::from_svg("M0 0C80 0 100 140 20 150C-60 160 -70 15 0 0Z").unwrap());
|
||||
let bounds = path.bounds().unwrap();
|
||||
for (both_sides, cap, twist, taper, perspective) in [(true, true, 0.0, 1.0, 0.0), (true, true, 80.0, 0.7, 80.0), (false, false, 0.0, 1.0, 120.0)]
|
||||
{
|
||||
let o = Inflate { both_sides, cap, twist, taper, ..Default::default() };
|
||||
let view = View { light_color: [1.0, 0.3, 0.1], rotation_x: -25.0, rotation_y: 145.0, perspective, ..Default::default() };
|
||||
let faces = inflate(&path, FillRule::NonZero, bounds, o, view).unwrap();
|
||||
assert!(faces.len() < MAX_FACES);
|
||||
assert!(faces.iter().filter_map(|f| f.rgb_gradient).count() > 1000);
|
||||
assert!(faces.iter().filter_map(|f| f.rgb_gradient).all(crate::RgbGradient::is_finite));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn color_combines_with_neutral_ambient_before_channel_clamping() {
|
||||
let n = V3(0.0, 0.0, 1.0);
|
||||
let o = Revolve { light_color: [0.0, 0.5, 1.0], ..Default::default() };
|
||||
assert_eq!(illumination(&o, n, n), [0.25, 0.65, 1.0]);
|
||||
assert_eq!(illumination(&o, V3(0.0, 0.0, -1.0), n), [0.25; 3]);
|
||||
assert_eq!(illumination(&Revolve { light_intensity: 0.0, ..o }, n, n), [0.25; 3]);
|
||||
assert_eq!(illumination(&Revolve { shade: false, ..o }, n, n), [1.0; 3]);
|
||||
assert_eq!(illumination(&Revolve { light_color: [0.0; 3], ..o }, n, n), [0.25; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn white_light_matches_existing_brightness_with_softness_front_and_back() {
|
||||
for softness in [0.0, 0.6, 1.0] {
|
||||
for behind in [false, true] {
|
||||
let o = Revolve { light_softness: softness, light_behind: behind, ..Default::default() };
|
||||
let [x, y, z] = o.light_direction();
|
||||
let l = V3(x, y, z);
|
||||
for angle in [-180.0f64, -90.0, 0.0, 45.0, 90.0, 180.0] {
|
||||
let (s, c) = angle.to_radians().sin_cos();
|
||||
let n = V3(s, 0.0, c);
|
||||
assert_eq!(illumination(&o, n, l), [brightness(&o, n, l); 3]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_geometry_type_produces_finite_rgb_illumination_without_changing_geometry() {
|
||||
let p = PathData::from_bezpath(&BezPath::from_svg("M0 0L100 0L100 100L0 100Z").unwrap());
|
||||
let b = p.bounds().unwrap();
|
||||
let view = View { light_height: 90.0, light_color: [1.0, 0.0, 0.0], ..Default::default() };
|
||||
let shapes = [
|
||||
plane(&p, vectorcraft_geom::FillRule::NonZero, b, view).unwrap(),
|
||||
extrude(&p, vectorcraft_geom::FillRule::NonZero, b, Extrude::default(), view).unwrap(),
|
||||
inflate(&p, vectorcraft_geom::FillRule::NonZero, b, Inflate::default(), view).unwrap(),
|
||||
];
|
||||
for f in shapes.iter().flatten() {
|
||||
assert!(f.illumination[0] >= f.illumination[1]);
|
||||
assert_eq!(f.illumination[1], 0.25);
|
||||
assert_eq!(f.illumination[2], 0.25);
|
||||
}
|
||||
assert_eq!(shapes[0][0].illumination, [1.0, 0.25, 0.25]);
|
||||
let profile = PathData::from_bezpath(&BezPath::from_svg("M0 0L0 100").unwrap());
|
||||
let o = Revolve { offset: 50.0, light_color: [1.0, 0.0, 0.0], ..Default::default() };
|
||||
let red = revolve(&profile, profile.bounds().unwrap(), o).unwrap();
|
||||
let white = revolve(&profile, profile.bounds().unwrap(), Revolve { light_color: [1.0; 3], ..o }).unwrap();
|
||||
assert_eq!(red.len(), white.len());
|
||||
for (r, w) in red.iter().zip(&white) {
|
||||
assert_eq!(r.points, w.points);
|
||||
assert_eq!(r.depth, w.depth);
|
||||
assert_eq!(r.illumination[0], w.brightness);
|
||||
assert_eq!(&r.illumination[1..], &[0.25, 0.25]);
|
||||
}
|
||||
let visible = visible_faces(&red).unwrap();
|
||||
assert!(!visible.is_empty());
|
||||
for f in visible {
|
||||
assert_eq!(f.illumination, [f.brightness, 0.25, 0.25]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_finite_and_out_of_range_light_channels_are_bounded() {
|
||||
let o = Revolve { light_color: [f64::NAN, -10.0, f64::INFINITY], ..Default::default() }.sanitized();
|
||||
assert_eq!(o.light_color, [1.0, 0.0, 1.0]);
|
||||
assert_eq!(illumination(&o, V3(0.0, 0.0, 1.0), V3(0.0, 0.0, 1.0)), [1.0, 0.25, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_color_keeps_channel_saturation_and_shared_edges() {
|
||||
let a = Point::new(0.0, 0.0);
|
||||
let b = Point::new(100.0, 0.0);
|
||||
let c = Point::new(100.0, 100.0);
|
||||
let d = Point::new(0.0, 100.0);
|
||||
let make = |points: &[Point], values: &[f64]| LightGradient {
|
||||
diffuse: ShadeGradient::triangle(points, values).unwrap(),
|
||||
ambient: 0.25,
|
||||
color: [1.0, 0.5, 0.0],
|
||||
};
|
||||
let g = make(&[a, b, c], &[0.0, 2.0, 1.0]);
|
||||
let h = make(&[a, c, d], &[0.0, 1.0, 0.5]);
|
||||
assert_eq!(g.stops().len(), 4, "two distinct channel clipping points");
|
||||
for (t, rgb) in g.stops() {
|
||||
assert_eq!(rgb, g.at(g.diffuse.start.lerp(g.diffuse.end, f64::from(t))));
|
||||
}
|
||||
for t in [0.0, 0.2, 0.5, 0.9, 1.0] {
|
||||
let p = a.lerp(c, t);
|
||||
for (x, y) in g.at(p).into_iter().zip(h.at(p)) {
|
||||
assert!((x - y).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
assert_eq!(g.at(b), [1.0, 1.0, 0.25]);
|
||||
assert_eq!(g.at(a.lerp(b, 0.5)), [1.0, 0.75, 0.25], "clipped red must not flatten green");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colored_smooth_surfaces_keep_caps_curves_and_visibility_placement() {
|
||||
let p = PathData::from_bezpath(&BezPath::from_svg("M40 0 C70 20 70 60 40 80").unwrap());
|
||||
let o = Revolve { offset: 20.0, rotation_x: -25.0, light_color: [1.0, 0.4, 0.1], segments: 24, ..Default::default() };
|
||||
let faces = revolve(&p, p.bounds().unwrap(), o).unwrap();
|
||||
let white = revolve(&p, p.bounds().unwrap(), Revolve { light_color: [1.0; 3], ..o }).unwrap();
|
||||
assert_eq!(faces.len(), white.len());
|
||||
assert!(faces.iter().any(|f| f.light_gradient.is_some()));
|
||||
let caps: Vec<_> = faces.iter().filter(|f| f.cap).collect();
|
||||
assert_eq!(caps.len(), 2);
|
||||
assert!(caps.iter().all(|f| f.outline.is_some() && f.light_gradient.is_none()), "caps remain continuous flat-lit paths");
|
||||
for (red, white) in faces.iter().zip(&white) {
|
||||
assert_eq!(red.path(), white.path());
|
||||
assert_eq!(red.depth, white.depth);
|
||||
assert_eq!(red.gradient, white.gradient, "the legacy white ramp is unchanged");
|
||||
}
|
||||
let visible = visible_faces(&faces).unwrap();
|
||||
assert!(visible.iter().any(|f| f.light_gradient.is_some()));
|
||||
assert!(visible.iter().all(|v| faces.iter().any(|f| f.light_gradient == v.light_gradient && f.illumination == v.illumination)));
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn directional_lights_add_before_saturation_and_ambient_is_added_once() {
|
||||
let red = Light { height: 90.0, color: [1.0, 0.0, 0.0], intensity: 0.7, ..Default::default() };
|
||||
let blue = Light { color: [0.0, 0.0, 1.0], ..red };
|
||||
let o = Revolve { ambient: 0.2, lights: Some(Lights::new(&[red, blue]).unwrap()), ..Default::default() };
|
||||
let n = V3(0.0, 0.0, 1.0);
|
||||
for (actual, expected) in illumination(&o, n, n).into_iter().zip([0.9, 0.2, 0.9]) {
|
||||
assert!((actual - expected).abs() < 1e-12);
|
||||
}
|
||||
assert_eq!(illumination(&o, V3(0.0, 0.0, -1.0), n), [0.2; 3]);
|
||||
assert_eq!(illumination(&Revolve { shade: false, ..o }, n, n), [1.0; 3]);
|
||||
let same = Revolve { lights: Some(Lights::new(&[red, red]).unwrap()), ..o };
|
||||
assert_eq!(illumination(&same, n, n), [1.0, 0.2, 0.2]);
|
||||
let behind = Light { behind: true, ..blue };
|
||||
let opposite = Revolve { lights: Some(Lights::new(&[red, behind]).unwrap()), ..o };
|
||||
assert!(illumination(&opposite, n, n)[2] < 0.21);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_lights_are_bounded_and_sanitized_and_keep_the_legacy_single_light() {
|
||||
assert!(Lights::new(&[]).is_err());
|
||||
assert!(Lights::new(&[Light::default(); MAX_LIGHTS + 1]).is_err());
|
||||
assert_eq!(Lights::new(&[Light::default(); MAX_LIGHTS]).unwrap().iter().count(), MAX_LIGHTS);
|
||||
let invalid = Light { color: [f64::NAN, -1.0, f64::INFINITY], height: f64::NAN, ..Default::default() };
|
||||
assert_eq!(Lights::new(&[invalid]).unwrap().iter().next().unwrap().color, [1.0, 0.0, 1.0]);
|
||||
let o = Revolve { light_color: [0.3, 0.7, 1.0], light_softness: 0.6, ..Default::default() };
|
||||
let one = Light { color: o.light_color, softness: o.light_softness, ..Default::default() };
|
||||
let explicit = Revolve { lights: Some(Lights::new(&[one]).unwrap()), ..o };
|
||||
let [x, y, z] = o.light_direction();
|
||||
let l = V3(x, y, z);
|
||||
for n in [V3(0.0, 0.0, 1.0), V3(1.0, 0.0, 0.0), V3(0.0, -1.0, 0.0)] {
|
||||
assert_eq!(illumination(&o, n, l), illumination(&explicit, n, l));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_rgb_channels_keep_different_axes_and_clip_after_interpolation() {
|
||||
let points = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(0.0, 100.0)];
|
||||
let g = RgbGradient::triangle(&points, &[[0.1, 0.1, 0.2], [1.5, 0.1, 0.2], [0.1, 1.5, 0.2]]).unwrap();
|
||||
assert_eq!(g.at(points[1]), [1.0, 0.1, 0.2]);
|
||||
assert_eq!(g.at(points[2]), [0.1, 1.0, 0.2]);
|
||||
let middle = g.at(Point::new(25.0, 25.0));
|
||||
assert!((middle[0] - 0.45).abs() < 1e-12 && (middle[1] - 0.45).abs() < 1e-12);
|
||||
assert_eq!(middle[2], 0.2);
|
||||
assert!(g.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_lights_preserve_cap_geometry_and_visibility_ramps() {
|
||||
let path = PathData::from_bezpath(&BezPath::from_svg("M40 0C70 20 70 60 40 80").unwrap());
|
||||
let bounds = path.bounds().unwrap();
|
||||
let options = Revolve { offset: 20.0, rotation_x: -25.0, segments: 24, ..Default::default() };
|
||||
let before = revolve(&path, bounds, options).unwrap();
|
||||
let lights = Lights::new(&[Light::default(), Light { rotation: -45.0, color: [0.0, 0.0, 1.0], ..Default::default() }]).unwrap();
|
||||
let after = revolve(&path, bounds, Revolve { lights: Some(lights), ..options }).unwrap();
|
||||
assert_eq!(before.len(), after.len());
|
||||
for (a, b) in before.iter().zip(&after) {
|
||||
assert_eq!(a.path(), b.path());
|
||||
}
|
||||
assert_eq!(after.iter().filter(|f| f.cap).count(), 2);
|
||||
assert!(after.iter().filter(|f| f.cap).all(|f| f.rgb_gradient.is_none()));
|
||||
assert!(after.iter().any(|f| f.rgb_gradient.is_some()));
|
||||
for f in visible_faces(&after).unwrap() {
|
||||
assert!(after.iter().any(|source| source.rgb_gradient == f.rgb_gradient && source.illumination == f.illumination));
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@ pub struct VisibleFace {
|
||||
pub brightness: f64,
|
||||
pub depth: f64,
|
||||
pub gradient: Option<ShadeGradient>,
|
||||
pub illumination: [f64; 3],
|
||||
pub light_gradient: Option<crate::LightGradient>,
|
||||
pub rgb_gradient: Option<crate::RgbGradient>,
|
||||
}
|
||||
|
||||
struct Polygon {
|
||||
@@ -178,6 +181,9 @@ pub fn visible_faces(faces: &[Face]) -> Result<Vec<VisibleFace>, String> {
|
||||
if faces.iter().any(|f| {
|
||||
f.points.len() > 1026
|
||||
|| !f.brightness.is_finite()
|
||||
|| f.illumination.iter().any(|v| !v.is_finite())
|
||||
|| f.light_gradient.is_some_and(|g| !g.is_finite())
|
||||
|| f.rgb_gradient.is_some_and(|g| !g.is_finite())
|
||||
|| !f.depth.is_finite()
|
||||
|| f.gradient.is_some_and(|g| [g.start.x, g.start.y, g.end.x, g.end.y, g.brightness[0], g.brightness[1]].iter().any(|v| !v.is_finite()))
|
||||
|| f.points.iter().any(|p| !p.x.is_finite() || !p.y.is_finite() || p.x.abs().max(p.y.abs()) > 1e8)
|
||||
@@ -256,7 +262,15 @@ pub fn visible_faces(faces: &[Face]) -> Result<Vec<VisibleFace>, String> {
|
||||
return Err("Revolve visibility creates too many vector anchors".into());
|
||||
}
|
||||
let path = if !clipped { face.path() } else { PathData::new(fragments.into_iter().map(|p| SubPath::polyline(&p, true)).collect()) };
|
||||
out.push(VisibleFace { path, brightness: face.brightness, depth: face.depth, gradient: face.gradient });
|
||||
out.push(VisibleFace {
|
||||
path,
|
||||
brightness: face.brightness,
|
||||
depth: face.depth,
|
||||
gradient: face.gradient,
|
||||
illumination: face.illumination,
|
||||
light_gradient: face.light_gradient,
|
||||
rgb_gradient: face.rgb_gradient,
|
||||
});
|
||||
// A completely covered face need not occupy the grid. Its coverage already belongs
|
||||
// to nearer faces; this keeps repeated overlapping surfaces bounded in practice.
|
||||
for candidate in source_covers {
|
||||
@@ -281,6 +295,9 @@ mod tests {
|
||||
depth: brightness,
|
||||
brightness,
|
||||
gradient: None,
|
||||
illumination: [brightness; 3],
|
||||
light_gradient: None,
|
||||
rgb_gradient: None,
|
||||
outline: None,
|
||||
cap: false,
|
||||
visibility_pieces: None,
|
||||
|
||||
@@ -53,6 +53,8 @@ pub(super) fn body(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
&& let Some(defaults) = defaults.as_object()
|
||||
{
|
||||
d.fields.extend(defaults.clone());
|
||||
d.fields.remove("__light");
|
||||
super::revolve_gizmo::clear(ui.ctx());
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -295,13 +297,153 @@ fn lighting_controls(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
}
|
||||
});
|
||||
ui.add_enabled_ui(d.bool("shade"), |ui| {
|
||||
// The documented Light Settings, in their order (one white light: no Color yet).
|
||||
number(ui, d, "lightIntensity", tl!("Intensity"), (0.0, 100.0), "%", 1, None);
|
||||
number(ui, d, "lightRotation", tl!("Rotation"), (-180.0, 180.0), "°", 1, None);
|
||||
number(ui, d, "lightHeight", tl!("Height"), (0.0, 90.0), "°", 1, None);
|
||||
number(ui, d, "lightSoftness", tl!("Softness"), (0.0, 100.0), "%", 1, None);
|
||||
light_list(ui, d);
|
||||
let mut selected = selected_light(d);
|
||||
let before = light_record(&selected);
|
||||
// The documented Light Settings, in their order.
|
||||
light_color(ui, &mut selected);
|
||||
number(ui, &mut selected, "lightIntensity", tl!("Intensity"), (0.0, 100.0), "%", 1, None);
|
||||
number(ui, &mut selected, "lightRotation", tl!("Rotation"), (-180.0, 180.0), "°", 1, None);
|
||||
number(ui, &mut selected, "lightHeight", tl!("Height"), (0.0, 90.0), "°", 1, None);
|
||||
number(ui, &mut selected, "lightSoftness", tl!("Softness"), (0.0, 100.0), "%", 1, None);
|
||||
number(ui, d, "ambient", tl!("Ambient Light"), (0.0, 200.0), "%", 1, None);
|
||||
toggle(ui, d, "lightBehind", tl!("Light behind the object"), false);
|
||||
toggle(ui, &mut selected, "lightBehind", tl!("Light behind the object"), false);
|
||||
if light_record(&selected) != before {
|
||||
store_selected(d, &selected);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const LIGHT_KEYS: [(&str, &str); 6] = [
|
||||
("color", "lightColor"),
|
||||
("intensity", "lightIntensity"),
|
||||
("rotation", "lightRotation"),
|
||||
("height", "lightHeight"),
|
||||
("softness", "lightSoftness"),
|
||||
("behind", "lightBehind"),
|
||||
];
|
||||
|
||||
fn explicit_lights(d: &Dialog) -> Option<&Vec<Value>> {
|
||||
d.fields.get("lights").and_then(Value::as_array).filter(|v| !v.is_empty() && v.len() <= vectorcraft_three_d::MAX_LIGHTS)
|
||||
}
|
||||
|
||||
fn light_index(d: &Dialog, count: usize) -> usize {
|
||||
d.fields.get("__light").and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok()).unwrap_or(0).min(count.saturating_sub(1))
|
||||
}
|
||||
|
||||
/// Only the selected light's six settings are overlaid. Selection is UI state and never
|
||||
/// enters effect parameters, so selecting another light creates no document history.
|
||||
pub(super) fn selected_light(d: &Dialog) -> Dialog {
|
||||
let mut selected = d.clone();
|
||||
selected.fields.remove("lights");
|
||||
if let Some(list) = explicit_lights(d)
|
||||
&& let Some(light) = list.get(light_index(d, list.len()))
|
||||
{
|
||||
let defaults = vectorcraft_effects::default_params(&d.str("__effect")).unwrap_or(json!({}));
|
||||
for (key, field) in LIGHT_KEYS {
|
||||
if let Some(value) = light.get(key).or_else(|| defaults.get(field)) {
|
||||
selected.fields.insert(field.into(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
selected
|
||||
}
|
||||
|
||||
pub(super) fn light_options(d: &Dialog) -> vectorcraft_three_d::Revolve {
|
||||
vectorcraft_effects::revolve_options(&super::form::params(&selected_light(d)))
|
||||
}
|
||||
|
||||
fn light_record(d: &Dialog) -> Value {
|
||||
Value::Object(LIGHT_KEYS.iter().filter_map(|(key, field)| d.fields.get(*field).map(|v| ((*key).into(), v.clone()))).collect())
|
||||
}
|
||||
|
||||
fn store_selected(d: &mut Dialog, selected: &Dialog) {
|
||||
if let Some(list) = explicit_lights(d) {
|
||||
let index = light_index(d, list.len());
|
||||
let mut list = list.clone();
|
||||
if let Some(light) = list.get_mut(index) {
|
||||
*light = light_record(selected);
|
||||
}
|
||||
d.fields.insert("lights".into(), json!(list));
|
||||
} else {
|
||||
for (_, key) in LIGHT_KEYS {
|
||||
if let Some(value) = selected.fields.get(key) {
|
||||
d.fields.insert(key.into(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn set_light_value(d: &mut Dialog, key: &str, value: Value) {
|
||||
let mut selected = selected_light(d);
|
||||
selected.fields.insert(key.into(), value);
|
||||
store_selected(d, &selected);
|
||||
}
|
||||
|
||||
fn light_list(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
let mut list = explicit_lights(d).cloned().unwrap_or_else(|| vec![light_record(d)]);
|
||||
let mut index = light_index(d, list.len());
|
||||
let names: Vec<_> = (1..=list.len()).map(|i| format!("{} {i}", c3("Light"))).collect();
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
label(ui, tl!("Lights"));
|
||||
let labels: Vec<_> = names.iter().map(String::as_str).collect();
|
||||
if let Some(next) =
|
||||
widgets::dropdown(ui, "three-d-lights", names.get(index).map_or("", String::as_str), &labels, VALUE_W + rail_width(ui) + 6.0)
|
||||
{
|
||||
index = next;
|
||||
ui.memory_mut(|m| m.stop_text_input());
|
||||
super::revolve_gizmo::clear(ui.ctx());
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
if ui.add_enabled(list.len() < vectorcraft_three_d::MAX_LIGHTS, egui::Button::new(tl!("Add Light"))).clicked() {
|
||||
let mut added = selected_light(d);
|
||||
let defaults = vectorcraft_effects::default_params(&d.str("__effect")).unwrap_or(json!({}));
|
||||
for (_, key) in LIGHT_KEYS {
|
||||
if let Some(value) = defaults.get(key) {
|
||||
added.fields.insert(key.into(), value.clone());
|
||||
}
|
||||
}
|
||||
added.fields.insert("lightRotation".into(), json!(0.0));
|
||||
added.fields.insert("lightHeight".into(), json!(90.0));
|
||||
list.push(light_record(&added));
|
||||
index = list.len() - 1;
|
||||
d.fields.insert("lights".into(), json!(list));
|
||||
super::revolve_gizmo::clear(ui.ctx());
|
||||
}
|
||||
if ui.add_enabled(list.len() > 1, egui::Button::new(tl!("Delete Light"))).clicked() && index < list.len() {
|
||||
list.remove(index);
|
||||
index = index.min(list.len().saturating_sub(1));
|
||||
d.fields.insert("lights".into(), json!(list));
|
||||
super::revolve_gizmo::clear(ui.ctx());
|
||||
}
|
||||
});
|
||||
d.fields.insert("__light".into(), json!(index));
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
|
||||
fn light_color(ui: &mut egui::Ui, d: &mut Dialog) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
label(ui, tl!("Color"));
|
||||
let color = d
|
||||
.fields
|
||||
.get("lightColor")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| s.len() == 7 && s.starts_with('#'))
|
||||
.and_then(vectorcraft_color::Color::from_hex)
|
||||
.unwrap_or(vectorcraft_color::Color::WHITE);
|
||||
// Light colors are display RGB, independent of the document's working profile.
|
||||
let mut rgb = color.to_rgb_uncalibrated().map(|c| (c * 255.0).round() as u8);
|
||||
if ui.color_edit_button_srgb(&mut rgb).on_hover_text(tl!("Color")).changed() {
|
||||
d.fields.insert("lightColor".into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
|
||||
}
|
||||
ui.label(format!("#{:02X}{:02X}{:02X}", rgb[0], rgb[1], rgb[2]));
|
||||
ui.add_space((ui.available_width() - 24.0).max(0.0));
|
||||
if reset_dot(ui, "lightColor", rgb != [255; 3], "#FFFFFF") {
|
||||
d.fields.insert("lightColor".into(), json!("#ffffff"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! the existing effect preview applies commands and owns OK, Cancel and undo.
|
||||
use egui::{Align2, FontId, Id, Pos2, Rect, Sense, Stroke, Vec2, vec2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_effects::{REVOLVE, revolve_options};
|
||||
use vectorcraft_effects::REVOLVE;
|
||||
use vectorcraft_geom::Point;
|
||||
use vectorcraft_three_d::Revolve;
|
||||
|
||||
@@ -85,7 +85,7 @@ impl Geometry {
|
||||
let node = state.doc.node(*state.selection.objects.first()?)?;
|
||||
let bounds = node.geometric_bounds()?;
|
||||
let xf = Xf::new(app.canvas_rect?, app.view()?);
|
||||
let options = revolve_options(&super::form::params(d));
|
||||
let options = super::revolve::light_options(d);
|
||||
// Revolve turns about its axis; the others about the centre of the art's bounds.
|
||||
let origin = if d.str("__effect") == REVOLVE { options.rotation_origin(bounds) } else { bounds.center() };
|
||||
let center = xf.to_screen(origin);
|
||||
@@ -204,7 +204,7 @@ pub(super) fn show(app: &VectorcraftApp, ctx: &egui::Context, d: &mut Dialog) {
|
||||
egui::Area::new(Id::new("revolve-gizmo-area")).order(egui::Order::Foreground).fixed_pos(rect.min).movable(false).show(ctx, |ui| {
|
||||
ui.set_clip_rect(geometry.xf.rect);
|
||||
let response = ui.allocate_rect(rect.intersect(geometry.xf.rect), Sense::drag());
|
||||
let options = revolve_options(&super::form::params(d));
|
||||
let options = super::revolve::light_options(d);
|
||||
let light = light_position(geometry, options);
|
||||
let hovered = response
|
||||
.hovered()
|
||||
@@ -267,21 +267,21 @@ pub(super) fn show(app: &VectorcraftApp, ctx: &egui::Context, d: &mut Dialog) {
|
||||
}
|
||||
}
|
||||
Mode::Light => {
|
||||
let start = revolve_options(&serde_json::Value::Object(current.fields.clone()));
|
||||
let start = super::revolve::light_options(&Dialog::new("effect", serde_json::Value::Object(current.fields.clone())));
|
||||
let projected = light_position(geometry, start) + (p - current.origin);
|
||||
let point = geometry.xf.delta_to_doc(projected - geometry.center) * (geometry.xf.zoom / f64::from(geometry.radius * 1.4));
|
||||
let [rotation, height] = start.light_at_on_side(Point::new(point.x, point.y));
|
||||
d.fields.insert("lightRotation".into(), json!(wrap(rotation, snap)));
|
||||
d.fields.insert("lightHeight".into(), json!(snap_height(height, snap)));
|
||||
super::revolve::set_light_value(d, "lightRotation", json!(wrap(rotation, snap)));
|
||||
super::revolve::set_light_value(d, "lightHeight", json!(snap_height(height, snap)));
|
||||
}
|
||||
Mode::LightRing { axis } => {
|
||||
let delta = geometry.xf.delta_to_doc(p - current.origin) * geometry.xf.zoom;
|
||||
let start = revolve_options(&serde_json::Value::Object(current.fields.clone()));
|
||||
let start = super::revolve::light_options(&Dialog::new("effect", serde_json::Value::Object(current.fields.clone())));
|
||||
if *axis == 0 {
|
||||
// Right turns the light counterclockwise, as along the ring's foot.
|
||||
d.fields.insert("lightRotation".into(), json!(wrap(start.light_rotation + delta.x * 0.6, snap)));
|
||||
super::revolve::set_light_value(d, "lightRotation", json!(wrap(start.light_rotation + delta.x * 0.6, snap)));
|
||||
} else {
|
||||
d.fields.insert("lightHeight".into(), json!(snap_height(start.light_height - delta.y * 0.6, snap)));
|
||||
super::revolve::set_light_value(d, "lightHeight", json!(snap_height(start.light_height - delta.y * 0.6, snap)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -347,7 +347,7 @@ fn paint(ui: &egui::Ui, d: &Dialog, g: Geometry, active: Option<usize>, drag: Op
|
||||
painter.circle(g.center, 8.0, t.panel.gamma_multiply(0.8), Stroke::new(1.5, t.text_dim));
|
||||
painter.line_segment([g.center - vec2(4.0, 0.0), g.center + vec2(4.0, 0.0)], Stroke::new(1.0, t.text_dim));
|
||||
painter.line_segment([g.center - vec2(0.0, 4.0), g.center + vec2(0.0, 4.0)], Stroke::new(1.0, t.text_dim));
|
||||
let options = revolve_options(&super::form::params(d));
|
||||
let options = super::revolve::light_options(d);
|
||||
if d.bool("__showAxis") {
|
||||
painter.line_segment(options.projected_axis(g.bounds).map(|p| g.xf.to_screen(p)), Stroke::new(1.0, t.warning));
|
||||
}
|
||||
@@ -365,7 +365,7 @@ fn paint(ui: &egui::Ui, d: &Dialog, g: Geometry, active: Option<usize>, drag: Op
|
||||
fn paint_light(ui: &egui::Ui, d: &Dialog, g: Geometry, active: Option<usize>, drag: Option<&Drag>) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let painter = ui.painter();
|
||||
let options = revolve_options(&super::form::params(d));
|
||||
let options = super::revolve::light_options(d);
|
||||
let radius = g.radius * 1.4;
|
||||
painter.circle_stroke(g.center, radius, Stroke::new(1.0, t.text_dim.gamma_multiply(0.3)));
|
||||
let labels = g.light_labels();
|
||||
@@ -402,7 +402,13 @@ fn paint_light(ui: &egui::Ui, d: &Dialog, g: Geometry, active: Option<usize>, dr
|
||||
painter.circle(g.center, 4.0, t.panel, Stroke::new(1.0, t.text_dim));
|
||||
// An original sun handle. A back light is hollow; the guide's back half is dashed.
|
||||
let front = options.light_direction()[2] >= 0.0;
|
||||
painter.circle(p, 9.0, if front { t.warning } else { t.panel }, Stroke::new(2.0, t.warning));
|
||||
let [r, green, b] = options.light_color.map(|c| (c * 255.0).round() as u8);
|
||||
let color = egui::Color32::from_rgb(r, green, b);
|
||||
// Keep the themed outline and rays readable even with a dark light color.
|
||||
painter.circle(p, 9.0, if front { color } else { t.panel }, Stroke::new(2.0, t.warning));
|
||||
if !front {
|
||||
painter.circle_stroke(p, 5.0, Stroke::new(2.0, color));
|
||||
}
|
||||
for i in 0..8 {
|
||||
let angle = i as f32 * std::f32::consts::TAU / 8.0;
|
||||
let direction = vec2(angle.cos(), angle.sin());
|
||||
|
||||
@@ -153,11 +153,11 @@ fn lighting_tab_shows_light_fields_and_keeps_the_dialog_compact() {
|
||||
h.settle();
|
||||
h.click(h.rect("Lighting").center());
|
||||
assert_eq!(h.app.ui.dialog.as_ref().unwrap().str("__gizmo"), "light");
|
||||
for label in ["Shading", "Intensity", "Height", "Softness", "Ambient Light", "Light behind the object"] {
|
||||
for label in ["Shading", "Color", "Intensity", "Height", "Softness", "Ambient Light", "Light behind the object"] {
|
||||
assert!(Rect::from_min_size(Pos2::ZERO, h.screen).contains_rect(h.rect(label)));
|
||||
}
|
||||
let panel = h.ctx.memory(|m| m.area_rect(Id::new(("dialog", "effect")).with("revolve"))).unwrap();
|
||||
assert!(panel.width() < 420.0 && panel.height() < 460.0, "compact lighting controls: {panel:?}");
|
||||
assert!(panel.width() < 420.0 && panel.height() < 570.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());
|
||||
@@ -346,3 +346,95 @@ fn cap_checkbox_previews_resets_and_respects_cancel_and_one_step_undo() {
|
||||
assert!(!h.app.session.in_interaction());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_picker_previews_and_reset_dot_and_cancel_restore_white() {
|
||||
let mut h = Harness::new(vec2(1280.0, 900.0));
|
||||
let before = h.app.session.doc().unwrap().doc.clone();
|
||||
h.settle();
|
||||
h.click(h.rect("Lighting").center());
|
||||
h.click(pos2(h.rect("#FFFFFF").left() - 18.0, h.rect("Color").center().y));
|
||||
let green =
|
||||
h.texts.iter().find(|(s, _)| s.starts_with("G ")).map(|(_, r)| *r).unwrap_or_else(|| panic!("the opaque RGB picker opens: {:?}", h.texts));
|
||||
h.drag(green.center(), vec2(-100.0, 0.0));
|
||||
let color = h.app.ui.dialog.as_ref().unwrap().str("lightColor");
|
||||
assert_ne!(color, "#ffffff");
|
||||
let st = h.app.session.doc().unwrap();
|
||||
assert_eq!(st.doc.node(st.selection.objects[0]).unwrap().appearance.effects[0].params["lightColor"], json!(color));
|
||||
h.click(h.rect("Color").center());
|
||||
h.click(h.response(("revolve-reset", "lightColor")).center());
|
||||
assert_eq!(h.app.ui.dialog.as_ref().unwrap().str("lightColor"), "#ffffff");
|
||||
dialogs::cancel(&mut h.app);
|
||||
assert_eq!(h.app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_3d_dialog_has_color_and_reset_all_restores_white() {
|
||||
for id in [REVOLVE, EXTRUDE, INFLATE, vectorcraft_effects::ROTATE] {
|
||||
let mut h = Harness::with("M100 100L200 100L200 200L100 200Z", id, vec2(1280.0, 900.0));
|
||||
h.settle();
|
||||
h.click(h.rect("Lighting").center());
|
||||
h.app.ui.dialog.as_mut().unwrap().fields.insert("lightColor".into(), json!("blue"));
|
||||
h.settle();
|
||||
assert!(!h.rect("#FFFFFF").is_negative(), "invalid stored colors show the renderer's white fallback");
|
||||
h.app.ui.dialog.as_mut().unwrap().fields.insert("lightColor".into(), json!("#ff8000"));
|
||||
h.settle();
|
||||
assert!(!h.rect("Color").is_negative());
|
||||
assert!(!h.rect("#FF8000").is_negative());
|
||||
h.click(h.response("revolve-reset-all").center());
|
||||
assert_eq!(h.app.ui.dialog.as_ref().unwrap().str("lightColor"), "#ffffff");
|
||||
dialogs::confirm(&mut h.app).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_list_add_select_edit_delete_and_reset_keep_each_light_independent() {
|
||||
for effect in [REVOLVE, EXTRUDE, INFLATE, vectorcraft_effects::ROTATE] {
|
||||
let mut h = Harness::with("M100 100L200 100L200 200L100 200Z", effect, vec2(1280.0, 900.0));
|
||||
let before = h.app.session.doc().unwrap().doc.clone();
|
||||
h.settle();
|
||||
h.click(h.rect("Lighting").center());
|
||||
let one = h.app.session.doc().unwrap().doc.clone();
|
||||
h.click(h.rect("Delete Light").center());
|
||||
assert_eq!(h.app.session.doc().unwrap().doc, one, "retain at least one light");
|
||||
h.click(h.rect("Add Light").center());
|
||||
let d = h.app.ui.dialog.as_ref().unwrap();
|
||||
let first = d.fields["lights"][0].clone();
|
||||
assert_eq!(d.fields["lights"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(d.fields["__light"], 1);
|
||||
assert_eq!(selected_light(d).f64("lightHeight", 0.0), 90.0);
|
||||
h.drag(h.rect("Height").center(), vec2(-35.0, 0.0));
|
||||
let d = h.app.ui.dialog.as_ref().unwrap();
|
||||
assert_ne!(d.fields["lights"][1]["height"], 90);
|
||||
assert_eq!(d.fields["lights"][0], first);
|
||||
let two = h.app.session.doc().unwrap().doc.clone();
|
||||
h.click(h.rect("Light 2").center());
|
||||
h.click(h.rect("Light 1").center());
|
||||
assert_eq!(h.app.session.doc().unwrap().doc, two, "selection changes no effect params");
|
||||
assert_eq!(h.app.ui.dialog.as_ref().unwrap().fields["__light"], 0);
|
||||
h.click(h.rect("Delete Light").center());
|
||||
assert_eq!(h.app.ui.dialog.as_ref().unwrap().fields["lights"].as_array().unwrap().len(), 1);
|
||||
h.click(h.response("revolve-reset-all").center());
|
||||
assert!(h.app.ui.dialog.as_ref().unwrap().fields["lights"].is_null());
|
||||
h.click(h.rect("Add Light").center());
|
||||
dialogs::confirm(&mut h.app).unwrap();
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc().unwrap().doc, before, "whole dialog is one undo step");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn light_list_stops_at_ten_and_cancel_restores_the_source() {
|
||||
let mut h = Harness::new(vec2(1280.0, 900.0));
|
||||
let before = h.app.session.doc().unwrap().doc.clone();
|
||||
h.settle();
|
||||
h.click(h.rect("Lighting").center());
|
||||
for _ in 0..11 {
|
||||
h.click(h.rect("Add Light").center());
|
||||
}
|
||||
let d = h.app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!(d.fields["lights"].as_array().unwrap().len(), 10);
|
||||
assert_eq!(d.fields["__light"], 9);
|
||||
dialogs::cancel(&mut h.app);
|
||||
assert_eq!(h.app.session.doc().unwrap().doc, before);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
use super::*;
|
||||
use crate::{dialogs, theme};
|
||||
use egui::{Event, Modifiers, PointerButton};
|
||||
use vectorcraft_effects::revolve_options;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
struct Harness {
|
||||
@@ -248,3 +249,34 @@ fn light_height_is_bounded_and_shading_off_disables_the_light_gizmo() {
|
||||
h.release(from + vec2(40.0, 0.0), Modifiers::NONE);
|
||||
assert_eq!(super::super::form::params(h.app.ui.dialog.as_ref().unwrap()), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_light_gizmo_edits_only_that_light_and_escape_restores_the_list() {
|
||||
let mut h = Harness::new();
|
||||
let d = h.app.ui.dialog.as_mut().unwrap();
|
||||
d.fields.insert("__gizmo".into(), json!("light"));
|
||||
d.fields.insert("__light".into(), json!(1));
|
||||
d.fields.insert(
|
||||
"lights".into(),
|
||||
json!([
|
||||
{"color":"#ff0000","rotation":30,"height":45,"intensity":60},
|
||||
{"color":"#0000ff","rotation":-45,"height":30,"intensity":50}
|
||||
]),
|
||||
);
|
||||
h.settle();
|
||||
let before = h.app.ui.dialog.as_ref().unwrap().fields["lights"].clone();
|
||||
let g = h.geometry();
|
||||
let p = g.light_labels()[0].center();
|
||||
h.start(p, Modifiers::NONE);
|
||||
h.frame(vec![Event::PointerMoved(p + vec2(60.0, 0.0))], Modifiers::NONE);
|
||||
h.frame(vec![Event::PointerMoved(p + vec2(80.0, 0.0))], Modifiers::NONE);
|
||||
let d = h.app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!(d.fields["lights"][0], before[0]);
|
||||
assert_ne!(d.fields["lights"][1]["rotation"], before[1]["rotation"]);
|
||||
h.frame(
|
||||
vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }],
|
||||
Modifiers::NONE,
|
||||
);
|
||||
h.release(p + vec2(80.0, 0.0), Modifiers::NONE);
|
||||
assert_eq!(h.app.ui.dialog.as_ref().unwrap().fields["lights"], before);
|
||||
}
|
||||
|
||||
@@ -4977,6 +4977,11 @@ theme Appearance Външен вид
|
||||
Softness Мекота
|
||||
Ambient Light Околна светлина
|
||||
Light behind the object Светлина зад обекта
|
||||
# 3D light management.
|
||||
Lights Светлини
|
||||
Add Light Добавяне на светлина
|
||||
Delete Light Изтриване на светлина
|
||||
3d Light Светлина
|
||||
Automation Автоматизация
|
||||
Essentials Основни
|
||||
Essentials Classic Основни класически
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5057,6 +5057,11 @@ mark-style Japanese Japonské
|
||||
Softness Měkkost
|
||||
Ambient Light Okolní světlo
|
||||
Light behind the object Světlo za objektem
|
||||
# 3D light management.
|
||||
Lights Světla
|
||||
Add Light Přidat světlo
|
||||
Delete Light Odstranit světlo
|
||||
3d Light Světlo
|
||||
Registration: Registrace:
|
||||
# Dimension tool (linked dimension annotations)
|
||||
Display Zobrazení
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4755,6 +4755,11 @@ mark-style Japanese Japansk
|
||||
Softness Blødhed
|
||||
Ambient Light Omgivelseslys
|
||||
Light behind the object Lys bag objektet
|
||||
# 3D light management.
|
||||
Lights Lyskilder
|
||||
Add Light Tilføj lyskilde
|
||||
Delete Light Slet lyskilde
|
||||
3d Light Lyskilde
|
||||
2up 2 pr. ark
|
||||
4up 4 pr. ark
|
||||
A6 A6
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4628,6 +4628,11 @@ effect Regular Regelmäßig
|
||||
Softness Weichheit
|
||||
Ambient Light Umgebungslicht
|
||||
Light behind the object Licht hinter dem Objekt
|
||||
# 3D light management.
|
||||
Lights Lichter
|
||||
Add Light Licht hinzufügen
|
||||
Delete Light Licht löschen
|
||||
3d Light Licht
|
||||
Registration: Registrierung:
|
||||
# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
|
||||
Dimension Bemaßung
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4779,6 +4779,11 @@ theme Appearance Εμφάνιση
|
||||
to set spacing and orientation για ορισμό απόστασης και προσανατολισμού
|
||||
: :
|
||||
Light behind the object Φως πίσω από το αντικείμενο
|
||||
# 3D light management.
|
||||
Lights Φώτα
|
||||
Add Light Προσθήκη φωτός
|
||||
Delete Light Διαγραφή φωτός
|
||||
3d Light Φως
|
||||
@msg give the path `id` Δώστε το `id` της διαδρομής
|
||||
Fill and Stroke Controls Στοιχεία ελέγχου γεμίσματος και περιγράμματος
|
||||
@msg give a fraction `t` from 0 to 1 Δώστε μια κλασματική τιμή `t` από 0 έως 1
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1996 and column 19.
|
@@ -4998,6 +4998,11 @@ mark-style Japanese Japonesas
|
||||
Softness Suavidad
|
||||
Ambient Light Luz ambiental
|
||||
Light behind the object Luz detrás del objeto
|
||||
# 3D light management.
|
||||
Lights Luces
|
||||
Add Light Añadir luz
|
||||
Delete Light Eliminar luz
|
||||
3d Light Luz
|
||||
Registration: Registro:
|
||||
# Dimension tool (linked dimension annotations)
|
||||
Create Dimension Crear cota
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4959,6 +4959,11 @@ mark-style Japanese Japanilainen
|
||||
Softness Pehmeys
|
||||
Ambient Light Ympäristövalo
|
||||
Light behind the object Valo objektin takana
|
||||
# 3D light management.
|
||||
Lights Valot
|
||||
Add Light Lisää valo
|
||||
Delete Light Poista valo
|
||||
3d Light Valo
|
||||
2up 2 sivua
|
||||
4up 4 sivua
|
||||
A6 A6
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5057,6 +5057,11 @@ theme Appearance Apparence
|
||||
Softness Douceur
|
||||
Ambient Light Lumière ambiante
|
||||
Light behind the object Lumière derrière l'objet
|
||||
# 3D light management.
|
||||
Lights Lumières
|
||||
Add Light Ajouter une lumière
|
||||
Delete Light Supprimer la lumière
|
||||
3d Light Lumière
|
||||
Registration: Repère d'alignement :
|
||||
# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
|
||||
Dimension Cote
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -5043,6 +5043,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
|
||||
# 3D light management.
|
||||
Lights Fények
|
||||
Add Light Fény hozzáadása
|
||||
Delete Light Fény törlése
|
||||
3d Light Fény
|
||||
Registration: Regisztráció:
|
||||
# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
|
||||
Dimension Méret
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4755,6 +4755,11 @@ mark-style Japanese Jepang
|
||||
Softness Kelembutan
|
||||
Ambient Light Cahaya ambient
|
||||
Light behind the object Cahaya di belakang objek
|
||||
# 3D light management.
|
||||
Lights Lampu
|
||||
Add Light Tambah Lampu
|
||||
Delete Light Hapus Lampu
|
||||
3d Light Lampu
|
||||
2up 2up
|
||||
4up 4up
|
||||
A6 A6
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3758 and column 19.
|
@@ -4666,6 +4666,11 @@ lighting Light Luce
|
||||
Softness Morbidezza
|
||||
Ambient Light Luce ambientale
|
||||
Light behind the object Luce dietro l'oggetto
|
||||
# 3D light management.
|
||||
Lights Luci
|
||||
Add Light Aggiungi luce
|
||||
Delete Light Elimina luce
|
||||
3d Light Luce
|
||||
Registration: Registrazione:
|
||||
# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
|
||||
Dimension Quota
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4706,6 +4706,11 @@ lighting Light ライト
|
||||
Softness 柔らかさ
|
||||
Ambient Light 環境光
|
||||
Light behind the object オブジェクトの背後のライト
|
||||
# 3D light management.
|
||||
Lights ライト一覧
|
||||
Add Light ライトを追加
|
||||
Delete Light ライトを削除
|
||||
3d Light ライト
|
||||
Registration: 基準点:
|
||||
# Dimension tool (linked dimension annotations)
|
||||
Create Dimension 寸法を作成
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4630,6 +4630,11 @@ lighting Light 광원
|
||||
Softness 부드러움
|
||||
Ambient Light 주변광
|
||||
Light behind the object 개체 뒤의 조명
|
||||
# 3D light management.
|
||||
Lights 광원 목록
|
||||
Add Light 광원 추가
|
||||
Delete Light 광원 삭제
|
||||
3d Light 광원
|
||||
Registration: 기준점:
|
||||
# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
|
||||
Dimension 치수
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4769,6 +4769,11 @@ mark-style Japanese Japans
|
||||
Softness Zachtheid
|
||||
Ambient Light Omgevingslicht
|
||||
Light behind the object Licht achter het object
|
||||
# 3D light management.
|
||||
Lights Lichtbronnen
|
||||
Add Light Lichtbron toevoegen
|
||||
Delete Light Lichtbron verwijderen
|
||||
3d Light Lichtbron
|
||||
2up 2up
|
||||
4up 4up
|
||||
A6 A6
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4894,6 +4894,11 @@ mark-style Japanese Japońskie
|
||||
Softness Miękkość
|
||||
Ambient Light Światło otoczenia
|
||||
Light behind the object Światło za obiektem
|
||||
# 3D light management.
|
||||
Lights Światła
|
||||
Add Light Dodaj światło
|
||||
Delete Light Usuń światło
|
||||
3d Light Światło
|
||||
Registration: Punkt rejestracji:
|
||||
Legacy (version 7) Starsza (wersja 7)
|
||||
Transparent Images: Przezroczyste obrazy:
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3979,6 +3979,11 @@ axis Both Ambos
|
||||
Softness Suavidade
|
||||
Ambient Light Luz ambiente
|
||||
Light behind the object Luz atrás do objeto
|
||||
# 3D light management.
|
||||
Lights Luzes
|
||||
Add Light Adicionar luz
|
||||
Delete Light Excluir luz
|
||||
3d Light Luz
|
||||
|
||||
# Completeness follow-up (#1914): remaining UI strings and messages.
|
||||
Glowing Edges Bordas Brilhantes
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -4662,6 +4662,11 @@ lighting Light Свет
|
||||
Softness Мягкость
|
||||
Ambient Light Окружающий свет
|
||||
Light behind the object Свет позади объекта
|
||||
# 3D light management.
|
||||
Lights Источники света
|
||||
Add Light Добавить свет
|
||||
Delete Light Удалить свет
|
||||
3d Light Свет
|
||||
Registration: Регистрация:
|
||||
# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
|
||||
Dimension Размер
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -4975,6 +4975,11 @@ theme Appearance Utseende
|
||||
Softness Mjukhet
|
||||
Ambient Light Omgivande ljus
|
||||
Light behind the object Ljus bakom objektet
|
||||
# 3D light management.
|
||||
Lights Ljuskällor
|
||||
Add Light Lägg till ljus
|
||||
Delete Light Ta bort ljus
|
||||
3d Light Ljus
|
||||
cancels | Double-click the tool for options avbryter | Dubbelklicka på verktyget för alternativ
|
||||
out from a highlighted edge to place its dimension | utåt från en framhävd kant för att placera dess mått |
|
||||
A legacy .ai file holds one artboard: each artboard you check is saved as a separate file. En äldre .ai-fil rymmer en rityta: varje rityta du markerar sparas som en separat fil.
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -4646,6 +4646,11 @@ lighting Light Світло
|
||||
Softness М'якість
|
||||
Ambient Light Навколишнє світло
|
||||
Light behind the object Світло позаду об'єкта
|
||||
# 3D light management.
|
||||
Lights Джерела світла
|
||||
Add Light Додати світло
|
||||
Delete Light Видалити світло
|
||||
3d Light Світло
|
||||
Registration: Реєстрація:
|
||||
# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
|
||||
Dimension Розмір
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 724 and column 40.
|
@@ -4648,6 +4648,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
|
||||
# 3D light management.
|
||||
Lights Danh sách đèn
|
||||
Add Light Thêm đèn
|
||||
Delete Light Xóa đèn
|
||||
3d Light Đèn
|
||||
Registration: Điểm đăng ký:
|
||||
# Dimension tool and Object › Dimensions (dimension groups, their dialog and messages).
|
||||
Dimension Kích thước
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 3939 and column 10.
|
@@ -4919,6 +4919,11 @@ lighting Light 光源
|
||||
Softness 柔和度
|
||||
Ambient Light 环境光
|
||||
Light behind the object 光源位于对象后方
|
||||
# 3D light management.
|
||||
Lights 光源列表
|
||||
Add Light 添加光源
|
||||
Delete Light 删除光源
|
||||
3d Light 光源
|
||||
Registration: 对齐点:
|
||||
# Dimension tool (linked dimension annotations)
|
||||
Create Dimension 创建尺寸标注
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3997,6 +3997,11 @@ lighting Light 光源
|
||||
Softness 柔和度
|
||||
Ambient Light 環境光
|
||||
Light behind the object 光源位於物件後方
|
||||
# 3D light management.
|
||||
Lights 光源清單
|
||||
Add Light 新增光源
|
||||
Delete Light 刪除光源
|
||||
3d Light 光源
|
||||
Registration: 對齊點:
|
||||
# Dimension tool (linked dimension annotations)
|
||||
Create Dimension 建立尺寸標註
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -0,0 +1,75 @@
|
||||
# 3D lighting performance
|
||||
|
||||
Measured 2026-10-17 for PR #2706 on one macOS machine.
|
||||
|
||||
## Method
|
||||
|
||||
- Apple M1 Pro, 10 cores, 16 GiB RAM, macOS 26.5.1 (arm64).
|
||||
- `cargo build --release -p vectorcraft-cli`, the workspace release profile (thin LTO, one codegen unit), without bundled fonts.
|
||||
- Before: published PR head `f63fafdb`. After: the reviewed implementation rebased on main `f7de3d6a`, with legacy white-light Inflate restored and colored solid-fill channel groups batched per surface. The main update is included in this comparison.
|
||||
- Both executables were retained separately. No compilation ran during measurement. Three paired repetitions alternate before/after for each fixture. Each `bench` invocation warms its renderer once, then averages five frames for each thread setting. The table reports the median of the three reported averages.
|
||||
- `bench` renders a 400 x 400 pt artboard at 1200 x 1200 pixels, fitting it to 95% of the viewport. The scene contains one white-filled ellipse, centered at (200, 200), with radii 110 and 140 pt, no stroke, Inflate volume 50%, depth 0, solid cap, one side, view rotation (-20, 25, 0), no perspective, and 15% neutral ambient.
|
||||
- Two lights: orange `#ff8040`, intensity 70%, rotation 135 degrees, height 35 degrees, softness 15%; blue `#4080ff`, intensity 65%, rotation -35 degrees, height 25 degrees, softness 10%. Both are in front.
|
||||
- The one-light case uses the legacy fields: `#ff8040`, intensity 80%, rotation 125.26438968275465 degrees, height 30 degrees, softness 0%. The white case changes its color to `#ffffff`.
|
||||
- The source-gradient case uses the same two lights with a linear source gradient from (90, 200) to (310, 200), orange at opacity 0.45 and blue at opacity 0.7.
|
||||
- `perf` covers the standard 50,000-path synthetic budget scene. It was also run three times per executable, alternating before/after. Its table reports medians.
|
||||
|
||||
## Reproduce
|
||||
|
||||
Create the two-light scene using the updated CLI, then use the same saved file with both executables:
|
||||
|
||||
```sh
|
||||
vectorcraft-cli run \
|
||||
--cmd file.new --params '{"width":400,"height":400}' \
|
||||
--cmd document.setup --params '{"backgroundContents":"white"}' \
|
||||
--cmd path.create --params '{"d":"M310.0 200.0C310.0 277.3198649763111 260.7513224813873 340.0 200.0 340.0C139.2486775186127 340.0 90.0 277.3198649763111 90.0 200.0C90.0 122.6801350236889 139.2486775186127 60.0 200.0 60.0C260.7513224813873 60.0 310.0 122.6801350236889 310.0 200.0Z"}' \
|
||||
--cmd paint.setFill --params '{"color":"#ffffff"}' \
|
||||
--cmd paint.setStroke --params '{"none":true}' \
|
||||
--cmd effect.apply --params '{"effect":"threeD.inflate","item":null,"params":{"rotationX":-20,"rotationY":25,"rotationZ":0,"ambient":15,"volume":50,"depth":0,"cap":true,"bothSides":false,"lights":[{"color":"#ff8040","intensity":70,"rotation":135,"height":35,"softness":15},{"color":"#4080ff","intensity":65,"rotation":-35,"height":25,"softness":10}]}}' \
|
||||
--cmd document.save --params '{"path":"/tmp/inflate-two-lights.vectorcraft"}'
|
||||
|
||||
vectorcraft-cli bench /tmp/inflate-two-lights.vectorcraft --size 1200x1200 --iters 5
|
||||
vectorcraft-cli perf
|
||||
```
|
||||
|
||||
## Measurements
|
||||
|
||||
Milliseconds per frame, medians of three five-frame averages. `8` is the CLI default thread count on this machine; `0` selects the single-thread renderer.
|
||||
|
||||
| Inflate scene | Before, 8 | After, 8 | Before, 0 | After, 0 |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Two colored lights, solid fill | 136.2 | 96.8 | 147.2 | 100.6 |
|
||||
| One colored light, solid fill | 94.4 | 96.7 | 107.0 | 100.0 |
|
||||
| Legacy white light, restored polygon shading | 12.3 | 4.1 | 27.3 | 9.5 |
|
||||
| Two colored lights, transparent source gradient | 145.6 | 146.8 | 156.9 | 157.0 |
|
||||
|
||||
The two-light solid-fill case takes 28.9% less frame time at the default thread count and 31.7% less in the single-thread renderer. The one-light case is 2.4% higher with eight threads and 6.5% lower single-threaded. The source-gradient case retains its previous per-facet compositor and changes by less than 1% in these medians. Restoring legacy white-light polygons also removes the extra smooth triangles from that path.
|
||||
|
||||
All six `perf` runs exited successfully, with no busy-machine warning and every individual budget row passing. The reported one-minute load averages were 4.5 to 5.9 on ten cores. Median milliseconds:
|
||||
|
||||
| Standard 50,000-path workload | Before | After | Budget |
|
||||
|---|---:|---:|---:|
|
||||
| render: fit page, 2880×1800 | 47.15 | 47.80 | 100 |
|
||||
| render: background refresh after a pan | 48.18 | 46.71 | 100 |
|
||||
| render: 400% zoom, 2880×1800 | 13.45 | 13.68 | 16 |
|
||||
| hit test (per click) | 0.43 | 0.42 | 2 |
|
||||
| save .vectorcraft | 52.78 | 52.15 | 300 |
|
||||
| open .vectorcraft | 143.84 | 142.55 | 300 |
|
||||
| export SVG | 207.37 | 206.25 | 500 |
|
||||
| Pathfinder Unite, 1,000 paths | 43.18 | 43.29 | 150 |
|
||||
| handle drag, 5,000-anchor path (per move) | 0.94 | 0.84 | 8 |
|
||||
| segment drag, 5,000-anchor path (per move) | 0.87 | 0.92 | 8 |
|
||||
|
||||
The colored fixture uses the same projected mesh before and after. The solid-fill batch reduces compositor groups and clips; it retains three sets of channel facet paths. Smooth vector export still has many gradient elements.
|
||||
|
||||
## Rendering structure
|
||||
|
||||
For a smooth colored solid fill, all facets share one compound coverage clip and three isolated channel groups. Each channel has an opaque black backdrop; Screen combines the disjoint RGB channels and Multiply applies the source color. Face order is retained in each channel. Surface opacity and outer coverage apply once. The group/clip counts are independent of triangle count, and export remains vector artwork.
|
||||
|
||||
Source gradients retain their independently placed per-facet vector compositor. They remain a more expensive case. Patterns retain their neutral shading approximation. White-light Inflate uses the original polygon shading, paint composition and default seam joins; the seam width retains its 0.05 to 0.75 pt bounds.
|
||||
|
||||
Geometry/effects tests cover legacy polygons and seam bounds, shared smooth normals, finite ramps, holes and constant group/clip counts. Engine tests cover the batched SVG round trip, vector PDF, expansion, undo, compound holes and surface opacity.
|
||||
|
||||
## Legacy white-light comparison
|
||||
|
||||
Four original synthetic scenes were exported at 1200 x 1200 from unchanged main `f7de3d6a` and the updated implementation. The PNG files match byte for byte in all four cases: the legacy white-lit ellipse above; the same ellipse with both sides, hollow cap, twist 60 degrees, taper 70%, perspective 80 degrees and view Y rotation 145 degrees; an unshaded ellipse; and the transparent source gradient above under the legacy white light. The command recipe was also checked against the measured colored fixture's image pixels.
|
||||
@@ -1,6 +1,6 @@
|
||||
# Effects parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-16 · **Change:** minor (Zig Zag's Smooth points make a continuous wave, #2343; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); Round Corners also rounds corners where a curve meets a line or another curve, with Live Corners' cut; straight corners unchanged; score unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; wide shadows and glows at high zoom, #1706; stacked Transform effects, #1705; 3D on nonzero compound paths, #1724; pattern tiles redraw, #1723; scores unchanged; Roughen's Smooth turn-backs, #1643; Scribble per region, #1648; score unchanged; Warp Arc bends in the object's proportions, #1578; Zig Zag ridges evenly spaced, #1558; effect parameters checked, #1592; score unchanged; raster effects after a pattern, radial or freeform gradient fill export at their place and size, #1536; score unchanged; 3D and Materials on outlined type and other shapes of many small curves, #1481; Twist 0° to 360°, #1524; twisted walls keep their faces, #1525; score unchanged; 3D and Materials: Inflate takes Twist, Taper and Cap; score unchanged; 3D and Materials: Perspective as the documented 0°–160° angle; 3D and Materials: the documented light settings) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (3D lighting: one to ten colored lights, smooth colored Inflate, legacy white-light appearance preserved; surface batching and restored seam minimum; scores and estimates unchanged; Zig Zag's Smooth points make a continuous wave, #2343; G2: Effect › Sketch › Halftone Pattern; Photoshop filters 30/57, 27 remaining (22–38 h); Round Corners also rounds corners where a curve meets a line or another curve, with Live Corners' cut; straight corners unchanged; score unchanged; Revolve: automatic smooth shading and default-on Cap with continuous rims; wide shadows and glows at high zoom, #1706; stacked Transform effects, #1705; 3D on nonzero compound paths, #1724; pattern tiles redraw, #1723; scores unchanged; Roughen's Smooth turn-backs, #1643; Scribble per region, #1648; score unchanged; Warp Arc bends in the object's proportions, #1578; Zig Zag ridges evenly spaced, #1558; effect parameters checked, #1592; score unchanged; raster effects after a pattern, radial or freeform gradient fill export at their place and size, #1536; score unchanged; 3D and Materials on outlined type and other shapes of many small curves, #1481; Twist 0° to 360°, #1524; twisted walls keep their faces, #1525; score unchanged; 3D and Materials: Inflate takes Twist, Taper and Cap; score unchanged; 3D and Materials: Perspective as the documented 0°–160° angle; 3D and Materials: the documented light settings) · **Target:** Adobe Illustrator 2026 (30.x)
|
||||
|
||||
Every entry of Illustrator's Effect menu, by its public feature name, against VectorCraft's effects catalogue
|
||||
(`effect_catalog()` in `crates/effects/src/lib.rs`) and the Effect menu built from it
|
||||
@@ -101,13 +101,13 @@ not an accuracy guarantee for arbitrary artwork. Tests: `pixel::tests::film_grai
|
||||
|---|---|
|
||||
| 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, Position presets, rotation and light gizmos, SVG/PDF output, Expand Appearance ([three-d.md](three-d.md)) |
|
||||
| Materials panel, lighting, ray-traced render | one directional light with the documented Intensity, Rotation, Height, Softness and Ambient Light, Revolve uses smooth vector shading, the other types use flat facets; no materials, several lights, light colour or presets, shadows or ray tracing |
|
||||
| Materials panel, lighting, ray-traced render | 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; no materials, light presets, shadows or ray tracing |
|
||||
| 3D (Classic): Extrude & Bevel, Revolve, Rotate | none |
|
||||
| Turntable (30.0 beta) | none |
|
||||
| Export 3D objects (OBJ, USDA, glTF) | none |
|
||||
|
||||
The `three-d` crate (depends on `geom` and `pathops`, with no UI or GPU dependencies) builds, rotates, projects, shades and depth-sorts the surfaces;
|
||||
the effects crate turns them into art. What's left (materials, several lights and shadows, ray-traced rendering,
|
||||
the effects crate turns them into art. What's left (materials, light presets and shadows, ray-traced rendering,
|
||||
angular cut caps, exact visibility, 3D Classic, Turntable, 3D export) is 25–45 h.
|
||||
|
||||
## Revision history
|
||||
@@ -116,6 +116,9 @@ angular cut caps, exact visibility, 3D Classic, Turntable, 3D export) is 25–45
|
||||
|---|---|---|
|
||||
| 2026-10-16 | minor | Zig Zag with Smooth points draws a continuous wave: every peak and valley is a smooth point with handles along the path, a third of the way to the next ridge, so each span follows a half cosine through the path instead of a straight diagonal with rounded tips, also at the ends of open paths and for ridges much taller than they are apart (#2343); score unchanged (estimated) |
|
||||
| 2026-10-16 | minor | G2: Effect › Sketch › Halftone Pattern (Size, Contrast, Pattern Type Circle, Dot or Line; black ink on white paper, Size in points at any Document Raster Effects resolution); measured Photoshop filters 30/57, 27 remaining (22–38 h). |
|
||||
| 2026-10-17 | minor | Colored Inflate: shared membrane normals and smooth RGB vector ramps replace flat polygon bands; channel groups are batched per solid-fill surface. White-light Inflate retains its original polygons, paint composition and seam joins; the 0.05 seam minimum is restored. Scores and hour estimates unchanged. |
|
||||
| 2026-10-16 | minor | 3D lighting: one to ten directional lights, a selectable light list, Add Light and Delete Light, independent settings and artboard gestures; ambient applied once and smooth RGB vector export; scores and hour estimates unchanged |
|
||||
| 2026-10-16 | minor | 3D lighting: opaque RGB Color for the shared directional light, with neutral ambient, preview/reset controls and smooth vector Revolve shading; scores and hour estimates unchanged |
|
||||
| 2026-10-15 | minor | Round Corners rounds corners whose anchor carries a handle (where a curve meets a line, or two curves meet) with the round cut Live Corners uses (#1144), reaching `radius` along each side; before, they were left sharp. Corners between handle-less anchors are rounded as before; score unchanged (estimated) |
|
||||
| 2026-10-14 | minor | Revolve: automatic smooth vector shading, default-on Cap with continuous curved rims, and bounded seam joins; angular cut sides remain open. Scores and hour estimates unchanged at this rounding |
|
||||
| 2026-10-14 | minor | Wide drop shadows and glows at high zoom render within the raster budget (#1706); stacked Transform effects are bounded (#1705); 3D effects keep the area a nonzero compound path fills (#1724); pattern tiles redraw when a symbol, pattern or image their art shows changes (#1723) (all #1864). Scores unchanged (estimated) |
|
||||
|
||||
+6
-3
File diff suppressed because one or more lines are too long
+9
-2
@@ -1239,7 +1239,7 @@ SVG/PDF exports bake the same faces the canvas draws. Only one Revolve is suppor
|
||||
|
||||
Parameters: `angle` (degrees, 0..360, default 360), `offset` (points, 0..100000, default 0),
|
||||
`edge` (`"left"` or `"right"`), `rotationX/Y/Z` (degrees, defaults 0/0/0), `perspectiveAngle`
|
||||
(degrees, 0..160, default 0; an earlier `perspective`, 0..100%, is read as the angle that shows the same view), `segments` (8..128, default 64), `shade` (default true), `lightIntensity` (0..100%, default 80),
|
||||
(degrees, 0..160, default 0; an earlier `perspective`, 0..100%, is read as the angle that shows the same view), `segments` (8..128, default 64), `shade` (default true), `lightColor` (opaque `#rrggbb`, default `#ffffff`), `lightIntensity` (0..100%, default 80),
|
||||
`lightRotation` (degrees, -180..180, default 125.26; 0 on the object's right, counterclockwise), `lightHeight`
|
||||
(degrees, 0..90, default 30; 90 straight in front), `lightBehind` (default false), `lightSoftness` (0..100%,
|
||||
default 0), `ambient` (Ambient Light, 0..200%, default 25; an earlier `lightAzimuth` and `lightElevation` are read
|
||||
@@ -1248,6 +1248,13 @@ as the same light), `cap` (boolean, default true), and `expandVisibleOnly` (bool
|
||||
vector path. Body faces share that curved rim. Partial sweeps use sectors and leave their angular
|
||||
cut sides open. Closed or coincident-end profiles are unchanged.
|
||||
Missing `cap` enables caps; set it to false for open endpoints. Shaded Revolve surfaces automatically interpolate curved-surface lighting with vector gradients while preserving profile corners and flat caps. There is no separate smoothing parameter. `shade: false` disables all lighting. Visibility of intersecting surfaces is approximate.
|
||||
`lights` is null by default, retaining the earlier single-light controls. Set an array of one to ten objects to replace that light: `{color: "#rrggbb", intensity: 0..100, rotation: -180..180, height: 0..90, softness: 0..100, behind: bool}`. Defaults per light are white, 80%, 125.26 degrees, 30 degrees, 0%, false. Omitted fields use defaults; unknown nested keys and malformed lists are refused before mutation. `ambient` is shared and counted once. Set `lights: null` to restore the earlier controls. The list is saved in the native file and survives preview, undo and expansion.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"lights":[{"color":"#ff8040","rotation":135,"height":35,"intensity":70},{"color":"#4080ff","rotation":-45,"height":20,"intensity":40}],"ambient":20}}}}
|
||||
```
|
||||
|
||||
`lightColor` changes only the earlier directional light; ambient stays neutral. Invalid command colors are refused before editing, and invalid loaded colors fall back to white. Colored smooth faces use ordinary RGB vector gradients; source gradients keep their placement and opacity with vector blending/masks. Pattern tiles retain neutral shading.
|
||||
The dialog's Lighting tab manipulates the same `lightRotation` and `lightHeight` parameters;
|
||||
its guide visibility and tab choice are UI state, excluded from saved effect parameters.
|
||||
`expandVisibleOnly` is saved with the effect. It trims covered portions of opaque solid surfaces only
|
||||
@@ -1258,7 +1265,7 @@ See [Revolve](revolve.md) for the scope and limitations.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"effect.apply","params":{"effect":"threeD.revolve","item":null,"params":{"angle":360,"offset":20,"edge":"left","rotationX":0,"rotationY":0,"rotationZ":0}}}}
|
||||
{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"angle":180,"lightRotation":30,"cap":true}}}}
|
||||
{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"angle":180,"lightRotation":30,"lightColor":"#ff8040","cap":true}}}}
|
||||
{"name":"run_command","arguments":{"command":"effect.setParams","params":{"index":0,"item":null,"params":{"expandVisibleOnly":true}}}}
|
||||
{"name":"run_command","arguments":{"command":"effect.expandAppearance","params":{}}}
|
||||
```
|
||||
|
||||
+5
-4
@@ -10,8 +10,9 @@ line, give it a nonzero offset. A vertical line at zero offset lies on the axis
|
||||
**Revolve angle** sweeps 0 to 360 degrees. **Rotation X/Y/Z** rotates the viewing direction, with all three axes defaulting to 0 degrees. **Perspective**
|
||||
runs 0° to 160°, as documented: 0° is an orthographic view, and wider angles bring the eye nearer (see
|
||||
[three-d.md](three-d.md#perspective)). **Segments** controls radial resolution (8 to 128 for a full turn).
|
||||
Lighting is one directional light with the documented Light Settings (see [three-d.md](three-d.md#lighting)):
|
||||
**Intensity**, **Rotation**, **Height**, **Softness** and **Ambient Light**, and **Light behind the object**.
|
||||
Lighting supports one to ten directional lights with the documented Light Settings (see [three-d.md](three-d.md#lighting)):
|
||||
**Color**, **Intensity**, **Rotation**, **Height**, **Softness** and **Ambient Light**, and **Light behind the object**.
|
||||
Color is white by default, with a picker and a reset dot. Colored light preserves neutral ambient illumination.
|
||||
Turn **Shading** off to keep the source paint without lighting.
|
||||
|
||||
Shaded Revolve surfaces automatically use smooth lighting from the profile's tangents,
|
||||
@@ -44,7 +45,7 @@ horizontally to turn the light round the object; drag the **Height** line or its
|
||||
raise or lower it. The labels remain usable when the ring shrinks to a point (Height 90°).
|
||||
Hold Shift to snap to 15-degree increments. Light gestures update their matching fields without
|
||||
rotating the object, and keep the same temporary preview, Escape, Cancel and undo behaviour.
|
||||
Intensity, Softness and Ambient Light are draggable fields and sliders.
|
||||
Intensity, Softness and Ambient Light are draggable fields and sliders. The Lights list selects which light the gizmo and settings edit; Add Light and Delete Light manage up to ten lights, keeping at least one. Ambient is shared and applied once. Preview, Cancel, Reset all and undo include the whole list.
|
||||
|
||||
**Shading** off hides the light gizmo and disables lighting controls. Switch to **Rotation** to return
|
||||
to the object rings. **Show axis** in **Advanced** displays the source's vertical axis on the object.
|
||||
@@ -89,7 +90,7 @@ with the standard command.
|
||||
- Open profiles have circular end caps by default; turning Cap off keeps them open. Partial-sweep angular cut sides remain open. Closed profile boundaries are swept as provided.
|
||||
- One Revolve per source object. Edit its options rather than applying another.
|
||||
- Object-level Revolve uses the top visible fill, falling back to a stroke. Per-item Revolve uses that item's paint.
|
||||
- Smooth diffuse shading, one directional light, no textures, cast shadows or physical materials. Smooth shading interpolates lighting in projected triangles; it does not smooth the silhouette or add specular highlights.
|
||||
- Smooth diffuse shading, one to ten directional lights, no textures, cast shadows or physical materials. Smooth shading interpolates lighting in projected triangles; it does not smooth the silhouette or add specular highlights.
|
||||
- Faces use back-to-front depth sorting. Caps facing the camera cover their rim seams; caps facing away sit behind the body. Intersecting profiles can have incorrect visibility.
|
||||
- Curves use sixteen samples per segment; profiles are limited to 1,024 samples and surfaces to 32,768 faces. Smooth shaded side quads become two triangles and share that same limit. A continuous cap retains its original radial mesh cost within this limit. Projected ring curves have a 0.001-point fitting tolerance and a shared 32,768-curve limit.
|
||||
- Axis, bounds and ordinary selection handles continue to refer to the editable 2D source profile.
|
||||
|
||||
+5
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+22
-9
@@ -50,7 +50,7 @@ middle.
|
||||
| Volume | 0–100 %. The surface is a membrane under even pressure, held along the outline (and around holes): it rises vertically from the edge and rounds smoothly inside, without creases along the shape's middle. At 50 % a circle becomes a half sphere; 100 % puffs twice as high, 0 % is flat. |
|
||||
| Both sides | The back puffs out too; otherwise it is flat. |
|
||||
|
||||
The surface is cut into a grid of 48 facets across the larger side, halved or quartered where it rises steeply near the outline (the cells meet without cracks), each clipped to the shape and flat shaded.
|
||||
The surface starts with a grid of 48 cells across the larger side, halved or quartered where it rises steeply near the outline. White-light Inflate retains the existing clipped polygon cells, shading, paint composition and seam joins. With colored lights, simple cells are triangulated with shared border samples, and lighting interpolates shared membrane normals through ordinary RGB vector gradients, including on a puffed back. Solid fills batch those triangles into three channel groups and one coverage clip for the whole surface. Source gradients retain per-facet vector composition and apply transparent coverage once. Seam widths retain the 0.05 to 0.75 pt bounds; colored facets use round joins. Shading is automatic, with no additional dialog option.
|
||||
|
||||
## Rotate
|
||||
|
||||
@@ -83,18 +83,23 @@ saves the angle.
|
||||
|
||||
## Lighting
|
||||
|
||||
One white directional light, with the public panel options' Light Settings in their order (Color aside):
|
||||
One to ten directional lights. **Lights** selects the light whose settings and artboard gizmo are edited. **Add Light** adds and selects a white front light; **Delete Light** removes the selected light while retaining at least one. Ambient Light is shared by all lights. The public panel options' Light Settings are kept in their order:
|
||||
|
||||
| Option | |
|
||||
|---|---|
|
||||
| Color | Opaque RGB light color, white by default. The picker previews changes; its dot resets to white. |
|
||||
| Intensity | 0–100 %, default 80 %: the light's brightness. |
|
||||
| Rotation | −180° to 180°: round the object on the page. Our reading: 0° is on the object's right, counterclockwise, as Illustrator's other angles. |
|
||||
| Height | 0° to 90°: from beside the object (0°) to straight in front of it (90°). |
|
||||
| Softness | 0–100 %, default 0 %: how far round the object the light reaches. Our reading: at 0 % the light falls off with the angle to nothing at 90° (as before), at 100 % it reaches nothing only straight away from the light. |
|
||||
| Ambient Light | 0–200 %, default 25 %: light from everywhere. Faces are never brighter than the art's own colour, so past 100 % (less the light's share) it adds nothing more. |
|
||||
| Ambient Light | 0–200 %, default 25 %: neutral light from everywhere. Each channel is clamped to the art's own color; at 100 % ambient every channel is fully lit, so higher values add nothing. |
|
||||
| Light behind the object | The documented "Move selected light to the back of object": the light shines from behind. |
|
||||
|
||||
The documentation gives no defaults; ours keep the earlier light: Rotation 125.26° and Height 30° are the direction
|
||||
Colored illumination multiplies each source RGB channel by `clamp(ambient + sum(intensity * diffuse * color), 0, 1)`. Each light has its own direction and softness. Ambient is added once, and channel saturation happens after the sum. Ambient light stays neutral. Softness supplies the same diffuse falloff as before. White supplies neutral illumination; Shading off ignores the lights. Solid fills and gradient stops (including freeform points) receive the color. Pattern fills retain the existing neutral shadow approximation because their referenced tiles are not evaluated per face. SVG/PDF and Expand Appearance keep the shaded solids and gradients as vectors.
|
||||
|
||||
Revolve interpolates the directional illumination before clipping each RGB channel, adding vector stops where a channel saturates. Its continuous caps keep flat lighting. A source gradient and the light ramp are combined with isolated vector Multiply groups; a vector clip applies the curved face boundary once, avoiding antialiasing seams, and a vector opacity mask preserves transparent stops when needed. With multiple lights, each RGB channel has its own affine vector gradient; disjoint channel ramps combine through Screen before multiplying the source paint. This preserves differently colored directions and clips each channel after interpolation. No raster image is needed.
|
||||
|
||||
The documentation gives no numeric defaults; ours keep the earlier light: Rotation 125.26° and Height 30° are the direction
|
||||
the default light had (45° left of the eye and 45° up). Effects saved before (and scripts) give the light by
|
||||
`lightAzimuth` and `lightElevation`; those are read as the Rotation, Height and side of the same direction, so the
|
||||
shading is exactly as before.
|
||||
@@ -104,9 +109,9 @@ shading is exactly as before.
|
||||
`threeD.extrude`: `depth`, `twist`, `taper` (%), `cap`, `bevel` (`none`, `classic`, `rounded`, `cove`, `step`,
|
||||
`ogee`), `bevelWidth`, `bevelHeight`, `bevelRepeat`, `bevelSpace` (%), `bevelInside`, `bevelBothSides`.
|
||||
`threeD.inflate`: `depth`, `volume` (%), `bothSides`. All three take the view and lighting parameters Revolve
|
||||
uses: `rotationX`, `rotationY`, `rotationZ`, `perspectiveAngle` (degrees), `shade`, `lightIntensity` (%), `lightRotation`,
|
||||
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.
|
||||
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.
|
||||
|
||||
## What differs from the public documentation
|
||||
|
||||
@@ -115,8 +120,8 @@ and [Create 3D objects](https://helpx.adobe.com/illustrator/desktop/special-effe
|
||||
pages, and the archived Extrude & Bevel option list (depth 0–2,000, cap on/off, bevel height, bevel extent in or
|
||||
out, perspective). No Adobe software, assets, presets or bevel shapes were used.
|
||||
|
||||
- **Materials** (base, Adobe Substance and graphics), **several lights**, light colour, light presets, **shadows**
|
||||
and **ray-traced rendering** are not built: shading is one directional light, flat per face, painted with the
|
||||
- **Materials** (base, Adobe Substance and graphics), light presets, **shadows**
|
||||
and **ray-traced rendering** are not built: shading uses directional lights, painted with the
|
||||
object's top fill (else its top stroke) for Extrude & Bevel and Inflate.
|
||||
- The bevel shapes and their names are our own; custom bevel paths are not supported.
|
||||
- Visibility is a painter's algorithm: faces of a closed solid turned away are left out, the rest drawn back to
|
||||
@@ -124,10 +129,18 @@ out, perspective). No Adobe software, assets, presets or bevel shapes were used.
|
||||
Twisted walls and bevels are drawn in pieces about as long as a wall slice is wide (fewer near the limit of
|
||||
32,768 facets), and a cap or flat back turned toward the viewer is drawn over them. The piece drawn on top can
|
||||
still lie a few points behind another nearby piece.
|
||||
- Shading is flat per facet: an inflated rim seen almost edge-on shows faint banding where the facets get finer.
|
||||
- Extrude & Bevel and Rotate retain planar shading. Colored Inflate uses smooth vector lighting; white-light Inflate keeps its original polygon shading; bounded cell triangulation leaves a cell flat when it contains a complete hole or an unusually complex/degenerate contour. Its geometric silhouette is still a polygon approximation.
|
||||
- Paths and compound paths only: convert type, images and groups to outlines first. One 3D effect per object.
|
||||
- Curves are followed within 1/2000 of the shape's size (at least 0.05 pt), so a small curve takes a few points
|
||||
and a large one up to 32. Extrude & Bevel and Inflate take up to 2,048 outline points, Rotate (which only turns
|
||||
the outline) 65,536.
|
||||
- **3D (Classic)** (the track cube, surface types, blend steps, map art), Turntable and 3D export are separate,
|
||||
unbuilt items.
|
||||
|
||||
Release measurements and a repeatable CLI scene are in [3D lighting performance](3d-lighting-performance.md).
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change |
|
||||
|---|---|
|
||||
| 2026-10-17 | Smooth colored Inflate lighting and surface batching; legacy white-light appearance and the 0.05 seam minimum preserved; shared light Color and multiple lights remain vector output. |
|
||||
|
||||
+4
-1
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user