mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2025-08-03 21:08:18 +00:00
Curves image adjustment node (#1214)
* Create ValueMapperNode and use it for brightness/contrast * move spline code into seperate module * Add GenerateCurvesNode * add a `LuminanceMut`-trait * add `lerp` to `Channel` * Add frontend code to handle the curves widget's inputs * Rename spline module to curve * Make messages in CurveInput pass * Improve curves widget design and fix sizing issue * Implement proper bezier handling * Use bezier_rs's intersections function instead of own cubic root solver * Debounce CurveInput events and change how debouncer works the first event issued to the debouncer was unneccessarily delayed. Instead now the debouncer fires it instantaneously but blocks events that come in until a timeout was reached. * Make curve editing more user friendly * Change code to use project terminology * sample -> manipulator group or manipulator * marker -> handle * Fix small documentation mistake in bezier-rs * Add find_tvalues_for_x function to bezier-rs also integrate the function into curves node * Add tests for find_tvalues_for_x in bezier-rs * Fix formatting * Revert BrightnessContrastNode changes * Frontend cleanup --------- Co-authored-by: Keavon Chambers <keavon@keavon.com> Co-authored-by: Dennis Kobert <dennis@kobert.dev>
This commit is contained in:
parent
cfe38c6413
commit
dc4b16aead
29 changed files with 822 additions and 75 deletions
|
@ -1,20 +1,16 @@
|
|||
use crate::raster::ImageFrame;
|
||||
use crate::transform::Transform;
|
||||
use crate::transform::TransformMut;
|
||||
use crate::Color;
|
||||
use crate::Node;
|
||||
use alloc::sync::Arc;
|
||||
use dyn_any::StaticType;
|
||||
use dyn_any::StaticTypeSized;
|
||||
use glam::DAffine2;
|
||||
use crate::text::FontCache;
|
||||
use crate::transform::{Transform, TransformMut};
|
||||
use crate::{Color, Node};
|
||||
|
||||
use dyn_any::{StaticType, StaticTypeSized};
|
||||
|
||||
use alloc::sync::Arc;
|
||||
use core::fmt::Debug;
|
||||
use core::future::Future;
|
||||
use core::hash::{Hash, Hasher};
|
||||
use core::pin::Pin;
|
||||
|
||||
use crate::text::FontCache;
|
||||
|
||||
use core::fmt::Debug;
|
||||
use glam::DAffine2;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
|
|
|
@ -3,10 +3,10 @@ use crate::vector::VectorData;
|
|||
use crate::{Color, Node};
|
||||
|
||||
use dyn_any::{DynAny, StaticType};
|
||||
use node_macro::node_fn;
|
||||
|
||||
use core::ops::{Deref, DerefMut};
|
||||
use glam::IVec2;
|
||||
use node_macro::node_fn;
|
||||
|
||||
pub mod renderer;
|
||||
|
||||
|
|
|
@ -146,9 +146,10 @@ impl<'i, 's: 'i, I: 'i, O: 'i, N: Node<'i, I, Output = O> + ?Sized> Node<'i, I>
|
|||
}
|
||||
}
|
||||
|
||||
use dyn_any::StaticTypeSized;
|
||||
|
||||
use core::pin::Pin;
|
||||
|
||||
use dyn_any::StaticTypeSized;
|
||||
#[cfg(feature = "alloc")]
|
||||
impl<'i, I: 'i, O: 'i> Node<'i, I> for Pin<Box<dyn Node<'i, I, Output = O> + 'i>> {
|
||||
type Output = O;
|
||||
|
|
|
@ -14,17 +14,24 @@ pub mod brightness_contrast;
|
|||
#[cfg(not(target_arch = "spirv"))]
|
||||
pub mod brush_cache;
|
||||
pub mod color;
|
||||
pub mod curve;
|
||||
pub mod discrete_srgb;
|
||||
pub use adjustments::*;
|
||||
|
||||
#[cfg(target_arch = "spirv")]
|
||||
use num_traits::Float;
|
||||
|
||||
pub trait Linear {
|
||||
fn from_f32(x: f32) -> Self;
|
||||
fn to_f32(self) -> f32;
|
||||
fn from_f64(x: f64) -> Self;
|
||||
fn to_f64(self) -> f64;
|
||||
fn lerp(self, other: Self, value: Self) -> Self
|
||||
where
|
||||
Self: Sized + Copy,
|
||||
Self: core::ops::Sub<Self, Output = Self>,
|
||||
Self: core::ops::Mul<Self, Output = Self>,
|
||||
Self: core::ops::Add<Self, Output = Self>,
|
||||
{
|
||||
self + (other - self) * value
|
||||
}
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
|
@ -191,6 +198,10 @@ pub trait Luminance {
|
|||
}
|
||||
}
|
||||
|
||||
pub trait LuminanceMut: Luminance {
|
||||
fn set_luminance(&mut self, luminance: Self::LuminanceChannel);
|
||||
}
|
||||
|
||||
// TODO: We might rename this to Raster at some point
|
||||
pub trait Sample {
|
||||
type Pixel: Pixel;
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
#![allow(clippy::too_many_arguments)]
|
||||
use super::Color;
|
||||
use crate::Node;
|
||||
use core::fmt::Debug;
|
||||
use super::curve::{Curve, CurveManipulatorGroup, ValueMapperNode};
|
||||
use super::{Channel, Color, Node};
|
||||
|
||||
use bezier_rs::{Bezier, TValue};
|
||||
use dyn_any::{DynAny, StaticType};
|
||||
|
||||
use core::fmt::Debug;
|
||||
#[cfg(feature = "serde")]
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[cfg(target_arch = "spirv")]
|
||||
use spirv_std::num_traits::float::Float;
|
||||
|
||||
|
@ -204,7 +205,7 @@ pub struct ExtractAlphaNode;
|
|||
#[node_macro::node_fn(ExtractAlphaNode)]
|
||||
fn extract_alpha_node(color: Color) -> Color {
|
||||
let alpha = color.a();
|
||||
Color::from_rgbaf32(alpha, alpha, alpha, 1.0).unwrap()
|
||||
Color::from_rgbaf32(alpha, alpha, alpha, 1.).unwrap()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
|
@ -215,7 +216,7 @@ fn extract_opaque_node(color: Color) -> Color {
|
|||
if color.a() == 0. {
|
||||
return color.with_alpha(1.);
|
||||
}
|
||||
Color::from_rgbaf32(color.r() / color.a(), color.g() / color.a(), color.b() / color.a(), 1.0).unwrap()
|
||||
Color::from_rgbaf32(color.r() / color.a(), color.g() / color.a(), color.b() / color.a(), 1.).unwrap()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
|
@ -856,6 +857,52 @@ fn exposure(color: Color, exposure: f32, offset: f32, gamma_correction: f32) ->
|
|||
adjusted.map_rgb(|c: f32| c.clamp(0., 1.))
|
||||
}
|
||||
|
||||
const WINDOW_SIZE: usize = 1024;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct GenerateCurvesNode<OutputChannel, Curve> {
|
||||
curve: Curve,
|
||||
_channel: core::marker::PhantomData<OutputChannel>,
|
||||
}
|
||||
|
||||
#[node_macro::node_fn(GenerateCurvesNode<_Channel>)]
|
||||
fn generate_curves<_Channel: Channel + super::Linear>(_primary: (), curve: Curve) -> ValueMapperNode<_Channel> {
|
||||
let [mut pos, mut param]: [[f32; 2]; 2] = [[0.; 2], curve.first_handle];
|
||||
let mut lut = vec![_Channel::from_f64(0.); WINDOW_SIZE];
|
||||
let end = CurveManipulatorGroup {
|
||||
anchor: [1.; 2],
|
||||
handles: [curve.last_handle, [0.; 2]],
|
||||
};
|
||||
for sample in curve.manipulator_groups.iter().chain(core::iter::once(&end)) {
|
||||
let [x0, y0, x1, y1, x2, y2, x3, y3] = [pos[0], pos[1], param[0], param[1], sample.handles[0][0], sample.handles[0][1], sample.anchor[0], sample.anchor[1]].map(f64::from);
|
||||
|
||||
let bezier = Bezier::from_cubic_coordinates(x0, y0, x1, y1, x2, y2, x3, y3);
|
||||
|
||||
let [left, right] = [pos[0], sample.anchor[0]].map(|c| c.clamp(0., 1.));
|
||||
let lut_index_left: usize = (left * (lut.len() - 1) as f32).floor() as _;
|
||||
let lut_index_right: usize = (right * (lut.len() - 1) as f32).ceil() as _;
|
||||
for index in lut_index_left..=lut_index_right {
|
||||
let x = index as f64 / (lut.len() - 1) as f64;
|
||||
let y = if x <= x0 {
|
||||
y0
|
||||
} else if x >= x3 {
|
||||
y3
|
||||
} else {
|
||||
bezier.find_tvalues_for_x(x)
|
||||
.next()
|
||||
.map(|t| bezier.evaluate(TValue::Parametric(t.clamp(0., 1.))).y)
|
||||
// a very bad approximation if bezier_rs failes
|
||||
.unwrap_or_else(|| (x - x0) / (x3 - x0) * (y3 - y0) + y0)
|
||||
};
|
||||
lut[index] = _Channel::from_f64(y);
|
||||
}
|
||||
|
||||
pos = sample.anchor;
|
||||
param = sample.handles[1];
|
||||
}
|
||||
ValueMapperNode::new(lut)
|
||||
}
|
||||
|
||||
#[cfg(feature = "alloc")]
|
||||
pub use index_node::IndexNode;
|
||||
|
||||
|
|
|
@ -64,6 +64,7 @@ pub struct GenerateBrightnessContrastMapperNode<Brightness, Contrast> {
|
|||
contrast: Contrast,
|
||||
}
|
||||
|
||||
// TODO: Replace this node implementation with one that uses the more generalized Curves adjustment node
|
||||
#[node_macro::node_fn(GenerateBrightnessContrastMapperNode)]
|
||||
fn brightness_contrast_node(_primary: (), brightness: f32, contrast: f32) -> BrightnessContrastMapperNode {
|
||||
// Brightness LUT
|
||||
|
|
|
@ -14,7 +14,7 @@ use bytemuck::{Pod, Zeroable};
|
|||
|
||||
use super::{
|
||||
discrete_srgb::{float_to_srgb_u8, srgb_u8_to_float},
|
||||
Alpha, AssociatedAlpha, Luminance, Pixel, RGBMut, Rec709Primaries, RGB, SRGB,
|
||||
Alpha, AssociatedAlpha, Luminance, LuminanceMut, Pixel, RGBMut, Rec709Primaries, RGB, SRGB,
|
||||
};
|
||||
|
||||
#[repr(C)]
|
||||
|
@ -113,6 +113,12 @@ impl Luminance for Luma {
|
|||
}
|
||||
}
|
||||
|
||||
impl LuminanceMut for Luma {
|
||||
fn set_luminance(&mut self, luminance: Self::LuminanceChannel) {
|
||||
self.0 = luminance
|
||||
}
|
||||
}
|
||||
|
||||
impl RGB for Luma {
|
||||
type ColorChannel = f32;
|
||||
#[inline(always)]
|
||||
|
@ -230,6 +236,28 @@ impl Luminance for Color {
|
|||
}
|
||||
}
|
||||
|
||||
impl LuminanceMut for Color {
|
||||
fn set_luminance(&mut self, luminance: f32) {
|
||||
let current = self.luminance();
|
||||
// When we have a black-ish color, we just set the color to a grey-scale value. This prohibits a divide-by-0.
|
||||
if current < f32::EPSILON {
|
||||
self.red = 0.2126 * luminance;
|
||||
self.green = 0.7152 * luminance;
|
||||
self.blue = 0.0722 * luminance;
|
||||
return;
|
||||
}
|
||||
let fac = luminance / current;
|
||||
// TODO: when we have for example the rgb color (0, 0, 1) and want to
|
||||
// TODO: do `.set_luminance(1)`, then the actual luminance is not 1 at
|
||||
// TODO: the end. With no clamp, the resulting color would be
|
||||
// TODO: (0, 0, 12.8504). The excess should be spread to the other
|
||||
// TODO: channels, but is currently just clamped away.
|
||||
self.red = (self.red * fac).clamp(0., 1.);
|
||||
self.green = (self.green * fac).clamp(0., 1.);
|
||||
self.blue = (self.blue * fac).clamp(0., 1.);
|
||||
}
|
||||
}
|
||||
|
||||
impl Rec709Primaries for Color {}
|
||||
impl SRGB for Color {}
|
||||
|
||||
|
|
204
node-graph/gcore/src/raster/curve.rs
Normal file
204
node-graph/gcore/src/raster/curve.rs
Normal file
|
@ -0,0 +1,204 @@
|
|||
use super::{Channel, Linear, LuminanceMut};
|
||||
use crate::Node;
|
||||
|
||||
use dyn_any::{DynAny, StaticType};
|
||||
|
||||
use core::ops::{Add, Mul, Sub};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, DynAny, specta::Type)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct Curve {
|
||||
#[serde(rename = "manipulatorGroups")]
|
||||
pub manipulator_groups: Vec<CurveManipulatorGroup>,
|
||||
#[serde(rename = "firstHandle")]
|
||||
pub first_handle: [f32; 2],
|
||||
#[serde(rename = "lastHandle")]
|
||||
pub last_handle: [f32; 2],
|
||||
}
|
||||
|
||||
impl Default for Curve {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
manipulator_groups: vec![],
|
||||
first_handle: [0.2; 2],
|
||||
last_handle: [0.8; 2],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::hash::Hash for Curve {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
self.manipulator_groups.hash(state);
|
||||
[self.first_handle, self.last_handle].iter().flatten().for_each(|f| f.to_bits().hash(state));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, DynAny, specta::Type)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct CurveManipulatorGroup {
|
||||
pub anchor: [f32; 2],
|
||||
pub handles: [[f32; 2]; 2],
|
||||
}
|
||||
|
||||
impl std::hash::Hash for CurveManipulatorGroup {
|
||||
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
|
||||
for c in self.handles.iter().chain([&self.anchor]).flatten() {
|
||||
c.to_bits().hash(state);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CubicSplines {
|
||||
pub x: [f32; 4],
|
||||
pub y: [f32; 4],
|
||||
}
|
||||
|
||||
impl CubicSplines {
|
||||
pub fn solve(&self) -> [f32; 4] {
|
||||
let (x, y) = (&self.x, &self.y);
|
||||
|
||||
// Build an augmented matrix to solve the system of equations using Gaussian elimination
|
||||
let mut augmented_matrix = [
|
||||
[
|
||||
2. / (x[1] - x[0]),
|
||||
1. / (x[1] - x[0]),
|
||||
0.,
|
||||
0.,
|
||||
// |
|
||||
3. * (y[1] - y[0]) / ((x[1] - x[0]) * (x[1] - x[0])),
|
||||
],
|
||||
[
|
||||
1. / (x[1] - x[0]),
|
||||
2. * (1. / (x[1] - x[0]) + 1. / (x[2] - x[1])),
|
||||
1. / (x[2] - x[1]),
|
||||
0.,
|
||||
// |
|
||||
3. * ((y[1] - y[0]) / ((x[1] - x[0]) * (x[1] - x[0])) + (y[2] - y[1]) / ((x[2] - x[1]) * (x[2] - x[1]))),
|
||||
],
|
||||
[
|
||||
0.,
|
||||
1. / (x[2] - x[1]),
|
||||
2. * (1. / (x[2] - x[1]) + 1. / (x[3] - x[2])),
|
||||
1. / (x[3] - x[2]),
|
||||
// |
|
||||
3. * ((y[2] - y[1]) / ((x[2] - x[1]) * (x[2] - x[1])) + (y[3] - y[2]) / ((x[3] - x[2]) * (x[3] - x[2]))),
|
||||
],
|
||||
[
|
||||
0.,
|
||||
0.,
|
||||
1. / (x[3] - x[2]),
|
||||
2. / (x[3] - x[2]),
|
||||
// |
|
||||
3. * (y[3] - y[2]) / ((x[3] - x[2]) * (x[3] - x[2])),
|
||||
],
|
||||
];
|
||||
|
||||
// Gaussian elimination: forward elimination
|
||||
for row in 0..4 {
|
||||
let pivot_row_index = (row..4)
|
||||
.max_by(|&a_row, &b_row| {
|
||||
augmented_matrix[a_row][row]
|
||||
.abs()
|
||||
.partial_cmp(&augmented_matrix[b_row][row].abs())
|
||||
.unwrap_or(core::cmp::Ordering::Equal)
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Swap the current row with the row that has the largest pivot element
|
||||
augmented_matrix.swap(row, pivot_row_index);
|
||||
|
||||
// Eliminate the current column in all rows below the current one
|
||||
for row_below_current in row + 1..4 {
|
||||
assert!(augmented_matrix[row][row].abs() > core::f32::EPSILON);
|
||||
|
||||
let scale_factor = augmented_matrix[row_below_current][row] / augmented_matrix[row][row];
|
||||
for col in row..5 {
|
||||
augmented_matrix[row_below_current][col] -= augmented_matrix[row][col] * scale_factor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gaussian elimination: back substitution
|
||||
let mut solutions = [0.; 4];
|
||||
for col in (0..4).rev() {
|
||||
assert!(augmented_matrix[col][col].abs() > core::f32::EPSILON);
|
||||
|
||||
solutions[col] = augmented_matrix[col][4] / augmented_matrix[col][col];
|
||||
|
||||
for row in (0..col).rev() {
|
||||
augmented_matrix[row][4] -= augmented_matrix[row][col] * solutions[col];
|
||||
augmented_matrix[row][col] = 0.;
|
||||
}
|
||||
}
|
||||
|
||||
solutions
|
||||
}
|
||||
|
||||
pub fn interpolate(&self, input: f32, solutions: &[f32]) -> f32 {
|
||||
if input <= self.x[0] {
|
||||
return self.y[0];
|
||||
}
|
||||
if input >= self.x[self.x.len() - 1] {
|
||||
return self.y[self.x.len() - 1];
|
||||
}
|
||||
|
||||
// Find the segment that the input falls between
|
||||
let mut segment = 1;
|
||||
while self.x[segment] < input {
|
||||
segment += 1;
|
||||
}
|
||||
let segment_start = segment - 1;
|
||||
let segment_end = segment;
|
||||
|
||||
// Calculate the output value using quadratic interpolation
|
||||
let input_value = self.x[segment_start];
|
||||
let input_value_prev = self.x[segment_end];
|
||||
let output_value = self.y[segment_start];
|
||||
let output_value_prev = self.y[segment_end];
|
||||
let solutions_value = solutions[segment_start];
|
||||
let solutions_value_prev = solutions[segment_end];
|
||||
|
||||
let output_delta = solutions_value_prev * (input_value - input_value_prev) - (output_value - output_value_prev);
|
||||
let solution_delta = (output_value - output_value_prev) - solutions_value * (input_value - input_value_prev);
|
||||
|
||||
let input_ratio = (input - input_value_prev) / (input_value - input_value_prev);
|
||||
let prev_output_ratio = (1. - input_ratio) * output_value_prev;
|
||||
let output_ratio = input_ratio * output_value;
|
||||
let quadratic_ratio = input_ratio * (1. - input_ratio) * (output_delta * (1. - input_ratio) + solution_delta * input_ratio);
|
||||
|
||||
let result = prev_output_ratio + output_ratio + quadratic_ratio;
|
||||
result.clamp(0., 1.)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ValueMapperNode<C> {
|
||||
lut: Vec<C>,
|
||||
}
|
||||
|
||||
impl<C> ValueMapperNode<C> {
|
||||
pub const fn new(lut: Vec<C>) -> Self {
|
||||
Self { lut }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'i, L: LuminanceMut + 'i> Node<'i, L> for ValueMapperNode<L::LuminanceChannel>
|
||||
where
|
||||
L::LuminanceChannel: Linear + Copy,
|
||||
L::LuminanceChannel: Add<Output = L::LuminanceChannel>,
|
||||
L::LuminanceChannel: Sub<Output = L::LuminanceChannel>,
|
||||
L::LuminanceChannel: Mul<Output = L::LuminanceChannel>,
|
||||
{
|
||||
type Output = L;
|
||||
|
||||
fn eval(&'i self, mut val: L) -> L {
|
||||
let luminance: f32 = val.luminance().to_linear();
|
||||
let floating_sample_index = luminance * (self.lut.len() - 1) as f32;
|
||||
let index_in_lut = floating_sample_index.floor() as usize;
|
||||
let a = self.lut[index_in_lut];
|
||||
let b = self.lut[(index_in_lut + 1).clamp(0, self.lut.len() - 1)];
|
||||
let result = a.lerp(b, L::LuminanceChannel::from_linear(floating_sample_index.fract()));
|
||||
val.set_luminance(result);
|
||||
val
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
use crate::uuid::ManipulatorGroupId;
|
||||
|
||||
use bezier_rs::{ManipulatorGroup, Subpath};
|
||||
|
||||
use glam::DVec2;
|
||||
use rustybuzz::ttf_parser::{GlyphId, OutlineBuilder};
|
||||
use rustybuzz::{GlyphBuffer, UnicodeBuffer};
|
||||
|
|
|
@ -3,6 +3,7 @@ use crate::vector::VectorData;
|
|||
use crate::Node;
|
||||
|
||||
use bezier_rs::Subpath;
|
||||
|
||||
use glam::DVec2;
|
||||
|
||||
pub struct UnitCircleGenerator;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
use super::style::{PathStyle, Stroke};
|
||||
use crate::{uuid::ManipulatorGroupId, Color};
|
||||
use crate::uuid::ManipulatorGroupId;
|
||||
use crate::Color;
|
||||
|
||||
use bezier_rs::ManipulatorGroup;
|
||||
use dyn_any::{DynAny, StaticType};
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
use super::style::{Fill, FillType, Gradient, GradientType, Stroke};
|
||||
use super::VectorData;
|
||||
use crate::{Color, Node};
|
||||
|
||||
use bezier_rs::Subpath;
|
||||
|
||||
use glam::{DAffine2, DVec2};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
|
@ -138,7 +140,7 @@ fn circular_repeat_vector_data(mut vector_data: VectorData, rotation_offset: f32
|
|||
pub struct BoundingBoxNode;
|
||||
|
||||
#[node_macro::node_fn(BoundingBoxNode)]
|
||||
fn generate_bounding_box(mut vector_data: VectorData) -> VectorData {
|
||||
fn generate_bounding_box(vector_data: VectorData) -> VectorData {
|
||||
let bounding_box = vector_data.bounding_box().unwrap();
|
||||
VectorData::from_subpaths(vec![Subpath::new_rect(
|
||||
vector_data.transform.transform_point2(bounding_box[0]),
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue