Revamp the syntax to #[hard(a..b)] and #[soft(a..b)] bounds on node definitions (#4307)

* Revamp the syntax to #[hard(a..b)] and #[soft(a..b)] bounds on node definitions

* Address review feedback: error on empty bounds ranges and remove redundant count clamps

* Fix NaN transform from Repeat Array with a count of 1

* Remove redundant manual clamps already enforced by #[hard(...)] bounds
This commit is contained in:
Keavon Chambers
2026-07-03 18:48:58 -07:00
committed by GitHub
parent 95c1ab81f3
commit f05a644bd9
17 changed files with 341 additions and 197 deletions

View File

@@ -156,7 +156,9 @@ async fn mirror<T: 'n + Send + Clone>(
content: List<T>,
#[default(ReferencePoint::Center)] relative_to_bounds: ReferencePoint,
#[unit(" px")] offset: f64,
#[range((-90., 90.))] angle: Angle,
#[range]
#[soft(-90..90)]
angle: Angle,
#[default(true)] keep_original: bool,
) -> List<T>
where

View File

@@ -22,13 +22,13 @@ fn text(
/// The font size used to draw the text.
#[unit(" px")]
#[default(24.)]
#[hard_min(1.)]
#[hard(1..)]
size: f64,
/// The line height ratio, relative to the font size. Each line is drawn lower than its previous line by the distance of *Size* × *Line Height*.
///
/// 0 means all lines overlap. 1 means all lines are spaced by just the font size. 1.2 is a common default for readable text. 2 means double-spaced text.
#[unit("x")]
#[hard_min(0.)]
#[hard(0..)]
#[step(0.1)]
#[default(1.2)]
line_height: f64,
@@ -38,15 +38,14 @@ fn text(
letter_spacing: f64,
/// The angle of faux italic slant applied to each glyph.
#[unit("°")]
#[hard_min(-85.)]
#[hard_max(85.)]
#[hard(-85..85)]
letter_tilt: f64,
/// Enables the maximum width constraint so lines can wrap.
#[widget(ParsedWidgetOverride::Hidden)]
has_max_width: bool,
/// The maximum width that the text block can occupy before wrapping to a new line. Otherwise, lines do not wrap.
#[unit(" px")]
#[hard_min(1.)]
#[hard(1..)]
#[widget(ParsedWidgetOverride::Custom = "optional_f64")]
max_width: f64,
/// Whether the *Max Height* property is enabled so that lines beyond it are not drawn.
@@ -54,7 +53,7 @@ fn text(
has_max_height: bool,
/// The maximum height that the text block can occupy. Excess lines are not drawn.
#[unit(" px")]
#[hard_min(1.)]
#[hard(1..)]
#[widget(ParsedWidgetOverride::Custom = "optional_f64")]
max_height: f64,
/// The horizontal alignment of each line of text within its surrounding box. To have an effect on a single line of text, *Max Width* must be set.

View File

@@ -85,8 +85,9 @@ fn gamma_correction<T: Adjust<Color>>(
#[gpu_image]
mut input: T,
#[default(2.2)]
#[range((0.01, 10.))]
#[hard_min(0.0001)]
#[range]
#[hard(0.0001..)]
#[soft(0.01..10)]
gamma: f32,
inverse: bool,
) -> T {
@@ -344,22 +345,28 @@ fn black_and_white<T: Adjust<Color>>(
mut image: T,
#[default(Color::BLACK)] tint: Color,
#[default(40.)]
#[range((-200., 300.))]
#[range]
#[soft(-200..300)]
reds: PercentageF32,
#[default(60.)]
#[range((-200., 300.))]
#[range]
#[soft(-200..300)]
yellows: PercentageF32,
#[default(40.)]
#[range((-200., 300.))]
#[range]
#[soft(-200..300)]
greens: PercentageF32,
#[default(60.)]
#[range((-200., 300.))]
#[range]
#[soft(-200..300)]
cyans: PercentageF32,
#[default(20.)]
#[range((-200., 300.))]
#[range]
#[soft(-200..300)]
blues: PercentageF32,
#[default(80.)]
#[range((-200., 300.))]
#[range]
#[soft(-200..300)]
magentas: PercentageF32,
) -> T {
image.adjust(|color| {
@@ -997,12 +1004,11 @@ fn posterize<T: Adjust<Color>>(
#[gpu_image]
mut input: T,
#[default(4)]
#[hard_min(2)]
#[hard(2..)]
levels: u32,
) -> T {
let levels = levels as f32;
input.adjust(|color| {
// `hard_min(2)` constrains the widget but doesn't bind the data-flow input (a saved doc or upstream node could still feed 0 or 1, producing inf/NaN below).
let levels = (levels as f32).max(2.);
let number_of_areas = levels.recip();
let size_of_areas = (levels - 1.).recip();
color.map_gamma_rgb(|c| (c / number_of_areas).floor() * size_of_areas)
@@ -1029,8 +1035,9 @@ fn exposure<T: Adjust<Color>>(
exposure: f32,
offset: f32,
#[default(1.)]
#[range((0.01, 10.))]
#[hard_min(0.0001)]
#[range]
#[hard(0.0001..)]
#[soft(0.01..10)]
gamma_correction: f32,
) -> T {
input.adjust(|color| {

View File

@@ -92,8 +92,9 @@ async fn blur(
/// The image to be blurred.
image_frame: List<Raster<CPU>>,
/// The radius of the blur kernel.
#[range((0., 100.))]
#[hard_min(0.)]
#[range]
#[hard(0..)]
#[soft(..100)]
radius: PixelLength,
/// Use a lower-quality box kernel instead of a circular Gaussian kernel. This is faster but produces boxy artifacts.
box_blur: bool,
@@ -128,8 +129,9 @@ async fn median_filter(
/// The image to be filtered.
image_frame: List<Raster<CPU>>,
/// The radius of the filter kernel. Larger values remove more noise but may blur fine details.
#[range((0., 50.))]
#[hard_min(0.)]
#[range]
#[hard(0..)]
#[soft(..50)]
radius: PixelLength,
) -> List<Raster<CPU>> {
image_frame

View File

@@ -8,7 +8,7 @@ async fn image_color_palette(
_: impl Ctx,
image: List<Raster<CPU>>,
#[default(4)]
#[hard_min(1)]
#[hard(1..)]
count: u32,
) -> List<Color> {
const GRID: f32 = 3.;

View File

@@ -20,13 +20,13 @@ async fn repeat<T: Into<Graphic> + Default + Send + Clone + 'static>(
)]
content: impl Node<'n, Context<'static>, Output = List<T>>,
#[default(1)]
#[hard_min(1)]
#[hard(1..)]
count: u32,
reverse: bool,
) -> List<T> {
// Someday this node can have the option to generate infinitely instead of a fixed count (basically `std::iter::repeat`).
let count = count.max(1) as usize;
let count = count as usize;
let mut result_list = List::new();
@@ -60,12 +60,12 @@ pub async fn repeat_array<T: Into<Graphic> + Default + Send + Clone + 'static>(
direction: PixelSize,
angle: Angle,
#[default(5)]
#[hard_min(1)]
#[hard(1..)]
count: u32,
) -> List<T> {
let angle = angle.to_radians();
let count = count.max(1);
let total = (count - 1) as f64;
// A single copy has no steps between copies, so the denominator is kept at 1 to avoid `0. / 0.` producing a NaN transform
let total = (count - 1).max(1) as f64;
let mut result_list = List::new();
@@ -108,11 +108,9 @@ async fn repeat_radial<T: Into<Graphic> + Default + Send + Clone + 'static>(
#[default(5)]
radius: f64,
#[default(5)]
#[hard_min(1)]
#[hard(1..)]
count: u32,
) -> List<T> {
let count = count.max(1);
let mut result_list = List::new();
for index in 0..count {
@@ -265,6 +263,26 @@ mod test {
}
}
#[tokio::test]
async fn repeat_single_copy() {
let context = OwnedContextImpl::default().into_context();
let repeated = super::repeat_array(
context,
&FutureWrapperNode(vector_node_from_bezpath(Rect::new(0., 0., 1., 1.).to_path(DEFAULT_ACCURACY))),
DVec2::new(12., 10.),
45.,
1,
)
.await;
let vector_list = vector_nodes::flatten_path(Footprint::default(), repeated).await;
let vector = vector_list.element(0).unwrap();
assert_eq!(vector.region_manipulator_groups().count(), 1);
let (_, manipulator_groups) = vector.region_manipulator_groups().next().unwrap();
let anchor = manipulator_groups[0].anchor;
assert!(anchor.length() < 1e-5, "Expected the single copy to be untransformed, found anchor {anchor}");
}
#[tokio::test]
async fn repeat_transform_position() {
let direction = DVec2::new(12., 10.);

View File

@@ -424,7 +424,7 @@ fn string_repeat(
string: String,
/// The number of times the string should appear in the output.
#[default(2)]
#[hard_min(1)]
#[hard(1..)]
count: u32,
/// The string placed between each repetition.
#[default("\\n")]
@@ -436,7 +436,7 @@ fn string_repeat(
) -> String {
let separator = if separator_escaping { unescape_string(separator) } else { separator };
let count = count.max(1) as usize;
let count = count as usize;
let mut result = String::with_capacity((string.len() + separator.len()) * count);
for i in 0..count {

View File

@@ -77,7 +77,8 @@ fn arc(
radius: f64,
start_angle: Angle,
#[default(270.)]
#[range((0., 360.))]
#[range]
#[soft(0..360)]
sweep_angle: Angle,
arc_type: ArcType,
) -> List<Vector> {
@@ -167,7 +168,7 @@ fn regular_polygon<T: AsU64>(
_: impl Ctx,
_primary: (),
#[default(6)]
#[hard_min(3.)]
#[hard(3..)]
#[implementations(u32, u64, f64)]
sides: T,
#[unit(" px")]
@@ -185,7 +186,7 @@ fn star<T: AsU64>(
_: impl Ctx,
_primary: (),
#[default(5)]
#[hard_min(2.)]
#[hard(2..)]
#[implementations(u32, u64, f64)]
sides: T,
#[unit(" px")]
@@ -228,7 +229,7 @@ fn qr_code(
text: String,
#[widget(ParsedWidgetOverride::Hidden)] has_size: bool,
#[unit(" px")]
#[hard_min(1.)]
#[hard(1..)]
#[widget(ParsedWidgetOverride::Custom = "optional_f64")]
size: f64,
error_correction: QRCodeErrorCorrectionLevel,
@@ -267,7 +268,7 @@ fn qr_code(
};
if has_size {
vector.transform(glam::DAffine2::from_scale(DVec2::splat(size.max(1.) / qr_code.size() as f64)));
vector.transform(glam::DAffine2::from_scale(DVec2::splat(size / qr_code.size() as f64)));
}
List::new_from_element(vector)
@@ -312,7 +313,7 @@ fn grid<T: GridSpacing>(
_primary: (),
grid_type: GridType,
#[unit(" px")]
#[hard_min(0.)]
#[hard(0..)]
#[default(10)]
#[implementations(f64, DVec2)]
spacing: T,

View File

@@ -263,21 +263,25 @@ async fn copy_to_points<I: 'n + Send + Clone>(
content: List<I>,
/// Minimum range of randomized sizes given to each placed copy.
#[default(1)]
#[range((0., 2.))]
#[range]
#[soft(0..2)]
#[unit("x")]
random_scale_min: Multiplier,
/// Maximum range of randomized sizes given to each placed copy.
#[default(1)]
#[range((0., 2.))]
#[range]
#[soft(0..2)]
#[unit("x")]
random_scale_max: Multiplier,
/// Bias for the probability distribution of randomized sizes (0 is uniform, negatives favor more of small sizes, positives favor more of large sizes).
#[range((-50., 50.))]
#[range]
#[soft(-50..50)]
random_scale_bias: f64,
/// Seed to determine unique variations on all the randomized copy sizes.
random_scale_seed: SeedValue,
/// Range of randomized angles given to each placed copy, in degrees ranging from furthest clockwise to counterclockwise.
#[range((0., 360.))]
#[range]
#[soft(0..360)]
random_rotation: Angle,
/// Seed to determine unique variations on all the randomized copy angles.
random_rotation_seed: SeedValue,
@@ -337,18 +341,16 @@ async fn copy_to_points<I: 'n + Send + Clone>(
async fn round_corners(
_: impl Ctx,
source: List<Vector>,
#[hard_min(0.)]
#[hard(0..)]
#[default(10.)]
radius: PixelLength,
#[range((0., 1.))]
#[hard_min(0.)]
#[hard_max(1.)]
#[range]
#[hard(0..1)]
#[default(0.5)]
roundness: f64,
#[default(100.)] edge_length_limit: Percentage,
#[range((0., 180.))]
#[hard_min(0.)]
#[hard_max(180.)]
#[range]
#[hard(0..180)]
#[default(5.)]
min_angle_threshold: Angle,
) -> List<Vector> {
@@ -452,7 +454,7 @@ pub fn merge_by_distance(
_: impl Ctx,
content: List<Vector>,
#[default(0.1)]
#[hard_min(0.0001)]
#[hard(0.0001..)]
distance: PixelLength,
algorithm: MergeByDistanceAlgorithm,
) -> List<Vector> {
@@ -892,8 +894,8 @@ async fn auto_tangents(
source: List<Vector>,
/// The amount of spread for the auto-tangents, from 0 (sharp corner) to 1 (full spread).
#[default(0.5)]
// TODO: Make this a soft range to allow any value to be typed in outside the slider range of 0 to 1
#[range((0., 1.))]
#[range]
#[soft(0..1)]
spread: f64,
/// If active, existing non-zero handles won't be affected.
#[default(true)]
@@ -1376,16 +1378,16 @@ async fn sample_polyline(
content: List<Vector>,
spacing: PointSpacingType,
#[default(100.)]
#[hard_min(0.)]
#[hard(0..)]
#[unit(" px")]
separation: f64,
#[default(100)]
#[hard_min(2)]
#[hard(2..)]
quantity: u32,
#[hard_min(0.)]
#[hard(0..)]
#[unit(" px")]
start_offset: f64,
#[hard_min(0.)]
#[hard(0..)]
#[unit(" px")]
stop_offset: f64,
adaptive_spacing: bool,
@@ -1830,8 +1832,9 @@ async fn scatter_points(
content: List<Vector>,
#[unit(" px")]
#[default(10.)]
#[hard_min(0.01)]
#[range((1., 100.))]
#[range]
#[hard(0.01..)]
#[soft(1..100)]
separation: f64,
seed: SeedValue,
) -> List<Vector> {