mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-19 10:58:04 +08:00
Finish the convex hull node: in-repo algorithm, robustness, and tests
Replaces the WIP node crate, which depended on a convex_hull crate that only existed outside the repository, with a self-contained implementation: - The curved hull algorithm lives in vector-types' algorithms module. It splits curves at inflections and cusps into curvature-monotone arcs, discovers the boundary structure from a sampled polygonal hull, refines every transition to an exact tangency with closed-form quartic tangent-through-point solves, then emits the boundary as cuts of the original segments joined by straight bridge lines. - The node moves into vector-nodes alongside the other 'Vector: Modifier' nodes, ported to the List/attributes model. It accepts Graphic[] or Vector[], wraps every subpath (open or closed) and free-floating anchor point across all items, and outputs a single world-space hull path. - The boolean-union pre-pass is dropped as unnecessary: the hull of all segments equals the hull of their union, and the polyline connector it relied on lost floating points and mishandled open paths.
This commit is contained in:
@@ -19,17 +19,19 @@ use graphic_types::{Graphic, IntoGraphicList};
|
||||
use kurbo::simplify::{SimplifyOptions, simplify_bezpath};
|
||||
use kurbo::{Affine, BezPath, DEFAULT_ACCURACY, Line, ParamCurve, ParamCurveArclen, PathEl, PathSeg, Shape};
|
||||
use rand::{Rng, SeedableRng};
|
||||
use std::collections::HashSet;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use vector_types::gradient::{build_transform_with_y_preservation, initial_gradient_transform_for_bounding_box};
|
||||
use vector_types::subpath::{BezierHandles, ManipulatorGroup};
|
||||
use vector_types::vector::PointDomain;
|
||||
use vector_types::vector::algorithms::bezpath_algorithms::{self, TValue, eval_pathseg_euclidean, evaluate_bezpath, split_bezpath, tangent_on_bezpath};
|
||||
use vector_types::vector::algorithms::convex_hull::convex_hull_of_geometry;
|
||||
use vector_types::vector::algorithms::merge_by_distance::MergeByDistanceExt;
|
||||
use vector_types::vector::algorithms::offset_subpath::offset_bezpath;
|
||||
use vector_types::vector::algorithms::spline::{solve_spline_first_handle_closed, solve_spline_first_handle_open};
|
||||
use vector_types::vector::misc::{
|
||||
CentroidType, ExtrudeJoiningAlgorithm, HandleId, InterpolationDistribution, MergeByDistanceAlgorithm, PointSpacingType, RowsOrColumns, bezpath_from_manipulator_groups,
|
||||
bezpath_to_manipulator_groups, handles_to_segment, is_linear, point_to_dvec2, segment_to_handles,
|
||||
bezpath_to_manipulator_groups, dvec2_to_point, handles_to_segment, is_linear, point_to_dvec2, segment_to_handles,
|
||||
};
|
||||
use vector_types::vector::style::{GradientStops, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin};
|
||||
use vector_types::vector::{FillId, PointId, RegionId, SegmentDomain, SegmentId, StrokeId, VectorExt};
|
||||
@@ -579,6 +581,67 @@ pub fn merge_by_distance(
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps all of the input geometry in its convex hull: the shape a taut rubber band would form when stretched around it.
|
||||
///
|
||||
/// Convex portions of curved segments are kept exactly as they are, and the boundary departs from a curve only where it must, continuing along a straight bridging line that leaves and rejoins the curves at perfect tangents. The anchor points, floating points, and subpaths (open or closed) of all the input shapes are wrapped together into one combined hull.
|
||||
#[node_macro::node(category("Vector: Modifier"), path(core_types::vector))]
|
||||
async fn convex_hull<I: IntoGraphicList>(
|
||||
_: impl Ctx,
|
||||
/// The `List` of vector paths to wrap in the convex hull. Nested `List`s are automatically flattened.
|
||||
#[implementations(List<Graphic>, List<Vector>)]
|
||||
content: I,
|
||||
) -> List<Vector> {
|
||||
let content = content.into_graphic_list();
|
||||
let flattened: List<Vector> = content.clone().into_flattened_list();
|
||||
|
||||
// Gather the world-space segments and floating anchor points of every input item
|
||||
let mut segments = Vec::new();
|
||||
let mut loose_points = Vec::new();
|
||||
for index in 0..flattened.len() {
|
||||
let Some(element) = flattened.element(index) else { continue };
|
||||
let transform: DAffine2 = flattened.attribute_cloned_or_default(ATTR_TRANSFORM, index);
|
||||
let affine = Affine::new(transform.to_cols_array());
|
||||
|
||||
for bezpath in element.stroke_bezpath_iter() {
|
||||
segments.extend(bezpath.segments().map(|segment| affine * segment));
|
||||
}
|
||||
|
||||
// Anchor points not connected to any segment still participate in the hull
|
||||
let connected_points: HashSet<usize> = element.segment_domain.start_point().iter().chain(element.segment_domain.end_point()).copied().collect();
|
||||
for (point_index, &position) in element.point_domain.positions().iter().enumerate() {
|
||||
if !connected_points.contains(&point_index) {
|
||||
loose_points.push(dvec2_to_point(transform.transform_point2(position)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let hull = convex_hull_of_geometry(&segments, &loose_points);
|
||||
|
||||
// Carry over the attributes and stroke of the last input item, matching the Boolean Operation node
|
||||
let Some(last_index) = flattened.len().checked_sub(1) else { return List::new() };
|
||||
let mut attributes = flattened.clone_item_attributes(last_index);
|
||||
let last_transform: DAffine2 = flattened.attribute_cloned_or_default(ATTR_TRANSFORM, last_index);
|
||||
|
||||
// The hull geometry is built in world space, so the result item carries no transform of its own
|
||||
attributes.insert(ATTR_TRANSFORM, DAffine2::IDENTITY);
|
||||
bake_paint_transforms(&mut attributes, last_transform);
|
||||
|
||||
let mut element = Vector {
|
||||
stroke: flattened.element(last_index).map(|vector| vector.stroke.clone()).unwrap_or_default(),
|
||||
..Default::default()
|
||||
};
|
||||
element.append_bezpath(hull);
|
||||
element.set_stroke_transform(DAffine2::IDENTITY);
|
||||
|
||||
let mut result = List::new();
|
||||
result.push(Item::from_parts(element, attributes));
|
||||
|
||||
// Snapshot the input layers so the renderer can recurse into them for editor click-target preservation
|
||||
result.set_attribute(ATTR_EDITOR_MERGED_LAYERS, 0, content);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
pub mod extrude_algorithms {
|
||||
use glam::DVec2;
|
||||
use kurbo::{ParamCurve, ParamCurveDeriv};
|
||||
@@ -3350,6 +3413,33 @@ mod test {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn convex_hull_wraps_multiple_items_and_floating_points() {
|
||||
// Two squares far apart, each placed by its own transform, plus a free-floating anchor point far above
|
||||
let square = Rect::new(0., 0., 10., 10.).to_path(DEFAULT_ACCURACY);
|
||||
let mut content = List::new();
|
||||
content.push(create_vector_item(square.clone(), DAffine2::IDENTITY));
|
||||
content.push(create_vector_item(square, DAffine2::from_translation(DVec2::new(100., 0.))));
|
||||
|
||||
let mut floating = Vector::default();
|
||||
floating.point_domain.push(PointId::generate(), DVec2::new(50., 200.));
|
||||
content.push(Item::new_from_element(floating));
|
||||
|
||||
let hull = super::convex_hull(Footprint::default(), content).await;
|
||||
let element = hull.element(0).unwrap();
|
||||
|
||||
// The hull is the pentagon spanning both squares' outer corners and the floating point
|
||||
let positions = element.point_domain.positions();
|
||||
assert_eq!(positions.len(), 5, "expected a pentagon, got anchors at {positions:?}");
|
||||
for expected in [DVec2::new(0., 0.), DVec2::new(110., 0.), DVec2::new(110., 10.), DVec2::new(50., 200.), DVec2::new(0., 10.)] {
|
||||
assert!(positions.iter().any(|position| position.distance(expected) < 1e-6), "expected a hull anchor near {expected}");
|
||||
}
|
||||
|
||||
// The hull geometry is emitted in world space with no residual transform
|
||||
let transform: DAffine2 = hull.attribute_cloned_or_default(ATTR_TRANSFORM, 0);
|
||||
assert_eq!(transform, DAffine2::IDENTITY);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sample_polyline() {
|
||||
let path = BezPath::from_vec(vec![PathEl::MoveTo(Point::ZERO), PathEl::CurveTo(Point::ZERO, Point::new(100., 0.), Point::new(100., 0.))]);
|
||||
|
||||
Reference in New Issue
Block a user