feat(node-editor, blueprint): add group box and math/logic nodes (#438)

* feat(node-editor, blueprint): add group box and math/logic nodes

node-editor:
- Add visual group box for organizing nodes
- Dynamic bounds calculation based on node pin counts
- Groups auto-resize to wrap contained nodes
- Dragging group header moves all nodes together

blueprint:
- Add comprehensive math nodes (modulo, power, sqrt, trig, etc.)
- Add logic nodes (comparison, boolean, select)

docs:
- Update nodes.md with new math and logic nodes
- Add group feature documentation to editor-guide.md

* chore: remove unused debug and test scripts

Remove FBX animation debug scripts that are no longer needed:
- analyze-fbx, debug-*, test-*, verify-*, check-*, compare-*, trace-*, simple-fbx-test

Remove unused kill-dev-server.js from editor-app
This commit is contained in:
YHH
2026-01-05 11:23:42 +08:00
committed by GitHub
parent 45de62e453
commit 0d33cf0097
39 changed files with 1770 additions and 6487 deletions

View File

@@ -4,8 +4,10 @@ import { GraphNode, NodeTemplate } from '../../domain/models/GraphNode';
import { Connection } from '../../domain/models/Connection';
import { Pin } from '../../domain/models/Pin';
import { Position } from '../../domain/value-objects/Position';
import { NodeGroup, computeGroupBounds, estimateNodeHeight } from '../../domain/models/NodeGroup';
import { GraphCanvas } from '../canvas/GraphCanvas';
import { MemoizedGraphNodeComponent, NodeExecutionState } from '../nodes/GraphNodeComponent';
import { MemoizedGroupNodeComponent } from '../nodes/GroupNodeComponent';
import { ConnectionLayer } from '../connections/ConnectionLine';
/**
@@ -56,6 +58,12 @@ export interface NodeEditorProps {
/** Connection context menu callback (连接右键菜单回调) */
onConnectionContextMenu?: (connection: Connection, e: React.MouseEvent) => void;
/** Group context menu callback (组右键菜单回调) */
onGroupContextMenu?: (group: NodeGroup, e: React.MouseEvent) => void;
/** Group double click callback - typically used to expand group (组双击回调 - 通常用于展开组) */
onGroupDoubleClick?: (group: NodeGroup) => void;
}
/**
@@ -112,7 +120,9 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
onNodeDoubleClick: _onNodeDoubleClick,
onCanvasContextMenu,
onNodeContextMenu,
onConnectionContextMenu
onConnectionContextMenu,
onGroupContextMenu,
onGroupDoubleClick
}) => {
// Silence unused variable warnings (消除未使用变量警告)
void _templates;
@@ -152,6 +162,64 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
return graph.nodes.map(n => `${n.id}:${n.isCollapsed}`).join(',');
}, [graph.nodes]);
// Groups are now simple visual boxes - no node hiding
// 组现在是简单的可视化框 - 不隐藏节点
// Track selected group IDs (local state, managed similarly to nodes)
// 跟踪选中的组ID本地状态类似节点管理方式
const [selectedGroupIds, setSelectedGroupIds] = useState<Set<string>>(new Set());
// Group drag state - includes initial positions of nodes in the group
// 组拖拽状态 - 包含组内节点的初始位置
const [groupDragState, setGroupDragState] = useState<{
groupId: string;
startGroupPosition: Position;
startMouse: { x: number; y: number };
nodeStartPositions: Map<string, Position>;
} | null>(null);
// Key for tracking group changes
const groupsKey = useMemo(() => {
return graph.groups.map(g => `${g.id}:${g.position.x}:${g.position.y}`).join(',');
}, [graph.groups]);
// Compute dynamic group bounds based on current node positions and sizes
// 根据当前节点位置和尺寸动态计算组边界
const groupsWithDynamicBounds = useMemo(() => {
const defaultNodeWidth = 200;
return graph.groups.map(group => {
// Get current bounds of all nodes in this group
const nodeBounds = group.nodeIds
.map(nodeId => graph.getNode(nodeId))
.filter((node): node is GraphNode => node !== undefined)
.map(node => ({
x: node.position.x,
y: node.position.y,
width: defaultNodeWidth,
height: estimateNodeHeight(
node.inputPins.length,
node.outputPins.length,
node.isCollapsed
)
}));
if (nodeBounds.length === 0) {
// No nodes found, use stored position/size as fallback
return group;
}
// Calculate dynamic bounds based on actual node sizes
const { position, size } = computeGroupBounds(nodeBounds);
return {
...group,
position,
size
};
});
}, [graph.groups, graph.nodes]);
useEffect(() => {
// Use requestAnimationFrame to wait for DOM to be fully rendered
// 使用 requestAnimationFrame 等待 DOM 完全渲染
@@ -159,7 +227,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
forceUpdate(n => n + 1);
});
return () => cancelAnimationFrame(rafId);
}, [graph.id, collapsedNodesKey]);
}, [graph.id, collapsedNodesKey, groupsKey]);
/**
* Converts screen coordinates to canvas coordinates
@@ -181,6 +249,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
*
* 直接从节点位置和引脚在节点内的相对位置计算,不依赖 DOM 测量
* 当节点收缩时,返回节点头部的位置
* 当节点在折叠组中时,返回组节点的位置
*/
const getPinPosition = useCallback((pinId: string): Position | undefined => {
// First, find which node this pin belongs to
@@ -319,6 +388,22 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
onGraphChange?.(newGraph);
}
// Group dragging - moves all nodes inside (group bounds are dynamic)
// 组拖拽 - 移动组内所有节点(组边界是动态计算的)
if (groupDragState) {
const dx = mousePos.x - groupDragState.startMouse.x;
const dy = mousePos.y - groupDragState.startMouse.y;
// Only move nodes - group bounds will auto-recalculate
let newGraph = graph;
for (const [nodeId, startPos] of groupDragState.nodeStartPositions) {
const newNodePos = new Position(startPos.x + dx, startPos.y + dy);
newGraph = newGraph.moveNode(nodeId, newNodePos);
}
onGraphChange?.(newGraph);
}
// Connection dragging (连接拖拽)
if (connectionDrag) {
const isValid = hoveredPin ? connectionDrag.fromPin.canConnectTo(hoveredPin) : undefined;
@@ -330,7 +415,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
isValid
} : null);
}
}, [graph, dragState, connectionDrag, hoveredPin, screenToCanvas, onGraphChange]);
}, [graph, dragState, groupDragState, connectionDrag, hoveredPin, screenToCanvas, onGraphChange]);
/**
* Handles mouse up to end dragging
@@ -342,6 +427,11 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
setDragState(null);
}
// End group dragging (结束组拖拽)
if (groupDragState) {
setGroupDragState(null);
}
// End connection dragging (结束连接拖拽)
if (connectionDrag) {
// Use hoveredPin directly instead of relying on async state update
@@ -376,7 +466,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
setConnectionDrag(null);
}
}, [graph, dragState, connectionDrag, hoveredPin, onGraphChange]);
}, [graph, dragState, groupDragState, connectionDrag, hoveredPin, onGraphChange]);
/**
* Handles pin mouse down
@@ -487,6 +577,81 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
}
}, [graph, onConnectionContextMenu]);
/**
* Handles group selection
* 处理组选择
*/
const handleGroupSelect = useCallback((groupId: string, additive: boolean) => {
if (readOnly) return;
const newSelection = new Set(selectedGroupIds);
if (additive) {
if (newSelection.has(groupId)) {
newSelection.delete(groupId);
} else {
newSelection.add(groupId);
}
} else {
newSelection.clear();
newSelection.add(groupId);
}
setSelectedGroupIds(newSelection);
// Clear node and connection selection when selecting groups
onSelectionChange?.(new Set(), new Set());
}, [selectedGroupIds, readOnly, onSelectionChange]);
/**
* Handles group drag start
* 处理组拖拽开始
*
* Captures initial positions of both the group and all nodes inside it
* 捕获组和组内所有节点的初始位置
*/
const handleGroupDragStart = useCallback((groupId: string, startMouse: { x: number; y: number }) => {
if (readOnly) return;
const group = graph.getGroup(groupId);
if (!group) return;
// Convert screen coordinates to canvas coordinates (same as node dragging)
// 将屏幕坐标转换为画布坐标(与节点拖拽相同)
const canvasPos = screenToCanvas(startMouse.x, startMouse.y);
// Capture initial positions of all nodes in the group
const nodeStartPositions = new Map<string, Position>();
for (const nodeId of group.nodeIds) {
const node = graph.getNode(nodeId);
if (node) {
nodeStartPositions.set(nodeId, node.position);
}
}
setGroupDragState({
groupId,
startGroupPosition: group.position,
startMouse: { x: canvasPos.x, y: canvasPos.y },
nodeStartPositions
});
}, [graph, readOnly, screenToCanvas]);
/**
* Handles group context menu
* 处理组右键菜单
*/
const handleGroupContextMenu = useCallback((group: NodeGroup, e: React.MouseEvent) => {
onGroupContextMenu?.(group, e);
}, [onGroupContextMenu]);
/**
* Handles group double click
* 处理组双击
*/
const handleGroupDoubleClick = useCallback((group: NodeGroup) => {
onGroupDoubleClick?.(group);
}, [onGroupDoubleClick]);
/**
* Handles canvas click to deselect
* 处理画布点击取消选择
@@ -500,6 +665,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
}
if (!readOnly) {
onSelectionChange?.(new Set(), new Set());
setSelectedGroupIds(new Set());
}
}, [readOnly, onSelectionChange]);
@@ -644,6 +810,21 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
onCanvasMouseMove={handleBoxSelectMove}
onCanvasMouseUp={handleBoxSelectEnd}
>
{/* Group boxes - rendered first so they appear behind nodes (组框 - 先渲染,这样显示在节点后面) */}
{/* Use dynamically calculated bounds so groups auto-resize to fit nodes */}
{groupsWithDynamicBounds.map(group => (
<MemoizedGroupNodeComponent
key={group.id}
group={group}
isSelected={selectedGroupIds.has(group.id)}
isDragging={groupDragState?.groupId === group.id}
onSelect={handleGroupSelect}
onDragStart={handleGroupDragStart}
onContextMenu={handleGroupContextMenu}
onDoubleClick={handleGroupDoubleClick}
/>
))}
{/* Connection layer (连接层) */}
<ConnectionLayer
connections={graph.connections}
@@ -655,7 +836,7 @@ export const NodeEditor: React.FC<NodeEditorProps> = ({
onConnectionContextMenu={handleConnectionContextMenu}
/>
{/* Nodes (节点) */}
{/* All Nodes (所有节点) */}
{graph.nodes.map(node => (
<MemoizedGraphNodeComponent
key={node.id}

View File

@@ -0,0 +1,124 @@
import React, { useCallback, useMemo, useRef } from 'react';
import { NodeGroup } from '../../domain/models/NodeGroup';
import '../../styles/GroupNode.css';
export interface GroupNodeComponentProps {
/** The group to render */
group: NodeGroup;
/** Whether the group is selected */
isSelected?: boolean;
/** Whether the group is being dragged */
isDragging?: boolean;
/** Selection handler */
onSelect?: (groupId: string, additive: boolean) => void;
/** Drag start handler */
onDragStart?: (groupId: string, startPosition: { x: number; y: number }) => void;
/** Context menu handler */
onContextMenu?: (group: NodeGroup, e: React.MouseEvent) => void;
/** Double click handler for editing name */
onDoubleClick?: (group: NodeGroup) => void;
}
/**
* GroupNodeComponent - Renders a visual group box around nodes
* GroupNodeComponent - 渲染节点周围的可视化组框
*
* This is a simple background box that provides visual organization.
* 这是一个简单的背景框,提供视觉组织功能。
*/
export const GroupNodeComponent: React.FC<GroupNodeComponentProps> = ({
group,
isSelected = false,
isDragging = false,
onSelect,
onDragStart,
onContextMenu,
onDoubleClick
}) => {
const groupRef = useRef<HTMLDivElement>(null);
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return;
// Only handle clicks on the header or border, not on the content area
const target = e.target as HTMLElement;
if (!target.closest('.ne-group-box-header') && !target.classList.contains('ne-group-box')) {
return;
}
e.stopPropagation();
const additive = e.ctrlKey || e.metaKey;
onSelect?.(group.id, additive);
onDragStart?.(group.id, { x: e.clientX, y: e.clientY });
}, [group.id, onSelect, onDragStart]);
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onContextMenu?.(group, e);
}, [group, onContextMenu]);
const handleDoubleClick = useCallback((e: React.MouseEvent) => {
// Only handle double-click on the header
const target = e.target as HTMLElement;
if (!target.closest('.ne-group-box-header')) {
return;
}
e.stopPropagation();
onDoubleClick?.(group);
}, [group, onDoubleClick]);
const classNames = useMemo(() => {
const classes = ['ne-group-box'];
if (isSelected) classes.push('selected');
if (isDragging) classes.push('dragging');
return classes.join(' ');
}, [isSelected, isDragging]);
const style: React.CSSProperties = useMemo(() => ({
left: group.position.x,
top: group.position.y,
width: group.size.width,
height: group.size.height,
'--group-color': group.color || 'rgba(100, 149, 237, 0.15)'
} as React.CSSProperties), [group.position.x, group.position.y, group.size.width, group.size.height, group.color]);
return (
<div
ref={groupRef}
className={classNames}
style={style}
data-group-id={group.id}
onMouseDown={handleMouseDown}
onContextMenu={handleContextMenu}
onDoubleClick={handleDoubleClick}
>
<div className="ne-group-box-header">
<span className="ne-group-box-title">{group.name}</span>
</div>
</div>
);
};
/**
* Memoized version for performance
*/
export const MemoizedGroupNodeComponent = React.memo(GroupNodeComponent, (prev, next) => {
if (prev.group.id !== next.group.id) return false;
if (prev.isSelected !== next.isSelected) return false;
if (prev.isDragging !== next.isDragging) return false;
if (prev.group.position.x !== next.group.position.x ||
prev.group.position.y !== next.group.position.y) return false;
if (prev.group.size.width !== next.group.size.width ||
prev.group.size.height !== next.group.size.height) return false;
if (prev.group.name !== next.group.name) return false;
if (prev.group.color !== next.group.color) return false;
return true;
});
export default GroupNodeComponent;

View File

@@ -4,3 +4,9 @@ export {
type GraphNodeComponentProps,
type NodeExecutionState
} from './GraphNodeComponent';
export {
GroupNodeComponent,
MemoizedGroupNodeComponent,
type GroupNodeComponentProps
} from './GroupNodeComponent';

View File

@@ -2,6 +2,7 @@ import { GraphNode } from './GraphNode';
import { Connection } from './Connection';
import { Pin } from './Pin';
import { Position } from '../value-objects/Position';
import { NodeGroup, serializeNodeGroup } from './NodeGroup';
/**
* Graph - Aggregate root for the node graph
@@ -15,6 +16,7 @@ export class Graph {
private readonly _name: string;
private readonly _nodes: Map<string, GraphNode>;
private readonly _connections: Connection[];
private readonly _groups: NodeGroup[];
private readonly _metadata: Record<string, unknown>;
constructor(
@@ -22,12 +24,14 @@ export class Graph {
name: string,
nodes: GraphNode[] = [],
connections: Connection[] = [],
metadata: Record<string, unknown> = {}
metadata: Record<string, unknown> = {},
groups: NodeGroup[] = []
) {
this._id = id;
this._name = name;
this._nodes = new Map(nodes.map(n => [n.id, n]));
this._connections = [...connections];
this._groups = [...groups];
this._metadata = { ...metadata };
}
@@ -59,6 +63,29 @@ export class Graph {
return this._connections.length;
}
/**
* Gets all groups (节点组)
*/
get groups(): NodeGroup[] {
return [...this._groups];
}
/**
* Gets a group by ID
* 通过ID获取组
*/
getGroup(groupId: string): NodeGroup | undefined {
return this._groups.find(g => g.id === groupId);
}
/**
* Gets the group containing a specific node
* 获取包含特定节点的组
*/
getNodeGroup(nodeId: string): NodeGroup | undefined {
return this._groups.find(g => g.nodeIds.includes(nodeId));
}
/**
* Gets a node by ID
* 通过ID获取节点
@@ -112,7 +139,7 @@ export class Graph {
throw new Error(`Node with ID "${node.id}" already exists`);
}
const newNodes = [...this.nodes, node];
return new Graph(this._id, this._name, newNodes, this._connections, this._metadata);
return new Graph(this._id, this._name, newNodes, this._connections, this._metadata, this._groups);
}
/**
@@ -125,7 +152,14 @@ export class Graph {
}
const newNodes = this.nodes.filter(n => n.id !== nodeId);
const newConnections = this._connections.filter(c => !c.involvesNode(nodeId));
return new Graph(this._id, this._name, newNodes, newConnections, this._metadata);
// Also remove the node from any groups it belongs to
const newGroups = this._groups.map(g => {
if (g.nodeIds.includes(nodeId)) {
return { ...g, nodeIds: g.nodeIds.filter(id => id !== nodeId) };
}
return g;
}).filter(g => g.nodeIds.length > 0); // Remove empty groups
return new Graph(this._id, this._name, newNodes, newConnections, this._metadata, newGroups);
}
/**
@@ -138,7 +172,7 @@ export class Graph {
const updatedNode = updater(node);
const newNodes = this.nodes.map(n => n.id === nodeId ? updatedNode : n);
return new Graph(this._id, this._name, newNodes, this._connections, this._metadata);
return new Graph(this._id, this._name, newNodes, this._connections, this._metadata, this._groups);
}
/**
@@ -184,7 +218,7 @@ export class Graph {
}
newConnections.push(connection);
return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata);
return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata, this._groups);
}
/**
@@ -196,7 +230,7 @@ export class Graph {
if (newConnections.length === this._connections.length) {
return this;
}
return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata);
return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata, this._groups);
}
/**
@@ -208,7 +242,47 @@ export class Graph {
if (newConnections.length === this._connections.length) {
return this;
}
return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata);
return new Graph(this._id, this._name, this.nodes, newConnections, this._metadata, this._groups);
}
// ========== Group Operations (组操作) ==========
/**
* Adds a new group (immutable)
* 添加新组(不可变)
*/
addGroup(group: NodeGroup): Graph {
if (this._groups.some(g => g.id === group.id)) {
throw new Error(`Group with ID "${group.id}" already exists`);
}
const newGroups = [...this._groups, group];
return new Graph(this._id, this._name, this.nodes, this._connections, this._metadata, newGroups);
}
/**
* Removes a group (immutable)
* 移除组(不可变)
*/
removeGroup(groupId: string): Graph {
const newGroups = this._groups.filter(g => g.id !== groupId);
if (newGroups.length === this._groups.length) {
return this;
}
return new Graph(this._id, this._name, this.nodes, this._connections, this._metadata, newGroups);
}
/**
* Updates a group (immutable)
* 更新组(不可变)
*/
updateGroup(groupId: string, updater: (group: NodeGroup) => NodeGroup): Graph {
const groupIndex = this._groups.findIndex(g => g.id === groupId);
if (groupIndex === -1) return this;
const updatedGroup = updater(this._groups[groupIndex]);
const newGroups = [...this._groups];
newGroups[groupIndex] = updatedGroup;
return new Graph(this._id, this._name, this.nodes, this._connections, this._metadata, newGroups);
}
/**
@@ -219,7 +293,7 @@ export class Graph {
return new Graph(this._id, this._name, this.nodes, this._connections, {
...this._metadata,
...metadata
});
}, this._groups);
}
/**
@@ -227,7 +301,7 @@ export class Graph {
* 创建具有更新名称的新图(不可变)
*/
rename(newName: string): Graph {
return new Graph(this._id, newName, this.nodes, this._connections, this._metadata);
return new Graph(this._id, newName, this.nodes, this._connections, this._metadata, this._groups);
}
/**
@@ -257,6 +331,7 @@ export class Graph {
name: this._name,
nodes: this.nodes.map(n => n.toJSON()),
connections: this._connections.map(c => c.toJSON()),
groups: this._groups.map(g => serializeNodeGroup(g)),
metadata: this._metadata
};
}

View File

@@ -0,0 +1,146 @@
import { Position } from '../value-objects/Position';
/**
* NodeGroup - Represents a visual group box around nodes
* NodeGroup - 表示节点周围的可视化组框
*
* Groups are purely visual organization - they don't affect runtime execution.
* 组是纯视觉组织 - 不影响运行时执行。
*/
export interface NodeGroup {
/** Unique identifier for the group */
id: string;
/** Display name of the group */
name: string;
/** IDs of nodes contained in this group */
nodeIds: string[];
/** Position of the group box (top-left corner) */
position: Position;
/** Size of the group box */
size: { width: number; height: number };
/** Optional color for the group box */
color?: string;
}
/**
* Creates a new NodeGroup with the given properties
*/
export function createNodeGroup(
id: string,
name: string,
nodeIds: string[],
position: Position,
size: { width: number; height: number },
color?: string
): NodeGroup {
return {
id,
name,
nodeIds: [...nodeIds],
position,
size,
color
};
}
/**
* Node bounds info for group calculation
* 用于组计算的节点边界信息
*/
export interface NodeBounds {
x: number;
y: number;
width: number;
height: number;
}
/**
* Estimates node height based on pin count
* 根据引脚数量估算节点高度
*/
export function estimateNodeHeight(inputPinCount: number, outputPinCount: number, isCollapsed: boolean = false): number {
if (isCollapsed) {
return 32; // Just header
}
const headerHeight = 32;
const pinHeight = 26;
const bottomPadding = 12;
const maxPins = Math.max(inputPinCount, outputPinCount);
return headerHeight + maxPins * pinHeight + bottomPadding;
}
/**
* Computes the bounding box for a group based on its nodes
* Returns position (top-left) and size with padding
*
* @param nodeBounds - Array of node bounds (position + size)
* @param padding - Padding around the group box
*/
export function computeGroupBounds(
nodeBounds: NodeBounds[],
padding: number = 30
): { position: Position; size: { width: number; height: number } } {
if (nodeBounds.length === 0) {
return {
position: new Position(0, 0),
size: { width: 250, height: 150 }
};
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const node of nodeBounds) {
minX = Math.min(minX, node.x);
minY = Math.min(minY, node.y);
maxX = Math.max(maxX, node.x + node.width);
maxY = Math.max(maxY, node.y + node.height);
}
// Add padding and header space for group title
const groupHeaderHeight = 28;
return {
position: new Position(minX - padding, minY - padding - groupHeaderHeight),
size: {
width: maxX - minX + padding * 2,
height: maxY - minY + padding * 2 + groupHeaderHeight
}
};
}
/**
* Serializes a NodeGroup for JSON storage
*/
export function serializeNodeGroup(group: NodeGroup): Record<string, unknown> {
return {
id: group.id,
name: group.name,
nodeIds: [...group.nodeIds],
position: { x: group.position.x, y: group.position.y },
size: { width: group.size.width, height: group.size.height },
color: group.color
};
}
/**
* Deserializes a NodeGroup from JSON
*/
export function deserializeNodeGroup(data: Record<string, unknown>): NodeGroup {
const pos = data.position as { x: number; y: number } | undefined;
const size = data.size as { width: number; height: number } | undefined;
return {
id: data.id as string,
name: data.name as string,
nodeIds: (data.nodeIds as string[]) || [],
position: new Position(pos?.x || 0, pos?.y || 0),
size: size || { width: 250, height: 150 },
color: data.color as string | undefined
};
}

View File

@@ -2,3 +2,12 @@ export { Pin, type PinDefinition } from './Pin';
export { GraphNode, type NodeTemplate, type NodeCategory } from './GraphNode';
export { Connection } from './Connection';
export { Graph } from './Graph';
export {
type NodeGroup,
type NodeBounds,
createNodeGroup,
computeGroupBounds,
estimateNodeHeight,
serializeNodeGroup,
deserializeNodeGroup
} from './NodeGroup';

View File

@@ -23,7 +23,15 @@ export {
// Types
type NodeTemplate,
type NodeCategory,
type PinDefinition
type PinDefinition,
// NodeGroup
type NodeGroup,
type NodeBounds,
createNodeGroup,
computeGroupBounds,
estimateNodeHeight,
serializeNodeGroup,
deserializeNodeGroup
} from './domain/models';
// Value objects (值对象)

View File

@@ -281,3 +281,32 @@
0 0 20px rgba(255, 167, 38, 0.7);
}
}
/* ==================== Group Node 组节点 ==================== */
.ne-node.ne-group-node {
border: 2px dashed rgba(100, 149, 237, 0.6);
background: rgba(40, 60, 90, 0.85);
}
.ne-node.ne-group-node:hover {
border-color: rgba(100, 149, 237, 0.8);
}
.ne-node.ne-group-node.selected {
border-color: #6495ed;
box-shadow: 0 0 0 1px #6495ed,
0 0 16px rgba(100, 149, 237, 0.5),
0 4px 12px rgba(0, 0, 0, 0.5);
}
.ne-group-header {
background: linear-gradient(90deg, #3a5f8a 0%, #2a4a6a 100%) !important;
}
.ne-group-hint {
padding: 8px 12px;
font-size: 11px;
color: #8899aa;
font-style: italic;
text-align: center;
}

View File

@@ -0,0 +1,60 @@
/**
* Group Box Styles
* 组框样式
*/
/* ==================== Group Box Container 组框容器 ==================== */
.ne-group-box {
position: absolute;
background: var(--group-color, rgba(100, 149, 237, 0.15));
border: 2px dashed rgba(100, 149, 237, 0.5);
border-radius: 8px;
pointer-events: auto;
z-index: 0;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.ne-group-box:hover {
border-color: rgba(100, 149, 237, 0.7);
}
.ne-group-box.selected {
border-color: var(--ne-node-border-selected, #e5a020);
box-shadow: 0 0 0 1px var(--ne-node-border-selected, #e5a020),
0 0 12px rgba(229, 160, 32, 0.3);
}
.ne-group-box.dragging {
opacity: 0.9;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
}
/* ==================== Group Box Header 组框头部 ==================== */
.ne-group-box-header {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 28px;
display: flex;
align-items: center;
padding: 0 10px;
background: rgba(100, 149, 237, 0.3);
border-radius: 6px 6px 0 0;
cursor: grab;
user-select: none;
}
.ne-group-box-header:active {
cursor: grabbing;
}
.ne-group-box-title {
color: #fff;
font-size: 12px;
font-weight: 500;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -6,6 +6,7 @@
@import './variables.css';
@import './Canvas.css';
@import './GraphNode.css';
@import './GroupNode.css';
@import './NodePin.css';
@import './Connection.css';
@import './ContextMenu.css';

View File

@@ -1,47 +0,0 @@
/**
* 清理开发服务器进程
* 用于 Windows 平台自动清理残留的 Vite 进程
*/
import { execSync } from 'child_process';
const PORT = 5173;
try {
console.log(`正在查找占用端口 ${PORT} 的进程...`);
// Windows 命令
const result = execSync(`netstat -ano | findstr :${PORT}`, { encoding: 'utf8' });
// 解析 PID
const lines = result.split('\n');
const pids = new Set();
for (const line of lines) {
if (line.includes('LISTENING')) {
const parts = line.trim().split(/\s+/);
const pid = parts[parts.length - 1];
if (pid && pid !== '0') {
pids.add(pid);
}
}
}
if (pids.size === 0) {
console.log(`✓ 端口 ${PORT} 未被占用`);
} else {
console.log(`发现 ${pids.size} 个进程占用端口 ${PORT}`);
for (const pid of pids) {
try {
// Windows 需要使用 /F /PID 而不是 //F //PID
execSync(`taskkill /F /PID ${pid}`, { encoding: 'utf8', stdio: 'ignore' });
console.log(`✓ 已终止进程 PID: ${pid}`);
} catch (e) {
console.log(`✗ 无法终止进程 PID: ${pid}`);
}
}
}
} catch (error) {
// 如果 netstat 没有找到结果,会抛出错误,这是正常的
console.log(`✓ 端口 ${PORT} 未被占用`);
}

View File

@@ -7,6 +7,7 @@
* - ecs: ECS 操作Entity, Component, Flow
* - variables: 变量读写
* - math: 数学运算
* - logic: 比较和逻辑运算
* - time: 时间工具
* - debug: 调试工具
*
@@ -15,6 +16,7 @@
* - ecs: ECS operations (Entity, Component, Flow)
* - variables: Variable get/set
* - math: Math operations
* - logic: Comparison and logical operations
* - time: Time utilities
* - debug: Debug utilities
*/
@@ -31,6 +33,9 @@ export * from './variables';
// Math operations | 数学运算
export * from './math';
// Logic operations | 逻辑运算
export * from './logic';
// Time utilities | 时间工具
export * from './time';

View File

@@ -0,0 +1,435 @@
/**
* @zh 比较运算节点
* @en Comparison Operation Nodes
*/
import { BlueprintNodeTemplate, BlueprintNode } from '../../types/nodes';
import { ExecutionContext, ExecutionResult } from '../../runtime/ExecutionContext';
import { INodeExecutor, RegisterNode } from '../../runtime/NodeRegistry';
// ============================================================================
// Equal Node (等于节点)
// ============================================================================
export const EqualTemplate: BlueprintNodeTemplate = {
type: 'Equal',
title: 'Equal',
category: 'logic',
color: '#9C27B0',
description: 'Returns true if A equals B (如果 A 等于 B 则返回 true)',
keywords: ['equal', '==', 'same', 'compare', 'logic'],
isPure: true,
inputs: [
{ name: 'a', type: 'any', displayName: 'A' },
{ name: 'b', type: 'any', displayName: 'B' }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Result' }
]
};
@RegisterNode(EqualTemplate)
export class EqualExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = context.evaluateInput(node.id, 'a', null);
const b = context.evaluateInput(node.id, 'b', null);
return { outputs: { result: a === b } };
}
}
// ============================================================================
// Not Equal Node (不等于节点)
// ============================================================================
export const NotEqualTemplate: BlueprintNodeTemplate = {
type: 'NotEqual',
title: 'Not Equal',
category: 'logic',
color: '#9C27B0',
description: 'Returns true if A does not equal B (如果 A 不等于 B 则返回 true)',
keywords: ['not', 'equal', '!=', 'different', 'compare', 'logic'],
isPure: true,
inputs: [
{ name: 'a', type: 'any', displayName: 'A' },
{ name: 'b', type: 'any', displayName: 'B' }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Result' }
]
};
@RegisterNode(NotEqualTemplate)
export class NotEqualExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = context.evaluateInput(node.id, 'a', null);
const b = context.evaluateInput(node.id, 'b', null);
return { outputs: { result: a !== b } };
}
}
// ============================================================================
// Greater Than Node (大于节点)
// ============================================================================
export const GreaterThanTemplate: BlueprintNodeTemplate = {
type: 'GreaterThan',
title: 'Greater Than',
category: 'logic',
color: '#9C27B0',
description: 'Returns true if A is greater than B (如果 A 大于 B 则返回 true)',
keywords: ['greater', 'than', '>', 'compare', 'logic'],
isPure: true,
inputs: [
{ name: 'a', type: 'float', displayName: 'A', defaultValue: 0 },
{ name: 'b', type: 'float', displayName: 'B', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Result' }
]
};
@RegisterNode(GreaterThanTemplate)
export class GreaterThanExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Number(context.evaluateInput(node.id, 'a', 0));
const b = Number(context.evaluateInput(node.id, 'b', 0));
return { outputs: { result: a > b } };
}
}
// ============================================================================
// Greater Than Or Equal Node (大于等于节点)
// ============================================================================
export const GreaterThanOrEqualTemplate: BlueprintNodeTemplate = {
type: 'GreaterThanOrEqual',
title: 'Greater Or Equal',
category: 'logic',
color: '#9C27B0',
description: 'Returns true if A is greater than or equal to B (如果 A 大于等于 B 则返回 true)',
keywords: ['greater', 'equal', '>=', 'compare', 'logic'],
isPure: true,
inputs: [
{ name: 'a', type: 'float', displayName: 'A', defaultValue: 0 },
{ name: 'b', type: 'float', displayName: 'B', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Result' }
]
};
@RegisterNode(GreaterThanOrEqualTemplate)
export class GreaterThanOrEqualExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Number(context.evaluateInput(node.id, 'a', 0));
const b = Number(context.evaluateInput(node.id, 'b', 0));
return { outputs: { result: a >= b } };
}
}
// ============================================================================
// Less Than Node (小于节点)
// ============================================================================
export const LessThanTemplate: BlueprintNodeTemplate = {
type: 'LessThan',
title: 'Less Than',
category: 'logic',
color: '#9C27B0',
description: 'Returns true if A is less than B (如果 A 小于 B 则返回 true)',
keywords: ['less', 'than', '<', 'compare', 'logic'],
isPure: true,
inputs: [
{ name: 'a', type: 'float', displayName: 'A', defaultValue: 0 },
{ name: 'b', type: 'float', displayName: 'B', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Result' }
]
};
@RegisterNode(LessThanTemplate)
export class LessThanExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Number(context.evaluateInput(node.id, 'a', 0));
const b = Number(context.evaluateInput(node.id, 'b', 0));
return { outputs: { result: a < b } };
}
}
// ============================================================================
// Less Than Or Equal Node (小于等于节点)
// ============================================================================
export const LessThanOrEqualTemplate: BlueprintNodeTemplate = {
type: 'LessThanOrEqual',
title: 'Less Or Equal',
category: 'logic',
color: '#9C27B0',
description: 'Returns true if A is less than or equal to B (如果 A 小于等于 B 则返回 true)',
keywords: ['less', 'equal', '<=', 'compare', 'logic'],
isPure: true,
inputs: [
{ name: 'a', type: 'float', displayName: 'A', defaultValue: 0 },
{ name: 'b', type: 'float', displayName: 'B', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Result' }
]
};
@RegisterNode(LessThanOrEqualTemplate)
export class LessThanOrEqualExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Number(context.evaluateInput(node.id, 'a', 0));
const b = Number(context.evaluateInput(node.id, 'b', 0));
return { outputs: { result: a <= b } };
}
}
// ============================================================================
// And Node (逻辑与节点)
// ============================================================================
export const AndTemplate: BlueprintNodeTemplate = {
type: 'And',
title: 'AND',
category: 'logic',
color: '#9C27B0',
description: 'Returns true if both A and B are true (如果 A 和 B 都为 true 则返回 true)',
keywords: ['and', '&&', 'both', 'logic'],
isPure: true,
inputs: [
{ name: 'a', type: 'bool', displayName: 'A', defaultValue: false },
{ name: 'b', type: 'bool', displayName: 'B', defaultValue: false }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Result' }
]
};
@RegisterNode(AndTemplate)
export class AndExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Boolean(context.evaluateInput(node.id, 'a', false));
const b = Boolean(context.evaluateInput(node.id, 'b', false));
return { outputs: { result: a && b } };
}
}
// ============================================================================
// Or Node (逻辑或节点)
// ============================================================================
export const OrTemplate: BlueprintNodeTemplate = {
type: 'Or',
title: 'OR',
category: 'logic',
color: '#9C27B0',
description: 'Returns true if either A or B is true (如果 A 或 B 为 true 则返回 true)',
keywords: ['or', '||', 'either', 'logic'],
isPure: true,
inputs: [
{ name: 'a', type: 'bool', displayName: 'A', defaultValue: false },
{ name: 'b', type: 'bool', displayName: 'B', defaultValue: false }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Result' }
]
};
@RegisterNode(OrTemplate)
export class OrExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Boolean(context.evaluateInput(node.id, 'a', false));
const b = Boolean(context.evaluateInput(node.id, 'b', false));
return { outputs: { result: a || b } };
}
}
// ============================================================================
// Not Node (逻辑非节点)
// ============================================================================
export const NotTemplate: BlueprintNodeTemplate = {
type: 'Not',
title: 'NOT',
category: 'logic',
color: '#9C27B0',
description: 'Returns the opposite boolean value (返回相反的布尔值)',
keywords: ['not', '!', 'negate', 'invert', 'logic'],
isPure: true,
inputs: [
{ name: 'value', type: 'bool', displayName: 'Value', defaultValue: false }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Result' }
]
};
@RegisterNode(NotTemplate)
export class NotExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Boolean(context.evaluateInput(node.id, 'value', false));
return { outputs: { result: !value } };
}
}
// ============================================================================
// XOR Node (异或节点)
// ============================================================================
export const XorTemplate: BlueprintNodeTemplate = {
type: 'Xor',
title: 'XOR',
category: 'logic',
color: '#9C27B0',
description: 'Returns true if exactly one of A or B is true (如果 A 和 B 中恰好有一个为 true 则返回 true)',
keywords: ['xor', 'exclusive', 'or', 'logic'],
isPure: true,
inputs: [
{ name: 'a', type: 'bool', displayName: 'A', defaultValue: false },
{ name: 'b', type: 'bool', displayName: 'B', defaultValue: false }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Result' }
]
};
@RegisterNode(XorTemplate)
export class XorExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Boolean(context.evaluateInput(node.id, 'a', false));
const b = Boolean(context.evaluateInput(node.id, 'b', false));
return { outputs: { result: (a || b) && !(a && b) } };
}
}
// ============================================================================
// NAND Node (与非节点)
// ============================================================================
export const NandTemplate: BlueprintNodeTemplate = {
type: 'Nand',
title: 'NAND',
category: 'logic',
color: '#9C27B0',
description: 'Returns true if not both A and B are true (如果 A 和 B 不都为 true 则返回 true)',
keywords: ['nand', 'not', 'and', 'logic'],
isPure: true,
inputs: [
{ name: 'a', type: 'bool', displayName: 'A', defaultValue: false },
{ name: 'b', type: 'bool', displayName: 'B', defaultValue: false }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Result' }
]
};
@RegisterNode(NandTemplate)
export class NandExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Boolean(context.evaluateInput(node.id, 'a', false));
const b = Boolean(context.evaluateInput(node.id, 'b', false));
return { outputs: { result: !(a && b) } };
}
}
// ============================================================================
// In Range Node (范围检查节点)
// ============================================================================
export const InRangeTemplate: BlueprintNodeTemplate = {
type: 'InRange',
title: 'In Range',
category: 'logic',
color: '#9C27B0',
description: 'Returns true if value is between min and max (如果值在 min 和 max 之间则返回 true)',
keywords: ['range', 'between', 'check', 'logic'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 },
{ name: 'min', type: 'float', displayName: 'Min', defaultValue: 0 },
{ name: 'max', type: 'float', displayName: 'Max', defaultValue: 1 },
{ name: 'inclusive', type: 'bool', displayName: 'Inclusive', defaultValue: true }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Result' }
]
};
@RegisterNode(InRangeTemplate)
export class InRangeExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
const min = Number(context.evaluateInput(node.id, 'min', 0));
const max = Number(context.evaluateInput(node.id, 'max', 1));
const inclusive = Boolean(context.evaluateInput(node.id, 'inclusive', true));
const result = inclusive
? value >= min && value <= max
: value > min && value < max;
return { outputs: { result } };
}
}
// ============================================================================
// Is Null Node (空值检查节点)
// ============================================================================
export const IsNullTemplate: BlueprintNodeTemplate = {
type: 'IsNull',
title: 'Is Null',
category: 'logic',
color: '#9C27B0',
description: 'Returns true if the value is null or undefined (如果值为 null 或 undefined 则返回 true)',
keywords: ['null', 'undefined', 'empty', 'check', 'logic'],
isPure: true,
inputs: [
{ name: 'value', type: 'any', displayName: 'Value' }
],
outputs: [
{ name: 'result', type: 'bool', displayName: 'Is Null' }
]
};
@RegisterNode(IsNullTemplate)
export class IsNullExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = context.evaluateInput(node.id, 'value', null);
return { outputs: { result: value == null } };
}
}
// ============================================================================
// Select Node (选择节点)
// ============================================================================
export const SelectTemplate: BlueprintNodeTemplate = {
type: 'Select',
title: 'Select',
category: 'logic',
color: '#9C27B0',
description: 'Returns A if condition is true, otherwise returns B (如果条件为 true 返回 A否则返回 B)',
keywords: ['select', 'choose', 'ternary', '?:', 'logic'],
isPure: true,
inputs: [
{ name: 'condition', type: 'bool', displayName: 'Condition', defaultValue: false },
{ name: 'a', type: 'any', displayName: 'A (True)' },
{ name: 'b', type: 'any', displayName: 'B (False)' }
],
outputs: [
{ name: 'result', type: 'any', displayName: 'Result' }
]
};
@RegisterNode(SelectTemplate)
export class SelectExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const condition = Boolean(context.evaluateInput(node.id, 'condition', false));
const a = context.evaluateInput(node.id, 'a', null);
const b = context.evaluateInput(node.id, 'b', null);
return { outputs: { result: condition ? a : b } };
}
}

View File

@@ -0,0 +1,6 @@
/**
* @zh 逻辑节点 - 比较和逻辑运算节点
* @en Logic Nodes - Comparison and logical operation nodes
*/
export * from './ComparisonNodes';

View File

@@ -120,3 +120,444 @@ export class DivideExecutor implements INodeExecutor {
return { outputs: { result: a / b } };
}
}
// ============================================================================
// Modulo Node (取模节点)
// ============================================================================
export const ModuloTemplate: BlueprintNodeTemplate = {
type: 'Modulo',
title: 'Modulo',
category: 'math',
color: '#4CAF50',
description: 'Returns the remainder of A divided by B (返回 A 除以 B 的余数)',
keywords: ['modulo', 'mod', 'remainder', '%', 'math'],
isPure: true,
inputs: [
{ name: 'a', type: 'float', displayName: 'A', defaultValue: 0 },
{ name: 'b', type: 'float', displayName: 'B', defaultValue: 1 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(ModuloTemplate)
export class ModuloExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Number(context.evaluateInput(node.id, 'a', 0));
const b = Number(context.evaluateInput(node.id, 'b', 1));
if (b === 0) return { outputs: { result: 0 } };
return { outputs: { result: a % b } };
}
}
// ============================================================================
// Absolute Value Node (绝对值节点)
// ============================================================================
export const AbsTemplate: BlueprintNodeTemplate = {
type: 'Abs',
title: 'Absolute',
category: 'math',
color: '#4CAF50',
description: 'Returns the absolute value (返回绝对值)',
keywords: ['abs', 'absolute', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(AbsTemplate)
export class AbsExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
return { outputs: { result: Math.abs(value) } };
}
}
// ============================================================================
// Min Node (最小值节点)
// ============================================================================
export const MinTemplate: BlueprintNodeTemplate = {
type: 'Min',
title: 'Min',
category: 'math',
color: '#4CAF50',
description: 'Returns the smaller of two values (返回两个值中较小的一个)',
keywords: ['min', 'minimum', 'smaller', 'math'],
isPure: true,
inputs: [
{ name: 'a', type: 'float', displayName: 'A', defaultValue: 0 },
{ name: 'b', type: 'float', displayName: 'B', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(MinTemplate)
export class MinExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Number(context.evaluateInput(node.id, 'a', 0));
const b = Number(context.evaluateInput(node.id, 'b', 0));
return { outputs: { result: Math.min(a, b) } };
}
}
// ============================================================================
// Max Node (最大值节点)
// ============================================================================
export const MaxTemplate: BlueprintNodeTemplate = {
type: 'Max',
title: 'Max',
category: 'math',
color: '#4CAF50',
description: 'Returns the larger of two values (返回两个值中较大的一个)',
keywords: ['max', 'maximum', 'larger', 'math'],
isPure: true,
inputs: [
{ name: 'a', type: 'float', displayName: 'A', defaultValue: 0 },
{ name: 'b', type: 'float', displayName: 'B', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(MaxTemplate)
export class MaxExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Number(context.evaluateInput(node.id, 'a', 0));
const b = Number(context.evaluateInput(node.id, 'b', 0));
return { outputs: { result: Math.max(a, b) } };
}
}
// ============================================================================
// Clamp Node (限制范围节点)
// ============================================================================
export const ClampTemplate: BlueprintNodeTemplate = {
type: 'Clamp',
title: 'Clamp',
category: 'math',
color: '#4CAF50',
description: 'Clamps a value between min and max (将值限制在最小和最大之间)',
keywords: ['clamp', 'limit', 'range', 'bound', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 },
{ name: 'min', type: 'float', displayName: 'Min', defaultValue: 0 },
{ name: 'max', type: 'float', displayName: 'Max', defaultValue: 1 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(ClampTemplate)
export class ClampExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
const min = Number(context.evaluateInput(node.id, 'min', 0));
const max = Number(context.evaluateInput(node.id, 'max', 1));
return { outputs: { result: Math.max(min, Math.min(max, value)) } };
}
}
// ============================================================================
// Lerp Node (线性插值节点)
// ============================================================================
export const LerpTemplate: BlueprintNodeTemplate = {
type: 'Lerp',
title: 'Lerp',
category: 'math',
color: '#4CAF50',
description: 'Linear interpolation between A and B (A 和 B 之间的线性插值)',
keywords: ['lerp', 'interpolate', 'blend', 'mix', 'math'],
isPure: true,
inputs: [
{ name: 'a', type: 'float', displayName: 'A', defaultValue: 0 },
{ name: 'b', type: 'float', displayName: 'B', defaultValue: 1 },
{ name: 't', type: 'float', displayName: 'Alpha', defaultValue: 0.5 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(LerpTemplate)
export class LerpExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const a = Number(context.evaluateInput(node.id, 'a', 0));
const b = Number(context.evaluateInput(node.id, 'b', 1));
const t = Number(context.evaluateInput(node.id, 't', 0.5));
return { outputs: { result: a + (b - a) * t } };
}
}
// ============================================================================
// Random Range Node (随机范围节点)
// ============================================================================
export const RandomRangeTemplate: BlueprintNodeTemplate = {
type: 'RandomRange',
title: 'Random Range',
category: 'math',
color: '#4CAF50',
description: 'Returns a random number between min and max (返回 min 和 max 之间的随机数)',
keywords: ['random', 'range', 'rand', 'math'],
isPure: true,
inputs: [
{ name: 'min', type: 'float', displayName: 'Min', defaultValue: 0 },
{ name: 'max', type: 'float', displayName: 'Max', defaultValue: 1 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(RandomRangeTemplate)
export class RandomRangeExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const min = Number(context.evaluateInput(node.id, 'min', 0));
const max = Number(context.evaluateInput(node.id, 'max', 1));
return { outputs: { result: min + Math.random() * (max - min) } };
}
}
// ============================================================================
// Random Integer Node (随机整数节点)
// ============================================================================
export const RandomIntTemplate: BlueprintNodeTemplate = {
type: 'RandomInt',
title: 'Random Integer',
category: 'math',
color: '#4CAF50',
description: 'Returns a random integer between min and max inclusive (返回 min 和 max 之间的随机整数,包含边界)',
keywords: ['random', 'int', 'integer', 'rand', 'math'],
isPure: true,
inputs: [
{ name: 'min', type: 'int', displayName: 'Min', defaultValue: 0 },
{ name: 'max', type: 'int', displayName: 'Max', defaultValue: 10 }
],
outputs: [
{ name: 'result', type: 'int', displayName: 'Result' }
]
};
@RegisterNode(RandomIntTemplate)
export class RandomIntExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const min = Math.floor(Number(context.evaluateInput(node.id, 'min', 0)));
const max = Math.floor(Number(context.evaluateInput(node.id, 'max', 10)));
return { outputs: { result: Math.floor(min + Math.random() * (max - min + 1)) } };
}
}
// ============================================================================
// Power Node (幂运算节点)
// ============================================================================
export const PowerTemplate: BlueprintNodeTemplate = {
type: 'Power',
title: 'Power',
category: 'math',
color: '#4CAF50',
description: 'Returns base raised to the power of exponent (返回底数的指数次幂)',
keywords: ['power', 'pow', 'exponent', '^', 'math'],
isPure: true,
inputs: [
{ name: 'base', type: 'float', displayName: 'Base', defaultValue: 2 },
{ name: 'exponent', type: 'float', displayName: 'Exponent', defaultValue: 2 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(PowerTemplate)
export class PowerExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const base = Number(context.evaluateInput(node.id, 'base', 2));
const exponent = Number(context.evaluateInput(node.id, 'exponent', 2));
return { outputs: { result: Math.pow(base, exponent) } };
}
}
// ============================================================================
// Square Root Node (平方根节点)
// ============================================================================
export const SqrtTemplate: BlueprintNodeTemplate = {
type: 'Sqrt',
title: 'Square Root',
category: 'math',
color: '#4CAF50',
description: 'Returns the square root (返回平方根)',
keywords: ['sqrt', 'square', 'root', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 4 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(SqrtTemplate)
export class SqrtExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 4));
return { outputs: { result: Math.sqrt(Math.abs(value)) } };
}
}
// ============================================================================
// Floor Node (向下取整节点)
// ============================================================================
export const FloorTemplate: BlueprintNodeTemplate = {
type: 'Floor',
title: 'Floor',
category: 'math',
color: '#4CAF50',
description: 'Rounds down to the nearest integer (向下取整)',
keywords: ['floor', 'round', 'down', 'int', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'int', displayName: 'Result' }
]
};
@RegisterNode(FloorTemplate)
export class FloorExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
return { outputs: { result: Math.floor(value) } };
}
}
// ============================================================================
// Ceil Node (向上取整节点)
// ============================================================================
export const CeilTemplate: BlueprintNodeTemplate = {
type: 'Ceil',
title: 'Ceil',
category: 'math',
color: '#4CAF50',
description: 'Rounds up to the nearest integer (向上取整)',
keywords: ['ceil', 'ceiling', 'round', 'up', 'int', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'int', displayName: 'Result' }
]
};
@RegisterNode(CeilTemplate)
export class CeilExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
return { outputs: { result: Math.ceil(value) } };
}
}
// ============================================================================
// Round Node (四舍五入节点)
// ============================================================================
export const RoundTemplate: BlueprintNodeTemplate = {
type: 'Round',
title: 'Round',
category: 'math',
color: '#4CAF50',
description: 'Rounds to the nearest integer (四舍五入到最近的整数)',
keywords: ['round', 'int', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'int', displayName: 'Result' }
]
};
@RegisterNode(RoundTemplate)
export class RoundExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
return { outputs: { result: Math.round(value) } };
}
}
// ============================================================================
// Negate Node (取反节点)
// ============================================================================
export const NegateTemplate: BlueprintNodeTemplate = {
type: 'Negate',
title: 'Negate',
category: 'math',
color: '#4CAF50',
description: 'Returns the negative of a value (返回值的负数)',
keywords: ['negate', 'negative', 'minus', '-', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'float', displayName: 'Result' }
]
};
@RegisterNode(NegateTemplate)
export class NegateExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
return { outputs: { result: -value } };
}
}
// ============================================================================
// Sign Node (符号节点)
// ============================================================================
export const SignTemplate: BlueprintNodeTemplate = {
type: 'Sign',
title: 'Sign',
category: 'math',
color: '#4CAF50',
description: 'Returns -1, 0, or 1 based on the sign of the value (根据值的符号返回 -1、0 或 1)',
keywords: ['sign', 'positive', 'negative', 'math'],
isPure: true,
inputs: [
{ name: 'value', type: 'float', displayName: 'Value', defaultValue: 0 }
],
outputs: [
{ name: 'result', type: 'int', displayName: 'Result' }
]
};
@RegisterNode(SignTemplate)
export class SignExecutor implements INodeExecutor {
execute(node: BlueprintNode, context: ExecutionContext): ExecutionResult {
const value = Number(context.evaluateInput(node.id, 'value', 0));
return { outputs: { result: Math.sign(value) } };
}
}