From 06c5ea94d331b9c42f922ea7ea6c40f22a9cfa8c Mon Sep 17 00:00:00 2001 From: ericvannunen <106070823+ericvannunen@users.noreply.github.com> Date: Tue, 23 Sep 2025 23:47:03 +0200 Subject: [PATCH 1/6] fix: Race conditions when adding many library items (#10013) * Fix for race condition when adding many library items * Remove unused import * Replace any with LibraryItem type * Fix comments on pr * Fix build errors * Fix hoisted variable * new mime type * duplicate before passing down to be sure * lint * fix tests * Remove unused import --------- Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/common/src/constants.ts | 3 + packages/excalidraw/components/App.tsx | 48 ++++-- .../components/LibraryMenuItems.tsx | 14 +- packages/excalidraw/data/library.ts | 1 + packages/excalidraw/data/types.ts | 5 + packages/excalidraw/tests/library.test.tsx | 151 +++++++++++------- 6 files changed, 147 insertions(+), 75 deletions(-) diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index b41fb1a37d..a02ccfbb46 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -266,7 +266,10 @@ export const STRING_MIME_TYPES = { json: "application/json", // excalidraw data excalidraw: "application/vnd.excalidraw+json", + // LEGACY: fully-qualified library JSON data excalidrawlib: "application/vnd.excalidrawlib+json", + // list of excalidraw library item ids + excalidrawlibIds: "application/vnd.excalidrawlib.ids+json", } as const; export const MIME_TYPES = { diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 0f6fa840b0..2def157b4a 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -433,6 +433,8 @@ import { findShapeByKey } from "./shapes"; import UnlockPopup from "./UnlockPopup"; +import type { ExcalidrawLibraryIds } from "../data/types"; + import type { RenderInteractiveSceneCallback, ScrollBars, @@ -10545,16 +10547,44 @@ class App extends React.Component { if (imageFiles.length > 0 && this.isToolSupported("image")) { return this.insertImages(imageFiles, sceneX, sceneY); } - - const libraryJSON = dataTransferList.getData(MIME_TYPES.excalidrawlib); - if (libraryJSON && typeof libraryJSON === "string") { + const excalidrawLibrary_ids = dataTransferList.getData( + MIME_TYPES.excalidrawlibIds, + ); + const excalidrawLibrary_data = dataTransferList.getData( + MIME_TYPES.excalidrawlib, + ); + if (excalidrawLibrary_ids || excalidrawLibrary_data) { try { - const libraryItems = parseLibraryJSON(libraryJSON); - this.addElementsFromPasteOrLibrary({ - elements: distributeLibraryItemsOnSquareGrid(libraryItems), - position: event, - files: null, - }); + let libraryItems: LibraryItems | null = null; + if (excalidrawLibrary_ids) { + const { itemIds } = JSON.parse( + excalidrawLibrary_ids, + ) as ExcalidrawLibraryIds; + const allLibraryItems = await this.library.getLatestLibrary(); + libraryItems = allLibraryItems.filter((item) => + itemIds.includes(item.id), + ); + // legacy library dataTransfer format + } else if (excalidrawLibrary_data) { + libraryItems = parseLibraryJSON(excalidrawLibrary_data); + } + if (libraryItems?.length) { + libraryItems = libraryItems.map((item) => ({ + ...item, + // #6465 + elements: duplicateElements({ + type: "everything", + elements: item.elements, + randomizeSeed: true, + }).duplicatedElements, + })); + + this.addElementsFromPasteOrLibrary({ + elements: distributeLibraryItemsOnSquareGrid(libraryItems), + position: event, + files: null, + }); + } } catch (error: any) { this.setState({ errorMessage: error.message }); } diff --git a/packages/excalidraw/components/LibraryMenuItems.tsx b/packages/excalidraw/components/LibraryMenuItems.tsx index 8e06632aad..eb82dde550 100644 --- a/packages/excalidraw/components/LibraryMenuItems.tsx +++ b/packages/excalidraw/components/LibraryMenuItems.tsx @@ -10,7 +10,6 @@ import { MIME_TYPES, arrayToMap } from "@excalidraw/common"; import { duplicateElements } from "@excalidraw/element"; -import { serializeLibraryAsJSON } from "../data/json"; import { useLibraryCache } from "../hooks/useLibraryItemSvg"; import { useScrollPosition } from "../hooks/useScrollPosition"; import { t } from "../i18n"; @@ -27,6 +26,8 @@ import Stack from "./Stack"; import "./LibraryMenuItems.scss"; +import type { ExcalidrawLibraryIds } from "../data/types"; + import type { ExcalidrawProps, LibraryItem, @@ -175,12 +176,17 @@ export default function LibraryMenuItems({ const onItemDrag = useCallback( (id: LibraryItem["id"], event: React.DragEvent) => { + // we want to serialize just the ids so the operation is fast and there's + // no race condition if people drop the library items on canvas too fast + const data: ExcalidrawLibraryIds = { + itemIds: selectedItems.includes(id) ? selectedItems : [id], + }; event.dataTransfer.setData( - MIME_TYPES.excalidrawlib, - serializeLibraryAsJSON(getInsertedElements(id)), + MIME_TYPES.excalidrawlibIds, + JSON.stringify(data), ); }, - [getInsertedElements], + [selectedItems], ); const isItemSelected = useCallback( diff --git a/packages/excalidraw/data/library.ts b/packages/excalidraw/data/library.ts index 4269edbd7f..429ba1046c 100644 --- a/packages/excalidraw/data/library.ts +++ b/packages/excalidraw/data/library.ts @@ -192,6 +192,7 @@ const createLibraryUpdate = ( class Library { /** latest libraryItems */ private currLibraryItems: LibraryItems = []; + /** snapshot of library items since last onLibraryChange call */ private prevLibraryItems = cloneLibraryItems(this.currLibraryItems); diff --git a/packages/excalidraw/data/types.ts b/packages/excalidraw/data/types.ts index 6878b81b18..94947c2b98 100644 --- a/packages/excalidraw/data/types.ts +++ b/packages/excalidraw/data/types.ts @@ -6,6 +6,7 @@ import type { cleanAppStateForExport } from "../appState"; import type { AppState, BinaryFiles, + LibraryItem, LibraryItems, LibraryItems_anyVersion, } from "../types"; @@ -59,3 +60,7 @@ export interface ImportedLibraryData extends Partial { /** @deprecated v1 */ library?: LibraryItems; } + +export type ExcalidrawLibraryIds = { + itemIds: LibraryItem["id"][]; +}; diff --git a/packages/excalidraw/tests/library.test.tsx b/packages/excalidraw/tests/library.test.tsx index f1c0f0a457..55c25188de 100644 --- a/packages/excalidraw/tests/library.test.tsx +++ b/packages/excalidraw/tests/library.test.tsx @@ -15,7 +15,7 @@ import { Excalidraw } from "../index"; import { API } from "./helpers/api"; import { UI } from "./helpers/ui"; -import { fireEvent, getCloneByOrigId, render, waitFor } from "./test-utils"; +import { fireEvent, render, waitFor } from "./test-utils"; import type { LibraryItem, LibraryItems } from "../types"; @@ -46,52 +46,8 @@ vi.mock("../data/filesystem.ts", async (importOriginal) => { }; }); -describe("library", () => { +describe("library items inserting", () => { beforeEach(async () => { - await render(); - await act(() => { - return h.app.library.resetLibrary(); - }); - }); - - it("import library via drag&drop", async () => { - expect(await h.app.library.getLatestLibrary()).toEqual([]); - await API.drop([ - { - kind: "file", - type: MIME_TYPES.excalidrawlib, - file: await API.loadFile("./fixtures/fixture_library.excalidrawlib"), - }, - ]); - await waitFor(async () => { - expect(await h.app.library.getLatestLibrary()).toEqual([ - { - status: "unpublished", - elements: [expect.objectContaining({ id: "A" })], - id: "id0", - created: expect.any(Number), - }, - ]); - }); - }); - - // NOTE: mocked to test logic, not actual drag&drop via UI - it("drop library item onto canvas", async () => { - expect(h.elements).toEqual([]); - const libraryItems = parseLibraryJSON(await libraryJSONPromise); - await API.drop([ - { - kind: "string", - value: serializeLibraryAsJSON(libraryItems), - type: MIME_TYPES.excalidrawlib, - }, - ]); - await waitFor(() => { - expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]); - }); - }); - - it("should regenerate ids but retain bindings on library insert", async () => { const rectangle = API.createElement({ id: "rectangle1", type: "rectangle", @@ -117,45 +73,116 @@ describe("library", () => { }, }); + const libraryItems: LibraryItems = [ + { + id: "libraryItem_id0", + status: "unpublished", + elements: [rectangle, text, arrow], + created: 0, + name: "test", + }, + ]; + + await render(); + }); + + afterEach(async () => { + await act(() => { + return h.app.library.resetLibrary(); + }); + }); + + it("should regenerate ids but retain bindings on library insert", async () => { + const libraryItems = await h.app.library.getLatestLibrary(); + + expect(libraryItems.length).toBe(1); + await API.drop([ { kind: "string", - value: serializeLibraryAsJSON([ - { - id: "item1", - status: "published", - elements: [rectangle, text, arrow], - created: 1, - }, - ]), - type: MIME_TYPES.excalidrawlib, + value: JSON.stringify({ + itemIds: [libraryItems[0].id], + }), + type: MIME_TYPES.excalidrawlibIds, }, ]); await waitFor(() => { + const rectangle = h.elements.find((e) => e.type === "rectangle")!; + const text = h.elements.find((e) => e.type === "text")!; + const arrow = h.elements.find((e) => e.type === "arrow")!; expect(h.elements).toEqual( expect.arrayContaining([ expect.objectContaining({ - [ORIG_ID]: "rectangle1", + type: "rectangle", + id: expect.not.stringMatching("rectangle1"), boundElements: expect.arrayContaining([ - { type: "text", id: getCloneByOrigId("text1").id }, - { type: "arrow", id: getCloneByOrigId("arrow1").id }, + { type: "text", id: text.id }, + { type: "arrow", id: arrow.id }, ]), }), expect.objectContaining({ - [ORIG_ID]: "text1", - containerId: getCloneByOrigId("rectangle1").id, + type: "text", + id: expect.not.stringMatching("text1"), + containerId: rectangle.id, }), expect.objectContaining({ - [ORIG_ID]: "arrow1", + type: "arrow", + id: expect.not.stringMatching("arrow1"), endBinding: expect.objectContaining({ - elementId: getCloneByOrigId("rectangle1").id, + elementId: rectangle.id, }), }), ]), ); }); }); +}); + +describe("library", () => { + beforeEach(async () => { + await render(); + await act(() => { + return h.app.library.resetLibrary(); + }); + }); + + it("import library via drag&drop", async () => { + expect(await h.app.library.getLatestLibrary()).toEqual([]); + await API.drop([ + { + kind: "file", + type: MIME_TYPES.excalidrawlib, + file: await API.loadFile("./fixtures/fixture_library.excalidrawlib"), + }, + ]); + await waitFor(async () => { + expect(await h.app.library.getLatestLibrary()).toEqual([ + { + status: "unpublished", + elements: [expect.objectContaining({ id: "A" })], + id: expect.any(String), + created: expect.any(Number), + }, + ]); + }); + }); + + // NOTE: mocked to test logic, not actual drag&drop via UI + it("drop library item onto canvas", async () => { + expect(h.elements).toEqual([]); + const libraryItems = parseLibraryJSON(await libraryJSONPromise); + await API.drop([ + { + kind: "string", + value: serializeLibraryAsJSON(libraryItems), + type: MIME_TYPES.excalidrawlib, + }, + ]); + await waitFor(() => { + expect(h.elements).toEqual([expect.objectContaining({ [ORIG_ID]: "A" })]); + }); + }); it("should fix duplicate ids between items on insert", async () => { // note, we're not testing for duplicate group ids and such because From 00ae455873f1d29aea75cf1c1d4dfb61471d074b Mon Sep 17 00:00:00 2001 From: Omar Brikaa Date: Wed, 24 Sep 2025 01:18:41 +0300 Subject: [PATCH 2/6] fix: Remove local elements when there is room data during `startCollaboration` (#9786) * Remove local elements when there is room data * Update excalidraw-app/collab/Collab.tsx --------- Co-authored-by: David Luzar <5153846+dwelle@users.noreply.github.com> --- excalidraw-app/collab/Collab.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/excalidraw-app/collab/Collab.tsx b/excalidraw-app/collab/Collab.tsx index bed130e24a..3c7e686d38 100644 --- a/excalidraw-app/collab/Collab.tsx +++ b/excalidraw-app/collab/Collab.tsx @@ -530,7 +530,10 @@ class Collab extends PureComponent { return null; } - if (!existingRoomLinkData) { + if (existingRoomLinkData) { + // when joining existing room, don't merge it with current scene data + this.excalidrawAPI.resetScene(); + } else { const elements = this.excalidrawAPI.getSceneElements().map((element) => { if (isImageElement(element) && element.status === "saved") { return newElementWith(element, { status: "pending" }); From f738b74791b837a2530c1502eca61d3d51b3b583 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Wed, 24 Sep 2025 18:17:39 +0200 Subject: [PATCH 3/6] fix: reintroduce height-based mobile query detection (#10020) --- packages/common/src/constants.ts | 3 +++ packages/excalidraw/components/App.tsx | 8 ++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index a02ccfbb46..646fb08bff 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -354,6 +354,9 @@ export const DEFAULT_UI_OPTIONS: AppProps["UIOptions"] = { // mobile: up to 699px export const MQ_MAX_MOBILE = 599; +export const MQ_MAX_WIDTH_LANDSCAPE = 1000; +export const MQ_MAX_HEIGHT_LANDSCAPE = 500; + // tablets export const MQ_MIN_TABLET = MQ_MAX_MOBILE + 1; // lower bound (excludes phones) export const MQ_MAX_TABLET = 1400; // upper bound (excludes laptops/desktops) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index 2def157b4a..daeb44cefa 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -103,6 +103,8 @@ import { MQ_MAX_MOBILE, MQ_MIN_TABLET, MQ_MAX_TABLET, + MQ_MAX_HEIGHT_LANDSCAPE, + MQ_MAX_WIDTH_LANDSCAPE, } from "@excalidraw/common"; import { @@ -2423,8 +2425,10 @@ class App extends React.Component { }; private isMobileBreakpoint = (width: number, height: number) => { - const minSide = Math.min(width, height); - return minSide <= MQ_MAX_MOBILE; + return ( + width <= MQ_MAX_MOBILE || + (height < MQ_MAX_HEIGHT_LANDSCAPE && width < MQ_MAX_WIDTH_LANDSCAPE) + ); }; private isTabletBreakpoint = (editorWidth: number, editorHeight: number) => { From 91c7748c3d58a54444193695d3b7d3ced34434ad Mon Sep 17 00:00:00 2001 From: Mossberg Date: Wed, 24 Sep 2025 18:30:50 +0200 Subject: [PATCH 4/6] fix: added normalization to images added with the image tool to prevent MIME-mismatches (#10018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: fixed a bug where a MIME-mismatch in an image would cause an error to update cache * fix: fixed a bug where a MIME-mismatch in an image would cause an error to update cache * normalize inside insertImages() --------- Co-authored-by: MÃ¥rten Mossberg Co-authored-by: dwelle <5153846+dwelle@users.noreply.github.com> --- packages/excalidraw/components/App.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/excalidraw/components/App.tsx b/packages/excalidraw/components/App.tsx index daeb44cefa..af888b1921 100644 --- a/packages/excalidraw/components/App.tsx +++ b/packages/excalidraw/components/App.tsx @@ -10461,7 +10461,10 @@ class App extends React.Component { const initialized = await Promise.all( placeholders.map(async (placeholder, i) => { try { - return await this.initializeImage(placeholder, imageFiles[i]); + return await this.initializeImage( + placeholder, + await normalizeFile(imageFiles[i]), + ); } catch (error: any) { this.setState({ errorMessage: error.message || t("errors.imageInsertError"), From 06c40006db969b10b77b6e80266311a555b4cdbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Wed, 24 Sep 2025 19:22:32 +0200 Subject: [PATCH 5/6] fix: Elbow arrow routing issue with diamonds and ellipses (#10021) --- packages/element/src/binding.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/element/src/binding.ts b/packages/element/src/binding.ts index 9d97801f2e..fa1355309b 100644 --- a/packages/element/src/binding.ts +++ b/packages/element/src/binding.ts @@ -999,6 +999,29 @@ export const bindPointToSnapToElementOutline = ( intersector, FIXED_BINDING_DISTANCE, ).sort(pointDistanceSq)[0]; + + if (!intersection) { + const anotherPoint = pointFrom( + !isHorizontal ? center[0] : snapPoint[0], + isHorizontal ? center[1] : snapPoint[1], + ); + const anotherIntersector = lineSegment( + anotherPoint, + pointFromVector( + vectorScale( + vectorNormalize(vectorFromPoint(snapPoint, anotherPoint)), + Math.max(bindableElement.width, bindableElement.height) * 2, + ), + anotherPoint, + ), + ); + intersection = intersectElementWithLineSegment( + bindableElement, + elementsMap, + anotherIntersector, + FIXED_BINDING_DISTANCE, + ).sort(pointDistanceSq)[0]; + } } else { intersection = intersectElementWithLineSegment( bindableElement, From e32836f799b65f978cbf4877eab7ffc42c6e6494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1rk=20Tolm=C3=A1cs?= Date: Wed, 24 Sep 2025 19:33:20 +0200 Subject: [PATCH 6/6] fix: Use analytical Jacobian for curve intersection testing (#10007) --- .../tests/__snapshots__/history.test.tsx.snap | 2 +- packages/math/src/curve.ts | 175 ++++++++++-------- packages/math/tests/curve.test.ts | 6 +- 3 files changed, 101 insertions(+), 82 deletions(-) diff --git a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap index e799a58b1b..8e0b5dabe0 100644 --- a/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap +++ b/packages/excalidraw/tests/__snapshots__/history.test.tsx.snap @@ -363,7 +363,7 @@ exports[`history > multiplayer undo/redo > conflicts in arrows and their bindabl 0, ], [ - "98.00000", + 98, "-0.00656", ], ], diff --git a/packages/math/src/curve.ts b/packages/math/src/curve.ts index fa11abd460..7be0f72245 100644 --- a/packages/math/src/curve.ts +++ b/packages/math/src/curve.ts @@ -21,20 +21,9 @@ export function curve( return [a, b, c, d] as Curve; } -function gradient( - f: (t: number, s: number) => number, - t0: number, - s0: number, - delta: number = 1e-6, -): number[] { - return [ - (f(t0 + delta, s0) - f(t0 - delta, s0)) / (2 * delta), - (f(t0, s0 + delta) - f(t0, s0 - delta)) / (2 * delta), - ]; -} - -function solve( - f: (t: number, s: number) => [number, number], +function solveWithAnalyticalJacobian( + curve: Curve, + lineSegment: LineSegment, t0: number, s0: number, tolerance: number = 1e-3, @@ -48,33 +37,75 @@ function solve( return null; } - const y0 = f(t0, s0); - const jacobian = [ - gradient((t, s) => f(t, s)[0], t0, s0), - gradient((t, s) => f(t, s)[1], t0, s0), - ]; - const b = [[-y0[0]], [-y0[1]]]; - const det = - jacobian[0][0] * jacobian[1][1] - jacobian[0][1] * jacobian[1][0]; + // Compute bezier point at parameter t0 + const bt = 1 - t0; + const bt2 = bt * bt; + const bt3 = bt2 * bt; + const t0_2 = t0 * t0; + const t0_3 = t0_2 * t0; - if (det === 0) { + const bezierX = + bt3 * curve[0][0] + + 3 * bt2 * t0 * curve[1][0] + + 3 * bt * t0_2 * curve[2][0] + + t0_3 * curve[3][0]; + const bezierY = + bt3 * curve[0][1] + + 3 * bt2 * t0 * curve[1][1] + + 3 * bt * t0_2 * curve[2][1] + + t0_3 * curve[3][1]; + + // Compute line point at parameter s0 + const lineX = + lineSegment[0][0] + s0 * (lineSegment[1][0] - lineSegment[0][0]); + const lineY = + lineSegment[0][1] + s0 * (lineSegment[1][1] - lineSegment[0][1]); + + // Function values + const fx = bezierX - lineX; + const fy = bezierY - lineY; + + error = Math.abs(fx) + Math.abs(fy); + + if (error < tolerance) { + break; + } + + // Analytical derivatives + const dfx_dt = + -3 * bt2 * curve[0][0] + + 3 * bt2 * curve[1][0] - + 6 * bt * t0 * curve[1][0] - + 3 * t0_2 * curve[2][0] + + 6 * bt * t0 * curve[2][0] + + 3 * t0_2 * curve[3][0]; + + const dfy_dt = + -3 * bt2 * curve[0][1] + + 3 * bt2 * curve[1][1] - + 6 * bt * t0 * curve[1][1] - + 3 * t0_2 * curve[2][1] + + 6 * bt * t0 * curve[2][1] + + 3 * t0_2 * curve[3][1]; + + // Line derivatives + const dfx_ds = -(lineSegment[1][0] - lineSegment[0][0]); + const dfy_ds = -(lineSegment[1][1] - lineSegment[0][1]); + + // Jacobian determinant + const det = dfx_dt * dfy_ds - dfx_ds * dfy_dt; + + if (Math.abs(det) < 1e-12) { return null; } - const iJ = [ - [jacobian[1][1] / det, -jacobian[0][1] / det], - [-jacobian[1][0] / det, jacobian[0][0] / det], - ]; - const h = [ - [iJ[0][0] * b[0][0] + iJ[0][1] * b[1][0]], - [iJ[1][0] * b[0][0] + iJ[1][1] * b[1][0]], - ]; + // Newton step + const invDet = 1 / det; + const dt = invDet * (dfy_ds * -fx - dfx_ds * -fy); + const ds = invDet * (-dfy_dt * -fx + dfx_dt * -fy); - t0 = t0 + h[0][0]; - s0 = s0 + h[1][0]; - - const [tErr, sErr] = f(t0, s0); - error = Math.max(Math.abs(tErr), Math.abs(sErr)); + t0 += dt; + s0 += ds; iter += 1; } @@ -96,63 +127,49 @@ export const bezierEquation = ( t ** 3 * c[3][1], ); +const initial_guesses: [number, number][] = [ + [0.5, 0], + [0.2, 0], + [0.8, 0], +]; + +const calculate = ( + [t0, s0]: [number, number], + l: LineSegment, + c: Curve, +) => { + const solution = solveWithAnalyticalJacobian(c, l, t0, s0, 1e-2, 3); + + if (!solution) { + return null; + } + + const [t, s] = solution; + + if (t < 0 || t > 1 || s < 0 || s > 1) { + return null; + } + + return bezierEquation(c, t); +}; + /** * Computes the intersection between a cubic spline and a line segment. */ export function curveIntersectLineSegment< Point extends GlobalPoint | LocalPoint, >(c: Curve, l: LineSegment): Point[] { - const line = (s: number) => - pointFrom( - l[0][0] + s * (l[1][0] - l[0][0]), - l[0][1] + s * (l[1][1] - l[0][1]), - ); - - const initial_guesses: [number, number][] = [ - [0.5, 0], - [0.2, 0], - [0.8, 0], - ]; - - const calculate = ([t0, s0]: [number, number]) => { - const solution = solve( - (t: number, s: number) => { - const bezier_point = bezierEquation(c, t); - const line_point = line(s); - - return [ - bezier_point[0] - line_point[0], - bezier_point[1] - line_point[1], - ]; - }, - t0, - s0, - ); - - if (!solution) { - return null; - } - - const [t, s] = solution; - - if (t < 0 || t > 1 || s < 0 || s > 1) { - return null; - } - - return bezierEquation(c, t); - }; - - let solution = calculate(initial_guesses[0]); + let solution = calculate(initial_guesses[0], l, c); if (solution) { return [solution]; } - solution = calculate(initial_guesses[1]); + solution = calculate(initial_guesses[1], l, c); if (solution) { return [solution]; } - solution = calculate(initial_guesses[2]); + solution = calculate(initial_guesses[2], l, c); if (solution) { return [solution]; } diff --git a/packages/math/tests/curve.test.ts b/packages/math/tests/curve.test.ts index 7395620968..0d1f3001de 100644 --- a/packages/math/tests/curve.test.ts +++ b/packages/math/tests/curve.test.ts @@ -46,9 +46,11 @@ describe("Math curve", () => { pointFrom(10, 50), pointFrom(50, 50), ); - const l = lineSegment(pointFrom(0, 112.5), pointFrom(90, 0)); + const l = lineSegment(pointFrom(10, -60), pointFrom(10, 60)); - expect(curveIntersectLineSegment(c, l)).toCloselyEqualPoints([[50, 50]]); + expect(curveIntersectLineSegment(c, l)).toCloselyEqualPoints([ + [9.99, 5.05], + ]); }); it("can be detected where the determinant is overly precise", () => {