diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index 12f939a8b5..7258de0aec 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -2262,6 +2262,7 @@ fn static_nodes() -> Vec { NodeInput::value(TaggedValue::F64(TypesettingConfig::default().character_spacing), false), NodeInput::value(TaggedValue::OptionalF64(TypesettingConfig::default().max_width), false), NodeInput::value(TaggedValue::OptionalF64(TypesettingConfig::default().max_height), false), + NodeInput::value(TaggedValue::Bool(false), false), ], ..Default::default() }, @@ -2315,6 +2316,7 @@ fn static_nodes() -> Vec { ..Default::default() }), ), + PropertiesRow::with_override("Instances", "TODO", WidgetOverride::Custom("instances".to_string())), ], output_names: vec!["Vector".to_string()], ..Default::default() diff --git a/node-graph/gcore/src/text/to_path.rs b/node-graph/gcore/src/text/to_path.rs index 4a0e13cb32..092b5a8638 100644 --- a/node-graph/gcore/src/text/to_path.rs +++ b/node-graph/gcore/src/text/to_path.rs @@ -1,6 +1,8 @@ -use crate::vector::PointId; +use crate::instances::Instance; +use crate::vector::{PointId, VectorData, VectorDataTable}; +use crate::{GraphicElement, GraphicGroupTable}; use bezier_rs::{ManipulatorGroup, Subpath}; -use glam::DVec2; +use glam::{DAffine2, DVec2}; use rustybuzz::ttf_parser::{GlyphId, OutlineBuilder}; use rustybuzz::{GlyphBuffer, UnicodeBuffer}; @@ -247,3 +249,151 @@ fn split_words_including_spaces() { assert_eq!(split_words.next(), Some(".")); assert_eq!(split_words.next(), None); } + +// Builder specifically for generating glyph paths relative to (0,0) +struct GlyphBuilder { + current_subpath: Subpath, + other_subpaths: Vec>, + ascender: f64, + scale: f64, + id: PointId, +} + +impl GlyphBuilder { + // Calculates point relative to glyph origin, scaled and adjusted for ascender + fn point(&self, x: f32, y: f32) -> DVec2 { + DVec2::new(x as f64, self.ascender - y as f64) * self.scale + } + + // Extracts the generated subpaths and resets the builder state + fn take_subpaths(&mut self) -> Vec> { + let mut subpaths = std::mem::take(&mut self.other_subpaths); + if !self.current_subpath.is_empty() { + subpaths.push(std::mem::replace(&mut self.current_subpath, Subpath::new(Vec::new(), false))); + } + subpaths + } +} + +impl OutlineBuilder for GlyphBuilder { + fn move_to(&mut self, x: f32, y: f32) { + if !self.current_subpath.is_empty() { + self.other_subpaths.push(core::mem::replace(&mut self.current_subpath, Subpath::new(Vec::new(), false))); + } + self.current_subpath.push_manipulator_group(ManipulatorGroup::new_anchor_with_id(self.point(x, y), self.id.next_id())); + } + + fn line_to(&mut self, x: f32, y: f32) { + self.current_subpath.push_manipulator_group(ManipulatorGroup::new_anchor_with_id(self.point(x, y), self.id.next_id())); + } + + fn quad_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32) { + let [handle, anchor] = [self.point(x1, y1), self.point(x2, y2)]; + self.current_subpath.last_manipulator_group_mut().unwrap().out_handle = Some(handle); + self.current_subpath.push_manipulator_group(ManipulatorGroup::new_with_id(anchor, None, None, self.id.next_id())); + } + + fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x3: f32, y3: f32) { + let [handle1, handle2, anchor] = [self.point(x1, y1), self.point(x2, y2), self.point(x3, y3)]; + self.current_subpath.last_manipulator_group_mut().unwrap().out_handle = Some(handle1); + self.current_subpath + .push_manipulator_group(ManipulatorGroup::new_with_id(anchor, Some(handle2), None, self.id.next_id())); + } + + fn close(&mut self) { + self.current_subpath.set_closed(true); + self.other_subpaths.push(core::mem::replace(&mut self.current_subpath, Subpath::new(Vec::new(), false))); + } +} + +/// Converts a string into a graphic group, where each element is a single character glyph as VectorData. +pub fn to_group(str: &str, buzz_face: Option, typesetting: TypesettingConfig) -> GraphicGroupTable { + let Some(buzz_face) = buzz_face else { return GraphicGroupTable::empty() }; + let space_glyph = buzz_face.glyph_index(' '); + + let (scale, line_height, mut buffer) = font_properties(&buzz_face, typesetting.font_size, typesetting.line_height_ratio); + let ascender = (buzz_face.ascender() as f64 / buzz_face.height() as f64) * typesetting.font_size / scale; + + let mut result_group = GraphicGroupTable::empty(); // Changed from Instances::empty() + let mut text_cursor = DVec2::ZERO; + let mut point_id_gen = PointId::ZERO; // Use a single generator across all glyphs + + for line in str.split('\n') { + for (index, word) in SplitWordsIncludingSpaces::new(line).enumerate() { + push_str(&mut buffer, word); + let glyph_buffer = rustybuzz::shape(&buzz_face, &[], buffer); + + // Don't wrap the first word + if index != 0 && wrap_word(typesetting.max_width, &glyph_buffer, scale, typesetting.character_spacing, text_cursor.x, space_glyph) { + text_cursor = DVec2::new(0., text_cursor.y + line_height); + } + + for (glyph_position, glyph_info) in glyph_buffer.glyph_positions().iter().zip(glyph_buffer.glyph_infos()) { + let glyph_id = GlyphId(glyph_info.glyph_id as u16); + + // Calculate the intended render position for this glyph's origin before advancing the cursor + let current_glyph_offset = DVec2::new(glyph_position.x_offset as f64, glyph_position.y_offset as f64) * scale; + let glyph_render_pos = text_cursor + current_glyph_offset; + + // Check for line wrap based on advance width BEFORE rendering the current glyph + if let Some(max_width) = typesetting.max_width { + if space_glyph != Some(glyph_id) && text_cursor.x + (glyph_position.x_advance as f64 * scale * typesetting.character_spacing) >= max_width { + // Move cursor to the next line for the *next* glyph + text_cursor = DVec2::new(0., text_cursor.y + line_height); + // Note: The current glyph still renders based on the glyph_render_pos calculated before the wrap check. + } + } + + // Clip when the height is exceeded - stop adding instances + if typesetting.max_height.is_some_and(|max_height| text_cursor.y > max_height - line_height) { + return result_group; // Changed from result_instances + } + + // Build the glyph geometry relative to its origin (0,0) + let mut glyph_builder = GlyphBuilder { + current_subpath: Subpath::new(Vec::new(), false), + other_subpaths: Vec::new(), + ascender, + scale, + id: point_id_gen, // Pass the current generator state + }; + + buzz_face.outline_glyph(glyph_id, &mut glyph_builder); + point_id_gen = glyph_builder.id; // Update the generator state for the next glyph + + let glyph_subpaths = glyph_builder.take_subpaths(); + + // Create an instance only if the glyph has geometry (e.g., not for empty spaces if they have no outline) + if !glyph_subpaths.is_empty() { + // Create VectorData for this single glyph + let glyph_vector_data = VectorData::from_subpaths(glyph_subpaths, true); + // Wrap VectorData in VectorDataTable + let glyph_vector_table = VectorDataTable::new(glyph_vector_data); + // TODO: Consider inheriting style (fill/stroke) if needed in the future. + + // Create the transform to position this glyph instance + let glyph_transform = DAffine2::from_translation(glyph_render_pos); + + // Add the instance to the results group + result_group.push(Instance { + // Changed from result_instances.push + instance: GraphicElement::VectorData(glyph_vector_table), // Changed instance type + transform: glyph_transform, + alpha_blending: Default::default(), // Use default blending for now + source_node_id: None, // Glyphs don't have a specific source node ID + }); + } + + // Advance the main text cursor based on the glyph's advance width for the next glyph + text_cursor += DVec2::new(glyph_position.x_advance as f64 * typesetting.character_spacing, glyph_position.y_advance as f64) * scale; + } + + buffer = glyph_buffer.clear(); + } + + // Move cursor down for the next line + text_cursor = DVec2::new(0., text_cursor.y + line_height); + } + + result_group // Changed from result_instances +} diff --git a/node-graph/gcore/src/vector/vector_nodes.rs b/node-graph/gcore/src/vector/vector_nodes.rs index d566316f44..329ffbd5f6 100644 --- a/node-graph/gcore/src/vector/vector_nodes.rs +++ b/node-graph/gcore/src/vector/vector_nodes.rs @@ -806,6 +806,98 @@ async fn select_subpath_by_index(_ctx: impl Ctx + ExtractAll + CloneVarArgs, vec result_table } +#[node_macro::node(name("Contains Shape"), category("Vector"), path(graphene_core::vector))] +async fn contains_shape( + _: impl Ctx, + /// The vector data representing the shape to check for containment. + shape_to_check: VectorDataTable, + /// The vector data representing the shape to check against. + #[expose] + filter_shape: VectorDataTable, + /// If true, requires all points of the shape to be contained. If false, requires at least one point to be contained. + #[default(true)] + require_full_containment: bool, +) -> bool { + let shape_transform = shape_to_check.transform(); + let shape_instance = shape_to_check.one_instance_ref().instance; + + let filter_transform = filter_shape.transform(); + let filter_instance = filter_shape.one_instance_ref().instance; + + // Pre-transform filter subpaths into world space for efficient checking + let filter_subpaths_world: Vec<_> = filter_instance + .stroke_bezier_paths() + .map(|mut subpath| { + subpath.apply_transform(filter_transform); + subpath + }) + .collect(); + + // If the filter shape has no geometry, containment is impossible (unless the shape to check is also empty) + if filter_subpaths_world.is_empty() { + return shape_instance.point_domain.positions().is_empty(); + } + + let points_to_check = shape_instance.point_domain.positions(); + + // Handle empty shape_to_check + if points_to_check.is_empty() { + return require_full_containment; // Empty shape is fully contained, but not partially contained. + } + + let mut points_contained_count = 0; + + // Iterate through each point in the input shape_to_check + for point_pos in points_to_check { + // Transform the point into world space + let point_world = shape_transform.transform_point2(*point_pos); + + // Check if the point is contained within any of the filter subpaths + let is_contained = filter_subpaths_world.iter().any(|subpath| subpath.contains_point(point_world)); + + if require_full_containment { + // If full containment is required, return false immediately if any point is outside + if !is_contained { + return false; + } + points_contained_count += 1; // Only needed to confirm loop ran if all points were contained + } else { + // If partial containment is sufficient, return true immediately if any point is inside + if is_contained { + return true; + } + } + } + + // If full containment was required and we looped through all points without returning false, it means all points were contained. + // If partial containment was required and we looped through all points without returning true, it means no points were contained. + require_full_containment && points_contained_count > 0 +} + +#[node_macro::node(name("Group Count"), category("Vector"), path(graphene_core::vector))] +async fn group_count(_: impl Ctx, group: GraphicGroupTable, #[default(false)] recurse: bool) -> u64 { + // Helper function to handle recursion + fn count_groups_recursive(group: &GraphicGroupTable) -> u64 { + let mut count = group.len() as u64; + + for instance in group.instance_ref_iter() { + if let GraphicElement::GraphicGroup(subgroup) = &instance.instance { + count += count_groups_recursive(subgroup); + } + } + + count + } + + // Non-recursive count just returns the number of items + if !recurse { + return group.len() as u64; + } + + // Use the helper function for recursive counting + count_groups_recursive(&group) +} + #[node_macro::node(name("Select Points Within Shape"), category("Vector"), path(graphene_core::vector))] async fn select_points_within_shape( _: impl Ctx, diff --git a/node-graph/gstd/src/text.rs b/node-graph/gstd/src/text.rs index 999e6bb730..102c0e380d 100644 --- a/node-graph/gstd/src/text.rs +++ b/node-graph/gstd/src/text.rs @@ -1,10 +1,10 @@ use crate::vector::{VectorData, VectorDataTable}; use graph_craft::wasm_application_io::WasmEditorApi; -use graphene_core::Ctx; use graphene_core::text::TypesettingConfig; -pub use graphene_core::text::{Font, FontCache, bounding_box, load_face, to_path}; +pub use graphene_core::text::{Font, FontCache, bounding_box, load_face, to_group, to_path}; +use graphene_core::{Ctx, GraphicGroupTable}; -#[node_macro::node(category(""))] +#[node_macro::node(category("Text"))] // Changed category for clarity fn text<'i: 'n>( _: impl Ctx, editor: &'i WasmEditorApi, @@ -15,7 +15,9 @@ fn text<'i: 'n>( #[default(1.)] character_spacing: f64, #[default(None)] max_width: Option, #[default(None)] max_height: Option, -) -> VectorDataTable { + #[default(false)] output_instances: bool, // Added parameter +) -> GraphicGroupTable { + // Changed return type let buzz_face = editor.font_cache.get(&font_name).map(|data| load_face(data)); let typesetting = TypesettingConfig { @@ -26,7 +28,12 @@ fn text<'i: 'n>( max_height, }; - let result = VectorData::from_subpaths(to_path(&text, buzz_face, typesetting), false); - - VectorDataTable::new(result) + if output_instances { + to_group(&text, buzz_face, typesetting) + } else { + let vector_data = VectorData::from_subpaths(to_path(&text, buzz_face, typesetting), false); + let vector_table = VectorDataTable::new(vector_data); + // Convert VectorDataTable into GraphicGroupTable + vector_table.into() + } }