Merge origin/master into the async record refactor

Scaffolding merge for the reconcile; the final series to master is
authored fresh. Rank plumbing resolves to our axis-IR model, the node
macro and the LaneSource render walk stay ours, master's vector
restructure and gradient vocabulary are adopted, and the paint and
appearance adoption is deliberately deferred behind our fill and stroke
markers.
This commit is contained in:
Dennis Kobert
2026-09-08 15:03:57 +00:00
385 changed files with 34669 additions and 20078 deletions

View File

@@ -16,7 +16,7 @@ wgpu = ["dep:raster-types", "raster-types/wgpu"]
# Local dependencies
dyn-any = { workspace = true }
core-types = { workspace = true }
graphene-hash = { workspace = true }
graphene-hash = { workspace = true, features = ["derive"] }
vector-types = { workspace = true }
text-nodes = { workspace = true }
graphene-resource = { workspace = true }

View File

@@ -1,6 +1,7 @@
use core_types::transform::Footprint;
use dyn_any::{DynAny, StaticType, StaticTypeSized};
use glam::DVec2;
use graphene_hash::CacheHash;
use std::fmt::Debug;
use std::hash::{Hash, Hasher};
use std::ptr::addr_of;
@@ -61,7 +62,7 @@ pub trait GetEditorPreferences {
fn max_render_region_area(&self) -> u32;
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, graphene_hash::CacheHash)]
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, CacheHash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ExportFormat {
#[default]
@@ -69,14 +70,14 @@ pub enum ExportFormat {
Raster,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, DynAny, graphene_hash::CacheHash)]
#[derive(Debug, Default, Clone, Copy, PartialEq, DynAny, CacheHash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TimingInformation {
pub time: f64,
pub animation_time: Duration,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, DynAny, graphene_hash::CacheHash)]
#[derive(Debug, Default, Clone, Copy, PartialEq, DynAny, CacheHash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RenderConfig {
pub viewport: Footprint,
@@ -136,7 +137,7 @@ impl<Io> Hash for EditorApi<Io> {
}
}
impl<Io> core_types::graphene_hash::CacheHash for EditorApi<Io> {
impl<Io> CacheHash for EditorApi<Io> {
fn cache_hash<H: core::hash::Hasher>(&self, state: &mut H) {
core::hash::Hash::hash(self, state);
}

View File

@@ -0,0 +1,23 @@
[package]
name = "brush-types"
version = "0.1.0"
edition = "2024"
description = "The brush stroke data format for Graphene"
authors = ["Graphite Authors <contact@graphite.art>"]
license = "MIT OR Apache-2.0"
[features]
default = ["serde"]
serde = ["dep:serde", "core-types/serde"]
[dependencies]
# Local dependencies
core-types = { workspace = true }
graphene-hash = { workspace = true }
# Workspace dependencies
dyn-any = { workspace = true }
glam = { workspace = true }
# Optional workspace dependencies
serde = { workspace = true, optional = true }

View File

@@ -0,0 +1,250 @@
//! Opaque render state cached per footprint.
//!
//! ```ignore
//! let state: SomeState = cache.take(ctx.footprint()).unwrap_or_default();
//! // ...render, freely mutating the state
//! cache.store(ctx.footprint(), state);
//! ```
use core_types::transform::Footprint;
use glam::DMat2;
use std::sync::{Arc, Mutex};
const STALE_EPOCHS: u64 = 2;
const MAX_VIEWS: usize = 3;
#[derive(Clone)]
pub struct BrushCache {
state: Arc<Mutex<State>>,
nonce: u64, // Avoid deduplication of cache entries across different brush nodes.
}
impl Default for BrushCache {
fn default() -> Self {
Self {
state: Default::default(),
nonce: core_types::uuid::generate_uuid(),
}
}
}
impl BrushCache {
pub fn take<S: std::any::Any + Send + Sync>(&self, footprint: &Footprint) -> Option<S> {
let mut guard = self.state.lock().unwrap();
let state = guard.take(footprint)?;
match state.downcast() {
Ok(state) => Some(*state),
Err(state) => {
guard.store(footprint, state);
None
}
}
}
pub fn store<S: std::any::Any + Send + Sync>(&self, footprint: &Footprint, state: S) {
self.state.lock().unwrap().store(footprint, Box::new(state));
}
}
impl PartialEq for BrushCache {
fn eq(&self, _: &Self) -> bool {
true
}
}
impl std::fmt::Debug for BrushCache {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("BrushCache").field("slots", &self.state.lock().unwrap().slots.len()).finish()
}
}
impl core_types::CacheHash for BrushCache {
fn cache_hash<H: core::hash::Hasher>(&self, state: &mut H) {
state.write_u64(self.nonce);
}
}
unsafe impl dyn_any::StaticType for BrushCache {
type Static = BrushCache;
}
#[cfg(feature = "serde")]
impl serde::Serialize for BrushCache {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_unit()
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for BrushCache {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
serde::de::IgnoredAny::deserialize(deserializer)?;
Ok(Self::default())
}
}
type BoxedData = Box<dyn std::any::Any + Send + Sync>;
#[derive(Default)]
struct State {
epoch: u64,
slots: Vec<Slot>,
}
struct Slot {
footprint: Footprint,
epoch: u64,
data: BoxedData,
}
impl Slot {
fn view(&self) -> DMat2 {
self.footprint.transform.matrix2
}
}
impl State {
fn take(&mut self, footprint: &Footprint) -> Option<BoxedData> {
self.touch(footprint.transform.matrix2);
let index = self.slots.iter().position(|slot| slot.footprint == *footprint);
let hit = index.map(|index| {
let slot = self.slots.remove(index);
if slot.epoch == self.epoch {
self.epoch += 1;
}
slot.data
});
self.retire();
hit
}
fn store(&mut self, footprint: &Footprint, data: BoxedData) {
self.touch(footprint.transform.matrix2);
self.slots.retain(|slot| slot.footprint != *footprint);
self.slots.push(Slot {
footprint: *footprint,
epoch: self.epoch,
data,
});
self.retire();
}
fn touch(&mut self, view: DMat2) {
self.slots.sort_by_key(|slot| slot.view() == view);
}
fn retire(&mut self) {
let epoch = self.epoch;
self.slots.retain(|slot| epoch - slot.epoch < STALE_EPOCHS);
while self.slots.chunk_by(|a, b| a.view() == b.view()).count() > MAX_VIEWS {
let front = self.slots[0].view();
let group = self.slots.iter().take_while(|slot| slot.view() == front).count();
self.slots.drain(..group.max(1));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use core_types::transform::RenderQuality;
use glam::{DAffine2, DVec2, UVec2};
struct Dummy;
fn view(zoom: f64, rotation: f64, pan: DVec2) -> Footprint {
Footprint {
transform: DAffine2::from_scale_angle_translation(DVec2::splat(zoom), rotation, pan),
resolution: UVec2::new(1920, 1080),
quality: RenderQuality::Full,
}
}
fn thumbnail(zoom: f64) -> Footprint {
Footprint {
resolution: UVec2::new(150, 150),
..view(zoom, 0., DVec2::ZERO)
}
}
fn live(cache: &BrushCache) -> usize {
cache.state.lock().unwrap().slots.len()
}
fn render(cache: &BrushCache, footprint: &Footprint) -> bool {
let hit = cache.take::<Dummy>(footprint).is_some();
cache.store(footprint, Dummy);
hit
}
#[test]
fn continuous_zoom_is_bounded_by_views() {
let cache = BrushCache::default();
for step in 0..100 {
render(&cache, &view(1. + step as f64 * 0.01, 0., DVec2::ZERO));
}
assert!(live(&cache) <= MAX_VIEWS);
}
#[test]
fn continuous_rotation_is_bounded_by_views() {
let cache = BrushCache::default();
for step in 0..100 {
render(&cache, &view(2., step as f64 * 0.01, DVec2::ZERO));
}
assert!(live(&cache) <= MAX_VIEWS);
}
#[test]
fn zooming_reclaims_pan_slots() {
let cache = BrushCache::default();
for step in 0..30 {
render(&cache, &view(1., 0., DVec2::splat(step as f64 * 100.)));
}
for step in 1..=3 {
render(&cache, &view(1. + step as f64, 0., DVec2::ZERO));
}
assert_eq!(live(&cache), 3);
}
#[test]
fn frames_may_hold_many_footprints_per_view() {
let cache = BrushCache::default();
let footprints: Vec<_> = (0..5).map(|step| view(1., 0., DVec2::splat(step as f64 * 100.))).collect();
for frame in 0..10 {
for footprint in &footprints {
assert_eq!(render(&cache, footprint), frame > 0, "footprint evicted while its frame still renders it");
}
}
assert_eq!(live(&cache), 5);
}
#[test]
fn thumbnail_drift_is_bounded_and_keeps_the_view() {
let cache = BrushCache::default();
for step in 0..100 {
render(&cache, &thumbnail(1. + step as f64 * 0.001));
}
assert!(live(&cache) <= MAX_VIEWS);
let viewport = view(2., 0., DVec2::ZERO);
render(&cache, &viewport);
for step in 0..50 {
render(&cache, &thumbnail(2. + step as f64 * 0.001));
assert!(render(&cache, &viewport), "thumbnail churn evicted the viewport slot");
}
}
#[test]
fn settled_view_retires_stale_slots() {
let cache = BrushCache::default();
for step in 0..3 {
render(&cache, &view(1. + step as f64, 0., DVec2::ZERO));
}
assert_eq!(live(&cache), 3);
for _ in 0..STALE_EPOCHS {
render(&cache, &view(1., 0., DVec2::ZERO));
}
assert_eq!(live(&cache), 1);
}
}

View File

@@ -0,0 +1,134 @@
pub mod cache;
pub use cache::BrushCache;
use core_types::CacheHash;
use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::render_complexity::RenderComplexity;
use dyn_any::DynAny;
use glam::{DAffine2, DVec2, Vec2};
use std::f32::consts::{PI, TAU};
#[derive(Clone, Debug, PartialEq, CacheHash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Channel<T> {
Uniform(T),
Samples(Vec<T>),
}
impl<T: Copy> Channel<T> {
pub fn get(&self, index: usize) -> T {
match self {
Self::Uniform(value) => *value,
Self::Samples(values) => values[index],
}
}
fn len(&self) -> Option<usize> {
match self {
Self::Uniform(_) => None,
Self::Samples(values) => Some(values.len()),
}
}
}
unsafe impl<T: dyn_any::StaticTypeSized> dyn_any::StaticType for Channel<T> {
type Static = Channel<T::Static>;
}
#[derive(Clone, Debug, PartialEq, CacheHash, DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Stroke {
pub position: Vec<DVec2>,
pub pressure: Channel<f32>,
pub tilt: Channel<Vec2>,
pub twist: Channel<f32>,
pub time: Channel<f64>,
pub seed: u64,
}
impl Default for Stroke {
fn default() -> Self {
Self {
position: Vec::new(),
pressure: Channel::Uniform(1.),
tilt: Channel::Uniform(Vec2::ZERO),
twist: Channel::Uniform(0.),
time: Channel::Uniform(0.),
seed: 0,
}
}
}
impl Stroke {
pub fn len(&self) -> usize {
self.position.len()
}
pub fn is_empty(&self) -> bool {
self.position.is_empty()
}
pub fn is_valid(&self) -> bool {
let n = self.len();
[self.pressure.len(), self.tilt.len(), self.twist.len(), self.time.len()].into_iter().flatten().all(|len| len == n)
}
pub fn sample(&self, index: usize) -> Sample {
Sample {
position: self.position[index],
pressure: self.pressure.get(index),
tilt: self.tilt.get(index),
twist: self.twist.get(index),
time: self.time.get(index),
}
}
pub fn sample_lerp(&self, index: usize, t: f32) -> Sample {
let a = self.sample(index);
let b = self.sample((index + 1).min(self.len().saturating_sub(1)));
Sample {
position: a.position.lerp(b.position, t as f64),
pressure: a.pressure + (b.pressure - a.pressure) * t,
tilt: a.tilt.lerp(b.tilt, t),
twist: {
let delta = (b.twist - a.twist).rem_euclid(TAU);
let delta = if delta > PI { delta - TAU } else { delta };
a.twist + delta * t
},
time: a.time + (b.time - a.time) * t as f64,
}
}
pub fn samples(&self) -> impl Iterator<Item = Sample> + '_ {
(0..self.len()).map(|index| self.sample(index))
}
}
impl BoundingBox for Stroke {
fn bounding_box(&self, transform: DAffine2, _include_stroke: bool) -> RenderBoundingBox {
let Some(first) = self.position.first() else { return RenderBoundingBox::None };
let (min, max) = self.position.iter().fold((*first, *first), |(min, max), &point| (min.min(point), max.max(point)));
let corners = [min, DVec2::new(max.x, min.y), max, DVec2::new(min.x, max.y)].map(|corner| transform.transform_point2(corner));
let (min, max) = corners.iter().fold((corners[0], corners[0]), |(min, max), &point| (min.min(point), max.max(point)));
RenderBoundingBox::Rectangle([min, max])
}
fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox {
self.bounding_box(transform, include_stroke)
}
}
impl RenderComplexity for Stroke {
fn render_complexity(&self) -> usize {
self.len()
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Sample {
pub position: DVec2,
pub pressure: f32,
pub tilt: Vec2,
pub twist: f32,
pub time: f64,
}

View File

@@ -16,7 +16,7 @@ pub trait BoundingBox {
/// Returns the bounding box to use when sizing this value's thumbnail in the Layers panel.
///
/// Diverges from `bounding_box` for types where the rendering bounds wouldn't make a useful thumbnail frame.
/// For instance, `GradientStops` is `Infinite` for rendering but returns the line's AABB here, so a `List<Graphic>`
/// For instance, `Gradient` is `Infinite` for rendering but returns the line's AABB here, so a `List<Graphic>`
/// group of a gradient and a vector frames around the vector's geometry rather than infinity.
/// Types with no meaningful contribution (e.g., `Color`) return `Infinite` from both; the runtime substitutes a
/// small fallback rectangle at the end if no finite bounds remain after combining.

View File

@@ -13,6 +13,7 @@ pub mod math;
pub mod memo;
pub mod misc;
pub mod node;
pub mod none;
pub mod ops;
pub mod record;
pub mod registry;
@@ -31,8 +32,9 @@ pub use dyn_any::{StaticTypeSized, WasmNotSend, WasmNotSync};
pub use graphene_hash;
pub use graphene_hash::CacheHash;
pub use list::{
ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_TEXT_FRAME, ATTR_END, ATTR_FONT_SIZE, ATTR_LETTER_SPACING, ATTR_LETTER_TILT,
ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_NAME, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_START, ATTR_TRANSFORM, ATTR_TYPE,
ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_TEXT_FRAME, ATTR_END, ATTR_FONT_SIZE, ATTR_GRADIENT_CYCLIC,
ATTR_GRADIENT_FORM, ATTR_GRADIENT_HUE_DIRECTION, ATTR_GRADIENT_INTERPOLATION, ATTR_GRADIENT_SPACE, ATTR_GRADIENT_SPREAD, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION,
ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_NAME, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_START, ATTR_TRANSFORM, ATTR_TYPE,
};
pub use memo::MemoHash;
pub use no_std_types::AsU32;
@@ -70,3 +72,23 @@ pub trait NodeInputDecleration {
fn identifier() -> ProtoNodeIdentifier;
type Result;
}
/// Master spells this trait `NodeParameter`; our node macro emits `NodeInputDecleration`.
pub use NodeInputDecleration as NodeParameter;
/// A runtime reference to one parameter of one proto node, for heterogeneous tables and runtime-chosen parameters.
/// Convert a symbol with `.into()`; unlike a raw index, the node identifier and index always stay paired.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ParameterRef {
pub node_identifier: ProtoNodeIdentifier,
pub input_index: usize,
}
impl<P: NodeParameter> From<P> for ParameterRef {
fn from(_: P) -> Self {
ParameterRef {
node_identifier: P::identifier(),
input_index: P::INDEX,
}
}
}

View File

@@ -1,7 +1,8 @@
use crate::attribute::Attribute as _;
use crate::bounds::{BoundingBox, RenderBoundingBox};
use crate::transform::ApplyTransform;
use dyn_any::{StaticType, StaticTypeSized};
use crate::uuid::NodeId;
use dyn_any::{DynAny, StaticType, StaticTypeSized};
use glam::DAffine2;
use graphene_hash::CacheHash;
use std::fmt::Debug;
@@ -37,6 +38,84 @@ pub const ATTR_MAX_WIDTH: &str = crate::attribute::MaxWidth::NAME;
pub const ATTR_MAX_HEIGHT: &str = crate::attribute::MaxHeight::NAME;
pub const ATTR_LETTER_TILT: &str = crate::attribute::LetterTilt::NAME;
// Name aliases for markers declared below core-types, so code here and in
// sibling crates can spell the name without depending on the owning crate.
// The marker, and with it the value type and the default, lives in the crate
// named beside each name; these are the string only.
// vector-types (gradient and stroke coverage):
pub const ATTR_GRADIENT_SPREAD: &str = "gradient_spread";
pub const ATTR_GRADIENT_FORM: &str = "gradient_form";
pub const ATTR_GRADIENT_SPACE: &str = "gradient_space";
pub const ATTR_GRADIENT_HUE_DIRECTION: &str = "gradient_hue_direction";
pub const ATTR_GRADIENT_INTERPOLATION: &str = "gradient_interpolation";
pub const ATTR_GRADIENT_CYCLIC: &str = "gradient_cyclic";
pub const ATTR_POSITION: &str = "position";
pub const ATTR_MIDPOINT: &str = "midpoint";
pub const ATTR_WEIGHT: &str = "weight";
pub const ATTR_DASH_OFFSET: &str = "dash_offset";
pub const ATTR_CAP: &str = "cap";
pub const ATTR_JOIN: &str = "join";
pub const ATTR_JOIN_MITER_LIMIT: &str = "join_miter_limit";
pub const ATTR_ALIGN: &str = "align";
// graphic-types (paint):
pub const ATTR_APPEARANCE: &str = "appearance";
pub const ATTR_PAINT: &str = "paint";
// vector-types (stroke coverage, value type is not `Copy` so it has no marker yet):
pub const ATTR_DASH_PATTERN: &str = "dash_pattern";
// brush-types (per-stroke styling):
pub const ATTR_COLOR: &str = "color";
pub const ATTR_DIAMETER: &str = "diameter";
pub const ATTR_HARDNESS: &str = "hardness";
pub const ATTR_FLOW: &str = "flow";
// =====================
// TYPE: NodeIdPath
// =====================
/// A single path of `NodeId`s locating a node (or its owning layer) within the nested document graph.
/// Wraps a `List<NodeId>` so it flows as one rank-0 value (`Item<NodeIdPath>`) rather than a rank-1
/// `List<NodeId>` that the element-wise machinery would wrongly zip over per ID.
#[derive(Default, Debug, Clone, PartialEq, CacheHash, DynAny)]
pub struct NodeIdPath(pub List<NodeId>);
impl From<Vec<NodeId>> for NodeIdPath {
fn from(ids: Vec<NodeId>) -> Self {
Self(ids.into_iter().map(Item::new_from_element).collect())
}
}
// ================
// TYPE: Bundle
// ================
/// A whole `List<T>` treated as one rank-0 value (`Item<Bundle<T>>`) rather than a rank-1 `List<T>`.
/// Bundling a collection lets it pass through a connector that selects or carries the entire collection as one opaque
/// cell (such as a Switch branch), instead of the element-wise machinery zipping over it per element.
#[derive(Clone, Debug, PartialEq)]
pub struct Bundle<T>(pub List<T>);
impl<T> Default for Bundle<T> {
fn default() -> Self {
Self(List::default())
}
}
impl<T: CacheHash> CacheHash for Bundle<T> {
fn cache_hash<H: core::hash::Hasher>(&self, state: &mut H) {
self.0.cache_hash(state);
}
}
impl<T> From<List<T>> for Bundle<T> {
fn from(list: List<T>) -> Self {
Self(list)
}
}
unsafe impl<T: StaticTypeSized> StaticType for Bundle<T> {
type Static = Bundle<T::Static>;
}
// ===========================
// Implicit attribute defaults
// ===========================
@@ -81,6 +160,13 @@ pub trait AnyAttributeValue: std::any::Any + Send + Sync {
/// Returns a debug-formatted string representation of this value.
fn display_string(&self) -> String;
/// Hashes this value into the given hasher (object-safe wrapper around `CacheHash`).
fn cache_hash_dyn(&self, state: &mut dyn core::hash::Hasher);
/// Compares this value to another for value-by-value equality (object-safe wrapper around `PartialEq`).
/// Returns `false` if the underlying types differ.
fn eq_dyn(&self, other: &dyn AnyAttributeValue) -> bool;
/// Wraps this scalar value into a new attribute, preceded by `preceding_defaults` implicit defaults for `key`.
fn into_attribute(self: Box<Self>, key: &str, preceding_defaults: usize) -> Box<dyn AnyAttribute>;
}
@@ -117,6 +203,17 @@ impl<T: Clone + Send + Sync + Default + Sized + Debug + PartialEq + CacheHash +
format!("{:?}", self)
}
/// Hashes this value into the given hasher (object-safe wrapper around `CacheHash`).
fn cache_hash_dyn(&self, state: &mut dyn core::hash::Hasher) {
self.cache_hash(&mut DynHasher(state));
}
/// Compares this value to another for value-by-value equality (object-safe wrapper around `PartialEq`).
/// Returns `false` if the underlying types differ.
fn eq_dyn(&self, other: &dyn AnyAttributeValue) -> bool {
other.as_any().downcast_ref::<Self>().is_some_and(|other| self == other)
}
/// Wraps this scalar value into a new attribute, preceded by `preceding_defaults` implicit defaults for `key`.
fn into_attribute(self: Box<Self>, key: &str, preceding_defaults: usize) -> Box<dyn AnyAttribute> {
let mut attribute: Box<dyn AnyAttribute> = Box::new(Attribute::<T>(Vec::with_capacity(preceding_defaults + 1)));
@@ -385,8 +482,7 @@ unsafe impl StaticType for AttributeDyn {
// ==================
/// Type-erased single attribute value, used as a node graph parameter type.
/// Lets a node accept a value of any concrete type via the auto-inserted `Convert<AttributeValueDyn, ()>`
/// without monomorphizing over the value type.
/// Lets a node accept a value of any valid concrete type via the auto-inserted input adapter conversion without monomorphizing over the value type.
pub struct AttributeValueDyn(pub Box<dyn AnyAttributeValue>);
impl Clone for AttributeValueDyn {
@@ -527,6 +623,17 @@ impl Debug for ItemAttributeValues {
}
}
impl PartialEq for ItemAttributeValues {
fn eq(&self, other: &Self) -> bool {
self.0.len() == other.0.len()
&& self
.0
.iter()
.zip(&other.0)
.all(|((self_key, self_value), (other_key, other_value))| self_key == other_key && self_value.eq_dyn(other_value.as_ref()))
}
}
impl ItemAttributeValues {
/// Creates an empty set of attributes.
pub fn new() -> Self {
@@ -602,6 +709,16 @@ impl ItemAttributeValues {
self.0.iter().map(|(key, value)| (key.as_str(), &**value))
}
/// Returns a type-erased reference to the value of the attribute with the given key, if it exists.
pub fn get_any(&self, key: &str) -> Option<&dyn std::any::Any> {
self.0.iter().find_map(|(existing_key, value)| if existing_key == key { Some((**value).as_any()) } else { None })
}
/// Returns an iterator over key and type-erased value pairs of all stored attributes, in insertion order.
pub fn iter_any(&self) -> impl Iterator<Item = (&str, &dyn std::any::Any)> {
self.0.iter().map(|(key, value)| (key.as_str(), (**value).as_any()))
}
/// Returns a debug-formatted string representation of the attribute value for the given key, if it exists.
/// The `overrides` function can provide custom formatting for specific type.
pub fn display_value(&self, key: &str, overrides: fn(&dyn std::any::Any) -> Option<String>) -> Option<String> {
@@ -1155,7 +1272,7 @@ impl<T: CacheHash> CacheHash for List<T> {
self.element.cache_hash(state);
// Hash every attribute attribute (key + values) rather than just the well-known ones, so changes to user-defined keys
// (e.g., gradient_type, spread_method) invalidate downstream graph caches as expected
// (e.g., gradient_form, gradient_spread) invalidate downstream graph caches as expected
for (key, attribute) in &self.attributes.attributes {
std::hash::Hash::hash(key.as_str(), state);
attribute.cache_hash_dyn(state);
@@ -1221,21 +1338,40 @@ impl<T> FromIterator<Item<T>> for List<T> {
/// An owned item containing an element of type `T` and a set of type-erased scalar attributes.
///
/// Used to build individual items before pushing them into a [`List`], or when consuming items out of a list via [`IntoIterator`].
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq)]
pub struct Item<T> {
element: T,
attributes: ItemAttributeValues,
}
impl<T: BoundingBox> BoundingBox for Item<T> {
/// Computes the element's bounding box, composing the item's transform attribute with the given transform.
fn bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox {
let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM);
self.element().bounding_box(transform * item_transform, include_stroke)
}
fn thumbnail_bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox {
let item_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM);
self.element().thumbnail_bounding_box(transform * item_transform, include_stroke)
}
}
impl<T: Default> Default for Item<T> {
fn default() -> Self {
Self::new_from_element(T::default())
}
}
impl<T: PartialEq> PartialEq for Item<T> {
fn eq(&self, other: &Self) -> bool {
self.element == other.element
impl<T: CacheHash> CacheHash for Item<T> {
fn cache_hash<H: core::hash::Hasher>(&self, state: &mut H) {
self.element.cache_hash(state);
// Hash every attribute (key + value) so attribute changes invalidate downstream caches, mirroring `List`
for (key, attribute) in &self.attributes.0 {
std::hash::Hash::hash(key.as_str(), state);
attribute.cache_hash_dyn(state);
}
}
}
@@ -1327,6 +1463,42 @@ impl<T> Item<T> {
}
}
impl<T> From<T> for Item<T> {
fn from(element: T) -> Self {
Self::new_from_element(element)
}
}
impl<T> From<Item<T>> for List<T> {
fn from(item: Item<T>) -> Self {
Self::new_from_item(item)
}
}
impl<T> From<T> for List<T> {
fn from(element: T) -> Self {
Self::new_from_element(element)
}
}
impl<T> ApplyTransform for Item<T> {
/// Right-multiplies the modification into the item's transform attribute.
fn apply_transform(&mut self, modification: &DAffine2) {
let transform = self.attribute_mut_or_insert_default::<DAffine2>(ATTR_TRANSFORM);
*transform *= *modification;
}
/// Left-multiplies the modification into the item's transform attribute.
fn left_apply_transform(&mut self, modification: &DAffine2) {
let transform = self.attribute_mut_or_insert_default::<DAffine2>(ATTR_TRANSFORM);
*transform = *modification * *transform;
}
}
unsafe impl<T: StaticTypeSized> StaticType for Item<T> {
type Static = Item<T::Static>;
}
// ===========
// ItemIter<T>
// ===========

View File

@@ -0,0 +1,71 @@
use std::fmt::Write;
/// Recovers the intended number from floating point imprecision noise when that can be done reliably, e.g. 0.30000000000000004 -> 0.3.
/// Rounding to each significant digit count from 1 to 12, the first candidate within a relative 1e-13 of the original is accepted.
/// Actual high-precision values (like 0.3333333333333333) never pass the tolerance and are returned unchanged.
/// f64 only, as f32 lacks precision to reliably distinguish between intentional digits and noise.
pub fn round_away_float_noise(value: f64) -> f64 {
if value == 0. || !value.is_finite() {
return if value == 0. { 0. } else { value };
}
// Candidates come from decimal formatting rather than scaling by a power of ten, which is inexact enough to invent
// noise of its own: it turns 1e300 into 9.999999999999999e299 and 999999.9999999 into 999999.9999999999.
// One buffer serves every candidate, since the digit counts are tried in turn.
let mut buffer = String::with_capacity(32);
for significant_digits in 1..=12 {
buffer.clear();
let _ = write!(buffer, "{value:.*e}", significant_digits - 1);
let Ok(rounded) = buffer.parse::<f64>() else { continue };
if ((rounded - value) / value).abs() < 1e-13 {
return rounded;
}
}
value
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_away_float_noise_snaps_noisy_values() {
assert_eq!(round_away_float_noise(0.1 + 0.2), 0.3);
assert_eq!(round_away_float_noise(0.3000000000000012), 0.3);
assert_eq!(round_away_float_noise(2.99999999999993), 3.);
assert_eq!(round_away_float_noise(45.00000000000001), 45.);
}
#[test]
fn round_away_float_noise_keeps_honest_values() {
assert_eq!(round_away_float_noise(1. / 3.), 1. / 3.);
assert_eq!(round_away_float_noise(0.2394023940209349), 0.2394023940209349);
assert_eq!(round_away_float_noise(0.25), 0.25);
assert_eq!(round_away_float_noise(-17.5), -17.5);
}
#[test]
fn round_away_float_noise_keeps_deliberate_values_with_zero_runs() {
assert_eq!(round_away_float_noise(0.30000005), 0.30000005);
assert_eq!(round_away_float_noise(0.3000000000001), 0.3000000000001);
assert_eq!(round_away_float_noise(1.00000001), 1.00000001);
assert_eq!(round_away_float_noise(2.9999993), 2.9999993);
}
#[test]
fn round_away_float_noise_normalizes_zero() {
let result = round_away_float_noise(-0.);
assert_eq!(result, 0.);
assert!(result.is_sign_positive());
}
#[test]
fn round_away_float_noise_keeps_extreme_magnitudes_exact() {
assert_eq!(round_away_float_noise(1e300), 1e300);
assert_eq!(round_away_float_noise(1.5e300), 1.5e300);
assert_eq!(round_away_float_noise(1e-300), 1e-300);
assert_eq!(round_away_float_noise(f64::MIN_POSITIVE), f64::MIN_POSITIVE);
}
}

View File

@@ -1,4 +1,5 @@
pub mod bbox;
pub mod float_noise;
pub mod polynomial;
pub mod quad;
pub mod rect;

View File

@@ -1,10 +1,9 @@
use kurbo::PathSeg;
use std::fmt::{self, Display, Formatter};
use std::ops::{Add, AddAssign, Mul, MulAssign, Neg, Sub, SubAssign};
use std::ops::{Mul, MulAssign};
/// A struct that represents a polynomial with a maximum degree of `N-1`.
///
/// It provides basic mathematical operations for polynomials like addition, multiplication, differentiation, integration, etc.
/// It provides basic mathematical operations for polynomials like multiplication, differentiation, integration, etc.
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct Polynomial<const N: usize> {
coefficients: [f64; N],
@@ -18,18 +17,6 @@ impl<const N: usize> Polynomial<N> {
Polynomial { coefficients }
}
/// Create a polynomial where all its coefficients are zero.
pub fn zero() -> Polynomial<N> {
Polynomial { coefficients: [0.; N] }
}
/// Return an immutable reference to the coefficients.
///
/// The coefficient for nth degree is at the nth index in array. Therefore the order of coefficients are reversed than the usual order for writing polynomials mathematically.
pub fn coefficients(&self) -> &[f64; N] {
&self.coefficients
}
/// Return a mutable reference to the coefficients.
///
/// The coefficient for nth degree is at the nth index in array. Therefore the order of coefficients are reversed than the usual order for writing polynomials mathematically.
@@ -83,98 +70,6 @@ impl<const N: usize> Polynomial<N> {
ans.derivative_mut();
ans
}
/// Computes the antiderivative at `C = 0`.
///
/// Returns `None` if the polynomial is not big enough to accommodate the extra degree.
pub fn antiderivative(&self) -> Option<Polynomial<N>> {
let mut ans = *self;
ans.antiderivative_mut()?;
Some(ans)
}
}
impl<const N: usize> Default for Polynomial<N> {
fn default() -> Self {
Self::zero()
}
}
impl<const N: usize> Display for Polynomial<N> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let mut first = true;
for (index, coefficient) in self.coefficients.iter().enumerate().rev().filter(|&(_, &coefficient)| coefficient != 0.) {
if first {
first = false;
} else {
f.write_str(" + ")?
}
coefficient.fmt(f)?;
if index == 0 {
continue;
}
f.write_str("x")?;
if index == 1 {
continue;
}
f.write_str("^")?;
index.fmt(f)?;
}
Ok(())
}
}
impl<const N: usize> AddAssign<&Polynomial<N>> for Polynomial<N> {
fn add_assign(&mut self, rhs: &Polynomial<N>) {
self.coefficients.iter_mut().zip(rhs.coefficients.iter()).for_each(|(a, b)| *a += b);
}
}
impl<const N: usize> Add for &Polynomial<N> {
type Output = Polynomial<N>;
fn add(self, other: &Polynomial<N>) -> Polynomial<N> {
let mut output = *self;
output += other;
output
}
}
impl<const N: usize> Neg for &Polynomial<N> {
type Output = Polynomial<N>;
fn neg(self) -> Polynomial<N> {
let mut output = *self;
output.coefficients.iter_mut().for_each(|x| *x = -*x);
output
}
}
impl<const N: usize> Neg for Polynomial<N> {
type Output = Polynomial<N>;
fn neg(mut self) -> Polynomial<N> {
self.coefficients.iter_mut().for_each(|x| *x = -*x);
self
}
}
impl<const N: usize> SubAssign<&Polynomial<N>> for Polynomial<N> {
fn sub_assign(&mut self, rhs: &Polynomial<N>) {
self.coefficients.iter_mut().zip(rhs.coefficients.iter()).for_each(|(a, b)| *a -= b);
}
}
impl<const N: usize> Sub for &Polynomial<N> {
type Output = Polynomial<N>;
fn sub(self, other: &Polynomial<N>) -> Polynomial<N> {
let mut output = *self;
output -= other;
output
}
}
impl<const N: usize> MulAssign<&Polynomial<N>> for Polynomial<N> {
@@ -248,18 +143,6 @@ mod test {
assert_eq!(p2.as_size::<2>(), None);
}
#[test]
fn addition_and_subtaction() {
let p1 = Polynomial::new([1., 2., 3.]);
let p2 = Polynomial::new([4., 5., 6.]);
let addition = Polynomial::new([5., 7., 9.]);
let subtraction = Polynomial::new([-3., -3., -3.]);
assert_eq!(&p1 + &p2, addition);
assert_eq!(&p1 - &p2, subtraction);
}
#[test]
fn multiplication() {
let p1 = Polynomial::new([1., 2., 3.]).as_size().unwrap();
@@ -278,15 +161,10 @@ mod test {
assert_eq!(p.derivative(), p_deriv);
p.coefficients_mut()[0] = 0.;
assert_eq!(p_deriv.antiderivative().unwrap(), p);
let mut antiderivative = p_deriv;
assert_eq!(antiderivative.antiderivative_mut(), Some(()));
assert_eq!(antiderivative, p);
assert_eq!(p.antiderivative(), None);
}
#[test]
fn display() {
let p = Polynomial::new([1., 2., 0., 3.]);
assert_eq!(format!("{p:.2}"), "3.00x^3 + 2.00x + 1.00");
assert_eq!(p.antiderivative_mut(), None);
}
}

View File

@@ -36,13 +36,6 @@ impl Rect {
bounds
}
/// Get all the edges in the rect.
#[must_use]
pub fn edges(&self) -> [[DVec2; 2]; 4] {
let corners = [self[0], DVec2::new(self[0].x, self[1].y), self[1], DVec2::new(self[1].y, self[0].x)];
[[corners[0], corners[1]], [corners[1], corners[2]], [corners[2], corners[3]], [corners[3], corners[0]]]
}
/// Gets the center of a rect
#[must_use]
pub fn center(&self) -> DVec2 {

View File

@@ -61,6 +61,27 @@ impl Clampable for DVec2 {
}
}
// Implement for ranked wires (element-wise clamping across the frame)
use crate::list::{Item, List};
impl<T: Clampable> Clampable for Item<T> {
fn clamp_hard_min(self, min: f64) -> Self {
let (element, attributes) = self.into_parts();
Item::from_parts(element.clamp_hard_min(min), attributes)
}
fn clamp_hard_max(self, max: f64) -> Self {
let (element, attributes) = self.into_parts();
Item::from_parts(element.clamp_hard_max(max), attributes)
}
}
impl<T: Clampable> Clampable for List<T> {
fn clamp_hard_min(self, min: f64) -> Self {
self.into_iter().map(|item| item.clamp_hard_min(min)).collect()
}
fn clamp_hard_max(self, max: f64) -> Self {
self.into_iter().map(|item| item.clamp_hard_max(max)).collect()
}
}
#[cfg(feature = "serde")]
#[derive(serde::Deserialize)]
struct LegacyTable<T> {
@@ -68,8 +89,8 @@ struct LegacyTable<T> {
element: Vec<T>,
}
// TODO: Eventually remove this migration document upgrade code
pub fn migrate_to_optional_color<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Option<no_std_types::color::Color>, D::Error> {
// TODO: Eventually remove this document upgrade code
pub fn migrate_to_color<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<no_std_types::color::Color, D::Error> {
use no_std_types::color::Color;
use serde::Deserialize;
@@ -81,12 +102,12 @@ pub fn migrate_to_optional_color<'de, D: serde::Deserializer<'de>>(deserializer:
}
Ok(match ColorFormat::deserialize(deserializer)? {
ColorFormat::OptionalColor(color) => color,
ColorFormat::List(list) => list.element.into_iter().next(),
ColorFormat::OptionalColor(color) => color.unwrap_or(Color::TRANSPARENT),
ColorFormat::List(list) => list.element.into_iter().next().unwrap_or(Color::TRANSPARENT),
})
}
// TODO: Eventually remove this migration document upgrade code
// TODO: Eventually remove this document upgrade code
pub fn migrate_to_f64_array<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Vec<f64>, D::Error> {
use serde::Deserialize;
@@ -103,6 +124,11 @@ pub fn migrate_to_f64_array<'de, D: serde::Deserializer<'de>>(deserializer: D) -
})
}
/// Parses a comma or space separated list of numbers, skipping any pieces that fail to parse.
pub fn parse_f64_list(text: &str) -> Vec<f64> {
text.split([',', ' ']).filter(|piece| !piece.is_empty()).filter_map(|piece| piece.parse::<f64>().ok()).collect()
}
/// Parse a CSS color string (named color, hex, `rgb(...)`, `hsl(...)`, etc.) into a linear-light [`Color`] using the `color` crate's CSS Color 4 parser.
/// Tries the input as-is first (catches CSS named colors like `red`, `rgb(...)`, and well-formed hex like `#abcdef`), then falls back to treating the input as bare hex with length-based expansion to a CSS-parseable form:
/// - 1 char `f` → `#fff` (CSS 3-char shorthand)

View File

@@ -0,0 +1,9 @@
use dyn_any::DynAny;
use graphene_hash::CacheHash;
/// An artist's declaration that there is no content here, distinct from the `()` type's "nothing was wired".
/// Visually represented as a red slash over a white background. Akin to the CSS `none` keyword.
///
/// Because its name matches the Rust prelude's `Option::None` variant, we always reference this as `none::None`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, CacheHash, DynAny)]
pub struct None;

View File

@@ -1,6 +1,7 @@
use crate::list::{Attribute, AttributeDyn, AttributeValueDyn, Item, List, ListDyn};
use crate::math::float_noise::round_away_float_noise;
use crate::transform::Footprint;
use glam::DVec2;
use glam::{DAffine2, DVec2};
use graphene_hash::CacheHash;
/// The [`Convert`] trait allows for conversion between Rust primitive numeric types.
@@ -17,11 +18,26 @@ pub trait ConvertAsync<T, C>: Sized {
fn convert(self, footprint: Footprint, converter: C) -> crate::runtime::SourceFuture<T>;
}
impl<T: ToString + Send> Convert<String, ()> for T {
/// Converts this type into a `String` using its `ToString` implementation.
/// Implements the [`Convert`] trait for formatting a type into a `String` via [`ToString`].
macro_rules! impl_convert_to_string {
($($from:ty),* $(,)?) => {
$(
impl Convert<String, ()> for $from {
#[inline]
fn convert(self, _: Footprint, _converter: ()) -> String {
self.to_string()
}
}
)*
};
}
impl_convert_to_string!(f32, u32, u64, i32, i64, bool, DVec2, DAffine2);
// Denoised so 0.1 + 0.2 reaches the string as "0.3" rather than "0.30000000000000004"
impl Convert<String, ()> for f64 {
#[inline]
fn convert(self, _: Footprint, _converter: ()) -> String {
self.to_string()
round_away_float_noise(self).to_string()
}
}
@@ -77,8 +93,7 @@ impl Convert<DVec2, ()> for DVec2 {
}
/// Constructs `Self` from a single anchor point at the given position. Implemented by the vector crate's
/// path type so the `Convert` impl below can build a single-point path without core-types depending on
/// that crate (mirroring how [`ListConvert`] bridges per-item list conversions).
/// path type so a position wire can convert to a single-point path without core-types depending on that crate.
pub trait FromAnchorPosition {
fn from_anchor_position(position: DVec2) -> Self;
}

View File

@@ -1,7 +1,7 @@
use crate::concrete;
use crate::context::{Context, ContextImpl};
use crate::node::Node;
use crate::{ContextFeature, ProtoNodeIdentifier, Type, WasmNotSend, WasmNotSync};
use crate::{Color, ContextFeature, ProtoNodeIdentifier, Type, WasmNotSend, WasmNotSync};
use dyn_any::DynAny;
use graphene_hash::CacheHash;
pub use no_std_types::registry::types;
@@ -35,6 +35,8 @@ pub struct FieldMetadata {
pub exposed: bool,
pub widget_override: RegistryWidgetOverride,
pub value_source: RegistryValueSource,
/// The default expression's colors, resolved by the macro when the expression consists solely of `Color::*` constants.
pub default_colors: Option<&'static [Color]>,
pub default_type: Option<Type>,
/// The slider's suggested extent, from `#[soft(a..b)]`. Typed values may exceed it.
pub number_soft_min: Option<f64>,

View File

@@ -1,6 +1,6 @@
// Raster types moved to raster-types crate
use crate::Color;
use crate::list::List;
use crate::list::{Item, List};
pub trait RenderComplexity {
fn render_complexity(&self) -> usize {
@@ -8,6 +8,12 @@ pub trait RenderComplexity {
}
}
impl<T: RenderComplexity> RenderComplexity for Item<T> {
fn render_complexity(&self) -> usize {
self.element().render_complexity()
}
}
impl<T: RenderComplexity> RenderComplexity for List<T> {
fn render_complexity(&self) -> usize {
self.iter_element_values().map(|element| element.render_complexity()).fold(0, usize::saturating_add)

View File

@@ -217,6 +217,23 @@ impl From<()> for Footprint {
}
}
/// Consumes an item's `transform` attribute by baking it into the underlying value itself.
pub trait BakeTransform {
fn bake_transform(&mut self, transform: &DAffine2);
}
impl BakeTransform for DAffine2 {
fn bake_transform(&mut self, transform: &DAffine2) {
*self = *transform * *self;
}
}
impl BakeTransform for DVec2 {
fn bake_transform(&mut self, transform: &DAffine2) {
*self = transform.transform_point2(*self);
}
}
pub trait ApplyTransform {
fn apply_transform(&mut self, modification: &DAffine2);
fn left_apply_transform(&mut self, modification: &DAffine2);

View File

@@ -61,8 +61,48 @@ macro_rules! generic {
($type:ty) => {{ $crate::Type::Generic($crate::Cow::Borrowed(stringify!($type))) }};
}
/// Constructs the [`Type`] of an `Item` holding the given element type, e.g. `item!(f64)` is the type of an `Item<f64>`.
/// The two-argument form tags the element descriptor with an alias, preserving the source spelling for widget dispatch.
#[macro_export]
macro_rules! item {
(Item<$inner:ty>) => {
$crate::Type::Item(Box::new($crate::item!($inner)))
};
($element:ty) => {
$crate::Type::Item(Box::new($crate::concrete!($element)))
};
($element:ty, $alias:ty) => {
$crate::Type::Item(Box::new($crate::concrete!($element, $alias)))
};
}
/// Constructs the [`Type`] of a `List` holding the given element type, e.g. `list!(f64)` is the type of a `List<f64>`.
#[macro_export]
macro_rules! list {
(List<$inner:ty>) => {
$crate::Type::List(Box::new($crate::list!($inner)))
};
($element:ty) => {
$crate::Type::List(Box::new($crate::concrete!($element)))
};
}
// The `List<...>`/`Item<...>` rules must appear before the generic `$type:ty` rules, and in each macro that sees the literal tokens,
// because a type captured as `ty` becomes opaque to any inner macro's ranked pattern
#[macro_export]
macro_rules! future {
(List<$inner:ty>) => {
$crate::Type::Future(Box::new($crate::list!($inner)))
};
(List<$inner:ty>, $name:ty) => {
$crate::Type::Future(Box::new($crate::list!($inner)))
};
(Item<$inner:ty>) => {
$crate::Type::Future(Box::new($crate::item!($inner)))
};
(Item<$inner:ty>, $name:ty) => {
$crate::Type::Future(Box::new($crate::item!($inner, $name)))
};
($type:ty) => {{ $crate::Type::Future(Box::new(concrete!($type))) }};
($type:ty, $name:ty) => {
$crate::Type::Future(Box::new(concrete!($type, $name)))
@@ -71,9 +111,27 @@ macro_rules! future {
#[macro_export]
macro_rules! fn_type_fut {
(List<$inner:ty>) => {
$crate::Type::Fn(Box::new(concrete!(())), Box::new($crate::Type::Future(Box::new($crate::list!($inner)))))
};
(Item<$inner:ty>) => {
$crate::Type::Fn(Box::new(concrete!(())), Box::new($crate::Type::Future(Box::new($crate::item!($inner)))))
};
($type:ty) => {
$crate::Type::Fn(Box::new(concrete!(())), Box::new(future!($type)))
};
($in_type:ty, List<$inner:ty>, alias: $outname:ty) => {
$crate::Type::Fn(Box::new(concrete!($in_type)), Box::new($crate::Type::Future(Box::new($crate::list!($inner)))))
};
($in_type:ty, List<$inner:ty>) => {
$crate::Type::Fn(Box::new(concrete!($in_type)), Box::new($crate::Type::Future(Box::new($crate::list!($inner)))))
};
($in_type:ty, Item<$inner:ty>, alias: $outname:ty) => {
$crate::Type::Fn(Box::new(concrete!($in_type)), Box::new($crate::Type::Future(Box::new($crate::item!($inner, $inner)))))
};
($in_type:ty, Item<$inner:ty>) => {
$crate::Type::Fn(Box::new(concrete!($in_type)), Box::new($crate::Type::Future(Box::new($crate::item!($inner)))))
};
($in_type:ty, $type:ty, alias: $outname:ty) => {
$crate::Type::Fn(Box::new(concrete!($in_type)), Box::new(future!($type, $outname)))
};
@@ -375,12 +433,13 @@ pub fn make_type_user_readable(ty: &str) -> String {
.replace("UVec2", "Vec2")
.replace("&str", "String");
rewrite_list_as_array_brackets(&ty)
rewrite_ranked_type_wrappers(&ty)
}
/// Rewrites `List<T>` as `T[]`. Handles nesting (e.g. `List<List<Vector>>` becomes `Vector[][]`).
/// Respects word boundaries so unrelated identifiers that happen to end in `List` are not affected.
fn rewrite_list_as_array_brackets(input: &str) -> String {
/// Rewrites `List<T>` and the whole-collection `Bundle<T>` as `T[]`, and unwraps `Item<T>` to `T`, so ranked wires read as their element type.
/// Handles nesting (e.g. `List<List<Vector>>` becomes `Vector[][]`).
/// Respects word boundaries so unrelated identifiers that happen to end in `List` or `Item` are not affected.
fn rewrite_ranked_type_wrappers(input: &str) -> String {
let bytes = input.as_bytes();
let mut result = String::with_capacity(input.len());
let mut i = 0;
@@ -391,12 +450,31 @@ fn rewrite_list_as_array_brackets(input: &str) -> String {
let inner_start = i + b"List<".len();
if let Some(close) = find_matching_angle_bracket(bytes, inner_start) {
let inner = &input[inner_start..close];
result.push_str(&rewrite_list_as_array_brackets(inner));
result.push_str(&rewrite_ranked_type_wrappers(inner));
result.push_str("[]");
i = close + 1;
continue;
}
}
if at_word_boundary && bytes[i..].starts_with(b"Bundle<") {
let inner_start = i + b"Bundle<".len();
if let Some(close) = find_matching_angle_bracket(bytes, inner_start) {
let inner = &input[inner_start..close];
result.push_str(&rewrite_ranked_type_wrappers(inner));
result.push_str("[]");
i = close + 1;
continue;
}
}
if at_word_boundary && bytes[i..].starts_with(b"Item<") {
let inner_start = i + b"Item<".len();
if let Some(close) = find_matching_angle_bracket(bytes, inner_start) {
let inner = &input[inner_start..close];
result.push_str(&rewrite_ranked_type_wrappers(inner));
i = close + 1;
continue;
}
}
if bytes[i].is_ascii() {
result.push(bytes[i] as char);
i += 1;

View File

@@ -57,6 +57,7 @@ impl_via_hash! {
bool, char,
u8, u16, u32, u64, u128, usize,
i8, i16, i32, i64, i128, isize,
core::time::Duration,
// glam integer vector types have Hash
glam::UVec2, glam::UVec3, glam::UVec4,
glam::IVec2, glam::IVec3, glam::IVec4,
@@ -68,7 +69,6 @@ impl_via_hash! {
#[cfg(feature = "std")]
impl_via_hash! {
String,
core::time::Duration,
}
impl<'a> CacheHash for std::borrow::Cow<'a, str> {

View File

@@ -20,6 +20,7 @@ wasm = [
# Local dependencies
core-types = { workspace = true }
graphene-hash = { workspace = true }
brush-types = { workspace = true }
raster-types = { workspace = true, features = ["wgpu"] }
vector-types = { workspace = true }
node-macro = { workspace = true }

View File

@@ -0,0 +1,386 @@
//! The appearance model: an ordered list of paint passes ("coverages") stored in the `ATTR_APPEARANCE` attribute.
//! Data uniform across all covers (the paint) rides the outer `List<Coverage>` so columnar presence holds,
//! while cover-specific data rides the inner `Item<Cover>`, reusing `ATTR_TRANSFORM` for the stroke-authoring space.
use crate::graphic::Graphic;
use core_types::graphene_hash::CacheHash;
use core_types::list::{ATTR_ALIGN, ATTR_APPEARANCE, ATTR_CAP, ATTR_DASH_OFFSET, ATTR_DASH_PATTERN, ATTR_JOIN, ATTR_JOIN_MITER_LIMIT, ATTR_PAINT, ATTR_TRANSFORM, ATTR_WEIGHT, Item, List};
use vector_types::vector::style::{DashPattern, Stroke};
/// The geometry-to-region operator a coverage applies before painting:
/// the interior of the geometry (fill) or the region swept along its outline (stroke).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, CacheHash)]
pub enum Cover {
#[default]
Fill,
Stroke,
}
impl std::fmt::Display for Cover {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Fill => write!(f, "Fill"),
Self::Stroke => write!(f, "Stroke"),
}
}
}
/// One paint pass of an [`Appearance`]: a [`Cover`] plus its cover-specific parameters, carried as
/// attributes on the inner item. Attributes for stroke parameters are ignored on fill coverages.
#[derive(Clone, Debug, Default, PartialEq, CacheHash)]
pub struct Coverage(pub Item<Cover>);
/// An item's ordered list of paint passes, stored in the `ATTR_APPEARANCE` attribute cell.
/// Earlier coverages paint first, compositing below later ones. Each row's paint is the
/// `ATTR_PAINT` attribute beside it.
///
/// The empty appearance is its elided attribute default form, and the state in which it is
/// replaced by an inherited appearance from an outer level of the cascade.
#[derive(Clone, Debug, Default, PartialEq, CacheHash)]
pub struct Appearance(pub List<Coverage>);
/// Where a newly inserted coverage lands in the paint order when no same-cover coverage exists to replace.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CoverPlacement {
/// The front of the list, painting first (below every existing pass).
Below,
/// The back of the list, painting last (above every existing pass).
Above,
}
impl Coverage {
/// Creates a fill coverage with no parameters beyond its cover.
pub fn new_fill() -> Self {
Self(Item::new_from_element(Cover::Fill))
}
/// Creates a stroke coverage, stamping only the parameters that differ from their implicit defaults.
pub fn new_stroke(stroke: &Stroke) -> Self {
let defaults = Stroke::default();
let mut item = Item::new_from_element(Cover::Stroke);
if stroke.weight != defaults.weight {
item.set_attribute(ATTR_WEIGHT, stroke.weight);
}
if !stroke.dash_lengths.is_empty() {
item.set_attribute(ATTR_DASH_PATTERN, DashPattern::from(stroke.dash_lengths.clone()));
}
if stroke.dash_offset != defaults.dash_offset {
item.set_attribute(ATTR_DASH_OFFSET, stroke.dash_offset);
}
if stroke.cap != defaults.cap {
item.set_attribute(ATTR_CAP, stroke.cap);
}
if stroke.join != defaults.join {
item.set_attribute(ATTR_JOIN, stroke.join);
}
if stroke.join_miter_limit != defaults.join_miter_limit {
item.set_attribute(ATTR_JOIN_MITER_LIMIT, stroke.join_miter_limit);
}
if stroke.align != defaults.align {
item.set_attribute(ATTR_ALIGN, stroke.align);
}
if stroke.transform != defaults.transform {
item.set_attribute(ATTR_TRANSFORM, stroke.transform);
}
Self(item)
}
/// This coverage's cover.
pub fn cover(&self) -> Cover {
*self.0.element()
}
/// Extracts the stroke parameters into a [`Stroke`], falling back to the default for any absent attribute.
/// Dash lengths are clamped to non-negative, matching what rendering accepts.
pub fn stroke_params(&self) -> Stroke {
// A single walk of the attribute pairs instead of one keyed scan per parameter, since this runs per item per render pass
let mut stroke = Stroke::default();
for (key, value) in self.0.attributes().iter_any() {
match key {
ATTR_WEIGHT => stroke.weight = value.downcast_ref().copied().unwrap_or(stroke.weight),
ATTR_DASH_PATTERN => stroke.dash_lengths = value.downcast_ref::<DashPattern>().map(DashPattern::clamped_lengths).unwrap_or(stroke.dash_lengths),
ATTR_DASH_OFFSET => stroke.dash_offset = value.downcast_ref().copied().unwrap_or(stroke.dash_offset),
ATTR_CAP => stroke.cap = value.downcast_ref().copied().unwrap_or(stroke.cap),
ATTR_JOIN => stroke.join = value.downcast_ref().copied().unwrap_or(stroke.join),
ATTR_JOIN_MITER_LIMIT => stroke.join_miter_limit = value.downcast_ref().copied().unwrap_or(stroke.join_miter_limit),
ATTR_ALIGN => stroke.align = value.downcast_ref().copied().unwrap_or(stroke.align),
ATTR_TRANSFORM => stroke.transform = value.downcast_ref().copied().unwrap_or(stroke.transform),
_ => {}
}
}
stroke
}
}
/// Builds an appearance row, eliding the paint attribute when it is the default none-paint.
fn cover_row(coverage: Coverage, paint: Graphic<'static>) -> Item<Coverage> {
let mut row = Item::new_from_element(coverage);
if paint != Graphic::default() {
row.set_attribute(ATTR_PAINT, paint);
}
row
}
impl Appearance {
/// Creates an appearance holding a single coverage with the given paint.
pub fn new_single(coverage: Coverage, paint: Graphic<'static>) -> Self {
Self(List::new_from_item(cover_row(coverage, paint)))
}
/// The number of coverages in this appearance.
pub fn len(&self) -> usize {
self.0.len()
}
/// Whether this appearance holds no coverages, the undeclared state that defers to the cascade.
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
/// This appearance if it declares any coverages, or `None` for the undeclared (empty) state.
pub fn declared(&self) -> Option<&Self> {
(!self.is_empty()).then_some(self)
}
/// Resolves the cascade for one item: its own declared appearance wins, while an undeclared
/// (absent or empty) cell inherits the nearest ancestor's declared appearance.
pub fn cascade<'a>(own: Option<&'a Self>, inherited: Option<&'a Self>) -> Option<&'a Self> {
own.and_then(Self::declared).or(inherited)
}
/// Iterates the coverages in paint order.
pub fn covers(&self) -> impl Iterator<Item = &Coverage> {
self.0.iter_element_values()
}
/// The coverage at the given index in paint order.
pub fn cover_at(&self, index: usize) -> Option<&Coverage> {
self.0.element(index)
}
/// The paint of the coverage at the given index, or `None` if the paint attribute is absent.
pub fn paint_at(&self, index: usize) -> Option<&Graphic<'static>> {
self.0.attribute::<Graphic<'static>>(ATTR_PAINT, index)
}
/// The index of the first coverage of the given cover in paint order.
pub fn first_index_of(&self, cover: Cover) -> Option<usize> {
self.covers().position(|coverage| coverage.cover() == cover)
}
/// The first coverage of the given cover in paint order.
pub fn first_coverage_of(&self, cover: Cover) -> Option<&Coverage> {
self.first_index_of(cover).and_then(|index| self.cover_at(index))
}
/// The paint of the first coverage of the given cover, filtered to paint that draws something.
pub fn first_paint_of(&self, cover: Cover) -> Option<&Graphic<'static>> {
self.first_index_of(cover).and_then(|index| self.paint_at(index)).filter(|paint| !paint.is_empty())
}
/// Iterates the coverages in paint order together with their paint, which is `None` when absent or drawing nothing.
pub fn covers_with_paints(&self) -> impl Iterator<Item = (&Coverage, Option<&Graphic<'static>>)> {
self.covers().enumerate().map(|(index, coverage)| (coverage, self.paint_at(index).filter(|paint| !paint.is_empty())))
}
/// Gathers the renderer's per-item reads in one walk of the coverage list.
pub fn fill_and_stroke(&self) -> FillAndStroke<'_> {
let mut first_fill = None;
let mut first_stroke = None;
for (index, coverage) in self.covers().enumerate() {
match coverage.cover() {
Cover::Fill if first_fill.is_none() => first_fill = Some(index),
Cover::Stroke if first_stroke.is_none() => first_stroke = Some((index, coverage)),
_ => {}
}
}
let painted = |index| self.paint_at(index).filter(|paint| !paint.is_empty());
FillAndStroke {
stroke: first_stroke.map(|(_, coverage)| coverage.stroke_params()),
fill_paint: first_fill.and_then(painted),
stroke_paint: first_stroke.and_then(|(index, _)| painted(index)),
stroke_below: first_stroke.zip(first_fill).is_some_and(|((stroke_index, _), fill_index)| stroke_index < fill_index),
}
}
/// Whether any coverage of the given cover exists, regardless of whether its paint draws anything.
pub fn has_cover(&self, cover: Cover) -> bool {
self.first_index_of(cover).is_some()
}
/// Whether any coverage of the given cover has paint that draws something, i.e. paint that is
/// present and not empty. A coverage whose paint is [`Graphic::None`] exists but paints nothing.
pub fn has_painted_cover(&self, cover: Cover) -> bool {
self.covers()
.enumerate()
.any(|(index, coverage)| coverage.cover() == cover && self.paint_at(index).is_some_and(|paint| !paint.is_empty()))
}
/// Replaces the first coverage of the incoming cover in place (keeping its position in the paint order),
/// or inserts a new row at the requested end of the paint order if none exists.
pub fn replace_or_insert(&mut self, coverage: Coverage, paint: Graphic<'static>, placement: CoverPlacement) {
if let Some(index) = self.first_index_of(coverage.cover()) {
if let Some(element) = self.0.element_mut(index) {
*element = coverage;
}
self.0.set_attribute(ATTR_PAINT, index, paint);
return;
}
let row = cover_row(coverage, paint);
match placement {
CoverPlacement::Above => self.0.push(row),
CoverPlacement::Below => {
let mut reordered = List::new_from_item(row);
reordered.extend(std::mem::take(&mut self.0));
self.0 = reordered;
}
}
}
/// Sets the paint of the first coverage of the given cover, leaving its other parameters untouched.
/// Returns `false` without changing anything if no coverage of that cover exists.
pub fn set_paint_of(&mut self, cover: Cover, paint: Graphic<'static>) -> bool {
let Some(index) = self.first_index_of(cover) else { return false };
self.0.set_attribute(ATTR_PAINT, index, paint);
true
}
/// Discards every coverage that is not of the given cover, preserving the survivors' paint order.
pub fn retain_cover(&mut self, cover: Cover) {
self.0 = std::mem::take(&mut self.0).into_iter().filter(|row| row.element().cover() == cover).collect();
}
}
/// The first fill and stroke of an appearance in the form rendering consumes: the stroke's parameters,
/// each cover's first paint (filtered to paint that draws something), and their relative paint order.
#[derive(Debug, Default)]
pub struct FillAndStroke<'a> {
pub stroke: Option<Stroke>,
pub fill_paint: Option<&'a Graphic<'static>>,
pub stroke_paint: Option<&'a Graphic<'static>>,
/// Whether the first stroke coverage sits before the first fill in the paint order, painting below it.
pub stroke_below: bool,
}
/// Stamps a coverage into the item's `ATTR_APPEARANCE` cell, creating the attribute if absent.
/// The coverage replaces the first same-cover one in place, or lands at the placement end of the paint order.
pub fn stamp_coverage<T>(item: &mut Item<T>, coverage: Coverage, paint: Graphic<'static>, placement: CoverPlacement) {
item.attribute_mut_or_insert_default::<Appearance>(ATTR_APPEARANCE).replace_or_insert(coverage, paint, placement);
}
#[cfg(test)]
mod tests {
use super::*;
use core_types::Color;
use glam::{DAffine2, DVec2};
use vector_types::vector::style::{StrokeAlign, StrokeCap, StrokeJoin};
// Our leaf holds the element bare, so a solid paint is the color leaf itself.
fn solid_paint(color: Color) -> Graphic<'static> {
Graphic::Color(color)
}
fn paint_color(appearance: &Appearance, index: usize) -> Option<Color> {
let paint = appearance.paint_at(index)?;
let Graphic::Color(color) = paint else { return None };
Some(*color)
}
#[test]
fn stroke_params_survive_the_attribute_round_trip() {
let stroke = Stroke {
weight: 3.,
dash_lengths: vec![4., -2.],
dash_offset: 1.5,
cap: StrokeCap::Round,
join: StrokeJoin::Bevel,
join_miter_limit: 7.,
align: StrokeAlign::Inside,
transform: DAffine2::from_scale(DVec2::new(2., 3.)),
};
let coverage = Coverage::new_stroke(&stroke);
assert_eq!(coverage.cover(), Cover::Stroke);
let extracted = coverage.stroke_params();
assert_eq!(extracted.weight, 3.);
assert_eq!(extracted.dash_lengths, vec![4., 0.], "negative dash lengths should clamp to zero on extraction");
assert_eq!(extracted.dash_offset, 1.5);
assert_eq!(extracted.cap, StrokeCap::Round);
assert_eq!(extracted.join, StrokeJoin::Bevel);
assert_eq!(extracted.join_miter_limit, 7.);
assert_eq!(extracted.align, StrokeAlign::Inside);
assert_eq!(extracted.transform, DAffine2::from_scale(DVec2::new(2., 3.)));
}
#[test]
fn empty_appearance_is_undeclared_and_defers_to_the_cascade() {
let inherited = Appearance::new_single(Coverage::new_fill(), solid_paint(Color::BLACK));
let empty = Appearance::default();
assert!(empty.declared().is_none(), "an empty appearance should be undeclared");
assert!(inherited.declared().is_some(), "an appearance with a coverage should be declared");
assert_eq!(Appearance::cascade(Some(&empty), Some(&inherited)), Some(&inherited));
assert_eq!(Appearance::cascade(None, Some(&inherited)), Some(&inherited));
assert_eq!(Appearance::cascade(Some(&inherited), None), Some(&inherited));
assert_eq!(Appearance::cascade(Some(&empty), None), None);
assert_eq!(Appearance::cascade(None, None), None);
}
#[test]
fn default_valued_stroke_parameters_elide_to_absence() {
let coverage = Coverage::new_stroke(&Stroke::default());
assert_eq!(coverage.0.attributes().keys().count(), 0, "default parameters should stay absent");
assert_eq!(coverage.stroke_params(), Stroke::default(), "absent attributes should read back as the defaults");
let coverage = Coverage::new_stroke(&Stroke::new(2.));
let keys: Vec<_> = coverage.0.attributes().keys().collect();
assert_eq!(keys, vec![ATTR_WEIGHT], "only the non-default weight should be stamped");
assert_eq!(coverage.stroke_params().weight, 2.);
}
#[test]
fn replace_keeps_position_and_the_other_rows_paint() {
let mut appearance = Appearance::default();
appearance.replace_or_insert(Coverage::new_fill(), solid_paint(Color::RED), CoverPlacement::Above);
appearance.replace_or_insert(Coverage::new_stroke(&Stroke::new(2.)), solid_paint(Color::BLACK), CoverPlacement::Above);
appearance.replace_or_insert(Coverage::new_fill(), solid_paint(Color::BLUE), CoverPlacement::Above);
assert_eq!(appearance.len(), 2, "replacement should not add a row");
assert_eq!(appearance.cover_at(0).map(Coverage::cover), Some(Cover::Fill), "the fill should keep its position");
assert_eq!(paint_color(&appearance, 0), Some(Color::BLUE));
assert_eq!(paint_color(&appearance, 1), Some(Color::BLACK), "the stroke row's paint should be untouched");
}
#[test]
fn below_insertion_prepends_and_preserves_paint_columns() {
let mut appearance = Appearance::default();
appearance.replace_or_insert(Coverage::new_stroke(&Stroke::new(2.)), solid_paint(Color::BLACK), CoverPlacement::Above);
appearance.replace_or_insert(Coverage::new_fill(), solid_paint(Color::RED), CoverPlacement::Below);
let covers: Vec<_> = appearance.covers().map(Coverage::cover).collect();
assert_eq!(covers, vec![Cover::Fill, Cover::Stroke], "a below-placed fill should paint before the stroke");
assert_eq!(paint_color(&appearance, 0), Some(Color::RED));
assert_eq!(paint_color(&appearance, 1), Some(Color::BLACK), "the existing row's paint should survive the reorder");
}
#[test]
fn painted_cover_distinguishes_none_paint_from_absence() {
let mut appearance = Appearance::default();
appearance.replace_or_insert(Coverage::new_fill(), Graphic::default(), CoverPlacement::Above);
assert!(appearance.has_cover(Cover::Fill), "a none-painted coverage still exists");
assert!(!appearance.has_painted_cover(Cover::Fill), "a none-painted coverage draws nothing");
assert!(!appearance.has_cover(Cover::Stroke));
assert!(!appearance.has_painted_cover(Cover::Stroke));
appearance.replace_or_insert(Coverage::new_fill(), solid_paint(Color::RED), CoverPlacement::Above);
assert!(appearance.has_painted_cover(Cover::Fill));
}
}

View File

@@ -12,7 +12,7 @@ use glam::DAffine2;
/// enclosing `List<Artboard>`, not as fields here. This keeps `Artboard` a pure type-system boundary
/// that prevents arbitrary `List<List<...<Graphic>>>` nesting.
#[derive(Clone, Debug, Default, CacheHash, PartialEq, DynAny)]
pub struct Artboard<'e>(List<Graphic<'e>>);
pub struct Artboard<'e>(pub List<Graphic<'e>>);
impl<'e> Artboard<'e> {
pub fn new(content: List<Graphic<'e>>) -> Self {

View File

@@ -13,7 +13,7 @@ use core_types::node::Node;
use core_types::record::{Group, GroupItem, LevelStatus, materialize_level};
use core_types::uuid::NodeId;
use glam::{DAffine2, DVec2};
use vector_types::GradientStops;
use vector_types::Gradient;
/// The outcome of materializing a leveled wire into a group.
// The group is the render path's success payload; boxing it would add a heap allocation per materialized level.
@@ -93,7 +93,7 @@ pub fn batch_to_legacy(layout: &core_types::record::Layout, batch: core_types::n
.or_else(|| typed::<Raster<CPU>>(&item))
.or_else(|| typed::<Raster<GPU>>(&item))
.or_else(|| typed::<Color>(&item))
.or_else(|| typed::<GradientStops>(&item))
.or_else(|| typed::<Gradient>(&item))
.or_else(|| typed::<String>(&item))
.or_else(|| typed::<f64>(&item))
.or_else(|| typed::<u64>(&item))

View File

@@ -12,15 +12,17 @@ use vector_types::Vector;
pub fn map_groups_to_owned<'out>(graphic: &Graphic<'_>) -> Graphic<'out> {
match graphic {
Graphic::Group(group) => Graphic::Group(group.copy_out()),
Graphic::Graphic(children) => {
Graphic::GraphicList(children) => {
let mut out = List::new();
for item in children.clone().into_iter() {
let (element, attributes) = item.into_parts();
out.push(Item::from_parts(map_groups_to_owned(&element), attributes));
}
map_attribute_groups_to_owned(&mut out);
Graphic::Graphic(out)
Graphic::GraphicList(out)
}
Graphic::Stroke(stroke) => Graphic::Stroke(stroke.clone()),
Graphic::StrokeList(strokes) => Graphic::StrokeList(strokes.clone()),
Graphic::Vector(vector) => Graphic::Vector(vector.clone()),
Graphic::RasterCPU(raster) => Graphic::RasterCPU(raster.clone()),
Graphic::RasterGPU(raster) => Graphic::RasterGPU(raster.clone()),
@@ -35,13 +37,13 @@ pub fn map_groups_to_owned<'out>(graphic: &Graphic<'_>) -> Graphic<'out> {
pub fn map_groups_to_resident<'a>(graphic: &Graphic<'a>, arena: &'a core_types::arena::Arena) -> Option<Graphic<'a>> {
match graphic {
Graphic::Group(group) => group.replay(arena).map(Graphic::Group),
Graphic::Graphic(children) => {
Graphic::GraphicList(children) => {
let mut children = children.clone();
for child in children.iter_element_values_mut() {
*child = map_groups_to_resident(child, arena)?;
}
map_attribute_groups_to_resident(&mut children, arena)?;
Some(Graphic::Graphic(children))
Some(Graphic::GraphicList(children))
}
other => Some(other.clone()),
}
@@ -79,15 +81,17 @@ unsafe fn deep_repark_graphic(value: &(dyn std::any::Any + Send + Sync), dst: *m
pub fn map_groups_to_persistent<'p>(graphic: &Graphic<'_>, promotion: &core_types::record::Promotion<'p>) -> Option<Graphic<'p>> {
match graphic {
Graphic::Group(group) => group.to_persistent(promotion).map(Graphic::Group),
Graphic::Graphic(children) => {
Graphic::GraphicList(children) => {
let mut out = List::new();
for item in children.clone().into_iter() {
let (element, attributes) = item.into_parts();
out.push(Item::from_parts(map_groups_to_persistent(&element, promotion)?, attributes));
}
map_attribute_groups_to_persistent(&mut out, promotion)?;
Some(Graphic::Graphic(out))
Some(Graphic::GraphicList(out))
}
Graphic::Stroke(stroke) => Some(Graphic::Stroke(stroke.clone())),
Graphic::StrokeList(strokes) => Some(Graphic::StrokeList(strokes.clone())),
Graphic::Vector(vector) => Some(Graphic::Vector(vector.clone())),
Graphic::RasterCPU(raster) => Some(Graphic::RasterCPU(raster.clone())),
Graphic::RasterGPU(raster) => Some(Graphic::RasterGPU(raster.clone())),
@@ -195,7 +199,9 @@ fn graphic_retained_heap(graphic: &Graphic<'_>) -> usize {
Graphic::RasterCPU(raster) => raster.data.len() * size_of::<Color>(),
Graphic::Text(text) => text.len(),
Graphic::Gradient(gradient) => gradient.len() * size_of::<(f64, Color)>(),
Graphic::Graphic(children) => (0..children.len()).filter_map(|index| children.element(index)).map(graphic_retained_heap).sum(),
Graphic::GraphicList(children) => (0..children.len()).filter_map(|index| children.element(index)).map(graphic_retained_heap).sum(),
Graphic::Stroke(stroke) => stroke.position.len() * size_of::<glam::DVec2>(),
Graphic::StrokeList(strokes) => (0..strokes.len()).filter_map(|index| strokes.element(index)).map(|stroke| stroke.position.len() * size_of::<glam::DVec2>()).sum(),
Graphic::Group(_) | Graphic::RasterGPU(_) | Graphic::Color(_) => 0,
}
}
@@ -206,7 +212,6 @@ fn vector_retained_heap(vector: &Vector) -> usize {
size_of_val(vector.point_domain.ids())
+ size_of_val(vector.point_domain.positions())
+ size_of_val(vector.segment_domain.ids())
+ size_of_val(vector.region_domain.ids())
+ size_of_val(vector.colinear_manipulators.as_slice())
}
@@ -215,7 +220,7 @@ fn vector_retained_heap(vector: &Vector) -> usize {
fn graphic_contains_groups(graphic: &Graphic) -> bool {
match graphic {
Graphic::Group(_) => true,
Graphic::Graphic(children) => list_contains_groups(children),
Graphic::GraphicList(children) => list_contains_groups(children),
_ => false,
}
}

View File

@@ -6,7 +6,7 @@ use crate::markers::{ATTR_FILL, ATTR_STROKE};
use core_types::Color;
use core_types::list::{AttributeValueDyn, Item, List};
use raster_types::{CPU, GPU, Raster};
use vector_types::{GradientStops, Vector};
use vector_types::{Gradient, Vector};
/// One typed run as an owned list, elements cloned and every attribute copied
/// through its erased read. Content keeps its native form; the legacy
@@ -52,15 +52,17 @@ pub(crate) fn run_to_legacy_list<T: Clone + Send + Sync + dyn_any::StaticTypeSiz
pub fn map_groups_to_legacy<'out>(graphic: &Graphic<'_>) -> Graphic<'out> {
match graphic {
Graphic::Group(group) => group_to_legacy_graphic(group),
Graphic::Graphic(children) => {
Graphic::GraphicList(children) => {
let mut out = List::new();
for item in children.clone().into_iter() {
let (element, attributes) = item.into_parts();
out.push(Item::from_parts(map_groups_to_legacy(&element), attributes));
}
map_paint_attrs_to_legacy(&mut out);
Graphic::Graphic(out)
Graphic::GraphicList(out)
}
Graphic::Stroke(stroke) => Graphic::Stroke(stroke.clone()),
Graphic::StrokeList(strokes) => Graphic::StrokeList(strokes.clone()),
Graphic::Vector(vector) => Graphic::Vector(vector.clone()),
Graphic::RasterCPU(raster) => Graphic::RasterCPU(raster.clone()),
Graphic::RasterGPU(raster) => Graphic::RasterGPU(raster.clone()),
@@ -81,13 +83,13 @@ pub fn group_to_legacy_graphic(group: &core_types::record::Group) -> Graphic<'st
.or_else(|| run_to_legacy_list::<Raster<CPU>>(item).map(|list| detable_items(list, Graphic::RasterCPU)))
.or_else(|| run_to_legacy_list::<Raster<GPU>>(item).map(|list| detable_items(list, Graphic::RasterGPU)))
.or_else(|| run_to_legacy_list::<Color>(item).map(|list| detable_items(list, Graphic::Color)))
.or_else(|| run_to_legacy_list::<GradientStops>(item).map(|list| detable_items(list, Graphic::Gradient)))
.or_else(|| run_to_legacy_list::<Gradient>(item).map(|list| detable_items(list, Graphic::Gradient)))
.or_else(|| run_to_legacy_list::<String>(item).map(|list| detable_items(list, Graphic::Text)));
if let Some(typed) = typed {
return Graphic::Graphic(typed);
return Graphic::GraphicList(typed);
}
}
Graphic::Graphic(group_to_legacy_list(group))
Graphic::GraphicList(group_to_legacy_list(group))
}
/// The group as a legacy `List<Graphic>`: a `Graphic` run becomes the items,
@@ -105,7 +107,7 @@ pub fn group_to_legacy_list(group: &core_types::record::Group) -> List<Graphic<'
.or_else(|| run_to_legacy_list::<Raster<CPU>>(item).map(|list| detable_items(list, Graphic::RasterCPU)))
.or_else(|| run_to_legacy_list::<Raster<GPU>>(item).map(|list| detable_items(list, Graphic::RasterGPU)))
.or_else(|| run_to_legacy_list::<Color>(item).map(|list| detable_items(list, Graphic::Color)))
.or_else(|| run_to_legacy_list::<GradientStops>(item).map(|list| detable_items(list, Graphic::Gradient)))
.or_else(|| run_to_legacy_list::<Gradient>(item).map(|list| detable_items(list, Graphic::Gradient)))
.or_else(|| run_to_legacy_list::<String>(item).map(|list| detable_items(list, Graphic::Text)))
.unwrap_or_default()
}

View File

@@ -14,6 +14,7 @@ pub use paint::{
pub use walk::{GraphicLevel, GraphicLevelColumn, RowStep, VectorRow, direct_vector_len, flatten_vector_rows, group_is_empty, lane_attributes, run_lane_attributes, walk_vector_rows};
use walk::{group_all_clipped, group_bounding_box, group_is_fully_transparent, group_is_opaque, group_render_complexity};
use brush_types::Stroke;
use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::graphene_hash::CacheHash;
use core_types::list::{Item, List};
@@ -24,7 +25,7 @@ use core_types::{ATTR_CLIPPING_MASK, ATTR_EDITOR_LAYER_PATH, ATTR_OPACITY, ATTR_
use dyn_any::DynAny;
use glam::{DAffine2, DVec2};
use raster_types::{CPU, GPU, Raster};
use vector_types::GradientStops;
use vector_types::Gradient;
pub use vector_types::Vector;
/// The possible forms of graphical content that can be rendered by the Render node into either an image or SVG syntax.
@@ -33,19 +34,22 @@ pub use vector_types::Vector;
/// transitionally the legacy `Graphic` list.
#[derive(Clone, Debug, CacheHash, PartialEq, DynAny)]
pub enum Graphic<'e> {
Graphic(List<Graphic<'e>>),
GraphicList(List<Graphic<'e>>),
Vector(Vector),
RasterCPU(Raster<CPU>),
RasterGPU(Raster<GPU>),
Color(Color),
Gradient(GradientStops),
Gradient(Gradient),
Text(String),
Stroke(Stroke),
/// Transitional typed list, kept because master's brush nodes match on it directly.
StrokeList(List<Stroke>),
Group(core_types::record::Group<'e>),
}
impl Default for Graphic<'_> {
fn default() -> Self {
Self::Graphic(List::new())
Self::GraphicList(List::new())
}
}
@@ -101,8 +105,9 @@ into_graphic_element! {
RasterCPU: Raster<CPU>;
RasterGPU: Raster<GPU>;
Color: Color;
Gradient: GradientStops;
Gradient: Gradient;
Text: String;
Stroke: Stroke;
}
impl IntoGraphicElement for Graphic<'static> {
@@ -146,9 +151,9 @@ impl From<Color> for Graphic<'_> {
}
// Note: List<Color> -> Option<Color> is in gcore (Color is defined there)
// GradientStops
impl From<GradientStops> for Graphic<'_> {
fn from(gradient: GradientStops) -> Self {
// Gradient
impl From<Gradient> for Graphic<'_> {
fn from(gradient: Gradient) -> Self {
Graphic::Gradient(gradient)
}
}
@@ -160,6 +165,33 @@ impl From<String> for Graphic<'_> {
}
}
// Stroke
impl From<Stroke> for Graphic<'_> {
fn from(stroke: Stroke) -> Self {
Graphic::Stroke(stroke)
}
}
impl From<List<Stroke>> for Graphic<'_> {
fn from(strokes: List<Stroke>) -> Self {
Graphic::StrokeList(strokes)
}
}
/// Whether the list is a single bare leaf carrying no attribute of its own, so
/// wrapping it collapses no structure and rebuilding it would be busywork.
/// Master reads one `appearance` here; our paint is still the fill and stroke pair.
pub fn is_lone_anonymous_leaf(content: &List<Graphic>) -> bool {
content.len() == 1
&& !matches!(content.element(0), Some(Graphic::GraphicList(_)))
&& content.attribute::<DAffine2>(ATTR_TRANSFORM, 0).is_none()
&& content.attribute::<f64>(ATTR_OPACITY, 0).is_none()
&& content.attribute::<f64>(ATTR_OPACITY_FILL, 0).is_none()
&& content.attribute::<List<Graphic>>(crate::markers::ATTR_FILL, 0).is_none()
&& content.attribute::<List<Graphic>>(crate::markers::ATTR_STROKE, 0).is_none()
&& content.attribute::<Vec<NodeId>>(ATTR_EDITOR_LAYER_PATH, 0).is_none()
}
/// Deeply flattens a `List<Graphic>`, collecting only elements matching a specific variant (extracted by `extract_variant`)
/// and discarding all other non-matching content. Recursion through `Graphic::Graphic` sub-`List`s composes transforms and opacity.
fn flatten_graphic_list<T>(content: List<Graphic>, extract_variant: fn(Graphic) -> Option<List<T>>) -> List<T> {
@@ -181,7 +213,7 @@ fn flatten_graphic_list<T>(content: List<Graphic>, extract_variant: fn(Graphic)
match element {
// Compose the parent's transform/opacity/fill onto each child, but only for attributes the parent carries.
// A child lacking one is padded with the composition identity (`1.` for opacity/fill, identity for transform), so composing through it is a no-op.
Graphic::Graphic(mut sub_list) => {
Graphic::GraphicList(mut sub_list) => {
if parent_has_transform {
for v in sub_list.iter_attribute_values_mut_or_default::<DAffine2>(ATTR_TRANSFORM) {
*v = current_transform * *v;
@@ -251,7 +283,7 @@ impl TryFromGraphic for Color {
}
}
impl TryFromGraphic for GradientStops {
impl TryFromGraphic for Gradient {
fn try_from_graphic(graphic: Graphic) -> Option<List<Self>> {
if let Graphic::Gradient(t) = graphic { Some(List::new_from_element(t)) } else { None }
}
@@ -306,7 +338,7 @@ impl IntoGraphicList for List<Color> {
}
}
impl IntoGraphicList for List<GradientStops> {
impl IntoGraphicList for List<Gradient> {
fn into_graphic_list(self) -> List<Graphic<'static>> {
detable_items(self, Graphic::Gradient)
}
@@ -340,16 +372,16 @@ impl From<DVec2> for Graphic<'_> {
// Note: List conversions handled by blanket impl in gcore
impl<'e> Graphic<'e> {
pub fn as_graphic(&self) -> Option<&List<Graphic<'_>>> {
pub fn as_graphic_list(&self) -> Option<&List<Graphic<'_>>> {
match self {
Graphic::Graphic(graphic) => Some(graphic),
Graphic::GraphicList(graphic) => Some(graphic),
_ => None,
}
}
pub fn as_graphic_mut(&mut self) -> Option<&mut List<Graphic<'e>>> {
pub fn as_graphic_list_mut(&mut self) -> Option<&mut List<Graphic<'e>>> {
match self {
Graphic::Graphic(graphic) => Some(graphic),
Graphic::GraphicList(graphic) => Some(graphic),
_ => None,
}
}
@@ -382,7 +414,7 @@ impl<'e> Graphic<'e> {
}
match self {
Graphic::Graphic(list) => all_clipped(list),
Graphic::GraphicList(list) => all_clipped(list),
Graphic::Group(group) => group_all_clipped(group),
_ => false,
}
@@ -395,42 +427,43 @@ impl<'e> Graphic<'e> {
}
}
pub fn is_opaque(&self) -> bool {
pub fn is_guaranteed_fully_opaque(&self) -> bool {
match self {
Graphic::Graphic(list) => !list.is_empty() && list.iter_element_values().all(Graphic::is_opaque),
Graphic::GraphicList(list) => !list.is_empty() && list.iter_element_values().all(Graphic::is_guaranteed_fully_opaque),
// A bare leaf carries no paint attribute, which rides its lane, so
// nothing here claims opacity.
Graphic::Vector(_) => false,
Graphic::Color(color) => color.is_opaque(),
Graphic::Gradient(stops) => stops.iter().all(|stop| stop.color.is_opaque()),
Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) => false,
Graphic::Gradient(gradient) => !gradient.is_empty() && gradient.iter().all(|stop| stop.color.is_opaque()),
Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) | Graphic::Stroke(_) | Graphic::StrokeList(_) => false,
Graphic::Group(group) => group_is_opaque(group),
}
}
pub fn is_fully_transparent(&self) -> bool {
pub fn is_guaranteed_fully_transparent(&self) -> bool {
match self {
Graphic::Graphic(list) => list.iter_element_values().all(Graphic::is_fully_transparent),
// A bare leaf carries no paint attribute, so only an unstroked
// vector is invisible on its own.
Graphic::Vector(vector) => vector.stroke.as_ref().is_none_or(|stroke| !stroke.has_renderable_stroke()),
Graphic::GraphicList(list) => list.iter_element_values().all(Graphic::is_guaranteed_fully_transparent),
// A bare leaf carries no paint attribute, which rides its lane, so
// nothing here can prove invisibility.
Graphic::Vector(_) => false,
Graphic::Color(color) => color.a() == 0.,
Graphic::Gradient(stops) => stops.iter().all(|stop| stop.color.a() == 0.),
Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) => false,
// A stopless ramp paints solid black, so it counts as transparent only once it has stops.
Graphic::Gradient(gradient) => !gradient.is_empty() && gradient.iter().all(|stop| stop.color.a() == 0.),
Graphic::RasterCPU(_) | Graphic::RasterGPU(_) | Graphic::Text(_) | Graphic::Stroke(_) | Graphic::StrokeList(_) => false,
Graphic::Group(group) => group_is_fully_transparent(group),
}
}
/// True if this paint opaquely covers the entire fill region.
/// Vector, Raster, and a nested Graphic may leave gaps, so they return false.
pub fn covers_opaquely(&self) -> bool {
matches!(self, Graphic::Color(_) | Graphic::Gradient(_)) && self.is_opaque()
pub fn is_guaranteed_to_cover_opaquely(&self) -> bool {
matches!(self, Graphic::Color(_) | Graphic::Gradient(_)) && self.is_guaranteed_fully_opaque()
}
/// Whether the graphic holds no content: a leaf always holds its element.
pub fn is_empty(&self) -> bool {
match self {
Graphic::Graphic(list) => list.is_empty(),
Graphic::GraphicList(list) => list.is_empty(),
Graphic::Group(group) => group_is_empty(group),
_ => false,
}
@@ -443,10 +476,12 @@ impl BoundingBox for Graphic<'_> {
Graphic::Vector(vector) => BoundingBox::bounding_box(vector, transform, include_stroke),
Graphic::RasterCPU(raster) => raster.bounding_box(transform, include_stroke),
Graphic::RasterGPU(raster) => raster.bounding_box(transform, include_stroke),
Graphic::Graphic(list) => list.bounding_box(transform, include_stroke),
Graphic::GraphicList(list) => list.bounding_box(transform, include_stroke),
Graphic::Color(color) => color.bounding_box(transform, include_stroke),
Graphic::Gradient(gradient) => gradient.bounding_box(transform, include_stroke),
Graphic::Text(text) => text.bounding_box(transform, include_stroke),
// Brush strokes carry no vector outline; a brush node renders them to rasters.
Graphic::Stroke(_) | Graphic::StrokeList(_) => RenderBoundingBox::None,
Graphic::Group(group) => group_bounding_box(group, transform, include_stroke, false),
}
}
@@ -456,10 +491,11 @@ impl BoundingBox for Graphic<'_> {
Graphic::Vector(vector) => vector.thumbnail_bounding_box(transform, include_stroke),
Graphic::RasterCPU(raster) => raster.thumbnail_bounding_box(transform, include_stroke),
Graphic::RasterGPU(raster) => raster.thumbnail_bounding_box(transform, include_stroke),
Graphic::Graphic(graphic) => graphic.thumbnail_bounding_box(transform, include_stroke),
Graphic::GraphicList(graphic) => graphic.thumbnail_bounding_box(transform, include_stroke),
Graphic::Color(color) => color.thumbnail_bounding_box(transform, include_stroke),
Graphic::Gradient(gradient) => gradient.thumbnail_bounding_box(transform, include_stroke),
Graphic::Text(list) => list.thumbnail_bounding_box(transform, include_stroke),
Graphic::Stroke(_) | Graphic::StrokeList(_) => RenderBoundingBox::None,
Graphic::Group(group) => group_bounding_box(group, transform, include_stroke, true),
}
}
@@ -484,13 +520,15 @@ impl<'e> ListConvert<Graphic<'e>> for Raster<GPU> {
impl RenderComplexity for Graphic<'_> {
fn render_complexity(&self) -> usize {
match self {
Self::Graphic(list) => list.render_complexity(),
Self::GraphicList(list) => list.render_complexity(),
Self::Vector(list) => list.render_complexity(),
Self::RasterCPU(list) => list.render_complexity(),
Self::RasterGPU(list) => list.render_complexity(),
Self::Color(list) => list.render_complexity(),
Self::Gradient(list) => list.render_complexity(),
Self::Text(list) => list.render_complexity(),
Self::Stroke(stroke) => stroke.position.len(),
Self::StrokeList(strokes) => (0..strokes.len()).filter_map(|index| strokes.element(index)).map(|stroke| stroke.position.len()).sum(),
Self::Group(group) => group_render_complexity(group),
}
}
@@ -594,7 +632,7 @@ mod tests {
let flattened: List<Vector> = graphics.into_flattened_list();
assert_eq!(flattened.attribute_cloned_or_default::<f64>(ATTR_OPACITY, 0), 0.5);
let mut group = List::new_from_element(Graphic::Graphic(List::new_from_element(vector_graphic())));
let mut group = List::new_from_element(Graphic::GraphicList(List::new_from_element(vector_graphic())));
group.set_attribute(ATTR_OPACITY, 0, 0.5_f64);
let flattened: List<Vector> = group.into_flattened_list();
assert_eq!(flattened.attribute_cloned_or_default::<f64>(ATTR_OPACITY, 0), 0.5);
@@ -612,33 +650,33 @@ mod graphic_is_opaque_tests {
Graphic::Color(color)
}
fn gradient_graphic(gradient: GradientStops) -> Graphic<'static> {
fn gradient_graphic(gradient: Gradient) -> Graphic<'static> {
Graphic::Gradient(gradient)
}
#[test]
fn opaque_color_is_opaque() {
let g = color_graphic(1.);
assert!(g.is_opaque());
assert!(g.is_guaranteed_fully_opaque());
}
#[test]
fn transparent_color_is_not_opaque() {
let g = color_graphic(0.5);
assert!(!g.is_opaque());
assert!(!g.is_guaranteed_fully_opaque());
}
#[test]
fn vector_is_not_opaque() {
let g = Graphic::Vector(Vector::default());
assert!(!g.is_opaque());
assert!(!g.is_guaranteed_fully_opaque());
}
#[test]
fn gradient_with_all_opaque_stops_is_opaque() {
let color_1 = Color::from_rgbaf32(1., 0., 0., 1.).unwrap();
let color_2 = Color::from_rgbaf32(1., 0., 0., 1.).unwrap();
let gradient = GradientStops::new(vec![
let gradient = Gradient::new(vec![
GradientStop {
position: 0.,
midpoint: 0.5,
@@ -651,14 +689,14 @@ mod graphic_is_opaque_tests {
},
]);
let g = gradient_graphic(gradient);
assert!(g.is_opaque());
assert!(g.is_guaranteed_fully_opaque());
}
#[test]
fn gradient_with_transparent_stop_is_not_opaque() {
let color_1 = Color::from_rgbaf32(1., 0., 0., 0.5).unwrap();
let color_2 = Color::from_rgbaf32(1., 0., 0., 1.).unwrap();
let gradient = GradientStops::new(vec![
let gradient = Gradient::new(vec![
GradientStop {
position: 0.,
midpoint: 0.5,
@@ -671,7 +709,7 @@ mod graphic_is_opaque_tests {
},
]);
let g = gradient_graphic(gradient);
assert!(!g.is_opaque());
assert!(!g.is_guaranteed_fully_opaque());
}
}
@@ -682,11 +720,11 @@ mod test_support {
use core_types::record::{RunBuilder, element_write_hashed};
use glam::DVec2;
use vector_types::Vector;
use vector_types::subpath::Subpath;
use vector_types::vector::algorithms::shapes::rectangle_bezpath;
use vector_types::vector::PointId;
pub(in crate::graphic) fn unit_square_at(corner: DVec2) -> Vector {
Vector::from_subpath(Subpath::<PointId>::new_rectangle(corner, corner + DVec2::ONE))
Vector::from_bezpath(rectangle_bezpath(corner, corner + DVec2::ONE))
}
pub(in crate::graphic) fn native_group_paint<'a>(vector: &Vector, arena: &'a core_types::arena::Arena) -> List<Graphic<'a>> {

View File

@@ -41,13 +41,13 @@ where
/// opaque, fill absent or opaque, stroke invisible or fully transparent.
pub fn vector_can_reduce_to_clip_path<S: LaneSource<Element = Vector>>(source: &S) -> bool {
(0..source.lane_count()).all(|index| {
let Some(element) = source.element(index) else { return false };
let opacity: f64 = source.attr::<Opacity>(index);
let fill_opaque_or_absent = paint_graphics::<Fill, _>(source, index).is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_opaque()));
let fill_opaque_or_absent = paint_graphics::<Fill, _>(source, index).is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_guaranteed_fully_opaque()));
let stroke_invisible_or_transparent = element.stroke.as_ref().is_none_or(|stroke| !stroke.has_renderable_stroke())
|| paint_graphics::<Stroke, _>(source, index).is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_fully_transparent()));
// Master deleted `Vector::stroke`, so the stroke width term has no source here; only the paint term is left, which reduces to a clip path less often but never wrongly.
let stroke_invisible_or_transparent =
paint_graphics::<Stroke, _>(source, index).is_none_or(|graphic_list| graphic_list.element(0).is_none_or(|graphic| graphic.is_guaranteed_fully_transparent()));
opacity > 1. - f64::EPSILON && fill_opaque_or_absent && stroke_invisible_or_transparent
})
@@ -228,7 +228,7 @@ pub fn bake_paint_transforms(attributes: &mut ItemAttributeValues, transform: DA
*item_transform = transform * *item_transform;
}
for graphic in graphics.iter_element_values_mut() {
if let Graphic::Graphic(list) = graphic {
if let Graphic::GraphicList(list) = graphic {
bake_graphic_paint_transform(list, transform);
}
}

View File

@@ -13,7 +13,7 @@ use core_types::uuid::NodeId;
use core_types::{ATTR_EDITOR_LAYER_PATH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_TRANSFORM, Color};
use glam::{DAffine2, DVec2};
use raster_types::{CPU, GPU, Raster};
use vector_types::{GradientStops, Vector};
use vector_types::{Gradient, Vector};
/// One run's attribute tokens, minted once so the lane loops read at an offset.
struct RunAttrs {
@@ -60,7 +60,7 @@ pub(in crate::graphic) fn group_is_opaque(group: &core_types::record::Group) ->
&& (0..item.len()).all(|lane| {
RunAttrs::read_or(item, attrs.opacity, lane, 1.) >= 1.
&& RunAttrs::read_or(item, attrs.opacity_fill, lane, 1.) >= 1.
&& lanes.as_ref().is_some_and(|lanes| lanes.element_ref(lane).is_opaque())
&& lanes.as_ref().is_some_and(|lanes| lanes.element_ref(lane).is_guaranteed_fully_opaque())
})
}
@@ -68,7 +68,7 @@ pub(in crate::graphic) fn group_is_fully_transparent(group: &core_types::record:
let item = &group.content;
let attrs = RunAttrs::of(item);
let lanes = item.typed_lanes::<Graphic>();
(0..item.len()).all(|lane| RunAttrs::read_or(item, attrs.opacity, lane, 1.) <= 0. || lanes.as_ref().is_some_and(|lanes| lanes.element_ref(lane).is_fully_transparent()))
(0..item.len()).all(|lane| RunAttrs::read_or(item, attrs.opacity, lane, 1.) <= 0. || lanes.as_ref().is_some_and(|lanes| lanes.element_ref(lane).is_guaranteed_fully_transparent()))
}
pub(in crate::graphic) fn group_bounding_box(group: &core_types::record::Group, transform: DAffine2, include_stroke: bool, thumbnail: bool) -> RenderBoundingBox {
@@ -117,7 +117,7 @@ pub(in crate::graphic) fn group_bounding_box(group: &core_types::record::Group,
.or_else(|| typed_run::<Raster<CPU>>(item, transform, include_stroke, thumbnail))
.or_else(|| typed_run::<Raster<GPU>>(item, transform, include_stroke, thumbnail))
.or_else(|| typed_run::<Color>(item, transform, include_stroke, thumbnail))
.or_else(|| typed_run::<GradientStops>(item, transform, include_stroke, thumbnail))
.or_else(|| typed_run::<Gradient>(item, transform, include_stroke, thumbnail))
.or_else(|| typed_run::<String>(item, transform, include_stroke, thumbnail))
.unwrap_or(RenderBoundingBox::Infinite)
}
@@ -377,7 +377,7 @@ fn walk_vector_rows_impl<'a>(
layer_path: parent_layer_path,
paint: row_paint,
}),
Graphic::Graphic(children) => walk_vector_rows_impl(
Graphic::GraphicList(children) => walk_vector_rows_impl(
GraphicLevel::Legacy(children),
scale.composed(&level, index),
level.try_attr::<EditorLayerPath>(index),
@@ -430,7 +430,7 @@ pub(in crate::graphic) fn push_lane_paint_into_interiors(list: &mut List<Graphic
let Some(paint) = stored.filter(|paint| is_paint_present(paint)).cloned() else {
continue;
};
let Some(Graphic::Graphic(children)) = list.element_mut(index) else { continue };
let Some(Graphic::GraphicList(children)) = list.element_mut(index) else { continue };
for child in 0..children.len() {
if matches!(children.element(child), Some(Graphic::Vector(_))) {
set_paint_attribute_at(children, child, key, paint.clone());
@@ -465,7 +465,7 @@ pub(in crate::graphic) fn group_render_complexity(group: &core_types::record::Gr
.or_else(|| typed_run::<Raster<CPU>>(item))
.or_else(|| typed_run::<Raster<GPU>>(item))
.or_else(|| typed_run::<Color>(item))
.or_else(|| typed_run::<GradientStops>(item))
.or_else(|| typed_run::<Gradient>(item))
.or_else(|| typed_run::<String>(item))
.unwrap_or(item.len())
}
@@ -496,8 +496,8 @@ mod run_tests {
nested.set_attribute(core_types::ATTR_TRANSFORM, 0, DAffine2::from_scale(DVec2::splat(2.)));
let mut top = List::new();
top.push(Item::new_from_element(Graphic::Graphic(painted)));
top.push(Item::new_from_element(Graphic::Graphic(nested)));
top.push(Item::new_from_element(Graphic::GraphicList(painted)));
top.push(Item::new_from_element(Graphic::GraphicList(nested)));
top.push(Item::new_from_element(Graphic::Group(core_types::record::Group { row: None, content: inner_item })));
top.push(Item::new_from_element(Graphic::Color(Color::BLACK)));
top.push(Item::new_from_element(Graphic::Vector(unit_square_at(DVec2::new(6., 0.)))));

View File

@@ -1,3 +1,4 @@
pub mod appearance;
pub mod artboard;
pub mod boundary;
pub mod graphic;
@@ -9,48 +10,53 @@ pub use raster_types;
pub use vector_types;
// Re-export commonly used types at the crate root
pub use appearance::{Appearance, Cover, CoverPlacement, Coverage, FillAndStroke, stamp_coverage};
pub use artboard::Artboard;
pub use graphic::{Graphic, IntoGraphicList, TryFromGraphic, Vector};
pub use graphic::{Graphic, IntoGraphicList, TryFromGraphic, Vector, is_lone_anonymous_leaf};
pub use markers::{ATTR_EDITOR_MERGED_LAYERS, ATTR_FILL, ATTR_STROKE};
pub mod migrations {
use crate::Vector;
use core_types::Color;
use vector_types::gradient::GradientStops;
use vector_types::{Gradient, GradientRamp, GradientSpace};
// Storing legacy structs that are only used in document migration.
// TODO: Eventually remove this migration document upgrade code
// TODO: Eventually remove this document upgrade code
pub mod legacy {
use core_types::Color;
use dyn_any::DynAny;
use glam::{DAffine2, DVec2};
use vector_types::vector::{PointDomain, RegionDomain, SegmentDomain, misc::HandleId, style::Stroke};
use vector_types::{GradientStops, Vector, vector};
use vector_types::vector::{PointDomain, SegmentDomain, misc::HandleId, style::Stroke};
use vector_types::{GradientRamp, Vector, vector};
#[derive(Default, Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny, serde::Serialize, serde::Deserialize)]
pub struct Gradient {
pub stops: GradientStops,
pub gradient_type: vector::style::GradientType,
pub struct LegacyGradient {
#[serde(deserialize_with = "crate::migrations::migrate_to_gradient_ramp")]
pub stops: GradientRamp,
pub gradient_type: vector::style::GradientForm,
pub start: DVec2,
pub end: DVec2,
#[serde(default)]
pub spread_method: vector::style::GradientSpreadMethod,
pub spread_method: vector::style::GradientSpread,
#[serde(default)]
pub absolute: bool,
#[serde(default)]
pub transform: DAffine2,
}
impl Gradient {
impl LegacyGradient {
/// Converts a legacy bounding-box-relative gradient (`start`/`end` in [0,1]) into an absolute one in the geometry's local space.
/// `bounding_box` maps [0,1] onto the geometry's bounding box; `layer_transform` is the layer's own transform,
/// used to bake the elliptical adjustment that reproduces the legacy isotropic radial through a non-uniform layer.
pub fn to_absolute(&self, bounding_box: DAffine2, layer_transform: DAffine2) -> Gradient {
pub fn to_absolute(&self, bounding_box: DAffine2, layer_transform: DAffine2) -> LegacyGradient {
let start = bounding_box.transform_point2(self.start);
let end = bounding_box.transform_point2(self.end);
let direction = end - start;
// The legacy radial drew as a circle in the layer's own space; bake the adjustment that, composed with the
// endpoint frame, makes the new pipeline reproduce that circle through the (possibly non-uniform) layer transform.
let radial_invertible = self.gradient_type == vector::style::GradientType::Radial
let radial_invertible = self.gradient_type == vector::style::GradientForm::Radial
&& layer_transform.is_finite()
&& layer_transform.matrix2.determinant().recip().is_finite()
&& direction.length_squared() > 1e-20;
@@ -66,7 +72,7 @@ pub mod migrations {
DAffine2::IDENTITY
};
Gradient {
LegacyGradient {
start,
end,
transform,
@@ -83,18 +89,19 @@ pub mod migrations {
}
#[derive(Default, Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny, serde::Serialize, serde::Deserialize)]
pub enum Fill {
pub enum LegacyFill {
#[default]
None,
Solid(Color),
Gradient(Gradient),
Gradient(LegacyGradient),
}
/// The legacy `fill` field is intentionally omitted because vector payload migration only
/// recovers editable vector data. The fill/stroke paints are migrated from the the node inputs.
/// recovers editable vector data. The stroke parses solely to validate the legacy shape.
#[derive(serde::Deserialize)]
#[cfg_attr(test, derive(Default, serde::Serialize))]
pub(super) struct PathStyle {
#[allow(dead_code)]
pub stroke: Option<Stroke>,
}
@@ -102,11 +109,11 @@ pub mod migrations {
#[derive(serde::Deserialize)]
#[cfg_attr(test, derive(Default, serde::Serialize))]
pub(super) struct VectorData {
#[allow(dead_code)]
pub style: PathStyle,
pub colinear_manipulators: Vec<[HandleId; 2]>,
pub point_domain: PointDomain,
pub segment_domain: SegmentDomain,
pub region_domain: RegionDomain,
}
#[derive(serde::Deserialize)]
@@ -116,7 +123,7 @@ pub mod migrations {
}
}
// TODO: Eventually remove this migration document upgrade code
// TODO: Eventually remove this document upgrade code
/// Returns the first `Vector` recovered from any of the legacy on-disk shapes (the legacy `VectorData` flat struct, a single `Vector`, or any of the historical `List<Vector>` variants).
pub fn migrate_to_optional_vector<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<Option<Vector>, D::Error> {
use serde::Deserialize;
@@ -134,26 +141,64 @@ pub mod migrations {
Ok(match VectorFormat::deserialize(deserializer)? {
VectorFormat::OldVectorData(old) => Some(Vector {
stroke: old.style.stroke,
colinear_manipulators: old.colinear_manipulators,
point_domain: old.point_domain,
segment_domain: old.segment_domain,
region_domain: old.region_domain,
}),
VectorFormat::Vector(vector) => Some(vector),
VectorFormat::List(list) => list.element.into_iter().next(),
})
}
// TODO: Eventually remove this document upgrade code
/// Recovers a [`GradientRamp`] from any of its on-disk shapes: the current nested form, the flat stops struct
/// that preceded it, or the ancient position-color tuple list (whose even positions elide back to absence).
/// The pre-ramp shapes come from documents that rendered in gamma, so they carry that space explicitly.
pub fn migrate_to_gradient_ramp<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<GradientRamp, D::Error> {
use serde::Deserialize;
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum GradientRampFormat {
Ramp(GradientRamp),
FlatStops(GradientStops<Color>),
Tuples(Vec<(f64, Color)>),
}
Ok(match GradientRampFormat::deserialize(deserializer)? {
GradientRampFormat::Ramp(ramp) => ramp,
GradientRampFormat::FlatStops(stops) => GradientRamp {
gradient_space: GradientSpace::RgbGamma,
..GradientRamp::from(stops)
},
GradientRampFormat::Tuples(stops) => {
let position: Vec<f64> = stops.iter().map(|(position, _)| *position).collect();
let mut gradient = Gradient::from(stops.into_iter().map(|(_, color)| color).collect::<Vec<_>>());
gradient.set_positions(&position);
gradient.elide_default_attributes(false);
GradientRamp {
gradient_space: GradientSpace::RgbGamma,
..GradientRamp::from(gradient)
}
}
})
}
#[cfg(test)]
mod migration_tests {
use super::*;
use vector_types::vector::style::Stroke;
/// The legacy `style` payload (including its stroke) must still parse so the untagged format
/// disambiguation succeeds, even though the geometry is all that survives.
#[test]
fn preserves_stroke_from_old_vector_data_style() {
fn recovers_geometry_from_old_vector_data_with_style() {
use core_types::ops::FromAnchorPosition;
let old_vector = legacy::VectorData {
style: legacy::PathStyle { stroke: Some(Stroke::new(12.)) },
point_domain: Vector::from_anchor_position(glam::DVec2::new(3., 4.)).point_domain,
..Default::default()
};
@@ -165,23 +210,27 @@ pub mod migrations {
.unwrap()
.as_object_mut()
.unwrap()
.insert("fill".into(), serde_json::to_value(legacy::Fill::default()).unwrap());
let migrated = migrate_to_optional_vector(value).unwrap().unwrap();
.insert("fill".into(), serde_json::to_value(legacy::LegacyFill::default()).unwrap());
assert_eq!(migrated.stroke.unwrap().weight, 12.);
let migrated = migrate_to_optional_vector(value).unwrap().expect("the legacy shape should parse into a vector");
assert_eq!(
migrated.point_domain.positions(),
[glam::DVec2::new(3., 4.)],
"the legacy geometry should survive alongside the discarded style"
);
}
#[test]
fn preserves_stroke_from_current_vector_data() {
let vector = Vector {
stroke: Some(Stroke::new(12.)),
..Default::default()
};
fn recovers_geometry_from_current_vector_data() {
use core_types::ops::FromAnchorPosition;
let vector = Vector::from_anchor_position(glam::DVec2::new(3., 4.));
let value = serde_json::to_value(&vector).unwrap();
let migrated = migrate_to_optional_vector(value).unwrap().unwrap();
assert_eq!(migrated.stroke.unwrap().weight, 12.);
assert_eq!(migrated.point_domain.positions(), [glam::DVec2::new(3., 4.)]);
}
}
}

View File

@@ -1,3 +1,4 @@
use crate::color::Color;
use core::fmt::Display;
use node_macro::BufferStruct;
use num_enum::{FromPrimitive, IntoPrimitive};
@@ -185,3 +186,125 @@ impl Display for BlendMode {
}
}
}
/// Composites `foreground` over `background` with the given blend mode, fading the result by `opacity`.
#[inline(always)]
pub fn blend_colors(foreground: Color, background: Color, blend_mode: BlendMode, opacity: f32) -> Color {
// The alpha-only utility modes composite no color, so opacity interpolates their alpha toward the backdrop's instead
let faded_alpha = |applied: Color| background.with_alpha(background.a() + (applied.a() - background.a()) * opacity);
let target_color = match blend_mode {
// Other utility blend modes (hidden from the normal list) - do not have alpha blend
BlendMode::Erase => return faded_alpha(background.alpha_subtract(foreground)),
BlendMode::Restore => return faded_alpha(background.alpha_add(foreground)),
BlendMode::MultiplyAlpha => return faded_alpha(background.alpha_multiply(foreground)),
blend_mode => apply_blend_mode(foreground, background, blend_mode),
};
background.alpha_blend(target_color.apply_opacity(opacity))
}
/// Mixes the two colors by the blend mode's own formula, leaving the alpha compositing to the caller.
pub fn apply_blend_mode(foreground: Color, background: Color, blend_mode: BlendMode) -> Color {
match blend_mode {
// Normal group
BlendMode::Normal => background.blend_rgb(foreground, Color::blend_normal),
// Darken group
BlendMode::Darken => background.blend_rgb(foreground, Color::blend_darken),
BlendMode::Multiply => background.blend_rgb(foreground, Color::blend_multiply),
BlendMode::ColorBurn => background.blend_rgb(foreground, Color::blend_color_burn),
BlendMode::LinearBurn => background.blend_rgb(foreground, Color::blend_linear_burn),
BlendMode::DarkerColor => background.blend_darker_color(foreground),
// Lighten group
BlendMode::Lighten => background.blend_rgb(foreground, Color::blend_lighten),
BlendMode::Screen => background.blend_rgb(foreground, Color::blend_screen),
BlendMode::ColorDodge => background.blend_rgb(foreground, Color::blend_color_dodge),
BlendMode::LinearDodge => background.blend_rgb(foreground, Color::blend_linear_dodge),
BlendMode::LighterColor => background.blend_lighter_color(foreground),
// Contrast group
BlendMode::Overlay => background.blend_rgb(foreground, Color::blend_overlay),
BlendMode::SoftLight => background.blend_rgb(foreground, Color::blend_softlight),
BlendMode::HardLight => background.blend_rgb(foreground, Color::blend_hardlight),
BlendMode::VividLight => background.blend_rgb(foreground, Color::blend_vivid_light),
BlendMode::LinearLight => background.blend_rgb(foreground, Color::blend_linear_light),
BlendMode::PinLight => background.blend_rgb(foreground, Color::blend_pin_light),
BlendMode::HardMix => background.blend_rgb(foreground, Color::blend_hard_mix),
// Inversion group
BlendMode::Difference => background.blend_rgb(foreground, Color::blend_difference),
BlendMode::Exclusion => background.blend_rgb(foreground, Color::blend_exclusion),
BlendMode::Subtract => background.blend_rgb(foreground, Color::blend_subtract),
BlendMode::Divide => background.blend_rgb(foreground, Color::blend_divide),
// Component group
BlendMode::Hue => background.blend_hue(foreground),
BlendMode::Saturation => background.blend_saturation(foreground),
BlendMode::Color => background.blend_color(foreground),
BlendMode::Luminosity => background.blend_luminosity(foreground),
// The alpha-only utility modes mix no color, so the foreground passes through for the caller to composite
BlendMode::Erase | BlendMode::Restore | BlendMode::MultiplyAlpha => foreground,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn overlay_is_hard_light_with_swapped_operands() {
let a = Color::from_rgbaf32_unchecked(0.8, 0.3, 0.6, 1.);
let b = Color::from_rgbaf32_unchecked(0.2, 0.7, 0.4, 1.);
let overlay = apply_blend_mode(a, b, BlendMode::Overlay);
let swapped_hard_light = apply_blend_mode(b, a, BlendMode::HardLight);
assert!((overlay.r() - swapped_hard_light.r()).abs() < 1e-5, "red was {} vs {}", overlay.r(), swapped_hard_light.r());
assert!((overlay.g() - swapped_hard_light.g()).abs() < 1e-5, "green was {} vs {}", overlay.g(), swapped_hard_light.g());
assert!((overlay.b() - swapped_hard_light.b()).abs() < 1e-5, "blue was {} vs {}", overlay.b(), swapped_hard_light.b());
}
#[test]
fn blended_colors_keep_the_foreground_alpha() {
let foreground = Color::from_rgbaf32_unchecked(0.8, 0.3, 0.6, 0.25);
let background = Color::from_rgbaf32_unchecked(0.2, 0.7, 0.4, 1.);
let modes = [
BlendMode::Multiply,
BlendMode::Overlay,
BlendMode::DarkerColor,
BlendMode::LighterColor,
BlendMode::Hue,
BlendMode::Saturation,
BlendMode::Color,
BlendMode::Luminosity,
];
for mode in modes {
let blended = apply_blend_mode(foreground, background, mode);
assert!((blended.a() - 0.25).abs() < 1e-5, "{mode} alpha was {}", blended.a());
}
}
#[test]
fn darker_color_compares_unassociated_channels() {
// The premultiplied backdrop reads as 0.1 gray but is really 0.5 gray, so the 0.4 gray foreground is the darker color
let foreground = Color::from_rgbaf32_unchecked(0.4, 0.4, 0.4, 1.);
let background = Color::from_rgbaf32_unchecked(0.1, 0.1, 0.1, 0.2);
let blended = apply_blend_mode(foreground, background, BlendMode::DarkerColor);
assert!((blended.r() - 0.4).abs() < 1e-5, "red was {}", blended.r());
assert!((blended.a() - 1.).abs() < 1e-5, "alpha was {}", blended.a());
}
#[test]
fn alpha_only_modes_fade_with_opacity() {
let foreground = Color::from_rgbaf32_unchecked(0.9, 0.9, 0.9, 1.);
let background = Color::from_rgbaf32_unchecked(0.3, 0.5, 0.7, 1.);
// A full-opacity erase removes all coverage, and half opacity fades that effect halfway back toward the backdrop
let full = blend_colors(foreground, background, BlendMode::Erase, 1.);
let half = blend_colors(foreground, background, BlendMode::Erase, 0.5);
assert!((full.a() - 0.).abs() < 1e-5, "alpha was {}", full.a());
assert!((half.a() - 0.5).abs() < 1e-5, "alpha was {}", half.a());
assert!((half.r() - 0.3).abs() < 1e-5, "red was {}", half.r());
}
}

View File

@@ -273,7 +273,7 @@ pub struct Color {
// `f32` channels mean `Color` doesn't qualify for a derived `Eq`, but in practice we never store NaN here, and the renderer's `HashMap<CacheHashWrapper<Image<Color>>, _>` deduplication needs `Color: Eq` to propagate up through the wrapper.
impl Eq for Color {}
// TODO: Eventually remove this migration document upgrade code
// TODO: Eventually remove this document upgrade code
#[cfg(feature = "std")]
impl serde::Serialize for Color {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
@@ -290,7 +290,7 @@ impl serde::Serialize for Color {
}
}
// TODO: Eventually remove this migration document upgrade code
// TODO: Eventually remove this document upgrade code
#[cfg(feature = "std")]
impl<'de> serde::Deserialize<'de> for Color {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
@@ -413,6 +413,7 @@ impl Color {
pub const YELLOW: Color = Color::from_rgbf32_unchecked(1., 1., 0.);
pub const CYAN: Color = Color::from_rgbf32_unchecked(0., 1., 1.);
pub const MAGENTA: Color = Color::from_rgbf32_unchecked(1., 0., 1.);
pub const MIDDLE_GRAY: Color = Color::from_rgbf32_unchecked(0.5, 0.5, 0.5);
pub const TRANSPARENT: Color = Self {
red: 0.,
green: 0.,
@@ -703,10 +704,13 @@ impl Color {
c_b + c_s - 1.
}
/// Whole-color "Darker Color" blend: keeps whichever color has the lower mean RGB.
/// Whole-color "Darker Color" blend: keeps whichever color has the lower mean RGB, with `other`'s alpha.
#[inline(always)]
pub fn blend_darker_color(&self, other: Color) -> Color {
if self.average_rgb_channels() <= other.average_rgb_channels() { *self } else { other }
let background = self.to_unassociated_alpha();
let darker = if background.average_rgb_channels() <= other.average_rgb_channels() { background } else { other };
darker.with_alpha(other.alpha)
}
/// Per-channel "Screen" blend.
@@ -733,10 +737,13 @@ impl Color {
c_b + c_s
}
/// Whole-color "Lighter Color" blend: keeps whichever color has the higher mean RGB.
/// Whole-color "Lighter Color" blend: keeps whichever color has the higher mean RGB, with `other`'s alpha.
#[inline(always)]
pub fn blend_lighter_color(&self, other: Color) -> Color {
if self.average_rgb_channels() >= other.average_rgb_channels() { *self } else { other }
let background = self.to_unassociated_alpha();
let lighter = if background.average_rgb_channels() >= other.average_rgb_channels() { background } else { other };
lighter.with_alpha(other.alpha)
}
/// Per-channel "Soft Light" blend.
@@ -758,6 +765,11 @@ impl Color {
}
}
/// Per-channel "Overlay" blend, which is "Hard Light" with the backdrop and source channels swapped.
pub fn blend_overlay(c_b: f32, c_s: f32) -> f32 {
Color::blend_hardlight(c_s, c_b)
}
/// Per-channel "Vivid Light" blend.
pub fn blend_vivid_light(c_b: f32, c_s: f32) -> f32 {
if c_s <= 0.5 {
@@ -810,33 +822,36 @@ impl Color {
if c_b == 0. { 1. } else { c_b / c_s }
}
/// Whole-color "Hue" blend: source hue with this color's saturation and Rec.601 luma.
/// Whole-color "Hue" blend: source hue with this color's saturation and Rec.601 luma, with `c_s`'s alpha.
pub fn blend_hue(&self, c_s: Color) -> Color {
let sat_b = self.chroma_range();
let lum_b = self.luminance_rec_601();
c_s.with_saturation(sat_b).with_luminance(lum_b)
let background = self.to_unassociated_alpha();
let sat_b = background.chroma_range();
let lum_b = background.luminance_rec_601();
c_s.with_saturation(sat_b).with_luminance(lum_b).with_alpha(c_s.alpha)
}
/// Whole-color "Saturation" blend: this color's hue/luma with source saturation.
/// Whole-color "Saturation" blend: this color's hue/luma with source saturation, with `c_s`'s alpha.
pub fn blend_saturation(&self, c_s: Color) -> Color {
let background = self.to_unassociated_alpha();
let sat_s = c_s.chroma_range();
let lum_b = self.luminance_rec_601();
let lum_b = background.luminance_rec_601();
self.with_saturation(sat_s).with_luminance(lum_b)
background.with_saturation(sat_s).with_luminance(lum_b).with_alpha(c_s.alpha)
}
/// Whole-color "Color" blend: source hue/saturation with this color's luma.
/// Whole-color "Color" blend: source hue/saturation with this color's luma, with `c_s`'s alpha.
pub fn blend_color(&self, c_s: Color) -> Color {
let lum_b = self.luminance_rec_601();
let lum_b = self.to_unassociated_alpha().luminance_rec_601();
c_s.with_luminance(lum_b)
c_s.with_luminance(lum_b).with_alpha(c_s.alpha)
}
/// Whole-color "Luminosity" blend: this color's hue/saturation with source luma.
/// Whole-color "Luminosity" blend: this color's hue/saturation with source luma, with `c_s`'s alpha.
pub fn blend_luminosity(&self, c_s: Color) -> Color {
let lum_s = c_s.luminance_rec_601();
self.with_luminance(lum_s)
self.to_unassociated_alpha().with_luminance(lum_s).with_alpha(c_s.alpha)
}
/// All four channels as `(red, green, blue, alpha)`.
@@ -918,6 +933,14 @@ impl Color {
)
}
/// Like [`Self::lerp`] but interpolating in gamma sRGB space, the space SVG interpolates in between adjacent gradient stops.
#[inline(always)]
pub fn lerp_gamma_srgb(&self, other: &Color, t: f32) -> Self {
let a = self.to_gamma_srgb_channels();
let b = other.to_gamma_srgb_channels();
Color::from_gamma_srgb_channels(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, a[3] + (b[3] - a[3]) * t)
}
/// Generic power curve `c.powf(1 / exponent)` applied per RGB channel. Distinct from the sRGB transfer curve (see [`Self::to_gamma_srgb_channels`]).
/// The expected output must still be treated as linear-light.
#[inline(always)]

View File

@@ -4,6 +4,7 @@ pub mod blending;
pub mod choice_type;
pub mod color;
pub mod context;
pub mod list;
pub mod registry;
pub mod shaders;

View File

@@ -0,0 +1,41 @@
//! A zero-cost stand-in for `core_types::list::Item` used when node kernels are compiled for the GPU.
//!
//! Shader node kernels compile twice: under `std` against the real attribute-carrying `Item`, and under
//! `no_std` (SPIR-V) against this transparent wrapper, imported as `Item`. Only the element-access surface is
//! provided, since rust-gpu cannot allocate and attributes have no per-pixel meaning; attribute use fails the
//! shader build. It is named distinctly from `Item` so a search for the canonical type finds only that one.
/// A rank-0 wire value holding a single element, mirroring the element-access API of the real `Item`.
#[repr(transparent)]
#[derive(Copy, Clone, Debug, Default, PartialEq)]
pub struct ShaderItem<T> {
element: T,
}
impl<T> ShaderItem<T> {
/// Constructs an item with the given element.
pub fn new_from_element(element: T) -> Self {
Self { element }
}
/// Returns a shared reference to this item's element.
pub fn element(&self) -> &T {
&self.element
}
/// Returns a mutable reference to this item's element.
pub fn element_mut(&mut self) -> &mut T {
&mut self.element
}
/// Consumes this item and returns the owned element.
pub fn into_element(self) -> T {
self.element
}
}
impl<T> From<T> for ShaderItem<T> {
fn from(element: T) -> Self {
Self::new_from_element(element)
}
}

View File

@@ -140,7 +140,7 @@ mod cpu {
pub use gpu::GPU;
#[cfg(feature = "wgpu")]
pub use gpu::Texture;
pub use gpu::{Texture, TextureWeakRef};
#[cfg(feature = "wgpu")]
mod gpu {
@@ -149,37 +149,57 @@ mod gpu {
use std::sync::Arc;
#[derive(Clone, Debug, PartialEq, Eq, Hash, DynAny)]
pub struct Texture(Arc<wgpu::Texture>);
pub struct Texture(Arc<TextureInner>);
#[derive(Debug, PartialEq, Eq, Hash)]
struct TextureInner(wgpu::Texture);
impl Drop for TextureInner {
fn drop(&mut self) {
self.0.destroy();
}
}
impl Texture {
pub fn is_shared(&self) -> bool {
Arc::strong_count(&self.0) > 1
}
pub fn is_weakly_shared(&self) -> bool {
Arc::weak_count(&self.0) > 0
}
pub fn downgrade(&self) -> TextureWeakRef {
TextureWeakRef(Arc::downgrade(&self.0))
}
}
#[derive(Clone, Debug)]
pub struct TextureWeakRef(std::sync::Weak<TextureInner>);
impl TextureWeakRef {
pub fn upgrade(&self) -> Option<Texture> {
self.0.upgrade().map(Texture)
}
}
impl Deref for Texture {
type Target = wgpu::Texture;
fn deref(&self) -> &Self::Target {
&self.0
&self.0.0
}
}
impl AsRef<wgpu::Texture> for Texture {
fn as_ref(&self) -> &wgpu::Texture {
&self.0
}
}
impl From<Arc<wgpu::Texture>> for Texture {
fn from(texture: Arc<wgpu::Texture>) -> Self {
Self(texture)
&self.0.0
}
}
impl From<wgpu::Texture> for Texture {
fn from(texture: wgpu::Texture) -> Self {
Self(Arc::new(texture))
}
}
impl From<Texture> for Arc<wgpu::Texture> {
fn from(texture: Texture) -> Self {
texture.0
Self(Arc::new(TextureInner(texture)))
}
}

View File

@@ -8,12 +8,13 @@ license = "MIT OR Apache-2.0"
[features]
default = ["serde"]
serde = ["dep:serde", "core-types/serde", "vector-types/serde", "graphic-types/serde"]
serde = ["dep:serde", "core-types/serde", "vector-types/serde", "graphic-types/serde", "brush-types/serde"]
[dependencies]
# Local dependencies
dyn-any = { workspace = true }
core-types = { workspace = true }
brush-types = { workspace = true }
graphene-hash = { workspace = true }
graphene-resource = { workspace = true }
text-nodes = { workspace = true }

View File

@@ -1,47 +1,35 @@
use glam::DVec2;
use vector_types::subpath::{ManipulatorGroup, Subpath};
use vector_types::vector::PointId;
use kurbo::{BezPath, Point};
pub fn convert_usvg_path(path: &usvg::Path) -> Vec<Subpath<PointId>> {
let mut subpaths = Vec::new();
let mut manipulators_list = Vec::new();
pub fn convert_usvg_path(path: &usvg::Path) -> BezPath {
let mut bezpath = BezPath::new();
let mut points = path.data().points().iter();
let to_vec = |p: &usvg::tiny_skia_path::Point| DVec2::new(p.x as f64, p.y as f64);
let to_point = |p: &usvg::tiny_skia_path::Point| Point::new(p.x as f64, p.y as f64);
for verb in path.data().verbs() {
match verb {
usvg::tiny_skia_path::PathVerb::Move => {
subpaths.push(Subpath::new(std::mem::take(&mut manipulators_list), false));
let Some(start) = points.next().map(to_vec) else { continue };
manipulators_list.push(ManipulatorGroup::new(start, Some(start), Some(start)));
let Some(start) = points.next().map(to_point) else { continue };
bezpath.move_to(start);
}
usvg::tiny_skia_path::PathVerb::Line => {
let Some(end) = points.next().map(to_vec) else { continue };
manipulators_list.push(ManipulatorGroup::new(end, Some(end), Some(end)));
let Some(end) = points.next().map(to_point) else { continue };
bezpath.line_to(end);
}
usvg::tiny_skia_path::PathVerb::Quad => {
let Some(handle) = points.next().map(to_vec) else { continue };
let Some(end) = points.next().map(to_vec) else { continue };
if let Some(last) = manipulators_list.last_mut() {
last.out_handle = Some(last.anchor + (2. / 3.) * (handle - last.anchor));
}
manipulators_list.push(ManipulatorGroup::new(end, Some(end + (2. / 3.) * (handle - end)), Some(end)));
let Some(handle) = points.next().map(to_point) else { continue };
let Some(end) = points.next().map(to_point) else { continue };
bezpath.quad_to(handle, end);
}
usvg::tiny_skia_path::PathVerb::Cubic => {
let Some(first_handle) = points.next().map(to_vec) else { continue };
let Some(second_handle) = points.next().map(to_vec) else { continue };
let Some(end) = points.next().map(to_vec) else { continue };
if let Some(last) = manipulators_list.last_mut() {
last.out_handle = Some(first_handle);
}
manipulators_list.push(ManipulatorGroup::new(end, Some(second_handle), Some(end)));
}
usvg::tiny_skia_path::PathVerb::Close => {
subpaths.push(Subpath::new(std::mem::take(&mut manipulators_list), true));
let Some(first_handle) = points.next().map(to_point) else { continue };
let Some(second_handle) = points.next().map(to_point) else { continue };
let Some(end) = points.next().map(to_point) else { continue };
bezpath.curve_to(first_handle, second_handle, end);
}
usvg::tiny_skia_path::PathVerb::Close => bezpath.close_path(),
}
}
subpaths.push(Subpath::new(manipulators_list, false));
subpaths
bezpath
}

View File

@@ -1,4 +1,7 @@
use crate::renderer::{RenderParams, format_transform_matrix, gradient_placement, transform_is_invertible};
use crate::renderer::{
ClearGuardPlacement, ItemRef, RenderParams, composite_paint_colors, faded_paint_color, format_transform_matrix, gradient_placement, gradient_settings_from_item, spread_adjusted_samples,
transform_is_invertible,
};
use crate::{Render, RenderSvgSegmentList, SvgRender};
use core_types::Color;
use core_types::attribute::Transform;
@@ -7,12 +10,12 @@ use core_types::list::List;
use core_types::uuid::generate_uuid;
use glam::{DAffine2, DVec2};
use graphic_types::Graphic;
use graphic_types::vector_types::gradient::GradientType;
use graphic_types::vector_types::markers::{GradientType as GradientTypeAttr, SpreadMethod};
use graphic_types::vector_types::gradient::GradientForm;
use graphic_types::vector_types::markers::{GradientForm as GradientFormAttr, GradientSpread as GradientSpreadAttr};
use graphic_types::vector_types::vector::style::{PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
use std::fmt::Write;
use vector_types::GradientStops;
use vector_types::gradient::GradientSpreadMethod;
use vector_types::Gradient;
use vector_types::gradient::GradientSpread;
#[derive(Copy, Clone, PartialEq)]
pub enum PaintTarget {
@@ -76,17 +79,106 @@ impl RenderExt for List<Color> {
_element_transform: DAffine2,
_stroke_transform: DAffine2,
_bounds: DAffine2,
_render_params: &RenderParams,
render_params: &RenderParams,
target: PaintTarget,
) -> Self::Output {
render_color_paint(self, target)
}
}
impl RenderExt for List<GradientStops> {
type Output = u64;
/// Adds one gradient item's def into `svg_defs` and returns the gradient ID, or `None` when the item is absent.
/// `for_mask` keeps the fill opacity at full, as [`ItemRef::paint_opacity`] explains.
fn render_gradient_paint(item: Option<ItemRef<'_, Gradient>>, svg_defs: &mut String, item_transform: DAffine2, element_transform: DAffine2, for_mask: bool) -> Option<u64> {
let mut stop = String::new();
/// Adds the gradient def through mutating the first argument, returning the gradient ID.
let item = item?;
let stops = item.element()?;
let gradient_form: GradientForm = item.attribute_cloned_or_default(ATTR_GRADIENT_FORM);
let local_gradient_transform: DAffine2 = item.attribute_cloned_or_default(ATTR_TRANSFORM);
let settings = gradient_settings_from_item(item);
let (mut samples, _) = spread_adjusted_samples(stops, settings, gradient_form, ClearGuardPlacement::SvgStopOrder);
let paint_opacity = item.paint_opacity(for_mask);
if paint_opacity < 1. {
for (_, color, _) in &mut samples {
*color = color.with_alpha(color.a() * paint_opacity);
}
}
for (position, color, original_midpoint) in samples {
stop.push_str("<stop");
if position != 0. {
let _ = write!(stop, r#" offset="{}""#, (position * 1_000_000.).round() / 1_000_000.);
}
let _ = write!(stop, r##" stop-color="#{}""##, SRGBA8::from(color).to_rgb_hex());
if color.a() < 1. {
let _ = write!(stop, r#" stop-opacity="{}""#, (color.a() * 1000.).round() / 1000.);
}
if let Some(midpoint) = original_midpoint {
let _ = write!(stop, r#" graphite:midpoint="{}""#, (midpoint * 1000.).round() / 1000.);
}
stop.push_str(" />")
}
// A gradient with no stops paints as solid black, matching `Gradient::evaluate` (a stopless def would otherwise render as no paint per the SVG spec)
if stop.is_empty() {
stop.push_str(r##"<stop stop-color="#000000""##);
if paint_opacity < 1. {
let _ = write!(stop, r#" stop-opacity="{}""#, (paint_opacity * 1000.).round() / 1000.);
}
stop.push_str(" />");
}
// Need to cancel out the element's transform as it is already applied to the path itself.
let element_transform_inverse = if transform_is_invertible(element_transform) {
element_transform.inverse()
} else {
DAffine2::IDENTITY
};
let document_transform = item_transform * local_gradient_transform;
let placement = gradient_placement(document_transform, gradient_form);
let gradient_transform = format_transform_matrix(element_transform_inverse * placement);
let gradient_transform = if gradient_transform.is_empty() {
String::new()
} else {
format!(r#" gradientTransform="{gradient_transform}""#)
};
let gradient_spread = if matches!(settings.spread, GradientSpread::Pad | GradientSpread::Clear) {
String::new()
} else {
format!(r#" spreadMethod="{}""#, settings.spread.svg_name())
};
let gradient_id = generate_uuid();
match gradient_form {
GradientForm::Linear => {
let _ = write!(
svg_defs,
r#"<linearGradient id="{}" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="1" y2="0"{gradient_spread}{gradient_transform}>{}</linearGradient>"#,
gradient_id, stop
);
}
GradientForm::Radial => {
let _ = write!(
svg_defs,
r#"<radialGradient id="{}" gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1"{gradient_spread}{gradient_transform}>{}</radialGradient>"#,
gradient_id, stop
);
}
}
Some(gradient_id)
}
impl RenderExt for List<Gradient> {
type Output = Option<u64>;
/// Adds the gradient def through mutating the first argument, returning the gradient ID, or `None` when the list is empty.
fn render(
&self,
svg_defs: &mut String,
@@ -94,7 +186,7 @@ impl RenderExt for List<GradientStops> {
element_transform: DAffine2,
_stroke_transform: DAffine2,
_bounds: DAffine2,
_render_params: &RenderParams,
render_params: &RenderParams,
_target: PaintTarget,
) -> Self::Output {
render_gradient_paint(self, svg_defs, item_transform, element_transform)
@@ -103,14 +195,14 @@ impl RenderExt for List<GradientStops> {
/// Adds the gradient def through mutating `svg_defs`, returning the gradient
/// ID, over any gradient lane source.
pub fn render_gradient_paint<S: core_types::lane::LaneSource<Element = GradientStops>>(source: &S, svg_defs: &mut String, item_transform: DAffine2, element_transform: DAffine2) -> u64 {
pub fn render_gradient_paint<S: core_types::lane::LaneSource<Element = Gradient>>(source: &S, svg_defs: &mut String, item_transform: DAffine2, element_transform: DAffine2) -> u64 {
let mut stop = String::new();
{
let Some(stops) = source.element(0) else { return 0 };
let gradient_type: GradientType = source.attr::<GradientTypeAttr>(0);
let gradient_type: GradientForm = source.attr::<GradientFormAttr>(0);
let local_gradient_transform: DAffine2 = source.attr::<Transform>(0);
let spread_method: GradientSpreadMethod = source.attr::<SpreadMethod>(0);
let spread_method: GradientSpread = source.attr::<GradientSpreadAttr>(0);
for (position, color, original_midpoint) in stops.interpolated_samples() {
stop.push_str("<stop");
@@ -144,7 +236,7 @@ pub fn render_gradient_paint<S: core_types::lane::LaneSource<Element = GradientS
format!(r#" gradientTransform="{gradient_transform}""#)
};
let spread_method = if spread_method == GradientSpreadMethod::Pad {
let spread_method = if spread_method == GradientSpread::Pad {
String::new()
} else {
format!(r#" spreadMethod="{}""#, spread_method.svg_name())
@@ -153,14 +245,14 @@ pub fn render_gradient_paint<S: core_types::lane::LaneSource<Element = GradientS
let gradient_id = generate_uuid();
match gradient_type {
GradientType::Linear => {
GradientForm::Linear => {
let _ = write!(
svg_defs,
r#"<linearGradient id="{}" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="1" y2="0"{spread_method}{gradient_transform}>{}</linearGradient>"#,
gradient_id, stop
);
}
GradientType::Radial => {
GradientForm::Radial => {
let _ = write!(
svg_defs,
r#"<radialGradient id="{}" gradientUnits="userSpaceOnUse" cx="0" cy="0" r="1"{spread_method}{gradient_transform}>{}</radialGradient>"#,
@@ -176,7 +268,7 @@ pub fn render_gradient_paint<S: core_types::lane::LaneSource<Element = GradientS
impl RenderExt for Stroke {
type Output = String;
/// Provide the shape-related SVG attributes for the stroke. The paint-related attributes for the stroke are generated from `List<Graphic>.render` with `PaintTarget::Stroke`.
/// Provide the shape-related SVG attributes for the stroke. The paint-related attributes for the stroke are generated from `Graphic::render` with `PaintTarget::Stroke`.
fn render(
&self,
_svg_defs: &mut String,
@@ -202,7 +294,6 @@ impl RenderExt for Stroke {
let stroke_join = (self.join != StrokeJoin::Miter).then_some(self.join);
let stroke_join_miter_limit = (self.join_miter_limit != 4.).then_some(self.join_miter_limit);
let stroke_align = (self.align != StrokeAlign::Center).then_some(self.align);
let paint_order = (self.paint_order != PaintOrder::StrokeAbove || render_params.override_paint_order).then_some(PaintOrder::StrokeBelow);
// Render the needed stroke attributes
let mut attributes = String::new();
@@ -227,7 +318,7 @@ impl RenderExt for Stroke {
if let Some(stroke_join_miter_limit) = stroke_join_miter_limit {
let _ = write!(&mut attributes, r#" stroke-miterlimit="{stroke_join_miter_limit}""#);
}
if paint_order.is_some() {
if render_params.stroke_below {
let _ = write!(&mut attributes, r#" style="paint-order: stroke;" "#);
}
attributes
@@ -247,7 +338,6 @@ impl RenderExt for List<Graphic<'_>> {
render_params: &RenderParams,
target: PaintTarget,
) -> Self::Output {
let fill_graphic = self.element(0);
let paint_attr = target.paint_attr();
match fill_graphic {
@@ -256,7 +346,7 @@ impl RenderExt for List<Graphic<'_>> {
let gradient_id = render_gradient_paint(&core_types::lane::LeafLane::new(self, 0, gradient), svg_defs, item_transform, element_transform);
format!(r##" {paint_attr}="url(#{gradient_id})""##)
}
Some(Graphic::Vector(_)) | Some(Graphic::RasterCPU(_)) | Some(Graphic::RasterGPU(_)) | Some(Graphic::Graphic(_)) | Some(Graphic::Text(_)) | Some(Graphic::Group(_)) => {
Some(Graphic::Vector(_)) | Some(Graphic::RasterCPU(_)) | Some(Graphic::RasterGPU(_)) | Some(Graphic::GraphicList(_)) | Some(Graphic::Text(_)) | Some(Graphic::Group(_)) => {
let bounds = if target == PaintTarget::Stroke {
// To prevent a wraparound artefact occurring when the tile boundary and the stroke region are perfectly aligned, the local coordinate is expanded slightly.
let inverse = |len: f64| if len > 0. { 1. / len } else { 0. };
@@ -271,14 +361,13 @@ impl RenderExt for List<Graphic<'_>> {
.map(|id| format!(r##" {paint_attr}="url(#{id})""##))
.unwrap_or_else(|| format!(r#" {paint_attr}="none""#))
}
None => format!(r#" {paint_attr}="none""#),
}
}
}
/// Emits an SVG `<pattern>` paint server into `svg_defs` that renders the given graphic list as the paint content, and returns the pattern ID.
/// Emits an SVG `<pattern>` paint server into `svg_defs` that renders the given graphic as the paint content, and returns the pattern ID.
/// Currently, this function is only used for clipping-based filling and stroking, not considering tiling yet.
fn render_svg_pattern(svg_defs: &mut String, fill_graphic_list: &List<Graphic>, stroke_transform: DAffine2, bounds: DAffine2, render_params: &RenderParams) -> Option<String> {
fn render_svg_pattern(svg_defs: &mut String, paint: &Graphic, stroke_transform: DAffine2, bounds: DAffine2, render_params: &RenderParams) -> Option<String> {
let min = bounds.transform_point2(DVec2::ZERO);
let max = bounds.transform_point2(DVec2::ONE);
let size = max - min;
@@ -288,7 +377,7 @@ fn render_svg_pattern(svg_defs: &mut String, fill_graphic_list: &List<Graphic>,
// Render the pattern content recursively
let mut content = SvgRender::new();
fill_graphic_list.render_svg(&mut content, &render_params.for_pattern());
paint.render_svg(&mut content, &render_params.for_pattern());
// Unwrap the inner def element
write!(svg_defs, "{}", content.svg_defs).unwrap();

File diff suppressed because it is too large Load Diff

View File

@@ -20,6 +20,7 @@ node-macro = { workspace = true }
# Workspace dependencies
bitflags = { workspace = true }
bytemuck = { workspace = true }
color = { workspace = true }
num-traits = { workspace = true }
glam = { workspace = true }
kurbo = { workspace = true }
@@ -36,3 +37,7 @@ serde = { workspace = true, optional = true }
tsify = { workspace = true, optional = true }
wasm-bindgen = { workspace = true, optional = true }
fixedbitset = "0.5.7"
[dev-dependencies]
# Workspace dependencies
serde_json = { workspace = true }

File diff suppressed because it is too large Load Diff

View File

@@ -4,15 +4,13 @@ extern crate log;
pub mod gradient;
pub mod markers;
pub mod math;
pub mod subpath;
pub mod vector;
// Re-export commonly used types at the crate root
pub use core_types as gcore;
pub use gradient::{GradientSpreadMethod, GradientStop, GradientStops, GradientType};
pub use markers::{ATTR_EDITOR_CLICK_TARGET, ATTR_GRADIENT_TYPE, ATTR_SPREAD_METHOD};
pub use math::{QuadExt, RectExt};
pub use subpath::Subpath;
pub use gradient::{Gradient, GradientForm, GradientHueDirection, GradientInterpolation, GradientRamp, GradientSettings, GradientSpace, GradientSpread, GradientStop};
pub use markers::ATTR_EDITOR_CLICK_TARGET;
pub use math::QuadExt;
pub use vector::Vector;
pub use vector::reference_point::ReferencePoint;

View File

@@ -1,13 +1,44 @@
//! Attribute markers whose value types live in this crate, with their name
//! constants for the string-keyed legacy readers and writers.
//!
//! The gradient names follow master's vocabulary: `gradient_form` for the
//! shape and `gradient_spread` for the endpoint behavior, replacing the
//! earlier `gradient_type` and `spread_method`.
use core_types::attribute::Attribute;
core_types::attribute! {
/// Gradient's spread behavior past its endpoints (`Pad`, `Reflect`, or `Repeat`).
pub SpreadMethod("spread_method"): crate::gradient::GradientSpreadMethod;
/// Gradient's spread behavior past its endpoints (`Pad`, `Reflect`, `Repeat`, or `Clear`).
pub GradientSpread("gradient_spread"): crate::gradient::GradientSpread;
/// Gradient's shape (`Linear` or `Radial`).
pub GradientType("gradient_type"): crate::gradient::GradientType;
pub GradientForm("gradient_form"): crate::gradient::GradientForm;
/// The color space a gradient's stops interpolate in.
pub GradientSpace("gradient_space"): crate::gradient::GradientSpace;
/// Which way around the hue wheel the stops interpolate when the space is polar.
pub GradientHueDirection("gradient_hue_direction"): crate::gradient::GradientHueDirection;
/// The path a gradient's stops interpolate along, so whether the ramp jumps,
/// turns corners, or flows smoothly through them.
pub GradientInterpolation("gradient_interpolation"): crate::gradient::GradientInterpolation;
/// Whether the stop list is a cycle, so a wrapped interval interpolates from the
/// last stop through the 1|0 boundary back to the first.
pub GradientCyclic("gradient_cyclic"): bool;
/// A gradient stop's position from 0 to 1, on the `List<Color>` inside a `Gradient`.
/// An absent column distributes the stops evenly.
pub Position("position"): f64;
/// A gradient stop's midpoint factor from 0 to 1 across the distance to the next stop.
pub Midpoint("midpoint"): f64 = 0.5;
/// A stroke coverage's line thickness.
pub Weight("weight"): f64;
/// A stroke coverage's dash phase offset distance.
pub DashOffset("dash_offset"): f64;
/// A stroke coverage's line cap.
pub Cap("cap"): crate::vector::style::StrokeCap;
/// A stroke coverage's line join.
pub Join("join"): crate::vector::style::StrokeJoin;
/// A stroke coverage's miter limit threshold.
pub JoinMiterLimit("join_miter_limit"): f64 = 4.;
/// A stroke coverage's alignment across the path.
pub Align("align"): crate::vector::style::StrokeAlign;
/// Optional `Vector` that overrides the item's own geometry for click-target generation.
/// Used by the 'Text' node for per-glyph bounding-box rectangles so glyphs are selectable
/// by clicking anywhere within their bounds, not just the filled letterform. An absent
@@ -15,8 +46,20 @@ core_types::attribute! {
pub EditorClickTarget("editor:click_target"): Option<&crate::Vector>;
}
pub const ATTR_SPREAD_METHOD: &str = SpreadMethod::NAME;
pub const ATTR_GRADIENT_TYPE: &str = GradientType::NAME;
pub const ATTR_GRADIENT_SPREAD: &str = GradientSpread::NAME;
pub const ATTR_GRADIENT_FORM: &str = GradientForm::NAME;
pub const ATTR_GRADIENT_SPACE: &str = GradientSpace::NAME;
pub const ATTR_GRADIENT_HUE_DIRECTION: &str = GradientHueDirection::NAME;
pub const ATTR_GRADIENT_INTERPOLATION: &str = GradientInterpolation::NAME;
pub const ATTR_GRADIENT_CYCLIC: &str = GradientCyclic::NAME;
pub const ATTR_POSITION: &str = Position::NAME;
pub const ATTR_MIDPOINT: &str = Midpoint::NAME;
pub const ATTR_WEIGHT: &str = Weight::NAME;
pub const ATTR_DASH_OFFSET: &str = DashOffset::NAME;
pub const ATTR_CAP: &str = Cap::NAME;
pub const ATTR_JOIN: &str = Join::NAME;
pub const ATTR_JOIN_MITER_LIMIT: &str = JoinMiterLimit::NAME;
pub const ATTR_ALIGN: &str = Align::NAME;
pub const ATTR_EDITOR_CLICK_TARGET: &str = EditorClickTarget::NAME;
#[cfg(test)]
@@ -27,8 +70,8 @@ mod tests {
#[test]
fn the_census_carries_this_crates_names() {
assert_eq!(info("gradient_type").unwrap().value_type, TypeId::of::<crate::gradient::GradientType>());
assert_eq!(info("spread_method").unwrap().value_type, TypeId::of::<crate::gradient::GradientSpreadMethod>());
assert_eq!(info("gradient_form").unwrap().value_type, TypeId::of::<crate::gradient::GradientForm>());
assert_eq!(info("gradient_spread").unwrap().value_type, TypeId::of::<crate::gradient::GradientSpread>());
assert_eq!(info("editor:click_target").unwrap().value_type, TypeId::of::<Option<&'static crate::Vector>>());
}
@@ -36,4 +79,9 @@ mod tests {
fn an_absent_click_target_defaults_to_none() {
assert_eq!(<EditorClickTarget as Attribute>::default(), None);
}
#[test]
fn an_absent_midpoint_defaults_to_the_halfway_point() {
assert_eq!(<Midpoint as Attribute>::default(), 0.5);
}
}

View File

@@ -1,32 +1,13 @@
use crate::subpath::Bezier;
use crate::vector::misc::dvec2_to_point;
use core_types::math::quad::Quad;
use core_types::math::rect::Rect;
use kurbo::{Line, PathSeg};
pub trait QuadExt {
/// Get all the edges in the rect as linear bezier curves
fn bezier_lines(&self) -> impl Iterator<Item = Bezier> + '_;
fn to_lines(&self) -> impl Iterator<Item = PathSeg>;
}
impl QuadExt for Quad {
fn bezier_lines(&self) -> impl Iterator<Item = Bezier> + '_ {
self.all_edges().into_iter().map(|[start, end]| Bezier::from_linear_dvec2(start, end))
}
fn to_lines(&self) -> impl Iterator<Item = PathSeg> {
self.all_edges().into_iter().map(|[start, end]| PathSeg::Line(Line::new(dvec2_to_point(start), dvec2_to_point(end))))
}
}
pub trait RectExt {
/// Get all the edges in the quad as linear bezier curves
fn bezier_lines(&self) -> impl Iterator<Item = Bezier> + '_;
}
impl RectExt for Rect {
fn bezier_lines(&self) -> impl Iterator<Item = Bezier> + '_ {
self.edges().into_iter().map(|[start, end]| Bezier::from_linear_dvec2(start, end))
}
}

View File

@@ -1,4 +0,0 @@
// Implementation constants
/// Constant used to determine if `f64`s are equivalent.
pub const MAX_ABSOLUTE_DIFFERENCE: f64 = 1e-3;

View File

@@ -1,469 +0,0 @@
use super::consts::*;
use super::*;
use crate::vector::misc::{SpiralType, point_to_dvec2};
use glam::DVec2;
use kurbo::PathSeg;
use std::f64::consts::TAU;
pub struct PathSegPoints {
pub p0: DVec2,
pub p1: Option<DVec2>,
pub p2: Option<DVec2>,
pub p3: DVec2,
}
impl PathSegPoints {
pub fn new(p0: DVec2, p1: Option<DVec2>, p2: Option<DVec2>, p3: DVec2) -> Self {
Self { p0, p1, p2, p3 }
}
}
pub fn pathseg_points(segment: PathSeg) -> PathSegPoints {
match segment {
PathSeg::Line(line) => PathSegPoints::new(point_to_dvec2(line.p0), None, None, point_to_dvec2(line.p1)),
PathSeg::Quad(quad) => PathSegPoints::new(point_to_dvec2(quad.p0), None, Some(point_to_dvec2(quad.p1)), point_to_dvec2(quad.p2)),
PathSeg::Cubic(cube) => PathSegPoints::new(point_to_dvec2(cube.p0), Some(point_to_dvec2(cube.p1)), Some(point_to_dvec2(cube.p2)), point_to_dvec2(cube.p3)),
}
}
/// Functionality relating to core `Subpath` operations, such as constructors and `iter`.
impl<PointId: Identifier> Subpath<PointId> {
/// Create a new `Subpath` using a list of [ManipulatorGroup]s.
/// A `Subpath` with less than 2 [ManipulatorGroup]s may not be closed.
#[track_caller]
pub fn new(manipulator_groups: Vec<ManipulatorGroup<PointId>>, closed: bool) -> Self {
assert!(!closed || !manipulator_groups.is_empty(), "A closed Subpath must contain more than 0 ManipulatorGroups.");
Self { manipulator_groups, closed }
}
/// Create a `Subpath` consisting of 2 manipulator groups from a `Bezier`.
pub fn from_bezier(segment: PathSeg) -> Self {
let PathSegPoints { p0, p1, p2, p3 } = pathseg_points(segment);
Subpath::new(vec![ManipulatorGroup::new(p0, None, p1), ManipulatorGroup::new(p3, p2, None)], false)
}
/// Creates a subpath from a slice of [Bezier]. When two consecutive Beziers do not share an end and start point, this function
/// resolves the discrepancy by simply taking the start-point of the second Bezier as the anchor of the Manipulator Group.
pub fn from_beziers(beziers: &[PathSeg], closed: bool) -> Self {
assert!(!closed || beziers.len() > 1, "A closed Subpath must contain at least 1 Bezier.");
if beziers.is_empty() {
return Subpath::new(vec![], closed);
}
let beziers: Vec<_> = beziers.iter().map(|b| pathseg_points(*b)).collect();
let first = beziers.first().unwrap();
let mut manipulator_groups = vec![ManipulatorGroup {
anchor: first.p0,
in_handle: None,
out_handle: first.p1,
id: PointId::new(),
}];
let mut inner_groups: Vec<ManipulatorGroup<PointId>> = beziers
.windows(2)
.map(|bezier_pair| ManipulatorGroup {
anchor: bezier_pair[1].p0,
in_handle: bezier_pair[0].p2,
out_handle: bezier_pair[1].p1,
id: PointId::new(),
})
.collect::<Vec<ManipulatorGroup<PointId>>>();
manipulator_groups.append(&mut inner_groups);
let last = beziers.last().unwrap();
if !closed {
manipulator_groups.push(ManipulatorGroup {
anchor: last.p3,
in_handle: last.p2,
out_handle: None,
id: PointId::new(),
});
return Subpath::new(manipulator_groups, false);
}
manipulator_groups[0].in_handle = last.p2;
Subpath::new(manipulator_groups, true)
}
/// Returns true if the `Subpath` contains no [ManipulatorGroup].
pub fn is_empty(&self) -> bool {
self.manipulator_groups.is_empty()
}
/// Returns the number of [ManipulatorGroup]s contained within the `Subpath`.
pub fn len(&self) -> usize {
self.manipulator_groups.len()
}
/// Returns the number of segments contained within the `Subpath`.
pub fn len_segments(&self) -> usize {
let mut number_of_curves = self.len();
if !self.closed && number_of_curves > 0 {
number_of_curves -= 1
}
number_of_curves
}
/// Returns a copy of the bezier segment at the given segment index, if this segment exists.
pub fn get_segment(&self, segment_index: usize) -> Option<PathSeg> {
if segment_index >= self.len_segments() {
return None;
}
Some(self[segment_index].to_bezier(&self[(segment_index + 1) % self.len()]))
}
/// Returns an iterator of the [Bezier]s along the `Subpath`.
pub fn iter(&self) -> SubpathIter<'_, PointId> {
SubpathIter {
subpath: self,
index: 0,
is_always_closed: false,
}
}
/// Returns an iterator of the [Bezier]s along the `Subpath` always considering it as a closed subpath.
pub fn iter_closed(&self) -> SubpathIter<'_, PointId> {
SubpathIter {
subpath: self,
index: 0,
is_always_closed: true,
}
}
/// Returns a slice of the [ManipulatorGroup]s in the `Subpath`.
pub fn manipulator_groups(&self) -> &[ManipulatorGroup<PointId>] {
&self.manipulator_groups
}
/// Returns a mutable reference to the [ManipulatorGroup]s in the `Subpath`.
pub fn manipulator_groups_mut(&mut self) -> &mut Vec<ManipulatorGroup<PointId>> {
&mut self.manipulator_groups
}
/// Returns a vector of all the anchors (DVec2) for this `Subpath`.
pub fn anchors(&self) -> Vec<DVec2> {
self.manipulator_groups().iter().map(|group| group.anchor).collect()
}
/// Returns if the Subpath is equivalent to a single point.
pub fn is_point(&self) -> bool {
if self.is_empty() {
return false;
}
let point = self.manipulator_groups[0].anchor;
self.manipulator_groups
.iter()
.all(|manipulator_group| manipulator_group.anchor.abs_diff_eq(point, MAX_ABSOLUTE_DIFFERENCE))
}
pub fn from_anchors(anchor_positions: impl IntoIterator<Item = DVec2>, closed: bool) -> Self {
Self::new(anchor_positions.into_iter().map(|anchor| ManipulatorGroup::new_anchor(anchor)).collect(), closed)
}
/// Constructs a rectangle with `corner1` and `corner2` as the two corners.
pub fn new_rectangle(corner1: DVec2, corner2: DVec2) -> Self {
Self::from_anchors([corner1, DVec2::new(corner2.x, corner1.y), corner2, DVec2::new(corner1.x, corner2.y)], true)
}
/// Constructs a rounded rectangle with `corner1` and `corner2` as the two corners and `corner_radii` as the radii of the corners: `[top_left, top_right, bottom_right, bottom_left]`.
pub fn new_rounded_rectangle(corner1: DVec2, corner2: DVec2, corner_radii: [f64; 4]) -> Self {
if corner_radii.iter().all(|radii| radii.abs() < f64::EPSILON * 100.) {
return Self::new_rectangle(corner1, corner2);
}
use std::f64::consts::{FRAC_1_SQRT_2, PI};
let new_arc = |center: DVec2, corner: DVec2, radius: f64| -> Vec<ManipulatorGroup<PointId>> {
let point1 = center + DVec2::from_angle(-PI * 0.25).rotate(corner - center) * FRAC_1_SQRT_2;
let point2 = center + DVec2::from_angle(PI * 0.25).rotate(corner - center) * FRAC_1_SQRT_2;
if radius == 0. {
return vec![ManipulatorGroup::new_anchor(point1), ManipulatorGroup::new_anchor(point2)];
}
// Constant from https://pomax.github.io/bezierinfo/#circles_cubic
const HANDLE_OFFSET_FACTOR: f64 = 0.551784777779014;
let handle_offset = radius * HANDLE_OFFSET_FACTOR;
vec![
ManipulatorGroup::new(point1, None, Some(point1 + handle_offset * (corner - point1).normalize())),
ManipulatorGroup::new(point2, Some(point2 + handle_offset * (corner - point2).normalize()), None),
]
};
Self::new(
[
new_arc(DVec2::new(corner1.x + corner_radii[0], corner1.y + corner_radii[0]), DVec2::new(corner1.x, corner1.y), corner_radii[0]),
new_arc(DVec2::new(corner2.x - corner_radii[1], corner1.y + corner_radii[1]), DVec2::new(corner2.x, corner1.y), corner_radii[1]),
new_arc(DVec2::new(corner2.x - corner_radii[2], corner2.y - corner_radii[2]), DVec2::new(corner2.x, corner2.y), corner_radii[2]),
new_arc(DVec2::new(corner1.x + corner_radii[3], corner2.y - corner_radii[3]), DVec2::new(corner1.x, corner2.y), corner_radii[3]),
]
.concat(),
true,
)
}
/// Constructs an ellipse with `corner1` and `corner2` as the two corners of the bounding box.
pub fn new_ellipse(corner1: DVec2, corner2: DVec2) -> Self {
let size = (corner1 - corner2).abs();
let center = (corner1 + corner2) / 2.;
let top = DVec2::new(center.x, corner1.y);
let bottom = DVec2::new(center.x, corner2.y);
let left = DVec2::new(corner1.x, center.y);
let right = DVec2::new(corner2.x, center.y);
// Based on https://pomax.github.io/bezierinfo/#circles_cubic
const HANDLE_OFFSET_FACTOR: f64 = 0.551784777779014;
let handle_offset = size * HANDLE_OFFSET_FACTOR * 0.5;
let manipulator_groups = vec![
ManipulatorGroup::new(top, Some(top - handle_offset * DVec2::X), Some(top + handle_offset * DVec2::X)),
ManipulatorGroup::new(right, Some(right - handle_offset * DVec2::Y), Some(right + handle_offset * DVec2::Y)),
ManipulatorGroup::new(bottom, Some(bottom + handle_offset * DVec2::X), Some(bottom - handle_offset * DVec2::X)),
ManipulatorGroup::new(left, Some(left + handle_offset * DVec2::Y), Some(left - handle_offset * DVec2::Y)),
];
Self::new(manipulator_groups, true)
}
/// Constructs an arc by a `radius`, `angle_start` and `angle_size`. Angles must be in radians. Slice option makes it look like pie or pacman.
pub fn new_arc(radius: f64, start_angle: f64, sweep_angle: f64, arc_type: ArcType) -> Self {
// Prevents glitches from numerical imprecision that have been observed during animation playback after about a minute
let start_angle = start_angle % (std::f64::consts::TAU * 2.);
let sweep_angle = sweep_angle % (std::f64::consts::TAU * 2.);
let original_start_angle = start_angle;
let sweep_angle_sign = sweep_angle.signum();
let mut start_angle = 0.;
let mut sweep_angle = sweep_angle.abs();
if ((sweep_angle / std::f64::consts::TAU).floor() as u32).is_multiple_of(2) {
sweep_angle %= std::f64::consts::TAU;
} else {
start_angle = sweep_angle % std::f64::consts::TAU;
sweep_angle = std::f64::consts::TAU - start_angle;
}
sweep_angle *= sweep_angle_sign;
start_angle *= sweep_angle_sign;
start_angle += original_start_angle;
let closed = arc_type == ArcType::Closed;
let slice = arc_type == ArcType::PieSlice;
let center = DVec2::new(0., 0.);
let segments = (sweep_angle.abs() / (std::f64::consts::PI / 4.)).ceil().max(1.) as usize;
let step = sweep_angle / segments as f64;
let factor = 4. / 3. * (step / 2.).sin() / (1. + (step / 2.).cos());
let mut manipulator_groups = Vec::with_capacity(segments);
let mut prev_in_handle = None;
let mut prev_end = DVec2::new(0., 0.);
for i in 0..segments {
let start_angle = start_angle + step * i as f64;
let end_angle = start_angle + step;
let start_vec = DVec2::from_angle(start_angle);
let end_vec = DVec2::from_angle(end_angle);
let start = center + radius * start_vec;
let end = center + radius * end_vec;
let handle_start = start + start_vec.perp() * radius * factor;
let handle_end = end - end_vec.perp() * radius * factor;
manipulator_groups.push(ManipulatorGroup::new(start, prev_in_handle, Some(handle_start)));
prev_in_handle = Some(handle_end);
prev_end = end;
}
manipulator_groups.push(ManipulatorGroup::new(prev_end, prev_in_handle, None));
if slice {
manipulator_groups.push(ManipulatorGroup::new(center, None, None));
}
Self::new(manipulator_groups, closed || slice)
}
/// Constructs a regular polygon (ngon). Based on `sides` and `radius`, which is the distance from the center to any vertex.
pub fn new_regular_polygon(center: DVec2, sides: u64, radius: f64) -> Self {
let sides = sides.max(3);
let angle_increment = std::f64::consts::TAU / (sides as f64);
let anchor_positions = (0..sides).map(|i| {
let angle = (i as f64) * angle_increment - std::f64::consts::FRAC_PI_2;
let center = center + DVec2::ONE * radius;
DVec2::new(center.x + radius * f64::cos(angle), center.y + radius * f64::sin(angle)) * 0.5
});
Self::from_anchors(anchor_positions, true)
}
/// Constructs a star polygon (n-star). See [new_regular_polygon], but with interspersed vertices at an `inner_radius`.
pub fn new_star_polygon(center: DVec2, sides: u64, radius: f64, inner_radius: f64) -> Self {
let sides = sides.max(2);
let angle_increment = 0.5 * std::f64::consts::TAU / (sides as f64);
let anchor_positions = (0..sides * 2).map(|i| {
let angle = (i as f64) * angle_increment - std::f64::consts::FRAC_PI_2;
let center = center + DVec2::ONE * radius;
let r = if i % 2 == 0 { radius } else { inner_radius };
DVec2::new(center.x + r * f64::cos(angle), center.y + r * f64::sin(angle)) * 0.5
});
Self::from_anchors(anchor_positions, true)
}
/// Constructs a line from `p1` to `p2`
pub fn new_line(p1: DVec2, p2: DVec2) -> Self {
Self::from_anchors([p1, p2], false)
}
/// Constructs an arrow shape from start and end points with parametric control over dimensions
pub fn new_arrow(start: DVec2, end: DVec2, shaft_width: f64, head_width: f64, head_length: f64) -> Self {
let delta = end - start;
let length = delta.length();
if length < 1e-10 {
// Degenerate case: return a point
return Self::from_anchors([start], true);
}
let direction = delta / length;
let perpendicular = DVec2::new(-direction.y, direction.x);
let half_shaft = shaft_width * 0.5;
let half_head = head_width * 0.5;
let head_base_distance = (length - head_length).max(0.);
let head_base = start + direction * head_base_distance;
// Arrow path starts at the tail, traces around the shape, and returns to the tail
let anchors = [
start, // Tail center (origin)
start + perpendicular * half_shaft, // Tail top
head_base + perpendicular * half_shaft, // Head base top (shaft)
head_base + perpendicular * half_head, // Head base top (wide)
end, // Tip
head_base - perpendicular * half_head, // Head base bottom (wide)
head_base - perpendicular * half_shaft, // Head base bottom (shaft)
start - perpendicular * half_shaft, // Tail bottom
];
Self::from_anchors(anchors, true)
}
pub fn new_spiral(a: f64, outer_radius: f64, turns: f64, start_angle: f64, delta_theta: f64, spiral_type: SpiralType) -> Self {
let mut manipulator_groups = Vec::new();
let mut prev_in_handle = None;
let theta_end = turns * std::f64::consts::TAU + start_angle;
let a = if spiral_type == SpiralType::Logarithmic { a.max(1e-10) } else { a };
let b = calculate_growth_factor(a, turns, outer_radius, spiral_type);
let mut theta = start_angle;
while theta < theta_end {
let theta_next = f64::min(theta + delta_theta, theta_end);
let p0 = spiral_point(theta, a, b, spiral_type);
let p3 = spiral_point(theta_next, a, b, spiral_type);
let t0 = spiral_tangent(theta, a, b, spiral_type);
let t1 = spiral_tangent(theta_next, a, b, spiral_type);
let arc_len = spiral_arc_length(theta, theta_next, a, b, spiral_type);
let d = arc_len / 3.;
let p1 = p0 + d * t0;
let p2 = p3 - d * t1;
manipulator_groups.push(ManipulatorGroup::new(p0, prev_in_handle, Some(p1)));
prev_in_handle = Some(p2);
// If final segment, end with anchor at theta_end
if (theta_next - theta_end).abs() < f64::EPSILON {
manipulator_groups.push(ManipulatorGroup::new(p3, prev_in_handle, None));
break;
}
theta = theta_next;
}
Self::new(manipulator_groups, false)
}
}
pub fn calculate_growth_factor(a: f64, turns: f64, outer_radius: f64, spiral_type: SpiralType) -> f64 {
match spiral_type {
SpiralType::Archimedean => {
let total_theta = turns * TAU;
(outer_radius - a) / total_theta
}
SpiralType::Logarithmic => {
let total_theta = turns * TAU;
((outer_radius.abs() / a).ln()) / total_theta
}
}
}
/// Returns a point on the given spiral type at angle `theta`.
pub fn spiral_point(theta: f64, a: f64, b: f64, spiral_type: SpiralType) -> DVec2 {
match spiral_type {
SpiralType::Archimedean => archimedean_spiral_point(theta, a, b),
SpiralType::Logarithmic => log_spiral_point(theta, a, b),
}
}
/// Returns the tangent direction at angle `theta` for the given spiral type.
pub fn spiral_tangent(theta: f64, a: f64, b: f64, spiral_type: SpiralType) -> DVec2 {
match spiral_type {
SpiralType::Archimedean => archimedean_spiral_tangent(theta, a, b),
SpiralType::Logarithmic => log_spiral_tangent(theta, a, b),
}
}
/// Computes arc length between two angles for the given spiral type.
pub fn spiral_arc_length(theta_start: f64, theta_end: f64, a: f64, b: f64, spiral_type: SpiralType) -> f64 {
match spiral_type {
SpiralType::Archimedean => archimedean_spiral_arc_length(theta_start, theta_end, a, b),
SpiralType::Logarithmic => log_spiral_arc_length(theta_start, theta_end, a, b),
}
}
/// Returns a point on a logarithmic spiral at angle `theta`.
pub fn log_spiral_point(theta: f64, a: f64, b: f64) -> DVec2 {
let r = a * (b * theta).exp(); // a * e^(bθ)
DVec2::new(r * theta.cos(), -r * theta.sin())
}
/// Computes arc length along a logarithmic spiral between two angles.
pub fn log_spiral_arc_length(theta_start: f64, theta_end: f64, a: f64, b: f64) -> f64 {
let factor = (1. + b * b).sqrt();
(a / b) * factor * ((b * theta_end).exp() - (b * theta_start).exp())
}
/// Returns the tangent direction of a logarithmic spiral at angle `theta`.
pub fn log_spiral_tangent(theta: f64, a: f64, b: f64) -> DVec2 {
let r = a * (b * theta).exp();
let dx = r * (b * theta.cos() - theta.sin());
let dy = r * (b * theta.sin() + theta.cos());
DVec2::new(dx, -dy).normalize_or(DVec2::X)
}
/// Returns a point on an Archimedean spiral at angle `theta`.
pub fn archimedean_spiral_point(theta: f64, a: f64, b: f64) -> DVec2 {
let r = a + b * theta;
DVec2::new(r * theta.cos(), -r * theta.sin())
}
/// Returns the tangent direction of an Archimedean spiral at angle `theta`.
pub fn archimedean_spiral_tangent(theta: f64, a: f64, b: f64) -> DVec2 {
let r = a + b * theta;
let dx = b * theta.cos() - r * theta.sin();
let dy = b * theta.sin() + r * theta.cos();
DVec2::new(dx, -dy).normalize_or(DVec2::X)
}
/// Computes arc length along an Archimedean spiral between two angles.
pub fn archimedean_spiral_arc_length(theta_start: f64, theta_end: f64, a: f64, b: f64) -> f64 {
archimedean_spiral_arc_length_origin(theta_end, a, b) - archimedean_spiral_arc_length_origin(theta_start, a, b)
}
/// Computes arc length from origin to a point on Archimedean spiral at angle `theta`.
pub fn archimedean_spiral_arc_length_origin(theta: f64, a: f64, b: f64) -> f64 {
let r = a + b * theta;
let sqrt_term = (r * r + b * b).sqrt();
(r * sqrt_term + b * b * ((r + sqrt_term).ln())) / (2. * b)
}

View File

@@ -1,128 +0,0 @@
use super::consts::MAX_ABSOLUTE_DIFFERENCE;
use super::*;
use crate::vector::algorithms::bezpath_algorithms::pathseg_length_centroid_and_length;
use crate::vector::algorithms::intersection::{filtered_all_segment_intersections, pathseg_self_intersections};
use core_types::math::polynomial::pathseg_to_parametric_polynomial;
use glam::DVec2;
impl<PointId: Identifier> Subpath<PointId> {
/// Returns a list of `t` values that correspond to all the self intersection points of the subpath always considering it as a closed subpath. The index and `t` value of both will be returned that corresponds to a point.
/// The points will be sorted based on their index and `t` repsectively.
/// - `error` - For intersections with non-linear beziers, `error` defines the threshold for bounding boxes to be considered an intersection point.
/// - `minimum_separation`: the minimum difference two adjacent `t`-values must have when comparing adjacent `t`-values in sorted order.
///
/// If the comparison condition is not satisfied, the function takes the larger `t`-value of the two
///
/// **NOTE**: if an intersection were to occur within an `error` distance away from an anchor point, the algorithm will filter that intersection out.
pub fn all_self_intersections(&self, accuracy: Option<f64>, minimum_separation: Option<f64>) -> Vec<(usize, f64)> {
let mut intersections_vec = Vec::new();
let err = accuracy.unwrap_or(MAX_ABSOLUTE_DIFFERENCE);
let num_curves = self.len();
// TODO: optimization opportunity - this for-loop currently compares all intersections with all curve-segments in the subpath list
self.iter_closed().enumerate().for_each(|(i, other)| {
intersections_vec.extend(pathseg_self_intersections(other, accuracy, minimum_separation).iter().flat_map(|value| [(i, value.0), (i, value.1)]));
self.iter_closed().enumerate().skip(i + 1).for_each(|(j, curve)| {
intersections_vec.extend(
filtered_all_segment_intersections(curve, other, accuracy, minimum_separation)
.iter()
.filter(|&value| (j != i + 1 || value.0 > err || (1. - value.1) > err) && (j != num_curves - 1 || i != 0 || value.1 > err || (1. - value.0) > err))
.flat_map(|value| [(j, value.0), (i, value.1)]),
);
});
});
intersections_vec.sort_by(|a, b| a.partial_cmp(b).unwrap());
intersections_vec
}
/// Return the area centroid, together with the area, of the `Subpath` always considering it as a closed subpath. The area will always be a positive value.
///
/// The area centroid is the center of mass for the area of a solid shape's interior.
/// An infinitely flat material forming the subpath's closed shape would balance at this point.
///
/// It will return `None` if no manipulator is present. If the area is less than `error`, it will return `Some((DVec2::NAN, 0.))`.
///
/// Because the calculation of area and centroid for self-intersecting path requires finding the intersections, the following parameters are used:
/// - `error` - For intersections with non-linear beziers, `error` defines the threshold for bounding boxes to be considered an intersection point.
/// - `minimum_separation` - the minimum difference two adjacent `t`-values must have when comparing adjacent `t`-values in sorted order.
///
/// If the comparison condition is not satisfied, the function takes the larger `t`-value of the two.
///
/// **NOTE**: if an intersection were to occur within an `error` distance away from an anchor point, the algorithm will filter that intersection out.
pub fn area_centroid_and_area(&self, error: Option<f64>, minimum_separation: Option<f64>) -> Option<(DVec2, f64)> {
let all_intersections = self.all_self_intersections(error, minimum_separation);
let mut current_sign: f64 = 1.;
let (x_sum, y_sum, area) = self
.iter_closed()
.enumerate()
.map(|(index, bezier)| {
let (f_x, f_y) = pathseg_to_parametric_polynomial(bezier);
let (f_x, f_y) = (f_x.as_size::<10>().unwrap(), f_y.as_size::<10>().unwrap());
let f_y_prime = f_y.derivative();
let f_x_prime = f_x.derivative();
let f_xy = &f_x * &f_y;
let mut x_part = &f_xy * &f_x_prime;
let mut y_part = &f_xy * &f_y_prime;
let mut area_part = &f_x * &f_y_prime;
x_part.antiderivative_mut();
y_part.antiderivative_mut();
area_part.antiderivative_mut();
let mut curve_sum_x = -current_sign * x_part.eval(0.);
let mut curve_sum_y = -current_sign * y_part.eval(0.);
let mut curve_sum_area = -current_sign * area_part.eval(0.);
for (_, t) in all_intersections.iter().filter(|(i, _)| *i == index) {
curve_sum_x += 2. * current_sign * x_part.eval(*t);
curve_sum_y += 2. * current_sign * y_part.eval(*t);
curve_sum_area += 2. * current_sign * area_part.eval(*t);
current_sign *= -1.;
}
curve_sum_x += current_sign * x_part.eval(1.);
curve_sum_y += current_sign * y_part.eval(1.);
curve_sum_area += current_sign * area_part.eval(1.);
(-curve_sum_x, curve_sum_y, curve_sum_area)
})
.reduce(|(x1, y1, area1), (x2, y2, area2)| (x1 + x2, y1 + y2, area1 + area2))?;
if area.abs() < error.unwrap_or(MAX_ABSOLUTE_DIFFERENCE) {
return Some((DVec2::NAN, 0.));
}
Some((DVec2::new(x_sum / area, y_sum / area), area.abs()))
}
/// Return the approximation of the length centroid, together with the length, of the `Subpath`.
///
/// The length centroid is the center of mass for the arc length of the solid shape's perimeter.
/// An infinitely thin wire forming the subpath's closed shape would balance at this point.
///
/// It will return `None` if no manipulator is present.
/// - `accuracy` is used to approximate the curve.
/// - `always_closed` is to consider the subpath as closed always.
pub fn length_centroid_and_length(&self, accuracy: Option<f64>, always_closed: bool) -> Option<(DVec2, f64)> {
if always_closed { self.iter_closed() } else { self.iter() }
.map(|bezier| pathseg_length_centroid_and_length(bezier, accuracy))
.map(|(centroid, length)| (centroid * length, length))
.reduce(|(centroid_part1, length1), (centroid_part2, length2)| (centroid_part1 + centroid_part2, length1 + length2))
.map(|(centroid_part, length)| (centroid_part / length, length))
.map(|(centroid_part, length)| (DVec2::new(centroid_part.x, centroid_part.y), length))
}
}
#[cfg(test)]
mod test_centroid {
use crate::vector::PointId;
use super::*;
#[test]
fn centroid_rect() {
let rect = Subpath::<PointId>::new_rectangle(DVec2::new(100., 100.), DVec2::new(300., 200.));
let (center, area) = rect.area_centroid_and_area(Some(1e-3), Some(1e-3)).unwrap();
assert_eq!(area, 200. * 100.);
assert_eq!(center, DVec2::new(200., 150.))
}
}

View File

@@ -1,52 +0,0 @@
// use super::consts::MAX_ABSOLUTE_DIFFERENCE;
// use super::utils::{SubpathTValue};
use super::*;
impl<PointId: super::structs::Identifier> Subpath<PointId> {
/// Get whether the subpath is closed.
pub fn closed(&self) -> bool {
self.closed
}
/// Set whether the subpath is closed.
pub fn set_closed(&mut self, new_closed: bool) {
self.closed = new_closed;
}
/// Access a [ManipulatorGroup] from a PointId.
pub fn manipulator_from_id(&self, id: PointId) -> Option<&ManipulatorGroup<PointId>> {
self.manipulator_groups.iter().find(|manipulator_group| manipulator_group.id == id)
}
/// Access a mutable [ManipulatorGroup] from a PointId.
pub fn manipulator_mut_from_id(&mut self, id: PointId) -> Option<&mut ManipulatorGroup<PointId>> {
self.manipulator_groups.iter_mut().find(|manipulator_group| manipulator_group.id == id)
}
/// Access the index of a [ManipulatorGroup] from a PointId.
pub fn manipulator_index_from_id(&self, id: PointId) -> Option<usize> {
self.manipulator_groups.iter().position(|manipulator_group| manipulator_group.id == id)
}
/// Insert a manipulator group at an index.
pub fn insert_manipulator_group(&mut self, index: usize, group: ManipulatorGroup<PointId>) {
assert!(group.is_finite(), "Inserting non finite manipulator group");
self.manipulator_groups.insert(index, group)
}
/// Push a manipulator group to the end.
pub fn push_manipulator_group(&mut self, group: ManipulatorGroup<PointId>) {
assert!(group.is_finite(), "Pushing non finite manipulator group");
self.manipulator_groups.push(group)
}
/// Get a mutable reference to the last manipulator
pub fn last_manipulator_group_mut(&mut self) -> Option<&mut ManipulatorGroup<PointId>> {
self.manipulator_groups.last_mut()
}
/// Remove a manipulator group at an index.
pub fn remove_manipulator_group(&mut self, index: usize) -> ManipulatorGroup<PointId> {
self.manipulator_groups.remove(index)
}
}

View File

@@ -1,71 +0,0 @@
mod consts;
mod core;
mod lookup;
mod manipulators;
mod solvers;
mod structs;
mod transform;
pub use core::*;
use kurbo::PathSeg;
use std::fmt::{Debug, Formatter, Result};
use std::ops::{Index, IndexMut};
pub use structs::*;
/// Structure used to represent a path composed of [Bezier] curves.
#[derive(Clone, PartialEq, graphene_hash::CacheHash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Subpath<PointId: Identifier> {
manipulator_groups: Vec<ManipulatorGroup<PointId>>,
pub closed: bool,
}
/// Iteration structure for iterating across each curve of a `Subpath`, using an intermediate `Bezier` representation.
pub struct SubpathIter<'a, PointId: Identifier> {
index: usize,
subpath: &'a Subpath<PointId>,
is_always_closed: bool,
}
impl<PointId: Identifier> Index<usize> for Subpath<PointId> {
type Output = ManipulatorGroup<PointId>;
fn index(&self, index: usize) -> &Self::Output {
assert!(index < self.len(), "Index out of bounds in trait Index of SubPath.");
&self.manipulator_groups[index]
}
}
impl<PointId: Identifier> IndexMut<usize> for Subpath<PointId> {
fn index_mut(&mut self, index: usize) -> &mut Self::Output {
assert!(index < self.len(), "Index out of bounds in trait IndexMut of SubPath.");
&mut self.manipulator_groups[index]
}
}
impl<PointId: Identifier> Iterator for SubpathIter<'_, PointId> {
type Item = PathSeg;
// Returns the Bezier representation of each `Subpath` segment, defined between a pair of adjacent manipulator points.
fn next(&mut self) -> Option<Self::Item> {
if self.subpath.is_empty() {
return None;
}
let closed = if self.is_always_closed { true } else { self.subpath.closed };
let len = self.subpath.len() - 1 + if closed { 1 } else { 0 };
if self.index >= len {
return None;
}
let start_index = self.index;
let end_index = (self.index + 1) % self.subpath.len();
self.index += 1;
Some(self.subpath[start_index].to_bezier(&self.subpath[end_index]))
}
}
impl<PointId: Identifier> Debug for Subpath<PointId> {
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
f.debug_struct("Subpath").field("closed", &self.closed).field("manipulator_groups", &self.manipulator_groups).finish()
}
}

View File

@@ -1,83 +0,0 @@
use crate::subpath::{Identifier, Subpath};
use crate::vector::algorithms::bezpath_algorithms::bezpath_is_inside_bezpath;
use crate::vector::misc::dvec2_to_point;
use glam::DVec2;
use kurbo::{Affine, BezPath, Shape};
impl<PointId: Identifier> Subpath<PointId> {
pub fn contains_point(&self, point: DVec2) -> bool {
self.to_bezpath().contains(dvec2_to_point(point))
}
pub fn to_bezpath(&self) -> BezPath {
let mut bezpath = kurbo::BezPath::new();
let mut out_handle;
let Some(first) = self.manipulator_groups.first() else { return bezpath };
bezpath.move_to(dvec2_to_point(first.anchor));
out_handle = first.out_handle;
for manipulator in self.manipulator_groups.iter().skip(1) {
match (out_handle, manipulator.in_handle) {
(Some(handle_start), Some(handle_end)) => bezpath.curve_to(dvec2_to_point(handle_start), dvec2_to_point(handle_end), dvec2_to_point(manipulator.anchor)),
(None, None) => bezpath.line_to(dvec2_to_point(manipulator.anchor)),
(None, Some(handle)) => bezpath.quad_to(dvec2_to_point(handle), dvec2_to_point(manipulator.anchor)),
(Some(handle), None) => bezpath.quad_to(dvec2_to_point(handle), dvec2_to_point(manipulator.anchor)),
}
out_handle = manipulator.out_handle;
}
if self.closed {
match (out_handle, first.in_handle) {
(Some(handle_start), Some(handle_end)) => bezpath.curve_to(dvec2_to_point(handle_start), dvec2_to_point(handle_end), dvec2_to_point(first.anchor)),
(None, None) => bezpath.line_to(dvec2_to_point(first.anchor)),
(None, Some(handle)) => bezpath.quad_to(dvec2_to_point(handle), dvec2_to_point(first.anchor)),
(Some(handle), None) => bezpath.quad_to(dvec2_to_point(handle), dvec2_to_point(first.anchor)),
}
bezpath.close_path();
}
bezpath
}
/// Returns `true` if this subpath is completely inside the `other` subpath.
pub fn is_inside_subpath(&self, other: &Subpath<PointId>, accuracy: Option<f64>, minimum_separation: Option<f64>) -> bool {
bezpath_is_inside_bezpath(&self.to_bezpath(), &other.to_bezpath(), accuracy, minimum_separation)
}
/// Return the min and max corners that represent the bounding box of the subpath. Return `None` if the subpath is empty.
pub fn bounding_box(&self) -> Option<[DVec2; 2]> {
self.iter()
.map(|bezier| bezier.bounding_box())
.map(|bbox| [DVec2::new(bbox.min_x(), bbox.min_y()), DVec2::new(bbox.max_x(), bbox.max_y())])
.reduce(|bbox1, bbox2| [bbox1[0].min(bbox2[0]), bbox1[1].max(bbox2[1])])
}
/// Return the min and max corners that represent the bounding box of the subpath, after a given affine transform.
pub fn bounding_box_with_transform(&self, transform: glam::DAffine2) -> Option<[DVec2; 2]> {
self.iter()
.map(|bezier| (Affine::new(transform.to_cols_array()) * bezier).bounding_box())
.map(|bbox| [DVec2::new(bbox.min_x(), bbox.min_y()), DVec2::new(bbox.max_x(), bbox.max_y())])
.reduce(|bbox1, bbox2| [bbox1[0].min(bbox2[0]), bbox1[1].max(bbox2[1])])
}
/// Return the min and max corners that represent the loose bounding box of the subpath (bounding box of all handles and anchors).
pub fn loose_bounding_box(&self) -> Option<[DVec2; 2]> {
self.manipulator_groups
.iter()
.flat_map(|group| [group.in_handle, group.out_handle, Some(group.anchor)])
.flatten()
.map(|pos| [pos, pos])
.reduce(|bbox1, bbox2| [bbox1[0].min(bbox2[0]), bbox1[1].max(bbox2[1])])
}
/// Return the min and max corners that represent the loose bounding box of the subpath, after a given affine transform.
pub fn loose_bounding_box_with_transform(&self, transform: glam::DAffine2) -> Option<[DVec2; 2]> {
self.manipulator_groups
.iter()
.flat_map(|group| [group.in_handle, group.out_handle, Some(group.anchor)])
.flatten()
.map(|pos| transform.transform_point2(pos))
.map(|pos| [pos, pos])
.reduce(|bbox1, bbox2| [bbox1[0].min(bbox2[0]), bbox1[1].max(bbox2[1])])
}
}

View File

@@ -1,384 +0,0 @@
use crate::vector::algorithms::intersection::filtered_segment_intersections;
use crate::vector::misc::{dvec2_to_point, handles_to_segment};
use glam::{DAffine2, DVec2};
use kurbo::{CubicBez, Line, PathSeg, QuadBez, Shape};
use std::fmt::{Debug, Formatter, Result};
use std::hash::Hash;
/// An id type used for each [ManipulatorGroup].
pub trait Identifier: Sized + Clone + PartialEq + Hash + graphene_hash::CacheHash + 'static {
fn new() -> Self;
}
/// Structure used to represent a single anchor with up to two optional associated handles along a `Subpath`
#[derive(Copy, Clone, PartialEq, graphene_hash::CacheHash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ManipulatorGroup<PointId: Identifier> {
pub anchor: DVec2,
pub in_handle: Option<DVec2>,
pub out_handle: Option<DVec2>,
pub id: PointId,
}
impl<PointId: Identifier> Debug for ManipulatorGroup<PointId> {
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
f.debug_struct("ManipulatorGroup")
.field("anchor", &self.anchor)
.field("in_handle", &self.in_handle)
.field("out_handle", &self.out_handle)
.finish()
}
}
impl<PointId: Identifier> ManipulatorGroup<PointId> {
/// Construct a new manipulator group from an anchor, in handle and out handle
pub fn new(anchor: DVec2, in_handle: Option<DVec2>, out_handle: Option<DVec2>) -> Self {
let id = PointId::new();
Self { anchor, in_handle, out_handle, id }
}
/// Construct a new manipulator point with just an anchor position
pub fn new_anchor(anchor: DVec2) -> Self {
Self::new(anchor, None, None)
}
/// Construct a new manipulator group from an anchor, in handle, out handle and an id
pub fn new_with_id(anchor: DVec2, in_handle: Option<DVec2>, out_handle: Option<DVec2>, id: PointId) -> Self {
Self { anchor, in_handle, out_handle, id }
}
/// Construct a new manipulator point with just an anchor position and an id
pub fn new_anchor_with_id(anchor: DVec2, id: PointId) -> Self {
Self::new_with_id(anchor, Some(anchor), Some(anchor), id)
}
/// Create a bezier curve that starts at the current manipulator group and finishes in the `end_group` manipulator group.
pub fn to_bezier(&self, end_group: &ManipulatorGroup<PointId>) -> PathSeg {
let start = self.anchor;
let end = end_group.anchor;
let out_handle = self.out_handle;
let in_handle = end_group.in_handle;
match (out_handle, in_handle) {
(Some(handle1), Some(handle2)) => PathSeg::Cubic(CubicBez::new(dvec2_to_point(start), dvec2_to_point(handle1), dvec2_to_point(handle2), dvec2_to_point(end))),
(Some(handle), None) | (None, Some(handle)) => PathSeg::Quad(QuadBez::new(dvec2_to_point(start), dvec2_to_point(handle), dvec2_to_point(end))),
(None, None) => PathSeg::Line(Line::new(dvec2_to_point(start), dvec2_to_point(end))),
}
}
/// Apply a transformation to all of the [ManipulatorGroup] points
pub fn apply_transform(&mut self, affine_transform: DAffine2) {
self.anchor = affine_transform.transform_point2(self.anchor);
self.in_handle = self.in_handle.map(|in_handle| affine_transform.transform_point2(in_handle));
self.out_handle = self.out_handle.map(|out_handle| affine_transform.transform_point2(out_handle));
}
/// Are all handles at finite positions
pub fn is_finite(&self) -> bool {
self.anchor.is_finite() && self.in_handle.is_none_or(|handle| handle.is_finite()) && self.out_handle.is_none_or(|handle| handle.is_finite())
}
/// Reverse directions of handles
pub fn flip(mut self) -> Self {
std::mem::swap(&mut self.in_handle, &mut self.out_handle);
self
}
pub fn has_in_handle(&self) -> bool {
self.in_handle.map(|handle| Self::has_handle(self.anchor, handle)).unwrap_or(false)
}
pub fn has_out_handle(&self) -> bool {
self.out_handle.map(|handle| Self::has_handle(self.anchor, handle)).unwrap_or(false)
}
fn has_handle(anchor: DVec2, handle: DVec2) -> bool {
!((handle.x - anchor.x).abs() < f64::EPSILON && (handle.y - anchor.y).abs() < f64::EPSILON)
}
}
#[derive(Copy, Clone)]
pub enum AppendType {
IgnoreStart,
SmoothJoin(f64),
}
#[derive(Copy, Clone, Eq, PartialEq, Hash, graphene_hash::CacheHash)]
pub enum ArcType {
Open,
Closed,
PieSlice,
}
/// Representation of the handle point(s) in a bezier segment.
#[derive(Copy, Clone, PartialEq, Debug, graphene_hash::CacheHash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum BezierHandles {
Linear,
/// Handles for a quadratic curve.
Quadratic {
/// Point representing the location of the single handle.
handle: DVec2,
},
/// Handles for a cubic curve.
Cubic {
/// Point representing the location of the handle associated to the start point.
handle_start: DVec2,
/// Point representing the location of the handle associated to the end point.
handle_end: DVec2,
},
}
impl BezierHandles {
pub fn is_cubic(&self) -> bool {
matches!(self, Self::Cubic { .. })
}
pub fn is_finite(&self) -> bool {
match self {
BezierHandles::Linear => true,
BezierHandles::Quadratic { handle } => handle.is_finite(),
BezierHandles::Cubic { handle_start, handle_end } => handle_start.is_finite() && handle_end.is_finite(),
}
}
/// Get the coordinates of the bezier segment's first handle point. This represents the only handle in a quadratic segment.
pub fn start(&self) -> Option<DVec2> {
match *self {
BezierHandles::Cubic { handle_start, .. } | BezierHandles::Quadratic { handle: handle_start } => Some(handle_start),
_ => None,
}
}
/// Get the coordinates of the second handle point. This will return `None` for a quadratic segment.
pub fn end(&self) -> Option<DVec2> {
match *self {
BezierHandles::Cubic { handle_end, .. } => Some(handle_end),
_ => None,
}
}
pub fn move_start(&mut self, delta: DVec2) {
if let BezierHandles::Cubic { handle_start, .. } | BezierHandles::Quadratic { handle: handle_start } = self {
*handle_start += delta
}
}
pub fn move_end(&mut self, delta: DVec2) {
if let BezierHandles::Cubic { handle_end, .. } = self {
*handle_end += delta
}
}
/// Returns a Bezier curve that results from applying the transformation function to each handle point in the Bezier.
#[must_use]
pub fn apply_transformation(&self, transformation_function: impl Fn(DVec2) -> DVec2) -> Self {
match *self {
BezierHandles::Linear => Self::Linear,
BezierHandles::Quadratic { handle } => {
let handle = transformation_function(handle);
Self::Quadratic { handle }
}
BezierHandles::Cubic { handle_start, handle_end } => {
let handle_start = transformation_function(handle_start);
let handle_end = transformation_function(handle_end);
Self::Cubic { handle_start, handle_end }
}
}
}
#[must_use]
pub fn reversed(self) -> Self {
match self {
BezierHandles::Cubic { handle_start, handle_end } => Self::Cubic {
handle_start: handle_end,
handle_end: handle_start,
},
_ => self,
}
}
}
/// Representation of a bezier curve with 2D points.
#[derive(Copy, Clone, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Bezier {
/// Start point of the bezier curve.
pub start: DVec2,
/// End point of the bezier curve.
pub end: DVec2,
/// Handles of the bezier curve.
pub handles: BezierHandles,
}
impl Debug for Bezier {
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
let mut debug_struct = f.debug_struct("Bezier");
let mut debug_struct_ref = debug_struct.field("start", &self.start);
debug_struct_ref = match self.handles {
BezierHandles::Linear => debug_struct_ref,
BezierHandles::Quadratic { handle } => debug_struct_ref.field("handle", &handle),
BezierHandles::Cubic { handle_start, handle_end } => debug_struct_ref.field("handle_start", &handle_start).field("handle_end", &handle_end),
};
debug_struct_ref.field("end", &self.end).finish()
}
}
/// Functionality for the getters and setters of the various points in a Bezier
impl Bezier {
/// Set the coordinates of the start point.
pub fn set_start(&mut self, s: DVec2) {
self.start = s;
}
/// Set the coordinates of the end point.
pub fn set_end(&mut self, e: DVec2) {
self.end = e;
}
/// Set the coordinates of the first handle point. This represents the only handle in a quadratic segment. If used on a linear segment, it will be changed to a quadratic.
pub fn set_handle_start(&mut self, h1: DVec2) {
match self.handles {
BezierHandles::Linear => {
self.handles = BezierHandles::Quadratic { handle: h1 };
}
BezierHandles::Quadratic { ref mut handle } => {
*handle = h1;
}
BezierHandles::Cubic { ref mut handle_start, .. } => {
*handle_start = h1;
}
};
}
/// Set the coordinates of the second handle point. This will convert both linear and quadratic segments into cubic ones. For a linear segment, the first handle will be set to the start point.
pub fn set_handle_end(&mut self, h2: DVec2) {
match self.handles {
BezierHandles::Linear => {
self.handles = BezierHandles::Cubic {
handle_start: self.start,
handle_end: h2,
};
}
BezierHandles::Quadratic { handle } => {
self.handles = BezierHandles::Cubic { handle_start: handle, handle_end: h2 };
}
BezierHandles::Cubic { ref mut handle_end, .. } => {
*handle_end = h2;
}
};
}
/// Get the coordinates of the bezier segment's start point.
pub fn start(&self) -> DVec2 {
self.start
}
/// Get the coordinates of the bezier segment's end point.
pub fn end(&self) -> DVec2 {
self.end
}
/// Get the coordinates of the bezier segment's first handle point. This represents the only handle in a quadratic segment.
pub fn handle_start(&self) -> Option<DVec2> {
self.handles.start()
}
/// Get the coordinates of the second handle point. This will return `None` for a quadratic segment.
pub fn handle_end(&self) -> Option<DVec2> {
self.handles.end()
}
/// Get an iterator over the coordinates of all points in a vector.
/// - For a linear segment, the order of the points will be: `start`, `end`.
/// - For a quadratic segment, the order of the points will be: `start`, `handle`, `end`.
/// - For a cubic segment, the order of the points will be: `start`, `handle_start`, `handle_end`, `end`.
pub fn get_points(&self) -> impl Iterator<Item = DVec2> + use<> {
match self.handles {
BezierHandles::Linear => [self.start, self.end, DVec2::ZERO, DVec2::ZERO].into_iter().take(2),
BezierHandles::Quadratic { handle } => [self.start, handle, self.end, DVec2::ZERO].into_iter().take(3),
BezierHandles::Cubic { handle_start, handle_end } => [self.start, handle_start, handle_end, self.end].into_iter().take(4),
}
}
// TODO: Consider removing this function
/// Create a linear bezier using the provided coordinates as the start and end points.
pub fn from_linear_coordinates(x1: f64, y1: f64, x2: f64, y2: f64) -> Self {
Bezier {
start: DVec2::new(x1, y1),
handles: BezierHandles::Linear,
end: DVec2::new(x2, y2),
}
}
/// Create a linear bezier using the provided DVec2s as the start and end points.
pub fn from_linear_dvec2(p1: DVec2, p2: DVec2) -> Self {
Bezier {
start: p1,
handles: BezierHandles::Linear,
end: p2,
}
}
// TODO: Consider removing this function
/// Create a quadratic bezier using the provided coordinates as the start, handle, and end points.
pub fn from_quadratic_coordinates(x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64) -> Self {
Bezier {
start: DVec2::new(x1, y1),
handles: BezierHandles::Quadratic { handle: DVec2::new(x2, y2) },
end: DVec2::new(x3, y3),
}
}
/// Create a quadratic bezier using the provided DVec2s as the start, handle, and end points.
pub fn from_quadratic_dvec2(p1: DVec2, p2: DVec2, p3: DVec2) -> Self {
Bezier {
start: p1,
handles: BezierHandles::Quadratic { handle: p2 },
end: p3,
}
}
// TODO: Consider removing this function
/// Create a cubic bezier using the provided coordinates as the start, handles, and end points.
#[allow(clippy::too_many_arguments)]
pub fn from_cubic_coordinates(x1: f64, y1: f64, x2: f64, y2: f64, x3: f64, y3: f64, x4: f64, y4: f64) -> Self {
Bezier {
start: DVec2::new(x1, y1),
handles: BezierHandles::Cubic {
handle_start: DVec2::new(x2, y2),
handle_end: DVec2::new(x3, y3),
},
end: DVec2::new(x4, y4),
}
}
/// Create a cubic bezier using the provided DVec2s as the start, handles, and end points.
pub fn from_cubic_dvec2(p1: DVec2, p2: DVec2, p3: DVec2, p4: DVec2) -> Self {
Bezier {
start: p1,
handles: BezierHandles::Cubic { handle_start: p2, handle_end: p3 },
end: p4,
}
}
/// Returns a Bezier curve that results from applying the transformation function to each point in the Bezier.
pub fn apply_transformation(&self, transformation_function: impl Fn(DVec2) -> DVec2) -> Bezier {
Self {
start: transformation_function(self.start),
end: transformation_function(self.end),
handles: self.handles.apply_transformation(transformation_function),
}
}
pub fn intersections(&self, other: &Bezier, accuracy: Option<f64>, minimum_separation: Option<f64>) -> Vec<f64> {
let this = handles_to_segment(self.start, self.handles, self.end);
let other = handles_to_segment(other.start, other.handles, other.end);
filtered_segment_intersections(this, other, accuracy, minimum_separation)
}
pub fn winding(&self, point: DVec2) -> i32 {
let this = handles_to_segment(self.start, self.handles, self.end);
this.winding(dvec2_to_point(point))
}
}

View File

@@ -1,62 +0,0 @@
use super::structs::Identifier;
use super::*;
use glam::{DAffine2, DVec2};
/// Functionality that transforms Subpaths, such as split, reduce, offset, etc.
impl<PointId: Identifier> Subpath<PointId> {
/// Returns [ManipulatorGroup]s with a reversed winding order.
fn reverse_manipulator_groups(manipulator_groups: &[ManipulatorGroup<PointId>]) -> Vec<ManipulatorGroup<PointId>> {
manipulator_groups
.iter()
.rev()
.map(|group| ManipulatorGroup {
anchor: group.anchor,
in_handle: group.out_handle,
out_handle: group.in_handle,
id: PointId::new(),
})
.collect::<Vec<ManipulatorGroup<PointId>>>()
}
/// Returns a [Subpath] with a reversed winding order.
/// Note that a reversed closed subpath will start on the same manipulator group and simply wind the other direction
pub fn reverse(&self) -> Subpath<PointId> {
let mut reversed = Subpath::reverse_manipulator_groups(self.manipulator_groups());
if self.closed {
reversed.rotate_right(1);
};
Subpath {
manipulator_groups: reversed,
closed: self.closed,
}
}
/// Apply a transformation to all of the [ManipulatorGroup]s in the [Subpath].
pub fn apply_transform(&mut self, affine_transform: DAffine2) {
for manipulator_group in &mut self.manipulator_groups {
manipulator_group.apply_transform(affine_transform);
}
}
/// Returns a subpath that results from rotating this subpath around the origin by the given angle (in radians).
pub fn rotate(&self, angle: f64) -> Subpath<PointId> {
let mut rotated_subpath = self.clone();
let affine_transform: DAffine2 = DAffine2::from_angle(angle);
rotated_subpath.apply_transform(affine_transform);
rotated_subpath
}
/// Returns a subpath that results from rotating this subpath around the provided point by the given angle (in radians).
pub fn rotate_about_point(&self, angle: f64, pivot: DVec2) -> Subpath<PointId> {
// Translate before and after the rotation to account for the pivot
let translate: DAffine2 = DAffine2::from_translation(pivot);
let rotate: DAffine2 = DAffine2::from_angle(angle);
let translate_inverse = translate.inverse();
let mut rotated_subpath = self.clone();
rotated_subpath.apply_transform(translate * rotate * translate_inverse);
rotated_subpath
}
}

View File

@@ -1,16 +1,16 @@
use super::intersection::bezpath_intersections;
use super::consts::MAX_ABSOLUTE_DIFFERENCE;
use super::intersection::{bezpath_intersections, filtered_all_segment_intersections, pathseg_self_intersections};
use super::poisson_disk::poisson_disk_sample;
use super::util::pathseg_tangent;
use crate::vector::algorithms::offset_subpath::MAX_ABSOLUTE_DIFFERENCE;
use crate::vector::misc::{PointSpacingType, dvec2_to_point, point_to_dvec2};
use core_types::math::polynomial::pathseg_to_parametric_polynomial;
use glam::{DMat2, DVec2};
use kurbo::{BezPath, CubicBez, DEFAULT_ACCURACY, Line, ParamCurve, ParamCurveArclen, ParamCurveDeriv, PathEl, PathSeg, Point, QuadBez, Rect, Shape, Vec2};
use kurbo::{BezPath, CubicBez, DEFAULT_ACCURACY, Line, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Point, QuadBez, Rect, Shape, Vec2};
use std::f64::consts::{FRAC_PI_2, PI};
/// Splits the [`BezPath`] at segment index at `t` value which lie in the range of [0, 1].
/// Returns [`None`] if the given [`BezPath`] has no segments or `t` is within f64::EPSILON of 0 or 1.
pub fn split_bezpath_at_segment(bezpath: &BezPath, segment_index: usize, t: f64) -> Option<(BezPath, BezPath)> {
fn split_bezpath_at_segment(bezpath: &BezPath, segment_index: usize, t: f64) -> Option<(BezPath, BezPath)> {
if t <= f64::EPSILON || (1. - t) <= f64::EPSILON || bezpath.segments().count() == 0 {
return None;
}
@@ -77,11 +77,7 @@ pub fn tangent_on_bezpath(bezpath: &BezPath, t_value: TValue, segments_length: O
let (segment_index, t) = eval_bezpath(bezpath, t_value, segments_length);
let segment = bezpath.get_seg(segment_index + 1).unwrap();
match segment {
PathSeg::Line(line) => line.deriv().eval(t),
PathSeg::Quad(quad_bez) => quad_bez.deriv().eval(t),
PathSeg::Cubic(cubic_bez) => cubic_bez.deriv().eval(t),
}
dvec2_to_point(pathseg_tangent(segment, t))
}
/// Computes sample locations along a bezpath, returning parametric `(segment_index, t)` pairs and whether the path was closed.
@@ -182,7 +178,7 @@ pub enum TValue {
}
/// Default LUT step size in `compute_lookup_table` function.
pub const DEFAULT_LUT_STEP_SIZE: usize = 10;
const DEFAULT_LUT_STEP_SIZE: usize = 10;
/// Return a selection of equidistant points on the bezier curve.
/// If no value is provided for `steps`, then the function will default `steps` to be 10.
@@ -252,7 +248,7 @@ pub(crate) fn pathseg_length_centroid_and_length(segment: PathSeg, accuracy: Opt
let QuadBez { p0, p1, p2 } = quad_bez;
// Use Casteljau subdivision, noting that the length is more than the straight line distance from start to end but less than the straight line distance through the handles
fn recurse(a0: Vec2, a1: Vec2, a2: Vec2, accuracy: f64, level: u8) -> (f64, Vec2) {
let lower = (a2 - a1).length();
let lower = (a2 - a0).length();
let upper = (a1 - a0).length() + (a2 - a1).length();
if upper - lower <= 2. * accuracy || level >= 8 {
let length = (lower + upper) / 2.;
@@ -415,21 +411,8 @@ pub fn poisson_disk_points(bezpath_index: usize, bezpaths: &[(BezPath, Rect)], s
poisson_disk_sample(offset, width, height, separation_disk_diameter, point_in_shape_checker, line_intersect_shape_checker, rng)
}
/// Returns true if the Bezier curve is equivalent to a line.
///
/// **NOTE**: This is different from simply checking if the segment is [`PathSeg::Line`] or [`PathSeg::Quad`] or [`PathSeg::Cubic`]. Bezier curve can also be a line if the control points are colinear to the start and end points. Therefore if the handles exceed the start and end point, it will still be considered as a line.
pub fn is_linear(segment: &PathSeg) -> bool {
let is_colinear = |a: Point, b: Point, c: Point| -> bool { ((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)).abs() < MAX_ABSOLUTE_DIFFERENCE };
match *segment {
PathSeg::Line(_) => true,
PathSeg::Quad(QuadBez { p0, p1, p2 }) => is_colinear(p0, p1, p2),
PathSeg::Cubic(CubicBez { p0, p1, p2, p3 }) => is_colinear(p0, p1, p3) && is_colinear(p0, p2, p3),
}
}
// TODO: If a segment curls back on itself tightly enough it could intersect again at the portion that should be trimmed. This could cause the Subpaths to be clipped
// TODO: at the incorrect location. This can be avoided by first trimming the two Subpaths at any extrema, effectively ignoring loopbacks.
// TODO: If a segment curls back on itself tightly enough it could intersect again at the portion that should be trimmed. This could cause the subpaths to be clipped
// TODO: at the incorrect location. This can be avoided by first trimming the two subpaths at any extrema, effectively ignoring loopbacks.
/// Helper function to clip overlap of two intersecting open BezPaths. Returns an Option because intersections may not exist for certain arrangements and distances.
/// Assumes that the BezPaths represents simple Bezier segments, and clips the BezPaths at the last intersection of the first BezPath, and first intersection of the last BezPath.
pub fn clip_simple_bezpaths(bezpath1: &BezPath, bezpath2: &BezPath) -> Option<(BezPath, BezPath)> {
@@ -499,7 +482,7 @@ pub fn miter_line_join(bezpath1: &BezPath, bezpath2: &BezPath, miter_limit: Opti
/// Computes the [`PathEl`] to form a circular join from `left` to `right`, along a circle around `center`.
/// By default, the angle is assumed to be 180 degrees.
pub fn compute_circular_subpath_details(left: DVec2, arc_point: DVec2, right: DVec2, center: DVec2, angle: Option<f64>) -> [PathEl; 2] {
fn compute_circular_subpath_details(left: DVec2, arc_point: DVec2, right: DVec2, center: DVec2, angle: Option<f64>) -> [PathEl; 2] {
let center_to_arc_point = arc_point - center;
// Based on https://pomax.github.io/bezierinfo/#circles_cubic
@@ -579,6 +562,124 @@ pub fn bezpath_is_inside_bezpath(bezpath1: &BezPath, bezpath2: &BezPath, accurac
true
}
/// The segments of the [`BezPath`] always considering it as a closed path, synthesizing the closing line when it is open.
fn closed_segments(bezpath: &BezPath) -> Vec<PathSeg> {
let mut segments = bezpath.segments().collect::<Vec<_>>();
if let (Some(first), Some(last)) = (segments.first(), segments.last())
&& last.end() != first.start()
{
segments.push(PathSeg::Line(Line::new(last.end(), first.start())));
}
segments
}
/// Returns a list of `t` values that correspond to all the self intersection points of the path always considering it as a closed path.
/// The index and `t` value of both will be returned that corresponds to a point, sorted based on their index and `t` respectively.
fn closed_bezpath_self_intersections(segments: &[PathSeg], accuracy: Option<f64>, minimum_separation: Option<f64>) -> Vec<(usize, f64)> {
let mut intersections_vec = Vec::new();
let err = accuracy.unwrap_or(MAX_ABSOLUTE_DIFFERENCE);
let num_curves = segments.len();
// O(n²) in the number of segments, since every segment pair is compared
segments.iter().enumerate().for_each(|(i, &other)| {
intersections_vec.extend(pathseg_self_intersections(other, accuracy, minimum_separation).iter().flat_map(|value| [(i, value.0), (i, value.1)]));
segments.iter().enumerate().skip(i + 1).for_each(|(j, &curve)| {
intersections_vec.extend(
filtered_all_segment_intersections(curve, other, accuracy, minimum_separation)
.iter()
.filter(|&value| (j != i + 1 || value.0 > err || (1. - value.1) > err) && (j != num_curves - 1 || i != 0 || value.1 > err || (1. - value.0) > err))
.flat_map(|value| [(j, value.0), (i, value.1)]),
);
});
});
intersections_vec.sort_by(|a, b| a.partial_cmp(b).unwrap());
intersections_vec
}
/// Return the area centroid, together with the area, of the [`BezPath`] always considering it as a closed path. The area will always be a positive value.
///
/// The area centroid is the center of mass for the area of a solid shape's interior.
/// An infinitely flat material forming the path's closed shape would balance at this point.
///
/// It will return `None` if no segment is present. If the area is less than `error`, it will return `Some((DVec2::NAN, 0.))`.
///
/// Because the calculation of area and centroid for a self-intersecting path requires finding the intersections, the following parameters are used:
/// - `error` - For intersections with non-linear beziers, `error` defines the threshold for bounding boxes to be considered an intersection point.
/// - `minimum_separation` - the minimum difference two adjacent `t`-values must have when comparing adjacent `t`-values in sorted order.
///
/// If the comparison condition is not satisfied, the function takes the larger `t`-value of the two.
///
/// **NOTE**: if an intersection were to occur within an `error` distance away from an anchor point, the algorithm will filter that intersection out.
pub fn bezpath_area_centroid_and_area(bezpath: &BezPath, error: Option<f64>, minimum_separation: Option<f64>) -> Option<(DVec2, f64)> {
let segments = closed_segments(bezpath);
let all_intersections = closed_bezpath_self_intersections(&segments, error, minimum_separation);
let mut current_sign: f64 = 1.;
let (x_sum, y_sum, area) = segments
.iter()
.enumerate()
.map(|(index, &bezier)| {
let (f_x, f_y) = pathseg_to_parametric_polynomial(bezier);
let (f_x, f_y) = (f_x.as_size::<10>().unwrap(), f_y.as_size::<10>().unwrap());
let f_y_prime = f_y.derivative();
let f_x_prime = f_x.derivative();
let f_xy = &f_x * &f_y;
let mut x_part = &f_xy * &f_x_prime;
let mut y_part = &f_xy * &f_y_prime;
let mut area_part = &f_x * &f_y_prime;
x_part.antiderivative_mut();
y_part.antiderivative_mut();
area_part.antiderivative_mut();
let mut curve_sum_x = -current_sign * x_part.eval(0.);
let mut curve_sum_y = -current_sign * y_part.eval(0.);
let mut curve_sum_area = -current_sign * area_part.eval(0.);
for (_, t) in all_intersections.iter().filter(|(i, _)| *i == index) {
curve_sum_x += 2. * current_sign * x_part.eval(*t);
curve_sum_y += 2. * current_sign * y_part.eval(*t);
curve_sum_area += 2. * current_sign * area_part.eval(*t);
current_sign *= -1.;
}
curve_sum_x += current_sign * x_part.eval(1.);
curve_sum_y += current_sign * y_part.eval(1.);
curve_sum_area += current_sign * area_part.eval(1.);
(-curve_sum_x, curve_sum_y, curve_sum_area)
})
.reduce(|(x1, y1, area1), (x2, y2, area2)| (x1 + x2, y1 + y2, area1 + area2))?;
if area.abs() < error.unwrap_or(MAX_ABSOLUTE_DIFFERENCE) {
return Some((DVec2::NAN, 0.));
}
Some((DVec2::new(x_sum / area, y_sum / area), area.abs()))
}
/// Return the approximation of the length centroid, together with the length, of the [`BezPath`].
///
/// The length centroid is the center of mass for the arc length of the solid shape's perimeter.
/// An infinitely thin wire forming the path's shape would balance at this point.
///
/// It will return `None` if no segment is present.
/// - `accuracy` is used to approximate the curve.
/// - `always_closed` is to consider the path as closed always.
pub fn bezpath_length_centroid_and_length(bezpath: &BezPath, accuracy: Option<f64>, always_closed: bool) -> Option<(DVec2, f64)> {
let segments = if always_closed { closed_segments(bezpath) } else { bezpath.segments().collect() };
segments
.into_iter()
.map(|bezier| pathseg_length_centroid_and_length(bezier, accuracy))
.map(|(centroid, length)| (centroid * length, length))
.reduce(|(centroid_part1, length1), (centroid_part2, length2)| (centroid_part1 + centroid_part2, length1 + length2))
.map(|(centroid_part, length)| (centroid_part / length, length))
.map(|(centroid_part, length)| (DVec2::new(centroid_part.x, centroid_part.y), length))
}
#[cfg(test)]
mod tests {
// TODO: add more intersection tests
@@ -608,4 +709,13 @@ mod tests {
let line_inside = Line::new(Point::new(101., 101.5), Point::new(150.2, 499.)).to_path(DEFAULT_ACCURACY);
assert!(bezpath_is_inside_bezpath(&line_inside, &boundary_polygon, None, None));
}
#[test]
fn centroid_rect() {
let rect = crate::vector::algorithms::shapes::rectangle_bezpath(glam::DVec2::new(100., 100.), glam::DVec2::new(300., 200.));
let (center, area) = super::bezpath_area_centroid_and_area(&rect, Some(1e-3), Some(1e-3)).unwrap();
assert_eq!(area, 200. * 100.);
assert_eq!(center, glam::DVec2::new(200., 150.));
}
}

View File

@@ -0,0 +1,8 @@
/// Minimum allowable separation between adjacent `t` values when calculating curve intersections
pub(crate) const MIN_SEPARATION_VALUE: f64 = 5. * 1e-3;
/// Threshold for comparing floating point values in intersection and centroid math.
pub(crate) const MAX_ABSOLUTE_DIFFERENCE: f64 = 1e-3;
/// Maximum distance at which two points are treated as one and the same point.
pub(crate) const MAX_COINCIDENT_POINT_DISTANCE: f64 = 1e-7;

View File

@@ -1,6 +0,0 @@
/// Minimum allowable separation between adjacent `t` values when calculating curve intersections
pub const MIN_SEPARATION_VALUE: f64 = 5. * 1e-3;
/// Constant used to determine if `f64`s are equivalent.
#[cfg(test)]
pub const MAX_ABSOLUTE_DIFFERENCE: f64 = 1e-3;

View File

@@ -1,4 +1,4 @@
use super::contants::MIN_SEPARATION_VALUE;
use super::consts::MIN_SEPARATION_VALUE;
use kurbo::{BezPath, DEFAULT_ACCURACY, ParamCurve, PathSeg, Shape};
use lyon_geom::{CubicBezierSegment, Point};
@@ -25,7 +25,7 @@ fn cubic_cubic_intersections_lyon(cubic1: kurbo::CubicBez, cubic2: kurbo::CubicB
/// that segment where the intersection occurred.
///
/// `minimum_separation` is the minimum difference that two adjacent `t`-values must have when comparing adjacent `t`-values in sorted order.
pub fn bezpath_and_segment_intersections(bezpath: &BezPath, segment: PathSeg, accuracy: Option<f64>, minimum_separation: Option<f64>) -> Vec<(usize, f64)> {
fn bezpath_and_segment_intersections(bezpath: &BezPath, segment: PathSeg, accuracy: Option<f64>, minimum_separation: Option<f64>) -> Vec<(usize, f64)> {
bezpath
.segments()
.enumerate()
@@ -39,7 +39,7 @@ pub fn bezpath_and_segment_intersections(bezpath: &BezPath, segment: PathSeg, ac
}
/// Calculates the intersection points the bezpath has with another given bezpath and returns a list of parametric `t`-values.
pub fn bezpath_intersections(bezpath1: &BezPath, bezpath2: &BezPath, accuracy: Option<f64>, minimum_separation: Option<f64>) -> Vec<(usize, f64)> {
pub(crate) fn bezpath_intersections(bezpath1: &BezPath, bezpath2: &BezPath, accuracy: Option<f64>, minimum_separation: Option<f64>) -> Vec<(usize, f64)> {
let mut intersection_t_values: Vec<(usize, f64)> = bezpath2
.segments()
.flat_map(|bezier| bezpath_and_segment_intersections(bezpath1, bezier, accuracy, minimum_separation))
@@ -50,7 +50,7 @@ pub fn bezpath_intersections(bezpath1: &BezPath, bezpath2: &BezPath, accuracy: O
}
/// Calculates the intersection points the segment has with another given segment and returns a list of parametric `t`-values with given accuracy.
pub fn segment_intersections(segment1: PathSeg, segment2: PathSeg, accuracy: Option<f64>) -> Vec<(f64, f64)> {
fn segment_intersections(segment1: PathSeg, segment2: PathSeg, accuracy: Option<f64>) -> Vec<(f64, f64)> {
let accuracy = accuracy.unwrap_or(DEFAULT_ACCURACY);
match (segment1, segment2) {
@@ -66,7 +66,7 @@ pub fn segment_intersections(segment1: PathSeg, segment2: PathSeg, accuracy: Opt
}
}
pub fn subsegment_intersections(segment1: PathSeg, min_t1: f64, max_t1: f64, segment2: PathSeg, min_t2: f64, max_t2: f64, accuracy: Option<f64>) -> Vec<(f64, f64)> {
fn subsegment_intersections(segment1: PathSeg, min_t1: f64, max_t1: f64, segment2: PathSeg, min_t2: f64, max_t2: f64, accuracy: Option<f64>) -> Vec<(f64, f64)> {
let accuracy = accuracy.unwrap_or(DEFAULT_ACCURACY);
match (segment1, segment2) {
@@ -180,7 +180,7 @@ pub fn filtered_segment_intersections(segment1: PathSeg, segment2: PathSeg, accu
/// `error`, for intersections where the provided bezier is non-linear, defines the threshold for bounding boxes to be considered an intersection point.
///
/// `minimum_separation` is the minimum difference between adjacent `t` values in sorted order
pub fn filtered_all_segment_intersections(segment1: PathSeg, segment2: PathSeg, accuracy: Option<f64>, minimum_separation: Option<f64>) -> Vec<(f64, f64)> {
pub(crate) fn filtered_all_segment_intersections(segment1: PathSeg, segment2: PathSeg, accuracy: Option<f64>, minimum_separation: Option<f64>) -> Vec<(f64, f64)> {
let mut intersection_t_values = segment_intersections(segment1, segment2, accuracy);
intersection_t_values.sort_by(|a, b| (a.0 + a.1).partial_cmp(&(b.0 + b.1)).unwrap());
@@ -240,7 +240,7 @@ fn pathseg_self_intersection(segment: PathSeg, accuracy: Option<f64>) -> Vec<(f6
/// If the difference between 2 adjacent `t` values is less than the minimum difference, the filtering takes the larger `t` value and discards the smaller `t` value.
/// - `error` - For intersections with non-linear beziers, `error` defines the threshold for bounding boxes to be considered an intersection point.
/// - `minimum_separation` - The minimum difference between adjacent `t` values in sorted order
pub fn pathseg_self_intersections(segment: PathSeg, accuracy: Option<f64>, minimum_separation: Option<f64>) -> Vec<(f64, f64)> {
pub(crate) fn pathseg_self_intersections(segment: PathSeg, accuracy: Option<f64>, minimum_separation: Option<f64>) -> Vec<(f64, f64)> {
let mut intersection_t_values = pathseg_self_intersection(segment, accuracy);
intersection_t_values.sort_by(|a, b| (a.0 + a.1).partial_cmp(&(b.0 + b.1)).unwrap());
@@ -260,7 +260,7 @@ pub fn pathseg_self_intersections(segment: PathSeg, accuracy: Option<f64>, minim
mod tests {
use super::{bezpath_and_segment_intersections, filtered_segment_intersections};
use crate::vector::algorithms::{
contants::MAX_ABSOLUTE_DIFFERENCE,
consts::MAX_ABSOLUTE_DIFFERENCE,
util::{compare_points, compare_vec_of_points, dvec2_compare},
};

View File

@@ -47,7 +47,7 @@ impl MergeByDistanceExt for Vector {
// Collect points and segments to delete at the end to avoid invalidating indices
let mut points_to_delete = FxHashSet::default();
let mut segments_to_delete = FxHashSet::default();
for (mut collapse_set, average_pos) in collapse.into_iter().zip(average_position.into_iter()) {
for (mut collapse_set, average_pos) in collapse.into_iter().zip(average_position) {
// Remove any segments where both endpoints are in the collapse set
segments_to_delete.extend(self.segment_domain.iter().filter_map(|(id, start_offset, end_offset, _)| {
let start = self.point_domain.ids()[start_offset];
@@ -94,10 +94,6 @@ impl MergeByDistanceExt for Vector {
points_to_delete.extend(collapse_set)
}
// Remove faces whose start or end segments are removed
// TODO: Adjust faces and only delete if all (or all but one) segments are removed
self.region_domain
.retain_with_region(|_, segment_range| segments_to_delete.contains(segment_range.start()) || segments_to_delete.contains(segment_range.end()));
self.segment_domain.retain(|id| !segments_to_delete.contains(id), usize::MAX);
self.point_domain.retain(&mut self.segment_domain, |id| !points_to_delete.contains(id));
}
@@ -197,7 +193,6 @@ impl MergeByDistanceExt for Vector {
let start = self.segment_domain.start_point()[segment_idx];
let end = self.segment_domain.end_point()[segment_idx];
let handles = self.segment_domain.handles()[segment_idx];
let stroke = self.segment_domain.stroke()[segment_idx];
// Get new indices for start and end points
let new_start = point_index_map[start].unwrap();
@@ -205,7 +200,7 @@ impl MergeByDistanceExt for Vector {
// Skip segments where start and end points were merged
if new_start != new_end {
new_segment_domain.push(id, new_start, new_end, handles, stroke);
new_segment_domain.push(id, new_start, new_end, handles);
}
}
@@ -224,7 +219,7 @@ pub(crate) struct Point {
/// Useful indexes to speed up various operations on [`Vector`].
///
/// Important: It is the user's responsibility to ensure the indexes remain valid after mutations to the data.
pub struct VectorIndex {
pub(crate) struct VectorIndex {
/// Points and segments form a graph. Store it here in a form amenable to graph algorithms.
///
/// Currently, segment data is not stored as it is not used, but it could easily be added.
@@ -237,7 +232,7 @@ pub struct VectorIndex {
impl VectorIndex {
/// Construct a [`VectorIndex`] by building indexes from the given [`Vector`]. Takes `O(n)` time.
pub fn build_from(data: &Vector) -> Self {
fn build_from(data: &Vector) -> Self {
let point_to_offset = data.point_domain.ids().iter().copied().enumerate().map(|(a, b)| (b, a)).collect::<FxHashMap<_, _>>();
let mut point_to_node = FxHashMap::default();
@@ -270,7 +265,7 @@ impl VectorIndex {
/// # Panics
///
/// Will panic if no segment with the given ID is found.
pub fn segment_chord_length(&self, id: SegmentId) -> f64 {
fn segment_chord_length(&self, id: SegmentId) -> f64 {
let edge_idx = self.segment_to_edge[&id];
let (start, end) = self.point_graph.edge_endpoints(edge_idx).unwrap();
let start_position = self.point_graph.node_weight(start).unwrap().position;
@@ -285,7 +280,7 @@ impl VectorIndex {
/// # Panics
///
/// This function will panic if the ID is not present.
pub fn segment_ends(&self, id: SegmentId) -> [NodeIndex; 2] {
fn segment_ends(&self, id: SegmentId) -> [NodeIndex; 2] {
let (start, end) = self.point_graph.edge_endpoints(self.segment_to_edge[&id]).unwrap();
if start < end { [start, end] } else { [end, start] }
}
@@ -295,7 +290,7 @@ impl VectorIndex {
/// # Panics
///
/// Will panic if `id` isn't in the data.
pub fn point_position(&self, id: PointId, data: &Vector) -> DVec2 {
fn point_position(&self, id: PointId, data: &Vector) -> DVec2 {
let offset = self.point_to_offset[&id];
data.point_domain.positions()[offset]
}

View File

@@ -1,8 +1,9 @@
pub mod bezpath_algorithms;
mod contants;
pub(crate) mod consts;
pub mod intersection;
pub mod merge_by_distance;
pub mod offset_subpath;
pub mod poisson_disk;
pub mod offset_bezpath;
mod poisson_disk;
pub mod shapes;
pub mod spline;
pub mod util;

View File

@@ -1,13 +1,12 @@
use super::bezpath_algorithms::{clip_simple_bezpaths, miter_line_join, round_line_join};
use super::consts::MAX_COINCIDENT_POINT_DISTANCE;
use crate::vector::misc::point_to_dvec2;
use kurbo::{BezPath, Join, ParamCurve, PathEl, PathSeg};
/// Value to control smoothness and mathematical accuracy to offset a cubic Bezier.
const CUBIC_REGULARIZATION_ACCURACY: f64 = 0.5;
/// Constant used to determine if `f64`s are equivalent.
pub const MAX_ABSOLUTE_DIFFERENCE: f64 = 1e-7;
/// Squared version to avoid sqrt in distance checks.
const MAX_ABSOLUTE_DIFFERENCE_SQUARED: f64 = MAX_ABSOLUTE_DIFFERENCE * MAX_ABSOLUTE_DIFFERENCE;
const MAX_COINCIDENT_POINT_DISTANCE_SQUARED: f64 = MAX_COINCIDENT_POINT_DISTANCE * MAX_COINCIDENT_POINT_DISTANCE;
const MAX_FITTED_SEGMENTS: usize = 10000;
/// Reduces the segments of the bezpath into simple subcurves, then offset each subcurve a set `distance` away.
@@ -26,9 +25,9 @@ pub fn offset_bezpath(bezpath: &BezPath, distance: f64, join: Join, miter_limit:
// Skip degenerate curves where all control points are at the same location.
// Offsetting a point is undefined and causes infinite recursion in fit_to_bezpath.
let start = cubic_bez.p0;
let is_degenerate = start.distance_squared(cubic_bez.p1) < MAX_ABSOLUTE_DIFFERENCE_SQUARED
&& start.distance_squared(cubic_bez.p2) < MAX_ABSOLUTE_DIFFERENCE_SQUARED
&& start.distance_squared(cubic_bez.p3) < MAX_ABSOLUTE_DIFFERENCE_SQUARED;
let is_degenerate = start.distance_squared(cubic_bez.p1) < MAX_COINCIDENT_POINT_DISTANCE_SQUARED
&& start.distance_squared(cubic_bez.p2) < MAX_COINCIDENT_POINT_DISTANCE_SQUARED
&& start.distance_squared(cubic_bez.p3) < MAX_COINCIDENT_POINT_DISTANCE_SQUARED;
if is_degenerate {
return None;
@@ -49,7 +48,7 @@ pub fn offset_bezpath(bezpath: &BezPath, distance: f64, join: Join, miter_limit:
return BezPath::new();
}
// Clip or join consecutive Subpaths
// Clip or join consecutive subpaths
for i in 0..bezpaths.len() - 1 {
let j = i + 1;
let bezpath1 = &bezpaths[i];
@@ -59,11 +58,11 @@ pub fn offset_bezpath(bezpath: &BezPath, distance: f64, join: Join, miter_limit:
let first_segment_start = point_to_dvec2(bezpath2.segments().next().unwrap().start());
// If the anchors are approximately equal, there is no need to clip / join the segments
if last_segment_end.abs_diff_eq(first_segment_start, MAX_ABSOLUTE_DIFFERENCE) {
if last_segment_end.abs_diff_eq(first_segment_start, MAX_COINCIDENT_POINT_DISTANCE) {
continue;
}
// The angle is concave. The Subpath overlap and must be clipped
// The angle is concave. The subpaths overlap and must be clipped
let mut apply_join = true;
if let Some((clipped_subpath1, clipped_subpath2)) = clip_simple_bezpaths(bezpath1, bezpath2) {
@@ -71,7 +70,7 @@ pub fn offset_bezpath(bezpath: &BezPath, distance: f64, join: Join, miter_limit:
bezpaths[j] = clipped_subpath2;
apply_join = false;
}
// The angle is convex. The Subpath must be joined using the specified join type
// The angle is convex. The subpaths must be joined using the specified join type
if apply_join {
match join {
Join::Bevel => {

View File

@@ -8,7 +8,7 @@ const DEEPEST_SUBDIVISION_LEVEL_BEFORE_DISCARDING: usize = 8;
/// Based on the paper:
/// "Poisson Disk Point Sets by Hierarchical Dart Throwing"
/// <https://scholarsarchive.byu.edu/facpub/237/>
pub fn poisson_disk_sample(
pub(crate) fn poisson_disk_sample(
offset: DVec2,
width: f64,
height: f64,
@@ -187,23 +187,23 @@ where
/// A square (represented by its top left corner position and width/height of `square_size`) that is currently a candidate for targetting by the dart throwing process.
/// The positive sign bit encodes if the square is contained entirely within the masking shape, or negative if it's outside or intersects the shape path.
pub struct ActiveSquare(DVec2);
struct ActiveSquare(DVec2);
impl ActiveSquare {
pub fn new(top_left_corner: DVec2, fully_in_shape: bool) -> Self {
fn new(top_left_corner: DVec2, fully_in_shape: bool) -> Self {
Self(if fully_in_shape { top_left_corner } else { -top_left_corner })
}
pub fn top_left_corner(&self) -> DVec2 {
fn top_left_corner(&self) -> DVec2 {
self.0.abs()
}
pub fn fully_in_shape(&self) -> bool {
fn fully_in_shape(&self) -> bool {
self.0.x.is_sign_positive()
}
}
pub struct ActiveListLevel {
struct ActiveListLevel {
/// List of all subdivided squares of the same size that are currently candidates for targetting by the dart throwing process
active_squares: Vec<ActiveSquare>,
/// Width and height of the squares in this level of subdivision
@@ -214,7 +214,7 @@ pub struct ActiveListLevel {
impl ActiveListLevel {
#[inline(always)]
pub fn new(square_size: f64) -> Self {
fn new(square_size: f64) -> Self {
Self {
active_squares: Vec::new(),
square_size,
@@ -222,7 +222,7 @@ impl ActiveListLevel {
}
}
pub fn new_filled(
fn new_filled(
square_size: f64,
offset: DVec2,
width: f64,
@@ -295,14 +295,14 @@ impl ActiveListLevel {
#[must_use]
#[inline(always)]
pub fn take_square(&mut self, active_square_index: usize) -> ActiveSquare {
fn take_square(&mut self, active_square_index: usize) -> ActiveSquare {
let targetted_square = self.active_squares.swap_remove(active_square_index);
self.total_area = self.square_size.powi(2) * self.active_squares.len() as f64;
targetted_square
}
#[inline(always)]
pub fn add_squares(&mut self, new_squares: impl Iterator<Item = ActiveSquare>) {
fn add_squares(&mut self, new_squares: impl Iterator<Item = ActiveSquare>) {
for new_square in new_squares {
self.active_squares.push(new_square);
}
@@ -310,28 +310,28 @@ impl ActiveListLevel {
}
#[inline(always)]
pub fn square_size(&self) -> f64 {
fn square_size(&self) -> f64 {
self.square_size
}
#[inline(always)]
pub fn square_area(&self) -> f64 {
fn square_area(&self) -> f64 {
self.square_size.powi(2)
}
#[inline(always)]
pub fn total_area(&self) -> f64 {
fn total_area(&self) -> f64 {
self.total_area
}
#[inline(always)]
pub fn not_empty(&self) -> bool {
fn not_empty(&self) -> bool {
!self.active_squares.is_empty()
}
}
#[derive(Clone, Default)]
pub struct PointsList {
struct PointsList {
// The worst-case number of points in a 3x3 grid is 16 (one at each intersection of the four gridlines per axis)
storage_slots: [DVec2; 16],
length: usize,
@@ -339,19 +339,19 @@ pub struct PointsList {
impl PointsList {
#[inline(always)]
pub fn push(&mut self, point: DVec2) {
fn push(&mut self, point: DVec2) {
self.storage_slots[self.length] = point;
self.length += 1;
}
#[inline(always)]
pub fn list_cell_and_neighbors(&self) -> impl Iterator<Item = DVec2> {
fn list_cell_and_neighbors(&self) -> impl Iterator<Item = DVec2> {
// The negative bit is used to store whether a point belongs to a neighboring cell
self.storage_slots.into_iter().take(self.length).map(|point| (point.x.abs(), point.y.abs()).into())
}
#[inline(always)]
pub fn list_cell(&self) -> impl Iterator<Item = DVec2> {
fn list_cell(&self) -> impl Iterator<Item = DVec2> {
// The negative bit is used to store whether a point belongs to a neighboring cell
self.storage_slots
.into_iter()
@@ -360,7 +360,7 @@ impl PointsList {
}
}
pub struct AccelerationGrid {
struct AccelerationGrid {
size: f64,
dimension_x: usize,
dimension_y: usize,
@@ -369,7 +369,7 @@ pub struct AccelerationGrid {
impl AccelerationGrid {
#[inline(always)]
pub fn new(width: f64, height: f64, size: f64) -> Self {
fn new(width: f64, height: f64, size: f64) -> Self {
let dimension_x = (width / size).ceil() as usize + 1;
let dimension_y = (height / size).ceil() as usize + 1;
@@ -382,7 +382,7 @@ impl AccelerationGrid {
}
#[inline(always)]
pub fn insert(&mut self, point: DVec2) {
fn insert(&mut self, point: DVec2) {
let x = (point.x / self.size).floor() as usize;
let y = (point.y / self.size).floor() as usize;
@@ -408,7 +408,7 @@ impl AccelerationGrid {
}
#[inline(always)]
pub fn nearby_points(&self, point: DVec2) -> impl Iterator<Item = DVec2> {
fn nearby_points(&self, point: DVec2) -> impl Iterator<Item = DVec2> {
let x = (point.x / self.size).floor() as usize;
let y = (point.y / self.size).floor() as usize;
@@ -416,7 +416,7 @@ impl AccelerationGrid {
}
#[inline(always)]
pub fn final_points(&self, offset: DVec2) -> Vec<DVec2> {
fn final_points(&self, offset: DVec2) -> Vec<DVec2> {
self.cells.iter().flat_map(|cell| cell.list_cell()).map(|point| point + offset).collect()
}
}

View File

@@ -0,0 +1,345 @@
//! Constructors for the primitive shapes used by the vector generator nodes.
//!
//! Anchor order and winding direction are load-bearing, since fills rely on every generator agreeing.
use crate::vector::misc::{ArcType, SpiralType, bezpath_from_anchors_and_handles};
use glam::DVec2;
use kurbo::BezPath;
use std::f64::consts::TAU;
/// Constant from <https://pomax.github.io/bezierinfo/#circles_cubic>
const HANDLE_OFFSET_FACTOR: f64 = 0.551784777779014;
/// An anchor point with its optional incoming and outgoing handle positions, in absolute coordinates.
#[derive(Clone)]
struct Anchor {
position: DVec2,
in_handle: Option<DVec2>,
out_handle: Option<DVec2>,
}
impl Anchor {
fn new(position: DVec2, in_handle: Option<DVec2>, out_handle: Option<DVec2>) -> Self {
Self { position, in_handle, out_handle }
}
fn sharp(position: DVec2) -> Self {
Self::new(position, None, None)
}
}
fn bezpath_from_anchors(anchors: &[Anchor], closed: bool) -> BezPath {
bezpath_from_anchors_and_handles(anchors.iter().map(|anchor| (anchor.position, anchor.in_handle, anchor.out_handle)), closed)
}
/// Stitches a sequence of sharp (handleless) anchors into a polyline, or a closed polygon.
pub fn polyline_bezpath(positions: impl IntoIterator<Item = DVec2>, closed: bool) -> BezPath {
let anchors: Vec<Anchor> = positions.into_iter().map(Anchor::sharp).collect();
bezpath_from_anchors(&anchors, closed)
}
/// Constructs a rectangle with `corner1` and `corner2` as the two corners.
pub fn rectangle_bezpath(corner1: DVec2, corner2: DVec2) -> BezPath {
polyline_bezpath([corner1, DVec2::new(corner2.x, corner1.y), corner2, DVec2::new(corner1.x, corner2.y)], true)
}
/// Constructs a rounded rectangle with `corner1` and `corner2` as the two corners and `corner_radii` as the radii of the corners: `[top_left, top_right, bottom_right, bottom_left]`.
pub fn rounded_rectangle_bezpath(corner1: DVec2, corner2: DVec2, corner_radii: [f64; 4]) -> BezPath {
if corner_radii.iter().all(|radius| radius.abs() < f64::EPSILON * 100.) {
return rectangle_bezpath(corner1, corner2);
}
use std::f64::consts::{FRAC_1_SQRT_2, PI};
// The pair of anchors where one rounded corner's arc leaves and rejoins the straight edges
let corner_anchors = |center: DVec2, corner: DVec2, radius: f64| -> Vec<Anchor> {
let point1 = center + DVec2::from_angle(-PI * 0.25).rotate(corner - center) * FRAC_1_SQRT_2;
let point2 = center + DVec2::from_angle(PI * 0.25).rotate(corner - center) * FRAC_1_SQRT_2;
if radius == 0. {
return vec![Anchor::sharp(point1), Anchor::sharp(point2)];
}
let handle_offset = radius * HANDLE_OFFSET_FACTOR;
vec![
Anchor::new(point1, None, Some(point1 + handle_offset * (corner - point1).normalize())),
Anchor::new(point2, Some(point2 + handle_offset * (corner - point2).normalize()), None),
]
};
let anchors = [
corner_anchors(DVec2::new(corner1.x + corner_radii[0], corner1.y + corner_radii[0]), DVec2::new(corner1.x, corner1.y), corner_radii[0]),
corner_anchors(DVec2::new(corner2.x - corner_radii[1], corner1.y + corner_radii[1]), DVec2::new(corner2.x, corner1.y), corner_radii[1]),
corner_anchors(DVec2::new(corner2.x - corner_radii[2], corner2.y - corner_radii[2]), DVec2::new(corner2.x, corner2.y), corner_radii[2]),
corner_anchors(DVec2::new(corner1.x + corner_radii[3], corner2.y - corner_radii[3]), DVec2::new(corner1.x, corner2.y), corner_radii[3]),
]
.concat();
bezpath_from_anchors(&anchors, true)
}
/// Constructs an ellipse with `corner1` and `corner2` as the two corners of the bounding box.
pub fn ellipse_bezpath(corner1: DVec2, corner2: DVec2) -> BezPath {
let size = (corner1 - corner2).abs();
let center = (corner1 + corner2) / 2.;
let top = DVec2::new(center.x, corner1.y);
let bottom = DVec2::new(center.x, corner2.y);
let left = DVec2::new(corner1.x, center.y);
let right = DVec2::new(corner2.x, center.y);
let handle_offset = size * HANDLE_OFFSET_FACTOR * 0.5;
let anchors = [
Anchor::new(top, Some(top - handle_offset * DVec2::X), Some(top + handle_offset * DVec2::X)),
Anchor::new(right, Some(right - handle_offset * DVec2::Y), Some(right + handle_offset * DVec2::Y)),
Anchor::new(bottom, Some(bottom + handle_offset * DVec2::X), Some(bottom - handle_offset * DVec2::X)),
Anchor::new(left, Some(left + handle_offset * DVec2::Y), Some(left - handle_offset * DVec2::Y)),
];
bezpath_from_anchors(&anchors, true)
}
/// Constructs an arc by a `radius`, `start_angle` and `sweep_angle`. Angles must be in radians. The arc type makes it look like a pie or pacman.
pub fn arc_bezpath(radius: f64, start_angle: f64, sweep_angle: f64, arc_type: ArcType) -> BezPath {
// Prevents glitches from numerical imprecision that have been observed during animation playback after about a minute
let start_angle = start_angle % (TAU * 2.);
let sweep_angle = sweep_angle % (TAU * 2.);
let original_start_angle = start_angle;
let sweep_angle_sign = sweep_angle.signum();
let mut start_angle = 0.;
let mut sweep_angle = sweep_angle.abs();
if ((sweep_angle / TAU).floor() as u32).is_multiple_of(2) {
sweep_angle %= TAU;
} else {
start_angle = sweep_angle % TAU;
sweep_angle = TAU - start_angle;
}
sweep_angle *= sweep_angle_sign;
start_angle *= sweep_angle_sign;
start_angle += original_start_angle;
let closed = arc_type == ArcType::Closed;
let slice = arc_type == ArcType::PieSlice;
let center = DVec2::new(0., 0.);
let segments = (sweep_angle.abs() / (std::f64::consts::PI / 4.)).ceil().max(1.) as usize;
let step = sweep_angle / segments as f64;
let factor = 4. / 3. * (step / 2.).sin() / (1. + (step / 2.).cos());
let mut anchors = Vec::with_capacity(segments);
let mut prev_in_handle = None;
let mut prev_end = DVec2::new(0., 0.);
for i in 0..segments {
let start_angle = start_angle + step * i as f64;
let end_angle = start_angle + step;
let start_vec = DVec2::from_angle(start_angle);
let end_vec = DVec2::from_angle(end_angle);
let start = center + radius * start_vec;
let end = center + radius * end_vec;
let handle_start = start + start_vec.perp() * radius * factor;
let handle_end = end - end_vec.perp() * radius * factor;
anchors.push(Anchor::new(start, prev_in_handle, Some(handle_start)));
prev_in_handle = Some(handle_end);
prev_end = end;
}
anchors.push(Anchor::new(prev_end, prev_in_handle, None));
if slice {
anchors.push(Anchor::sharp(center));
}
bezpath_from_anchors(&anchors, closed || slice)
}
/// Constructs a regular polygon (ngon). Based on `sides` and `radius`, which is the distance from the center to any vertex.
pub fn regular_polygon_bezpath(center: DVec2, sides: u64, radius: f64) -> BezPath {
let sides = sides.max(3);
let angle_increment = TAU / (sides as f64);
let positions = (0..sides).map(|i| {
let angle = (i as f64) * angle_increment - std::f64::consts::FRAC_PI_2;
center + radius * DVec2::new(f64::cos(angle), f64::sin(angle))
});
polyline_bezpath(positions, true)
}
/// Constructs a star polygon (n-star). See [`regular_polygon_bezpath`], but with interspersed vertices at an `inner_radius`.
pub fn star_polygon_bezpath(center: DVec2, sides: u64, radius: f64, inner_radius: f64) -> BezPath {
let sides = sides.max(2);
let angle_increment = 0.5 * TAU / (sides as f64);
let positions = (0..sides * 2).map(|i| {
let angle = (i as f64) * angle_increment - std::f64::consts::FRAC_PI_2;
let radius = if i % 2 == 0 { radius } else { inner_radius };
center + radius * DVec2::new(f64::cos(angle), f64::sin(angle))
});
polyline_bezpath(positions, true)
}
/// Constructs a line from `point1` to `point2`.
pub fn line_bezpath(point1: DVec2, point2: DVec2) -> BezPath {
polyline_bezpath([point1, point2], false)
}
/// Constructs an arrow shape from start and end points with parametric control over dimensions.
pub fn arrow_bezpath(start: DVec2, end: DVec2, shaft_width: f64, head_width: f64, head_length: f64) -> BezPath {
let delta = end - start;
let length = delta.length();
// Degenerate case: return a point
if length < 1e-10 {
return polyline_bezpath([start], true);
}
let direction = delta / length;
let perpendicular = DVec2::new(-direction.y, direction.x);
let half_shaft = shaft_width * 0.5;
let half_head = head_width * 0.5;
let head_base_distance = (length - head_length).max(0.);
let head_base = start + direction * head_base_distance;
// Arrow path starts at the tail, traces around the shape, and returns to the tail
let positions = [
start, // Tail center (origin)
start + perpendicular * half_shaft, // Tail top
head_base + perpendicular * half_shaft, // Head base top (shaft)
head_base + perpendicular * half_head, // Head base top (wide)
end, // Tip
head_base - perpendicular * half_head, // Head base bottom (wide)
head_base - perpendicular * half_shaft, // Head base bottom (shaft)
start - perpendicular * half_shaft, // Tail bottom
];
polyline_bezpath(positions, true)
}
/// Constructs a spiral winding from an inner radius `a` out to `outer_radius`, sampled every `delta_theta` radians.
pub fn spiral_bezpath(a: f64, outer_radius: f64, turns: f64, start_angle: f64, delta_theta: f64, spiral_type: SpiralType) -> BezPath {
let mut anchors = Vec::new();
let mut prev_in_handle = None;
let theta_end = turns * TAU + start_angle;
let a = if spiral_type == SpiralType::Logarithmic { a.max(1e-10) } else { a };
let b = calculate_growth_factor(a, turns, outer_radius, spiral_type);
let mut theta = start_angle;
while theta < theta_end {
let theta_next = f64::min(theta + delta_theta, theta_end);
let p0 = spiral_point(theta, a, b, spiral_type);
let p3 = spiral_point(theta_next, a, b, spiral_type);
let t0 = spiral_tangent(theta, a, b, spiral_type);
let t1 = spiral_tangent(theta_next, a, b, spiral_type);
let arc_length = spiral_arc_length(theta, theta_next, a, b, spiral_type);
let handle_distance = arc_length / 3.;
let p1 = p0 + handle_distance * t0;
let p2 = p3 - handle_distance * t1;
anchors.push(Anchor::new(p0, prev_in_handle, Some(p1)));
prev_in_handle = Some(p2);
// If final segment, end with anchor at theta_end
if (theta_next - theta_end).abs() < f64::EPSILON {
anchors.push(Anchor::new(p3, prev_in_handle, None));
break;
}
theta = theta_next;
}
bezpath_from_anchors(&anchors, false)
}
pub fn calculate_growth_factor(a: f64, turns: f64, outer_radius: f64, spiral_type: SpiralType) -> f64 {
match spiral_type {
SpiralType::Archimedean => {
let total_theta = turns * TAU;
(outer_radius - a) / total_theta
}
SpiralType::Logarithmic => {
let total_theta = turns * TAU;
((outer_radius.abs() / a).ln()) / total_theta
}
}
}
/// Returns a point on the given spiral type at angle `theta`.
pub fn spiral_point(theta: f64, a: f64, b: f64, spiral_type: SpiralType) -> DVec2 {
match spiral_type {
SpiralType::Archimedean => archimedean_spiral_point(theta, a, b),
SpiralType::Logarithmic => log_spiral_point(theta, a, b),
}
}
/// Returns the tangent direction at angle `theta` for the given spiral type.
fn spiral_tangent(theta: f64, a: f64, b: f64, spiral_type: SpiralType) -> DVec2 {
match spiral_type {
SpiralType::Archimedean => archimedean_spiral_tangent(theta, a, b),
SpiralType::Logarithmic => log_spiral_tangent(theta, a, b),
}
}
/// Computes arc length between two angles for the given spiral type.
fn spiral_arc_length(theta_start: f64, theta_end: f64, a: f64, b: f64, spiral_type: SpiralType) -> f64 {
match spiral_type {
SpiralType::Archimedean => archimedean_spiral_arc_length(theta_start, theta_end, a, b),
SpiralType::Logarithmic => log_spiral_arc_length(theta_start, theta_end, a, b),
}
}
/// Returns a point on a logarithmic spiral at angle `theta`.
fn log_spiral_point(theta: f64, a: f64, b: f64) -> DVec2 {
let r = a * (b * theta).exp(); // a * e^(bθ)
DVec2::new(r * theta.cos(), -r * theta.sin())
}
/// Computes arc length along a logarithmic spiral between two angles.
fn log_spiral_arc_length(theta_start: f64, theta_end: f64, a: f64, b: f64) -> f64 {
let factor = (1. + b * b).sqrt();
(a / b) * factor * ((b * theta_end).exp() - (b * theta_start).exp())
}
/// Returns the tangent direction of a logarithmic spiral at angle `theta`.
fn log_spiral_tangent(theta: f64, a: f64, b: f64) -> DVec2 {
let r = a * (b * theta).exp();
let dx = r * (b * theta.cos() - theta.sin());
let dy = r * (b * theta.sin() + theta.cos());
DVec2::new(dx, -dy).normalize_or(DVec2::X)
}
/// Returns a point on an Archimedean spiral at angle `theta`.
fn archimedean_spiral_point(theta: f64, a: f64, b: f64) -> DVec2 {
let r = a + b * theta;
DVec2::new(r * theta.cos(), -r * theta.sin())
}
/// Returns the tangent direction of an Archimedean spiral at angle `theta`.
fn archimedean_spiral_tangent(theta: f64, a: f64, b: f64) -> DVec2 {
let r = a + b * theta;
let dx = b * theta.cos() - r * theta.sin();
let dy = b * theta.sin() + r * theta.cos();
DVec2::new(dx, -dy).normalize_or(DVec2::X)
}
/// Computes arc length along an Archimedean spiral between two angles.
fn archimedean_spiral_arc_length(theta_start: f64, theta_end: f64, a: f64, b: f64) -> f64 {
archimedean_spiral_arc_length_origin(theta_end, a, b) - archimedean_spiral_arc_length_origin(theta_start, a, b)
}
/// Computes arc length from origin to a point on Archimedean spiral at angle `theta`.
fn archimedean_spiral_arc_length_origin(theta: f64, a: f64, b: f64) -> f64 {
let r = a + b * theta;
let sqrt_term = (r * r + b * b).sqrt();
(r * sqrt_term + b * b * ((r + sqrt_term).ln())) / (2. * b)
}

View File

@@ -150,7 +150,7 @@ mod tests {
// List of first handle or second point in a cubic bezier curve.
let first_handles = solve_spline_first_handle_closed(&points);
// Construct the Subpath
// Construct the subpath
let mut bezpath = BezPath::new();
bezpath.move_to(dvec2_to_point(points[0]));

View File

@@ -14,22 +14,16 @@ pub fn pathseg_tangent(segment: PathSeg, t: f64) -> DVec2 {
DVec2::new(tangent.x, tangent.y)
}
// Compare two f64s with some maximum absolute difference to account for floating point errors
#[cfg(test)]
pub fn compare_f64s(f1: f64, f2: f64) -> bool {
(f1 - f2).abs() < super::contants::MAX_ABSOLUTE_DIFFERENCE
}
/// Compare points by allowing some maximum absolute difference to account for floating point errors
#[cfg(test)]
pub fn compare_points(p1: kurbo::Point, p2: kurbo::Point) -> bool {
pub(crate) fn compare_points(p1: kurbo::Point, p2: kurbo::Point) -> bool {
let (p1, p2) = (crate::vector::misc::point_to_dvec2(p1), crate::vector::misc::point_to_dvec2(p2));
p1.abs_diff_eq(p2, super::contants::MAX_ABSOLUTE_DIFFERENCE)
p1.abs_diff_eq(p2, super::consts::MAX_ABSOLUTE_DIFFERENCE)
}
/// Compare vectors of points by allowing some maximum absolute difference to account for floating point errors
#[cfg(test)]
pub fn compare_vec_of_points(a: Vec<kurbo::Point>, b: Vec<kurbo::Point>, max_absolute_difference: f64) -> bool {
pub(crate) fn compare_vec_of_points(a: Vec<kurbo::Point>, b: Vec<kurbo::Point>, max_absolute_difference: f64) -> bool {
a.len() == b.len()
&& a.into_iter()
.zip(b)
@@ -39,6 +33,6 @@ pub fn compare_vec_of_points(a: Vec<kurbo::Point>, b: Vec<kurbo::Point>, max_abs
/// Compare the two values in a `DVec2` independently with a provided max absolute value difference.
#[cfg(test)]
pub fn dvec2_compare(a: kurbo::Point, b: kurbo::Point, max_abs_diff: f64) -> glam::BVec2 {
pub(crate) fn dvec2_compare(a: kurbo::Point, b: kurbo::Point, max_abs_diff: f64) -> glam::BVec2 {
glam::BVec2::new((a.x - b.x).abs() < max_abs_diff, (a.y - b.y).abs() < max_abs_diff)
}

View File

@@ -3,16 +3,42 @@ use std::sync::{Arc, RwLock};
use super::algorithms::{bezpath_algorithms::bezpath_is_inside_bezpath, intersection::filtered_segment_intersections};
use super::misc::dvec2_to_point;
use crate::math::QuadExt;
use crate::subpath::Subpath;
use crate::vector::PointId;
use crate::vector::misc::point_to_dvec2;
use core_types::math::quad::Quad;
use core_types::transform::Transform;
use glam::{DAffine2, DMat2, DVec2};
use kurbo::{Affine, BezPath, ParamCurve, PathSeg, Shape};
use kurbo::{Affine, BezPath, ParamCurve, PathEl, PathSeg, Shape};
type BoundingBox = Option<[DVec2; 2]>;
/// Per-segment tight bounding box union of the transformed path, or None if the path has no segments.
fn bezpath_bounding_box_with_transform(bezpath: &BezPath, transform: DAffine2) -> BoundingBox {
let affine = Affine::new(transform.to_cols_array());
bezpath
.segments()
.map(|segment| (affine * segment).bounding_box())
.reduce(|a, b| a.union(b))
.map(|rect| [DVec2::new(rect.min_x(), rect.min_y()), DVec2::new(rect.max_x(), rect.max_y())])
}
/// The explicitly closed contours of the path, which together form its fillable region.
fn closed_contours(bezpath: &BezPath) -> BezPath {
let elements = bezpath.elements();
let mut kept = Vec::new();
let mut contour_start = 0;
for (index, element) in elements.iter().enumerate() {
if matches!(element, PathEl::MoveTo(_)) {
contour_start = index;
}
if matches!(element, PathEl::ClosePath) {
kept.extend_from_slice(&elements[contour_start..=index]);
}
}
BezPath::from_vec(kept)
}
#[derive(Copy, Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct FreePoint {
@@ -33,11 +59,10 @@ impl FreePoint {
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ClickTargetType {
Subpath(Subpath<PointId>),
FreePoint(FreePoint),
/// Multiple subpaths tested as one compound shape using the non-zero fill rule, so holes
/// One or more contours tested as one compound shape using the non-zero fill rule, so holes
/// (e.g. the inside of an "O") correctly count as outside the fill.
CompoundPath(Vec<Subpath<PointId>>),
Path(BezPath),
FreePoint(FreePoint),
}
/// Fixed-size ring buffer cache for rotated bounding boxes.
@@ -93,9 +118,9 @@ impl BoundingBoxCache {
}
/// Computes and caches bounding box for the given rotation, then applies scale/translation.
/// Returns the final transformed bounds.
fn add_to_cache(&mut self, subpath: &Subpath<PointId>, rotation: f64, scale: DVec2, translation: DVec2, fingerprint: u8) -> BoundingBox {
fn add_to_cache(&mut self, bezpath: &BezPath, rotation: f64, scale: DVec2, translation: DVec2, fingerprint: u8) -> BoundingBox {
// Compute bounds for pure rotation (expensive operation we want to cache)
let bounds = subpath.bounding_box_with_transform(DAffine2::from_angle(rotation));
let bounds = bezpath_bounding_box_with_transform(bezpath, DAffine2::from_angle(rotation));
if bounds.is_none() {
return bounds;
@@ -137,23 +162,12 @@ impl PartialEq for ClickTarget {
}
impl ClickTarget {
pub fn new_with_subpath(subpath: Subpath<PointId>, stroke_width: f64) -> Self {
let bounding_box = subpath.loose_bounding_box();
pub fn new_with_path(path: BezPath, stroke_width: f64) -> Self {
// The control-point hull serves as the loose bounding box
let control_box = path.control_box();
let bounding_box = (!path.elements().is_empty()).then(|| [DVec2::new(control_box.min_x(), control_box.min_y()), DVec2::new(control_box.max_x(), control_box.max_y())]);
Self {
target_type: ClickTargetType::Subpath(subpath),
stroke_width,
bounding_box,
bounding_box_cache: Default::default(),
}
}
pub fn new_with_compound_path(subpaths: Vec<Subpath<PointId>>, stroke_width: f64) -> Self {
let bounding_box = subpaths
.iter()
.filter_map(|subpath| subpath.loose_bounding_box())
.reduce(|[a_min, a_max], [b_min, b_max]| [a_min.min(b_min), a_max.max(b_max)]);
Self {
target_type: ClickTargetType::CompoundPath(subpaths),
target_type: ClickTargetType::Path(path),
stroke_width,
bounding_box,
bounding_box_cache: Default::default(),
@@ -190,10 +204,10 @@ impl ClickTarget {
pub fn bounding_box_with_transform(&self, transform: DAffine2) -> BoundingBox {
match self.target_type {
ClickTargetType::Subpath(ref subpath) => {
ClickTargetType::Path(ref path) => {
// Bypass cache for skewed transforms since rotation decomposition isn't valid
if transform.has_skew() {
return subpath.bounding_box_with_transform(transform);
return bezpath_bounding_box_with_transform(path, transform);
}
// Decompose transform into rotation, scale, translation for caching strategy
@@ -213,12 +227,8 @@ impl ClickTarget {
// Cache miss - compute and store new entry
let mut write_lock = self.bounding_box_cache.write().unwrap();
write_lock.add_to_cache(subpath, rotation, scale, translation, fingerprint)
write_lock.add_to_cache(path, rotation, scale, translation, fingerprint)
}
ClickTargetType::CompoundPath(ref subpaths) => subpaths
.iter()
.filter_map(|subpath| subpath.bounding_box_with_transform(transform))
.reduce(|[a_min, a_max], [b_min, b_max]| [a_min.min(b_min), a_max.max(b_max)]),
// TODO: use point for calculation of bbox
ClickTargetType::FreePoint(_) => self.bounding_box.map(|[a, b]| [transform.transform_point2(a), transform.transform_point2(b)]),
}
@@ -226,13 +236,8 @@ impl ClickTarget {
pub fn apply_transform(&mut self, affine_transform: DAffine2) {
match self.target_type {
ClickTargetType::Subpath(ref mut subpath) => {
subpath.apply_transform(affine_transform);
}
ClickTargetType::CompoundPath(ref mut subpaths) => {
for subpath in subpaths {
subpath.apply_transform(affine_transform);
}
ClickTargetType::Path(ref mut path) => {
path.apply_affine(Affine::new(affine_transform.to_cols_array()));
}
ClickTargetType::FreePoint(ref mut point) => {
point.apply_transform(affine_transform);
@@ -243,14 +248,8 @@ impl ClickTarget {
fn update_bbox(&mut self) {
match self.target_type {
ClickTargetType::Subpath(ref subpath) => {
self.bounding_box = subpath.bounding_box();
}
ClickTargetType::CompoundPath(ref subpaths) => {
self.bounding_box = subpaths
.iter()
.filter_map(|subpath| subpath.bounding_box())
.reduce(|[a_min, a_max], [b_min, b_max]| [a_min.min(b_min), a_max.max(b_max)]);
ClickTargetType::Path(ref path) => {
self.bounding_box = bezpath_bounding_box_with_transform(path, DAffine2::IDENTITY);
}
ClickTargetType::FreePoint(ref point) => {
self.bounding_box = Some([point.position - DVec2::splat(self.stroke_width / 2.), point.position + DVec2::splat(self.stroke_width / 2.)]);
@@ -270,43 +269,26 @@ impl ClickTarget {
let mut bezier_iter = || bezier_iter().map(|bezier| Affine::new(inverse.to_cols_array()) * bezier);
match self.target_type() {
ClickTargetType::Subpath(subpath) => {
// Check if outlines intersect
let outline_intersects = |path_segment: PathSeg| bezier_iter().any(|line| !filtered_segment_intersections(path_segment, line, None, None).is_empty());
if subpath.iter().any(outline_intersects) {
return true;
}
// Check if selection is entirely within the shape
if subpath.closed() && bezier_iter().next().is_some_and(|bezier| subpath.contains_point(point_to_dvec2(bezier.start()))) {
return true;
}
let mut selection = BezPath::from_path_segments(bezier_iter());
selection.close_path();
// Check if shape is entirely within selection
bezpath_is_inside_bezpath(&subpath.to_bezpath(), &selection, None, None)
}
ClickTargetType::CompoundPath(subpaths) => {
ClickTargetType::Path(path) => {
// Outline intersection (catches strokes and both filled/unfilled shapes)
let outline_intersects = |path_segment: PathSeg| bezier_iter().any(|line| !filtered_segment_intersections(path_segment, line, None, None).is_empty());
if subpaths.iter().flat_map(|subpath| subpath.iter()).any(outline_intersects) {
if path.segments().any(outline_intersects) {
return true;
}
// Selection point inside compound fill (non-zero rule).
// Only closed subpaths contribute to the fill region; open segments would otherwise produce spurious winding on one side of the segment.
let combined: BezPath = subpaths.iter().filter(|subpath| subpath.closed()).flat_map(|subpath| subpath.to_bezpath()).collect();
if !combined.is_empty() && bezier_iter().next().is_some_and(|bezier| combined.contains(bezier.start())) {
// Selection point inside the fill (non-zero rule).
// Only closed contours contribute to the fill region; open segments would otherwise produce spurious winding on one side of the segment.
let fill_region = closed_contours(path);
if !fill_region.is_empty() && bezier_iter().next().is_some_and(|segment| fill_region.contains(segment.start())) {
return true;
}
// Build closed selection path, then check if all contours are entirely within it
// Build closed selection path, then check if the whole shape is entirely within it
let mut selection = BezPath::from_path_segments(bezier_iter());
selection.close_path();
subpaths.iter().all(|subpath| bezpath_is_inside_bezpath(&subpath.to_bezpath(), &selection, None, None))
bezpath_is_inside_bezpath(path, &selection, None, None)
}
ClickTargetType::FreePoint(point) => bezier_iter().map(|bezier: PathSeg| bezier.winding(dvec2_to_point(point.position))).sum::<i32>() != 0,
ClickTargetType::FreePoint(point) => bezier_iter().map(|segment: PathSeg| segment.winding(dvec2_to_point(point.position))).sum::<i32>() != 0,
}
}
@@ -337,11 +319,7 @@ impl ClickTarget {
{
// Check if the point is within the shape
match self.target_type() {
ClickTargetType::Subpath(subpath) => subpath.closed() && subpath.contains_point(point),
ClickTargetType::CompoundPath(subpaths) => {
let combined: BezPath = subpaths.iter().flat_map(|subpath| subpath.to_bezpath()).collect();
combined.contains(dvec2_to_point(point))
}
ClickTargetType::Path(path) => closed_contours(path).contains(dvec2_to_point(point)),
ClickTargetType::FreePoint(free_point) => free_point.position == point,
}
} else {
@@ -353,10 +331,14 @@ impl ClickTarget {
#[cfg(test)]
mod tests {
use super::*;
use crate::subpath::Subpath;
use glam::DVec2;
use kurbo::{DEFAULT_ACCURACY, Rect};
use std::f64::consts::PI;
fn rectangle_path(corner1: DVec2, corner2: DVec2) -> BezPath {
Rect::new(corner1.x, corner1.y, corner2.x, corner2.y).to_path(DEFAULT_ACCURACY)
}
#[test]
fn test_bounding_box_cache_fingerprint_generation() {
// Test that fingerprints have MSB set and use only 7 bits for data
@@ -386,8 +368,8 @@ mod tests {
fn test_bounding_box_cache_basic_operations() {
let mut cache = BoundingBoxCache::default();
// Create a simple rectangle subpath for testing
let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::new(100., 50.));
// Create a simple rectangle path for testing
let path = rectangle_path(DVec2::ZERO, DVec2::new(100., 50.));
let rotation = PI / 4.;
let scale = DVec2::new(2., 2.);
@@ -398,7 +380,7 @@ mod tests {
assert!(cache.try_read(rotation, scale, translation, fingerprint).is_none());
// Add to cache
let result = cache.add_to_cache(&subpath, rotation, scale, translation, fingerprint);
let result = cache.add_to_cache(&path, rotation, scale, translation, fingerprint);
assert!(result.is_some());
// Should now be able to read from cache
@@ -410,7 +392,7 @@ mod tests {
#[test]
fn test_bounding_box_cache_ring_buffer_behavior() {
let mut cache = BoundingBoxCache::default();
let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::new(10., 10.));
let path = rectangle_path(DVec2::ZERO, DVec2::new(10., 10.));
let scale = DVec2::ONE;
let translation = DVec2::ZERO;
@@ -419,7 +401,7 @@ mod tests {
for rotation in &rotations {
let fingerprint = BoundingBoxCache::rotation_fingerprint(*rotation);
cache.add_to_cache(&subpath, *rotation, scale, translation, fingerprint);
cache.add_to_cache(&path, *rotation, scale, translation, fingerprint);
}
// First two entries should be overwritten (cache size is 8)
@@ -435,8 +417,8 @@ mod tests {
#[test]
fn test_click_target_bounding_box_caching() {
// Create a click target with a simple rectangle
let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::new(100., 50.));
let click_target = ClickTarget::new_with_subpath(subpath, 1.);
let path = rectangle_path(DVec2::ZERO, DVec2::new(100., 50.));
let click_target = ClickTarget::new_with_path(path, 1.);
let rotation = PI / 6.;
let scale = DVec2::new(1.5, 1.5);
@@ -472,8 +454,8 @@ mod tests {
#[test]
fn test_click_target_skew_bypass_cache() {
let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::new(100., 50.));
let click_target = ClickTarget::new_with_subpath(subpath.clone(), 1.);
let path = rectangle_path(DVec2::ZERO, DVec2::new(100., 50.));
let click_target = ClickTarget::new_with_path(path.clone(), 1.);
// Create a transform with skew (non-uniform scaling in different directions)
let skew_transform = DAffine2::from_cols_array(&[2., 0.5, 0., 1., 10., 20.]);
@@ -481,14 +463,14 @@ mod tests {
// Should bypass cache and compute directly
let result = click_target.bounding_box_with_transform(skew_transform);
let expected = subpath.bounding_box_with_transform(skew_transform);
let expected = bezpath_bounding_box_with_transform(&path, skew_transform);
assert_eq!(result, expected);
}
#[test]
fn test_cache_fingerprint_collision_handling() {
let mut cache = BoundingBoxCache::default();
let subpath = Subpath::new_rectangle(DVec2::ZERO, DVec2::new(10., 10.));
let path = rectangle_path(DVec2::ZERO, DVec2::new(10., 10.));
let scale = DVec2::ONE;
let translation = DVec2::ZERO;
@@ -501,7 +483,7 @@ mod tests {
// If we found a collision, test that exact rotation matching still works
if fp1 == fp2 && rotation1 != rotation2 {
// Add first rotation
cache.add_to_cache(&subpath, rotation1, scale, translation, fp1);
cache.add_to_cache(&path, rotation1, scale, translation, fp1);
// Should find the exact rotation
assert!(cache.try_read(rotation1, scale, translation, fp1).is_some());

View File

@@ -1,10 +1,11 @@
use super::PointId;
use super::algorithms::offset_subpath::MAX_ABSOLUTE_DIFFERENCE;
use crate::subpath::{BezierHandles, ManipulatorGroup};
use super::algorithms::consts::MAX_COINCIDENT_POINT_DISTANCE;
use crate::vector::{SegmentId, Vector};
use core_types::list::{Item, List};
use dyn_any::DynAny;
use glam::DVec2;
use glam::{DAffine2, DVec2};
use kurbo::{BezPath, CubicBez, Line, ParamCurve, ParamCurveDeriv, PathSeg, Point, QuadBez};
use std::fmt::{Debug, Formatter};
use std::ops::Sub;
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
@@ -49,6 +50,57 @@ pub enum RowsOrColumns {
Columns,
}
/// A box's four corner values, such as a rectangle's corner radii, expanded on read from any number of stored
/// values by the CSS `border-radius` shorthand rules.
///
/// Wraps a `List<f64>` so the Data panel can introspect its values, mirroring how `DashPattern` wraps its lengths,
/// while remaining a single rank-0 value on the wire.
#[derive(Default, Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)]
pub struct BoxCorners(pub List<f64>);
impl BoxCorners {
/// Expands the stored values to the four corners, clockwise from the top-left, by the CSS `border-radius` shorthand rules.
/// - `[]` → `[0, 0, 0, 0]`
/// - `[a]` → `[a, a, a, a]`
/// - `[a, b]` → `[a, b, a, b]`
/// - `[a, b, c]` → `[a, b, c, b]`
/// - `[a, b, c, d, …]` → `[a, b, c, d]`
pub fn to_corner_values(&self) -> [f64; 4] {
let values: Vec<f64> = self.0.iter_element_values().copied().collect();
match values.as_slice() {
[] => [0., 0., 0., 0.],
&[a] => [a, a, a, a],
&[a, b] => [a, b, a, b],
&[a, b, c] => [a, b, c, b],
&[a, b, c, d, ..] => [a, b, c, d],
}
}
}
impl From<f64> for BoxCorners {
fn from(value: f64) -> Self {
Self(List::new_from_element(value))
}
}
impl From<Vec<f64>> for BoxCorners {
fn from(values: Vec<f64>) -> Self {
Self(values.into_iter().map(Item::new_from_element).collect())
}
}
impl From<&str> for BoxCorners {
fn from(text: &str) -> Self {
Self::from(core_types::misc::parse_f64_list(text))
}
}
impl From<String> for BoxCorners {
fn from(text: String) -> Self {
Self::from(text.as_str())
}
}
pub trait AsU64 {
fn as_u64(&self) -> u64;
}
@@ -68,25 +120,6 @@ impl AsU64 for f64 {
}
}
pub trait AsI64 {
fn as_i64(&self) -> i64;
}
impl AsI64 for u32 {
fn as_i64(&self) -> i64 {
*self as i64
}
}
impl AsI64 for u64 {
fn as_i64(&self) -> i64 {
*self as i64
}
}
impl AsI64 for f64 {
fn as_i64(&self) -> i64 {
*self as i64
}
}
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Hash, DynAny, node_macro::ChoiceType)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
@@ -103,9 +136,12 @@ pub enum GridType {
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[widget(Radio)]
pub enum ArcType {
/// Leaves the two ends of the arc unconnected.
#[default]
Open = 0,
/// Connects the two ends of the arc with a straight line.
Closed,
/// Connects the two ends of the arc to its center, forming a wedge.
PieSlice,
}
@@ -191,38 +227,44 @@ pub fn handles_to_segment(start: DVec2, handles: BezierHandles, end: DVec2) -> P
}
}
pub fn bezpath_from_manipulator_groups(manipulator_groups: &[ManipulatorGroup<PointId>], closed: bool) -> BezPath {
let mut bezpath = kurbo::BezPath::new();
let mut out_handle;
/// Stitches anchors into a path, emitting a cubic when both facing handles exist, a quadratic when only one does, and a line otherwise.
///
/// Each item is an anchor position paired with its incoming and outgoing handle positions, in absolute coordinates.
pub fn bezpath_from_anchors_and_handles(anchors: impl IntoIterator<Item = (DVec2, Option<DVec2>, Option<DVec2>)>, closed: bool) -> BezPath {
let mut bezpath = BezPath::new();
let mut anchors = anchors.into_iter();
let Some(first) = manipulator_groups.first() else { return bezpath };
bezpath.move_to(dvec2_to_point(first.anchor));
out_handle = first.out_handle;
let Some((first_anchor, first_in_handle, first_out_handle)) = anchors.next() else {
return bezpath;
};
bezpath.move_to(dvec2_to_point(first_anchor));
let mut out_handle = first_out_handle;
for manipulator in manipulator_groups.iter().skip(1) {
match (out_handle, manipulator.in_handle) {
(Some(handle_start), Some(handle_end)) => bezpath.curve_to(dvec2_to_point(handle_start), dvec2_to_point(handle_end), dvec2_to_point(manipulator.anchor)),
(None, None) => bezpath.line_to(dvec2_to_point(manipulator.anchor)),
(None, Some(handle)) => bezpath.quad_to(dvec2_to_point(handle), dvec2_to_point(manipulator.anchor)),
(Some(handle), None) => bezpath.quad_to(dvec2_to_point(handle), dvec2_to_point(manipulator.anchor)),
}
out_handle = manipulator.out_handle;
let connect_to = |bezpath: &mut BezPath, out_handle: Option<DVec2>, anchor: DVec2, in_handle: Option<DVec2>| match (out_handle, in_handle) {
(Some(handle_start), Some(handle_end)) => bezpath.curve_to(dvec2_to_point(handle_start), dvec2_to_point(handle_end), dvec2_to_point(anchor)),
(None, None) => bezpath.line_to(dvec2_to_point(anchor)),
(None, Some(handle)) | (Some(handle), None) => bezpath.quad_to(dvec2_to_point(handle), dvec2_to_point(anchor)),
};
for (anchor, in_handle, anchor_out_handle) in anchors {
connect_to(&mut bezpath, out_handle, anchor, in_handle);
out_handle = anchor_out_handle;
}
if closed {
match (out_handle, first.in_handle) {
(Some(handle_start), Some(handle_end)) => bezpath.curve_to(dvec2_to_point(handle_start), dvec2_to_point(handle_end), dvec2_to_point(first.anchor)),
(None, None) => bezpath.line_to(dvec2_to_point(first.anchor)),
(None, Some(handle)) => bezpath.quad_to(dvec2_to_point(handle), dvec2_to_point(first.anchor)),
(Some(handle), None) => bezpath.quad_to(dvec2_to_point(handle), dvec2_to_point(first.anchor)),
}
connect_to(&mut bezpath, out_handle, first_anchor, first_in_handle);
bezpath.close_path();
}
bezpath
}
pub fn bezpath_to_manipulator_groups(bezpath: &BezPath) -> (Vec<ManipulatorGroup<PointId>>, bool) {
let mut manipulator_groups = Vec::<ManipulatorGroup<PointId>>::new();
pub fn bezpath_from_manipulator_groups(manipulator_groups: &[ManipulatorGroup], closed: bool) -> BezPath {
bezpath_from_anchors_and_handles(manipulator_groups.iter().map(|group| (group.anchor, group.in_handle, group.out_handle)), closed)
}
pub fn bezpath_to_manipulator_groups(bezpath: &BezPath) -> (Vec<ManipulatorGroup>, bool) {
let mut manipulator_groups = Vec::<ManipulatorGroup>::new();
let mut is_closed = false;
for element in bezpath.elements() {
@@ -257,7 +299,7 @@ pub fn bezpath_to_manipulator_groups(bezpath: &BezPath) -> (Vec<ManipulatorGroup
///
/// This is different from simply checking if the segment is [`PathSeg::Line`] or [`PathSeg::Quad`] or [`PathSeg::Cubic`]. Bezier curve can also be a line if the control points are colinear to the start and end points. Therefore if the handles exceed the start and end point, it will still be considered as a line.
pub fn is_linear(segment: PathSeg) -> bool {
let is_colinear = |a: Point, b: Point, c: Point| -> bool { ((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)).abs() < MAX_ABSOLUTE_DIFFERENCE };
let is_colinear = |a: Point, b: Point, c: Point| -> bool { ((b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)).abs() < MAX_COINCIDENT_POINT_DISTANCE };
match segment {
PathSeg::Line(_) => true,
@@ -267,7 +309,7 @@ pub fn is_linear(segment: PathSeg) -> bool {
}
/// Get an vec of all the points in a path segment.
pub fn pathseg_points_vec(segment: PathSeg) -> Vec<Point> {
fn pathseg_points_vec(segment: PathSeg) -> Vec<Point> {
match segment {
PathSeg::Line(line) => [line.p0, line.p1].to_vec(),
PathSeg::Quad(quad_bez) => [quad_bez.p0, quad_bez.p1, quad_bez.p2].to_vec(),
@@ -394,8 +436,8 @@ impl ManipulatorPointId {
pub fn get_position(&self, vector: &Vector) -> Option<DVec2> {
match self {
ManipulatorPointId::Anchor(id) => vector.point_domain.position_from_id(*id),
ManipulatorPointId::PrimaryHandle(id) => vector.segment_from_id(*id).and_then(|bezier| bezier.handle_start()),
ManipulatorPointId::EndHandle(id) => vector.segment_from_id(*id).and_then(|bezier| bezier.handle_end()),
ManipulatorPointId::PrimaryHandle(id) => vector.segment_from_id(*id).and_then(|segment| segment_to_handles(&segment).start()),
ManipulatorPointId::EndHandle(id) => vector.segment_from_id(*id).and_then(|segment| segment_to_handles(&segment).end()),
}
}
@@ -508,9 +550,9 @@ pub struct HandleId {
impl std::fmt::Display for HandleId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.ty {
// I haven't checked if "out" and "in" are reversed, or are accurate translations of the "primary" and "end" terms used in the `HandleType` enum, so this naming is an assumption.
HandleType::Primary => write!(f, "{} out", self.segment.inner()),
HandleType::End => write!(f, "{} in", self.segment.inner()),
// The primary handle sits at the segment's start anchor and the end handle at its end anchor
HandleType::Primary => write!(f, "Segment {} (start handle)", self.segment.inner()),
HandleType::End => write!(f, "Segment {} (end handle)", self.segment.inner()),
}
}
}
@@ -598,3 +640,149 @@ graphene_hash::impl_via_hash!(
SpiralType,
InterpolationDistribution
);
/// Structure used to represent a single anchor with up to two optional associated handles along a path.
#[derive(Copy, Clone, PartialEq, graphene_hash::CacheHash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ManipulatorGroup {
pub anchor: DVec2,
pub in_handle: Option<DVec2>,
pub out_handle: Option<DVec2>,
pub id: PointId,
}
impl Debug for ManipulatorGroup {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ManipulatorGroup")
.field("anchor", &self.anchor)
.field("in_handle", &self.in_handle)
.field("out_handle", &self.out_handle)
.finish()
}
}
impl ManipulatorGroup {
/// Construct a new manipulator group from an anchor, in handle and out handle
pub fn new(anchor: DVec2, in_handle: Option<DVec2>, out_handle: Option<DVec2>) -> Self {
let id = PointId::generate();
Self { anchor, in_handle, out_handle, id }
}
/// Apply a transformation to all of the [ManipulatorGroup] points
pub fn apply_transform(&mut self, affine_transform: DAffine2) {
self.anchor = affine_transform.transform_point2(self.anchor);
self.in_handle = self.in_handle.map(|in_handle| affine_transform.transform_point2(in_handle));
self.out_handle = self.out_handle.map(|out_handle| affine_transform.transform_point2(out_handle));
}
/// Are all handles at finite positions
pub fn is_finite(&self) -> bool {
self.anchor.is_finite() && self.in_handle.is_none_or(|handle| handle.is_finite()) && self.out_handle.is_none_or(|handle| handle.is_finite())
}
}
/// Representation of the handle point(s) in a bezier segment.
#[derive(Copy, Clone, PartialEq, Debug, graphene_hash::CacheHash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum BezierHandles {
Linear,
/// Handles for a quadratic curve.
Quadratic {
/// Point representing the location of the single handle.
handle: DVec2,
},
/// Handles for a cubic curve.
Cubic {
/// Point representing the location of the handle associated to the start point.
handle_start: DVec2,
/// Point representing the location of the handle associated to the end point.
handle_end: DVec2,
},
}
impl BezierHandles {
pub fn is_finite(&self) -> bool {
match self {
BezierHandles::Linear => true,
BezierHandles::Quadratic { handle } => handle.is_finite(),
BezierHandles::Cubic { handle_start, handle_end } => handle_start.is_finite() && handle_end.is_finite(),
}
}
/// Get the coordinates of the bezier segment's first handle point. This represents the only handle in a quadratic segment.
pub fn start(&self) -> Option<DVec2> {
match *self {
BezierHandles::Cubic { handle_start, .. } | BezierHandles::Quadratic { handle: handle_start } => Some(handle_start),
_ => None,
}
}
/// Get the coordinates of the second handle point. This will return `None` for a quadratic segment.
pub fn end(&self) -> Option<DVec2> {
match *self {
BezierHandles::Cubic { handle_end, .. } => Some(handle_end),
_ => None,
}
}
pub fn move_start(&mut self, delta: DVec2) {
if let BezierHandles::Cubic { handle_start, .. } | BezierHandles::Quadratic { handle: handle_start } = self {
*handle_start += delta
}
}
pub fn move_end(&mut self, delta: DVec2) {
if let BezierHandles::Cubic { handle_end, .. } = self {
*handle_end += delta
}
}
/// Returns a Bezier curve that results from applying the transformation function to each handle point in the Bezier.
#[must_use]
pub fn apply_transformation(&self, transformation_function: impl Fn(DVec2) -> DVec2) -> Self {
match *self {
BezierHandles::Linear => Self::Linear,
BezierHandles::Quadratic { handle } => {
let handle = transformation_function(handle);
Self::Quadratic { handle }
}
BezierHandles::Cubic { handle_start, handle_end } => {
let handle_start = transformation_function(handle_start);
let handle_end = transformation_function(handle_end);
Self::Cubic { handle_start, handle_end }
}
}
}
#[must_use]
pub fn reversed(self) -> Self {
match self {
BezierHandles::Cubic { handle_start, handle_end } => Self::Cubic {
handle_start: handle_end,
handle_end: handle_start,
},
_ => self,
}
}
}
pub struct PathSegPoints {
pub p0: DVec2,
pub p1: Option<DVec2>,
pub p2: Option<DVec2>,
pub p3: DVec2,
}
impl PathSegPoints {
pub fn new(p0: DVec2, p1: Option<DVec2>, p2: Option<DVec2>, p3: DVec2) -> Self {
Self { p0, p1, p2, p3 }
}
}
pub fn pathseg_points(segment: PathSeg) -> PathSegPoints {
match segment {
PathSeg::Line(line) => PathSegPoints::new(point_to_dvec2(line.p0), None, None, point_to_dvec2(line.p1)),
PathSeg::Quad(quad) => PathSegPoints::new(point_to_dvec2(quad.p0), None, Some(point_to_dvec2(quad.p1)), point_to_dvec2(quad.p2)),
PathSeg::Cubic(cube) => PathSegPoints::new(point_to_dvec2(cube.p0), Some(point_to_dvec2(cube.p1)), Some(point_to_dvec2(cube.p2)), point_to_dvec2(cube.p3)),
}
}

View File

@@ -3,72 +3,71 @@
pub use crate::gradient::*;
use core_types::Color;
use core_types::color::SRGBA8;
use core_types::list::{Item, List};
use core_types::transform::Transform;
use dyn_any::DynAny;
use glam::DAffine2;
use std::f64::consts::{PI, TAU};
/// Describes an editable fill choice, storing color or gradient stops without gradient placement metadata.
/// The paint picker's choice of fill, generic over color format: `FillChoice<Color>` is the editor's in-memory
/// form, while `FillChoice<SRGBA8>` is the JS-boundary shape used by the color picker UI. Stores a color or
/// gradient ramp without gradient placement metadata, and is not stored in documents: paint inputs hold the
/// picked value as a plain color, gradient, or no-paint type default.
///
/// Can be None, a solid [Color], or a linear/radial [GradientStops].
/// Can be None, a solid color, or the [`GradientRamp`] of a linear/radial gradient.
///
/// In the future we'll probably also add a pattern fill.
///
/// Use [`FillChoiceUI`] at the JS boundary.
#[repr(C)]
#[derive(Default, Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum FillChoice {
#[default]
None,
Solid(Color),
Gradient(GradientStops),
}
// TODO: Deprecate [`FillChoice`] and keep this, renamed, as the main widget-controlling type
/// JS-boundary version of [`FillChoice`] where the solid color is [`SRGBA8`] and the gradient is [`GradientStopsUI`].
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
#[derive(Default, Debug, Clone, PartialEq, DynAny)]
#[derive(Default, Debug, Clone, PartialEq, graphene_hash::CacheHash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum FillChoiceUI {
pub enum FillChoice<C = Color> {
#[default]
None,
Solid(SRGBA8),
Gradient(GradientStopsUI),
Solid(C),
Gradient(GradientRamp<C>),
}
impl From<&FillChoice> for FillChoiceUI {
unsafe impl<C: dyn_any::StaticTypeSized> dyn_any::StaticType for FillChoice<C> {
type Static = FillChoice<C::Static>;
}
impl From<&FillChoice> for FillChoice<SRGBA8> {
fn from(value: &FillChoice) -> Self {
match value {
FillChoice::None => Self::None,
FillChoice::Solid(color) => Self::Solid(SRGBA8::from(*color)),
FillChoice::Gradient(stops) => Self::Gradient(GradientStopsUI::from(stops)),
FillChoice::Gradient(ramp) => Self::Gradient(ramp.into()),
}
}
}
impl From<&FillChoiceUI> for FillChoice {
fn from(value: &FillChoiceUI) -> Self {
impl From<&FillChoice<SRGBA8>> for FillChoice {
fn from(value: &FillChoice<SRGBA8>) -> Self {
match value {
FillChoiceUI::None => Self::None,
FillChoiceUI::Solid(srgba) => Self::Solid(Color::from(*srgba)),
FillChoiceUI::Gradient(stops) => Self::Gradient(GradientStops::from(stops)),
FillChoice::None => Self::None,
FillChoice::Solid(srgba) => Self::Solid(Color::from(*srgba)),
FillChoice::Gradient(ramp) => Self::Gradient(ramp.into()),
}
}
}
impl FillChoiceUI {
pub fn as_solid(&self) -> Option<SRGBA8> {
let Self::Solid(c) = self else { return None };
Some(*c)
impl<C: Copy> FillChoice<C> {
pub fn as_solid(&self) -> Option<C> {
let Self::Solid(color) = self else { return None };
Some(*color)
}
}
pub fn as_gradient(&self) -> Option<&GradientStopsUI> {
let Self::Gradient(g) = self else { return None };
Some(g)
impl<C> FillChoice<C> {
pub fn as_gradient(&self) -> Option<&GradientRamp<C>> {
let Self::Gradient(ramp) = self else { return None };
Some(ramp)
}
}
/// Build a CSS `background-image` string (always a `linear-gradient(...)`) representing this fill, or `None` if the fill is [`FillChoiceUI::None`].
impl FillChoice<SRGBA8> {
/// Build a CSS `background-image` string representing this fill, or `None` if the fill is [`FillChoice::None`].
/// Solid colors become a degenerate gradient between the same color so the CSS variable can always be assigned to a `background-image`.
pub fn to_css_background_image(&self) -> Option<String> {
match self {
@@ -77,31 +76,7 @@ impl FillChoiceUI {
let hex = srgba.to_rgba_hex();
Some(format!("linear-gradient(#{hex}, #{hex})"))
}
Self::Gradient(stops) => Some(stops.to_css_linear_gradient()),
}
}
}
impl FillChoice {
pub fn as_solid(&self) -> Option<Color> {
let Self::Solid(color) = self else { return None };
Some(*color)
}
pub fn as_gradient(&self) -> Option<&GradientStops> {
let Self::Gradient(gradient) = self else { return None };
Some(gradient)
}
/// Build a CSS `background-image` string (always a `linear-gradient(...)`) representing this fill, or `None` if the fill is [`FillChoice::None`]. Solid colors become a degenerate gradient between the same color so the CSS variable can always be assigned to a `background-image`.
pub fn to_css_background_image(&self) -> Option<String> {
match self {
Self::None => None,
Self::Solid(color) => {
let hex = SRGBA8::from(*color).to_rgba_hex();
Some(format!("linear-gradient(#{hex}, #{hex})"))
}
Self::Gradient(stops) => Some(stops.to_css_linear_gradient()),
Self::Gradient(ramp) => Some(ramp.stops.to_svg_background_image(ramp.into())),
}
}
}
@@ -116,18 +91,18 @@ pub enum StrokeCap {
#[default]
#[icon("StrokeCapButt")]
Butt,
#[icon("StrokeCapRound")]
Round,
#[icon("StrokeCapSquare")]
Square,
#[icon("StrokeCapRound")]
Round,
}
impl StrokeCap {
pub fn svg_name(&self) -> &'static str {
match self {
StrokeCap::Butt => "butt",
StrokeCap::Round => "round",
StrokeCap::Square => "square",
StrokeCap::Round => "round",
}
}
}
@@ -178,6 +153,8 @@ impl StrokeAlign {
}
}
// Backs the control bar's stroke popover radio and legacy document parsing: the relative order
// of the Fill and Stroke nodes in the chain is what actually determines the paint order
#[repr(C)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, graphene_hash::CacheHash, DynAny, node_macro::ChoiceType)]
@@ -201,6 +178,45 @@ fn daffine2_identity() -> DAffine2 {
DAffine2::IDENTITY
}
/// A stroke's dash pattern: a sequence of lengths that alternate dash, gap, dash, gap, and so on. An odd-length
/// sequence repeats with the dash and gap roles swapped.
///
/// Wraps a `List<f64>` so the Data panel can introspect its lengths, mirroring how `Artboard` wraps a `List<Graphic>`,
/// while remaining a single rank-0 value on the wire.
#[derive(Default, Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)]
pub struct DashPattern(pub List<f64>);
impl DashPattern {
/// Returns the dash lengths with any negative values clamped to zero.
pub fn clamped_lengths(&self) -> Vec<f64> {
self.0.iter_element_values().map(|length| length.max(0.)).collect()
}
}
impl From<f64> for DashPattern {
fn from(length: f64) -> Self {
Self(List::new_from_element(length))
}
}
impl From<Vec<f64>> for DashPattern {
fn from(lengths: Vec<f64>) -> Self {
Self(lengths.into_iter().map(Item::new_from_element).collect())
}
}
impl From<&str> for DashPattern {
fn from(text: &str) -> Self {
Self::from(core_types::misc::parse_f64_list(text))
}
}
impl From<String> for DashPattern {
fn from(text: String) -> Self {
Self::from(text.as_str())
}
}
#[repr(C)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)]
@@ -221,8 +237,6 @@ pub struct Stroke {
pub align: StrokeAlign,
#[cfg_attr(feature = "serde", serde(default = "daffine2_identity"))]
pub transform: DAffine2,
#[cfg_attr(feature = "serde", serde(default))]
pub paint_order: PaintOrder,
}
impl Stroke {
@@ -236,7 +250,6 @@ impl Stroke {
join_miter_limit: 4.,
align: StrokeAlign::Center,
transform: DAffine2::IDENTITY,
paint_order: PaintOrder::StrokeAbove,
}
}
@@ -273,7 +286,6 @@ impl Stroke {
let skew = DAffine2::from_cols_array(&[1., 0., lerp(s_skew, t_skew), 1., 0., 0.]);
trs * skew
},
paint_order: if time < 0.5 { self.paint_order } else { other.paint_order },
}
}
@@ -283,7 +295,7 @@ impl Stroke {
}
/// Get the effective stroke weight.
pub fn effective_width(&self) -> f64 {
pub(crate) fn effective_width(&self) -> f64 {
self.weight
* match self.align {
StrokeAlign::Center => 1.,
@@ -330,14 +342,6 @@ impl Stroke {
self.dash_offset
}
pub fn cap_index(&self) -> u32 {
self.cap as u32
}
pub fn join_index(&self) -> u32 {
self.join as u32
}
pub fn join_miter_limit(&self) -> f32 {
self.join_miter_limit as f32
}
@@ -347,44 +351,6 @@ impl Stroke {
self
}
pub fn with_dash_lengths(mut self, dash_lengths: &str) -> Option<Self> {
dash_lengths
.split(&[',', ' '])
.filter(|x| !x.is_empty())
.map(str::parse::<f64>)
.collect::<Result<Vec<_>, _>>()
.ok()
.map(|lengths| {
self.dash_lengths = lengths;
self
})
}
pub fn with_dash_offset(mut self, dash_offset: f64) -> Self {
self.dash_offset = dash_offset;
self
}
pub fn with_stroke_cap(mut self, stroke_cap: StrokeCap) -> Self {
self.cap = stroke_cap;
self
}
pub fn with_stroke_join(mut self, stroke_join: StrokeJoin) -> Self {
self.join = stroke_join;
self
}
pub fn with_stroke_join_miter_limit(mut self, limit: f64) -> Self {
self.join_miter_limit = limit;
self
}
pub fn with_stroke_align(mut self, stroke_align: StrokeAlign) -> Self {
self.align = stroke_align;
self
}
pub fn has_renderable_stroke(&self) -> bool {
self.weight > 0.
}
@@ -401,7 +367,6 @@ impl Default for Stroke {
join_miter_limit: 4.,
align: StrokeAlign::Center,
transform: DAffine2::IDENTITY,
paint_order: PaintOrder::default(),
}
}
}

View File

@@ -1,10 +1,10 @@
use super::*;
use crate::subpath::BezierHandles;
use crate::vector::misc::{HandleId, HandleType, point_to_dvec2};
use crate::vector::misc::BezierHandles;
use crate::vector::misc::{HandleId, HandleType, point_to_dvec2, segment_to_handles};
use core_types::uuid::generate_uuid;
use dyn_any::DynAny;
use glam::DVec2;
use kurbo::{BezPath, PathEl, Point};
use kurbo::{BezPath, ParamCurve, PathEl, Point};
use serde::de::{SeqAccess, Visitor};
use serde::ser::SerializeSeq;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
@@ -16,7 +16,7 @@ use std::hash::Hash;
/// Represents a procedural change to the [`PointDomain`] in [`Vector`].
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PointModification {
pub(crate) struct PointModification {
add: Vec<PointId>,
#[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashset"))]
remove: HashSet<PointId>,
@@ -83,7 +83,7 @@ impl PointModification {
/// Represents a procedural change to the [`SegmentDomain`] in [`Vector`].
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct SegmentModification {
pub(crate) struct SegmentModification {
add: Vec<SegmentId>,
#[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashset"))]
remove: HashSet<SegmentId>,
@@ -95,8 +95,6 @@ pub struct SegmentModification {
handle_primary: HashMap<SegmentId, Option<DVec2>>,
#[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashmap", deserialize_with = "deserialize_hashmap"))]
handle_end: HashMap<SegmentId, Option<DVec2>>,
#[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashmap", deserialize_with = "deserialize_hashmap"))]
stroke: HashMap<SegmentId, StrokeId>,
}
impl SegmentModification {
@@ -167,17 +165,11 @@ impl SegmentModification {
};
}
for (id, stroke) in segment_domain.stroke_mut() {
let Some(&new) = self.stroke.get(&id) else { continue };
*stroke = new;
}
for &add_id in &self.add {
let Some(&start) = self.start_point.get(&add_id) else { continue };
let Some(&end) = self.end_point.get(&add_id) else { continue };
let Some(&handle_start) = self.handle_primary.get(&add_id) else { continue };
let Some(&handle_end) = self.handle_end.get(&add_id) else { continue };
let Some(&stroke) = self.stroke.get(&add_id) else { continue };
let Some(start_index) = point_domain.resolve_id(start) else {
warn!("invalid start id: {start:#?}");
@@ -204,7 +196,7 @@ impl SegmentModification {
continue;
}
segment_domain.push(add_id, start_index, end_index, handles, stroke);
segment_domain.push(add_id, start_index, end_index, handles);
}
assert!(
@@ -225,20 +217,24 @@ impl SegmentModification {
remove: HashSet::new(),
start_point: vector.segment_domain.ids().iter().zip(vector.segment_domain.start_point()).map(point_id).collect(),
end_point: vector.segment_domain.ids().iter().zip(vector.segment_domain.end_point()).map(point_id).collect(),
handle_primary: vector.segment_bezier_iter().map(|(id, b, _, _)| (id, b.handle_start().map(|handle| handle - b.start))).collect(),
handle_end: vector.segment_bezier_iter().map(|(id, b, _, _)| (id, b.handle_end().map(|handle| handle - b.end))).collect(),
stroke: vector.segment_domain.ids().iter().copied().zip(vector.segment_domain.stroke().iter().cloned()).collect(),
handle_primary: vector
.segment_iter()
.map(|(id, segment, _, _)| (id, segment_to_handles(&segment).start().map(|handle| handle - point_to_dvec2(segment.start()))))
.collect(),
handle_end: vector
.segment_iter()
.map(|(id, segment, _, _)| (id, segment_to_handles(&segment).end().map(|handle| handle - point_to_dvec2(segment.end()))))
.collect(),
}
}
fn push(&mut self, id: SegmentId, points: [PointId; 2], handles: [Option<DVec2>; 2], stroke: StrokeId) {
fn push(&mut self, id: SegmentId, points: [PointId; 2], handles: [Option<DVec2>; 2]) {
self.remove.remove(&id);
self.add.push(id);
self.start_point.insert(id, points[0]);
self.end_point.insert(id, points[1]);
self.handle_primary.insert(id, handles[0]);
self.handle_end.insert(id, handles[1]);
self.stroke.insert(id, stroke);
}
fn remove(&mut self, id: SegmentId) {
@@ -248,53 +244,6 @@ impl SegmentModification {
self.end_point.remove(&id);
self.handle_primary.remove(&id);
self.handle_end.remove(&id);
self.stroke.remove(&id);
}
}
/// Represents a procedural change to the [`RegionDomain`] in [`Vector`].
#[derive(Clone, Debug, Default, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct RegionModification {
add: Vec<RegionId>,
#[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashset"))]
remove: HashSet<RegionId>,
#[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashmap", deserialize_with = "deserialize_hashmap"))]
segment_range: HashMap<RegionId, std::ops::RangeInclusive<SegmentId>>,
#[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashmap", deserialize_with = "deserialize_hashmap"))]
fill: HashMap<RegionId, FillId>,
}
impl RegionModification {
/// Apply this modification to the specified [`RegionDomain`].
pub fn apply(&self, region_domain: &mut RegionDomain) {
region_domain.retain(|id| !self.remove.contains(id));
for (id, segment_range) in region_domain.segment_range_mut() {
let Some(new) = self.segment_range.get(&id) else { continue };
*segment_range = new.clone(); // Range inclusive is not copy
}
for (id, fill) in region_domain.fill_mut() {
let Some(&new) = self.fill.get(&id) else { continue };
*fill = new;
}
for &add_id in &self.add {
let Some(segment_range) = self.segment_range.get(&add_id) else { continue };
let Some(&fill) = self.fill.get(&add_id) else { continue };
region_domain.push(add_id, segment_range.clone(), fill);
}
}
/// Create a new modification that will convert an empty [`Vector`] into the target [`Vector`].
pub fn create_from_vector(vector: &Vector) -> Self {
Self {
add: vector.region_domain.ids().to_vec(),
remove: HashSet::new(),
segment_range: vector.region_domain.ids().iter().copied().zip(vector.region_domain.segment_range().iter().cloned()).collect(),
fill: vector.region_domain.ids().iter().copied().zip(vector.region_domain.fill().iter().cloned()).collect(),
}
}
}
@@ -304,7 +253,6 @@ impl RegionModification {
pub struct VectorModification {
points: PointModification,
segments: SegmentModification,
regions: RegionModification,
#[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashset"))]
add_g1_continuous: HashSet<[HandleId; 2]>,
#[cfg_attr(feature = "serde", serde(serialize_with = "serialize_hashset"))]
@@ -337,7 +285,6 @@ pub enum VectorModificationType {
struct ModificationCategoryCounts {
points: [usize; 3],
segments: [usize; 3],
regions: [usize; 3],
smooth_handles: [usize; 3],
}
@@ -345,7 +292,7 @@ impl ModificationCategoryCounts {
/// Returns the `[added, removed, modified]` totals across all categories.
fn totals(&self) -> [usize; 3] {
let mut totals = [0; 3];
for [a, r, m] in [self.points, self.segments, self.regions, self.smooth_handles] {
for [a, r, m] in [self.points, self.segments, self.smooth_handles] {
totals[0] += a;
totals[1] += r;
totals[2] += m;
@@ -355,7 +302,7 @@ impl ModificationCategoryCounts {
/// Iterates over each named category and its `[added, removed, modified]` counts.
fn iter_categories(&self) -> impl Iterator<Item = (&str, [usize; 3])> {
[("Points", self.points), ("Segments", self.segments), ("Regions", self.regions), ("Smooth Handles", self.smooth_handles)].into_iter()
[("Points", self.points), ("Segments", self.segments), ("Smooth Handles", self.smooth_handles)].into_iter()
}
}
@@ -365,7 +312,6 @@ impl VectorModification {
// Build sets of added IDs so we can distinguish true modifications from initial values stored for newly added items
let add_points: HashSet<_> = self.points.add.iter().copied().collect();
let add_segments: HashSet<_> = self.segments.add.iter().copied().collect();
let add_regions: HashSet<_> = self.regions.add.iter().copied().collect();
let point_modifications = self.points.delta.keys().filter(|id| !add_points.contains(id)).count();
@@ -376,18 +322,10 @@ impl VectorModification {
modified_segments.extend(self.segments.end_point.keys().filter(not_added_segment));
modified_segments.extend(self.segments.handle_primary.keys().filter(not_added_segment));
modified_segments.extend(self.segments.handle_end.keys().filter(not_added_segment));
modified_segments.extend(self.segments.stroke.keys().filter(not_added_segment));
// Count unique modified region IDs across all field maps
let mut modified_regions: HashSet<&RegionId> = HashSet::with_capacity(self.regions.segment_range.len());
let not_added_region = |id: &&RegionId| !add_regions.contains(id);
modified_regions.extend(self.regions.segment_range.keys().filter(not_added_region));
modified_regions.extend(self.regions.fill.keys().filter(not_added_region));
ModificationCategoryCounts {
points: [self.points.add.len(), self.points.remove.len(), point_modifications],
segments: [self.segments.add.len(), self.segments.remove.len(), modified_segments.len()],
regions: [self.regions.add.len(), self.regions.remove.len(), modified_regions.len()],
smooth_handles: [self.add_g1_continuous.len(), self.remove_g1_continuous.len(), 0],
}
}
@@ -439,7 +377,6 @@ impl VectorModification {
pub fn apply(&self, vector: &mut Vector) {
self.points.apply(&mut vector.point_domain, &mut vector.segment_domain);
self.segments.apply(&mut vector.segment_domain, &vector.point_domain);
self.regions.apply(&mut vector.region_domain);
let valid = |val: &[HandleId; 2]| vector.segment_domain.ids().contains(&val[0].segment) && vector.segment_domain.ids().contains(&val[1].segment);
vector
@@ -456,7 +393,7 @@ impl VectorModification {
/// Add a [`VectorModificationType`] to this modification.
pub fn modify(&mut self, vector_modification: &VectorModificationType) {
match vector_modification {
VectorModificationType::InsertSegment { id, points, handles } => self.segments.push(*id, *points, *handles, StrokeId::ZERO),
VectorModificationType::InsertSegment { id, points, handles } => self.segments.push(*id, *points, *handles),
VectorModificationType::InsertPoint { id, position } => self.points.push(*id, *position),
VectorModificationType::RemoveSegment { id } => self.segments.remove(*id),
@@ -513,7 +450,6 @@ impl VectorModification {
Self {
points: PointModification::create_from_vector(vector),
segments: SegmentModification::create_from_vector(vector),
regions: RegionModification::create_from_vector(vector),
add_g1_continuous: vector.colinear_manipulators.iter().copied().collect(),
remove_g1_continuous: HashSet::new(),
}
@@ -573,7 +509,7 @@ where
}
/// Serializes as sorted `[value, ...]` (JSON array)
pub fn serialize_hashset<T, S, H>(set: &HashSet<T, H>, serializer: S) -> Result<S::Ok, S::Error>
pub(crate) fn serialize_hashset<T, S, H>(set: &HashSet<T, H>, serializer: S) -> Result<S::Ok, S::Error>
where
T: Serialize + Eq + Hash + Ord,
S: Serializer,
@@ -629,13 +565,15 @@ where
deserializer.deserialize_seq(visitor)
}
pub struct AppendBezpath<'a> {
/// Distance below which a path's closing point is treated as coincident with its start point.
/// Matches Kurbo's default path accuracy, so points within an offset operation's own precision are not split into separate anchors.
const CLOSE_POINT_TOLERANCE: f64 = 1e-6;
pub(crate) struct AppendBezpath<'a> {
first_point: Option<Point>,
last_point: Option<Point>,
first_point_index: Option<usize>,
last_point_index: Option<usize>,
first_segment_id: Option<SegmentId>,
last_segment_id: Option<SegmentId>,
point_id: PointId,
segment_id: SegmentId,
vector: &'a mut Vector,
@@ -648,8 +586,6 @@ impl<'a> AppendBezpath<'a> {
last_point: None,
first_point_index: None,
last_point_index: None,
first_segment_id: None,
last_segment_id: None,
point_id: vector.point_domain.next_id(),
segment_id: vector.segment_domain.next_id(),
vector,
@@ -657,7 +593,11 @@ impl<'a> AppendBezpath<'a> {
}
fn append_segment_and_close_path(&mut self, point: Point, handle: BezierHandles) {
let handle = if self.first_point.unwrap() != point {
// A path's final point may return to approximately (but not bit-exactly) its start before closing, e.g. a contour
// produced by Kurbo's path offsetting. Treat a near-coincident final point as already on the start so we don't
// introduce a redundant duplicate anchor and a zero-length closing segment.
let endpoints_coincide = (self.first_point.unwrap() - point).hypot2() <= CLOSE_POINT_TOLERANCE * CLOSE_POINT_TOLERANCE;
let handle = if !endpoints_coincide {
// If the first point is not the same as the last point of the path then we append the segment
// with given handle and point and then close the path with linear handle.
self.append_segment(point, handle);
@@ -671,14 +611,7 @@ impl<'a> AppendBezpath<'a> {
let next_segment_id = self.segment_id.next_id();
self.vector
.segment_domain
.push(next_segment_id, self.last_point_index.unwrap(), self.first_point_index.unwrap(), handle, StrokeId::ZERO);
// Create a new region.
let next_region_id = self.vector.region_domain.next_id();
let first_segment_id = self.first_segment_id.unwrap_or(next_segment_id);
let last_segment_id = next_segment_id;
self.vector.region_domain.push(next_region_id, first_segment_id..=last_segment_id, FillId::ZERO);
.push(next_segment_id, self.last_point_index.unwrap(), self.first_point_index.unwrap(), handle);
}
fn append_segment(&mut self, end_point: Point, handle: BezierHandles) {
@@ -690,16 +623,11 @@ impl<'a> AppendBezpath<'a> {
// Append the segment.
let next_segment_id = self.segment_id.next_id();
self.vector
.segment_domain
.push(next_segment_id, self.last_point_index.unwrap(), next_point_index, handle, StrokeId::ZERO);
self.vector.segment_domain.push(next_segment_id, self.last_point_index.unwrap(), next_point_index, handle);
// Update the states.
self.last_point = Some(end_point);
self.last_point_index = Some(next_point_index);
self.first_segment_id = Some(self.first_segment_id.unwrap_or(next_segment_id));
self.last_segment_id = Some(next_segment_id);
}
fn append_first_point(&mut self, point: Point) {
@@ -720,8 +648,6 @@ impl<'a> AppendBezpath<'a> {
self.last_point = None;
self.first_point_index = None;
self.last_point_index = None;
self.first_segment_id = None;
self.last_segment_id = None;
}
pub fn append_bezpath(vector: &'a mut Vector, bezpath: BezPath) {
@@ -732,7 +658,11 @@ impl<'a> AppendBezpath<'a> {
let close_path = elements.peek().is_some_and(|elm| **elm == PathEl::ClosePath);
match *element {
PathEl::MoveTo(point) => this.append_first_point(point),
PathEl::MoveTo(point) => {
// Clear any segment state left by a preceding open contour so its segments don't leak into this contour's region
this.reset();
this.append_first_point(point);
}
PathEl::LineTo(point) => {
let handle = BezierHandles::Linear;
if close_path {
@@ -798,15 +728,15 @@ impl HandleExt for HandleId {
#[cfg(test)]
mod tests {
use kurbo::{PathSeg, QuadBez};
use super::*;
use crate::subpath::{Bezier, Subpath};
use crate::vector::algorithms::shapes::{ellipse_bezpath, rectangle_bezpath};
use kurbo::{PathSeg, QuadBez};
#[test]
fn modify_new() {
let vector: Vector = Vector::from_subpaths([Subpath::new_ellipse(DVec2::ZERO, DVec2::ONE), Subpath::new_rectangle(DVec2::NEG_ONE, DVec2::ZERO)], false);
let mut vector = Vector::from_bezpath(ellipse_bezpath(DVec2::ZERO, DVec2::ONE));
vector.append_bezpath(rectangle_bezpath(DVec2::NEG_ONE, DVec2::ZERO));
let modify = VectorModification::create_from_vector(&vector);
@@ -817,18 +747,14 @@ mod tests {
#[test]
fn modify_existing() {
let subpaths = [
Subpath::new_ellipse(DVec2::ZERO, DVec2::ONE),
Subpath::new_rectangle(DVec2::NEG_ONE, DVec2::ZERO),
Subpath::from_beziers(
&[
PathSeg::Quad(QuadBez::new(Point::new(0., 0.), Point::new(5., 10.), Point::new(10., 0.))),
PathSeg::Quad(QuadBez::new(Point::new(10., 0.), Point::new(15., 10.), Point::new(20., 0.))),
],
false,
),
];
let mut vector: Vector = Vector::from_subpaths(subpaths, false);
let mut open_quads = BezPath::new();
open_quads.move_to(Point::new(0., 0.));
open_quads.quad_to(Point::new(5., 10.), Point::new(10., 0.));
open_quads.quad_to(Point::new(15., 10.), Point::new(20., 0.));
let mut vector = Vector::from_bezpath(ellipse_bezpath(DVec2::ZERO, DVec2::ONE));
vector.append_bezpath(rectangle_bezpath(DVec2::NEG_ONE, DVec2::ZERO));
vector.append_bezpath(open_quads);
let mut modify_new = VectorModification::create_from_vector(&vector);
let mut modify_original = VectorModification::default();
@@ -849,12 +775,12 @@ mod tests {
assert_eq!(vector.point_domain.positions()[0], DVec2::X);
assert_eq!(vector.point_domain.positions()[9], DVec2::new(11., 0.));
assert_eq!(
vector.segment_bezier_iter().nth(8).unwrap().1,
Bezier::from_quadratic_dvec2(DVec2::new(0., 0.), DVec2::new(5., 10.), DVec2::new(11., 0.))
vector.segment_iter().nth(8).unwrap().1,
PathSeg::Quad(QuadBez::new(Point::new(0., 0.), Point::new(5., 10.), Point::new(11., 0.)))
);
assert_eq!(
vector.segment_bezier_iter().nth(9).unwrap().1,
Bezier::from_quadratic_dvec2(DVec2::new(11., 0.), DVec2::new(16., 10.), DVec2::new(20., 0.))
vector.segment_iter().nth(9).unwrap().1,
PathSeg::Quad(QuadBez::new(Point::new(11., 0.), Point::new(16., 10.), Point::new(20., 0.)))
);
}
}

View File

@@ -1,14 +1,11 @@
use super::misc::dvec2_to_point;
use super::style::{Stroke, StrokeAlign, StrokeCap, StrokeJoin};
pub use super::vector_attributes::*;
use crate::subpath::{BezierHandles, ManipulatorGroup, Subpath};
use crate::vector::click_target::{ClickTargetType, FreePoint};
use super::vector_attributes::*;
use crate::vector::misc::{BezierHandles, ManipulatorGroup};
use crate::vector::misc::{HandleId, ManipulatorPointId};
use crate::vector::vector_modification::VectorExt;
use core::borrow::Borrow;
use core_types::bounds::{BoundingBox, RenderBoundingBox};
use core_types::render_complexity::RenderComplexity;
use core_types::transform::Transform;
use dyn_any::StaticType;
use glam::{DAffine2, DVec2};
use kurbo::{Affine, BezPath, Rect, Shape};
@@ -18,15 +15,12 @@ use std::collections::HashMap;
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Vector {
pub stroke: Option<Stroke>,
/// A list of all manipulator groups (referenced in `subpaths`) that have colinear handles (where they're locked at 180° angles from one another).
/// This gets read in `graph_operation_message_handler.rs` by calling `inputs.as_mut_slice()` (search for the string `"Shape does not have both `subpath` and `colinear_manipulators` inputs"` to find it).
pub colinear_manipulators: Vec<[HandleId; 2]>,
pub point_domain: PointDomain,
pub segment_domain: SegmentDomain,
pub region_domain: RegionDomain,
}
unsafe impl StaticType for Vector {
type Static = Self;
@@ -35,11 +29,9 @@ unsafe impl StaticType for Vector {
impl Default for Vector {
fn default() -> Self {
Self {
stroke: Some(Stroke::new(0.)),
colinear_manipulators: Vec::new(),
point_domain: PointDomain::new(),
segment_domain: SegmentDomain::new(),
region_domain: RegionDomain::new(),
}
}
}
@@ -48,8 +40,6 @@ impl graphene_hash::CacheHash for Vector {
fn cache_hash<H: core::hash::Hasher>(&self, state: &mut H) {
self.point_domain.cache_hash(state);
self.segment_domain.cache_hash(state);
self.region_domain.cache_hash(state);
self.stroke.cache_hash(state);
self.colinear_manipulators.cache_hash(state);
}
}
@@ -63,33 +53,38 @@ impl core_types::ops::FromAnchorPosition for Vector {
}
}
// Identity item conversion so `List<Vector>` satisfies the blanket `Convert<List<U>, ()> for List<T>`, letting its
// auto-inserted input wrapper be a `ConvertNode` (which also accepts a `DVec2` anchor position) rather than an `IntoNode`.
impl core_types::ops::ListConvert<Vector> for Vector {
fn convert_item(self) -> Vector {
self
// Lets a position wire feed a ranked vector connector through the input adapter's element conversion
impl From<DVec2> for Vector {
fn from(position: DVec2) -> Self {
<Self as core_types::ops::FromAnchorPosition>::from_anchor_position(position)
}
}
impl core_types::transform::BakeTransform for Vector {
fn bake_transform(&mut self, transform: &glam::DAffine2) {
for (_, point) in self.point_domain.positions_mut() {
*point = transform.transform_point2(*point);
}
self.segment_domain.transform(*transform);
}
}
impl Vector {
/// Add a subpath to this vector path.
pub fn append_subpath(&mut self, subpath: impl Borrow<Subpath<PointId>>, preserve_id: bool) {
let subpath: &Subpath<PointId> = subpath.borrow();
let stroke_id = StrokeId::ZERO;
/// Add a path of manipulator groups to this vector path.
pub fn append_manipulator_groups(&mut self, manipulator_groups: &[ManipulatorGroup], closed: bool, preserve_id: bool) {
let mut point_id = self.point_domain.next_id();
let handles = |a: &ManipulatorGroup<_>, b: &ManipulatorGroup<_>| match (a.out_handle, b.in_handle) {
let handles = |a: &ManipulatorGroup, b: &ManipulatorGroup| match (a.out_handle, b.in_handle) {
(None, None) => BezierHandles::Linear,
(Some(handle), None) | (None, Some(handle)) => BezierHandles::Quadratic { handle },
(Some(handle_start), Some(handle_end)) => BezierHandles::Cubic { handle_start, handle_end },
};
let [mut first_seg, mut last_seg] = [None, None];
let mut segment_id = self.segment_domain.next_id();
let mut last_point = None;
let mut first_point = None;
// Construct a bezier segment from the two manipulators on the subpath.
for pair in subpath.manipulator_groups().windows(2) {
for pair in manipulator_groups.windows(2) {
let start = last_point.unwrap_or_else(|| {
let id = if preserve_id && !self.point_domain.ids().contains(&pair[0].id) {
pair[0].id
@@ -109,46 +104,17 @@ impl Vector {
self.point_domain.push(end, pair[1].anchor);
let id = segment_id.next_id();
first_seg = Some(first_seg.unwrap_or(id));
last_seg = Some(id);
self.segment_domain.push(id, start, end_index, handles(&pair[0], &pair[1]), stroke_id);
self.segment_domain.push(id, start, end_index, handles(&pair[0], &pair[1]));
last_point = Some(end_index);
}
let fill_id = FillId::ZERO;
if subpath.closed() {
if let (Some(last), Some(first), Some(first_id), Some(last_id)) = (subpath.manipulator_groups().last(), subpath.manipulator_groups().first(), first_point, last_point) {
let id = segment_id.next_id();
first_seg = Some(first_seg.unwrap_or(id));
last_seg = Some(id);
self.segment_domain.push(id, last_id, first_id, handles(last, first), stroke_id);
}
if let [Some(first_seg), Some(last_seg)] = [first_seg, last_seg] {
self.region_domain.push(self.region_domain.next_id(), first_seg..=last_seg, fill_id);
}
if closed && let (Some(last), Some(first), Some(first_id), Some(last_id)) = (manipulator_groups.last(), manipulator_groups.first(), first_point, last_point) {
let id = segment_id.next_id();
self.segment_domain.push(id, last_id, first_id, handles(last, first));
}
}
pub fn append_free_point(&mut self, point: &FreePoint, preserve_id: bool) {
let mut point_id = self.point_domain.next_id();
// Use the current point ID if it's not already in the domain, otherwise generate a new one
let id = if preserve_id && !self.point_domain.ids().contains(&point.id) {
point.id
} else {
point_id.next_id()
};
self.point_domain.push(id, point.position);
}
/// Construct some new vector path from a single subpath with an identity transform and black fill.
pub fn from_subpath(subpath: impl Borrow<Subpath<PointId>>) -> Self {
Self::from_subpaths([subpath], false)
}
/// Construct some new vector path from a single [`BezPath`] with an identity transform and black fill.
pub fn from_bezpath(bezpath: BezPath) -> Self {
let mut vector = Self::default();
@@ -156,35 +122,6 @@ impl Vector {
vector
}
/// Construct some new vector path from subpaths with an identity transform and black fill.
pub fn from_subpaths(subpaths: impl IntoIterator<Item = impl Borrow<Subpath<PointId>>>, preserve_id: bool) -> Self {
let mut vector = Self::default();
for subpath in subpaths.into_iter() {
vector.append_subpath(subpath, preserve_id);
}
vector
}
pub fn from_target_types(target_types: impl IntoIterator<Item = impl Borrow<ClickTargetType>>, preserve_id: bool) -> Self {
let mut vector = Self::default();
for target_type in target_types.into_iter() {
match target_type.borrow() {
ClickTargetType::Subpath(subpath) => vector.append_subpath(subpath, preserve_id),
ClickTargetType::FreePoint(point) => vector.append_free_point(point, preserve_id),
ClickTargetType::CompoundPath(subpaths) => {
for subpath in subpaths {
vector.append_subpath(subpath, preserve_id);
}
}
}
}
vector
}
/// Compute the bounding boxes of the bezpaths without any transform
pub fn bounding_box_rect(&self) -> Option<Rect> {
self.bounding_box_with_transform_rect(DAffine2::IDENTITY)
@@ -203,7 +140,7 @@ impl Vector {
for (start, end) in segments_to_add {
let segment_id = self.segment_domain.next_id().next_id();
self.segment_domain.push(segment_id, start, end, BezierHandles::Linear, StrokeId::ZERO);
self.segment_domain.push(segment_id, start, end, BezierHandles::Linear);
}
}
@@ -220,7 +157,7 @@ impl Vector {
}
/// Compute the bounding boxes of the bezpaths with the specified transform
pub fn bounding_box_with_transform_rect(&self, transform: DAffine2) -> Option<Rect> {
fn bounding_box_with_transform_rect(&self, transform: DAffine2) -> Option<Rect> {
let combine = |r1: Rect, r2: Rect| r1.union(r2);
self.stroke_bezpath_iter()
.map(|mut bezpath| {
@@ -239,13 +176,13 @@ impl Vector {
/// identity (`Inside` = 0, `Outside` = 2×weight): the renderer masks half of a centered double-width
/// stroke, so its AABB matches the unmasked centered stroke's. For open paths the renderer always
/// draws a centered `weight`-wide stroke regardless of the align attribute, so we mirror that here.
pub fn stroke_inclusive_bounding_box_with_transform(&self, transform: DAffine2) -> Option<[DVec2; 2]> {
pub fn stroke_inclusive_bounding_box_with_transform(&self, transform: DAffine2, stroke: Option<&Stroke>) -> Option<[DVec2; 2]> {
let path_bounds = self.bounding_box_with_transform(transform);
let Some(stroke) = self.stroke.as_ref() else { return path_bounds };
let Some(stroke) = stroke else { return path_bounds };
// Stroke alignment is only honored by the renderer when every subpath is closed; open paths fall
// back to drawing a Center-aligned `weight`-wide stroke. Match that behavior to keep bounds in sync.
let aligned_renders = stroke.align != StrokeAlign::Center && self.stroke_bezier_paths().all(|p| p.closed());
let aligned_renders = stroke.align != StrokeAlign::Center && self.stroke_bezpath_iter().all(|path| matches!(path.elements().last(), Some(kurbo::PathEl::ClosePath)));
let kurbo_width = if aligned_renders { stroke.effective_width() } else { stroke.weight };
// `Inside`-aligned strokes never expand beyond the path bounds; a zero-weight stroke is invisible
if kurbo_width <= 0. {
@@ -318,13 +255,6 @@ impl Vector {
[bounds_min, bounds_max]
}
/// Compute the pivot of the layer in layerspace (the coordinates of the subpaths)
pub fn layerspace_pivot(&self, normalized_pivot: DVec2) -> DVec2 {
let [bounds_min, bounds_max] = self.nonzero_bounding_box();
let bounds_size = bounds_max - bounds_min;
bounds_min + bounds_size * normalized_pivot
}
pub fn start_point(&self) -> impl Iterator<Item = PointId> + '_ {
self.segment_domain.start_point().iter().map(|&index| self.point_domain.ids()[index])
}
@@ -333,7 +263,7 @@ impl Vector {
self.segment_domain.end_point().iter().map(|&index| self.point_domain.ids()[index])
}
pub fn push(&mut self, id: SegmentId, start: PointId, end: PointId, handles: (Option<DVec2>, Option<DVec2>), stroke: StrokeId) {
pub fn push(&mut self, id: SegmentId, start: PointId, end: PointId, handles: (Option<DVec2>, Option<DVec2>)) {
let [Some(start), Some(end)] = [start, end].map(|id| self.point_domain.resolve_id(id)) else {
return;
};
@@ -342,7 +272,7 @@ impl Vector {
(None, Some(handle)) | (Some(handle), None) => BezierHandles::Quadratic { handle },
(Some(handle_start), Some(handle_end)) => BezierHandles::Cubic { handle_start, handle_end },
};
self.segment_domain.push(id, start, end, handles, stroke)
self.segment_domain.push(id, start, end, handles)
}
pub fn handles_mut(&mut self) -> impl Iterator<Item = (SegmentId, &mut BezierHandles, PointId, PointId)> {
@@ -359,11 +289,6 @@ impl Vector {
self.segment_domain.segment_end_from_id(segment).map(|index| self.point_domain.ids()[index])
}
/// Returns an array for the start and end points of a segment.
pub fn points_from_id(&self, segment: SegmentId) -> Option<[PointId; 2]> {
self.segment_domain.points_from_id(segment).map(|val| val.map(|index| self.point_domain.ids()[index]))
}
/// Attempts to find another point in the segment that is not the one passed in.
pub fn other_point(&self, segment: SegmentId, current: PointId) -> Option<PointId> {
let index = self.point_domain.resolve_id(current);
@@ -378,8 +303,8 @@ impl Vector {
/// Returns the number of linear segments connected to the given point.
pub fn connected_linear_segments(&self, point_id: PointId) -> usize {
self.segment_bezier_iter()
.filter(|(_, bez, start, end)| (*start == point_id || *end == point_id) && matches!(bez.handles, BezierHandles::Linear))
self.segment_iter()
.filter(|(_, segment, start, end)| (*start == point_id || *end == point_id) && matches!(segment, kurbo::PathSeg::Line(_)))
.count()
}
@@ -441,7 +366,15 @@ impl Vector {
/// Anchor points at the ends of open subpaths. These are points with exactly one connection by a segment to another anchor.
pub fn anchor_endpoints(&self) -> impl Iterator<Item = PointId> + '_ {
self.anchor_points().enumerate().filter(|&(index, _)| self.segment_domain.connected_count(index) == 1).map(|(_, id)| id)
// O(points + segments): tally every point's connections in a single pass
let mut connected_counts = vec![0_usize; self.point_domain.ids().len()];
for &point_index in self.segment_domain.start_point().iter().chain(self.segment_domain.end_point()) {
if let Some(count) = connected_counts.get_mut(point_index) {
*count += 1;
}
}
self.anchor_points().zip(connected_counts).filter(|&(_, count)| count == 1).map(|(id, _)| id)
}
/// Computes if all the connected handles are colinear for an anchor, or if that handle is colinear for a handle.
@@ -511,59 +444,25 @@ impl Vector {
.map(|&old| (old, old.generate_from_hash(collision_hash_seed)))
.collect::<HashMap<_, _>>();
let region_map = additional
.region_domain
.ids()
.iter()
.filter(|id| self.region_domain.ids().contains(id))
.map(|&old| (old, old.generate_from_hash(collision_hash_seed)))
.collect::<HashMap<_, _>>();
let id_map = IdMap {
point_offset: self.point_domain.ids().len(),
point_map,
segment_map,
region_map,
};
self.point_domain.concat(&additional.point_domain, transform_of_additional, &id_map);
self.segment_domain.concat(&additional.segment_domain, transform_of_additional, &id_map);
self.region_domain.concat(&additional.region_domain, transform_of_additional, &id_map);
// TODO: properly deal with fills such as gradients
self.stroke = additional.stroke.clone();
self.colinear_manipulators.extend(additional.colinear_manipulators.iter().copied());
}
pub fn set_stroke_transform(&mut self, transform: DAffine2) {
if let Some(stroke) = &mut self.stroke {
stroke.transform = transform;
}
}
}
// The element sees only geometry; stroke inflation is applied at the row level by `vector_list_bounding_box`
// in graphic-types, which can read the appearance attribute the stroke parameters live on.
impl BoundingBox for Vector {
fn bounding_box(&self, transform: DAffine2, include_stroke: bool) -> RenderBoundingBox {
if !include_stroke {
// Just use the path bounds without stroke
return match self.bounding_box_with_transform(transform) {
Some(bounds) => RenderBoundingBox::Rectangle(bounds),
None => RenderBoundingBox::None,
};
}
// Include stroke by adding offset based on stroke width
let stroke = self.stroke.clone();
let stroke_width = stroke.as_ref().map(|s| s.weight()).unwrap_or_default();
let miter_limit = stroke.as_ref().map(|s| s.join_miter_limit).unwrap_or(1.);
let scale = transform.scale_magnitudes();
// Use the full line width to account for different styles of stroke caps
let offset = DVec2::splat(stroke_width * scale.x.max(scale.y) * miter_limit);
fn bounding_box(&self, transform: DAffine2, _include_stroke: bool) -> RenderBoundingBox {
match self.bounding_box_with_transform(transform) {
Some([a, b]) => RenderBoundingBox::Rectangle([a - offset, b + offset]),
Some(bounds) => RenderBoundingBox::Rectangle(bounds),
None => RenderBoundingBox::None,
}
}
@@ -583,64 +482,68 @@ impl RenderComplexity for Vector {
#[cfg(test)]
mod tests {
use crate::vector::algorithms::shapes::ellipse_bezpath;
use kurbo::{CubicBez, PathSeg, Point};
use super::*;
fn assert_subpath_eq(generated: &[Subpath<PointId>], expected: &[Subpath<PointId>]) {
assert_eq!(generated.len(), expected.len());
for (generated, expected) in generated.iter().zip(expected) {
assert_eq!(generated.manipulator_groups().len(), expected.manipulator_groups().len());
assert_eq!(generated.closed(), expected.closed());
for (generated, expected) in generated.manipulator_groups().iter().zip(expected.manipulator_groups()) {
assert_eq!(generated.in_handle, expected.in_handle);
assert_eq!(generated.out_handle, expected.out_handle);
assert_eq!(generated.anchor, expected.anchor);
}
}
fn open_curve_bezpath() -> BezPath {
let mut bezpath = BezPath::new();
bezpath.move_to(Point::ZERO);
bezpath.curve_to(Point::new(-1., -1.), Point::new(1., 1.), Point::new(1., 0.));
bezpath
}
#[test]
fn construct_closed_subpath() {
let circle = Subpath::new_ellipse(DVec2::NEG_ONE, DVec2::ONE);
let vector: Vector = Vector::from_subpath(&circle);
fn construct_closed_path() {
let circle = ellipse_bezpath(DVec2::NEG_ONE, DVec2::ONE);
let vector = Vector::from_bezpath(circle.clone());
assert_eq!(vector.point_domain.ids().len(), 4);
let bezier_paths = vector.segment_iter().map(|(_, bezier, _, _)| bezier).collect::<Vec<_>>();
assert_eq!(bezier_paths.len(), 4);
assert!(bezier_paths.iter().all(|&bezier| circle.iter().any(|original_bezier| original_bezier == bezier)));
let generated = vector.stroke_bezier_paths().collect::<Vec<_>>();
assert_subpath_eq(&generated, &[circle]);
let segments = vector.segment_iter().map(|(_, bezier, _, _)| bezier).collect::<Vec<_>>();
assert_eq!(segments.len(), 4);
assert!(segments.iter().all(|&segment| circle.segments().any(|original| original == segment)));
let generated = vector.stroke_bezpath_iter().collect::<Vec<_>>();
assert_eq!(generated.len(), 1);
assert_eq!(generated[0].elements(), circle.elements());
}
#[test]
fn construct_open_subpath() {
let bezier = PathSeg::Cubic(CubicBez::new(Point::ZERO, Point::new(-1., -1.), Point::new(1., 1.), Point::new(1., 0.)));
let subpath = Subpath::from_bezier(bezier);
let vector: Vector = Vector::from_subpath(&subpath);
fn construct_open_path() {
let curve = open_curve_bezpath();
let vector = Vector::from_bezpath(curve.clone());
assert_eq!(vector.point_domain.ids().len(), 2);
let bezier_paths = vector.segment_iter().map(|(_, bezier, _, _)| bezier).collect::<Vec<_>>();
assert_eq!(bezier_paths, vec![bezier]);
let generated = vector.stroke_bezier_paths().collect::<Vec<_>>();
assert_subpath_eq(&generated, &[subpath]);
let segments = vector.segment_iter().map(|(_, bezier, _, _)| bezier).collect::<Vec<_>>();
assert_eq!(segments, vec![PathSeg::Cubic(CubicBez::new(Point::ZERO, Point::new(-1., -1.), Point::new(1., 1.), Point::new(1., 0.)))]);
let generated = vector.stroke_manipulator_groups().collect::<Vec<_>>();
assert_eq!(generated.len(), 1);
let (groups, closed) = &generated[0];
assert!(!closed);
assert_eq!(groups.len(), 2);
assert_eq!((groups[0].anchor, groups[0].in_handle, groups[0].out_handle), (DVec2::ZERO, None, Some(DVec2::new(-1., -1.))));
assert_eq!((groups[1].anchor, groups[1].in_handle, groups[1].out_handle), (DVec2::new(1., 0.), Some(DVec2::new(1., 1.)), None));
}
#[test]
fn construct_many_subpath() {
let curve = PathSeg::Cubic(CubicBez::new(Point::ZERO, Point::new(-1., -1.), Point::new(1., 1.), Point::new(1., 0.)));
let curve = Subpath::from_bezier(curve);
let circle = Subpath::new_ellipse(DVec2::NEG_ONE, DVec2::ONE);
fn construct_many_paths() {
let curve = open_curve_bezpath();
let circle = ellipse_bezpath(DVec2::NEG_ONE, DVec2::ONE);
let vector: Vector = Vector::from_subpaths([&curve, &circle], false);
let mut vector = Vector::from_bezpath(curve.clone());
vector.append_bezpath(circle.clone());
assert_eq!(vector.point_domain.ids().len(), 6);
let bezier_paths = vector.segment_iter().map(|(_, bezier, _, _)| bezier).collect::<Vec<_>>();
assert_eq!(bezier_paths.len(), 5);
assert!(bezier_paths.iter().all(|&bezier| circle.iter().chain(curve.iter()).any(|original_bezier| original_bezier == bezier)));
let segments = vector.segment_iter().map(|(_, bezier, _, _)| bezier).collect::<Vec<_>>();
assert_eq!(segments.len(), 5);
assert!(segments.iter().all(|&segment| circle.segments().chain(curve.segments()).any(|original| original == segment)));
let generated = vector.stroke_bezier_paths().collect::<Vec<_>>();
assert_subpath_eq(&generated, &[curve, circle]);
let generated = vector.stroke_bezpath_iter().collect::<Vec<_>>();
assert_eq!(generated.len(), 2);
assert_eq!(generated[0].elements(), curve.elements());
assert_eq!(generated[1].elements(), circle.elements());
}
// Verifies the `DVec2 -> List<Vector>` conversion that replaced the former "Vec2 to Point" node yields a path

View File

@@ -0,0 +1,34 @@
use std::ops::Deref;
use std::sync::Arc;
#[derive(Clone, Debug)]
pub struct Buffer(Arc<BufferInner>);
#[derive(Debug)]
struct BufferInner(wgpu::Buffer);
impl Drop for BufferInner {
fn drop(&mut self) {
self.0.destroy();
}
}
impl Deref for Buffer {
type Target = wgpu::Buffer;
fn deref(&self) -> &Self::Target {
&self.0.0
}
}
impl AsRef<wgpu::Buffer> for Buffer {
fn as_ref(&self) -> &wgpu::Buffer {
&self.0.0
}
}
impl From<wgpu::Buffer> for Buffer {
fn from(buffer: wgpu::Buffer) -> Self {
Self(Arc::new(BufferInner(buffer)))
}
}

View File

@@ -1,3 +1,4 @@
mod buffer;
mod context;
mod pipeline;
pub mod shader_runtime;
@@ -11,16 +12,18 @@ use core_types::Color;
use core_types::color::SRGBA8;
use glam::UVec2;
use graphene_application_io::{ApplicationIo, EditorApi};
use raster_types::Texture;
use std::sync::Arc;
use std::sync::Mutex;
use vello::{AaConfig, AaSupport, RenderParams, Renderer, RendererOptions, Scene};
use wgpu::util::DeviceExt;
use wgpu::{Origin3d, TextureAspect};
pub use buffer::Buffer;
pub use context::Context as WgpuContext;
pub use context::ContextBuilder as WgpuContextBuilder;
pub use pipeline::Pipeline as WgpuPipeline;
pub use pipeline::PipelineCache as WgpuPipelineCache;
pub use raster_types::Texture;
pub use rendering::RenderContext;
pub use wgpu::Backends as WgpuBackends;
pub use wgpu::Features as WgpuFeatures;
@@ -29,7 +32,10 @@ pub use wgpu_sync::Instance as WgpuInstance;
pub use wgpu_sync::Queue as WgpuQueue;
pub use wgpu_sync::Surface as WgpuSurface;
const TEXTURE_CACHE_SIZE: u64 = 256 * 1024 * 1024; // 256 MiB
#[cfg(not(target_family = "wasm"))]
const TEXTURE_CACHE_SIZE: u64 = 1024 * 1024 * 1024; // 1GB
#[cfg(target_family = "wasm")]
const TEXTURE_CACHE_SIZE: u64 = 512 * 1024 * 1024; // 512MB
#[derive(dyn_any::DynAny, Clone)]
pub struct WgpuExecutor {
@@ -40,16 +46,12 @@ impl WgpuExecutor {
pub fn context(&self) -> &WgpuContext {
&self.inner.context
}
pub fn shader_runtime(&self) -> &ShaderRuntime {
&self.inner.shader_runtime
}
}
#[derive(dyn_any::DynAny)]
pub struct WgpuExecutorInner {
context: WgpuContext,
texture_cache: Mutex<TextureCache>,
texture_cache: std::sync::Mutex<TextureCache>,
vello_renderer: Mutex<Renderer>,
shader_runtime: ShaderRuntime,
}
@@ -121,7 +123,19 @@ impl WgpuExecutor {
}
pub fn request_texture(&self, size: UVec2) -> Texture {
self.inner.texture_cache.lock().unwrap().request_texture(&self.context().device, size)
self.request_texture_with_format(size, wgpu::TextureFormat::Rgba8Unorm)
}
pub fn request_texture_with_format(&self, size: UVec2, format: wgpu::TextureFormat) -> Texture {
self.inner.texture_cache.lock().unwrap().request_texture(&self.context().device, size, format)
}
pub fn create_buffer(&self, desc: &wgpu::BufferDescriptor) -> Buffer {
self.context().device.create_buffer(desc).into()
}
pub fn create_buffer_init(&self, desc: &wgpu::util::BufferInitDescriptor) -> Buffer {
self.context().device.create_buffer_init(desc).into()
}
}
@@ -145,7 +159,7 @@ impl WgpuExecutor {
let texture_cache = TextureCache::new(TEXTURE_CACHE_SIZE);
let shader_runtime = ShaderRuntime::new(&context);
let shader_runtime = ShaderRuntime::default();
Some(Self {
inner: Arc::new(WgpuExecutorInner {

View File

@@ -1,20 +1,10 @@
use crate::WgpuContext;
use crate::shader_runtime::per_pixel_adjust_runtime::PerPixelAdjustShaderRuntime;
pub mod per_pixel_adjust_runtime;
pub const FULLSCREEN_VERTEX_SHADER_NAME: &str = "fullscreen_vertex_fullscreen_vertex";
#[derive(Default)]
pub struct ShaderRuntime {
context: WgpuContext,
per_pixel_adjust: PerPixelAdjustShaderRuntime,
}
impl ShaderRuntime {
pub fn new(context: &WgpuContext) -> Self {
Self {
context: context.clone(),
per_pixel_adjust: PerPixelAdjustShaderRuntime::new(),
}
}
}

View File

@@ -1,16 +1,17 @@
use crate::WgpuContext;
use crate::shader_runtime::{FULLSCREEN_VERTEX_SHADER_NAME, ShaderRuntime};
use crate::shader_runtime::FULLSCREEN_VERTEX_SHADER_NAME;
use crate::{Buffer, WgpuContext, WgpuExecutor};
use core_types::list::{Item, List};
use core_types::shaders::buffer_struct::BufferStruct;
use glam::UVec2;
use raster_types::{GPU, Raster};
use std::borrow::Cow;
use std::collections::HashMap;
use std::sync::{Mutex, PoisonError};
use wgpu::util::{BufferInitDescriptor, DeviceExt};
use wgpu::util::BufferInitDescriptor;
use wgpu::{
BindGroupDescriptor, BindGroupEntry, BindGroupLayoutDescriptor, BindGroupLayoutEntry, BindingResource, BindingType, Buffer, BufferBinding, BufferBindingType, BufferUsages, ColorTargetState, Face,
BindGroupDescriptor, BindGroupEntry, BindGroupLayoutDescriptor, BindGroupLayoutEntry, BindingResource, BindingType, BufferBinding, BufferBindingType, BufferUsages, ColorTargetState, Face,
FragmentState, FrontFace, LoadOp, Operations, PipelineLayoutDescriptor, PolygonMode, PrimitiveState, PrimitiveTopology, RenderPassColorAttachment, RenderPassDescriptor, RenderPipelineDescriptor,
ShaderModuleDescriptor, ShaderSource, ShaderStages, StoreOp, TextureDescriptor, TextureDimension, TextureFormat, TextureSampleType, TextureViewDescriptor, TextureViewDimension, VertexState,
ShaderModuleDescriptor, ShaderSource, ShaderStages, StoreOp, TextureFormat, TextureSampleType, TextureViewDescriptor, TextureViewDimension, VertexState,
};
pub struct PerPixelAdjustShaderRuntime {
@@ -32,22 +33,21 @@ impl PerPixelAdjustShaderRuntime {
}
}
impl ShaderRuntime {
impl WgpuExecutor {
pub fn run_per_pixel_adjust<T: BufferStruct>(&self, shaders: &Shaders<'_>, textures: List<Raster<GPU>>, args: Option<&T>) -> List<Raster<GPU>> {
let mut cache = self.per_pixel_adjust.pipeline_cache.lock().unwrap_or_else(PoisonError::into_inner);
let mut cache = self.inner.shader_runtime.per_pixel_adjust.pipeline_cache.lock().unwrap_or_else(PoisonError::into_inner);
let pipeline = cache
.entry(shaders.fragment_shader_name.to_owned())
.or_insert_with(|| PerPixelAdjustGraphicsPipeline::new(&self.context, shaders));
.or_insert_with(|| PerPixelAdjustGraphicsPipeline::new(self.context(), shaders));
let arg_buffer = args.map(|args| {
let device = &self.context.device;
device.create_buffer_init(&BufferInitDescriptor {
self.create_buffer_init(&BufferInitDescriptor {
label: Some(&format!("{} arg buffer", pipeline.name.as_str())),
usage: BufferUsages::STORAGE,
contents: bytemuck::bytes_of(&T::write(*args)),
})
});
pipeline.dispatch(&self.context, textures, arg_buffer)
pipeline.dispatch(self, textures, arg_buffer)
}
}
@@ -160,9 +160,9 @@ impl PerPixelAdjustGraphicsPipeline {
}
}
pub fn dispatch(&self, context: &WgpuContext, textures: List<Raster<GPU>>, arg_buffer: Option<Buffer>) -> List<Raster<GPU>> {
pub fn dispatch(&self, executor: &WgpuExecutor, textures: List<Raster<GPU>>, arg_buffer: Option<Buffer>) -> List<Raster<GPU>> {
assert_eq!(self.has_uniform, arg_buffer.is_some());
let device = &context.device;
let device = &executor.context().device;
let name = self.name.as_str();
let mut cmd = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
@@ -203,16 +203,7 @@ impl PerPixelAdjustGraphicsPipeline {
entries,
});
let tex_out = device.create_texture(&TextureDescriptor {
label: Some(&format!("{name} texture out")),
size: tex_in.size(),
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[format],
});
let tex_out = executor.request_texture_with_format(UVec2::new(tex_in.width(), tex_in.height()), format);
let view_out = tex_out.create_view(&TextureViewDescriptor::default());
let mut rp = cmd.begin_render_pass(&RenderPassDescriptor {
@@ -237,7 +228,7 @@ impl PerPixelAdjustGraphicsPipeline {
Item::from_parts(Raster::new_gpu(tex_out), attributes)
})
.collect::<List<_>>();
context.queue.submit([cmd.finish()]);
executor.context().queue.submit([cmd.finish()]);
out
}
}

View File

@@ -1,11 +1,10 @@
use glam::UVec2;
use raster_types::Texture;
use std::collections::VecDeque;
use std::sync::Arc;
pub(crate) struct TextureCache {
/// Always sorted oldest-first by insertion/last-use order.
textures: VecDeque<Arc<wgpu::Texture>>,
textures: VecDeque<Texture>,
max_free_bytes: u64,
}
@@ -17,49 +16,53 @@ impl TextureCache {
}
}
pub fn request_texture(&mut self, device: &wgpu::Device, size: UVec2) -> Texture {
pub fn request_texture(&mut self, device: &wgpu::Device, size: UVec2, format: wgpu::TextureFormat) -> Texture {
let size = size.max(UVec2::ONE);
if let Some(pos) = self
.textures
.iter()
.position(|texture| UVec2::new(texture.width(), texture.height()) == size && Arc::strong_count(texture) == 1)
.position(|texture| UVec2::new(texture.width(), texture.height()) == size && texture.format() == format && !texture.is_shared() && !texture.is_weakly_shared())
{
let entry = self.textures.remove(pos).unwrap();
let texture = entry.clone();
self.textures.push_back(entry);
return texture.into();
return texture;
}
let incoming_bytes = size.x as u64 * size.y as u64 * 4;
let incoming_bytes = size.x as u64 * size.y as u64 * format.block_copy_size(None).unwrap_or(4) as u64;
self.evict_until_fits(incoming_bytes);
let texture = Arc::new(device.create_texture(&wgpu::TextureDescriptor {
label: Some(&format!("cached_texture_{}x{}", size.x, size.y)),
size: wgpu::Extent3d {
width: size.x,
height: size.y,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
}));
let texture: Texture = device
.create_texture(&wgpu::TextureDescriptor {
label: Some(&format!("cached_{}x{}", size.x, size.y)),
size: wgpu::Extent3d {
width: size.x,
height: size.y,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: {
let common = wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT;
match format {
wgpu::TextureFormat::Rgba8Unorm => common | wgpu::TextureUsages::STORAGE_BINDING,
_ => common,
}
},
view_formats: &[],
})
.into();
self.textures.push_back(texture.clone());
texture.into()
texture
}
fn total_free_bytes(&self) -> u64 {
self.textures
.iter()
.filter(|texture| Arc::strong_count(texture) == 1)
.map(|texture| texture.memory_size_estimate())
.sum()
self.textures.iter().filter(|texture| !texture.is_shared()).map(|texture| texture.memory_size_estimate()).sum()
}
fn evict_until_fits(&mut self, incoming_bytes: u64) {
@@ -70,18 +73,19 @@ impl TextureCache {
return;
}
self.textures.retain(|texture| {
if free_bytes + incoming_bytes <= max_free_bytes {
return true;
}
if Arc::strong_count(texture) == 1 {
free_bytes -= texture.memory_size_estimate();
texture.destroy();
false
} else {
true
}
});
for parked in [false, true] {
self.textures.retain(|texture| {
if free_bytes + incoming_bytes <= max_free_bytes {
return true;
}
if !texture.is_shared() && texture.is_weakly_shared() == parked {
free_bytes -= texture.memory_size_estimate();
false
} else {
true
}
});
}
}
}
@@ -91,6 +95,6 @@ trait TextureMemoryCostEstimateExt {
impl TextureMemoryCostEstimateExt for wgpu::Texture {
fn memory_size_estimate(&self) -> u64 {
self.width() as u64 * self.height() as u64 * 4
self.width() as u64 * self.height() as u64 * self.format().block_copy_size(None).unwrap_or(4) as u64
}
}

View File

@@ -1,4 +1,4 @@
use crate::WgpuExecutorHandle;
use crate::{Buffer, WgpuExecutor, WgpuExecutorHandle};
use core_types::Color;
use core_types::Ctx;
use core_types::color::SRGBA8;
@@ -7,36 +7,29 @@ use core_types::ops::{Convert, ConvertAsync};
use core_types::runtime::SourceFuture;
use core_types::transform::Footprint;
use raster_types::Image;
use raster_types::{CPU, GPU, Raster};
use wgpu::util::{DeviceExt, TextureDataOrder};
use wgpu::{Extent3d, TextureDescriptor, TextureDimension, TextureFormat, TextureUsages};
use raster_types::{CPU, GPU, Raster, Texture};
use wgpu::{Extent3d, TextureFormat};
/// Uploads CPU image data to a GPU texture
///
/// Creates a new WGPU texture with RGBA8UnormSrgb format and uploads the provided
/// image data. The texture is configured for binding, copying, and source operations.
fn upload_to_texture(device: &wgpu::Device, queue: &wgpu::Queue, image: &Raster<CPU>) -> wgpu::Texture {
fn upload_to_texture(executor: &WgpuExecutor, queue: &wgpu::Queue, image: &Raster<CPU>) -> Texture {
let rgba8_data: Vec<SRGBA8> = image.data.iter().map(|x| (*x).into()).collect();
device.create_texture_with_data(
queue,
&TextureDescriptor {
label: Some("upload_texture node texture"),
size: Extent3d {
width: image.width,
height: image.height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format: TextureFormat::Rgba8UnormSrgb,
usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST | TextureUsages::COPY_SRC,
view_formats: &[],
},
TextureDataOrder::LayerMajor,
let texture = executor.request_texture_with_format(glam::UVec2::new(image.width, image.height), TextureFormat::Rgba8UnormSrgb);
queue.write_texture(
texture.as_image_copy(),
bytemuck::cast_slice(rgba8_data.as_slice()),
)
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(4 * image.width),
rows_per_image: Some(image.height),
},
Extent3d {
width: image.width,
height: image.height,
depth_or_array_layers: 1,
},
);
texture
}
/// Passthrough conversion for GPU `List`s - no conversion needed
@@ -49,13 +42,12 @@ impl Convert<List<Raster<GPU>>, WgpuExecutorHandle> for List<Raster<GPU>> {
/// Converts a `List<Raster<CPU>>` to `List<Raster<GPU>>` by uploading each image to a texture
impl Convert<List<Raster<GPU>>, WgpuExecutorHandle> for List<Raster<CPU>> {
fn convert(self, _: Footprint, executor: WgpuExecutorHandle) -> List<Raster<GPU>> {
let device = &executor.context().device;
let queue = executor.context().queue.lock();
let list = self
.into_iter()
.map(|row| {
let (image, attributes) = row.into_parts();
let texture = upload_to_texture(device, &queue, &image);
let texture = upload_to_texture(&executor, &queue, &image);
Item::from_parts(Raster::new_gpu(texture), attributes)
})
@@ -69,9 +61,8 @@ impl Convert<List<Raster<GPU>>, WgpuExecutorHandle> for List<Raster<CPU>> {
/// Converts single CPU raster to GPU by uploading to texture
impl Convert<Raster<GPU>, WgpuExecutorHandle> for Raster<CPU> {
fn convert(self, _: Footprint, executor: WgpuExecutorHandle) -> Raster<GPU> {
let device = &executor.context().device;
let queue = executor.context().queue.lock();
let texture = upload_to_texture(device, &queue, &self);
let texture = upload_to_texture(&executor, &queue, &self);
queue.submit([]);
Raster::new_gpu(texture)
@@ -92,7 +83,7 @@ impl Convert<List<Raster<CPU>>, WgpuExecutorHandle> for List<Raster<CPU>> {
/// - 4 bytes-per-pixel RGBA8
/// - Texture has COPY_SRC usage
struct RasterGpuToRasterCpuConverter {
buffer: wgpu::Buffer,
buffer: Buffer,
width: u32,
height: u32,
unpadded_bytes_per_row: u32,
@@ -100,7 +91,7 @@ struct RasterGpuToRasterCpuConverter {
_source: raster_types::Texture,
}
impl RasterGpuToRasterCpuConverter {
fn new(device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, data_gpu: Raster<GPU>) -> Self {
fn new(executor: &WgpuExecutor, encoder: &mut wgpu::CommandEncoder, data_gpu: Raster<GPU>) -> Self {
let texture = data_gpu.data();
let width = texture.width();
let height = texture.height();
@@ -110,7 +101,7 @@ impl RasterGpuToRasterCpuConverter {
let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align;
let buffer_size = padded_bytes_per_row as u64 * height as u64;
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
let buffer = executor.create_buffer(&wgpu::BufferDescriptor {
label: Some("texture_download_buffer"),
size: buffer_size,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
@@ -204,7 +195,7 @@ impl ConvertAsync<List<Raster<CPU>>, WgpuExecutorHandle> for List<Raster<GPU>> {
for row in self {
let (element, attributes) = row.into_parts();
converters.push(RasterGpuToRasterCpuConverter::new(&device, &mut encoder, element));
converters.push(RasterGpuToRasterCpuConverter::new(&executor, &mut encoder, element));
rows_meta.push(Item::from_parts((), attributes));
}
@@ -243,7 +234,7 @@ impl ConvertAsync<Raster<CPU>, WgpuExecutorHandle> for Raster<GPU> {
label: Some("single_texture_download_encoder"),
});
let converter = RasterGpuToRasterCpuConverter::new(&device, &mut encoder, self);
let converter = RasterGpuToRasterCpuConverter::new(&executor, &mut encoder, self);
queue.submit([encoder.finish()]);