Amp-Thread-ID: https://ampcode.com/threads/T-019f9d91-77c1-7206-a60f-ed6554ce92ab Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
294 lines
12 KiB
TypeScript
294 lines
12 KiB
TypeScript
import { linkId, type GraphLink, type LinkId, type NodeId, type SocketId, type Vec2 } from "../core/types.js";
|
|
import type { Command } from "../commands/types.js";
|
|
import { viewToWorld } from "../layout/geometry.js";
|
|
import { GEOMETRY as G } from "../layout/constants.js";
|
|
import {
|
|
layoutSocketAcceptsLink,
|
|
type LayoutControl,
|
|
type LayoutSnapshot,
|
|
type Rect,
|
|
type ViewTransform,
|
|
} from "../layout/types.js";
|
|
import { isColorRamp } from "../widgets/color-ramp.js";
|
|
|
|
const inRect = (p: Vec2, r: Rect, tolerance = 0): boolean =>
|
|
p.x >= r.x - tolerance &&
|
|
p.x <= r.x + r.width + tolerance &&
|
|
p.y <= r.y + tolerance &&
|
|
p.y >= r.y - r.height - tolerance;
|
|
|
|
export type RampTarget =
|
|
| "add"
|
|
| "remove"
|
|
| "flip"
|
|
| "distribute"
|
|
| "mode"
|
|
| "interpolation"
|
|
| "hue"
|
|
| "gradient"
|
|
| "selector"
|
|
| "position"
|
|
| "swatch";
|
|
export type Hit =
|
|
| { readonly kind: "color-wheel"; readonly id: string; readonly region: "plane" | "lightness" }
|
|
| {
|
|
readonly kind: "ramp";
|
|
readonly id: string;
|
|
readonly target: RampTarget | "handle";
|
|
readonly stopId?: string;
|
|
readonly position?: number;
|
|
}
|
|
| { readonly kind: "control-step"; readonly id: string; readonly component: number; readonly direction: -1 | 1 }
|
|
| { readonly kind: "resource"; readonly id: string }
|
|
| { readonly kind: "control"; readonly id: string; readonly component: number }
|
|
| { readonly kind: "socket"; readonly id: SocketId }
|
|
| { readonly kind: "collapse" | "resize" | "node" | "frame-header" | "frame-body"; readonly id: NodeId }
|
|
| { readonly kind: "link"; readonly id: LinkId }
|
|
| { readonly kind: "canvas" };
|
|
export function hitRamp(control: LayoutControl, p: Vec2, tolerance = 0): Extract<Hit, { kind: "ramp" }> | undefined {
|
|
const b = control.rampBounds,
|
|
v = control.value as { kind?: unknown; value?: unknown };
|
|
if (control.kind !== "color-ramp" || !b || v.kind !== "json" || !isColorRamp(v.value)) return;
|
|
const ramp = v.value;
|
|
if (inRect(p, b.toolbar)) {
|
|
const x = (p.x - b.toolbar.x) / b.toolbar.width;
|
|
return {
|
|
kind: "ramp",
|
|
id: control.id,
|
|
target: x < 0.12 ? "add" : x < 0.24 ? "remove" : x < 0.62 ? "flip" : "distribute",
|
|
};
|
|
}
|
|
if (inRect(p, b.mode)) return { kind: "ramp", id: control.id, target: "mode" };
|
|
if (inRect(p, b.interpolation)) return { kind: "ramp", id: control.id, target: "interpolation" };
|
|
if (inRect(p, b.hue)) return { kind: "ramp", id: control.id, target: "hue" };
|
|
if (inRect(p, b.handles, tolerance)) {
|
|
const position = Math.max(0, Math.min(1, (p.x - b.handles.x) / b.handles.width)),
|
|
near = ramp.stops
|
|
.filter((s) => Math.abs(s.position - position) * b.handles.width <= Math.max(7, tolerance))
|
|
.sort((a, c) => a.position - c.position || a.id.localeCompare(c.id));
|
|
if (near.length) return { kind: "ramp", id: control.id, target: "handle", stopId: near[0]!.id, position };
|
|
}
|
|
if (inRect(p, b.gradient))
|
|
return {
|
|
kind: "ramp",
|
|
id: control.id,
|
|
target: "gradient",
|
|
position: Math.max(0, Math.min(1, (p.x - b.gradient.x) / b.gradient.width)),
|
|
};
|
|
if (inRect(p, b.selector)) return { kind: "ramp", id: control.id, target: "selector" };
|
|
if (inRect(p, b.position)) return { kind: "ramp", id: control.id, target: "position" };
|
|
if (inRect(p, b.color)) return { kind: "ramp", id: control.id, target: "swatch" };
|
|
}
|
|
const segmentDistance = (p: Vec2, a: Vec2, b: Vec2): number => {
|
|
const dx = b.x - a.x,
|
|
dy = b.y - a.y,
|
|
l = dx * dx + dy * dy;
|
|
if (!l) return Math.hypot(p.x - a.x, p.y - a.y);
|
|
const q = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / l));
|
|
return Math.hypot(p.x - a.x - q * dx, p.y - a.y - q * dy);
|
|
};
|
|
export function hitTest(layout: LayoutSnapshot, view: Vec2, preferredDirection?: "input" | "output"): Hit {
|
|
const world = viewToWorld(view, layout.transform),
|
|
tolerance = 7 / layout.transform.zoom;
|
|
const topNode = layout.drawOrder
|
|
.slice()
|
|
.reverse()
|
|
.map((id) => layout.nodes.get(id))
|
|
.find((node) => node !== undefined && node.kind !== "frame" && inRect(world, node.bounds));
|
|
for (const control of [...layout.controls.values()].reverse())
|
|
if (!control.linked && (!topNode || control.nodeId === topNode.id)) {
|
|
if (control.kind === "resource") {
|
|
if (
|
|
control.resourceBounds &&
|
|
(inRect(world, control.resourceBounds.preview) || inRect(world, control.resourceBounds.open))
|
|
)
|
|
return { kind: "resource", id: control.id };
|
|
continue;
|
|
}
|
|
if (control.colorWheelBounds) {
|
|
if (inRect(world, control.colorWheelBounds.plane))
|
|
return { kind: "color-wheel", id: control.id, region: "plane" };
|
|
if (inRect(world, control.colorWheelBounds.lightness))
|
|
return { kind: "color-wheel", id: control.id, region: "lightness" };
|
|
continue;
|
|
}
|
|
const ramp = hitRamp(control, world, tolerance);
|
|
if (ramp) return ramp;
|
|
for (const field of control.numericFields) {
|
|
if (inRect(world, field.decrement))
|
|
return { kind: "control-step", id: control.id, component: field.component, direction: -1 };
|
|
if (inRect(world, field.increment))
|
|
return { kind: "control-step", id: control.id, component: field.component, direction: 1 };
|
|
}
|
|
if (inRect(world, control.bounds)) {
|
|
const component = control.subfields.find((field) => inRect(world, field.bounds))?.index ?? 0;
|
|
return { kind: "control", id: control.id, component };
|
|
}
|
|
}
|
|
for (const id of layout.drawOrder.slice().reverse()) {
|
|
const node = layout.nodes.get(id);
|
|
if (node?.kind !== "reroute") continue;
|
|
const center = { x: node.bounds.x + node.bounds.width / 2, y: node.bounds.y - node.bounds.height / 2 },
|
|
distance = Math.hypot(world.x - center.x, world.y - center.y),
|
|
core = Math.max(G.reroute, 7 / layout.transform.zoom),
|
|
halo = core + 8 / layout.transform.zoom;
|
|
if (distance > core && distance <= halo) return { kind: "node", id };
|
|
}
|
|
const socketHits = [...layout.sockets.values()]
|
|
.filter(
|
|
(socket) =>
|
|
(!topNode || socket.nodeId === topNode.id) &&
|
|
(socket.shape === "multi-input" && socket.bounds
|
|
? inRect(world, socket.bounds, tolerance)
|
|
: Math.hypot(world.x - socket.anchor.x, world.y - socket.anchor.y) <= tolerance),
|
|
)
|
|
.sort((a, b) => {
|
|
const an = layout.nodes.get(a.nodeId),
|
|
bn = layout.nodes.get(b.nodeId),
|
|
rank = (node: typeof an) => (node ? layout.drawOrder.indexOf(node.id) : -1);
|
|
return (
|
|
rank(bn) - rank(an) || Number(b.direction === preferredDirection) - Number(a.direction === preferredDirection)
|
|
);
|
|
});
|
|
if (socketHits[0]) return { kind: "socket", id: socketHits[0].id };
|
|
const regular = layout.drawOrder
|
|
.slice()
|
|
.reverse()
|
|
.map((id) => layout.nodes.get(id))
|
|
.filter((n) => n?.kind !== "frame");
|
|
for (const node of regular)
|
|
if (node && node.kind === "node" && inRect(world, node.collapseHitRect, tolerance))
|
|
return { kind: "collapse", id: node.id };
|
|
for (const node of regular)
|
|
if (node && node.kind === "node" && !node.collapsed && inRect(world, node.resizeHitRect, tolerance))
|
|
return { kind: "resize", id: node.id };
|
|
for (const node of regular) if (node && inRect(world, node.bounds)) return { kind: "node", id: node.id };
|
|
for (const link of [...layout.links.values()].reverse())
|
|
if (link.visible && link.points.slice(1).some((p, i) => segmentDistance(world, link.points[i]!, p) <= tolerance))
|
|
return { kind: "link", id: link.id };
|
|
for (const id of layout.drawOrder.slice().reverse()) {
|
|
const n = layout.nodes.get(id);
|
|
if (n?.kind === "frame" && inRect(world, n.header)) return { kind: "frame-header", id };
|
|
}
|
|
for (const id of layout.drawOrder.slice().reverse()) {
|
|
const n = layout.nodes.get(id);
|
|
if (n?.kind === "frame" && inRect(world, n.bounds)) return { kind: "frame-body", id };
|
|
}
|
|
return { kind: "canvas" };
|
|
}
|
|
|
|
/** Hit order is stable and all tolerances are expressed in view pixels. */
|
|
export function hitNode(layout: LayoutSnapshot, view: Vec2): NodeId | undefined {
|
|
const hit = hitTest(layout, view);
|
|
return hit.kind === "control" ||
|
|
hit.kind === "control-step" ||
|
|
hit.kind === "ramp" ||
|
|
hit.kind === "color-wheel" ||
|
|
hit.kind === "resource"
|
|
? layout.controls.get(hit.id)?.nodeId
|
|
: hit.kind === "socket"
|
|
? layout.sockets.get(hit.id)?.nodeId
|
|
: hit.kind === "node" ||
|
|
hit.kind === "collapse" ||
|
|
hit.kind === "resize" ||
|
|
hit.kind === "frame-header" ||
|
|
hit.kind === "frame-body"
|
|
? hit.id
|
|
: undefined;
|
|
}
|
|
|
|
export const boxNodes = (layout: LayoutSnapshot, a: Vec2, b: Vec2): NodeId[] => {
|
|
const p = viewToWorld(a, layout.transform),
|
|
q = viewToWorld(b, layout.transform),
|
|
left = Math.min(p.x, q.x),
|
|
right = Math.max(p.x, q.x),
|
|
top = Math.max(p.y, q.y),
|
|
bottom = Math.min(p.y, q.y);
|
|
return layout.drawOrder.filter((id) => {
|
|
const n = layout.nodes.get(id);
|
|
return (
|
|
!!n &&
|
|
n.kind !== "frame" &&
|
|
n.bounds.x >= left &&
|
|
n.bounds.x + n.bounds.width <= right &&
|
|
n.bounds.y <= top &&
|
|
n.bounds.y - n.bounds.height >= bottom
|
|
);
|
|
});
|
|
};
|
|
export const compatibleTargets = (layout: LayoutSnapshot, fromId: SocketId) => {
|
|
const from = layout.sockets.get(fromId);
|
|
if (!from || from.direction !== "output") return [];
|
|
return [...layout.sockets.values()].filter((to) => layoutSocketAcceptsLink(from, to));
|
|
};
|
|
export function planLink(
|
|
layout: LayoutSnapshot,
|
|
fromId: SocketId,
|
|
toId: SocketId,
|
|
newId: LinkId = linkId(`gesture-${fromId}-${toId}`),
|
|
): Command | undefined {
|
|
const from = layout.sockets.get(fromId),
|
|
to = layout.sockets.get(toId);
|
|
if (!from || !to || !compatibleTargets(layout, fromId).some((s) => s.id === toId)) return;
|
|
const replacing = to.capacity === 1 && to.linkIds.length > 0;
|
|
const link: GraphLink = {
|
|
id: replacing ? to.linkIds[0]! : newId,
|
|
fromNodeId: from.nodeId,
|
|
fromSocketId: from.id,
|
|
toNodeId: to.nodeId,
|
|
toSocketId: to.id,
|
|
muted: false,
|
|
extensions: {},
|
|
};
|
|
return replacing ? { type: "link.replace", removeId: to.linkIds[0]!, link } : { type: "link.add", link };
|
|
}
|
|
export const clampResize = (layout: LayoutSnapshot, id: NodeId, world: Vec2): Vec2 | undefined => {
|
|
const n = layout.nodes.get(id);
|
|
if (!n || n.kind !== "node") return;
|
|
return {
|
|
x: Math.min(G.maxWidth, Math.max(n.minimumSize.x, world.x - n.bounds.x)),
|
|
y: Math.max(n.minimumSize.y, n.bounds.y - world.y),
|
|
};
|
|
};
|
|
export function frameDropCandidate(layout: LayoutSnapshot, id: NodeId, world: Vec2): NodeId | undefined {
|
|
const node = layout.nodes.get(id);
|
|
if (!node) return;
|
|
const descendants = new Set<NodeId>();
|
|
for (const n of layout.nodes.values()) {
|
|
let p = n.parentId;
|
|
while (p) {
|
|
if (p === id) {
|
|
descendants.add(n.id);
|
|
break;
|
|
}
|
|
p = layout.nodes.get(p)?.parentId;
|
|
}
|
|
}
|
|
return [...layout.nodes.values()]
|
|
.filter((n) => n.kind === "frame" && n.id !== id && !descendants.has(n.id) && inRect(world, n.bounds))
|
|
.sort((a, b) => a.bounds.width * a.bounds.height - b.bounds.width * b.bounds.height)[0]?.id;
|
|
}
|
|
|
|
export function zoomAt(transform: ViewTransform, cursor: Vec2, deltaY: number): { center: Vec2; zoom: number } {
|
|
const anchor = viewToWorld(cursor, transform);
|
|
const zoom = Math.min(4, Math.max(0.1, transform.zoom * Math.exp(-deltaY * 0.0015)));
|
|
return {
|
|
zoom,
|
|
center: {
|
|
x: anchor.x - (cursor.x - transform.viewport.x / 2) / zoom,
|
|
y: anchor.y + (cursor.y - transform.viewport.y / 2) / zoom,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function groupRoots(selected: ReadonlySet<NodeId>, layout: LayoutSnapshot): readonly NodeId[] {
|
|
return [...selected].filter((id) => {
|
|
let parent = layout.nodes.get(id)?.parentId;
|
|
while (parent) {
|
|
if (selected.has(parent)) return false;
|
|
parent = layout.nodes.get(parent)?.parentId;
|
|
}
|
|
return true;
|
|
});
|
|
}
|