diff --git a/.changeset/square-arcs-detour.md b/.changeset/square-arcs-detour.md
new file mode 100644
index 00000000000..769cd1eebe3
--- /dev/null
+++ b/.changeset/square-arcs-detour.md
@@ -0,0 +1,5 @@
+---
+"@hashintel/petrinaut": patch
+---
+
+Add square automatic arcs that can route around nearby nodes. Fix ID generation when the editor runs over HTTP on a local network.
diff --git a/libs/@hashintel/petrinaut/docs/drawing-a-net.md b/libs/@hashintel/petrinaut/docs/drawing-a-net.md
index d5979178fa5..a573e477c5a 100644
--- a/libs/@hashintel/petrinaut/docs/drawing-a-net.md
+++ b/libs/@hashintel/petrinaut/docs/drawing-a-net.md
@@ -86,6 +86,8 @@ Enable **Automatic arc connections** in [Viewport Settings](visual-settings.md#a
2. Drag the handle onto the target node. A blue outline shows a valid target.
3. Release to create the arc. Its endpoints follow the node outlines when you move either node.
+Choose **Square** under **Automatic arc shape** for right-angle paths. With **Avoid nodes** on, both the drag preview and completed arcs route around nearby nodes. Move obstructing nodes apart if a route cannot fit. See [automatic arc settings](visual-settings.md#automatic-arc-connections-experimental) for limits.
+
Drag from the source: place to transition creates an input arc; transition to place creates an output arc. Release on empty space or press **Escape** to cancel. Dropping onto a subnet does not create an arc in this mode.
You can also focus the outgoing handle with **Tab**, press **Enter** or **Space**, then focus a target and press **Enter** or **Space** again. On touch devices, the outgoing handle stays visible.
diff --git a/libs/@hashintel/petrinaut/docs/visual-settings.md b/libs/@hashintel/petrinaut/docs/visual-settings.md
index f5f858be507..01976ce2956 100644
--- a/libs/@hashintel/petrinaut/docs/visual-settings.md
+++ b/libs/@hashintel/petrinaut/docs/visual-settings.md
@@ -138,9 +138,13 @@ Disable it and the pointer changes nothing. Selecting a node still highlights it
### Automatic arc connections (experimental)
-Off by default. Hides the fixed handles on places and transitions. Hover over a node to reveal one outgoing handle, then drag it onto a place or transition to create an arc. Arcs attach to the node outlines and adjust their direction as you move nodes. Opposite directions use separate curves.
+Off by default. Hides the fixed handles on places and transitions. Hover over a node to reveal one outgoing handle, then drag it onto a place or transition to create an arc. Arcs attach to the node outlines and adjust their direction as you move nodes. Opposite directions use separate attachment points.
-This setting uses automatic curves and temporarily hides the **Arc rendering** selector. Turning it off restores your previous style. Existing subnet connections stay visible; turn the experiment off to create connections through subnet ports. See [Connecting with arcs](drawing-a-net.md#connecting-with-arcs).
+Choose **Curved** (the default) or **Square** under **Automatic arc shape**. Square arcs use horizontal and vertical segments and choose their attachment sides automatically.
+
+For square arcs, **Avoid nodes** is on by default. It routes around nearby places, transitions, and subnet boxes with a gap around their edges, and updates when you move a node. Turn it off for simpler square paths. Arcs and their labels can still cross each other. Overlapping nodes, blocked endpoints, or very crowded areas can prevent a route; the arc then falls back to a square path that may cross nodes. Move the blocking nodes apart to make room.
+
+Turning automatic connections off restores your previous **Arcs rendering** style. Existing subnet connections stay visible; turn the experiment off to create connections through subnet ports. See [Connecting with arcs](drawing-a-net.md#connecting-with-arcs).
### Arcs rendering
diff --git a/libs/@hashintel/petrinaut/src/react/optimizations/provider.tsx b/libs/@hashintel/petrinaut/src/react/optimizations/provider.tsx
index d6d8b29b105..e8bb3fb7e74 100644
--- a/libs/@hashintel/petrinaut/src/react/optimizations/provider.tsx
+++ b/libs/@hashintel/petrinaut/src/react/optimizations/provider.tsx
@@ -3,6 +3,7 @@
* @role Tracks the studies driving parameter sweeps: connects the host's in-browser optimizer, folds each study's event stream into a record, and routes its trials to the sweep that evaluates them
*/
import { use, useCallback, useEffect, useRef, useState } from "react";
+import { v4 as generateUuid } from "uuid";
import {
PETRINAUT_OPTIMIZATION_CANCELLED_ERROR_CODE,
@@ -381,7 +382,7 @@ export const OptimizationsProvider = ({ children }: PropsWithChildren) => {
}
const { capability } = connection;
const input = petrinautOptimizationInputSchema.parse(rawInput);
- const optimizationId = crypto.randomUUID();
+ const optimizationId = generateUuid();
const abortController = new AbortController();
sweepEvaluatorsRef.current.set(
optimizationId,
diff --git a/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts b/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts
index 3e5528a2467..290179b7b14 100644
--- a/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts
+++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-context.ts
@@ -16,6 +16,8 @@ import type {
TimelineChartType,
} from "./editor-context";
+export type AutomaticArcRendering = "curved" | "square";
+
export type ArcRendering = "smoothstep" | "bezier" | "custom";
export type SubViewSectionSettings = {
@@ -36,6 +38,8 @@ export type UserSettings = {
compactNodes: boolean;
enableExperimentalIconPack: boolean;
enableAutomaticArcConnections: boolean;
+ automaticArcRendering: AutomaticArcRendering;
+ avoidArcObstacles: boolean;
arcRendering: ArcRendering;
cursorMode: CursorMode;
isLeftSidebarOpen: boolean;
@@ -116,6 +120,8 @@ export type UserSettingsActions = {
setCompactNodes: (value: boolean) => void;
setEnableExperimentalIconPack: (value: boolean) => void;
setEnableAutomaticArcConnections: (value: boolean) => void;
+ setAutomaticArcRendering: (value: AutomaticArcRendering) => void;
+ setAvoidArcObstacles: (value: boolean) => void;
setArcRendering: (value: ArcRendering) => void;
setIsLeftSidebarOpen: (value: boolean) => void;
setLeftSidebarWidth: (value: number) => void;
@@ -153,6 +159,8 @@ export const defaultUserSettings: UserSettings = {
compactNodes: false,
enableExperimentalIconPack: false,
enableAutomaticArcConnections: false,
+ automaticArcRendering: "curved",
+ avoidArcObstacles: true,
arcRendering: "custom",
cursorMode: "pan",
isLeftSidebarOpen: true,
@@ -189,6 +197,8 @@ export const defaultUserSettingsContextValue: UserSettingsContextValue = {
setCompactNodes: () => {},
setEnableExperimentalIconPack: () => {},
setEnableAutomaticArcConnections: () => {},
+ setAutomaticArcRendering: () => {},
+ setAvoidArcObstacles: () => {},
setArcRendering: () => {},
setIsLeftSidebarOpen: () => {},
setLeftSidebarWidth: () => {},
diff --git a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.test.tsx b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.test.tsx
index 0f6ae45762d..b463fbe4fc0 100644
--- a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.test.tsx
+++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.test.tsx
@@ -58,6 +58,28 @@ const ArcConnectionsProbe = () => {
);
};
+const SquareArcsProbe = () => {
+ const {
+ automaticArcRendering,
+ setAutomaticArcRendering,
+ avoidArcObstacles,
+ setAvoidArcObstacles,
+ } = use(UserSettingsContext);
+ return (
+ <>
+
+
+ >
+ );
+};
+
describe("UserSettingsProvider", () => {
it("defaults automatic arcs off for saved preferences from before the experiment", () => {
localStorage.setItem(
@@ -102,6 +124,40 @@ describe("UserSettingsProvider", () => {
});
});
+ it("preserves the old arc style while persisting square routing preferences", () => {
+ localStorage.setItem(
+ "petrinaut:user-settings",
+ JSON.stringify({
+ enableAutomaticArcConnections: true,
+ arcRendering: "bezier",
+ }),
+ );
+ const first = render(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Shape: curved" }));
+ fireEvent.click(screen.getByRole("button", { name: "Avoid nodes: on" }));
+ first.unmount();
+ render(
+
+
+ ,
+ );
+ expect(screen.getByRole("button", { name: "Shape: square" })).toBeTruthy();
+ expect(
+ screen.getByRole("button", { name: "Avoid nodes: off" }),
+ ).toBeTruthy();
+ expect(
+ JSON.parse(localStorage.getItem("petrinaut:user-settings") ?? "{}"),
+ ).toMatchObject({
+ arcRendering: "bezier",
+ automaticArcRendering: "square",
+ avoidArcObstacles: false,
+ });
+ });
+
it("starts with Brunch demo mode off and toggles it", () => {
render(
diff --git a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx
index 7a9eb8bbc59..d83d9b10b96 100644
--- a/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx
+++ b/libs/@hashintel/petrinaut/src/react/state/user-settings-provider.tsx
@@ -102,6 +102,10 @@ const OwnedUserSettingsProvider: React.FC = ({
...settings,
enableAutomaticArcConnections: value,
})),
+ setAutomaticArcRendering: (value: UserSettings["automaticArcRendering"]) =>
+ setState((settings) => ({ ...settings, automaticArcRendering: value })),
+ setAvoidArcObstacles: (value: boolean) =>
+ setState((settings) => ({ ...settings, avoidArcObstacles: value })),
setArcRendering: (value: ArcRendering) =>
setState((prev) => ({ ...prev, arcRendering: value })),
setCursorMode: (value: CursorMode) =>
diff --git a/libs/@hashintel/petrinaut/src/ui/automatic-arc-connections.stories.tsx b/libs/@hashintel/petrinaut/src/ui/automatic-arc-connections.stories.tsx
index 415f6fde938..6ce2449705d 100644
--- a/libs/@hashintel/petrinaut/src/ui/automatic-arc-connections.stories.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/automatic-arc-connections.stories.tsx
@@ -4,6 +4,7 @@ import { UserSettingsContext } from "../react/state/user-settings-context";
import { UserSettingsProvider } from "../react/state/user-settings-provider";
import { PetrinautStoryProvider } from "./petrinaut-story-provider";
+import type { AutomaticArcRendering } from "../react/state/user-settings-context";
import type { SDCPN } from "@hashintel/petrinaut-core";
import type { Meta, StoryObj } from "@storybook/react-vite";
@@ -111,26 +112,66 @@ const definitionWithSubnet: SDCPN = {
],
};
+const definitionWithObstacles: SDCPN = {
+ ...definition,
+ places: [
+ ...definition.places.map((place) => ({
+ ...place,
+ x: place.id === "serving" || place.id === "served" ? 860 : 0,
+ y: place.id === "staff" || place.id === "served" ? 280 : 0,
+ })),
+ {
+ id: "obstacle",
+ name: "Obstacle",
+ x: 280,
+ y: 0,
+ colorId: null,
+ dynamicsEnabled: false,
+ differentialEquationId: null,
+ },
+ ],
+ transitions: definition.transitions.map((transition) => ({
+ ...transition,
+ x: 560,
+ y: transition.id === "begin" ? 0 : 280,
+ })),
+};
+
const AutomaticArcEditor = ({
readonly = false,
withSubnet = false,
+ withObstacles = false,
+ initialArcShape,
}: {
readonly?: boolean;
withSubnet?: boolean;
+ withObstacles?: boolean;
+ initialArcShape?: AutomaticArcRendering;
}) => {
const settings = use(UserSettingsContext);
const [automaticArcs, setAutomaticArcs] = useState(true);
+ const [arcShape, setArcShape] = useState(
+ initialArcShape ?? settings.automaticArcRendering,
+ );
return (
@@ -156,3 +197,8 @@ type Story = StoryObj;
export const Editable: Story = {};
export const ReadOnly: Story = { args: { readonly: true } };
export const WithSubnet: Story = { args: { withSubnet: true } };
+
+export const Square: Story = { args: { initialArcShape: "square" } };
+export const SquareWithObstacles: Story = {
+ args: { initialArcShape: "square", withObstacles: true },
+};
diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/use-ad-hoc-lsp-session.test.tsx b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/use-ad-hoc-lsp-session.test.tsx
index b314ffb3e06..1a09f880acf 100644
--- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/use-ad-hoc-lsp-session.test.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/use-ad-hoc-lsp-session.test.tsx
@@ -8,7 +8,7 @@
*/
import { renderHook } from "@testing-library/react";
-import { describe, expect, it, vi } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import {
DEFAULT_LANGUAGE_CLIENT_CONTEXT,
@@ -25,6 +25,25 @@ const stateWith = (expression: string): AdHocScenarioState => ({
});
describe("useAdHocLspSession", () => {
+ afterEach(() => vi.unstubAllGlobals());
+
+ it("keeps a stable session ID when crypto.randomUUID is unavailable", () => {
+ vi.stubGlobal("crypto", {
+ getRandomValues: crypto.getRandomValues.bind(crypto),
+ });
+ const { result, rerender, unmount } = renderHook(() =>
+ useAdHocLspSession(stateWith("1")),
+ );
+ const sessionId = result.current;
+
+ expect(sessionId).toMatch(
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
+ );
+ rerender();
+ expect(result.current).toBe(sessionId);
+ unmount();
+ });
+
it("syncs the worker on content changes, never on state identity alone", () => {
const client = {
...DEFAULT_LANGUAGE_CLIENT_CONTEXT,
diff --git a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/use-ad-hoc-lsp-session.ts b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/use-ad-hoc-lsp-session.ts
index 5e673501edb..5b6fa9b3eff 100644
--- a/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/use-ad-hoc-lsp-session.ts
+++ b/libs/@hashintel/petrinaut/src/ui/components/ad-hoc-scenario-form/use-ad-hoc-lsp-session.ts
@@ -1,4 +1,5 @@
import { use, useEffect, useRef, useState } from "react";
+import { v4 as generateUuid } from "uuid";
import { useLatest } from "../../../react/hooks/use-latest";
import { LanguageClientContext } from "../../../react/lsp/context";
@@ -29,7 +30,7 @@ export function useAdHocLspSession(
LanguageClientContext,
);
// useState (not useRef/useMemo) — needed for a stable per-mount value.
- const [generatedSessionId] = useState(() => crypto.randomUUID());
+ const [generatedSessionId] = useState(() => generateUuid());
const sessionId = externalSessionId ?? generatedSessionId;
const initializedRef = useRef(false);
// The content key; the effect reads the state itself through the ref so
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.test.tsx
index 8c63bc7059b..056d727a7af 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.test.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings.test.tsx
@@ -494,6 +494,20 @@ describe("combined UX settings", () => {
}),
),
);
+ const shape = await screen.findByRole("combobox", {
+ name: "Automatic arc shape",
+ });
+ fireEvent.click(shape);
+ fireEvent.click(await screen.findByRole("option", { name: "Square" }));
+ await waitFor(() =>
+ expect(shape.getAttribute("aria-expanded")).toBe("false"),
+ );
+ const avoid = await screen.findByRole("checkbox", { name: "Avoid nodes" });
+ expect((avoid as HTMLInputElement).checked).toBe(true);
+ await act(async () => fireEvent.click(avoid));
+ await waitFor(() =>
+ expect((avoid as HTMLInputElement).checked).toBe(false),
+ );
first.unmount();
renderSettings({ overlay: { type: "user-settings", section: "viewport" } });
expect(
@@ -506,10 +520,10 @@ describe("combined UX settings", () => {
expect(
(
screen.getByRole("checkbox", {
- name: "Automatic arc connections",
+ name: "Avoid nodes",
}) as HTMLInputElement
).checked,
- ).toBe(true);
+ ).toBe(false);
expect(
screen.queryByRole("combobox", { name: "Arc rendering" }),
).toBeNull();
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog.tsx
index 2bcbcea49e1..bc241af7b1d 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/editor-view/user-settings/user-settings-dialog.tsx
@@ -522,7 +522,37 @@ export const UserSettingsDialog = ({
value={settings.enableAutomaticArcConnections}
onChange={settings.setEnableAutomaticArcConnections}
/>
- {!settings.enableAutomaticArcConnections && (
+ {settings.enableAutomaticArcConnections ? (
+ <>
+
+ {(aria) => (
+
+ )}
+
+ {settings.automaticArcRendering === "square" && (
+
+ )}
+ >
+ ) : (
{},
setEnableExperimentalIconPack: () => {},
setEnableAutomaticArcConnections: () => {},
+ setAutomaticArcRendering: () => {},
+ setAvoidArcObstacles: () => {},
setArcRendering: () => {},
setCursorMode: () => {},
setIsLeftSidebarOpen: () => {},
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.tsx
index cf62107dbab..092103b06bc 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer.tsx
@@ -1,5 +1,6 @@
import { Collapsible } from "@ark-ui/react/collapsible";
import { use, useEffect, useLayoutEffect, useRef, useState } from "react";
+import { v4 as generateUuid } from "uuid";
import {
Button,
@@ -378,7 +379,7 @@ function createDefaultMetricDraft(sdcpn: SDCPN): ExperimentMetricDraft {
: "expression";
return {
- id: crypto.randomUUID(),
+ id: generateUuid(),
kind,
label: getDefaultMetricLabel(kind, sdcpn),
expanded: true,
@@ -387,7 +388,7 @@ function createDefaultMetricDraft(sdcpn: SDCPN): ExperimentMetricDraft {
transitionMode: "firedInThisFrame",
code: DEFAULT_METRIC_CODE,
sourceMetricId: null,
- metricSessionId: crypto.randomUUID(),
+ metricSessionId: generateUuid(),
lspDiagnostics: EMPTY_METRIC_LSP_DIAGNOSTICS,
};
}
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer/constraints-section.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer/constraints-section.tsx
index 13ec731d82e..a1ad0dda2f9 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer/constraints-section.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/experiments/create-experiment-drawer/constraints-section.tsx
@@ -7,6 +7,7 @@
* mid-typing moves nothing.
*/
import { use, useState } from "react";
+import { v4 as generateUuid } from "uuid";
import {
Button,
@@ -218,7 +219,7 @@ export const ConstraintsSection = ({
DEFAULT_OPTIMIZATION_CONSTRAINT_ALPHA;
const addRow = (space: ConstraintSpace) => {
- const id = crypto.randomUUID();
+ const id = generateUuid();
setFocusRowId(id);
onChange(addConstraintDraft(drafts, { id, space, code: "" }));
};
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/metrics/create-metric-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/metrics/create-metric-drawer.tsx
index 428531d80e7..95d600463ed 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/metrics/create-metric-drawer.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/metrics/create-metric-drawer.tsx
@@ -1,5 +1,6 @@
import { useStore } from "@tanstack/react-form";
import { use } from "react";
+import { v4 as generateUuid } from "uuid";
import { Button, Drawer } from "@hashintel/ds-components";
import { css } from "@hashintel/ds-helpers/css";
@@ -102,7 +103,7 @@ const CreateMetricContent = ({ onClose }: { onClose: () => void }) => {
const form = useMetricForm(
EMPTY_METRIC_FORM_STATE,
(value, ctx) => {
- const metric = buildMetricFromFormState(value, crypto.randomUUID());
+ const metric = buildMetricFromFormState(value, generateUuid());
const result = metricSchema.safeParse(metric);
if (!result.success) {
return;
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/metrics/metric-form.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/metrics/metric-form.tsx
index fe2804b2d07..7180d8ecde3 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/metrics/metric-form.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/metrics/metric-form.tsx
@@ -1,5 +1,6 @@
import { useForm, useStore } from "@tanstack/react-form";
import { use, useEffect, useRef, useState } from "react";
+import { v4 as generateUuid } from "uuid";
import { Form, TextArea, TextInput } from "@hashintel/ds-components";
import { css } from "@hashintel/ds-helpers/css";
@@ -131,7 +132,7 @@ export function useMetricLspSession(
use(LanguageClientContext);
// useState (not useRef/useMemo) — needed for a stable per-mount value.
// React Compiler doesn't replace useState; it only memoizes derived values.
- const [sessionId] = useState(() => providedSessionId ?? crypto.randomUUID());
+ const [sessionId] = useState(() => providedSessionId ?? generateUuid());
const initializedRef = useRef(false);
useEffect(() => {
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/ad-hoc-scenario-authoring.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/ad-hoc-scenario-authoring.tsx
index 5ceb56c4bf3..33772ae3087 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/ad-hoc-scenario-authoring.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/ad-hoc-scenario-authoring.tsx
@@ -14,6 +14,7 @@
*/
import { use, useState } from "react";
+import { v4 as generateUuid } from "uuid";
import { Drawer, Form, TextArea, TextInput } from "@hashintel/ds-components";
import { css } from "@hashintel/ds-helpers/css";
@@ -139,7 +140,7 @@ export function useAdHocScenarioAuthoring({
);
// Owned here (not generated inside the form) so the footer can address
// exactly this form's diagnostics.
- const [sessionId] = useState(() => crypto.randomUUID());
+ const [sessionId] = useState(() => generateUuid());
const context = {
netParameters: extensions.parameters ? petriNetDefinition.parameters : [],
diff --git a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/create-scenario-drawer.tsx b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/create-scenario-drawer.tsx
index 3b8ac131879..761921d2607 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/create-scenario-drawer.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/Editor/panels/SimulateView/scenarios/create-scenario-drawer.tsx
@@ -1,4 +1,5 @@
import { use, useState } from "react";
+import { v4 as generateUuid } from "uuid";
import { Button, Drawer } from "@hashintel/ds-components";
import { scenarioSchema } from "@hashintel/petrinaut-core";
@@ -28,7 +29,7 @@ const CreateScenarioContent = ({ onClose }: { onClose: () => void }) => {
const authoring = useAdHocScenarioAuthoring({ existingScenarioNames });
const save = () => {
- const scenario = authoring.buildScenario(crypto.randomUUID());
+ const scenario = authoring.buildScenario(generateUuid());
if (!scenario) {
return;
}
diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/outline-connection-line.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/outline-connection-line.tsx
index da33e4d220a..77fee8f4ba9 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/outline-connection-line.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/outline-connection-line.tsx
@@ -1,7 +1,9 @@
import { useStore, type ConnectionLineComponent } from "@xyflow/react";
-import { useId } from "react";
+import { use, useId } from "react";
+import { UserSettingsContext } from "../../../../../../react/state/user-settings-context";
import { getOutlineArcPath, getOutlineNode } from "./shared/outline-arcs";
+import { getSquareArcPath, getSquareArcRoute } from "./shared/square-arcs";
import type { NodeType } from "./react-flow-types";
@@ -13,6 +15,8 @@ export const OutlineConnectionLine: ConnectionLineComponent = ({
connectionStatus,
connectionLineStyle,
}) => {
+ const { automaticArcRendering, avoidArcObstacles } = use(UserSettingsContext);
+ const nodes = useStore((state) => state.nodes);
const markerId = useId();
const hasReverseArc = useStore(
(state) =>
@@ -27,11 +31,31 @@ export const OutlineConnectionLine: ConnectionLineComponent = ({
if (!source) {
return null;
}
- const [path] = getOutlineArcPath(
- source,
- target ?? { x: toX, y: toY },
- target !== null && hasReverseArc,
- );
+ const [path] =
+ automaticArcRendering === "square"
+ ? getSquareArcPath(
+ getSquareArcRoute(source, target ?? { x: toX, y: toY }, {
+ hasReverseArc: target !== null && hasReverseArc,
+ avoidObstacles: avoidArcObstacles,
+ obstacles: nodes
+ .filter(
+ (node) =>
+ node.id !== fromNode.id &&
+ (target === null || node.id !== toNode?.id),
+ )
+ .map((node) => ({
+ position: node.position,
+ width: node.measured?.width ?? node.width ?? 0,
+ height: node.measured?.height ?? node.height ?? 0,
+ cornerRadius: 0,
+ })),
+ }),
+ )
+ : getOutlineArcPath(
+ source,
+ target ?? { x: toX, y: toY },
+ target !== null && hasReverseArc,
+ );
return (
<>
diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/outline-arcs.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/outline-arcs.ts
index 28cf5b50636..8fdbb095d0c 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/outline-arcs.ts
+++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/outline-arcs.ts
@@ -9,7 +9,9 @@ export type OutlineNode = {
export type OutlineArcPath = [path: string, labelX: number, labelY: number];
-export const getOutlineNode = (node: CanvasNode): OutlineNode | null =>
+export const getOutlineNode = (
+ node: Pick,
+): OutlineNode | null =>
node.kind === "componentInstance"
? null
: {
diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/square-arcs.test.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/square-arcs.test.ts
new file mode 100644
index 00000000000..41fcbe30c7c
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/square-arcs.test.ts
@@ -0,0 +1,167 @@
+import { describe, expect, it } from "vitest";
+
+import { getSquareArcPath, getSquareArcRoute } from "./square-arcs";
+
+import type { OutlineNode } from "./outline-arcs";
+import type { SquareArcRoute } from "./square-arcs";
+
+const node = (
+ x: number,
+ y: number,
+ width = 100,
+ height = 100,
+ cornerRadius = 50,
+): OutlineNode => ({ position: { x, y }, width, height, cornerRadius });
+
+const expectOrthogonal = (route: SquareArcRoute) => {
+ expect(route.points.length).toBeGreaterThan(1);
+ for (const [index, point] of route.points.entries()) {
+ expect(Number.isFinite(point.x) && Number.isFinite(point.y)).toBe(true);
+ const previous = route.points[index - 1];
+ if (previous)
+ expect(point.x === previous.x || point.y === previous.y).toBe(true);
+ }
+};
+
+const expectClear = (route: SquareArcRoute, obstacles: OutlineNode[]) => {
+ for (const [index, point] of route.points.entries()) {
+ const previous = route.points[index - 1];
+ if (!previous) continue;
+ for (const obstacle of obstacles) {
+ const left = obstacle.position.x - obstacle.width / 2 - 8;
+ const right = obstacle.position.x + obstacle.width / 2 + 8;
+ const top = obstacle.position.y - obstacle.height / 2 - 8;
+ const bottom = obstacle.position.y + obstacle.height / 2 + 8;
+ const intersects =
+ point.x === previous.x
+ ? point.x > left &&
+ point.x < right &&
+ Math.min(point.y, previous.y) < bottom &&
+ Math.max(point.y, previous.y) > top
+ : point.y > top &&
+ point.y < bottom &&
+ Math.min(point.x, previous.x) < right &&
+ Math.max(point.x, previous.x) > left;
+ expect(intersects, JSON.stringify({ previous, point, obstacle })).toBe(
+ false,
+ );
+ }
+ }
+};
+
+describe("square automatic arcs", () => {
+ it.each([
+ [400, 0],
+ [-400, 0],
+ [0, 400],
+ [0, -400],
+ [400, 250],
+ [-400, -250],
+ ])("attaches orthogonally toward (%s, %s)", (x, y) => {
+ const source = node(0, 0);
+ const target = node(x, y, 160, 80, 12);
+ const route = getSquareArcRoute(source, target);
+ expect(route.obstacleAvoidance).toBe("routed");
+ expectOrthogonal(route);
+ const first = route.points[0]!;
+ expect(Math.hypot(first.x, first.y)).toBeCloseTo(50);
+ const last = route.points.at(-1)!;
+ expect(
+ Math.max(Math.abs(last.x - x) / 80, Math.abs(last.y - y) / 40),
+ ).toBeCloseTo(1);
+ });
+
+ it("detours around intervening nodes with clearance", () => {
+ const obstacles = [node(200, 0), node(340, -90, 100, 200, 0)];
+ const route = getSquareArcRoute(node(0, 0), node(550, 0), { obstacles });
+ expect(route.obstacleAvoidance).toBe("routed");
+ expectOrthogonal(route);
+ expectClear(route, obstacles);
+ expect(route.points.some((point) => Math.abs(point.y) >= 66)).toBe(true);
+ });
+
+ it("chooses another face when the nearest attachment is blocked", () => {
+ const obstacles = [node(105, 0, 80, 100, 0)];
+ const route = getSquareArcRoute(node(0, 0), node(400, 0), { obstacles });
+ expect(route.obstacleAvoidance).toBe("routed");
+ expectOrthogonal(route);
+ expectClear(route, obstacles);
+ expect(route.points[0]!.x).not.toBe(50);
+ });
+
+ it("keeps reciprocal arcs on distinct attachment lanes", () => {
+ const source = node(0, 0);
+ const target = node(400, 0, 160, 80, 12);
+ const forward = getSquareArcRoute(source, target, { hasReverseArc: true });
+ const reverse = getSquareArcRoute(target, source, { hasReverseArc: true });
+ expectOrthogonal(forward);
+ expectOrthogonal(reverse);
+ expect(forward.points[0]).not.toEqual(reverse.points.at(-1));
+ expect(forward.points.at(-1)).not.toEqual(reverse.points[0]);
+ expect(Math.hypot(forward.points[0]!.x, forward.points[0]!.y)).toBeCloseTo(
+ 50,
+ );
+ });
+
+ it("recomputes a route when an obstacle enters its path", () => {
+ const source = node(0, 0);
+ const target = node(400, 0);
+ const clear = getSquareArcRoute(source, target, {
+ obstacles: [node(200, 200)],
+ });
+ const moved = getSquareArcRoute(source, target, {
+ obstacles: [node(200, 0)],
+ });
+ expect(moved.points).not.toEqual(clear.points);
+ expectClear(moved, [node(200, 0)]);
+ });
+
+ it("routes a drag preview all the way to the pointer", () => {
+ const pointer = { x: 400, y: 30 };
+ const obstacles = [node(200, 0)];
+ const route = getSquareArcRoute(node(0, 0), pointer, { obstacles });
+ expect(route.obstacleAvoidance).toBe("routed");
+ expectOrthogonal(route);
+ expectClear(route, obstacles);
+ expect(route.points.at(-1)).toEqual(pointer);
+ });
+
+ it("reports a fallback when another node covers the source", () => {
+ const route = getSquareArcRoute(node(0, 0), node(400, 0), {
+ obstacles: [node(0, 0, 300, 300)],
+ });
+ expect(route.obstacleAvoidance).toBe("fallback");
+ expectOrthogonal(route);
+ });
+
+ it("can turn avoidance off without changing to curves", () => {
+ const source = node(0, 0);
+ const target = node(400, 0);
+ const route = getSquareArcRoute(source, target, {
+ obstacles: [node(200, 0)],
+ avoidObstacles: false,
+ });
+ expect(route.obstacleAvoidance).toBe("off");
+ expect(route.points).toEqual([
+ { x: 50, y: 0 },
+ { x: 350, y: 0 },
+ ]);
+ expect(getSquareArcPath(route)).toEqual(["M 50,0 L 350,0", 200, 0]);
+ });
+
+ it("ignores distant nodes when searching the local corridor", () => {
+ const obstacles = Array.from({ length: 1000 }, (_, index) =>
+ node(2000 + index * 140, 2000 + index * 120),
+ );
+ obstacles.push(node(200, 0));
+ const route = getSquareArcRoute(node(0, 0), node(400, 0), { obstacles });
+ expect(route.obstacleAvoidance).toBe("routed");
+ expectClear(route, obstacles);
+ });
+
+ it("keeps blocked and overlapping endpoints finite", () => {
+ for (const target of [node(0, 0), node(20, 10), node(100, 0)]) {
+ expectOrthogonal(getSquareArcRoute(node(0, 0), target));
+ }
+ });
+});
diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/square-arcs.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/square-arcs.ts
new file mode 100644
index 00000000000..51b7d45dede
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/square-arcs.ts
@@ -0,0 +1,233 @@
+import { findRoute, segmentIsClear } from "./square-arcs/find-route";
+
+import type { CanvasPoint } from "../../../../canvas-scene";
+import type { OutlineArcPath, OutlineNode } from "./outline-arcs";
+import type { RoutingBox, RoutingPort } from "./square-arcs/find-route";
+
+const clearance = 16;
+const directions = [
+ { x: 1, y: 0 },
+ { x: 0, y: 1 },
+ { x: -1, y: 0 },
+ { x: 0, y: -1 },
+];
+
+const boxOf = (node: OutlineNode): RoutingBox => ({
+ left: node.position.x - node.width / 2 - clearance,
+ right: node.position.x + node.width / 2 + clearance,
+ top: node.position.y - node.height / 2 - clearance,
+ bottom: node.position.y + node.height / 2 + clearance,
+});
+
+const portsOf = (node: OutlineNode, lane: number): RoutingPort[] =>
+ directions.map((normal, direction) => {
+ const halfWidth = Math.max(0, node.width / 2);
+ const halfHeight = Math.max(0, node.height / 2);
+ const radius = Math.max(
+ 0,
+ Math.min(node.cornerRadius, halfWidth, halfHeight),
+ );
+ const halfSide = normal.x === 0 ? halfWidth : halfHeight;
+ const halfDepth = normal.x === 0 ? halfHeight : halfWidth;
+ const offset = lane * Math.min(10, halfSide / 3);
+ const cornerOffset = Math.max(0, Math.abs(offset) - (halfSide - radius));
+ const depth =
+ cornerOffset > 0
+ ? halfDepth -
+ radius +
+ Math.sqrt(Math.max(0, radius ** 2 - cornerOffset ** 2))
+ : halfDepth;
+ const anchor = {
+ x: node.position.x + normal.x * depth - normal.y * offset,
+ y: node.position.y + normal.y * depth + normal.x * offset,
+ };
+ return {
+ anchor,
+ direction,
+ point: {
+ x:
+ node.position.x +
+ normal.x * (halfDepth + clearance) -
+ normal.y * offset,
+ y:
+ node.position.y +
+ normal.y * (halfDepth + clearance) +
+ normal.x * offset,
+ },
+ };
+ });
+
+const simplify = (points: readonly CanvasPoint[]): CanvasPoint[] => {
+ const result: CanvasPoint[] = [];
+ for (const point of points) {
+ const last = result.at(-1);
+ if (last?.x === point.x && last.y === point.y) continue;
+ const before = result.at(-2);
+ if (
+ last &&
+ before &&
+ ((before.x === last.x && last.x === point.x) ||
+ (before.y === last.y && last.y === point.y))
+ )
+ result.pop();
+ result.push(point);
+ }
+ return result;
+};
+
+export type SquareArcRoute = {
+ points: CanvasPoint[];
+ obstacleAvoidance: "routed" | "fallback" | "off";
+};
+
+export const getSquareArcRoute = (
+ source: OutlineNode,
+ target: OutlineNode | CanvasPoint,
+ {
+ obstacles = [],
+ avoidObstacles = true,
+ hasReverseArc = false,
+ }: {
+ obstacles?: readonly OutlineNode[];
+ avoidObstacles?: boolean;
+ hasReverseArc?: boolean;
+ } = {},
+): SquareArcRoute => {
+ const targetNode = "position" in target ? target : null;
+ const targetCenter = targetNode
+ ? targetNode.position
+ : (target as CanvasPoint);
+ const sourcePorts = portsOf(source, hasReverseArc ? 1 : 0);
+ const targetPorts = targetNode
+ ? portsOf(targetNode, hasReverseArc ? -1 : 0)
+ : [{ point: targetCenter, anchor: targetCenter, direction: -1 }];
+ const endpointBoxes = [
+ boxOf(source),
+ ...(targetNode ? [boxOf(targetNode)] : []),
+ ];
+ const boundsWithMargin = (margin: number): RoutingBox => ({
+ left:
+ Math.min(...endpointBoxes.map((box) => box.left), targetCenter.x) -
+ margin,
+ right:
+ Math.max(...endpointBoxes.map((box) => box.right), targetCenter.x) +
+ margin,
+ top:
+ Math.min(...endpointBoxes.map((box) => box.top), targetCenter.y) - margin,
+ bottom:
+ Math.max(...endpointBoxes.map((box) => box.bottom), targetCenter.y) +
+ margin,
+ });
+ const outerBounds = boundsWithMargin(256);
+ const obstacleBoxes = avoidObstacles
+ ? obstacles
+ .filter(
+ (obstacle) =>
+ obstacle.position.x - obstacle.width / 2 - clearance <
+ outerBounds.right &&
+ obstacle.position.x + obstacle.width / 2 + clearance >
+ outerBounds.left &&
+ obstacle.position.y - obstacle.height / 2 - clearance <
+ outerBounds.bottom &&
+ obstacle.position.y + obstacle.height / 2 + clearance >
+ outerBounds.top,
+ )
+ .map(boxOf)
+ : [];
+ const sourceBlockedBy = [...obstacleBoxes, ...endpointBoxes.slice(1)];
+ const targetBlockedBy = [...obstacleBoxes, endpointBoxes[0]!];
+ const sources = sourcePorts.filter((port) =>
+ segmentIsClear(port.anchor, port.point, sourceBlockedBy),
+ );
+ const targets = targetPorts.filter((port) =>
+ segmentIsClear(port.anchor, port.point, targetBlockedBy),
+ );
+ const boxes = [...endpointBoxes, ...obstacleBoxes];
+ for (const start of sources) {
+ for (const end of targets) {
+ const normal = directions[start.direction];
+ if (
+ !normal ||
+ (end.direction >= 0 && end.direction !== (start.direction + 2) % 4)
+ )
+ continue;
+ const horizontal =
+ start.point.y === end.point.y &&
+ normal.x * (end.point.x - start.point.x) > 0;
+ const vertical =
+ start.point.x === end.point.x &&
+ normal.y * (end.point.y - start.point.y) > 0;
+ if (
+ (horizontal || vertical) &&
+ segmentIsClear(start.point, end.point, boxes)
+ ) {
+ return {
+ points: [start.anchor, end.anchor],
+ obstacleAvoidance: avoidObstacles ? "routed" : "off",
+ };
+ }
+ }
+ }
+ for (const margin of [64, 256]) {
+ const bounds = boundsWithMargin(margin);
+ const nearby = boxes.filter(
+ (box) =>
+ box.left < bounds.right &&
+ box.right > bounds.left &&
+ box.top < bounds.bottom &&
+ box.bottom > bounds.top,
+ );
+ const points = findRoute(sources, targets, nearby, bounds);
+ if (points)
+ return {
+ points: simplify(points),
+ obstacleAvoidance: avoidObstacles ? "routed" : "off",
+ };
+ }
+ if (avoidObstacles)
+ return {
+ ...getSquareArcRoute(source, target, {
+ hasReverseArc,
+ avoidObstacles: false,
+ }),
+ obstacleAvoidance: "fallback",
+ };
+ const direction =
+ Math.abs(targetCenter.x - source.position.x) >=
+ Math.abs(targetCenter.y - source.position.y)
+ ? targetCenter.x >= source.position.x
+ ? 0
+ : 2
+ : targetCenter.y >= source.position.y
+ ? 1
+ : 3;
+ const start = sourcePorts[direction]!.anchor;
+ const end = targetNode
+ ? targetPorts[(direction + 2) % 4]!.anchor
+ : targetCenter;
+ return {
+ points: simplify([start, { x: end.x, y: start.y }, end]),
+ obstacleAvoidance: "off",
+ };
+};
+
+export const getSquareArcPath = (route: SquareArcRoute): OutlineArcPath => {
+ let longest = -1;
+ let label = route.points[0] ?? { x: 0, y: 0 };
+ for (let index = 1; index < route.points.length; index++) {
+ const start = route.points[index - 1]!;
+ const end = route.points[index]!;
+ const length = Math.hypot(end.x - start.x, end.y - start.y);
+ if (length > longest) {
+ longest = length;
+ label = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
+ }
+ }
+ return [
+ route.points
+ .map((point, index) => `${index === 0 ? "M" : "L"} ${point.x},${point.y}`)
+ .join(" "),
+ label.x,
+ label.y,
+ ];
+};
diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/square-arcs/find-route.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/square-arcs/find-route.ts
new file mode 100644
index 00000000000..aff43adc329
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/shared/square-arcs/find-route.ts
@@ -0,0 +1,214 @@
+import type { CanvasPoint } from "../../../../../canvas-scene";
+
+export type RoutingBox = {
+ left: number;
+ right: number;
+ top: number;
+ bottom: number;
+};
+export type RoutingPort = {
+ point: CanvasPoint;
+ anchor: CanvasPoint;
+ direction: number;
+};
+
+const directions = [
+ { x: 1, y: 0 },
+ { x: 0, y: 1 },
+ { x: -1, y: 0 },
+ { x: 0, y: -1 },
+];
+const bendCost = 32;
+const maxGridPoints = 24_000;
+const maxVisitedStates = 12_000;
+const epsilon = 0.001;
+
+export const segmentIsClear = (
+ start: CanvasPoint,
+ end: CanvasPoint,
+ boxes: readonly RoutingBox[],
+): boolean =>
+ !boxes.some((box) =>
+ start.x === end.x
+ ? start.x > box.left + epsilon &&
+ start.x < box.right - epsilon &&
+ Math.max(start.y, end.y) > box.top + epsilon &&
+ Math.min(start.y, end.y) < box.bottom - epsilon
+ : start.y > box.top + epsilon &&
+ start.y < box.bottom - epsilon &&
+ Math.max(start.x, end.x) > box.left + epsilon &&
+ Math.min(start.x, end.x) < box.right - epsilon,
+ );
+
+type Entry = { state: number; cost: number; estimate: number };
+
+const createQueue = () => {
+ const heap: Entry[] = [];
+ return {
+ get length() {
+ return heap.length;
+ },
+ push: (entry: Entry) => {
+ let index = heap.length;
+ heap.push(entry);
+ while (index > 0) {
+ const parent = Math.floor((index - 1) / 2);
+ const parentEntry = heap[parent];
+ if (!parentEntry || parentEntry.estimate <= entry.estimate) break;
+ heap[index] = parentEntry;
+ index = parent;
+ }
+ heap[index] = entry;
+ },
+ pop: (): Entry | undefined => {
+ const first = heap[0];
+ const last = heap.pop();
+ if (!last || heap.length === 0) return first;
+ let index = 0;
+ while (index * 2 + 1 < heap.length) {
+ let child = index * 2 + 1;
+ let childEntry = heap[child];
+ const nextChild = heap[child + 1];
+ if (
+ nextChild &&
+ childEntry &&
+ nextChild.estimate < childEntry.estimate
+ ) {
+ child++;
+ childEntry = nextChild;
+ }
+ if (!childEntry || childEntry.estimate >= last.estimate) break;
+ heap[index] = childEntry;
+ index = child;
+ }
+ heap[index] = last;
+ return first;
+ },
+ };
+};
+
+const distance = (start: CanvasPoint, end: CanvasPoint) =>
+ Math.abs(end.x - start.x) + Math.abs(end.y - start.y);
+
+/** Searches the orthogonal visibility grid, with heading in the state so bends have a cost. */
+export const findRoute = (
+ sources: readonly RoutingPort[],
+ targets: readonly RoutingPort[],
+ boxes: readonly RoutingBox[],
+ bounds: RoutingBox,
+): CanvasPoint[] | null => {
+ if (!sources.length || !targets.length) return null;
+ const ports = [...sources, ...targets];
+ const xs = [
+ ...new Set([
+ bounds.left,
+ bounds.right,
+ ...ports.map((port) => port.point.x),
+ ...boxes
+ .flatMap((box) => [box.left, box.right])
+ .filter((value) => value > bounds.left && value < bounds.right),
+ ]),
+ ].sort((left, right) => left - right);
+ const ys = [
+ ...new Set([
+ bounds.top,
+ bounds.bottom,
+ ...ports.map((port) => port.point.y),
+ ...boxes
+ .flatMap((box) => [box.top, box.bottom])
+ .filter((value) => value > bounds.top && value < bounds.bottom),
+ ]),
+ ].sort((left, right) => left - right);
+ if (xs.length * ys.length > maxGridPoints) return null;
+
+ const cellOf = (point: CanvasPoint) =>
+ ys.indexOf(point.y) * xs.length + xs.indexOf(point.x);
+ const pointOf = (cell: number): CanvasPoint => ({
+ x: xs[cell % xs.length]!,
+ y: ys[Math.floor(cell / xs.length)]!,
+ });
+ const targetsByCell = new Map(
+ targets.map((port) => [cellOf(port.point), port]),
+ );
+ const heuristic = (point: CanvasPoint) =>
+ Math.min(
+ ...targets.map(
+ (port) =>
+ distance(point, port.point) + distance(port.point, port.anchor),
+ ),
+ );
+ const costs = new Map();
+ const previous = new Map();
+ const starts = new Map();
+ const queue = createQueue();
+ const clearSegments = new Map();
+ for (const port of sources) {
+ const state = cellOf(port.point) * 4 + port.direction;
+ const cost = distance(port.anchor, port.point);
+ costs.set(state, cost);
+ starts.set(state, port);
+ queue.push({ state, cost, estimate: cost + heuristic(port.point) });
+ }
+
+ let best: { state: number; target: RoutingPort; cost: number } | undefined;
+ let visited = 0;
+ while (queue.length && visited < maxVisitedStates) {
+ const entry = queue.pop();
+ if (!entry) break;
+ if (best && entry.estimate >= best.cost) break;
+ if (entry.cost !== costs.get(entry.state)) continue;
+ visited++;
+ const cell = Math.floor(entry.state / 4);
+ const heading = entry.state % 4;
+ const point = pointOf(cell);
+ const target = targetsByCell.get(cell);
+ if (target && (target.direction < 0 || heading !== target.direction)) {
+ const cost =
+ entry.cost +
+ distance(point, target.anchor) +
+ (target.direction >= 0 && heading !== (target.direction + 2) % 4
+ ? bendCost
+ : 0);
+ if (!best || cost < best.cost)
+ best = { state: entry.state, target, cost };
+ }
+ for (const [direction, delta] of directions.entries()) {
+ if (direction === (heading + 2) % 4) continue;
+ const column = (cell % xs.length) + delta.x;
+ const row = Math.floor(cell / xs.length) + delta.y;
+ if (column < 0 || column >= xs.length || row < 0 || row >= ys.length)
+ continue;
+ const nextCell = row * xs.length + column;
+ const nextPoint = pointOf(nextCell);
+ const key = `${Math.min(cell, nextCell)}:${Math.max(cell, nextCell)}`;
+ let clear = clearSegments.get(key);
+ if (clear === undefined) {
+ clear = segmentIsClear(point, nextPoint, boxes);
+ clearSegments.set(key, clear);
+ }
+ if (!clear) continue;
+ const state = nextCell * 4 + direction;
+ const cost =
+ entry.cost +
+ distance(point, nextPoint) +
+ (heading === direction ? 0 : bendCost);
+ if (cost >= (costs.get(state) ?? Infinity)) continue;
+ costs.set(state, cost);
+ previous.set(state, entry.state);
+ queue.push({ state, cost, estimate: cost + heuristic(nextPoint) });
+ }
+ }
+ if (!best) return null;
+ const points = [best.target.anchor];
+ let state = best.state;
+ for (;;) {
+ points.push(pointOf(Math.floor(state / 4)));
+ const parent = previous.get(state);
+ if (parent === undefined) break;
+ state = parent;
+ }
+ const source = starts.get(state);
+ if (!source) return null;
+ points.push(source.anchor);
+ return points.reverse();
+};
diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-automatic-arc-paths.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-automatic-arc-paths.ts
new file mode 100644
index 00000000000..b161765da8d
--- /dev/null
+++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-automatic-arc-paths.ts
@@ -0,0 +1,93 @@
+import { use } from "react";
+
+import { UserSettingsContext } from "../../../../../../react/state/user-settings-context";
+import { useStableItems } from "../../../use-stable-items";
+import { getOutlineArcPath, getOutlineNode } from "./shared/outline-arcs";
+import { getSquareArcPath, getSquareArcRoute } from "./shared/square-arcs";
+
+import type { CanvasScene, CanvasNode } from "../../../canvas-scene";
+import type { OutlineArcPath } from "./shared/outline-arcs";
+
+type RoutingScene = {
+ id: string;
+ nodes: Pick[];
+ arcs: { id: string; sourceId: string; targetId: string }[];
+};
+
+const routeArcs = (
+ scene: RoutingScene,
+ square: boolean,
+ avoidObstacles: boolean,
+) => {
+ const nodesById = new Map(scene.nodes.map((node) => [node.id, node]));
+ const connections = new Set(
+ scene.arcs.map((arc) => JSON.stringify([arc.sourceId, arc.targetId])),
+ );
+ const paths = new Map();
+ for (const arc of scene.arcs) {
+ const source = nodesById.get(arc.sourceId);
+ const target = nodesById.get(arc.targetId);
+ const sourceOutline = source && getOutlineNode(source);
+ const targetOutline = target && getOutlineNode(target);
+ if (!sourceOutline || !targetOutline) continue;
+ const hasReverseArc = connections.has(
+ JSON.stringify([arc.targetId, arc.sourceId]),
+ );
+ paths.set(
+ arc.id,
+ square
+ ? getSquareArcPath(
+ getSquareArcRoute(sourceOutline, targetOutline, {
+ hasReverseArc,
+ avoidObstacles,
+ obstacles: avoidObstacles
+ ? scene.nodes
+ .filter(
+ (node) =>
+ node.id !== arc.sourceId && node.id !== arc.targetId,
+ )
+ .map((node) => ({ ...node, cornerRadius: 0 }))
+ : [],
+ }),
+ )
+ : getOutlineArcPath(sourceOutline, targetOutline, hasReverseArc),
+ );
+ }
+ return paths;
+};
+
+export const useAutomaticArcPaths = (
+ scene: CanvasScene,
+): Map => {
+ const {
+ enableAutomaticArcConnections,
+ automaticArcRendering,
+ avoidArcObstacles,
+ } = use(UserSettingsContext);
+ const [routingScene] = useStableItems([
+ {
+ id: "routing",
+ nodes: enableAutomaticArcConnections
+ ? scene.nodes.map(({ id, kind, position, width, height }) => ({
+ id,
+ kind,
+ position,
+ width,
+ height,
+ }))
+ : [],
+ arcs: enableAutomaticArcConnections
+ ? scene.arcs
+ .filter((arc) => !arc.sourcePortId && !arc.targetPortId)
+ .map(({ id, sourceId, targetId }) => ({ id, sourceId, targetId }))
+ : [],
+ },
+ ]);
+ return enableAutomaticArcConnections && routingScene
+ ? routeArcs(
+ routingScene,
+ automaticArcRendering === "square",
+ avoidArcObstacles,
+ )
+ : new Map();
+};
diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-react-flow-elements.test.tsx b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-react-flow-elements.test.tsx
index 6bb2ce7b605..81360f85d41 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-react-flow-elements.test.tsx
+++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-react-flow-elements.test.tsx
@@ -1,7 +1,7 @@
/** @vitest-environment jsdom */
import { cleanup, fireEvent, renderHook, screen } from "@testing-library/react";
import { useState, type ReactNode } from "react";
-import { afterEach, describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import {
classicNodeDimensions,
@@ -12,11 +12,15 @@ import {
defaultUserSettingsContextValue,
UserSettingsContext,
} from "../../../../../../react/state/user-settings-context";
+import * as squareArcs from "./shared/square-arcs";
import { useReactFlowElements } from "./use-react-flow-elements";
import type { CanvasArc, CanvasNode, CanvasScene } from "../../../canvas-scene";
-afterEach(cleanup);
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
const nodeBase = {
position: { x: 0, y: 0 },
@@ -277,4 +281,54 @@ describe("automatic arc rendering", () => {
}
}
});
+ it("reroutes square arcs for moved obstacles, but reuses routes on hover", () => {
+ const router = vi.spyOn(squareArcs, "getSquareArcRoute");
+ const SquareSettings = ({ children }: { children: ReactNode }) => (
+
+ {children}
+
+ );
+ const { result, rerender } = renderHook(
+ (input: CanvasScene) => useReactFlowElements(input),
+ { initialProps: scene, wrapper: SquareSettings },
+ );
+ expect(router).toHaveBeenCalledTimes(2);
+ const before = result.current.edges;
+ rerender({
+ ...scene,
+ focusActive: true,
+ nodes: nodes.map((node) => ({
+ ...node,
+ hovered: node.id === "place",
+ focus: node.id === "place" ? "focused" : "none",
+ })),
+ });
+ expect(router).toHaveBeenCalledTimes(2);
+ expect(result.current.edges).toEqual(before);
+ const path = before.find((edge) => edge.id === "input")?.data?.outlinePath;
+ expect(path).toBeDefined();
+ const obstaclePosition = { x: path?.[1] ?? 0, y: path?.[2] ?? 0 };
+ rerender({
+ ...scene,
+ nodes: nodes.map((node) =>
+ node.id === "subnet" ? { ...node, position: obstaclePosition } : node,
+ ),
+ });
+ expect(router).toHaveBeenCalledTimes(4);
+ expect(
+ result.current.edges.find((edge) => edge.id === "input")?.data
+ ?.outlinePath,
+ ).not.toEqual(
+ before.find((edge) => edge.id === "input")?.data?.outlinePath,
+ );
+ expect(
+ result.current.edges.find((edge) => edge.id === "port-input"),
+ ).toEqual(before.find((edge) => edge.id === "port-input"));
+ });
});
diff --git a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-react-flow-elements.ts b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-react-flow-elements.ts
index 00ae6bf5d7b..b89574be1cf 100644
--- a/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-react-flow-elements.ts
+++ b/libs/@hashintel/petrinaut/src/ui/views/SDCPN/renderers/react-flow/react-flow-canvas/use-react-flow-elements.ts
@@ -1,11 +1,9 @@
import { MarkerType } from "@xyflow/react";
-import { use } from "react";
-import { UserSettingsContext } from "../../../../../../react/state/user-settings-context";
import { arcHaloColor } from "../../../styles/focus";
import { useStableItems } from "../../../use-stable-items";
import { portInHandleId, portOutHandleId } from "./port-handles";
-import { getOutlineArcPath, getOutlineNode } from "./shared/outline-arcs";
+import { useAutomaticArcPaths } from "./use-automatic-arc-paths";
import type { CanvasArc, CanvasNode, CanvasScene } from "../../../canvas-scene";
import type { ArcEdgeType, NodeType } from "./react-flow-types";
@@ -82,13 +80,7 @@ const toReactFlowEdge = (arc: CanvasArc): ArcEdgeType => {
export const useReactFlowElements = (
scene: CanvasScene,
): { nodes: NodeType[]; edges: ArcEdgeType[] } => {
- const { enableAutomaticArcConnections } = use(UserSettingsContext);
- const nodesById = new Map(scene.nodes.map((node) => [node.id, node]));
- const connections = new Set(
- scene.arcs
- .filter((arc) => !arc.sourcePortId && !arc.targetPortId)
- .map((arc) => JSON.stringify([arc.sourceId, arc.targetId])),
- );
+ const paths = useAutomaticArcPaths(scene);
// Rebuilt from the scene, then held at their previous identity where
// nothing changed, so React Flow re-renders only what a hover touched.
@@ -97,19 +89,8 @@ export const useReactFlowElements = (
edges: useStableItems(
scene.arcs.map((arc) => {
const edge = toReactFlowEdge(arc);
- if (enableAutomaticArcConnections && edge.data) {
- const source = nodesById.get(arc.sourceId);
- const target = nodesById.get(arc.targetId);
- const sourceOutline = source && getOutlineNode(source);
- const targetOutline = target && getOutlineNode(target);
- if (sourceOutline && targetOutline) {
- edge.data.outlinePath = getOutlineArcPath(
- sourceOutline,
- targetOutline,
- connections.has(JSON.stringify([arc.targetId, arc.sourceId])),
- );
- }
- }
+ const outlinePath = paths.get(arc.id);
+ if (outlinePath && edge.data) edge.data.outlinePath = outlinePath;
return edge;
}),
),