Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pixi-canvas-renderer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@hashintel/petrinaut": patch
---

Add an experimental Pixi renderer for the canvas, selectable in the viewport settings dialog.
11 changes: 11 additions & 0 deletions libs/@hashintel/petrinaut/docs/visual-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,14 @@ Choose how arcs are drawn between nodes:
| **Square** | Right-angle paths (smoothstep routing). |
| **Bezier** | Smooth curved paths. |
| **Adaptive Bezier** | Curved paths that adjust based on node positions. (Default) |

### Renderer (experimental)

Choose which engine draws the canvas:

| Renderer | Description |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| **React Flow** | The default. Nodes and arcs are regular page elements. |
| **Pixi (GPU)** | Draws the net on a GPU-accelerated canvas. Stays smooth on large nets and during playback; some visual details differ slightly from React Flow. |

Both renderers share the same nets, selection, tools and settings, so switching is safe at any time. The choice is saved with your other settings.
2 changes: 2 additions & 0 deletions libs/@hashintel/petrinaut/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,14 @@
"@hashintel/petrinaut-core": "workspace:^",
"@hashintel/refractive": "workspace:^",
"@monaco-editor/react": "4.8.0-rc.3",
"@pixi/react": "8.0.5",
"@tanstack/react-form": "1.29.0",
"@xyflow/react": "12.10.1",
"ai": "6.0.182",
"fuzzysort": "3.1.0",
"lodash-es": "4.18.1",
"monaco-editor": "0.55.1",
"pixi.js": "8.20.0",
"react-markdown": "10.1.0",
"react-resizable-panels": "4.6.5",
"uplot": "1.6.32",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ import type {

export type ArcRendering = "smoothstep" | "bezier" | "custom";

/** Which engine draws the canvas. */
export const canvasRendererNames = ["react-flow", "pixi"] as const;

export type CanvasRendererName = (typeof canvasRendererNames)[number];

export type SubViewSectionSettings = {
collapsed: boolean;
/** Last known panel height in pixels */
Expand All @@ -35,6 +40,7 @@ export type UserSettings = {
keepPanelsMounted: boolean;
compactNodes: boolean;
arcRendering: ArcRendering;
canvasRenderer: CanvasRendererName;
cursorMode: CursorMode;
isLeftSidebarOpen: boolean;
leftSidebarWidth: number;
Expand Down Expand Up @@ -104,6 +110,7 @@ export type UserSettingsActions = {
setKeepPanelsMounted: (value: boolean) => void;
setCompactNodes: (value: boolean) => void;
setArcRendering: (value: ArcRendering) => void;
setCanvasRenderer: (value: CanvasRendererName) => void;
setIsLeftSidebarOpen: (value: boolean) => void;
setLeftSidebarWidth: (value: number) => void;
setPropertiesPanelWidth: (value: number) => void;
Expand Down Expand Up @@ -139,6 +146,7 @@ export const defaultUserSettings: UserSettings = {
keepPanelsMounted: true,
compactNodes: false,
arcRendering: "custom",
canvasRenderer: "react-flow",
cursorMode: "pan",
isLeftSidebarOpen: true,
leftSidebarWidth: DEFAULT_LEFT_SIDEBAR_WIDTH,
Expand Down Expand Up @@ -169,6 +177,7 @@ const DEFAULT_CONTEXT_VALUE: UserSettingsContextValue = {
setKeepPanelsMounted: () => {},
setCompactNodes: () => {},
setArcRendering: () => {},
setCanvasRenderer: () => {},
setIsLeftSidebarOpen: () => {},
setLeftSidebarWidth: () => {},
setPropertiesPanelWidth: () => {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
} from "./editor-context";
import type {
ArcRendering,
CanvasRendererName,
SubViewSectionSettings,
UserSettings,
} from "./user-settings-context";
Expand Down Expand Up @@ -74,6 +75,8 @@ export const UserSettingsProvider: React.FC<React.PropsWithChildren> = ({
setState((prev) => ({ ...prev, compactNodes: value })),
setArcRendering: (value: ArcRendering) =>
setState((prev) => ({ ...prev, arcRendering: value })),
setCanvasRenderer: (value: CanvasRendererName) =>
setState((prev) => ({ ...prev, canvasRenderer: value })),
setCursorMode: (value: CursorMode) =>
setState((prev) => ({ ...prev, cursorMode: value })),
setIsLeftSidebarOpen: (value: boolean) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ const TestProviders = ({
setShowCompilationOutput: () => {},
setEnableParameterSweeps: () => {},
setEnableOptimizationSurface: () => {},
setCanvasRenderer: () => {},
setCanvasViewport: () => {},
updateSubViewSection: () => {},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,5 @@ export type CanvasRendererProps = {

export type CanvasRenderer = React.FC<CanvasRendererProps>;

export const canvasRendererNames = ["react-flow"] as const;

export type CanvasRendererName = (typeof canvasRendererNames)[number];
/** The user setting that picks the renderer names the same engines. */
export type { CanvasRendererName } from "../../../react/state/user-settings-context";
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { PixiCanvas } from "./renderers/pixi/pixi-canvas";
import { ReactFlowCanvas } from "./renderers/react-flow/react-flow-canvas";

import type { CanvasRenderer, CanvasRendererName } from "./canvas-renderer";

/** Every renderer the canvas view can mount, by name. */
/** Every renderer the canvas view can mount, by the name the user setting stores. */
export const canvasRenderers: Record<CanvasRendererName, CanvasRenderer> = {
"react-flow": ReactFlowCanvas,
pixi: PixiCanvas,
};

export const defaultCanvasRenderer: CanvasRendererName = "react-flow";
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import { isWebGpuAvailable } from "@hashintel/petrinaut-core";
import { SDCPNContext } from "../../../../react/state/sdcpn-context";
import { UserSettingsContext } from "../../../../react/state/user-settings-context";

import type { ArcRendering } from "../../../../react/state/user-settings-context";
import type {
ArcRendering,
CanvasRendererName,
} from "../../../../react/state/user-settings-context";

const rowStyle = css({
display: "grid",
Expand Down Expand Up @@ -94,6 +97,8 @@ export const ViewportSettingsDialog: React.FC<ViewportSettingsDialogProps> = ({
setCompactNodes,
arcRendering,
setArcRendering,
canvasRenderer,
setCanvasRenderer,
showMinimap,
setShowMinimap,
snapToGrid,
Expand Down Expand Up @@ -174,6 +179,31 @@ export const ViewportSettingsDialog: React.FC<ViewportSettingsDialogProps> = ({
]}
/>
</SettingRow>
<SettingRow
label={
<>
Renderer{" "}
<Chip size="xs" color="orange" variant="outline" shape="round">
Experimental
</Chip>
</>
}
description="Draw the canvas with React Flow or on a GPU-accelerated Pixi canvas"
>
<Select
size="sm"
className={selectStyle}
required
value={canvasRenderer}
onChange={(nextRenderer) =>
setCanvasRenderer(nextRenderer as CanvasRendererName)
}
items={[
{ value: "react-flow", text: "React Flow" },
{ value: "pixi", text: "Pixi (GPU)" },
]}
/>
</SettingRow>

<h3 className={sectionTitleStyle}>General</h3>
<SettingRow
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
/**
* @layerRoot ui.views.canvas.pixi
* @role Draws the canvas scene with Pixi and turns pointer gestures into the shared canvas interactions
*/

import { use, useEffect, useRef, useState } from "react";

import { css } from "@hashintel/ds-helpers/css";
import {
getBoundsOfCenteredBoxes,
getMinZoomForBounds,
} from "@hashintel/petrinaut-core";

import { useLatest } from "../../../../../react/hooks/use-latest";
import { CanvasViewportContext } from "../../../../../react/state/canvas-viewport-context";
import { EditorContext } from "../../../../../react/state/editor-context";
import { UserSettingsContext } from "../../../../../react/state/user-settings-context";
import {
CanvasControllerContext,
type CanvasRenderer,
} from "../../canvas-renderer";
import { getInitialViewport } from "../../canvas-viewport";
import { ViewportControls } from "../../components/viewport-controls";
import { useRecenterOnPanelOpen } from "../../hooks/use-recenter-on-panel-open";
import { useDebouncedValue } from "../../hooks/util/use-debounced-value";
import { useCanvasInteractions } from "../../use-canvas-interactions";
import { buildArcPolylines } from "./pixi-canvas/arc-polylines";
import {
createFrameStore,
FrameBridge,
useFrameSnapshot,
} from "./pixi-canvas/frame-store";
import { usePixiController } from "./pixi-canvas/pixi-controller";
import { PixiMiniMap } from "./pixi-canvas/pixi-mini-map";
import { PixiPlaceStateTooltip } from "./pixi-canvas/pixi-place-state-tooltip";
import { readPixiTheme } from "./pixi-canvas/pixi-theme";
import { PixiWorld } from "./pixi-canvas/pixi-world";
import { useCanvasGestures } from "./pixi-canvas/use-canvas-gestures";
import { createViewportStore, useViewport } from "./pixi-canvas/viewport-store";

import type { ArcBatch } from "./pixi-canvas/pixi-arcs";
import type { NodeRegistry } from "./pixi-canvas/pixi-nodes";

/** React Flow's default zoom ceiling. */
const maxZoom = 2;
const minZoomDebounceMs = 100;
/** How long the viewport holds still before it counts as settled and is saved. */
const viewportSettleMs = 150;

const hostStyle = css({
position: "absolute",
inset: "[0]",
overflow: "hidden",
touchAction: "none",
userSelect: "none",
"& canvas": {
display: "block",
},
});

const fadeBgStyle = css({
position: "absolute",
inset: "[0]",
background: "[rgba(255, 255, 255, 0.3)]",
pointerEvents: "none",
});

/**
* The Pixi implementation of the canvas. The renderer owns the viewport in an
* external store, draws the scene in one WebGL canvas, and keeps the shared
* DOM overlays (viewport controls, minimap, tooltips) around it.
*/
export const PixiCanvas: CanvasRenderer = ({
scene,
containerSize,
viewportActions,
}) => {
const hostRef = useRef<HTMLDivElement>(null);
const { compactNodes, showMinimap, partialSelection, arcRendering } =
use(UserSettingsContext);
const { hasCanvasSelection, globalMode } = use(EditorContext);
const { savedViewport, rememberViewport } = use(CanvasViewportContext);
const interactions = useCanvasInteractions(scene);

const [theme] = useState(readPixiTheme);
const [frames] = useState(createFrameStore);
const [registry] = useState<NodeRegistry>(() => new Map());
const batchRef = useRef<ArcBatch | null>(null);

const bounds = getBoundsOfCenteredBoxes(scene.nodes);
// The viewport at mount: where this net was last left, or centered on the
// net. Gestures own it from then on, and every move is remembered.
const [viewport] = useState(() =>
createViewportStore(
savedViewport ?? getInitialViewport(bounds, containerSize),
),
);
const current = useViewport(viewport);
const remember = useLatest(rememberViewport);
// The store reports every frame of a gesture, and a saved viewport is one
// write, so the report waits for the viewport to hold still. Whatever is
// still waiting when the renderer goes away is saved rather than dropped.
useEffect(() => {
const save = () => remember.current(viewport.get());
let settling: ReturnType<typeof setTimeout> | null = null;
const unsubscribe = viewport.subscribe(() => {
if (settling) {
clearTimeout(settling);
}
settling = setTimeout(save, viewportSettleMs);
});
return () => {
unsubscribe();
if (settling) {
clearTimeout(settling);
save();
}
};
}, [viewport, remember]);

// The zoom floor keeps the net at a readable fraction of the viewport, but
// never rises above the current zoom, and settles before it applies, as on
// the React Flow canvas.
const boundsMinZoom = getMinZoomForBounds(bounds, containerSize);
const minZoom = useDebouncedValue(
Math.min(boundsMinZoom, current.zoom),
minZoomDebounceMs,
);
const zoomLimits = { min: minZoom, max: maxZoom };

const polylines = buildArcPolylines(scene, arcRendering);
const { gestures, handlers, cursor } = useCanvasGestures(hostRef, {
scene,
polylines,
interactions,
viewport,
zoomLimits,
partialSelection,
});

const controller = usePixiController(
viewport,
hostRef,
containerSize,
zoomLimits,
);
useRecenterOnPanelOpen(controller, containerSize, scene.nodes);

// The arc mesh publishes its GPU batch here for the animator to write to.
const publishBatch = (batch: ArcBatch | null) => {
batchRef.current = batch;
};

const { hasFrames } = useFrameSnapshot(frames);
const hoveredPlaceWithVisualizer = scene.nodes.find(
(node) =>
node.kind === "place" &&
node.hovered &&
node.hasColorType &&
node.hasVisualizer,
);

return (
<CanvasControllerContext value={controller}>
<FrameBridge store={frames} />
<div ref={hostRef} className={hostStyle} style={{ cursor }} {...handlers}>
<PixiWorld
host={hostRef}
scene={scene}
polylines={polylines}
theme={theme}
viewport={viewport}
frames={frames}
gestures={gestures}
registry={registry}
batchRef={batchRef}
onBatchChange={publishBatch}
compact={compactNodes}
containerSize={containerSize}
/>
{hasCanvasSelection && <div className={fadeBgStyle} />}
{hasFrames && hoveredPlaceWithVisualizer?.kind === "place" && (
<PixiPlaceStateTooltip
node={hoveredPlaceWithVisualizer}
viewport={viewport}
/>
)}
{showMinimap && (
<PixiMiniMap
scene={scene}
viewport={viewport}
containerSize={containerSize}
/>
)}
{globalMode !== "actual" && (
<ViewportControls viewportActions={viewportActions} />
)}
</div>
</CanvasControllerContext>
);
};
Loading
Loading