Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion editor/src/messages/frontend/utility_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ mod tests {

for file_type in [FileType::Png, FileType::Jpg, FileType::Webp, FileType::Tiff, FileType::Bmp, FileType::Tga, FileType::Ico] {
let encoded = file_type.encode(2, 1, pixels.clone()).unwrap_or_else(|error| panic!("{file_type:?}: {error}"));
let decoded = Image::from_encoded(&encoded).unwrap_or_else(|| panic!("{file_type:?} should read back"));
let decoded = Image::from_encoded(&encoded).unwrap_or_else(|error| panic!("{file_type:?} should read back: {error}"));
assert_eq!((decoded.width, decoded.height), (2, 1), "{file_type:?}");

// Only JPG lacks transparency, so it lands on white
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -640,13 +640,13 @@ fn document_node_definitions() -> HashMap<DefinitionIdentifier, DocumentNodeDefi
.collect(),
..Default::default()
}),
inputs: vec![NodeInput::value(TaggedValue::None, false), NodeInput::value(TaggedValue::String("graphite:null".to_string()), false)],
inputs: vec![NodeInput::value(TaggedValue::None, false), NodeInput::value(TaggedValue::String(String::new()), false)],
input_metadata: vec![
("Empty", "Has no effect, since the image is loaded from the URL.").into(),
("URL", "The web address or local asset path of the image to load.").into(),
],
output_names: vec!["Image".to_string()],
output_descriptions: vec!["The loaded image, which is empty if it fails to load or decode.".to_string()],
output_descriptions: vec!["The loaded image.".to_string()],
..Default::default()
},
description: Cow::Borrowed("Loads an image from a given URL."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1157,7 +1157,7 @@ impl OverlayContextInternal {
// Lay out the text once, taking its dimensions and vector paths from the same thread-local context pass
let (text_size, text_list) = TextContext::with_thread_local(|text_context| {
let text_size = text_context.bounding_box(text, &FALLBACK_FONT_RESOURCE, typesetting, false);
let text_list = text_context.to_path(text, &FALLBACK_FONT_RESOURCE, typesetting, false);
let text_list = text_context.to_path(text, &FALLBACK_FONT_RESOURCE, typesetting, false).unwrap_or_default();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This discards every TextToVectorError and silently renders no overlay text. The bounding box still drives layout, so labels can disappear while their background remains; log the error or propagate it instead of converting it to an empty list.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At editor/src/messages/portfolio/document/overlays/utility_types_native.rs, line 1160:

<comment>This discards every `TextToVectorError` and silently renders no overlay text. The bounding box still drives layout, so labels can disappear while their background remains; log the error or propagate it instead of converting it to an empty list.</comment>

<file context>
@@ -1157,7 +1157,7 @@ impl OverlayContextInternal {
 		let (text_size, text_list) = TextContext::with_thread_local(|text_context| {
 			let text_size = text_context.bounding_box(text, &FALLBACK_FONT_RESOURCE, typesetting, false);
-			let text_list = text_context.to_path(text, &FALLBACK_FONT_RESOURCE, typesetting, false);
+			let text_list = text_context.to_path(text, &FALLBACK_FONT_RESOURCE, typesetting, false).unwrap_or_default();
 			(text_size, text_list)
 		});
</file context>

(text_size, text_list)
});
let text_width = text_size.x;
Expand Down
12 changes: 12 additions & 0 deletions editor/src/messages/portfolio/document_migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3415,6 +3415,18 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, 1), old_inputs[2].clone(), network_path);
}

// Load Image's URL is empty until set, it used to be `graphite:null`
if reference == DefinitionIdentifier::Network("Load Image".into())
&& let Some(url) = node.inputs.get(1)
&& matches!(url.as_value(), Some(TaggedValue::String(url)) if url == "graphite:null")
{
document.network_interface.set_input(
&InputConnector::node_at_index(*node_id, 1),
NodeInput::value(TaggedValue::String(String::new()), url.is_exposed()),
network_path,
);
}

// `wgpu-executor` scope was removed, change to the auto injected scope node `graphene_std::platform_application_io::wgpu_executor`
for (i, input) in node.inputs.iter().enumerate() {
if let NodeInput::Scope(name) = input
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,8 @@ pub fn star_outline(layer: Option<LayerNodeIdentifier>, document: &DocumentMessa
let viewport = document.metadata().transform_to_viewport(layer);

let points = sides as u64;
let targets: Vec<ClickTargetType> = vec![ClickTargetType::Path(star_polygon_bezpath(DVec2::ZERO, points, radius1, radius2))];
let Ok(star) = star_polygon_bezpath(DVec2::ZERO, points, radius1, radius2) else { return };
let targets: Vec<ClickTargetType> = vec![ClickTargetType::Path(star)];

overlay_context.outline(targets.iter(), viewport, None);
}
Expand All @@ -495,7 +496,8 @@ pub fn polygon_outline(layer: Option<LayerNodeIdentifier>, document: &DocumentMe
let viewport = document.metadata().transform_to_viewport(layer);

let points = sides as u64;
let targets: Vec<ClickTargetType> = vec![ClickTargetType::Path(regular_polygon_bezpath(DVec2::ZERO, points, radius))];
let Ok(polygon) = regular_polygon_bezpath(DVec2::ZERO, points, radius) else { return };
let targets: Vec<ClickTargetType> = vec![ClickTargetType::Path(polygon)];

overlay_context.outline(targets.iter(), viewport, None);
}
Expand Down
2 changes: 0 additions & 2 deletions node-graph/interpreted-executor/src/node_registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,6 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => Item<graphene_std::RuntimeHandle>, Context => Item<graphene_std::ContextModification>]),
async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => Item<graphene_std::SourceId>, Context => Item<graphene_std::ContextModification>]),
async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => Item<wgpu_executor::WgpuExecutor>, Context => Item<graphene_std::ContextModification>]),
async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => Item<Option<wgpu_executor::WgpuExecutor>>, Context => Item<graphene_std::ContextModification>]),
async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => Item<wgpu_executor::WgpuPipelineCache>, Context => Item<graphene_std::ContextModification>]),
// ==========
// MEMO NODES
Expand Down Expand Up @@ -147,7 +146,6 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => List<graphene_std::brush::Stroke>]),
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<RenderIntermediate>]),
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<wgpu_executor::WgpuExecutor>]),
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<Option<wgpu_executor::WgpuExecutor>>]),
async_node!(graphene_core::memo::MemoizeNode<_, _>, input: Context, fn_params: [Context => Item<wgpu_executor::WgpuPipelineCache>]),
];
// The per-connector input adapter, registered per element type: an `Item` or `List` wire passes through unchanged.
Expand Down
4 changes: 2 additions & 2 deletions node-graph/interpreted-executor/src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ pub fn wrap_network_in_scope(network: NodeNetwork, editor_api: Arc<PlatformEdito
},
DocumentNode {
call_argument: concrete!(Context),
inputs: vec![NodeInput::scope(graphene_std::platform_application_io::try_wgpu_executor::IDENTIFIER), NodeInput::node(NodeId(0), 0)],
inputs: vec![NodeInput::scope(graphene_std::platform_application_io::wgpu_executor::IDENTIFIER), NodeInput::node(NodeId(0), 0)],
implementation: DocumentNodeImplementation::ProtoNode(graphene_std::render_node::render::IDENTIFIER),
compile_metadata: Some(graphene_std::NodeCompileMetadata {
extract: ContextFeatures::FOOTPRINT | ContextFeatures::VARARGS,
Expand All @@ -52,7 +52,7 @@ pub fn wrap_network_in_scope(network: NodeNetwork, editor_api: Arc<PlatformEdito
DocumentNode {
call_argument: concrete!(Context),
inputs: vec![
NodeInput::scope(graphene_std::platform_application_io::try_wgpu_executor::IDENTIFIER),
NodeInput::scope(graphene_std::platform_application_io::wgpu_executor::IDENTIFIER),
NodeInput::scope(graphene_std::platform_application_io::editor_api::IDENTIFIER),
NodeInput::node(NodeId(1), 0),
],
Expand Down
13 changes: 12 additions & 1 deletion node-graph/libraries/core-types/src/transfer_curve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ impl TransferCurveEvaluator {
}

// O(log n) search for the segment holding x
let upper = points.partition_point(|point| point.x <= x).min(points.len() - 1);
let upper = points.partition_point(|point| point.x <= x).clamp(1, points.len() - 1);
let lower = upper - 1;
let (a, b) = (points[lower], points[upper]);
let width = (b.x - a.x).max(f64::EPSILON);
Expand Down Expand Up @@ -206,6 +206,17 @@ mod tests {
}
}

#[test]
fn sampling_survives_a_value_or_point_that_is_not_a_number() {
assert!(TransferCurve::default().evaluate(f64::NAN).is_nan());

// A negative NaN sorts first, so every x between the points meets it
let curve = TransferCurve::new(vec![DVec2::new(-f64::NAN, 0.), DVec2::new(0.5, 0.7), DVec2::ONE]);
for x in (1..100).map(|i| i as f64 / 100.) {
curve.evaluate(x);
}
}

#[test]
fn a_moved_point_may_pass_another_while_keeping_its_index() {
let mut curve = TransferCurve::default();
Expand Down
4 changes: 4 additions & 0 deletions node-graph/libraries/core-types/src/transform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,10 @@ impl Footprint {
};

pub fn viewport_bounds_in_local_space(&self) -> AxisAlignedBbox {
if self.transform.matrix2.determinant() == 0. {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This guard only handles an exactly zero determinant. NaN or subnormal determinants still reach inverse(), which can panic with debug-glam-assert; reject non-finite reciprocals as the renderer does.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At node-graph/libraries/core-types/src/transform.rs, line 192:

<comment>This guard only handles an exactly zero determinant. NaN or subnormal determinants still reach `inverse()`, which can panic with `debug-glam-assert`; reject non-finite reciprocals as the renderer does.</comment>

<file context>
@@ -189,6 +189,10 @@ impl Footprint {
 	};
 
 	pub fn viewport_bounds_in_local_space(&self) -> AxisAlignedBbox {
+		if self.transform.matrix2.determinant() == 0. {
+			return AxisAlignedBbox::ZERO;
+		}
</file context>
Suggested change
if self.transform.matrix2.determinant() == 0. {
if !self.transform.matrix2.determinant().recip().is_finite() {

return AxisAlignedBbox::ZERO;
}

let inverse = self.transform.inverse();
let res = self.resolution.as_dvec2();
let c0 = inverse.transform_point2(DVec2::ZERO);
Expand Down
1 change: 1 addition & 0 deletions node-graph/libraries/raster-types/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ base64 = { workspace = true }
bytemuck = { workspace = true }
image = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }

# Optional workspace dependencies
serde = { workspace = true, optional = true }
Expand Down
76 changes: 67 additions & 9 deletions node-graph/libraries/raster-types/src/image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,14 @@ impl<P: core_types::CacheHash + Pixel> core_types::CacheHash for Image<P> {
}
}

#[derive(Debug, thiserror::Error)]
pub enum ImageError {
#[error("The {width}×{height} px image is too large")]
TooLarge { width: u32, height: u32 },
#[error("The file is not an image in a supported format, or it is damaged")]
Undecodable(#[source] ::image::ImageError),
}

impl<P: Pixel> Image<P> {
pub fn new(width: u32, height: u32, color: P) -> Self {
Self {
Expand All @@ -139,6 +147,23 @@ impl<P: Pixel> Image<P> {
base64_string: None,
}
}

/// An image filled with `color`, failing where the pixel count overflows the `u32` pixel indices or can't be allocated.
pub fn try_new(width: u32, height: u32, color: P) -> Result<Self, ImageError> {
let too_large = || ImageError::TooLarge { width, height };
let count = width.checked_mul(height).ok_or_else(too_large)? as usize;

let mut data = Vec::new();
data.try_reserve_exact(count).map_err(|_| too_large())?;
data.resize(count, color);

Ok(Self {
width,
height,
data,
base64_string: None,
})
}
}

impl Image<Color> {
Expand All @@ -153,8 +178,8 @@ impl Image<Color> {
}
}

/// Decodes an image file of any supported format.
pub fn from_encoded(data: &[u8]) -> Option<Self> {
/// Decodes an image file of any supported format, or gives the reason it can't be.
pub fn from_encoded(data: &[u8]) -> Result<Self, ImageError> {
let image = decode(data)?;

// Float samples hold linear light, as in HDR and EXR files, while integer samples are gamma encoded
Expand All @@ -178,7 +203,7 @@ impl Image<Color> {
})
.collect();

Some(Image {
Ok(Image {
width: image.width(),
height: image.height(),
data,
Expand All @@ -188,7 +213,7 @@ impl Image<Color> {

/// The pixel size of an image file, if it decodes in full as [`Image::from_encoded`] needs it to.
pub fn encoded_size(data: &[u8]) -> Option<(u32, u32)> {
decode(data).map(|image| (image.width(), image.height()))
decode(data).ok().map(|image| (image.width(), image.height()))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: encoded_size drops the error here, so an image that is too large is reported as unreadable. The ingest handler then shows "This file could not be read as an image." Consider returning Result<(u32, u32), ImageError> and passing TooLarge through to the ingest message.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At node-graph/libraries/raster-types/src/image.rs, line 216:

<comment>`encoded_size` drops the error here, so an image that is too large is reported as unreadable. The ingest handler then shows "This file could not be read as an image." Consider returning `Result<(u32, u32), ImageError>` and passing `TooLarge` through to the ingest message.</comment>

<file context>
@@ -188,7 +213,7 @@ impl Image<Color> {
 	/// The pixel size of an image file, if it decodes in full as [`Image::from_encoded`] needs it to.
 	pub fn encoded_size(data: &[u8]) -> Option<(u32, u32)> {
-		decode(data).map(|image| (image.width(), image.height()))
+		decode(data).ok().map(|image| (image.width(), image.height()))
 	}
 
</file context>

}

pub fn to_png(&self) -> Vec<u8> {
Expand All @@ -202,9 +227,17 @@ impl Image<Color> {
}

/// A TGA file has no signature to recognize it by, so a file of no recognized format is tried as one.
fn decode(data: &[u8]) -> Option<::image::DynamicImage> {
fn decode(data: &[u8]) -> Result<::image::DynamicImage, ImageError> {
let format = ::image::guess_format(data).unwrap_or(::image::ImageFormat::Tga);
::image::load_from_memory_with_format(data, format).ok()
::image::load_from_memory_with_format(data, format).map_err(|error| {
// The memory limit error carries no size, which the header gives
if let ::image::ImageError::Limits(_) = error
&& let Ok((width, height)) = ::image::ImageReader::with_format(std::io::Cursor::new(data), format).into_dimensions()
{
return ImageError::TooLarge { width, height };
}
ImageError::Undecodable(error)
})
}

use super::*;
Expand Down Expand Up @@ -346,12 +379,37 @@ mod test {
let mut encoded = Vec::new();
pixels.write_to(&mut std::io::Cursor::new(&mut encoded), format).unwrap();

let image = Image::from_encoded(&encoded).unwrap_or_else(|| panic!("{format:?} should decode"));
let image = Image::from_encoded(&encoded).unwrap_or_else(|error| panic!("{format:?} should decode: {error}"));
assert_eq!(Image::encoded_size(&encoded), Some((2, 1)), "{format:?}");
assert_eq!(image.to_flat_u8().0, pixels.as_raw().as_slice(), "{format:?}");
}

assert!(Image::from_encoded(b"not an image").is_none());
assert!(Image::from_encoded(b"not an image").is_err());
}

#[test]
fn a_file_that_does_not_decode_gives_its_reason() {
use super::*;

assert!(matches!(Image::from_encoded(b"not an image"), Err(ImageError::Undecodable(_))));

// A BMP header for a 32-bit image of 30000×30000 pixels, which is far beyond the decoder's memory limit
let mut file = b"BM".to_vec();
for field in [54_u32, 0, 54, 40, 30000, 30000] {
file.extend(field.to_le_bytes());
}
file.extend([1, 0, 32, 0]);
file.extend([0; 24]);
let error = Image::from_encoded(&file).unwrap_err();
assert!(matches!(error, ImageError::TooLarge { width: 30000, height: 30000 }), "{error:?}");
}

#[test]
fn an_image_too_large_for_its_pixel_indices_is_not_made() {
use super::*;

assert!(matches!(Image::try_new(65536, 65536, Color::BLACK), Err(ImageError::TooLarge { width: 65536, height: 65536 })));
assert_eq!(Image::try_new(3, 2, Color::BLACK).unwrap().data, vec![Color::BLACK; 6]);
}

#[test]
Expand All @@ -371,7 +429,7 @@ mod test {
file.extend([0, 255, 0, 0]);

assert!(::image::load_from_memory_with_format(&file, ::image::ImageFormat::Tga).is_ok());
assert!(Image::from_encoded(&file).is_none());
assert!(Image::from_encoded(&file).is_err());
}

#[test]
Expand Down
Loading
Loading