diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5b4c058..b64e028 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -65,9 +65,11 @@ import { type NodeChange, type NodeProps, type NodeTypes, + type OnNodeDrag, useEdgesState, useNodesState, useReactFlow, + useStoreApi, useUpdateNodeInternals, } from "@xyflow/react"; import { @@ -79,9 +81,15 @@ import { import { AutoFitView } from "./AutoFitView"; import { ComponentSymbol, + componentSymbolPortAnchors, componentSymbolPresentation, hasComponentSymbol, } from "./ComponentSymbol"; +import type { ComponentSymbolPortAnchors } from "./componentSymbols/types"; +import { + CONTACT_AWARE_EDGE_TYPE, + contactAwareEdgeTypes, +} from "./ContactAwareEdge"; import { ParameterTable, loadParameterColumnLayout, @@ -114,6 +122,37 @@ type PortDefinition = { side: PortSide; }; +type PortConnectionState = + | "idle" + | "connected" + | "origin" + | "compatible" + | "incompatible"; + +type ActivePortConnection = { + nodeId: string; + portName: string; + mode: "drag" | "click"; +}; + +type ContactSnapCandidate = { + connection: Connection; + draggedNodeId: string; + draggedPortName: string; + snappedPosition: { x: number; y: number }; + targetNodeId: string; + targetPortName: string; +}; + +type ContactDragSession = { + contactEdgeIds: Set; + disconnected: boolean; + effectiveEdges: SimulationEdge[]; + multipleNodes: boolean; + nodeId: string; + startPosition: { x: number; y: number }; +}; + type UnitOption = { value: string; label: string; @@ -218,6 +257,7 @@ type SimulationNodeData = { parameterUnits: Record; rotation: NodeRotation; mirrored: boolean; + portConnectionStates?: Record; }; type SimulationNode = Node; @@ -320,15 +360,20 @@ type EditorClipboard = { type ModelingPaneLayout = { paletteWidth: number; propertiesWidth: number; + componentParametersHeight: number; }; -type ModelingPaneResizeKind = "palette-width" | "properties-width"; +type ModelingPaneResizeKind = + | "palette-width" + | "properties-width" + | "component-parameters-height"; type ModelingPaneResizeState = { kind: ModelingPaneResizeKind; pointerId: number; startX: number; - startWidth: number; + startY: number; + startSize: number; }; type CanvasGridVisibility = { @@ -363,16 +408,28 @@ const MIN_PALETTE_WIDTH = 180; const MAX_PALETTE_WIDTH = 420; const MIN_PROPERTIES_WIDTH = 280; const MAX_PROPERTIES_WIDTH = 560; +const PROPERTIES_PANE_SPLITTER_SIZE = 7; +const MIN_COMPONENT_PARAMETERS_HEIGHT = 120; +const MIN_SIMULATION_SETTINGS_HEIGHT = 120; const MIN_MODELING_CANVAS_WIDTH = 420; const MODELING_PANE_KEYBOARD_STEP = 20; const HISTORY_LIMIT = 50; const NODE_FRAME_WIDTH = 132; const NODE_FRAME_HEIGHT = 84; +const NODE_SYMBOL_WIDTH = 112; +const NODE_SYMBOL_HEIGHT = 84; +const SYMBOL_VIEWBOX_WIDTH = 64; +const SYMBOL_VIEWBOX_HEIGHT = 48; +const CONTACT_SNAP_RADIUS_PX = 24; +const CONTACT_DISCONNECT_DISTANCE_PX = 8; +const CONTACT_GEOMETRY_TOLERANCE = 0.5; +const CONTACT_DELETE_SEPARATION = 16; function defaultModelingPaneLayout(): ModelingPaneLayout { return { paletteWidth: 230, propertiesWidth: 330, + componentParametersHeight: 340, }; } @@ -382,22 +439,30 @@ function loadModelingPaneLayout(): ModelingPaneLayout { const parsed = JSON.parse( localStorage.getItem(MODELING_PANE_LAYOUT_KEY) ?? "null", ) as Partial | null; - if ( - !parsed || - !Number.isFinite(parsed.paletteWidth) || - !Number.isFinite(parsed.propertiesWidth) - ) { + if (!parsed) { return fallback; } return { - paletteWidth: Math.min( - MAX_PALETTE_WIDTH, - Math.max(MIN_PALETTE_WIDTH, Number(parsed.paletteWidth)), - ), - propertiesWidth: Math.min( - MAX_PROPERTIES_WIDTH, - Math.max(MIN_PROPERTIES_WIDTH, Number(parsed.propertiesWidth)), - ), + paletteWidth: Number.isFinite(parsed.paletteWidth) + ? Math.min( + MAX_PALETTE_WIDTH, + Math.max(MIN_PALETTE_WIDTH, Number(parsed.paletteWidth)), + ) + : fallback.paletteWidth, + propertiesWidth: Number.isFinite(parsed.propertiesWidth) + ? Math.min( + MAX_PROPERTIES_WIDTH, + Math.max(MIN_PROPERTIES_WIDTH, Number(parsed.propertiesWidth)), + ) + : fallback.propertiesWidth, + componentParametersHeight: Number.isFinite( + parsed.componentParametersHeight, + ) + ? Math.max( + MIN_COMPONENT_PARAMETERS_HEIGHT, + Number(parsed.componentParametersHeight), + ) + : fallback.componentParametersHeight, }; } catch { return fallback; @@ -407,6 +472,7 @@ function loadModelingPaneLayout(): ModelingPaneLayout { function clampModelingPaneLayout( layout: ModelingPaneLayout, workbenchWidth: number, + workbenchHeight: number, ): ModelingPaneLayout { let paletteWidth = Math.min( MAX_PALETTE_WIDTH, @@ -437,7 +503,25 @@ function clampModelingPaneLayout( paletteWidth - MIN_PALETTE_WIDTH, ); } - return { paletteWidth, propertiesWidth }; + const maximumComponentParametersHeight = Math.max( + MIN_COMPONENT_PARAMETERS_HEIGHT, + workbenchHeight - + PROPERTIES_PANE_SPLITTER_SIZE - + MIN_SIMULATION_SETTINGS_HEIGHT, + ); + const requestedComponentParametersHeight = Number.isFinite( + layout.componentParametersHeight, + ) + ? layout.componentParametersHeight + : defaultModelingPaneLayout().componentParametersHeight; + const componentParametersHeight = Math.min( + maximumComponentParametersHeight, + Math.max( + MIN_COMPONENT_PARAMETERS_HEIGHT, + requestedComponentParametersHeight, + ), + ); + return { paletteWidth, propertiesWidth, componentParametersHeight }; } function loadCanvasGridVisibility(): CanvasGridVisibility { @@ -1318,7 +1402,15 @@ function SimulationComponentNode({ id, data, selected }: NodeProps { updateNodeInternals(id); @@ -1326,7 +1418,7 @@ function SimulationComponentNode({ id, data, selected }: NodeProps
- {portPlacements.map(({ port, position, x, y }) => ( - - ))} + {portPlacements.map(({ port, position, x, y }) => { + const connectionState = data.portConnectionStates?.[port.name] ?? "idle"; + const isConnected = connectionState === "connected"; + return ( + + ); + })} {hasDedicatedSymbol ? ( @@ -5008,12 +5868,16 @@ function findNodePort( ?.data.ports.find((port) => port.name === portName); } +function portConnectionKey(nodeId: string, portName: string) { + return `${nodeId}:${portName}`; +} + function arePortsCompatible(first: PortDefinition, second: PortDefinition) { - if (first.kind !== second.kind) { + if (first.kind !== second.kind || first.domain !== second.domain) { return false; } if (first.kind === "physical") { - return first.domain === second.domain; + return true; } return new Set([first.nominalRole, second.nominalRole]).size === 2 && [first.nominalRole, second.nominalRole].every((role) => @@ -5023,6 +5887,7 @@ function arePortsCompatible(first: PortDefinition, second: PortDefinition) { function canConnectPorts( nodes: SimulationNode[], + edges: SimulationEdge[], connection: Connection | SimulationEdge, ) { if ( @@ -5034,7 +5899,22 @@ function canConnectPorts( } const first = findNodePort(nodes, connection.source, connection.sourceHandle ?? null); const second = findNodePort(nodes, connection.target, connection.targetHandle ?? null); - return Boolean(first && second && arePortsCompatible(first, second)); + if (!first || !second || !arePortsCompatible(first, second)) { + return false; + } + const firstKey = portConnectionKey(connection.source, connection.sourceHandle!); + const secondKey = portConnectionKey(connection.target, connection.targetHandle!); + return !edges.some( + (edge) => + (typeof edge.sourceHandle === "string" && + portConnectionKey(edge.source, edge.sourceHandle) === firstKey) || + (typeof edge.targetHandle === "string" && + portConnectionKey(edge.target, edge.targetHandle) === firstKey) || + (typeof edge.sourceHandle === "string" && + portConnectionKey(edge.source, edge.sourceHandle) === secondKey) || + (typeof edge.targetHandle === "string" && + portConnectionKey(edge.target, edge.targetHandle) === secondKey), + ); } function connectionIdentity(edge: SimulationEdge, unordered: boolean) { diff --git a/frontend/src/ComponentSymbol.tsx b/frontend/src/ComponentSymbol.tsx index 41d947b..da1867c 100644 --- a/frontend/src/ComponentSymbol.tsx +++ b/frontend/src/ComponentSymbol.tsx @@ -25,6 +25,7 @@ import { SymbolSvg } from "./componentSymbols/primitives"; import { AmesimStep0Symbol, AmesimUd00Symbol } from "./componentSymbols/signals"; import { AmesimPnch012Symbol, AmesimPnch023Symbol } from "./componentSymbols/storage"; import type { + ComponentSymbolPortAnchors, ComponentSymbolPresentation, SymbolDefinition, } from "./componentSymbols/types"; @@ -60,7 +61,7 @@ function CylinderSymbol() { function TankSymbol() { return ( - + - + ); @@ -99,8 +100,18 @@ function OrificeSymbol() { function TeeSymbol() { return ( - - + + ); } @@ -195,6 +206,123 @@ const symbolRegistry = { type RegisteredComponentSymbol = keyof typeof symbolRegistry; +const symbolPortAnchors: Partial< + Record +> = { + cylinder: { + left: [{ x: 3, y: 24 }], + right: [{ x: 61, y: 24 }], + }, + tank: { left: [{ x: 2, y: 24 }] }, + pipe: { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], + }, + orifice: { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], + }, + tee: { + left: [{ x: 2, y: 24 }], + right: [ + { x: 62, y: 12 }, + { x: 62, y: 36 }, + ], + }, + generic: { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], + }, + signal: { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], + }, + junction: { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], + }, + amesim_pnpl01: { left: [{ x: 2, y: 24 }] }, + amesim_pnch023: { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], + }, + amesim_pnch012: { + left: [ + { x: 2, y: 16 }, + { x: 2, y: 32 }, + ], + right: [ + { x: 62, y: 16 }, + { x: 62, y: 32 }, + ], + }, + amesim_pnor001: { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], + }, + amesim_pnvo001_fixed: { + left: [{ x: 2, y: 28 }], + right: [{ x: 62, y: 28 }], + }, + amesim_pnvo001: { + left: [ + { x: 2, y: 16 }, + { x: 2, y: 32 }, + ], + right: [{ x: 62, y: 25 }], + }, + amesim_pnl00r: { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], + }, + amesim_pnl0001: { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], + }, + amesim_pnl0002: { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], + }, + amesim_pnl0003: { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], + }, + amesim_pn3node2: { + left: [{ x: 3, y: 24 }], + right: [ + { x: 61, y: 12 }, + { x: 61, y: 36 }, + ], + }, + amesim_p4node2: { + left: [{ x: 3, y: 24 }], + right: [ + { x: 61, y: 8 }, + { x: 61, y: 24 }, + { x: 61, y: 40 }, + ], + }, + amesim_step0: { right: [{ x: 60, y: 24 }] }, + amesim_ud00: { right: [{ x: 60, y: 24 }] }, + amesim_f000: { right: [{ x: 62, y: 24 }] }, + amesim_forc: { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], + }, + amesim_mecmas21: { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], + }, + amesim_lstp00a: { + left: [{ x: 2, y: 24 }], + right: [{ x: 62, y: 24 }], + }, + amesim_lmechn1: { + left: [7, 12, 17, 22, 27, 32, 37, 42].map((y) => ({ x: 5, y })), + right: [{ x: 59, y: 24 }], + }, +}; + function isRegisteredComponentSymbol(symbol: string): symbol is RegisteredComponentSymbol { return Object.prototype.hasOwnProperty.call(symbolRegistry, symbol); } @@ -211,6 +339,14 @@ export function componentSymbolPresentation( : "framed"; } +export function componentSymbolPortAnchors( + symbol: string, +): ComponentSymbolPortAnchors | undefined { + return isRegisteredComponentSymbol(symbol) + ? symbolPortAnchors[symbol] + : undefined; +} + export function ComponentSymbol({ symbol, surface, diff --git a/frontend/src/ContactAwareEdge.tsx b/frontend/src/ContactAwareEdge.tsx new file mode 100644 index 0000000..6564787 --- /dev/null +++ b/frontend/src/ContactAwareEdge.tsx @@ -0,0 +1,76 @@ +import { + BaseEdge, + getSmoothStepPath, + type EdgeProps, + type EdgeTypes, +} from "@xyflow/react"; + +export const CONTACT_AWARE_EDGE_TYPE = "contact-aware"; +// React Flow 在 Loose 模式下会把重叠 Handle 的边端点分别放在圆点两侧, +// 因此两个端口中心完全重合时,edge props 仍会相差约一个 Handle 直径。 +export const CONTACT_EDGE_SCREEN_TOLERANCE = 12; + +export function ContactAwareEdge({ + id, + interactionWidth, + markerEnd, + markerStart, + sourcePosition, + sourceX, + sourceY, + style, + targetPosition, + targetX, + targetY, +}: EdgeProps) { + const distance = Math.hypot(targetX - sourceX, targetY - sourceY); + if (distance <= CONTACT_EDGE_SCREEN_TOLERANCE) { + const centerX = (sourceX + targetX) / 2; + const centerY = (sourceY + targetY) / 2; + return ( + <> + +