diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 58467c78ec..40f17065aa 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,6 +4,8 @@ on: push: branches: - master + tags: + - latest-stable workflow_dispatch: inputs: web: @@ -59,8 +61,6 @@ jobs: RUSTC_WRAPPER: /usr/bin/sccache CARGO_INCREMENTAL: 0 SCCACHE_DIR: /var/lib/github-actions/.cache - INDEX_HTML_HEAD_REPLACEMENT: - steps: - name: πŸ“₯ Clone repository uses: actions/checkout@v6 @@ -90,9 +90,22 @@ jobs: rustflags: "" target: wasm32-unknown-unknown + - name: πŸ”€ Choose production deployment environment + id: production-env + if: github.event_name == 'push' + run: | + if [[ "${{ github.ref }}" == "refs/tags/latest-stable" ]]; then + echo "cf_project=graphite-editor" >> $GITHUB_OUTPUT + DOMAIN="editor.graphite.art" + else + echo "cf_project=graphite-dev" >> $GITHUB_OUTPUT + DOMAIN="dev.graphite.art" + fi + echo "head_replacement=" >> $GITHUB_OUTPUT + - name: βœ‚ Replace template in of index.html if: github.event_name == 'push' - run: sed -i "s||$INDEX_HTML_HEAD_REPLACEMENT|" frontend/index.html + run: sed -i "s||${{ steps.production-env.outputs.head_replacement }}|" frontend/index.html - name: 🌐 Build Graphite web code env: @@ -105,11 +118,15 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} run: | + if [ -z "$CLOUDFLARE_API_TOKEN" ]; then + echo "No Cloudflare API token available (fork PR), skipping deploy." + exit 0 + fi MAX_ATTEMPTS=5 DELAY=30 for ATTEMPT in $(seq 1 $MAX_ATTEMPTS); do echo "Attempt $ATTEMPT of $MAX_ATTEMPTS..." - if OUTPUT=$(npx wrangler@3 pages deploy "frontend/dist" --project-name="graphite-dev" --commit-dirty=true 2>&1); then + if OUTPUT=$(npx wrangler@3 pages deploy "frontend/dist" --project-name="${{ steps.production-env.outputs.cf_project }}" --commit-dirty=true 2>&1); then URL=$(echo "$OUTPUT" | grep -oP 'https://[^\s]+\.pages\.dev' | head -1) echo "url=$URL" >> "$GITHUB_OUTPUT" echo "Published successfully: $URL" @@ -126,6 +143,43 @@ jobs: echo "All $MAX_ATTEMPTS Cloudflare Pages publish attempts failed." exit 1 + - name: πŸš€ Create GitHub Deployment for "View deployment" button + if: inputs.checkout_repo == '' || inputs.checkout_repo == github.repository + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CF_URL: ${{ steps.cloudflare.outputs.url }} + run: | + if [ -z "$CF_URL" ]; then + echo "No Cloudflare URL available, skipping deployment." + exit 0 + fi + if [ "${{ github.ref }}" = "refs/tags/latest-stable" ]; then + REF="latest-stable" + ENVIRONMENT="graphite-editor (Production)" + elif [ "${{ github.event_name }}" = "push" ]; then + REF="master" + ENVIRONMENT="graphite-dev (Production)" + else + REF="${{ inputs.checkout_ref || github.head_ref || github.ref_name }}" + ENVIRONMENT="graphite-dev (Preview)" + fi + DEPLOY_ID=$(gh api \ + -X POST \ + -H "Accept: application/vnd.github+json" \ + repos/${{ github.repository }}/deployments \ + --input - \ + --jq '.id' < - - steps: - - name: πŸ“₯ Clone repository - uses: actions/checkout@v6 - - - name: πŸ—‘ Clear wasm-bindgen cache - run: rm -r ~/.cache/.wasm-pack - - - name: 🟒 Install Node.js - uses: actions/setup-node@v6 - with: - node-version-file: .nvmrc - - - name: 🚧 Install build dependencies - run: | - cd frontend - npm run setup - - - name: πŸ¦€ Install Rust - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: - toolchain: stable - override: true - cache: false - rustflags: "" - target: wasm32-unknown-unknown - - - name: βœ‚ Replace template in of index.html - run: sed -i "s||$INDEX_HTML_HEAD_REPLACEMENT|" frontend/index.html - - - name: 🌐 Build Graphite web code - env: - NODE_ENV: production - run: mold -run cargo run build web - - - name: πŸ“€ Publish to Cloudflare Pages - env: - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - run: npx wrangler@3 pages deploy "frontend/dist" --project-name="graphite-editor" --branch="master" --commit-dirty=true - - - name: πŸ“¦ Upload assets to GitHub release - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - DATE=$(git log -1 --format=%cd --date=format:%Y-%m-%d) - cd frontend - sed -i "s|$INDEX_HTML_HEAD_REPLACEMENT||" dist/index.html - mv dist "graphite-$DATE" - zip -r "graphite-self-hosted-build.zip" "graphite-$DATE" - gh release upload latest-stable "graphite-self-hosted-build.zip" --clobber diff --git a/frontend/src/components/widgets/inputs/NumberInput.svelte b/frontend/src/components/widgets/inputs/NumberInput.svelte index 3f5a71d74f..dc018c6a8a 100644 --- a/frontend/src/components/widgets/inputs/NumberInput.svelte +++ b/frontend/src/components/widgets/inputs/NumberInput.svelte @@ -2,9 +2,9 @@ import { createEventDispatcher, onMount, onDestroy, getContext } from "svelte"; import { evaluateMathExpression, isPlatformNative } from "@graphite/../wasm/pkg/graphite_wasm"; - import type { NumberInputMode, NumberInputIncrementBehavior, ActionShortcut } from "@graphite/../wasm/pkg/graphite_wasm"; import type { Editor } from "@graphite/editor"; import { PRESS_REPEAT_DELAY_MS, PRESS_REPEAT_INTERVAL_MS } from "@graphite/io-managers/input"; + import type { NumberInputMode, NumberInputIncrementBehavior, ActionShortcut } from "@graphite/messages"; import { browserVersion } from "@graphite/utility-functions/platform"; import { preventEscapeClosingParentFloatingMenu } from "@graphite/components/layout/FloatingMenu.svelte"; @@ -43,8 +43,8 @@ export let isInteger = false; /// `incrementBehavior` is only applicable with a `mode` of "Increment". /// "Add"/"Multiply": The value is added or multiplied by `step`. - /// "Callback": the functions `incrementCallbackIncrease` and `incrementCallbackDecrease` call custom behavior. /// "None": the increment arrows are not shown. + /// "Callback": the functions `incrementCallbackIncrease` and `incrementCallbackDecrease` call custom behavior. export let incrementBehavior: NumberInputIncrementBehavior = "Add"; export let displayDecimalPlaces = 2; export let unit = ""; @@ -386,13 +386,18 @@ // Tell the backend that we are beginning a transaction for the history system startDragging(); - // We ignore the first event invocation's `e.movementX` value because it's unreliable. - // In both Chrome and Firefox (tested on Windows 10), the first `e.movementX` value is occasionally a very large number - // (around positive 1000, even if movement was in the negative direction). This seems to happen more often if the movement is rapid. - // TODO: On rarer occasions, it isn't sufficient to ignore just the first event, so this solution is imperfect. - // TODO: Using a counter to ignore more frames helps progressively decreaseβ€”but not eliminateβ€”the issue, but it makes drag initiation feel delayed so we don't do that. - // TODO: A better solution will need to discard outlier movement values across multiple frames by basically implementing a time-series data analysis filtering algorithm. - let ignoredFirstMovement = false; + // The first few `movementX` values reported after entering pointer lock are often wildly wrong in Chrome + // (and occasionally in Firefox). We used to skip just the first event, but that wasn't enough β€” sometimes + // 2 or 3 bogus events come through with deltas around Β±1000px even when the mouse barely moved. + // So now we skip the first event entirely and clamp any suspiciously large deltas during a short settling + // window after that. 50px is way more than a real human moves in one event frame at drag start, but way + // less than the garbage values the API spits out. We also have a time-based fallback for high-polling-rate + // mice that might burn through the event count in just a few ms. + const SETTLING_EVENTS = 5; + const SETTLING_MS = 80; + const MAX_DELTA = 50; + let moveEvents = 0; + const dragStart = performance.now(); const pointerUp = () => { // Confirm on release by setting the reset value to the current value, so once the pointer lock ends, @@ -422,19 +427,31 @@ } // Calculate and then update the dragged value offset, slowed down by 10x when Shift is held. - if (ignoredFirstMovement && initialValueBeforeDragging !== undefined) { - pointerLockMoveUpdate(e.movementX, e.shiftKey, e.ctrlKey, initialValueBeforeDragging); + moveEvents += 1; + if (moveEvents === 1) return; + + if (initialValueBeforeDragging !== undefined) { + let delta = e.movementX; + const settling = moveEvents <= SETTLING_EVENTS || performance.now() - dragStart < SETTLING_MS; + // Don't clamp to the sign β€” bogus values point in random directions so that would still be wrong + if (settling && Math.abs(delta) > MAX_DELTA) delta = 0; + + pointerLockMoveUpdate(delta, e.shiftKey, e.ctrlKey, initialValueBeforeDragging); } - ignoredFirstMovement = true; }; - // On desktop we don't get `pointermove` events while in pointer lock (CEF doesn't support pointer lock). + // On desktop we don't get `pointermove` events while in pointer lock (cef doesn't support pointer lock). // We have to listen for our custom `pointerlockmove` events instead. - const pointerLockMove = ({ detail }: WindowEventMap["pointerlockmove"]) => { - if (ignoredFirstMovement && initialValueBeforeDragging !== undefined) { - const delta = detail.x; + const pointerLockMove = (e: Event) => { + moveEvents += 1; + if (moveEvents === 1) return; + + if (initialValueBeforeDragging !== undefined && e instanceof CustomEvent) { + let delta = (e.detail as { x: number }).x; + const settling = moveEvents <= SETTLING_EVENTS || performance.now() - dragStart < SETTLING_MS; + if (settling && Math.abs(delta) > MAX_DELTA) delta = 0; + pointerLockMoveUpdate(delta, shiftKeyDown, ctrlKeyDown, initialValueBeforeDragging); } - ignoredFirstMovement = true; }; const pointerLockChange = () => { // Do nothing if we just entered, rather than exited, pointer lock. @@ -469,7 +486,7 @@ cumulativeDragDelta += dragDelta; const combined = initialValue + cumulativeDragDelta; - const combineSnapped = snapping || isInteger ? Math.round(combined) : combined; + const combineSnapped = snapping ? Math.round(combined) : combined; const newValue = updateValue(combineSnapped); diff --git a/node-graph/libraries/vector-types/src/vector/vector_types.rs b/node-graph/libraries/vector-types/src/vector/vector_types.rs index e504bb7199..101c1a884c 100644 --- a/node-graph/libraries/vector-types/src/vector/vector_types.rs +++ b/node-graph/libraries/vector-types/src/vector/vector_types.rs @@ -427,6 +427,38 @@ impl Vector { } } + pub fn detect_colinear_manipulators(&mut self) + where + Upstream: 'static, + { + self.colinear_manipulators.clear(); + for index in 0..self.point_domain.ids().len() { + let point_pos = self.point_domain.positions()[index]; + + let mut connected = Vec::new(); + for seg_id in self.segment_domain.start_connected(index) { + connected.push(HandleId::primary(seg_id)); + } + for seg_id in self.segment_domain.end_connected(index) { + connected.push(HandleId::end(seg_id)); + } + + if connected.len() == 2 { + let h1 = connected[0]; + let h2 = connected[1]; + + if let (Some(pos1), Some(pos2)) = (h1.to_manipulator_point().get_position(self), h2.to_manipulator_point().get_position(self)) { + let vec1 = (pos1 - point_pos).normalize_or_zero(); + let vec2 = (pos2 - point_pos).normalize_or_zero(); + + if vec1.dot(vec2) < -0.99999 { + self.colinear_manipulators.push([h1, h2]); + } + } + } + } + } + pub fn concat(&mut self, additional: &Self, transform_of_additional: DAffine2, collision_hash_seed: u64) { let point_map = additional .point_domain diff --git a/node-graph/nodes/path-bool/src/lib.rs b/node-graph/nodes/path-bool/src/lib.rs index 3a80a44189..55482d8dc6 100644 --- a/node-graph/nodes/path-bool/src/lib.rs +++ b/node-graph/nodes/path-bool/src/lib.rs @@ -141,6 +141,7 @@ fn boolean_operation_on_vector_table<'a>(vector: impl DoubleEndedIterator Table { let radius = radius.abs(); - Table::new_from_element(Vector::from_subpath(subpath::Subpath::new_ellipse(DVec2::splat(-radius), DVec2::splat(radius)))) + let mut circle = Vector::from_subpath(subpath::Subpath::new_ellipse(DVec2::splat(-radius), DVec2::splat(radius))); + set_ellipse_colinear_manipulators(&mut circle); + Table::new_from_element(circle) } /// Generates an arc shape forming a portion of a circle which may be open, closed, or a pie slice. @@ -66,7 +79,7 @@ fn arc( sweep_angle: Angle, arc_type: ArcType, ) -> Table { - Table::new_from_element(Vector::from_subpath(subpath::Subpath::new_arc( + let mut vector = Vector::from_subpath(subpath::Subpath::new_arc( radius, start_angle / 360. * std::f64::consts::TAU, sweep_angle / 360. * std::f64::consts::TAU, @@ -75,7 +88,9 @@ fn arc( ArcType::Closed => subpath::ArcType::Closed, ArcType::PieSlice => subpath::ArcType::PieSlice, }, - ))) + )); + vector.detect_colinear_manipulators(); + Table::new_from_element(vector) } /// Generates a spiral shape that winds from an inner to an outer radius. @@ -90,14 +105,16 @@ fn spiral( #[default(25)] outer_radius: f64, #[default(90.)] angular_resolution: f64, ) -> Table { - Table::new_from_element(Vector::from_subpath(subpath::Subpath::new_spiral( + let mut vector = Vector::from_subpath(subpath::Subpath::new_spiral( inner_radius, outer_radius, turns, start_angle.to_radians(), angular_resolution.to_radians(), spiral_type, - ))) + )); + vector.detect_colinear_manipulators(); + Table::new_from_element(vector) } /// Generates an ellipse shape (an oval or stretched circle) with the chosen radii. @@ -117,13 +134,7 @@ fn ellipse( let corner2 = radius; let mut ellipse = Vector::from_subpath(subpath::Subpath::new_ellipse(corner1, corner2)); - - let len = ellipse.segment_domain.ids().len(); - for i in 0..len { - ellipse - .colinear_manipulators - .push([HandleId::end(ellipse.segment_domain.ids()[i]), HandleId::primary(ellipse.segment_domain.ids()[(i + 1) % len])]); - } + set_ellipse_colinear_manipulators(&mut ellipse); Table::new_from_element(ellipse) } diff --git a/node-graph/nodes/vector/src/vector_nodes.rs b/node-graph/nodes/vector/src/vector_nodes.rs index dc3194c06d..54a34825b2 100644 --- a/node-graph/nodes/vector/src/vector_nodes.rs +++ b/node-graph/nodes/vector/src/vector_nodes.rs @@ -1622,6 +1622,8 @@ async fn spline(_: impl Ctx, content: Table) -> Table { } row.element.segment_domain = segment_domain; + // Clear stale colinear_manipulators since all segment IDs have been replaced + row.element.colinear_manipulators.clear(); Some(row) }) .collect() @@ -2002,135 +2004,157 @@ fn bevel_algorithm(mut vector: Vector, transform: DAffine2, distance: f64) -> Ve split_distance } - fn sort_segments(segment_domain: &SegmentDomain) -> Vec { + fn sort_segments(segment_domain: &SegmentDomain) -> Vec> { let start_points = segment_domain.start_point(); let end_points = segment_domain.end_point(); - let mut sorted_segments = vec![0]; - let segment_domain_length = segment_domain.ids().len(); - - for _ in 0..segment_domain_length { - match sorted_segments.last() { - Some(&last) => { - if let Some(index) = start_points.iter().position(|&p| p == end_points[last]) { - if index == 0 { - break; - } - sorted_segments.push(index); - } + let mut paths = Vec::new(); + let mut unvisited_segments: std::collections::HashSet = (0..segment_domain.ids().len()).collect(); + + while !unvisited_segments.is_empty() { + let first = *unvisited_segments.iter().next().unwrap(); + unvisited_segments.remove(&first); + + let mut path = vec![first]; + + loop { + let last = *path.last().unwrap(); + // Find next segment + if let Some(&next) = unvisited_segments.iter().find(|&&p| start_points[p] == end_points[last]) { + path.push(next); + unvisited_segments.remove(&next); + } else { + break; } - None => break, } - } - - if segment_domain_length != sorted_segments.len() { - for i in 0..segment_domain_length { - if !sorted_segments.contains(&i) { - sorted_segments.push(i); + + // Try to extend backwards + loop { + let first = *path.first().unwrap(); + if let Some(&prev) = unvisited_segments.iter().find(|&&p| end_points[p] == start_points[first]) { + path.insert(0, prev); + unvisited_segments.remove(&prev); + } else { + break; } } + + paths.push(path); } - sorted_segments + paths } fn update_existing_segments(vector: &mut Vector, transform: DAffine2, distance: f64, segments_connected: &mut [usize]) -> Vec<[usize; 2]> { let mut next_id = vector.point_domain.next_id(); let mut new_segments = Vec::new(); - let sorted_segments = sort_segments(&vector.segment_domain); + let paths = sort_segments(&vector.segment_domain); let segment_domain = &mut vector.segment_domain; - let segment_domain_length = segment_domain.ids().len(); - let mut first_original_length = 0.; - let mut first_length = 0.; - let mut prev_original_length = 0.; - let mut prev_length = 0.; + for path in paths { + let mut first_original_length = 0.; + let mut first_length = 0.; + let mut prev_original_length = 0.; + let mut prev_length = 0.; + + let path_len = path.len(); + for i in 0..path_len { + let index = path[i]; + let (next_index, is_connected) = if i == path_len - 1 { + let is_closed = segment_domain.start_point()[path[0]] == segment_domain.end_point()[path[path_len - 1]]; + (path[0], is_closed) + } else { + (path[i + 1], true) + }; - for i in 0..segment_domain_length { - let (index, next_index) = if i == segment_domain_length - 1 { (i, 0) } else { (i, i + 1) }; - let pair_handles_and_points = segment_domain.pair_handles_and_points_mut_by_index(sorted_segments[index], sorted_segments[next_index]); - let (handles, start_point, end_point, next_handles, next_start_point, next_end_point) = pair_handles_and_points; + if !is_connected || index == next_index { + continue; + } - let start = vector.point_domain.positions()[*start_point]; - let end = vector.point_domain.positions()[*end_point]; + let pair_handles_and_points = segment_domain.pair_handles_and_points_mut_by_index(index, next_index); + let (handles, start_point, end_point, next_handles, next_start_point, next_end_point) = pair_handles_and_points; - let mut bezier = handles_to_segment(start, *handles, end); - bezier = Affine::new(transform.to_cols_array()) * bezier; + let start = vector.point_domain.positions()[*start_point]; + let end = vector.point_domain.positions()[*end_point]; - let next_start = vector.point_domain.positions()[*next_start_point]; - let next_end = vector.point_domain.positions()[*next_end_point]; + let mut bezier = handles_to_segment(start, *handles, end); + bezier = Affine::new(transform.to_cols_array()) * bezier; - let mut next_bezier = handles_to_segment(next_start, *next_handles, next_end); - next_bezier = Affine::new(transform.to_cols_array()) * next_bezier; + let next_start = vector.point_domain.positions()[*next_start_point]; + let next_end = vector.point_domain.positions()[*next_end_point]; - let calculated_split_distance = calculate_distance_to_split(bezier, next_bezier, distance); + let mut next_bezier = handles_to_segment(next_start, *next_handles, next_end); + next_bezier = Affine::new(transform.to_cols_array()) * next_bezier; - if is_linear(bezier) { - bezier = PathSeg::Line(Line::new(bezier.start(), bezier.end())); - } + let calculated_split_distance = calculate_distance_to_split(bezier, next_bezier, distance); - if is_linear(next_bezier) { - next_bezier = PathSeg::Line(Line::new(next_bezier.start(), next_bezier.end())); - } + if is_linear(bezier) { + bezier = PathSeg::Line(Line::new(bezier.start(), bezier.end())); + } - let inverse_transform = if transform.matrix2.determinant() != 0. { transform.inverse() } else { Default::default() }; + if is_linear(next_bezier) { + next_bezier = PathSeg::Line(Line::new(next_bezier.start(), next_bezier.end())); + } - if index == 0 && next_index == 1 { - first_original_length = bezier.perimeter(DEFAULT_ACCURACY); - first_length = first_original_length; - } + let inverse_transform = if transform.matrix2.determinant() != 0. { transform.inverse() } else { Default::default() }; - let (original_length, length) = if index == 0 { - (bezier.perimeter(DEFAULT_ACCURACY), bezier.perimeter(DEFAULT_ACCURACY)) - } else { - (prev_original_length, prev_length) - }; + if i == 0 { + first_original_length = bezier.perimeter(DEFAULT_ACCURACY); + first_length = first_original_length; + } - let (next_original_length, mut next_length) = if index == segment_domain_length - 1 && next_index == 0 { - (first_original_length, first_length) - } else { - (next_bezier.perimeter(DEFAULT_ACCURACY), next_bezier.perimeter(DEFAULT_ACCURACY)) - }; + let (original_length, length) = if i == 0 { + (bezier.perimeter(DEFAULT_ACCURACY), bezier.perimeter(DEFAULT_ACCURACY)) + } else { + (prev_original_length, prev_length) + }; + + let (next_original_length, mut next_length) = if i == path_len - 1 { + (first_original_length, first_length) + } else { + (next_bezier.perimeter(DEFAULT_ACCURACY), next_bezier.perimeter(DEFAULT_ACCURACY)) + }; - // Only split if the length is big enough to make it worthwhile - let valid_length = length > 1e-10; - if segments_connected[*end_point] > 0 && valid_length { - // Apply the bevel to the end - let distance = calculated_split_distance.min(original_length.min(next_original_length) / 2.); - bezier = split_distance(bezier.reverse(), distance, length).reverse(); + // Only split if the length is big enough to make it worthwhile + let valid_length = length > 1e-10; + if segments_connected[*end_point] > 0 && valid_length { + // Apply the bevel to the end + let distance = calculated_split_distance.min(original_length.min(next_original_length) / 2.); + bezier = split_distance(bezier.reverse(), distance, length).reverse(); - if index == 0 && next_index == 1 { - first_length = (length - distance).max(0.); + if i == 0 { + first_length = (length - distance).max(0.); + } + + // Update the end position + let pos = inverse_transform.transform_point2(point_to_dvec2(bezier.end())); + create_or_modify_point(&mut vector.point_domain, segments_connected, pos, end_point, &mut next_id, &mut new_segments); } - // Update the end position - let pos = inverse_transform.transform_point2(point_to_dvec2(bezier.end())); - create_or_modify_point(&mut vector.point_domain, segments_connected, pos, end_point, &mut next_id, &mut new_segments); - } + // Update the handles + *handles = segment_to_handles(&bezier).apply_transformation(|p| inverse_transform.transform_point2(p)); - // Update the handles - *handles = segment_to_handles(&bezier).apply_transformation(|p| inverse_transform.transform_point2(p)); + // Only split if the length is big enough to make it worthwhile + let valid_length = next_length > 1e-10; + if segments_connected[*next_start_point] > 0 && valid_length { + // Apply the bevel to the start + let distance = calculated_split_distance.min(next_original_length.min(original_length) / 2.); + next_bezier = split_distance(next_bezier, distance, next_length); + next_length = (next_length - distance).max(0.); - // Only split if the length is big enough to make it worthwhile - let valid_length = next_length > 1e-10; - if segments_connected[*next_start_point] > 0 && valid_length { - // Apply the bevel to the start - let distance = calculated_split_distance.min(next_original_length.min(original_length) / 2.); - next_bezier = split_distance(next_bezier, distance, next_length); - next_length = (next_length - distance).max(0.); + // Update the start position + let pos = inverse_transform.transform_point2(point_to_dvec2(next_bezier.start())); - // Update the start position - let pos = inverse_transform.transform_point2(point_to_dvec2(next_bezier.start())); + create_or_modify_point(&mut vector.point_domain, segments_connected, pos, next_start_point, &mut next_id, &mut new_segments); - create_or_modify_point(&mut vector.point_domain, segments_connected, pos, next_start_point, &mut next_id, &mut new_segments); + // Update the handles + *next_handles = segment_to_handles(&next_bezier).apply_transformation(|p| inverse_transform.transform_point2(p)); + } - // Update the handles - *next_handles = segment_to_handles(&next_bezier).apply_transformation(|p| inverse_transform.transform_point2(p)); + prev_original_length = next_original_length; + prev_length = next_length; } - - prev_original_length = next_original_length; - prev_length = next_length; } new_segments @@ -2149,6 +2173,21 @@ fn bevel_algorithm(mut vector: Vector, transform: DAffine2, distance: f64) -> Ve let mut segments_connected = segments_connected_count(&vector); let new_segments = update_existing_segments(&mut vector, transform, distance, &mut segments_connected); insert_new_segments(&mut vector, &new_segments); + + // Clean up colinear_manipulators: remove entries that reference + // segments which no longer exist or have become linear after beveling. + // Collect valid non-linear segment IDs first to avoid borrow conflicts. + let valid_nonlinear_segments: std::collections::HashSet = vector + .segment_domain + .ids() + .iter() + .zip(vector.segment_domain.handles()) + .filter(|(_, handles)| !matches!(handles, BezierHandles::Linear)) + .map(|(&id, _)| id) + .collect(); + vector.colinear_manipulators.retain(|[h1, h2]| { + valid_nonlinear_segments.contains(&h1.segment) && valid_nonlinear_segments.contains(&h2.segment) + }); } vector