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