From bd86224cba1c89f362f376941ca6c2f4af49fc7d Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Thu, 7 May 2026 22:30:13 -0300 Subject: [PATCH 1/7] sync(ladder): pull handle-branch reimplementation from web Mirrors openplc-web feature/reimplement-handle-branches-on-ladder (PR #386) onto editor: - Persistent handle-branch index on rung state (Zod schema, slice, reconciliation hooks). - Drop-on-handle creation, compact branch placement next to FB, serial chaining + parallels (OR-paths) inside branches. - Layout: nested-parallel X cascade collapse, sibling-block vertical extent rolls up nested content, branch-specific BRANCH_BLOCK_SIDE_GAP for short FB-side wires, FB-shift anchored against any predecessor. - Compile-time wiring: blockToXml (codesys + old-editor) picks up edges targeting each secondary FB input handle so branch booleans reach the FB at runtime. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/__architecture__/validate.ts | 2 + .../ladder/autocomplete/index.tsx | 24 + .../_atoms/graphical-editor/ladder/block.tsx | 54 +- .../graphical-editor/ladder/buildNodes.tsx | 73 +- .../graphical-editor/ladder/power-rail.tsx | 8 +- .../graphical-editor/ladder/utils/types.ts | 151 +- .../graphical-editor/ladder/variable.tsx | 144 +- .../_atoms/highlighted-textarea/index.tsx | 4 +- .../graphical-editor/ladder/rung/body.tsx | 33 +- .../ladder/rung/ladder-utils/edges.ts | 32 +- .../ladder-utils/elements/diagram/index.ts | 261 +- .../elements/drag-n-drop/index.ts | 248 +- .../elements/handle-branch/index.ts | 2657 +++++++++++++++++ .../rung/ladder-utils/elements/index.ts | 234 +- .../elements/placeholder/index.ts | 46 +- .../rung/ladder-utils/elements/utils/index.ts | 103 +- .../elements/variable-block/index.ts | 14 +- src/frontend/store/slices/ladder/slice.ts | 85 +- src/frontend/store/slices/ladder/types.ts | 56 +- .../store/slices/ladder/utils/index.ts | 121 +- .../codesys/language/ladder-xml.ts | 309 +- .../old-editor/language/ladder-xml.ts | 274 +- .../utils/PLC/xml-generator/rung-graph.ts | 303 ++ src/middleware/shared/ports/flow-schemas.ts | 45 +- src/middleware/shared/ports/types.ts | 14 + 25 files changed, 4398 insertions(+), 897 deletions(-) create mode 100644 src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/handle-branch/index.ts create mode 100644 src/frontend/utils/PLC/xml-generator/rung-graph.ts diff --git a/src/__architecture__/validate.ts b/src/__architecture__/validate.ts index 964fcef1a..347ec1199 100644 --- a/src/__architecture__/validate.ts +++ b/src/__architecture__/validate.ts @@ -268,6 +268,8 @@ const KNOWN_EXCEPTIONS: Record = { 'frontend/store/slices/ladder/utils/index.ts': ['components'], // Ladder slice — needs nodesBuilder + defaultCustomNodesStyles for rung creation 'frontend/store/slices/ladder/slice.ts': ['components'], + // Ladder slice types — re-exports RungLadderState narrowed to RungNode[] + 'frontend/store/slices/ladder/types.ts': ['components'], } // --------------------------------------------------------------------------- diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx index 1804ac44d..ef6671f52 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx @@ -163,6 +163,30 @@ const VariablesBlockAutoComplete = forwardRef { if (!variableName.trim()) { + // For variable nodes on block handles, an empty submission clears the + // variable instead of erroring — letting the user pick a different + // variable (or place a branch on the handle once that's supported). + if (blockType === 'variable') { + const { rung, node: variableNode } = getLadderPouVariablesRungNodeAndEdges(editor, pous, ladderFlows, { + nodeId: (block as Node).id, + }) + if (rung && variableNode) { + updateNode({ + editorName: editor.meta.name, + rungId: rung.id, + nodeId: variableNode.id, + node: { + ...variableNode, + data: { + ...variableNode.data, + variable: { id: '', name: '' }, + }, + }, + }) + } + return + } + toast({ title: 'Invalid variable name', description: 'Variable name cannot be empty', diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx index 9790f1139..849326013 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx @@ -10,6 +10,10 @@ import { checkVariableName } from '../../../../store/slices/project/validation/v import { cn } from '../../../../utils/cn' import { toast } from '../../../_features/[app]/toast/use-toast' import { updateDiagramElementsPosition } from '../../../_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram' +import { + findInvalidatedBranches, + reconcileBranches, +} from '../../../_molecules/graphical-editor/ladder/rung/ladder-utils/elements/handle-branch' import { HighlightedTextArea } from '../../highlighted-textarea' import { InputWithRef } from '../../input' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../tooltip' @@ -60,7 +64,7 @@ export const BlockNodeElement = ({ editorActions: { updateModelVariables }, libraries, ladderFlows, - ladderFlowActions: { setNodes, setEdges }, + ladderFlowActions, project: { data: { pous }, }, @@ -176,6 +180,26 @@ export const BlockNodeElement = ({ }) if (!pou || !rung || !node) return + /** + * Handle-branch reconciliation: a block-variant change can rename or + * retype handles in ways that orphan existing branches. If any branch + * on this block would be invalidated by the new variant, ask the user + * for confirmation before silently dropping them. + */ + const invalidatedBranches = findInvalidatedBranches(rung, node.id, libraryBlock as BlockVariant) + if (invalidatedBranches.length > 0) { + const handleNames = invalidatedBranches.map((b) => `${b.handleId} (${b.direction})`).join(', ') + const confirmed = window.confirm( + `Changing this block to "${blockNameValue}" will drop ${invalidatedBranches.length} handle ` + + `branch${invalidatedBranches.length === 1 ? '' : 'es'} on the following pin${invalidatedBranches.length === 1 ? '' : 's'}: ${handleNames}.\n\n` + + `Continue?`, + ) + if (!confirmed) { + setBlockNameValue(validBlockNameValue) + return + } + } + if (libraryBlock && pou.pouType === 'function' && (libraryBlock as BlockVariant).type !== 'function') { setWrongName(true) toast({ @@ -284,11 +308,22 @@ export const BlockNodeElement = ({ newEdges = newEdges.map((e) => (e.id === edge.id ? newEdge : e)) }) + // Reconcile any handle branches on this block before layout runs: + // - drop branches whose handle disappeared or stopped being BOOL + // - remap surviving branches' references to the new block id + const reconciled = reconcileBranches( + { ...rung, nodes: newNodes, edges: newEdges }, + node.id, + newBlockNode.id, + libraryBlock as BlockVariant, + ) + const { nodes: variableNodes, edges: variableEdges } = updateDiagramElementsPosition( { ...rung, - nodes: newNodes, - edges: newEdges, + nodes: reconciled.nodes, + edges: reconciled.edges, + handleBranches: reconciled.handleBranches, }, [rung.defaultBounds[0], rung.defaultBounds[1]], ) @@ -300,15 +335,12 @@ export const BlockNodeElement = ({ body: currentPou2?.body.value, }) - setNodes({ + ladderFlowActions.updateRungData({ editorName: editor.meta.name, rungId: rung.id, nodes: variableNodes, - }) - setEdges({ - editorName: editor.meta.name, - rungId: rung.id, edges: variableEdges, + handleBranches: reconciled.handleBranches, }) setWrongName(false) @@ -353,7 +385,7 @@ export const BlockNodeElement = ({
{connector}
@@ -362,7 +394,7 @@ export const BlockNodeElement = ({
{connector}
@@ -841,7 +873,7 @@ export const Block = (block: BlockProps) => { rungId: rung.id, node: { ...node, - draggable: node.data.draggable as boolean, + draggable: node.data.draggable, }, }) return diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/buildNodes.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/buildNodes.tsx index ac79fc34d..65ea65c38 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/buildNodes.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/buildNodes.tsx @@ -546,26 +546,69 @@ export const builderPlaceholderNode = ({ * @param connector: 'left' | 'right' - The connector of the power rail node * @param handleX: number - The x coordinate of the handle based on the global position (inside the flow panel) * @param handleY: number - The y coordinate of the handle based on the global position (inside the flow panel) + * @param branchHandles: optional dynamic handles that anchor handle branches + * on the rail. Each entry adds one extra source/target handle and may push + * the rail's visual height down to encompass it. * @returns PowerRailNode */ -export const buildPowerRailNode = ({ id, posX, posY, connector, handleX, handleY }: PowerRailBuilderProps) => { - const handles = [ +export const buildPowerRailNode = ({ + id, + posX, + posY, + connector, + handleX, + handleY, + branchHandles = [], +}: PowerRailBuilderProps) => { + const primaryHandle = buildHandle({ + id: `${connector === 'left' ? 'right' : 'left'}-rail`, + position: connector === 'left' ? Position.Left : Position.Right, + type: connector === 'left' ? 'target' : 'source', + isConnectable: false, + glbX: handleX, + glbY: handleY, + relX: DEFAULT_POWER_RAIL_CONNECTOR_X, + relY: DEFAULT_POWER_RAIL_CONNECTOR_Y, + style: { + top: DEFAULT_POWER_RAIL_CONNECTOR_Y, + left: connector === 'left' ? -DEFAULT_POWER_RAIL_WIDTH : undefined, + right: connector === 'right' ? -DEFAULT_POWER_RAIL_WIDTH : undefined, + }, + }) + + // Branch handles attach at arbitrary Y offsets along the rail. Direction + // follows the branch's flow: input branches go rail→block (rail is source), + // output branches go block→rail (rail is target). Left rails only host + // input branches; right rails only host output branches. + const railSidedBranches = branchHandles.filter((bh) => + connector === 'right' ? bh.direction === 'input' : bh.direction === 'output', + ) + const dynamicHandles = railSidedBranches.map((bh) => buildHandle({ - id: `${connector === 'left' ? 'right' : 'left'}-rail`, + id: bh.id, position: connector === 'left' ? Position.Left : Position.Right, - type: connector === 'left' ? 'target' : 'source', + type: bh.direction === 'input' ? 'source' : 'target', isConnectable: false, glbX: handleX, - glbY: handleY, + glbY: posY + bh.y, relX: DEFAULT_POWER_RAIL_CONNECTOR_X, - relY: DEFAULT_POWER_RAIL_CONNECTOR_Y, + relY: bh.y, style: { - top: DEFAULT_POWER_RAIL_CONNECTOR_Y, + top: bh.y, left: connector === 'left' ? -DEFAULT_POWER_RAIL_WIDTH : undefined, right: connector === 'right' ? -DEFAULT_POWER_RAIL_WIDTH : undefined, }, }), - ] + ) + + const handles = [primaryHandle, ...dynamicHandles] + + // Stretch the rail to cover every handle's Y. Default height stays as-is + // when there are no branch handles. + const branchExtent = railSidedBranches.reduce((max, bh) => Math.max(max, bh.y), 0) + const height = Math.max(DEFAULT_POWER_RAIL_HEIGHT, branchExtent + DEFAULT_POWER_RAIL_CONNECTOR_Y) + + const isLeftRail = connector === 'right' return { id, @@ -573,12 +616,12 @@ export const buildPowerRailNode = ({ id, posX, posY, connector, handleX, handleY position: { x: posX, y: posY }, data: { handles, - inputHandles: connector === 'left' ? handles : [], - outputHandles: connector === 'right' ? handles : [], - inputConnector: connector === 'left' ? handles[0] : undefined, - outputConnector: connector === 'right' ? handles[0] : undefined, + inputHandles: isLeftRail ? [] : handles, + outputHandles: isLeftRail ? handles : [], + inputConnector: isLeftRail ? undefined : primaryHandle, + outputConnector: isLeftRail ? primaryHandle : undefined, numericId: generateNumericUUID(), - variant: connector === 'right' ? 'left' : 'right', + variant: isLeftRail ? 'left' : 'right', variable: { name: '' }, executionOrder: 0, draggable: false, @@ -586,10 +629,10 @@ export const buildPowerRailNode = ({ id, posX, posY, connector, handleX, handleY deletable: false, }, width: DEFAULT_POWER_RAIL_WIDTH, - height: DEFAULT_POWER_RAIL_HEIGHT, + height, measured: { width: DEFAULT_POWER_RAIL_WIDTH, - height: DEFAULT_POWER_RAIL_HEIGHT, + height, }, draggable: false, selectable: false, diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/power-rail.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/power-rail.tsx index eaee9906d..23a1677b7 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/power-rail.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/power-rail.tsx @@ -2,11 +2,13 @@ import { CustomHandle } from './handle' import { DEFAULT_POWER_RAIL_HEIGHT, DEFAULT_POWER_RAIL_WIDTH } from './utils/constants' import { PowerRailProps } from './utils/types' -export const PowerRail = ({ data }: PowerRailProps) => { +export const PowerRail = ({ data, width, height }: PowerRailProps) => { + const railWidth = width ?? DEFAULT_POWER_RAIL_WIDTH + const railHeight = height ?? DEFAULT_POWER_RAIL_HEIGHT return ( <> - - + + {data.handles.map((handle, index) => ( diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/utils/types.ts b/src/frontend/components/_atoms/graphical-editor/ladder/utils/types.ts index 2aeeb04e9..0e4778762 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/utils/types.ts +++ b/src/frontend/components/_atoms/graphical-editor/ladder/utils/types.ts @@ -1,9 +1,11 @@ import type { Node, NodeProps } from '@xyflow/react' import { ReactNode } from 'react' -import { PLCVariable } from '../../../../../../middleware/shared/ports' +import { HandleBranch, PLCVariable } from '../../../../../../middleware/shared/ports' import { CustomHandleProps } from '../handle' +export type { HandleBranch } + export type BuilderBasicProps = { id: string posX: number @@ -12,6 +14,22 @@ export type BuilderBasicProps = { handleY: number } +/** + * Marker placed on contact / coil / parallel nodes that live inside a handle + * branch (the mini-rung attached to a function-block input or output handle). + * + * Block / variable / power-rail / placeholder nodes intentionally do NOT carry + * this marker — having `branchContext` on those types would model invalid + * states (a block can't be a branch element, a variable is replaced by a + * branch, etc.). Restricting it at the type level catches misuse at compile + * time. + * + * Derived from `HandleBranch` so the shared field set stays consistent — + * `branchContext` is the per-node marker, `HandleBranch` adds `nodeIds` to + * become the per-rung index. + */ +export type BranchContext = Omit + export type BasicNodeData = { handles: CustomHandleProps[] inputHandles: CustomHandleProps[] @@ -50,17 +68,17 @@ export type BlockNodeData = BasicNodeData & { variable: { id?: string; name: string } | PLCVariable hasDivergence?: boolean } -export type BlockNode = Node> +export type BlockNode = Node, 'block'> export type BlockProps = NodeProps> export type BlockBuilderProps = BuilderBasicProps & { variant: T; executionControl?: boolean } // coil -export type CoilNode = Node< - BasicNodeData & { - variant: 'default' | 'negated' | 'risingEdge' | 'fallingEdge' | 'set' | 'reset' - } -> +export type CoilNodeData = BasicNodeData & { + variant: 'default' | 'negated' | 'risingEdge' | 'fallingEdge' | 'set' | 'reset' + branchContext?: BranchContext +} +export type CoilNode = Node export type CoilProps = NodeProps export type CoilBuilderProps = BuilderBasicProps & { variant: 'default' | 'negated' | 'risingEdge' | 'fallingEdge' | 'set' | 'reset' @@ -74,7 +92,11 @@ export type CoilType = { // contact -export type ContactNode = Node +export type ContactNodeData = BasicNodeData & { + variant: 'default' | 'negated' | 'risingEdge' | 'fallingEdge' + branchContext?: BranchContext +} +export type ContactNode = Node export type ContactProps = NodeProps export type ContactBuilderProps = BuilderBasicProps & { variant: 'default' | 'negated' | 'risingEdge' | 'fallingEdge' } @@ -91,23 +113,53 @@ export type MockNodeProps = NodeProps // parallel -export type ParallelNode = Node< - BasicNodeData & { - parallelInputConnector: CustomHandleProps | undefined - parallelOutputConnector: CustomHandleProps | undefined - parallelOpenReference: string | undefined - parallelCloseReference: string | undefined - type: 'open' | 'close' - } -> +export type ParallelNodeData = BasicNodeData & { + parallelInputConnector: CustomHandleProps | undefined + parallelOutputConnector: CustomHandleProps | undefined + parallelOpenReference: string | undefined + parallelCloseReference: string | undefined + type: 'open' | 'close' + branchContext?: BranchContext +} +export type ParallelNode = Node export type ParallelProps = NodeProps export type ParallelBuilderProps = BuilderBasicProps & { type: 'open' | 'close' } // placeholder -export type PlaceholderNode = Node< - BasicNodeData & { relatedNode: Node | undefined; position: 'left' | 'right' | 'bottom' } -> +/** + * Marker on a placeholder that says "drop here to create or extend a handle + * branch on this block input/output." Distinguishes branch-target placeholders + * from regular main-rail / parallel placeholders. + * + * Variants: + * - no `insertIndex`, no `parallelPathSplice`: "create" placeholder over the + * Variable node slot. Routes to `replaceVariableWithBranch`. + * - `insertIndex` only: "splice" placeholder in the spine at that index. + * Routes to `insertIntoBranch`. Or, when the placeholder is of type + * `parallelPlaceholder`, it routes to `startParallelInBranch` / + * `addPathToBranchParallel` and `insertIndex` identifies which spine + * element is being parallelized. + * - `parallelPathSplice`: "splice into a parallel-path's serial chain" — + * `parallelOpenId` identifies which OPEN's parallel paths we're acting + * on, `predecessorId` and `successorId` are the existing parallel-path + * elements (or OPEN/CLOSE) the new element splices between. + */ +export type HandleBranchTarget = BranchContext & { + insertIndex?: number + parallelPathSplice?: { + parallelOpenId: string + predecessorId: string + successorId: string + } +} + +export type PlaceholderNodeData = BasicNodeData & { + relatedNode: Node | undefined + position: 'left' | 'right' | 'bottom' + handleBranchTarget?: HandleBranchTarget +} +export type PlaceholderNode = Node export type PlaceholderProps = NodeProps export type PlaceholderBuilderProps = BuilderBasicProps & { type: 'parallel' | 'default' @@ -117,22 +169,42 @@ export type PlaceholderBuilderProps = BuilderBasicProps & { // power rail -export type PowerRailNode = Node +export type PowerRailNodeData = BasicNodeData & { variant: 'left' | 'right' } +export type PowerRailNode = Node export type PowerRailProps = NodeProps -export type PowerRailBuilderProps = BuilderBasicProps & { connector: 'left' | 'right' } +/** + * Dynamic handle on a power rail that anchors a handle branch. + * + * Direction follows the branch's flow: + * - input branches start at the left rail and feed into a block input, + * so on a left rail (`variant: 'right'`) the handle is a `source`. + * - output branches start at a block output and end at the right rail, + * so on a right rail (`variant: 'left'`) the handle is a `target`. + * + * `y` is relative to the rail's top edge (top = 0). + */ +export type RailBranchHandle = { + id: string + y: number + direction: 'input' | 'output' +} + +export type PowerRailBuilderProps = BuilderBasicProps & { + connector: 'left' | 'right' + branchHandles?: RailBranchHandle[] +} // variable -export type VariableNode = Node< - BasicNodeData & { - variant: 'input' | 'output' - block: { - id: string - handleId: string - variableType: BlockVariant['variables'][0] - } +export type VariableNodeData = BasicNodeData & { + variant: 'input' | 'output' + block: { + id: string + handleId: string + variableType: BlockVariant['variables'][0] } -> +} +export type VariableNode = Node export type VariableProps = NodeProps export type VariableBuilderProps = BuilderBasicProps & { variant: 'input' | 'output' @@ -143,3 +215,20 @@ export type VariableBuilderProps = BuilderBasicProps & { } variable: PLCVariable | undefined } + +// rung-node discriminated union + +/** + * Every node type that may appear in a `RungLadderState['nodes']` array, + * discriminated by its literal `type` field. Use this in place of the generic + * `Node` from `@xyflow/react` so callers can narrow on `node.type` instead of + * casting `as BlockNode<...>` etc. + */ +export type RungNode = + | BlockNode + | CoilNode + | ContactNode + | ParallelNode + | PlaceholderNode + | PowerRailNode + | VariableNode diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx index b1f58741c..44a94aafe 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/variable.tsx @@ -110,90 +110,85 @@ const VariableElement = (block: VariableProps) => { } /** - * useEffect to sync variableValue with data.variable.name when it changes externally - * (e.g., from variable rename propagation or autocomplete selection). - * Only sync when autocomplete is closed to avoid overwriting user input while typing. - * Note: openAutocomplete is intentionally NOT in the dependency array to prevent a race - * condition where closing the autocomplete (before blur) would restore the old node value, - * overwriting the user's cleared input. + * Single source-of-truth sync for the variable input. + * + * Reads the variable node's current store value (not the prop, which may + * be stale during React's render cycle) and: + * - drives `variableValue` (display) when the user isn't actively editing + * - looks up the matching POU variable to validate the type + * - repairs the stored value's casing if it has drifted + * + * `openAutocomplete` is intentionally absent from the dep array so that + * closing the autocomplete (before the blur submit) doesn't run this + * effect with a stale prop and clobber the user's pending edit. */ useEffect(() => { - const name = data.variable?.name ?? '' - if (!openAutocomplete && name !== '') { - setVariableValue(name) - } - }, [data.variable?.name]) - - /** - * Update inputError state when the table of variables is updated - */ - useEffect(() => { - const { - node: variableNode, - rung, - variables, - } = getLadderPouVariablesRungNodeAndEdges(editor, pous, ladderFlows, { + const { node: variableNode, rung } = getLadderPouVariablesRungNodeAndEdges(editor, pous, ladderFlows, { nodeId: id, variableName: data.variable?.name, }) if (!rung || !variableNode) return - // Use the selected variable from getLadderPouVariablesRungNodeAndEdges which properly - // handles derived types for 'variable' node types (block pin variables) - const variable = variables.selected + const nodeVariableName = (variableNode as VariableNode).data.variable.name - if (!variable || !inputVariableRef) { + if (!nodeVariableName) { setIsAVariable(false) - } else { - const nodeVariableName = (variableNode as VariableNode).data.variable.name - - const namesMatchCI = variable.name.toLowerCase() === nodeVariableName.toLowerCase() - const caseDiffers = variable.name !== nodeVariableName - - if (!namesMatchCI || caseDiffers) { - updateNode({ - editorName: editor.meta.name, - rungId: rung.id, - nodeId: variableNode.id, - node: { - ...variableNode, - data: { - ...variableNode.data, - variable: variable, - }, - }, - }) - updateRelatedNode(rung, variableNode as VariableNode, variable) - } + setInputError(false) + return + } - const validation = validateVariableType(variable.type.value, data.block.variableType) - if (!validation.isValid && dataTypes.length > 0) { - const userDataTypes = dataTypes.map((dataType) => dataType.name) - validation.isValid = userDataTypes.includes(variable.type.value) - validation.error = undefined - } - // Only sync variableValue when not actively editing (autocomplete closed) + const pou = pous.find((p) => p.name === editor.meta.name) + const variable = (pou?.interface?.variables ?? []).find( + (v) => v.name.toLowerCase() === nodeVariableName.toLowerCase(), + ) + + if (!variable) { + // Literal or orphan name — sync display, but leave validation as-is + // (handleSubmit already classified it). if (!openAutocomplete) { - setVariableValue(variable.name) + setVariableValue(nodeVariableName) } - setInputError(!validation.isValid) - setIsAVariable(true) + setIsAVariable(false) + return } - if (!rung) return + const caseDiffers = variable.name !== nodeVariableName + if (caseDiffers) { + updateNode({ + editorName: editor.meta.name, + rungId: rung.id, + nodeId: variableNode.id, + node: { + ...variableNode, + data: { ...variableNode.data, variable }, + }, + }) + updateRelatedNode(rung, variableNode as VariableNode, variable) + } - const relatedBlock = rung.nodes.find((node) => node.id === data.block.id) - if (!relatedBlock) { + const validation = validateVariableType(variable.type.value, data.block.variableType) + if (!validation.isValid && dataTypes.length > 0) { + const userDataTypes = dataTypes.map((dataType) => dataType.name) + validation.isValid = userDataTypes.includes(variable.type.value) + validation.error = undefined + } + + if (!openAutocomplete) { + setVariableValue(variable.name) + } + setInputError(!validation.isValid) + setIsAVariable(true) + + if (!rung.nodes.find((node) => node.id === data.block.id)) { setInputError(true) - return } }, [pous, data.variable?.name]) /** * Handle with the variable input onBlur event */ - const handleSubmitVariableValueOnTextareaBlur = (variableName?: string) => { - const variableNameToSubmit = variableName || variableValue + const handleSubmitVariableValueOnTextareaBlur = (currentValue?: string) => { + const variableNameToSubmit = currentValue ?? variableValue const { pou, rung, node } = getLadderPouVariablesRungNodeAndEdges(editor, pous, ladderFlows, { nodeId: id, @@ -201,6 +196,31 @@ const VariableElement = (block: VariableProps) => { if (!pou || !rung || !node) return const variableNode = node as VariableNode + // Allow clearing a variable from a block handle by submitting an empty name. + // Resets the variable node to an empty placeholder so the user can pick a + // different variable (or, with handle branches, place contacts/coils on + // the handle instead). + if (!variableNameToSubmit.trim()) { + const emptyVariable = { id: '', name: '' } + setVariableValue('') + setIsAVariable(false) + setInputError(false) + updateNode({ + editorName: editor.meta.name, + rungId: rung.id, + nodeId: variableNode.id, + node: { + ...variableNode, + data: { + ...variableNode.data, + variable: emptyVariable, + }, + }, + }) + updateRelatedNode(rung, variableNode, emptyVariable as PLCVariable) + return + } + // For variable nodes (block pins), allow all types including derived (user-defined types) // Don't use getVariableByName here as it filters out derived types let variable: PLCVariable | { name: string } | undefined = (pou.interface?.variables ?? []).find( diff --git a/src/frontend/components/_atoms/highlighted-textarea/index.tsx b/src/frontend/components/_atoms/highlighted-textarea/index.tsx index 43ee3ffac..538728de7 100644 --- a/src/frontend/components/_atoms/highlighted-textarea/index.tsx +++ b/src/frontend/components/_atoms/highlighted-textarea/index.tsx @@ -8,7 +8,7 @@ import { cn } from '../../../utils/cn' type HighlightedTextAreaProps = ComponentPropsWithRef<'textarea'> & { textAreaValue: string setTextAreaValue: (value: string) => void - handleSubmit?: () => void + handleSubmit?: (currentValue?: string) => void submitWith?: { enter: boolean } @@ -147,7 +147,7 @@ const HighlightedTextArea = forwardRef onScrollHandler(e)} diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx b/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx index ba0577350..8e3b3295c 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx @@ -510,15 +510,20 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi } } - const { nodes, edges, newNode } = addNewElement(rungLocal, { + const { nodes, edges, handleBranches, newNode } = addNewElement(rungLocal, { elementType: newNodeType, blockVariant: pouLibrary, }) captureAndPush(editor.meta.name) - ladderFlowActions.setNodes({ editorName: editor.meta.name, rungId: rungLocal.id, nodes }) - ladderFlowActions.setEdges({ editorName: editor.meta.name, rungId: rungLocal.id, edges }) + ladderFlowActions.updateRungData({ + editorName: editor.meta.name, + rungId: rungLocal.id, + nodes, + edges, + handleBranches, + }) if (newNode) ladderFlowActions.setSelectedNodes({ @@ -541,12 +546,20 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi * Remove some nodes from the rung */ const handleRemoveNode = (nodes: FlowNode[]) => { - const { nodes: newNodes, edges: newEdges } = removeElements({ ...rungLocal }, nodes) + const { nodes: newNodes, edges: newEdges, handleBranches: newHandleBranches } = removeElements( + { ...rungLocal }, + nodes, + ) captureAndPush(editor.meta.name) - ladderFlowActions.setNodes({ editorName: editor.meta.name, rungId: rungLocal.id, nodes: newNodes }) - ladderFlowActions.setEdges({ editorName: editor.meta.name, rungId: rungLocal.id, edges: newEdges }) + ladderFlowActions.updateRungData({ + editorName: editor.meta.name, + rungId: rungLocal.id, + nodes: newNodes, + edges: newEdges, + handleBranches: newHandleBranches, + }) ladderFlowActions.setSelectedNodes({ editorName: editor.meta.name, rungId: rungLocal.id, @@ -657,8 +670,12 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi captureAndPush(editor.meta.name) setDragging(false) - ladderFlowActions.setNodes({ editorName: editor.meta.name, rungId: rungLocal.id, nodes: result.nodes }) - ladderFlowActions.setEdges({ editorName: editor.meta.name, rungId: rungLocal.id, edges: result.edges }) + ladderFlowActions.updateRungData({ + editorName: editor.meta.name, + rungId: rungLocal.id, + nodes: result.nodes, + edges: result.edges, + }) if (pouRef) { syncNodesWithVariables( diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/edges.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/edges.ts index 177873a38..058bf690a 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/edges.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/edges.ts @@ -9,6 +9,17 @@ type ConnectionOptions = { targetHandle?: string } +type ConnectNodesOptions = ConnectionOptions & { + /** + * When provided, locate the existing source edge by matching its `sourceHandle` + * against this value, instead of inferring it from the source node's connectors. + * Used by callers that operate on edges leaving non-default handles — e.g. + * branch elements rooted at a rail's `branch_*` handle or at a function-block + * input handle. + */ + sourceEdgeLookupHandle?: string +} + export const checkIfConnectedInParallel = ( rung: RungLadderState, node: Node, @@ -37,20 +48,21 @@ export const connectNodes = ( sourceNodeId: string, targetNodeId: string, type: 'serial' | 'parallel', - options?: ConnectionOptions, + options?: ConnectNodesOptions, ): Edge[] => { // Find the source edge const sourceNode = rung.nodes.find((node) => node.id === sourceNodeId) as Node const sourceEdge = rung.edges.find((edge) => { - return ( - edge.source === sourceNodeId && - (type === 'parallel' && isNodeOfType(sourceNode, 'parallel') - ? edge.sourceHandle === (sourceNode as ParallelNode).data.parallelOutputConnector?.id - : isNodeOfType(sourceNode, 'parallel') && sourceNode.data.type === 'close' - ? edge.sourceHandle === (sourceNode as ParallelNode).data.parallelOutputConnector?.id || - (sourceNode as ParallelNode).data.outputConnector?.id - : edge.sourceHandle === (sourceNode.data as BasicNodeData).outputConnector?.id) - ) + if (edge.source !== sourceNodeId) return false + if (options?.sourceEdgeLookupHandle !== undefined) { + return edge.sourceHandle === options.sourceEdgeLookupHandle + } + return type === 'parallel' && isNodeOfType(sourceNode, 'parallel') + ? edge.sourceHandle === (sourceNode as ParallelNode).data.parallelOutputConnector?.id + : isNodeOfType(sourceNode, 'parallel') && sourceNode.data.type === 'close' + ? edge.sourceHandle === (sourceNode as ParallelNode).data.parallelOutputConnector?.id || + (sourceNode as ParallelNode).data.outputConnector?.id + : edge.sourceHandle === (sourceNode.data as BasicNodeData).outputConnector?.id }) const targetNode = rung.nodes.find((node) => node.id === targetNodeId) diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts index b8f959463..00ebc738d 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts @@ -3,10 +3,16 @@ import type { RungLadderState } from '@root/frontend/store/slices' import type { Edge, Node } from '@xyflow/react' import { Position } from '@xyflow/react' -import type { CustomHandleProps } from '../../../../../../../_atoms/graphical-editor/ladder/handle' import { defaultCustomNodesStyles } from '../../../../../../../_atoms/graphical-editor/ladder/node-builders' import type { BasicNodeData, ParallelNode } from '../../../../../../../_atoms/graphical-editor/ladder/utils/types' import { getDefaultNodeStyle, isNodeOfType } from '../../nodes' +import { + applyDynamicBlockHandleOffsets as applyDynamicBlockHandleOffsetsImpl, + inflateBlockHeightsForBranches, + maxBranchSpanWidth, + positionBranchElements as positionBranchElementsImpl, + updateRailForBranches as updateRailForBranchesImpl, +} from '../handle-branch' import { findAllParallelsDepthAndNodes, findParallelsInRung, @@ -15,6 +21,12 @@ import { } from '../utils' import { updateVariableBlockPosition } from '../variable-block' +/** + * Uniform return shape for every layout pass — keeps the orchestrator + * a simple `passes.reduce(...)` instead of bespoke wrappers per pass. + */ +type LayoutResult = { nodes: Node[]; edges: Edge[] } + /** * Change the right rail bounds based on the nodes position * @@ -24,11 +36,11 @@ import { updateVariableBlockPosition } from '../variable-block' * * @returns The new right rail node */ -export const changeRailBounds = (rung: RungLadderState, defaultBounds: [number, number]): { nodes: Node[] } => { +export const changeRailBounds = (rung: RungLadderState, defaultBounds: [number, number]): LayoutResult => { const rightRail = rung.nodes.find((node) => node.id.startsWith('right-rail')) - if (!rightRail) return { nodes: rung.nodes } + if (!rightRail) return { nodes: rung.nodes, edges: rung.edges } - const handles = rightRail.data.handles as CustomHandleProps[] + const handles = rightRail.data.handles const railStyle = getDefaultNodeStyle({ node: rightRail }) const nodesWithNoRail = rung.nodes.filter((node) => !node.id.startsWith('right-rail')) @@ -59,8 +71,7 @@ export const changeRailBounds = (rung: RungLadderState, defaultBounds: [number, }, } - const newNodes = [...nodesWithNoRail, newRail] - return { nodes: newNodes } + return { nodes: [...nodesWithNoRail, newRail], edges: rung.edges } } const newRail = { @@ -71,22 +82,39 @@ export const changeRailBounds = (rung: RungLadderState, defaultBounds: [number, handles: handles.map((handle) => ({ ...handle, x: defaultBounds[0] - railStyle.width })), }, } - const newNodes = [...nodesWithNoRail, newRail] - return { nodes: newNodes } + return { nodes: [...nodesWithNoRail, newRail], edges: rung.edges } } /** - * Update the position of the diagram elements - * - * @param rung The current rung state - * @param defaultBounds The default bounds of the rung + * Look up the OPEN parallel node whose parallel contains the given node id + * (either as a serial step on a path, or as a parallel-path entry). + * Returns the OPEN node from `parallelsDepth`, or undefined when the node + * isn't inside any parallel. + */ +const findOwningOpenForNode = ( + parallelsDepth: ReturnType[], + nodeId: string, +): Node | undefined => { + for (const parallelMap of parallelsDepth) { + for (const objectKey in parallelMap) { + const objectParallel = parallelMap[objectKey] + const inSerial = objectParallel.nodes.serial.some((n) => n.id === nodeId) + const inParallel = objectParallel.nodes.parallel.some((n) => n.id === nodeId) + if (inSerial || inParallel) return objectParallel.parallels.open + } + } + return undefined +} + +/** + * Stage 1 of the layout pipeline. Walks every node in `rung.nodes` once, + * computing its new position based on its serial / parallel predecessors and + * its enclosing parallel (if any), and rewrites its handle positions. * - * @returns The new nodes + * Returns `null` when a previous-element lookup fails — callers treat that as + * a hard abort and leave the rung unchanged. */ -export const updateDiagramElementsPosition = ( - rung: RungLadderState, - defaultBounds: [number, number], -): { nodes: Node[]; edges: Edge[] } => { +const positionMainNodes = (rung: RungLadderState): { nodes: Node[]; edges: Edge[] } | null => { const { nodes } = rung const newNodes: Node[] = [] @@ -111,7 +139,33 @@ export const updateDiagramElementsPosition = ( continue } - if (node.type === 'variable') continue + /** + * Variable nodes are derived/transient — they are removed and regenerated + * by `updateVariableBlockPosition` based on each block's `connectedVariables`. + * Pass them through unmodified here so their edges have valid endpoints + * when downstream passes (the variable-edge filter, in particular) run. + */ + if (node.type === 'variable') { + newNodes.push(node) + continue + } + + /** + * Handle-branch elements (contacts / coils / parallels with a + * `branchContext` marker) are positioned by `positionBranchElements` + * against the block handle and rail branch handle they connect, + * NOT by the main-rail predecessor walk. Pass them through unchanged. + * + * The narrow on `node.type` lets TypeScript discriminate the union to + * just the three node types that can carry `branchContext`. + */ + if ( + (node.type === 'contact' || node.type === 'coil' || node.type === 'parallel') && + node.data.branchContext + ) { + newNodes.push(node) + continue + } let newNodePosition: { posX: number; posY: number; handleX: number; handleY: number } = { posX: 0, @@ -124,21 +178,44 @@ export const updateDiagramElementsPosition = ( * Find the previous nodes and edges of the current node */ const { nodes: previousNodes, edges: previousEdges } = getPreviousElementsByEdge({ ...rung, nodes: newNodes }, node) - if (!previousNodes || !previousEdges) return { nodes: rung.nodes, edges: rung.edges } + if (!previousNodes || !previousEdges) return null + + /** + * Detect whether `prevNode` (the immediate predecessor we'll feed into + * `getNodePositionBasedOnPreviousNode`) is itself the inner side of a + * same-type parallel chain — i.e. its own predecessor on the spine is + * another parallel of the same sub-type. When `node` is also a same- + * type parallel, the call collapses onto prev's X instead of stacking + * another clearance, so a 3-deep "add parallel" doesn't funnel the + * spine 49px-per-level rightward. + */ + const isSameTypeParallelOf = (n: Node | undefined, sub: 'open' | 'close'): boolean => + !!n && isNodeOfType(n, 'parallel') && (n as ParallelNode).data.type === sub + const prevIsAlreadyNestedFor = (prev: Node): boolean => { + if (!isNodeOfType(prev, 'parallel')) return false + const prevSubType = (prev as ParallelNode).data.type + const prevPrevEdges = rung.edges.filter((e) => e.target === prev.id) + for (const e of prevPrevEdges) { + const prevPrev = newNodes.find((n) => n.id === e.source) + if (isSameTypeParallelOf(prevPrev, prevSubType)) return true + } + return false + } if (previousNodes.all.length === 1) { /** * Nodes that only have one edge connecting to them */ const previousNode = previousNodes.all[0] + const prevAlreadyNested = prevIsAlreadyNestedFor(previousNode) if ( isNodeOfType(previousNode, 'parallel') && (previousNode as ParallelNode).data.type === 'open' && previousEdges[0].sourceHandle === (previousNode as ParallelNode).data.parallelOutputConnector?.id ) { - newNodePosition = getNodePositionBasedOnPreviousNode(previousNode, node, 'parallel') + newNodePosition = getNodePositionBasedOnPreviousNode(previousNode, node, 'parallel', prevAlreadyNested) } else { - newNodePosition = getNodePositionBasedOnPreviousNode(previousNode, node, 'serial') + newNodePosition = getNodePositionBasedOnPreviousNode(previousNode, node, 'serial', prevAlreadyNested) } } else { /** @@ -157,7 +234,8 @@ export const updateDiagramElementsPosition = ( let acc = newNodePosition for (let j = 0; j < previousNodes.all.length; j++) { const previousNode = previousNodes.all[j] - const position = getNodePositionBasedOnPreviousNode(previousNode, node, 'serial') + const prevAlreadyNested = prevIsAlreadyNestedFor(previousNode) + const position = getNodePositionBasedOnPreviousNode(previousNode, node, 'serial', prevAlreadyNested) acc = { posX: Math.max(acc.posX, position.posX), posY: Math.max(acc.posY, position.posY), @@ -183,15 +261,25 @@ export const updateDiagramElementsPosition = ( const objectParallel = parallel[object] if (objectParallel.nodes.parallel.find((n) => n.id === node.id)) { foundInParallel = true + // When the top path's tallest node carries handle branches, add + // extra vertical gap before the next path so the branch's + // contacts and the path-below's element have visible breathing + // room (the verticalGap on its own only inserts a small margin + // past the FB body). + const baseVerticalGap = getDefaultNodeStyle({ node: objectParallel.highestNode }).verticalGap + const highestNodeHasBranch = rung.handleBranches.some( + (b) => b.blockId === objectParallel.highestNode.id, + ) + const verticalGap = baseVerticalGap + (highestNodeHasBranch ? 80 : 0) const newPosY = objectParallel.highestNode.position.y + objectParallel.height + - getDefaultNodeStyle({ node: objectParallel.highestNode }).verticalGap - + verticalGap - getDefaultNodeStyle({ node }).handle.y const newHandleY = objectParallel.highestNode.position.y + objectParallel.height + - getDefaultNodeStyle({ node: objectParallel.highestNode }).verticalGap + verticalGap newNodePosition = { ...newNodePosition, posY: newPosY, @@ -206,6 +294,56 @@ export const updateDiagramElementsPosition = ( } }) + /** + * Ensure the FB sits far enough right that its input branch fits between + * the FB and whatever lies to its left — the local branch rail and any + * branch contacts have to clear: + * + * - The main left rail (when the FB is the first block on the rung), + * - The FB's immediate predecessor (a contact, a CLOSE, the rail), + * - The FB's enclosing parallel's OWN OPEN (when the FB lives inside + * a parallel — the local rail must anchor PAST that OPEN's vertical + * wire, not over it). + * + * `inputShift` is the branch's full horizontal extent; pick the strictest + * required X across every constraint and shift the FB if needed. + */ + if (rung.handleBranches.length > 0 && node.type === 'block') { + const inputShift = maxBranchSpanWidth(rung, node.id, 'input') + if (inputShift > 0) { + let requiredFbX = newNodePosition.posX + + const owningOpenStale = findOwningOpenForNode(parallelsDepth, node.id) + const owningOpen = owningOpenStale + ? newNodes.find((n) => n.id === owningOpenStale.id) ?? owningOpenStale + : undefined + if (owningOpen) { + const openRight = owningOpen.position.x + (owningOpen.width ?? 0) + requiredFbX = Math.max(requiredFbX, openRight + inputShift) + } + + // Anchor against EVERY predecessor's right edge, not just parallel + // CLOSEs. Without this, an FB at the start of the rung (predecessor + // = main left rail) keeps its natural `block.gap`-derived X, which + // is narrower than the branch span — so the local rail and the + // first branch contact end up overlapping the main rail's column. + for (const prev of previousNodes.all) { + const prevFresh = newNodes.find((n) => n.id === prev.id) ?? prev + const prevRight = prevFresh.position.x + (prevFresh.width ?? 0) + requiredFbX = Math.max(requiredFbX, prevRight + inputShift) + } + + if (newNodePosition.posX < requiredFbX) { + const delta = requiredFbX - newNodePosition.posX + newNodePosition = { + ...newNodePosition, + posX: newNodePosition.posX + delta, + handleX: newNodePosition.handleX + delta, + } + } + } + } + /** * Update the handles position * based on the new node position @@ -321,18 +459,69 @@ export const updateDiagramElementsPosition = ( } } - const { nodes: changedRailNodes } = changeRailBounds( - { - ...rung, - nodes: newNodes, - }, - defaultBounds, - ) + return { nodes: newNodes, edges: rung.edges } +} - const variablesNodes = updateVariableBlockPosition({ - ...rung, - nodes: changedRailNodes, - }) +/** + * Pushes branched handles down so the rail-to-branch wire has a clear + * horizontal path below any obstacle blocks at the same Y. The block's + * height grows to accommodate. Activated for serial branches in this + * commit; Phase 4 extends the same hook to handle parallels-in-branch + * (where vertical room is needed for OR-paths instead of obstacle clearance). + */ +const applyDynamicBlockHandleOffsets = (rung: RungLadderState, _defaultBounds: [number, number]): LayoutResult => + applyDynamicBlockHandleOffsetsImpl(rung) + +/** + * Positions contact / coil nodes that hang off a block input or output handle + * (handle branches). Activated in Phase 3.C. + */ +const positionBranchElements = (rung: RungLadderState, _defaultBounds: [number, number]): LayoutResult => + positionBranchElementsImpl(rung) - return { nodes: variablesNodes.nodes, edges: variablesNodes.edges } +/** + * Syncs the dynamic `branch_*` rail handles to the latest block handle Ys. + * Activated in Phase 3.C — keeps the rail handle aligned with its block + * handle when the block moves around (e.g. another element added on the + * main rung shifts the block). + */ +const updateRailForBranches = (rung: RungLadderState, _defaultBounds: [number, number]): LayoutResult => + updateRailForBranchesImpl(rung) + +type LayoutPass = (rung: RungLadderState, defaultBounds: [number, number]) => LayoutResult + +const layoutPasses: LayoutPass[] = [ + applyDynamicBlockHandleOffsets, + positionBranchElements, + updateRailForBranches, + changeRailBounds, + updateVariableBlockPosition, +] + +/** + * Update the position of the diagram elements + * + * @param rung The current rung state + * @param defaultBounds The default bounds of the rung + * + * @returns The new nodes + */ +export const updateDiagramElementsPosition = ( + rung: RungLadderState, + defaultBounds: [number, number], +): LayoutResult => { + // Pre-pass: grow each branched block's height to enclose its branch's + // vertical extent (rail + parallel paths). `positionMainNodes` reads + // `node.height` to decide where parallel sibling paths land on Y, so this + // has to run BEFORE main-rung positioning. + const inflated = inflateBlockHeightsForBranches(rung) + const rungWithInflatedHeights = { ...rung, nodes: inflated.nodes } + + const positioned = positionMainNodes(rungWithInflatedHeights) + if (!positioned) return { nodes: rung.nodes, edges: rung.edges } + + return layoutPasses.reduce( + (acc, pass) => pass({ ...rung, ...acc }, defaultBounds), + positioned, + ) } diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/drag-n-drop/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/drag-n-drop/index.ts index 46dbfcba4..cac666bec 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/drag-n-drop/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/drag-n-drop/index.ts @@ -1,4 +1,5 @@ import type { RungLadderState } from '@root/frontend/store/slices' +import type { HandleBranch } from '@root/middleware/shared/ports/types' import type { Edge, Node, ReactFlowInstance } from '@xyflow/react' import { toInteger } from 'lodash' @@ -76,134 +77,147 @@ export const onElementDragOver = ( return searchNearestPlaceholder(rung, reactFlowInstance, position) } +type DropClassification = 'restore' | 'parallel' | 'serial' + +type DropContext = { + rung: RungLadderState + selectedPlaceholder: PlaceholderNode + selectedPlaceholderIndex: number + copycatNode: Node + draggedNode: Node + oldNodeIndex: number +} + +type DropResult = { nodes: Node[]; edges: Edge[]; handleBranches: HandleBranch[] } + /** - * Drag and drop function to stop the drag of an element and connect it to the nearest placeholder - * - * @param rung The current rung state - * @param node The node to be connected - * - * @returns The new nodes and edges + * Pick which drop scenario applies. The classifier owns the decision so each + * handler stays focused on its own state mutation. */ -export const onElementDrop = ( - rung: RungLadderState, - oldStateRung: RungLadderState, - node: Node, -): { nodes: Node[]; edges: Edge[] } => { - /** - * Find the selected placeholder - * If not found, return the old rung as it is (remove the placeholder nodes) - */ +const classifyDrop = (placeholder: PlaceholderNode, copycatNodeId: string): DropClassification => { + if (placeholder.data.relatedNode?.id === copycatNodeId) return 'restore' + if (isNodeOfType(placeholder, 'parallelPlaceholder')) return 'parallel' + return 'serial' +} + +/** + * Index passed to serial / parallel connectors must account for the soon-to-be-removed + * dragged node when it sits before the placeholder in the node list. + */ +const computeAdjustedIndex = (oldNodeIndex: number, selectedPlaceholderIndex: number): number => + oldNodeIndex < selectedPlaceholderIndex ? selectedPlaceholderIndex - 1 : selectedPlaceholderIndex + +/** + * Common setup: strip transient variable nodes, locate the placeholder + copycat, + * verify the dragged node is still in the rung. Returns null when any precondition + * fails — caller falls back to the pre-drag state. + */ +const prepareDropContext = (rung: RungLadderState, draggedNode: Node): DropContext | null => { const [selectedPlaceholderIndex, selectedPlaceholder] = Object.entries(rung.nodes).find( - (node) => (node[1].type === 'placeholder' || node[1].type === 'parallelPlaceholder') && node[1].selected, + ([, n]) => (n.type === 'placeholder' || n.type === 'parallelPlaceholder') && n.selected, ) ?? [undefined, undefined] - if (!selectedPlaceholder || !selectedPlaceholderIndex) return { nodes: oldStateRung.nodes, edges: oldStateRung.edges } + if (!selectedPlaceholder || selectedPlaceholderIndex === undefined) return null - let newNodes = [...rung.nodes] - let newEdges = [...rung.edges] + const { nodes: nodesAfterVariableCleanup, edges: edgesAfterVariableCleanup } = removeVariableBlock(rung) + let nodes = nodesAfterVariableCleanup + const edges = edgesAfterVariableCleanup - const { nodes: removedVariablesNodes, edges: removedVariablesEdges } = removeVariableBlock({ - ...rung, - nodes: newNodes, - edges: newEdges, - }) - newNodes = removedVariablesNodes - newEdges = removedVariablesEdges + const copycatNode = nodes.filter((n) => n.type !== 'variable').find((n) => n.id === `copycat_${draggedNode.id}`) + if (!copycatNode) return null - /** - * Find the copycat node - * If not found, return the old rung as it is - */ - const copycatNode = newNodes.filter((n) => n.type !== 'variable').find((n) => n.id === `copycat_${node.id}`) - if (!copycatNode) return { nodes: oldStateRung.nodes, edges: oldStateRung.edges } + const oldNodeIndex = nodes.findIndex((n) => n.id === draggedNode.id) + if (oldNodeIndex === -1) return null + nodes = nodes.filter((n) => n.id !== draggedNode.id) - /** - * Remove the old node and the copycat node - * If the old node is not found, return the old rung as it is - */ - const oldNodeIndex = newNodes.findIndex((n) => n.id === node.id) - if (oldNodeIndex === -1) return { nodes: oldStateRung.nodes, edges: oldStateRung.edges } - newNodes = newNodes.filter((n) => n.id !== node.id) - - // Check if the selected placeholder is the same as the copycat node - if ((selectedPlaceholder as PlaceholderNode).data.relatedNode?.id === copycatNode.id) { - newNodes[newNodes.indexOf(copycatNode)] = { - ...node, - id: node.id, - dragging: false, - } - newEdges.forEach((edge, index) => { - if (edge.source === copycatNode.id) { - newEdges[index] = { ...edge, source: node.id, id: edge.id.replace('copycat_', '') } - } - if (edge.target === copycatNode.id) { - newEdges[index] = { ...edge, target: node.id, id: edge.id.replace('copycat_', '') } - } - }) - // Remove the placeholder nodes - newNodes = removePlaceholderElements(newNodes) - - /** - * After adding the new element, update the diagram with the new rung - */ - const { nodes: updatedDiagramNodes, edges: updatedDiagramEdges } = updateDiagramElementsPosition( - { ...rung, nodes: newNodes, edges: newEdges }, - rung.defaultBounds as [number, number], - ) - newNodes = updatedDiagramNodes - newEdges = updatedDiagramEdges - return { nodes: newNodes, edges: newEdges } + return { + rung: { ...rung, nodes, edges }, + selectedPlaceholder: selectedPlaceholder as PlaceholderNode, + selectedPlaceholderIndex: toInteger(selectedPlaceholderIndex), + copycatNode, + draggedNode, + oldNodeIndex, } +} - /** - * Check if the selected placeholder is a parallel placeholder - * If it is, create a new parallel junction and add the new element to it - * If it is not, add the new element to the selected placeholder - */ - if (isNodeOfType(selectedPlaceholder, 'parallelPlaceholder')) { - const { nodes: parallelNodes, edges: parallelEdges } = startParallelConnection( - { - ...rung, - nodes: newNodes, - edges: newEdges, - }, - { - selected: selectedPlaceholder as PlaceholderNode, - index: - oldNodeIndex < toInteger(selectedPlaceholderIndex) - ? toInteger(selectedPlaceholderIndex) - 1 - : toInteger(selectedPlaceholderIndex), - }, - node, - ) - newEdges = parallelEdges - newNodes = parallelNodes - } else { - const { nodes: serialNodes, edges: serialEdges } = appendSerialConnection( - { - ...rung, - nodes: newNodes, - edges: newEdges, - }, - { - selected: selectedPlaceholder as PlaceholderNode, - index: - oldNodeIndex < toInteger(selectedPlaceholderIndex) - ? toInteger(selectedPlaceholderIndex) - 1 - : toInteger(selectedPlaceholderIndex), - }, - node, - ) - newEdges = serialEdges - newNodes = serialNodes - } +/** + * Drop landed on the same placeholder the dragged node started from: replace the + * copycat with the original node, restore edge ids, and re-run layout. + */ +const handleRestoreDrop = (ctx: DropContext): DropResult => { + const { rung, copycatNode, draggedNode } = ctx + const nodes = [...rung.nodes] + nodes[nodes.indexOf(copycatNode)] = { ...draggedNode, id: draggedNode.id, dragging: false } + + const edges = rung.edges.map((edge) => { + if (edge.source === copycatNode.id) { + return { ...edge, source: draggedNode.id, id: edge.id.replace('copycat_', '') } + } + if (edge.target === copycatNode.id) { + return { ...edge, target: draggedNode.id, id: edge.id.replace('copycat_', '') } + } + return edge + }) - // If the selected placeholder is not the same as the copycat node, remove the copycat node and the old one - const { nodes: removedCopycatNodes, edges: removedCopycatEdges } = removeElement( - { ...rung, nodes: newNodes, edges: newEdges }, - copycatNode, + const restoredNodes = removePlaceholderElements(nodes) + const layoutResult = updateDiagramElementsPosition( + { ...rung, nodes: restoredNodes, edges }, + rung.defaultBounds as [number, number], ) - newNodes = removedCopycatNodes - newEdges = removedCopycatEdges + return { ...layoutResult, handleBranches: rung.handleBranches } +} - return { nodes: newNodes, edges: newEdges } +/** + * Insert the dragged node into a parallel branch starting at the selected + * placeholder, then drop the copycat. `removeElement` re-runs layout for us. + */ +const handleParallelDrop = (ctx: DropContext): DropResult => { + const { rung, selectedPlaceholder, selectedPlaceholderIndex, oldNodeIndex, draggedNode, copycatNode } = ctx + const { nodes: parallelNodes, edges: parallelEdges } = startParallelConnection( + rung, + { + selected: selectedPlaceholder, + index: computeAdjustedIndex(oldNodeIndex, selectedPlaceholderIndex), + }, + draggedNode, + ) + return removeElement({ ...rung, nodes: parallelNodes, edges: parallelEdges }, copycatNode) +} + +/** + * Splice the dragged node serially at the selected placeholder, then drop the + * copycat. `removeElement` re-runs layout for us. + */ +const handleSerialDrop = (ctx: DropContext): DropResult => { + const { rung, selectedPlaceholder, selectedPlaceholderIndex, oldNodeIndex, draggedNode, copycatNode } = ctx + const { nodes: serialNodes, edges: serialEdges } = appendSerialConnection( + rung, + { + selected: selectedPlaceholder, + index: computeAdjustedIndex(oldNodeIndex, selectedPlaceholderIndex), + }, + draggedNode, + ) + return removeElement({ ...rung, nodes: serialNodes, edges: serialEdges }, copycatNode) +} + +/** + * Drag and drop function to stop the drag of an element and connect it to the nearest placeholder + * + * @param rung The current rung state + * @param node The node to be connected + * + * @returns The new nodes and edges + */ +export const onElementDrop = (rung: RungLadderState, oldStateRung: RungLadderState, node: Node): DropResult => { + const ctx = prepareDropContext(rung, node) + if (!ctx) return { nodes: oldStateRung.nodes, edges: oldStateRung.edges, handleBranches: oldStateRung.handleBranches } + + switch (classifyDrop(ctx.selectedPlaceholder, ctx.copycatNode.id)) { + case 'restore': + return handleRestoreDrop(ctx) + case 'parallel': + return handleParallelDrop(ctx) + case 'serial': + return handleSerialDrop(ctx) + } } diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/handle-branch/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/handle-branch/index.ts new file mode 100644 index 000000000..be2c93535 --- /dev/null +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/handle-branch/index.ts @@ -0,0 +1,2657 @@ +import type { RungLadderState } from '@root/frontend/store/slices' +import { newGraphicalEditorNodeID } from '@root/frontend/utils/new-graphical-editor-node-id' +import type { Edge, Node } from '@xyflow/react' + +import { + defaultCustomNodesStyles, + nodesBuilder, +} from '../../../../../../../_atoms/graphical-editor/ladder/node-builders' +import { + DEFAULT_BLOCK_CONNECTOR_Y, + DEFAULT_BLOCK_CONNECTOR_Y_OFFSET, + DEFAULT_CONTACT_BLOCK_HEIGHT, + DEFAULT_CONTACT_BLOCK_WIDTH, + DEFAULT_PLACEHOLDER_GAP, + DEFAULT_PLACEHOLDER_HEIGHT, + DEFAULT_PLACEHOLDER_WIDTH, + DEFAULT_POWER_RAIL_CONNECTOR_Y, + DEFAULT_POWER_RAIL_HEIGHT, + DEFAULT_POWER_RAIL_WIDTH, + DEFAULT_VARIABLE_HEIGHT, + DEFAULT_VARIABLE_WIDTH, +} from '../../../../../../../_atoms/graphical-editor/ladder/utils/constants' +import type { + BlockNode, + BlockVariant, + CoilNode, + ContactNode, + HandleBranch, + ParallelNode, + PowerRailNode, + VariableNode, +} from '../../../../../../../_atoms/graphical-editor/ladder/utils/types' +import { buildEdge } from '../../edges' +import { buildGenericNode } from '../../nodes' + +// ============================================================================ +// Constants +// ============================================================================ + +const BRANCH_HANDLE_PREFIX = 'branch_' + +// ============================================================================ +// Queries +// ============================================================================ + +/** + * Build the dynamic-rail handle id for a given block handle. + * + * Format: `branch_${blockId}_${handleId}`. The blockId portion contains + * underscores (e.g. `BLOCK_`); the duplicate-rung remap parses this + * format by matching against a known blockIdMap. + */ +export const buildRailBranchHandleId = (blockId: string, handleId: string): string => + `${BRANCH_HANDLE_PREFIX}${blockId}_${handleId}` + +export const isRailBranchHandleId = (handleId: string | null | undefined): boolean => + typeof handleId === 'string' && handleId.startsWith(BRANCH_HANDLE_PREFIX) + +/** + * Build the id for the standalone power-rail node that anchors a branch + * near its block. Per-branch rail (one per `(block, handle, direction)` tuple). + */ +export const buildBranchRailId = (blockId: string, handleId: string, direction: 'input' | 'output'): string => + `branch-rail-${blockId}-${handleId}-${direction}` + +/** + * Find the standalone power rail attached to a branch (the local rail piece + * sitting near the block, replacing the dynamic handle that used to live on + * the main rail). + */ +export const findBranchRail = (rung: RungLadderState, branch: HandleBranch): PowerRailNode | undefined => { + const id = buildBranchRailId(branch.blockId, branch.handleId, branch.direction) + const node = rung.nodes.find((n) => n.id === id) + return node?.type === 'powerRail' ? (node as PowerRailNode) : undefined +} + +export const hasBranchOnHandle = ( + rung: RungLadderState, + blockId: string, + handleId: string, + direction: 'input' | 'output', +): boolean => + rung.handleBranches.some((b) => b.blockId === blockId && b.handleId === handleId && b.direction === direction) + +export const getBranch = ( + rung: RungLadderState, + blockId: string, + handleId: string, + direction: 'input' | 'output', +): HandleBranch | undefined => + rung.handleBranches.find((b) => b.blockId === blockId && b.handleId === handleId && b.direction === direction) + +/** + * Branches are valid only on BOOL handles other than the rail-side primary + * (handle index 0). The primary input handle is the block's connection to + * the main rung; the primary output handle (when present) is the OUT pin. + */ +export const canPlaceElementOnHandle = (block: BlockNode, handleId: string): boolean => { + const inputIdx = block.data.inputHandles.findIndex((h) => h.id === handleId) + const outputIdx = block.data.outputHandles.findIndex((h) => h.id === handleId) + + if (inputIdx === -1 && outputIdx === -1) return false + if (inputIdx === 0 || outputIdx === 0) return false + + const handleVariable = block.data.variant.variables.find((v) => v.name === handleId) + if (!handleVariable) return false + return handleVariable.type.value.toUpperCase() === 'BOOL' +} + +/** + * Resolve the direction of a block handle by id. Returns undefined for the + * primary input handle, which is not a valid branch target. + */ +export const getHandleDirection = ( + block: BlockNode, + handleId: string, +): 'input' | 'output' | undefined => { + const inputIdx = block.data.inputHandles.findIndex((h) => h.id === handleId) + if (inputIdx > 0) return 'input' + const outputIdx = block.data.outputHandles.findIndex((h) => h.id === handleId) + if (outputIdx > 0) return 'output' + return undefined +} + +// ============================================================================ +// Mutations — rail handle +// ============================================================================ + +/** + * Build a standalone power-rail node that anchors a branch near its block. + * Replaces the previous design of adding a dynamic handle to the main rail — + * the local rail piece compacts the diagram by keeping branch wiring near + * the FB instead of spanning the whole rung horizontally. + * + * Direction follows the branch's flow: + * - input branches → rail variant 'left' (handle is `source`, on the rail's right side) + * - output branches → rail variant 'right' (handle is `target`, on the rail's left side) + * + * The rail's primary handle id is overridden to `branch__` + * so existing edge walkers find it the same way they did when the handle + * lived on the main rail. + */ +export const addRailBranchHandle = ( + rung: RungLadderState, + params: { blockId: string; handleId: string; direction: 'input' | 'output'; y: number }, +): { nodes: Node[] } => { + // Initial X placement: directly over where the existing main-rail dynamic + // handle would have lived. `positionBranchElements` reflows this in the + // next layout pass. + const block = rung.nodes.find((n) => n.id === params.blockId) + const blockHandle = + block && block.type === 'block' + ? params.direction === 'input' + ? (block as BlockNode).data.inputHandles.find((h) => h.id === params.handleId) + : (block as BlockNode).data.outputHandles.find((h) => h.id === params.handleId) + : undefined + if (!blockHandle) return { nodes: rung.nodes } + + const branchRailId = buildBranchRailId(params.blockId, params.handleId, params.direction) + if (rung.nodes.some((n) => n.id === branchRailId)) return { nodes: rung.nodes } + + // Position 200px from the block edge initially; reflowed by layout. + const initialX = + params.direction === 'input' + ? blockHandle.glbPosition.x - 200 + : blockHandle.glbPosition.x + 200 + const railY = params.y - DEFAULT_POWER_RAIL_HEIGHT / 2 + // For input branches the local rail acts as a SOURCE feeding the branch + // element — same role the LEFT main rail plays. We pass connector='right' + // to nodesBuilder.powerRail to get a left-rail-style rail (variant='left'). + const railNode = nodesBuilder.powerRail({ + id: branchRailId, + posX: initialX, + posY: railY, + connector: params.direction === 'input' ? 'right' : 'left', + handleX: params.direction === 'input' ? initialX + DEFAULT_POWER_RAIL_WIDTH : initialX, + handleY: params.y, + }) + + // Override the primary handle's id to the branch-handle pattern so edges + // and walkers find it the way they did when the handle lived on the main + // rail. Same pattern used by walkParallelPath, reconcileBranchNodeIds, + // updateRailForBranches, etc. + const branchHandleId = buildRailBranchHandleId(params.blockId, params.handleId) + railNode.data.handles[0].id = branchHandleId + if (railNode.data.inputConnector) railNode.data.inputConnector.id = branchHandleId + if (railNode.data.outputConnector) railNode.data.outputConnector.id = branchHandleId + + return { nodes: [...rung.nodes, railNode] } +} + +/** + * Reverse of `addRailBranchHandle`. Removes the standalone branch rail node + * entirely. + */ +export const removeRailBranchHandle = ( + rung: RungLadderState, + blockId: string, + handleId: string, +): { nodes: Node[] } => { + // Branch direction is encoded in the rail's id; we don't have it here, + // so try both possibilities. + const inputId = buildBranchRailId(blockId, handleId, 'input') + const outputId = buildBranchRailId(blockId, handleId, 'output') + return { nodes: rung.nodes.filter((n) => n.id !== inputId && n.id !== outputId) } +} + +// ============================================================================ +// Mutations — handleBranches index +// ============================================================================ + +export const addHandleBranch = (handleBranches: HandleBranch[], branch: HandleBranch): HandleBranch[] => { + if ( + handleBranches.some( + (b) => b.blockId === branch.blockId && b.handleId === branch.handleId && b.direction === branch.direction, + ) + ) { + return handleBranches + } + return [...handleBranches, branch] +} + +export const removeHandleBranch = ( + handleBranches: HandleBranch[], + blockId: string, + handleId: string, + direction: 'input' | 'output', +): HandleBranch[] => + handleBranches.filter((b) => !(b.blockId === blockId && b.handleId === handleId && b.direction === direction)) + +// ============================================================================ +// Placeholders — branch creation targets +// ============================================================================ + +/** + * Emit one placeholder per BOOL block handle that does not yet host a branch. + * The placeholder lives at the position of the existing Variable node on that + * handle — the user drags a toolbox element toward the variable's slot to + * create the branch in place. Tagged via `data.handleBranchTarget` so + * `addNewElement` can route to `replaceVariableWithBranch`. + * + * In-branch splice placeholders (for chaining elements inside an existing + * branch) land in Phase 3.B. + */ +export const renderHandleBranchCreationPlaceholders = (rung: RungLadderState): Node[] => { + const placeholders: Node[] = [] + + rung.nodes.forEach((node) => { + if (node.type !== 'variable') return + const variable = node + const block = rung.nodes.find((n) => n.id === variable.data.block.id) as BlockNode | undefined + if (!block) return + if (!canPlaceElementOnHandle(block, variable.data.block.handleId)) return + + const posX = variable.position.x + (DEFAULT_VARIABLE_WIDTH - DEFAULT_PLACEHOLDER_WIDTH) / 2 + const posY = variable.position.y + (DEFAULT_VARIABLE_HEIGHT - DEFAULT_PLACEHOLDER_HEIGHT) / 2 + const handleX = posX + const handleY = variable.position.y + DEFAULT_VARIABLE_HEIGHT / 2 + + const placeholder = nodesBuilder.placeholder({ + id: newGraphicalEditorNodeID(`PLACEHOLDER_BRANCH_${block.id}_${variable.data.block.handleId}`), + type: 'default', + relatedNode: variable, + position: variable.data.variant === 'input' ? 'left' : 'right', + posX, + posY, + handleX, + handleY, + }) + + placeholder.data = { + ...placeholder.data, + handleBranchTarget: { + blockId: variable.data.block.id, + handleId: variable.data.block.handleId, + direction: variable.data.variant, + }, + } + + placeholders.push(placeholder) + }) + + return placeholders +} + +// ============================================================================ +// Branch creation +// ============================================================================ + +type ReplaceVariableWithBranchParams = { + blockId: string + handleId: string + direction: 'input' | 'output' + newElementType: string + newElementVariant?: string +} + +/** + * Create a handle branch by replacing the Variable node currently attached to + * a block handle with the first branch element. Composes: + * 1. remove the Variable node and its edge + * 2. addRailBranchHandle at the block handle's current Y + * 3. build the new contact / coil tagged with `branchContext` + * 4. wire edges: + * input branch: rail.branchHandle ─→ newElement ─→ block.handle + * output branch: block.handle ─→ newElement ─→ rail.branchHandle + * 5. addHandleBranch with `nodeIds: [newElement.id]` + */ +export const replaceVariableWithBranch = ( + rung: RungLadderState, + params: ReplaceVariableWithBranchParams, +): { nodes: Node[]; edges: Edge[]; handleBranches: HandleBranch[]; newNode?: Node } => { + const block = rung.nodes.find((n) => n.id === params.blockId) as BlockNode | undefined + if (!block) return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } + + const blockHandle = + params.direction === 'input' + ? block.data.inputHandles.find((h) => h.id === params.handleId) + : block.data.outputHandles.find((h) => h.id === params.handleId) + if (!blockHandle) return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } + + // Find and drop the Variable node currently bound to this handle. + const variableNode = rung.nodes.find( + (n) => + n.type === 'variable' && + (n).data.block.id === params.blockId && + (n).data.block.handleId === params.handleId && + (n).data.variant === params.direction, + ) as VariableNode | undefined + + let workingNodes = variableNode ? rung.nodes.filter((n) => n.id !== variableNode.id) : [...rung.nodes] + let workingEdges = variableNode + ? rung.edges.filter((e) => e.source !== variableNode.id && e.target !== variableNode.id) + : [...rung.edges] + + // Add the dynamic rail handle at the block handle's Y. + const railResult = addRailBranchHandle( + { ...rung, nodes: workingNodes, edges: workingEdges }, + { + blockId: params.blockId, + handleId: params.handleId, + direction: params.direction, + y: blockHandle.glbPosition.y, + }, + ) + workingNodes = railResult.nodes + + // Build the new branch element. Wire it to the branch rail (the standalone + // local rail piece we just created near the FB), NOT the main rail. + // `positionBranchElements` reflows the X positions in the next layout pass. + const branchRailId = buildBranchRailId(params.blockId, params.handleId, params.direction) + const railNode = workingNodes.find((n) => n.id === branchRailId) as PowerRailNode | undefined + if (!railNode) return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } + + const railHandleX = railNode.data.handles[0].glbPosition.x + const blockHandleX = blockHandle.glbPosition.x + const elementX = (railHandleX + blockHandleX) / 2 - DEFAULT_CONTACT_BLOCK_WIDTH / 2 + const elementY = blockHandle.glbPosition.y - DEFAULT_CONTACT_BLOCK_HEIGHT / 2 + + const newElement = buildGenericNode({ + nodeType: params.newElementType, + id: newGraphicalEditorNodeID(params.newElementType.toUpperCase()), + posX: elementX, + posY: elementY, + handleX: elementX, + handleY: blockHandle.glbPosition.y, + }) + newElement.data = { + ...newElement.data, + branchContext: { + blockId: params.blockId, + handleId: params.handleId, + direction: params.direction, + }, + } + + workingNodes = [...workingNodes, newElement] + + // Wire the two edges that connect the new element to the rail and the block. + const railBranchHandleId = buildRailBranchHandleId(params.blockId, params.handleId) + const elementInputId = + ((newElement as ContactNode | CoilNode).data.inputConnector?.id) ?? 'input' + const elementOutputId = + ((newElement as ContactNode | CoilNode).data.outputConnector?.id) ?? 'output' + + const newEdges = + params.direction === 'input' + ? [ + buildEdge(railNode.id, newElement.id, { + sourceHandle: railBranchHandleId, + targetHandle: elementInputId, + }), + buildEdge(newElement.id, params.blockId, { + sourceHandle: elementOutputId, + targetHandle: params.handleId, + }), + ] + : [ + buildEdge(params.blockId, newElement.id, { + sourceHandle: params.handleId, + targetHandle: elementInputId, + }), + buildEdge(newElement.id, railNode.id, { + sourceHandle: elementOutputId, + targetHandle: railBranchHandleId, + }), + ] + workingEdges = [...workingEdges, ...newEdges] + + // Register the branch in the per-rung index. + const newBranch: HandleBranch = { + blockId: params.blockId, + handleId: params.handleId, + direction: params.direction, + nodeIds: [newElement.id], + } + const newHandleBranches = addHandleBranch(rung.handleBranches, newBranch) + + return { + nodes: workingNodes, + edges: workingEdges, + handleBranches: newHandleBranches, + newNode: newElement, + } +} + +// ============================================================================ +// Branch boundary resolution +// ============================================================================ + +/** + * The two endpoints of a branch's serial spine. For an input branch the + * "before" anchor is the rail and the "after" anchor is the block; for an + * output branch they're swapped. Used by insert and remove operations to + * resolve the edges that need to be re-wired around a position in the spine. + */ +type SpineAnchor = + | { kind: 'rail'; nodeId: string; handleId: string } + | { kind: 'block'; nodeId: string; handleId: string } + | { kind: 'element'; nodeId: string; inputHandleId: string; outputHandleId: string } + +const resolveBranchEndpoint = ( + rung: RungLadderState, + branch: HandleBranch, + side: 'before' | 'after', +): SpineAnchor | undefined => { + // For an input branch the spine flows rail -> block; for an output branch + // it flows block -> rail. The "before" anchor sits at the start of the + // spine, "after" sits at the end. + const railSide: 'before' | 'after' = branch.direction === 'input' ? 'before' : 'after' + + if (side === railSide) { + const rail = findBranchRail(rung, branch) + if (!rail) return undefined + return { kind: 'rail', nodeId: rail.id, handleId: buildRailBranchHandleId(branch.blockId, branch.handleId) } + } + + return { kind: 'block', nodeId: branch.blockId, handleId: branch.handleId } +} + +const resolveSpineElement = (rung: RungLadderState, nodeId: string | undefined): SpineAnchor | undefined => { + if (!nodeId) return undefined + const node = rung.nodes.find((n) => n.id === nodeId) + if (!node) return undefined + if (node.type !== 'contact' && node.type !== 'coil' && node.type !== 'parallel') return undefined + const inputHandleId = (node.data.inputConnector?.id) ?? 'input' + const outputHandleId = (node.data.outputConnector?.id) ?? 'output' + return { kind: 'element', nodeId: node.id, inputHandleId, outputHandleId } +} + +/** + * Resolve the anchor at `index` within the branch's spine. + * - 0 .. nodeIds.length-1 → the spine element at that position + * - nodeIds.length → the boundary on the "after" side + * - -1 → the boundary on the "before" side + */ +const resolveAnchorAtIndex = ( + rung: RungLadderState, + branch: HandleBranch, + index: number, +): SpineAnchor | undefined => { + if (index === -1) return resolveBranchEndpoint(rung, branch, 'before') + if (index === branch.nodeIds.length) return resolveBranchEndpoint(rung, branch, 'after') + return resolveSpineElement(rung, branch.nodeIds[index]) +} + +const anchorOutputHandle = (anchor: SpineAnchor): string => { + switch (anchor.kind) { + case 'rail': + return anchor.handleId + case 'block': + return anchor.handleId + case 'element': + return anchor.outputHandleId + } +} + +const anchorInputHandle = (anchor: SpineAnchor): string => { + switch (anchor.kind) { + case 'rail': + return anchor.handleId + case 'block': + return anchor.handleId + case 'element': + return anchor.inputHandleId + } +} + +// ============================================================================ +// Placeholders — in-branch splice targets +// ============================================================================ + +/** + * Emit one placeholder per gap in every branch's serial spine — including + * before the first element and after the last. Each placeholder carries + * `handleBranchTarget` with the `insertIndex` that `addNewElement` should + * use to route the drop to `insertIntoBranch`. + * + * Position is a rough midpoint between the surrounding anchors at the + * branch's Y. Phase 3.C's `positionBranchElements` pass will refine these + * once branch layout is fully wired; until then the rough position is good + * enough to render and click on. + */ +export const renderInBranchSplicePlaceholders = (rung: RungLadderState): Node[] => { + const placeholders: Node[] = [] + // Match the main rail's placeholder spacing (DEFAULT_PLACEHOLDER_GAP = 15). + const SIDE_GAP = DEFAULT_PLACEHOLDER_GAP + + rung.handleBranches.forEach((branch) => { + const block = rung.nodes.find((n) => n.id === branch.blockId) as BlockNode | undefined + if (!block) return + const blockHandle = + branch.direction === 'input' + ? block.data.inputHandles.find((h) => h.id === branch.handleId) + : block.data.outputHandles.find((h) => h.id === branch.handleId) + if (!blockHandle) return + + const handleY = blockHandle.glbPosition.y + const posY = handleY - DEFAULT_PLACEHOLDER_HEIGHT / 2 + + // Mirror the main rail's "left + right per contact, shared at the gap + // between adjacent contacts" model. We emit a left placeholder + // immediately to the left of every spine contact/coil and a right + // placeholder immediately to the right. When two contacts sit + // adjacent, the right of one and the left of the next end up at + // overlapping X positions (~45px apart inside the 90px gap), + // visually reading as one shared midpoint. + // + // OPEN gets a LEFT placeholder (so the user can splice a serial element + // BEFORE the parallel pair); CLOSE gets a RIGHT placeholder (splice + // AFTER the parallel pair). Mirrors how the main rail handles parallel + // boundaries. + branch.nodeIds.forEach((id, idx) => { + const node = rung.nodes.find((n) => n.id === id) + if (!node) return + + if (node.type === 'parallel') { + const ptype = (node as ParallelNode).data.type + if (ptype === 'open') { + const leftX = node.position.x - SIDE_GAP - DEFAULT_PLACEHOLDER_WIDTH / 2 + placeholders.push(buildSplicePlaceholder(branch, leftX, posY, handleY, idx, `${idx}_left`)) + } else if (ptype === 'close') { + const rightX = node.position.x + (node.width ?? 0) + SIDE_GAP - DEFAULT_PLACEHOLDER_WIDTH / 2 + placeholders.push(buildSplicePlaceholder(branch, rightX, posY, handleY, idx + 1, `${idx}_right`)) + } + return + } + + if (node.type !== 'contact' && node.type !== 'coil') return + + // Left placeholder: routes to insertIndex = idx (insert before this + // node in the spine). + const leftX = node.position.x - SIDE_GAP - DEFAULT_PLACEHOLDER_WIDTH / 2 + placeholders.push( + buildSplicePlaceholder(branch, leftX, posY, handleY, idx, `${idx}_left`), + ) + + // Right placeholder: routes to insertIndex = idx + 1 (insert after). + const rightX = node.position.x + (node.width ?? 0) + SIDE_GAP - DEFAULT_PLACEHOLDER_WIDTH / 2 + placeholders.push( + buildSplicePlaceholder(branch, rightX, posY, handleY, idx + 1, `${idx}_right`), + ) + }) + }) + + return placeholders +} + +const buildSplicePlaceholder = ( + branch: HandleBranch, + posX: number, + posY: number, + handleY: number, + insertIndex: number, + suffix: string, +): Node => { + const placeholder = nodesBuilder.placeholder({ + id: newGraphicalEditorNodeID( + `PLACEHOLDER_BRANCH_SPLICE_${branch.blockId}_${branch.handleId}_${branch.direction}_${suffix}`, + ), + type: 'default', + relatedNode: undefined, + position: 'left', + posX, + posY, + handleX: posX, + handleY, + }) + + placeholder.data = { + ...placeholder.data, + handleBranchTarget: { + blockId: branch.blockId, + handleId: branch.handleId, + direction: branch.direction, + insertIndex, + }, + } + + return placeholder +} + +// ============================================================================ +// Insert into existing branch +// ============================================================================ + +type InsertIntoBranchParams = { + blockId: string + handleId: string + direction: 'input' | 'output' + insertIndex: number + newElementType: string +} + +/** + * Splice a new contact / coil into an existing branch's serial spine at + * `insertIndex`. Removes the edge between the two surrounding anchors, + * inserts the new element, wires the two new edges, and updates the + * branch's `nodeIds` array. + */ +export const insertIntoBranch = ( + rung: RungLadderState, + params: InsertIntoBranchParams, +): { nodes: Node[]; edges: Edge[]; handleBranches: HandleBranch[]; newNode?: Node } => { + const branch = getBranch(rung, params.blockId, params.handleId, params.direction) + if (!branch) return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } + + const block = rung.nodes.find((n) => n.id === params.blockId) as BlockNode | undefined + if (!block) return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } + const blockHandle = + params.direction === 'input' + ? block.data.inputHandles.find((h) => h.id === params.handleId) + : block.data.outputHandles.find((h) => h.id === params.handleId) + if (!blockHandle) return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } + + const predecessor = resolveAnchorAtIndex(rung, branch, params.insertIndex - 1) + const successor = resolveAnchorAtIndex(rung, branch, params.insertIndex) + if (!predecessor || !successor) { + return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } + } + + // Build the new element at a temporary midpoint between predecessor's and + // successor's nodes. Phase 3.C will refine via positionBranchElements. + const predNode = rung.nodes.find((n) => n.id === predecessor.nodeId) + const succNode = rung.nodes.find((n) => n.id === successor.nodeId) + if (!predNode || !succNode) return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } + + const elementX = + (predNode.position.x + (predNode.width ?? 0) + succNode.position.x) / 2 - DEFAULT_CONTACT_BLOCK_WIDTH / 2 + const elementY = blockHandle.glbPosition.y - DEFAULT_CONTACT_BLOCK_HEIGHT / 2 + + const newElement = buildGenericNode({ + nodeType: params.newElementType, + id: newGraphicalEditorNodeID(params.newElementType.toUpperCase()), + posX: elementX, + posY: elementY, + handleX: elementX, + handleY: blockHandle.glbPosition.y, + }) + newElement.data = { + ...newElement.data, + branchContext: { + blockId: params.blockId, + handleId: params.handleId, + direction: params.direction, + }, + } + + const newElementInputId = (newElement.data.inputConnector?.id) ?? 'input' + const newElementOutputId = (newElement.data.outputConnector?.id) ?? 'output' + + // Remove the edge between the two surrounding anchors (it spans the gap + // we're about to splice into). + const newEdges = rung.edges.filter( + (e) => + !( + e.source === predecessor.nodeId && + e.sourceHandle === anchorOutputHandle(predecessor) && + e.target === successor.nodeId && + e.targetHandle === anchorInputHandle(successor) + ), + ) + + newEdges.push( + buildEdge(predecessor.nodeId, newElement.id, { + sourceHandle: anchorOutputHandle(predecessor), + targetHandle: newElementInputId, + }), + ) + newEdges.push( + buildEdge(newElement.id, successor.nodeId, { + sourceHandle: newElementOutputId, + targetHandle: anchorInputHandle(successor), + }), + ) + + const newNodes = [...rung.nodes, newElement] + + const newHandleBranches = rung.handleBranches.map((b) => { + if (b.blockId !== params.blockId || b.handleId !== params.handleId || b.direction !== params.direction) return b + const updated = [...b.nodeIds] + updated.splice(params.insertIndex, 0, newElement.id) + return { ...b, nodeIds: updated } + }) + + return { nodes: newNodes, edges: newEdges, handleBranches: newHandleBranches, newNode: newElement } +} + +// ============================================================================ +// Parallels inside branches +// ============================================================================ + +/** + * Detect whether a branch's spine contains an OPEN/CLOSE parallel pair. + * Used by layout to decide whether the branched handle's slot needs the + * extra vertical room for the parallel-path row. + */ +export const branchHasParallel = (rung: RungLadderState, branch: HandleBranch): boolean => + branch.nodeIds.some((id) => rung.nodes.find((n) => n.id === id)?.type === 'parallel') + +/** + * Count the maximum number of OR-paths across every parallel pair in a + * branch. Used by layout to size the branched handle's slot — a branch + * with one OPEN/CLOSE and three parallel paths needs 3× the parallel-row + * height in vertical space. + */ +export const branchParallelPathCount = (rung: RungLadderState, branch: HandleBranch): number => { + let maxPaths = 0 + for (const id of branch.nodeIds) { + const node = rung.nodes.find((n) => n.id === id) + if (node?.type !== 'parallel') continue + if ((node).data.type !== 'open') continue + const parallelOutputId = (node).data.parallelOutputConnector?.id + if (!parallelOutputId) continue + const paths = rung.edges.filter((e) => e.source === node.id && e.sourceHandle === parallelOutputId).length + if (paths > maxPaths) maxPaths = paths + } + return maxPaths +} + +/** + * For a spine element that sits inside an OPEN/CLOSE parallel pair, return + * the OPEN and CLOSE nodes wrapping it. Used when adding a new parallel + * path to an existing branch parallel. + */ +const getEnclosingParallelPair = ( + rung: RungLadderState, + branch: HandleBranch, + spineNodeId: string, +): { open: ParallelNode; close: ParallelNode } | undefined => { + const idx = branch.nodeIds.indexOf(spineNodeId) + if (idx === -1) return undefined + + // Walk backward to find the enclosing OPEN. + let openNode: ParallelNode | undefined + let depth = 0 + for (let i = idx - 1; i >= 0; i--) { + const n = rung.nodes.find((node) => node.id === branch.nodeIds[i]) + if (n?.type !== 'parallel') continue + const ptype = (n).data.type + if (ptype === 'close') depth++ + else if (ptype === 'open') { + if (depth === 0) { + openNode = n + break + } + depth-- + } + } + if (!openNode) return undefined + + const closeId = openNode.data.parallelCloseReference + const closeNode = rung.nodes.find((n) => n.id === closeId) + if (!closeNode || closeNode.type !== 'parallel') return undefined + return { open: openNode, close: closeNode } +} + +type AddPathToBranchParallelParams = { + blockId: string + handleId: string + direction: 'input' | 'output' + /** The spine element wrapped by the parallel pair we're adding a path to. */ + spineNodeId: string + newElementType: string +} + +/** + * Add a new OR-path to an existing in-branch parallel. Mirrors what + * `startParallelConnection` does on the main rail when the dropped-on + * element is already inside a parallel: builds a new element wired + * OPEN.parallelOutput → newElement → CLOSE.parallelInput, leaving spine + * untouched. Counted by `branchParallelPathCount` so layout grows the + * slot height accordingly. + */ +export const addPathToBranchParallel = ( + rung: RungLadderState, + params: AddPathToBranchParallelParams, +): { nodes: Node[]; edges: Edge[]; handleBranches: HandleBranch[]; newNode?: Node } => { + const branch = getBranch(rung, params.blockId, params.handleId, params.direction) + if (!branch) return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } + + const enclosing = getEnclosingParallelPair(rung, branch, params.spineNodeId) + if (!enclosing) return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } + + const block = rung.nodes.find((n) => n.id === params.blockId) as BlockNode | undefined + if (!block) return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } + const blockHandle = + params.direction === 'input' + ? block.data.inputHandles.find((h) => h.id === params.handleId) + : block.data.outputHandles.find((h) => h.id === params.handleId) + if (!blockHandle) return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } + + const branchContext = { + blockId: params.blockId, + handleId: params.handleId, + direction: params.direction, + } + + const aboveX = + rung.nodes.find((n) => n.id === params.spineNodeId)?.position.x ?? blockHandle.glbPosition.x + const handleY = blockHandle.glbPosition.y + + const newElement = buildGenericNode({ + nodeType: params.newElementType, + id: newGraphicalEditorNodeID(params.newElementType.toUpperCase()), + posX: aboveX, + posY: handleY + DEFAULT_BLOCK_CONNECTOR_Y_OFFSET, + handleX: aboveX, + handleY: handleY + DEFAULT_BLOCK_CONNECTOR_Y_OFFSET, + }) + newElement.data = { ...newElement.data, branchContext } + const inId = (newElement.data.inputConnector?.id) ?? 'input' + const outId = (newElement.data.outputConnector?.id) ?? 'output' + + const newEdges = [ + ...rung.edges, + buildEdge(enclosing.open.id, newElement.id, { + sourceHandle: enclosing.open.data.parallelOutputConnector?.id, + targetHandle: inId, + }), + buildEdge(newElement.id, enclosing.close.id, { + sourceHandle: outId, + targetHandle: enclosing.close.data.parallelInputConnector?.id, + }), + ] + + const newNodes = [...rung.nodes, newElement] + return { nodes: newNodes, edges: newEdges, handleBranches: rung.handleBranches, newNode: newElement } +} + +type StartParallelInBranchParams = { + blockId: string + handleId: string + direction: 'input' | 'output' + /** The spine element under which the user opened a bottom placeholder. */ + aboveElementId: string + newElementType: string +} + +/** + * Wrap a spine element in an OPEN/CLOSE parallel pair and add a new element + * on the parallel path. Mirrors `startParallelConnection` from the main rail + * but does NOT rebuild the above-element with a fresh ID — branches identify + * their elements via `branchContext` and the spine `nodeIds` index, both of + * which would have to be remapped if the above-element's ID changed. + * + * Topology before (input branch direction; output is symmetric): + * predecessor → aboveElement → successor + * + * Topology after: + * predecessor → OPEN ─── aboveElement ─── CLOSE → successor + * └──── newElement ─────┘ + * + * Spine `nodeIds` becomes `[..., OPEN.id, aboveElement.id, CLOSE.id, ...]` — + * OPEN/CLOSE join the spine; `aboveElement` stays where it was; `newElement` + * is on the parallel path and reachable only via edge traversal between + * OPEN and CLOSE. + */ +export const startParallelInBranch = ( + rung: RungLadderState, + params: StartParallelInBranchParams, +): { nodes: Node[]; edges: Edge[]; handleBranches: HandleBranch[]; newNode?: Node } => { + const branch = getBranch(rung, params.blockId, params.handleId, params.direction) + if (!branch) return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } + + const aboveIdx = branch.nodeIds.indexOf(params.aboveElementId) + if (aboveIdx === -1) return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } + + const aboveElement = rung.nodes.find((n) => n.id === params.aboveElementId) + if (!aboveElement) return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } + + const block = rung.nodes.find((n) => n.id === params.blockId) as BlockNode | undefined + if (!block) return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } + const blockHandle = + params.direction === 'input' + ? block.data.inputHandles.find((h) => h.id === params.handleId) + : block.data.outputHandles.find((h) => h.id === params.handleId) + if (!blockHandle) return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } + + const predecessor = resolveAnchorAtIndex(rung, branch, aboveIdx - 1) + const successor = resolveAnchorAtIndex(rung, branch, aboveIdx + 1) + if (!predecessor || !successor) { + return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } + } + + const branchContext = { + blockId: params.blockId, + handleId: params.handleId, + direction: params.direction, + } + + // Build OPEN, CLOSE, and the new parallel-path element. Initial positions + // are throwaway — `positionBranchElements` runs as part of every layout + // cycle and rewrites them. + const openId = newGraphicalEditorNodeID('PARALLEL_OPEN') + const closeId = newGraphicalEditorNodeID('PARALLEL_CLOSE') + const handleY = blockHandle.glbPosition.y + const aboveX = aboveElement.position.x + + const openParallelNode = nodesBuilder.parallel({ + id: openId, + type: 'open', + posX: aboveX - 30, + posY: handleY, + handleX: aboveX - 30, + handleY, + }) + const closeParallelNode = nodesBuilder.parallel({ + id: closeId, + type: 'close', + posX: aboveX + (aboveElement.width ?? DEFAULT_CONTACT_BLOCK_WIDTH) + 10, + posY: handleY, + handleX: aboveX + (aboveElement.width ?? DEFAULT_CONTACT_BLOCK_WIDTH) + 10, + handleY, + }) + + // Wire OPEN/CLOSE references and tag with branchContext. + openParallelNode.data = { + ...openParallelNode.data, + parallelCloseReference: closeId, + branchContext, + } + closeParallelNode.data = { + ...closeParallelNode.data, + parallelOpenReference: openId, + branchContext, + } + + const newElement = buildGenericNode({ + nodeType: params.newElementType, + id: newGraphicalEditorNodeID(params.newElementType.toUpperCase()), + posX: aboveX, + posY: handleY + DEFAULT_BLOCK_CONNECTOR_Y_OFFSET, + handleX: aboveX, + handleY: handleY + DEFAULT_BLOCK_CONNECTOR_Y_OFFSET, + }) + newElement.data = { + ...newElement.data, + branchContext, + } + const newElementInputId = (newElement.data.inputConnector?.id) ?? 'input' + const newElementOutputId = (newElement.data.outputConnector?.id) ?? 'output' + + // Drop the existing predecessor → aboveElement and aboveElement → successor + // edges; they're going to be rerouted through OPEN and CLOSE. + let newEdges = rung.edges.filter( + (e) => + !(e.source === predecessor.nodeId && e.target === aboveElement.id) && + !(e.source === aboveElement.id && e.target === successor.nodeId), + ) + + // Predecessor → OPEN (serial spine input). + newEdges = [ + ...newEdges, + buildEdge(predecessor.nodeId, openId, { + sourceHandle: anchorOutputHandle(predecessor), + targetHandle: openParallelNode.data.inputConnector?.id, + }), + // OPEN → aboveElement (serial spine output). + buildEdge(openId, aboveElement.id, { + sourceHandle: openParallelNode.data.outputConnector?.id, + targetHandle: anchorInputHandleForSpineNode(aboveElement), + }), + // aboveElement → CLOSE (serial spine input). + buildEdge(aboveElement.id, closeId, { + sourceHandle: anchorOutputHandleForSpineNode(aboveElement), + targetHandle: closeParallelNode.data.inputConnector?.id, + }), + // CLOSE → successor (serial spine output). + buildEdge(closeId, successor.nodeId, { + sourceHandle: closeParallelNode.data.outputConnector?.id, + targetHandle: anchorInputHandle(successor), + }), + // OPEN → newElement (parallel-path). + buildEdge(openId, newElement.id, { + sourceHandle: openParallelNode.data.parallelOutputConnector?.id, + targetHandle: newElementInputId, + }), + // newElement → CLOSE (parallel-path). + buildEdge(newElement.id, closeId, { + sourceHandle: newElementOutputId, + targetHandle: closeParallelNode.data.parallelInputConnector?.id, + }), + ] + + const newNodes = [...rung.nodes, openParallelNode, closeParallelNode, newElement] + + // Splice OPEN before the above-element and CLOSE after it in the spine. + const newHandleBranches = rung.handleBranches.map((b) => { + if (b !== branch) return b + const updated = [...b.nodeIds] + updated.splice(aboveIdx + 1, 0, closeId) + updated.splice(aboveIdx, 0, openId) + return { ...b, nodeIds: updated } + }) + + return { nodes: newNodes, edges: newEdges, handleBranches: newHandleBranches, newNode: newElement } +} + +/** Output handle id of a spine element node — used to wire the spine. */ +const anchorOutputHandleForSpineNode = (node: Node): string | undefined => { + if (node.type === 'parallel') { + return (node as ParallelNode).data.outputConnector?.id + } + if (node.type === 'contact' || node.type === 'coil' || node.type === 'powerRail' || node.type === 'block') { + return (node.data as { outputConnector?: { id?: string } }).outputConnector?.id + } + return undefined +} + +const anchorInputHandleForSpineNode = (node: Node): string | undefined => { + if (node.type === 'parallel') { + return (node as ParallelNode).data.inputConnector?.id + } + if (node.type === 'contact' || node.type === 'coil' || node.type === 'powerRail' || node.type === 'block') { + return (node.data as { inputConnector?: { id?: string } }).inputConnector?.id + } + return undefined +} + +// ============================================================================ +// Spine reconciliation +// ============================================================================ + +/** + * Rebuild a branch's `nodeIds` by walking the rung's edges from the + * rail-side endpoint (or the block-side, for output branches) along the + * serial-spine output handles. Used after `removeEmptyParallelConnections` + * collapses an OPEN/CLOSE pair — the topology survives but `nodeIds` may + * still reference the now-removed OPEN/CLOSE ids. + */ +export const reconcileBranchNodeIds = ( + rung: RungLadderState, + branch: HandleBranch, +): HandleBranch => { + // Pick the starting node (branch rail for input branches, block for output). + const rail = findBranchRail(rung, branch) + if (!rail) return branch + + const railHandleId = buildRailBranchHandleId(branch.blockId, branch.handleId) + + // For input branches: walk rail.branchHandle → ... → block.handleId. + // For output branches: walk block.handleId → ... → rail.branchHandle. + const startNodeId = branch.direction === 'input' ? rail.id : branch.blockId + const startHandleId = branch.direction === 'input' ? railHandleId : branch.handleId + const endNodeId = branch.direction === 'input' ? branch.blockId : rail.id + + const visited = new Set() + const newNodeIds: string[] = [] + + let currentNodeId = startNodeId + let currentSourceHandle: string | undefined = startHandleId + + // Cap iterations defensively in case of an unexpected cycle. + for (let safety = 0; safety < 1000; safety++) { + const outgoing = rung.edges.find( + (e) => e.source === currentNodeId && e.sourceHandle === currentSourceHandle, + ) + if (!outgoing) break + if (outgoing.target === endNodeId) break + if (visited.has(outgoing.target)) break + + visited.add(outgoing.target) + newNodeIds.push(outgoing.target) + + const nextNode = rung.nodes.find((n) => n.id === outgoing.target) + if (!nextNode) break + + // Walk further along this node's serial output. For parallels, that's + // `outputConnector` (the spine output, NOT parallelOutputConnector). + currentNodeId = nextNode.id + currentSourceHandle = anchorOutputHandleForSpineNode(nextNode) + } + + return { ...branch, nodeIds: newNodeIds } +} + +// ============================================================================ +// In-branch parallel placeholders +// ============================================================================ + +/** + * Emit a bottom (parallel) placeholder under every spine contact/coil. + * The drop handler decides whether this creates a brand-new OPEN/CLOSE + * pair or adds a path to an already-existing one. + * + * For elements wrapped by an existing OPEN/CLOSE pair, the placeholder is + * positioned BELOW the lowest existing parallel-path element (so dropping + * there visibly extends the parallel downward). + */ +export const renderInBranchParallelPlaceholders = (rung: RungLadderState): Node[] => { + const placeholders: Node[] = [] + + rung.handleBranches.forEach((branch) => { + // First pass: identify each spine contact's "wrapping" relationship. + // A contact directly between an OPEN and its matching CLOSE is the + // spine "above" element of that parallel — it's INSIDE a parallel and + // shouldn't get a bottom placeholder of its own (matches main rail's + // `nodesInsideParallels` rule). + const aboveContactByOpen = new Map() + const insideParallel = new Set() + let depth = 0 + let currentOpen: ParallelNode | null = null + branch.nodeIds.forEach((id) => { + const node = rung.nodes.find((n) => n.id === id) + if (node?.type === 'parallel') { + const ptype = (node as ParallelNode).data.type + if (ptype === 'open') { + depth++ + currentOpen = node as ParallelNode + } else if (ptype === 'close') { + depth-- + currentOpen = null + } + } else if (depth > 0 && (node?.type === 'contact' || node?.type === 'coil')) { + insideParallel.add(node.id) + if (currentOpen) aboveContactByOpen.set((currentOpen as ParallelNode).id, node) + } + }) + + const emitBottom = ( + anchorNode: Node, + relatedNode: Node, + insertIndex: number, + suffix: string, + ) => { + const width = anchorNode.width ?? DEFAULT_CONTACT_BLOCK_WIDTH + const posX = anchorNode.position.x + width / 2 - DEFAULT_PLACEHOLDER_WIDTH / 2 + const posY = anchorNode.position.y + (anchorNode.height ?? DEFAULT_CONTACT_BLOCK_HEIGHT) + 10 + const handleY = posY + DEFAULT_PLACEHOLDER_HEIGHT / 2 + + const placeholder = nodesBuilder.parallelPlaceholder({ + id: newGraphicalEditorNodeID( + `PLACEHOLDER_BRANCH_PARALLEL_${branch.blockId}_${branch.handleId}_${branch.direction}_${suffix}`, + ), + type: 'parallel', + relatedNode, + position: 'bottom', + posX, + posY, + handleX: posX, + handleY, + }) + + placeholder.data = { + ...placeholder.data, + handleBranchTarget: { + blockId: branch.blockId, + handleId: branch.handleId, + direction: branch.direction, + insertIndex, + }, + } + + placeholders.push(placeholder) + } + + // (1) Bottom placeholder under each spine contact NOT inside any + // parallel — drop here to wrap that contact in a new OPEN/CLOSE pair + // and add an OR-path beneath it. + branch.nodeIds.forEach((id, idx) => { + const node = rung.nodes.find((n) => n.id === id) + if (!node) return + if (node.type !== 'contact' && node.type !== 'coil') return + if (insideParallel.has(id)) return + emitBottom(node, node, idx, `spine_${idx}`) + }) + + // (2) Bottom placeholder under each contact/coil on the BOTTOM-MOST + // parallel path of every existing OPEN/CLOSE pair. Drop here to add + // a new OR-path to that parallel. The placeholder's `relatedNode` + // points back to the spine "above" contact, so the routing classifies + // this as `isInsideExistingParallel` and dispatches to + // `addPathToBranchParallel`. + branch.nodeIds.forEach((id, idx) => { + const node = rung.nodes.find((n) => n.id === id) + if (node?.type !== 'parallel') return + if ((node as ParallelNode).data.type !== 'open') return + const open = node as ParallelNode + + const aboveContact = aboveContactByOpen.get(open.id) + if (!aboveContact) return + const closeId = open.data.parallelCloseReference + const closeNode = rung.nodes.find((n) => n.id === closeId) + if (!closeNode || closeNode.type !== 'parallel') return + + const parallelOutputId = open.data.parallelOutputConnector?.id + if (!parallelOutputId) return + const startEdges = rung.edges.filter( + (e) => e.source === open.id && e.sourceHandle === parallelOutputId, + ) + if (startEdges.length === 0) return + + // Bottom-most path = last start edge (highest pathIndex). + const lastStartEdge = startEdges[startEdges.length - 1] + const bottomPath = walkParallelPath(rung, open, closeNode as ParallelNode, lastStartEdge) + const aboveContactIdx = branch.nodeIds.indexOf(aboveContact.id) + bottomPath.forEach((pNode, pIdx) => { + if (pNode.type !== 'contact' && pNode.type !== 'coil') return + emitBottom(pNode, aboveContact, aboveContactIdx, `path_${idx}_${pIdx}`) + }) + }) + }) + + return placeholders +} + +// ============================================================================ +// Parallel-path serial chains +// ============================================================================ + +/** + * Walk a single parallel path between OPEN and CLOSE following each node's + * serial output handle. Returns the elements on that path, in order from + * OPEN side to CLOSE side. Each path starts at one of OPEN's parallel-output + * edges and ends when the next edge targets CLOSE on its parallel-input + * handle. + */ +const walkParallelPath = ( + rung: RungLadderState, + _open: ParallelNode, + close: ParallelNode, + startEdge: Edge, +): Node[] => { + const path: Node[] = [] + let currentEdge: Edge | undefined = startEdge + const visited = new Set() + + for (let safety = 0; safety < 1000; safety++) { + if (!currentEdge) break + if (currentEdge.target === close.id) break + if (visited.has(currentEdge.target)) break + visited.add(currentEdge.target) + + const node = rung.nodes.find((n) => n.id === currentEdge!.target) + if (!node) break + path.push(node) + + const outId = anchorOutputHandleForSpineNode(node) + currentEdge = rung.edges.find((e) => e.source === node.id && e.sourceHandle === outId) + } + + return path +} + +/** + * Emit left/right placeholders alongside every contact/coil on a branch + * parallel path. Lets the user chain serial elements within the path. + * + * Each placeholder carries a `parallelPathSplice` descriptor identifying + * the predecessor and successor on the parallel path (which can be OPEN + * for the leftmost slot, CLOSE for the rightmost, or another path + * element). + */ +export const renderInBranchParallelPathPlaceholders = (rung: RungLadderState): Node[] => { + const placeholders: Node[] = [] + const SIDE_GAP = DEFAULT_PLACEHOLDER_GAP + + rung.handleBranches.forEach((branch) => { + branch.nodeIds.forEach((id) => { + const node = rung.nodes.find((n) => n.id === id) + if (node?.type !== 'parallel') return + if ((node as ParallelNode).data.type !== 'open') return + const open = node as ParallelNode + const closeId = open.data.parallelCloseReference + if (!closeId) return + const closeNode = rung.nodes.find((n) => n.id === closeId) + if (!closeNode || closeNode.type !== 'parallel') return + const close = closeNode as ParallelNode + + const parallelOutputId = open.data.parallelOutputConnector?.id + if (!parallelOutputId) return + const startEdges = rung.edges.filter((e) => e.source === open.id && e.sourceHandle === parallelOutputId) + + startEdges.forEach((startEdge) => { + const pathNodes = walkParallelPath(rung, open, close, startEdge) + if (pathNodes.length === 0) return + + // Same model as the spine: emit left + right placeholders adjacent + // to each path contact/coil. Adjacent path elements share a + // visual midpoint via overlapping right-of-A / left-of-B. + pathNodes.forEach((pNode, idx) => { + if (pNode.type !== 'contact' && pNode.type !== 'coil') return + + const handleY = + pNode.position.y + (pNode.height ?? DEFAULT_CONTACT_BLOCK_HEIGHT) / 2 + const posY = handleY - DEFAULT_PLACEHOLDER_HEIGHT / 2 + + // Left placeholder: predecessor is OPEN if idx === 0, else previous path node. + const leftPred = idx === 0 ? (open as Node) : pathNodes[idx - 1] + const leftX = pNode.position.x - SIDE_GAP - DEFAULT_PLACEHOLDER_WIDTH / 2 + placeholders.push( + buildPathSplicePlaceholder(branch, open.id, leftPred.id, pNode.id, leftX, posY, handleY, `${idx}_left`), + ) + + // Right placeholder: successor is CLOSE if idx is last, else next path node. + const rightSucc = idx === pathNodes.length - 1 ? (close as Node) : pathNodes[idx + 1] + const rightX = + pNode.position.x + (pNode.width ?? 0) + SIDE_GAP - DEFAULT_PLACEHOLDER_WIDTH / 2 + placeholders.push( + buildPathSplicePlaceholder( + branch, + open.id, + pNode.id, + rightSucc.id, + rightX, + posY, + handleY, + `${idx}_right`, + ), + ) + }) + }) + }) + }) + + return placeholders +} + +const buildPathSplicePlaceholder = ( + branch: HandleBranch, + parallelOpenId: string, + predecessorId: string, + successorId: string, + posX: number, + posY: number, + handleY: number, + suffix: string, +): Node => { + const placeholder = nodesBuilder.placeholder({ + id: newGraphicalEditorNodeID( + `PLACEHOLDER_BRANCH_PATH_${branch.blockId}_${branch.handleId}_${branch.direction}_${parallelOpenId}_${suffix}`, + ), + type: 'default', + relatedNode: undefined, + position: 'left', + posX, + posY, + handleX: posX, + handleY, + }) + + placeholder.data = { + ...placeholder.data, + handleBranchTarget: { + blockId: branch.blockId, + handleId: branch.handleId, + direction: branch.direction, + parallelPathSplice: { + parallelOpenId, + predecessorId, + successorId, + }, + }, + } + + return placeholder +} + +type InsertIntoBranchParallelPathParams = { + blockId: string + handleId: string + direction: 'input' | 'output' + predecessorId: string + successorId: string + newElementType: string +} + +/** + * Splice a new contact/coil into the serial chain of a parallel path. + * Removes the edge `predecessor → successor` (whether predecessor is the + * OPEN, an existing path element, etc.) and wires + * `predecessor → newElement → successor`. + */ +export const insertIntoBranchParallelPath = ( + rung: RungLadderState, + params: InsertIntoBranchParallelPathParams, +): { nodes: Node[]; edges: Edge[]; handleBranches: HandleBranch[]; newNode?: Node } => { + const branch = getBranch(rung, params.blockId, params.handleId, params.direction) + if (!branch) return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } + + const predNode = rung.nodes.find((n) => n.id === params.predecessorId) + const succNode = rung.nodes.find((n) => n.id === params.successorId) + if (!predNode || !succNode) return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } + + const oldEdge = rung.edges.find((e) => e.source === params.predecessorId && e.target === params.successorId) + if (!oldEdge) return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } + + const branchContext = { + blockId: params.blockId, + handleId: params.handleId, + direction: params.direction, + } + + // Build the new element near the path's Y, between predecessor and + // successor's X positions. Layout will refine. + const refY = predNode.position.y + (predNode.height ?? DEFAULT_CONTACT_BLOCK_HEIGHT) / 2 + const midX = (predNode.position.x + (predNode.width ?? 0) + succNode.position.x) / 2 + const newElement = buildGenericNode({ + nodeType: params.newElementType, + id: newGraphicalEditorNodeID(params.newElementType.toUpperCase()), + posX: midX - DEFAULT_CONTACT_BLOCK_WIDTH / 2, + posY: refY - DEFAULT_CONTACT_BLOCK_HEIGHT / 2, + handleX: midX - DEFAULT_CONTACT_BLOCK_WIDTH / 2, + handleY: refY, + }) + newElement.data = { ...newElement.data, branchContext } + const inId = (newElement.data.inputConnector?.id) ?? 'input' + const outId = (newElement.data.outputConnector?.id) ?? 'output' + + const newEdges = rung.edges.filter((e) => e.id !== oldEdge.id) + newEdges.push( + buildEdge(params.predecessorId, newElement.id, { + sourceHandle: oldEdge.sourceHandle ?? undefined, + targetHandle: inId, + }), + buildEdge(newElement.id, params.successorId, { + sourceHandle: outId, + targetHandle: oldEdge.targetHandle ?? undefined, + }), + ) + + return { + nodes: [...rung.nodes, newElement], + edges: newEdges, + handleBranches: rung.handleBranches, + newNode: newElement, + } +} + +// ============================================================================ +// Branch element removal + branch collapse +// ============================================================================ + +/** + * Remove the dynamic rail handle and the per-rung `handleBranches` entry for + * this branch. The Variable node restores itself on the next layout pass — + * `renderVariableBlock`'s `hasBranchOnHandle` guard returns `false` once the + * index entry is gone, so the variable slot is free to be regenerated. + */ +export const cleanupAfterBranchRemoval = ( + rung: RungLadderState, + branch: HandleBranch, +): { nodes: Node[]; edges: Edge[]; handleBranches: HandleBranch[] } => { + const railResult = removeRailBranchHandle(rung, branch.blockId, branch.handleId) + return { + nodes: railResult.nodes, + edges: rung.edges, + handleBranches: removeHandleBranch(rung.handleBranches, branch.blockId, branch.handleId, branch.direction), + } +} + +/** + * Remove a single contact / coil / parallel-path element from a branch. + * Two cases: + * - Spine element: reconnect surrounding anchors across the gap (existing + * Phase 3.B path). If the spine empties, collapse the whole branch. + * - Parallel-path element: drop the node + its edges, then collapse the + * enclosing OPEN/CLOSE pair via the standard + * `removeEmptyParallelConnections` (it already understands the topology + * since branch parallels share it with main-rail parallels). After + * collapse, `reconcileBranchNodeIds` rebuilds the spine from the live + * edge graph. + */ +export const removeBranchElement = ( + rung: RungLadderState, + element: Node, +): { nodes: Node[]; edges: Edge[]; handleBranches: HandleBranch[] } => { + if (element.type !== 'contact' && element.type !== 'coil' && element.type !== 'parallel') { + return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } + } + const ctx = element.data.branchContext + if (!ctx) return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } + + const branch = getBranch(rung, ctx.blockId, ctx.handleId, ctx.direction) + if (!branch) return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } + + const idx = branch.nodeIds.indexOf(element.id) + + // Parallel-path element (not in spine): drop the node + its edges. The + // caller (`removeElement` in elements/index.ts) collapses the now-empty + // OPEN/CLOSE pair via the standard `removeEmptyParallelConnections` and + // then calls `reconcileBranchNodeIds` to rebuild the spine. + if (idx === -1) { + const newNodes = rung.nodes.filter((n) => n.id !== element.id) + const newEdges = rung.edges.filter((e) => e.source !== element.id && e.target !== element.id) + return { nodes: newNodes, edges: newEdges, handleBranches: rung.handleBranches } + } + + // After removal the surrounding spine collapses to predecessor + successor. + const predecessor = resolveAnchorAtIndex(rung, branch, idx - 1) + const successor = resolveAnchorAtIndex(rung, branch, idx + 1) + + // Drop the element and any edges touching it. + const newNodes = rung.nodes.filter((n) => n.id !== element.id) + let newEdges = rung.edges.filter((e) => e.source !== element.id && e.target !== element.id) + + // Reconnect predecessor → successor across the gap. Skipped when the branch + // had only this one element (no other anchors to bridge); the cleanup pass + // below removes the rail handle entirely instead. + const isLastElement = branch.nodeIds.length === 1 + if (!isLastElement && predecessor && successor) { + newEdges = [ + ...newEdges, + buildEdge(predecessor.nodeId, successor.nodeId, { + sourceHandle: anchorOutputHandle(predecessor), + targetHandle: anchorInputHandle(successor), + }), + ] + } + + // Update the branch index. If the spine empties, collapse the branch. + const updatedNodeIds = branch.nodeIds.filter((id) => id !== element.id) + if (updatedNodeIds.length === 0) { + return cleanupAfterBranchRemoval({ ...rung, nodes: newNodes, edges: newEdges }, branch) + } + + const newHandleBranches = rung.handleBranches.map((b) => (b === branch ? { ...b, nodeIds: updatedNodeIds } : b)) + return { nodes: newNodes, edges: newEdges, handleBranches: newHandleBranches } +} + +/** + * Reconcile every branch's spine `nodeIds` against the live edge graph. + * Used after `removeEmptyParallelConnections` collapses an OPEN/CLOSE pair + * inside a branch — the topology survives but `nodeIds` may still + * reference the now-removed parallel nodes. + */ +export const reconcileAllBranchNodeIds = (rung: RungLadderState): HandleBranch[] => + rung.handleBranches.map((b) => reconcileBranchNodeIds(rung, b)) + +// ============================================================================ +// Reconciliation — block variant changes +// ============================================================================ + +/** + * Inspect a block-variant change and return the branches that would be + * orphaned by it. A branch is orphaned when its handle either (a) no longer + * exists in the new variant or (b) still exists but is no longer BOOL — + * the type-level constraint that allows branches to live there. + */ +export const findInvalidatedBranches = ( + rung: RungLadderState, + blockId: string, + newVariant: BlockVariant, +): HandleBranch[] => { + const newBoolHandleIds = new Set( + newVariant.variables.filter((v) => v.type.value.toUpperCase() === 'BOOL').map((v) => v.name), + ) + return rung.handleBranches.filter((b) => b.blockId === blockId && !newBoolHandleIds.has(b.handleId)) +} + +/** + * Reconcile every branch on a block whose variant changed. Two outcomes: + * + * 1. Branches whose handle disappeared in the new variant or whose handle + * is no longer BOOL get DROPPED — `removeBranchElement` collapses each + * one through the regular path (rail handle removed, index entry + * removed, edges cleaned up). + * + * 2. Surviving branches get REMAPPED. The block-variant change rebuilds + * the block with a fresh uuid; without remap, surviving branches + * keep references to the old block id and break. We update: + * - `handleBranches[].blockId` + * - `branchContext.blockId` on every spine element node + * - rail-side `branch__` handle ids + * - edges whose sourceHandle / targetHandle is a `branch_*` id + * + * Caller is expected to ask the user for confirmation first when + * `findInvalidatedBranches` returned a non-empty array — silently dropping + * branches the user didn't ask to drop is bad UX. + */ +export const reconcileBranches = ( + rung: RungLadderState, + oldBlockId: string, + newBlockId: string, + newVariant: BlockVariant, +): { nodes: Node[]; edges: Edge[]; handleBranches: HandleBranch[] } => { + // Step 1 — drop invalidated branches via the standard removal path. + const invalidated = findInvalidatedBranches(rung, oldBlockId, newVariant) + let working: { nodes: Node[]; edges: Edge[]; handleBranches: HandleBranch[] } = { + nodes: rung.nodes, + edges: rung.edges, + handleBranches: rung.handleBranches, + } + for (const branch of invalidated) { + const idsCopy = [...branch.nodeIds] + for (let i = idsCopy.length - 1; i >= 0; i--) { + const elementNode = working.nodes.find((n) => n.id === idsCopy[i]) + if (!elementNode) continue + working = removeBranchElement({ ...rung, ...working }, elementNode) + } + } + + // Step 2 — remap surviving branches' refs from oldBlockId to newBlockId. + if (oldBlockId === newBlockId) return working + + const remapHandleId = (handleId: string | null | undefined): string | null | undefined => { + if (typeof handleId !== 'string') return handleId + const prefix = `${BRANCH_HANDLE_PREFIX}${oldBlockId}_` + if (!handleId.startsWith(prefix)) return handleId + return `${BRANCH_HANDLE_PREFIX}${newBlockId}_${handleId.slice(prefix.length)}` + } + + const remappedNodes = working.nodes.map((node) => { + if (node.type === 'powerRail') { + const remapHandle = (h: T): T => ({ + ...h, + id: (remapHandleId(h.id) ?? h.id) as T['id'], + }) + return { + ...node, + data: { + ...node.data, + handles: node.data.handles.map(remapHandle), + inputHandles: node.data.inputHandles.map(remapHandle), + outputHandles: node.data.outputHandles.map(remapHandle), + }, + } + } + + if ( + (node.type === 'contact' || node.type === 'coil' || node.type === 'parallel') && + node.data.branchContext && + node.data.branchContext.blockId === oldBlockId + ) { + return { + ...node, + data: { + ...node.data, + branchContext: { ...node.data.branchContext, blockId: newBlockId }, + }, + } + } + + return node + }) + + const remappedEdges = working.edges.map((edge) => ({ + ...edge, + sourceHandle: remapHandleId(edge.sourceHandle) ?? edge.sourceHandle, + targetHandle: remapHandleId(edge.targetHandle) ?? edge.targetHandle, + })) + + const remappedHandleBranches = working.handleBranches.map((b) => + b.blockId === oldBlockId ? { ...b, blockId: newBlockId } : b, + ) + + return { + nodes: remappedNodes, + edges: remappedEdges, + handleBranches: remappedHandleBranches, + } +} + +// ============================================================================ +// Layout — dynamic block-handle offsets +// ============================================================================ + +/** + * Push branched handles DOWN within their owning block so the branch element + * doesn't visually share a row with main-rail content at the same Y. The + * block's height grows by the same offset so all subsequent handles slide + * down with the branched one. + * + * Obstacle = any main-rail node (block / contact / coil / parallel that is + * NOT itself a branch element) whose Y range covers the branched handle's + * natural Y AND that sits on the rail-side of the branched block on the X + * axis. The branched handle shifts to `obstacle.bottom + clearance` so the + * branch element renders below every obstacle. + * + * When multiple branches on the same block need different shifts, the + * largest one wins; subsequent handles all shift by that max so the block + * stays visually consistent. + */ +// Vertical buffer between the bottom of the lowest obstacle and the shifted +// branched handle. Wide enough to read as deliberate spacing rather than the +// branch element touching the obstacle below it. +const BRANCH_OBSTACLE_CLEARANCE = 50 + +// Vertical distance between the spine row and each parallel-path row inside +// a branch parallel. Wider than the main-rail block verticalGap so paths +// (and any parallel-of-parallels content within them) have room to render +// without crowding the spine or the next-row content. +const BRANCH_PARALLEL_PATH_HEIGHT = 100 + +// Slot height (vertical distance to the next handle) is constant at the +// default offset. Branch parallel-paths used to grow the slot to push +// subsequent handles down — but that compounded with +// `inflateBlockHeightsForBranches` (which grows the block bottom margin +// to enclose paths), making the FB taller than necessary and forcing +// stacked rungs to overflow into each other. The branch's path elements +// render in the branch's X span (left/right of the FB), so they don't +// need slot growth to fit between handles; only the block's overall +// vertical extent has to enclose them, which the bottom-margin growth +// handles directly. +const slotHeightForHandleIndex = ( + _rung: RungLadderState, + _block: BlockNode, + _index: number, +): number => DEFAULT_BLOCK_CONNECTOR_Y_OFFSET + +// Re-derive the natural relY for an input/output handle from its index in +// the inputHandles / outputHandles array. The natural value composes per- +// handle slot heights (which grow when a branch contains a parallel) so +// the layout pass stays idempotent regardless of prior mutations. +const naturalRelYForIndex = (rung: RungLadderState, block: BlockNode, index: number): number => { + let y = DEFAULT_BLOCK_CONNECTOR_Y + for (let i = 0; i < index; i++) { + y += slotHeightForHandleIndex(rung, block, i) + } + return y +} + +// Per-pair spacing computed from each element's style gap, matching the +// main-rail rule `previousElementStyle.gap + newElementStyle.gap`. This way +// OPEN/CLOSE (gap=0) sit tightly next to the spine element they wrap, while +// contact↔contact pairs get the full 90px breathing room. +const styleGap = (node: Node | undefined): number => { + if (!node) return 0 + const style = defaultCustomNodesStyles[node.type as keyof typeof defaultCustomNodesStyles] + return style?.gap ?? 0 +} +// Horizontal wire between the FB's branched-handle edge and the first +// branch element's near edge. Intentionally smaller than the main-rail +// `block.gap` (120): main-rail gap reserves room for an in-series neighbor +// to the FB's right, but a branch wire just connects to a side-input +// handle on the FB's left edge — a long wire there reads as wasted space. +const BRANCH_BLOCK_SIDE_GAP = 25 + +const branchGapFromBlock = (firstElement: Node | undefined): number => + BRANCH_BLOCK_SIDE_GAP + styleGap(firstElement) +// Extra horizontal gap inserted between adjacent parallel pairs in the +// spine (a CLOSE immediately followed by an OPEN). Parallel-style gap is +// 0, so without this two consecutive parallel structures would touch each +// other. Matches contact-contact spacing for visual continuity with the +// main rung's between-element rhythm. +const BRANCH_BETWEEN_PARALLELS_GAP = 2 * defaultCustomNodesStyles.contact.gap +// Same-type adjacent parallels mean nesting (an outer OPEN wrapping an +// inner OPEN, or an inner CLOSE preceding an outer CLOSE). Without this +// gap they sit only the bracket's own 4px width apart and the inner +// vertical wire visually overlaps the outer's wire. +const BRANCH_NESTED_PARALLEL_GAP = defaultCustomNodesStyles.contact.gap +const branchGapBetween = (a: Node | undefined, b: Node | undefined): number => { + if (a?.type === 'parallel' && b?.type === 'parallel') { + const aType = (a as ParallelNode).data.type + const bType = (b as ParallelNode).data.type + if (aType === 'close' && bType === 'open') return BRANCH_BETWEEN_PARALLELS_GAP + if (aType === bType) return BRANCH_NESTED_PARALLEL_GAP + } + return styleGap(a) + styleGap(b) +} + +/** + * Width of the compact branch (from FB-side gap to local rail's outer edge), + * independent of where the FB block is positioned. Used by both: + * - the main-rung positioning pass (to reserve X room beside a block with + * a branch so the branch doesn't visually collide with neighbors), and + * - `computeCompactBranchXRange` (which adds the block's current X to + * produce the absolute span). + */ +export const computeBranchSpanWidth = (rung: RungLadderState, branch: HandleBranch): number => { + const branchElements = branch.nodeIds + .map((id) => rung.nodes.find((n) => n.id === id)) + .filter((n): n is Node => n !== undefined) + if (branchElements.length === 0) return 0 + + // Block-side first element; rail-side last element. Direction-dependent. + const firstNode = + branch.direction === 'input' ? branchElements[branchElements.length - 1] : branchElements[0] + const lastNode = + branch.direction === 'input' ? branchElements[0] : branchElements[branchElements.length - 1] + + let span = branchGapFromBlock(firstNode) + for (let i = 0; i < branchElements.length; i++) { + const node = branchElements[i] + span += node.width ?? DEFAULT_CONTACT_BLOCK_WIDTH + const nextIdx = branch.direction === 'input' ? i - 1 : i + 1 + const nextNode = branchElements[nextIdx] + if (nextNode) span += branchGapBetween(node, nextNode) + } + span += defaultCustomNodesStyles.powerRail.gap + styleGap(lastNode) + DEFAULT_POWER_RAIL_WIDTH + + // For each OPEN parallel in the spine, the layout stretches the spine + // when a path between OPEN and CLOSE is wider than the spine elements + // they wrap. That extra width pushes the rail further from the FB — + // capture it here so the main-rung shift accounts for the rendered + // branch width, not just the un-stretched spine. + for (let idx = 0; idx < branchElements.length; idx++) { + const node = branchElements[idx] + if (node.type !== 'parallel' || (node as ParallelNode).data.type !== 'open') continue + const open = node as ParallelNode + const closeId = open.data.parallelCloseReference + const parallelOutputId = open.data.parallelOutputConnector?.id + if (!closeId || !parallelOutputId) continue + const close = rung.nodes.find((n) => n.id === closeId) + if (!close || close.type !== 'parallel') continue + const startEdges = rung.edges.filter( + (e) => e.source === open.id && e.sourceHandle === parallelOutputId, + ) + let maxPathWidth = 0 + for (const startEdge of startEdges) { + const pathNodes = walkParallelPath(rung, open, close as ParallelNode, startEdge) + if (pathNodes.length === 0) continue + const totalWidth = + pathNodes.reduce((sum, n) => sum + (n.width ?? DEFAULT_CONTACT_BLOCK_WIDTH), 0) + + pathNodes.length * 2 * defaultCustomNodesStyles.contact.gap + if (totalWidth > maxPathWidth) maxPathWidth = totalWidth + } + if (maxPathWidth === 0) continue + + // Walk forward from OPEN through the spine until matching CLOSE; sum + // the natural interior width the spine would occupy without + // stretching. Includes the gap from OPEN to the first inside element + // so the natural interior is comparable to the path width formula + // above (otherwise the under-counted interior over-stretches the + // spine). + let naturalInterior = 0 + let depth = 0 + let firstInside = true + for (let j = idx + 1; j < branch.nodeIds.length; j++) { + const n = rung.nodes.find((n2) => n2.id === branch.nodeIds[j]) + if (!n) break + if (n.type === 'parallel') { + const ptype = (n as ParallelNode).data.type + if (ptype === 'close' && depth === 0) break + if (ptype === 'open') depth++ + else if (ptype === 'close') depth-- + } + if (firstInside) { + naturalInterior += branchGapBetween(open, n) + firstInside = false + } + naturalInterior += n.width ?? DEFAULT_CONTACT_BLOCK_WIDTH + const next = rung.nodes.find((n2) => n2.id === branch.nodeIds[j + 1]) + if (next) naturalInterior += branchGapBetween(n, next) + } + + span += Math.max(0, maxPathWidth - naturalInterior) + } + + return span +} + +// Visible horizontal gap between the parallel's OPEN-bracket vertical +// wire and the local branch rail's outer edge. Added to the FB shift on +// top of `computeBranchSpanWidth` so the rail doesn't sit flush against +// the bracket. +const BRANCH_WIRE_WRAP_BUFFER = 10 + +/** + * Sum of input or output branch widths on a single block, plus a wire-wrap + * buffer. Used by `positionMainNodes` to push the block (input) or its + * successor (output) far enough that the branch's local rail AND any wire + * routed to/from the block clear each other on the main rung. + */ +export const maxBranchSpanWidth = ( + rung: RungLadderState, + blockId: string, + direction: 'input' | 'output', +): number => { + let max = 0 + for (const branch of rung.handleBranches) { + if (branch.blockId !== blockId) continue + if (branch.direction !== direction) continue + const span = computeBranchSpanWidth(rung, branch) + if (span > max) max = span + } + if (max === 0) return 0 + // Clamp at 0 so a (rare) tiny branch doesn't ask the FB to shift LEFT of + // its natural main-rung position. + return Math.max(0, max + BRANCH_WIRE_WRAP_BUFFER) +} + +/** + * Compute the X span the compact branch occupies (from local rail's outer + * edge to the FB's branched-handle edge). Used by obstacle detection so + * vertical adjustment only fires for elements that actually visually + * overlap with the compact branch. + * + * Returns `[branchLeft, branchRight]` in absolute coordinates. + */ +const computeCompactBranchXRange = ( + rung: RungLadderState, + branch: HandleBranch, +): [number, number] | undefined => { + const block = rung.nodes.find( + (n): n is BlockNode => n.id === branch.blockId && n.type === 'block', + ) + if (!block) return undefined + + const span = computeBranchSpanWidth(rung, branch) + if (span === 0) return undefined + + if (branch.direction === 'input') { + const right = block.position.x + return [right - span, right] + } + const left = block.position.x + (block.width ?? 0) + return [left, left + span] +} + +/** + * Compute the deepest relative-Y a branch on this block extends to, below + * the branched handle. Includes the local rail's bottom edge AND any + * parallel-path contacts that sit further below the spine. + */ +const branchBottomRelYFor = ( + rung: RungLadderState, + block: BlockNode, + handleIndex: number, + branch: HandleBranch, +): number => { + const handleRelY = naturalRelYForIndex(rung, block, handleIndex) + const railBottom = handleRelY + DEFAULT_POWER_RAIL_HEIGHT / 2 + const paths = branchParallelPathCount(rung, branch) + const pathsBottom = + paths > 0 ? handleRelY + paths * BRANCH_PARALLEL_PATH_HEIGHT + DEFAULT_CONTACT_BLOCK_HEIGHT / 2 : 0 + return Math.max(railBottom, pathsBottom) +} + +/** + * Pre-pass that runs BEFORE `positionMainNodes`: grow each branched block's + * `height` so it encloses the branch's vertical extent (rail + parallel + * paths). The parallel layout uses `node.height` to decide where the next + * path's elements sit on Y, so without this growth the next-path block + * (e.g. CTU1 below a CTU0 with a branch) would land above the branch's + * bottom and overlap. + * + * Idempotent: derives from natural relYs and parallel-path counts, never + * from the block's current `height`. + */ +export const inflateBlockHeightsForBranches = (rung: RungLadderState): { nodes: Node[]; edges: Edge[] } => { + if (rung.handleBranches.length === 0) return { nodes: rung.nodes, edges: rung.edges } + + const branchedBlockIds = new Set(rung.handleBranches.map((b) => b.blockId)) + if (branchedBlockIds.size === 0) return { nodes: rung.nodes, edges: rung.edges } + + const newNodes = rung.nodes.map((node) => { + if (node.type !== 'block') return node + if (!branchedBlockIds.has(node.id)) return node + + let maxBranchBottomRelY = 0 + for (const branch of rung.handleBranches) { + if (branch.blockId !== node.id) continue + const handlesArr = branch.direction === 'input' ? node.data.inputHandles : node.data.outputHandles + const handleIndex = handlesArr.findIndex((h) => h.id === branch.handleId) + if (handleIndex === -1) continue + const branchBottom = branchBottomRelYFor(rung, node, handleIndex, branch) + if (branchBottom > maxBranchBottomRelY) maxBranchBottomRelY = branchBottom + } + + const naturalMaxHandleRelY = naturalRelYForIndex( + rung, + node, + Math.max(node.data.inputHandles.length, node.data.outputHandles.length) - 1, + ) + const naturalHeight = naturalMaxHandleRelY + DEFAULT_BLOCK_CONNECTOR_Y + const branchRequiredHeight = maxBranchBottomRelY + DEFAULT_BLOCK_CONNECTOR_Y + + // Always reserve enough block height for the branch's vertical extent + // (rail bottom + every parallel-path row). This is what tells the + // parallel layout to push siblings (e.g. CTU1 under CTU0) below the + // branch's bottom row, so a coil sitting on the bottom path doesn't + // visually overlap the FB or its branch elements. + const requiredHeight = Math.max(naturalHeight, branchRequiredHeight) + + if (requiredHeight === node.height) return node + return { + ...node, + height: requiredHeight, + measured: { width: node.measured?.width ?? (node.width ?? 0), height: requiredHeight }, + } + }) + + return { nodes: newNodes, edges: rung.edges } +} + +export const applyDynamicBlockHandleOffsets = (rung: RungLadderState): { nodes: Node[]; edges: Edge[] } => { + if (rung.handleBranches.length === 0) return { nodes: rung.nodes, edges: rung.edges } + + const branchedBlockIds = new Set(rung.handleBranches.map((b) => b.blockId)) + if (branchedBlockIds.size === 0) return { nodes: rung.nodes, edges: rung.edges } + + // Nodes that ARE branch elements never count as obstacles — they live on + // the branch and follow whatever Y we shift the handle to. The local + // branch rails likewise follow the handle. + const branchElementIds = new Set(rung.handleBranches.flatMap((b) => b.nodeIds)) + const isObstacleCandidate = (n: Node): boolean => { + if (branchElementIds.has(n.id)) return false + if (n.type === 'powerRail') return false + if (n.type === 'placeholder' || n.type === 'parallelPlaceholder') return false + if (n.type === 'variable') return false + return n.type === 'block' || n.type === 'contact' || n.type === 'coil' || n.type === 'parallel' + } + + // For each branched block, compute the largest shift any of its branched + // handles needs to clear obstacles. Both the obstacle search and the + // applied shift are computed against natural relYs, so the pass is + // idempotent. + const blockShifts = new Map() + + for (const branch of rung.handleBranches) { + const block = rung.nodes.find( + (n): n is BlockNode => n.id === branch.blockId && n.type === 'block', + ) + if (!block) continue + + const handlesArr = branch.direction === 'input' ? block.data.inputHandles : block.data.outputHandles + const handleIndex = handlesArr.findIndex((h) => h.id === branch.handleId) + if (handleIndex === -1) continue + + const naturalRelY = naturalRelYForIndex(rung, block, handleIndex) + const naturalHandleY = block.position.y + naturalRelY + + // Vertical adjustment only considers obstacles within the compact + // branch's X span (from local rail to FB edge). Anything outside that + // span won't visually overlap with the branch elements. + const xRange = computeCompactBranchXRange(rung, branch) + if (!xRange) continue + const [branchLeft, branchRight] = xRange + + // The local rail is centered on the branched-handle Y, so its top edge + // sits `DEFAULT_POWER_RAIL_HEIGHT / 2` ABOVE that Y. Obstacle detection + // has to account for the rail too: a block whose bottom is within + // (rail half-height + clearance) of the natural handle Y would visually + // touch the rail's top, so it counts as an obstacle even if its Y range + // doesn't strictly straddle the natural handle Y. + const RAIL_HALF = DEFAULT_POWER_RAIL_HEIGHT / 2 + // Minimum vertical padding between the main-rung wire and the local + // rail's top, even when no obstacles are detected in the branch's X + // span. Without this, a branched handle's natural Y can sit so close + // to the main wire that the rail visually touches it. + const BRANCH_MIN_TOP_PADDING = 30 + let obstacleBottom = + naturalHandleY - RAIL_HALF - BRANCH_OBSTACLE_CLEARANCE + BRANCH_MIN_TOP_PADDING + for (const other of rung.nodes) { + if (other.id === block.id) continue + if (!isObstacleCandidate(other)) continue + const otherTop = other.position.y + const otherBottom = otherTop + (other.height ?? DEFAULT_CONTACT_BLOCK_HEIGHT) + // Far above (with clearance to spare) — irrelevant. + if (otherBottom + RAIL_HALF + BRANCH_OBSTACLE_CLEARANCE < naturalHandleY) continue + // Far below — irrelevant. "Close" includes elements whose top sits + // within `RAIL_HALF + clearance` of the rail's bottom (handleY + + // RAIL_HALF), so a parallel-path contact whose Y range slightly + // straddles the handle's row counts and pushes the handle past it. + if (otherTop > naturalHandleY + RAIL_HALF + BRANCH_OBSTACLE_CLEARANCE) continue + const otherLeft = other.position.x + const otherRight = otherLeft + (other.width ?? DEFAULT_CONTACT_BLOCK_WIDTH) + // X-overlap with the compact branch span (which includes the local rail). + if (otherRight <= branchLeft || otherLeft >= branchRight) continue + if (otherBottom > obstacleBottom) obstacleBottom = otherBottom + } + + // Shift target: rail's top edge sits `BRANCH_OBSTACLE_CLEARANCE` below + // the lowest obstacle's bottom edge. Equivalent to: + // shifted handle Y = obstacleBottom + clearance + RAIL_HALF + const shift = obstacleBottom + BRANCH_OBSTACLE_CLEARANCE + RAIL_HALF - naturalHandleY + if (shift <= 0) continue + + const existing = blockShifts.get(block.id) + if (!existing || shift > existing.shift) { + blockShifts.set(block.id, { firstAffectedNaturalRelY: naturalRelY, shift }) + } else if (naturalRelY < existing.firstAffectedNaturalRelY) { + blockShifts.set(block.id, { firstAffectedNaturalRelY: naturalRelY, shift: existing.shift }) + } + } + + const newNodes = rung.nodes.map((node) => { + if (node.type !== 'block') return node + if (!branchedBlockIds.has(node.id)) return node + + const entry = blockShifts.get(node.id) + + const rewriteHandlesArray = }>( + arr: readonly T[], + ): T[] => + arr.map((h, index) => { + const naturalRelY = naturalRelYForIndex(rung, node, index) + const shifted = + entry && naturalRelY >= entry.firstAffectedNaturalRelY ? naturalRelY + entry.shift : naturalRelY + return { + ...h, + glbPosition: { x: h.glbPosition.x, y: node.position.y + shifted }, + relPosition: { x: h.relPosition.x, y: shifted }, + style: { ...(h.style ?? {}), top: shifted }, + } + }) + + const newInputHandles = rewriteHandlesArray(node.data.inputHandles) + const newOutputHandles = rewriteHandlesArray(node.data.outputHandles) + const newHandles = [...newInputHandles, ...newOutputHandles] + + const maxHandleRelY = Math.max( + ...newInputHandles.map((h) => h.relPosition.y), + ...newOutputHandles.map((h) => h.relPosition.y), + 0, + ) + + // Each handle branch on this block has a vertical footprint below its + // branched handle: + // - the local rail extends `DEFAULT_POWER_RAIL_HEIGHT / 2` below the + // handle Y (rail centered on handle Y). + // - parallel paths inside the branch sit at handle Y + + // `i * BRANCH_PARALLEL_PATH_HEIGHT`, with the deepest path's contact + // extending `DEFAULT_CONTACT_BLOCK_HEIGHT / 2` below its centerline. + // + // Block height has to grow to enclose the deepest of these footprints, + // otherwise the block's natural bottom-margin caps the parallel layout's + // height and the next-path element (e.g. a sibling block in a parallel) + // overlaps the branch. + let maxBranchBottomRelY = 0 + for (const branch of rung.handleBranches) { + if (branch.blockId !== node.id) continue + const handlesArr = branch.direction === 'input' ? newInputHandles : newOutputHandles + const handle = handlesArr.find((h) => h.id === branch.handleId) + if (!handle) continue + const handleRelY = handle.relPosition.y + const railBottom = handleRelY + DEFAULT_POWER_RAIL_HEIGHT / 2 + const paths = branchParallelPathCount(rung, branch) + const pathsBottom = + paths > 0 + ? handleRelY + paths * BRANCH_PARALLEL_PATH_HEIGHT + DEFAULT_CONTACT_BLOCK_HEIGHT / 2 + : 0 + const branchBottom = Math.max(railBottom, pathsBottom) + if (branchBottom > maxBranchBottomRelY) maxBranchBottomRelY = branchBottom + } + + const handleBasedHeight = maxHandleRelY + DEFAULT_BLOCK_CONNECTOR_Y + const branchRequiredHeight = maxBranchBottomRelY + DEFAULT_BLOCK_CONNECTOR_Y + const newHeight = Math.max(handleBasedHeight, branchRequiredHeight) + + return { + ...node, + height: newHeight, + measured: { width: node.measured?.width ?? (node.width ?? 0), height: newHeight }, + data: { + ...node.data, + handles: newHandles, + inputHandles: newInputHandles, + outputHandles: newOutputHandles, + inputConnector: newInputHandles.find((h) => h.id === node.data.inputConnector?.id), + outputConnector: newOutputHandles.find((h) => h.id === node.data.outputConnector?.id), + }, + } + }) + + return { nodes: newNodes, edges: rung.edges } +} + +// ============================================================================ +// Layout — branch element positioning + rail-Y sync +// ============================================================================ + +type BranchElementPosition = { + posX: number + posY: number + handleX: number + handleY: number +} + +/** + * Compute the on-screen position for every element in a branch's serial + * spine. Elements stack tightly NEAR the block (input branches → to the left, + * output branches → to the right), all sitting at the block handle's current + * Y. Compact placement keeps the rest of the rung's X span clear for main- + * rail wires. + * + * Spine order (`nodeIds`): + * - input branches: rail-side → … → block-side (last entry is closest to block) + * - output branches: block-side → … → rail-side (first entry is closest to block) + * + * In both cases we anchor at the block's edge and walk outward. + */ +export const calculateBranchElementPositions = ( + rung: RungLadderState, + branch: HandleBranch, +): Map => { + const positions = new Map() + + const block = rung.nodes.find((n) => n.id === branch.blockId) as BlockNode | undefined + if (!block) return positions + const blockHandle = + branch.direction === 'input' + ? block.data.inputHandles.find((h) => h.id === branch.handleId) + : block.data.outputHandles.find((h) => h.id === branch.handleId) + if (!blockHandle) return positions + + const branchElements = branch.nodeIds + .map((id) => rung.nodes.find((n) => n.id === id)) + .filter((n): n is Node => n !== undefined) + if (branchElements.length === 0) return positions + + const blockHandleX = blockHandle.glbPosition.x + const blockHandleY = blockHandle.glbPosition.y + + // Pre-compute the minimum width each spine element needs to occupy. For a + // regular contact/coil this is just its node width; for a parallel + // OPEN/CLOSE pair, the spine must reserve enough horizontal room for + // either the spine "above" element or the longest parallel path, + // whichever is wider — so paths longer than the wrapped spine element + // don't get squeezed into a too-narrow OPEN/CLOSE span. + const spineSpanFor = (idx: number, node: Node): number => { + if (node.type !== 'parallel' || (node as ParallelNode).data.type !== 'open') return 0 + // For an OPEN, the "interior" between OPEN and CLOSE is the spine + // entries between them; default span comes from those. We override only + // when a parallel path is wider. + const open = node as ParallelNode + const closeId = open.data.parallelCloseReference + const parallelOutputId = open.data.parallelOutputConnector?.id + if (!closeId || !parallelOutputId) return 0 + const startEdges = rung.edges.filter((e) => e.source === open.id && e.sourceHandle === parallelOutputId) + let maxPathWidth = 0 + startEdges.forEach((startEdge) => { + const close = rung.nodes.find((n) => n.id === closeId) + if (!close || close.type !== 'parallel') return + const pathNodes = walkParallelPath(rung, open, close as ParallelNode, startEdge) + if (pathNodes.length === 0) return + // Path width = element widths + n*2*contact.gap. The 2*contact.gap + // factor matches what the spine charges per element-pair (45 contact + // gap on each side of every contact), so a path with the same + // element count as the spine inside OPEN/CLOSE comes out exactly + // equal in width — no stretch needed when the parallel just mirrors + // a single spine element. + const totalWidth = + pathNodes.reduce((sum, n) => sum + (n.width ?? DEFAULT_CONTACT_BLOCK_WIDTH), 0) + + pathNodes.length * 2 * defaultCustomNodesStyles.contact.gap + if (totalWidth > maxPathWidth) maxPathWidth = totalWidth + }) + if (maxPathWidth === 0) return 0 + // Sum the "natural" interior the spine entries between OPEN and CLOSE + // would occupy: gap(OPEN, first inside) + each inside element's width + // + gap to its next neighbor (which includes gap to CLOSE for the last + // inside element). Without the OPEN-side gap the interior would be + // under-counted by ~45px, making the stretch over-aggressive. + let naturalInterior = 0 + let depth = 0 + let firstInside = true + for (let j = idx + 1; j < branch.nodeIds.length; j++) { + const n = rung.nodes.find((n2) => n2.id === branch.nodeIds[j]) + if (!n) break + if (n.type === 'parallel') { + const ptype = (n as ParallelNode).data.type + if (ptype === 'close' && depth === 0) break + if (ptype === 'open') depth++ + else if (ptype === 'close') depth-- + } + if (firstInside) { + naturalInterior += branchGapBetween(open, n) + firstInside = false + } + naturalInterior += (n.width ?? DEFAULT_CONTACT_BLOCK_WIDTH) + const next = rung.nodes.find((n2) => n2.id === branch.nodeIds[j + 1]) + if (next) naturalInterior += branchGapBetween(n, next) + } + return Math.max(0, maxPathWidth - naturalInterior) + } + + // For each parallel pair, compute the extra spine span needed when the + // path width exceeds what aboveContact alone would naturally span. We + // distribute the extra equally on BOTH sides of aboveContact so the spine + // "above" element stays centered between OPEN and CLOSE — otherwise the + // path layout (which centers in the OPEN-CLOSE span) ends up at a + // different X than aboveContact. + // + // Map keyed by spine index of OPEN/CLOSE: the gap on each side gets + // half of the OPEN's extra. Walk forward to mark CLOSE for the same OPEN. + const halfExtraPerEdge = new Map() + branchElements.forEach((node, idx) => { + if (node.type !== 'parallel') return + if ((node as ParallelNode).data.type !== 'open') return + const extra = spineSpanFor(idx, node) + if (extra <= 0) return + const half = extra / 2 + // Edge from OPEN to its successor in the spine: half goes here. + halfExtraPerEdge.set(idx, half) + // Edge from CLOSE's predecessor to CLOSE: half goes here. + let depth = 1 + for (let j = idx + 1; j < branchElements.length; j++) { + const n = branchElements[j] + if (n.type === 'parallel') { + const ptype = (n as ParallelNode).data.type + if (ptype === 'open') depth++ + else if (ptype === 'close') { + depth-- + if (depth === 0) { + // Edge index = j - 1 (between branchElements[j-1] and branchElements[j]). + halfExtraPerEdge.set(j - 1, (halfExtraPerEdge.get(j - 1) ?? 0) + half) + break + } + } + } + } + }) + + let leftmostElementX: number | undefined + let rightmostElementRight: number | undefined + + if (branch.direction === 'input') { + // Input branch: walk LEFT from the block handle. Place last spine entry + // (closest to block) first; each previous element sits to its left with + // the per-pair gap derived from each element's style gap. + const lastNode = branchElements[branchElements.length - 1] + let rightEdge = blockHandleX - branchGapFromBlock(lastNode) + for (let i = branchElements.length - 1; i >= 0; i--) { + const node = branchElements[i] + const width = node.width ?? DEFAULT_CONTACT_BLOCK_WIDTH + const height = node.height ?? DEFAULT_CONTACT_BLOCK_HEIGHT + const leftEdge = rightEdge - width + positions.set(node.id, { + posX: leftEdge, + posY: blockHandleY - height / 2, + handleX: leftEdge, + handleY: blockHandleY, + }) + if (i === 0) leftmostElementX = leftEdge + const prev = branchElements[i - 1] + // Edge index between prev (i-1) and node (i) is i-1. + const extraGap = halfExtraPerEdge.get(i - 1) ?? 0 + rightEdge = leftEdge - branchGapBetween(prev, node) - extraGap + } + } else { + // Output branch: walk RIGHT from the block handle. Place first spine + // entry (closest to block) first; each next element sits to its right. + const firstNode = branchElements[0] + let leftEdge = blockHandleX + branchGapFromBlock(firstNode) + for (let i = 0; i < branchElements.length; i++) { + const node = branchElements[i] + const width = node.width ?? DEFAULT_CONTACT_BLOCK_WIDTH + const height = node.height ?? DEFAULT_CONTACT_BLOCK_HEIGHT + positions.set(node.id, { + posX: leftEdge, + posY: blockHandleY - height / 2, + handleX: leftEdge, + handleY: blockHandleY, + }) + const next = branchElements[i + 1] + // Edge index between node (i) and next (i+1) is i. + const extraGap = halfExtraPerEdge.get(i) ?? 0 + leftEdge = leftEdge + width + branchGapBetween(node, next) + extraGap + if (i === branchElements.length - 1) rightmostElementRight = leftEdge - extraGap - branchGapBetween(node, next) + width + } + } + + // Position the standalone branch rail compactly: the local rail always + // sits just past the outermost branch element on the rail side, never + // snapping to the main rail. Vertical adjustment in + // `applyDynamicBlockHandleOffsets` shifts the branched handle past + // obstacles within the compact branch's X span, so the short rail-to- + // contact wire is always obstacle-free. + const branchRail = findBranchRail(rung, branch) + if (branchRail) { + const railGap = defaultCustomNodesStyles.powerRail.gap + styleGap(branchElements[0]) + const railX = + branch.direction === 'input' && leftmostElementX !== undefined + ? leftmostElementX - railGap - DEFAULT_POWER_RAIL_WIDTH + : branch.direction === 'output' && rightmostElementRight !== undefined + ? rightmostElementRight + railGap + : branchRail.position.x + + const railY = blockHandleY - DEFAULT_POWER_RAIL_HEIGHT / 2 + positions.set(branchRail.id, { + posX: railX, + posY: railY, + handleX: branch.direction === 'input' ? railX + DEFAULT_POWER_RAIL_WIDTH : railX, + handleY: blockHandleY, + }) + } + + // Position parallel-path elements. For each OPEN/CLOSE pair in the spine, + // walk each parallel-output edge to get the path's serial chain and + // place its elements stacked horizontally between OPEN.X and CLOSE.X, + // with each path stacked vertically (one row per OR-path). + for (let i = 0; i < branch.nodeIds.length; i++) { + const node = rung.nodes.find((n) => n.id === branch.nodeIds[i]) + if (!node || node.type !== 'parallel') continue + if ((node).data.type !== 'open') continue + const open = node + + const closeId = open.data.parallelCloseReference + const closeNode = rung.nodes.find((n) => n.id === closeId) + if (!closeNode || closeNode.type !== 'parallel') continue + const close = closeNode + + const openPos = positions.get(open.id) + const closePos = positions.get(close.id) + if (!openPos || !closePos) continue + + const parallelOutputId = open.data.parallelOutputConnector?.id + if (!parallelOutputId) continue + const startEdges = rung.edges.filter((e) => e.source === open.id && e.sourceHandle === parallelOutputId) + + // Compute the X span available for path elements (between OPEN's right + // edge and CLOSE's left edge — assume those are the OPEN and CLOSE + // graphical edges, accounting for direction). + const leftBoundary = + branch.direction === 'input' + ? Math.min(openPos.posX, closePos.posX) + (open.width ?? 4) + : Math.min(openPos.posX, closePos.posX) + (close.width ?? 4) + const rightBoundary = + branch.direction === 'input' + ? Math.max(openPos.posX, closePos.posX) + : Math.max(openPos.posX, closePos.posX) + + startEdges.forEach((startEdge, pathIndex) => { + const pathNodes = walkParallelPath(rung, open, close, startEdge) + if (pathNodes.length === 0) return + + const pathY = blockHandleY + (pathIndex + 1) * BRANCH_PARALLEL_PATH_HEIGHT + + const totalSpan = Math.max(0, rightBoundary - leftBoundary) + const totalElementWidth = pathNodes.reduce((sum, n) => sum + (n.width ?? DEFAULT_CONTACT_BLOCK_WIDTH), 0) + const slots = pathNodes.length + 1 + const gapPerSlot = (totalSpan - totalElementWidth) / slots + + let cursor = leftBoundary + gapPerSlot + pathNodes.forEach((pNode) => { + const width = pNode.width ?? DEFAULT_CONTACT_BLOCK_WIDTH + const height = pNode.height ?? DEFAULT_CONTACT_BLOCK_HEIGHT + positions.set(pNode.id, { + posX: cursor, + posY: pathY - height / 2, + handleX: cursor, + handleY: pathY, + }) + cursor += width + gapPerSlot + }) + }) + } + + return positions +} + +/** + * Layout pass: rewrite every branch element's position (and the glbPositions + * of its input / output handles) to match `calculateBranchElementPositions`. + * Runs after `positionMainNodes` has settled the blocks' final positions. + */ +export const positionBranchElements = (rung: RungLadderState): { nodes: Node[]; edges: Edge[] } => { + // Build a single id → position map across every branch in the rung so the + // node-rewrite below stays a one-pass map without quadratic lookups. + const branchPositions = new Map() + for (const branch of rung.handleBranches) { + const positions = calculateBranchElementPositions(rung, branch) + positions.forEach((pos, id) => branchPositions.set(id, pos)) + } + + if (branchPositions.size === 0) return { nodes: rung.nodes, edges: rung.edges } + + const newNodes = rung.nodes.map((node) => { + const pos = branchPositions.get(node.id) + if (!pos) return node + + // Standalone branch rail (its id matches our prefix). Reposition the + // rail box and its single handle (glbPosition tracks the block handle Y). + if (node.type === 'powerRail' && node.id.startsWith('branch-rail-')) { + const newHandles = node.data.handles.map((h) => ({ + ...h, + glbPosition: { x: pos.handleX, y: pos.handleY }, + })) + return { + ...node, + position: { x: pos.posX, y: pos.posY }, + data: { + ...node.data, + handles: newHandles, + inputHandles: node.data.variant === 'right' ? newHandles : node.data.inputHandles, + outputHandles: node.data.variant === 'left' ? newHandles : node.data.outputHandles, + inputConnector: node.data.variant === 'right' ? newHandles[0] : node.data.inputConnector, + outputConnector: node.data.variant === 'left' ? newHandles[0] : node.data.outputConnector, + }, + } + } + + if (node.type !== 'contact' && node.type !== 'coil' && node.type !== 'parallel') return node + + // Rewrite the element's own position and every handle's glbPosition so + // ReactFlow renders the element at the new spot AND so edges (which + // route via handle glbPositions) follow. + const newHandles = node.data.handles.map((h) => ({ + ...h, + glbPosition: { + x: h.relPosition.x === 0 ? pos.handleX : pos.handleX + (node.width ?? DEFAULT_CONTACT_BLOCK_WIDTH), + y: pos.handleY, + }, + })) + const newInputHandles = node.data.inputHandles.map((h) => ({ + ...h, + glbPosition: { x: pos.handleX, y: pos.handleY }, + })) + const newOutputHandles = node.data.outputHandles.map((h) => ({ + ...h, + glbPosition: { x: pos.handleX + (node.width ?? DEFAULT_CONTACT_BLOCK_WIDTH), y: pos.handleY }, + })) + + return { + ...node, + position: { x: pos.posX, y: pos.posY }, + data: { + ...node.data, + handles: newHandles, + inputHandles: newInputHandles, + outputHandles: newOutputHandles, + inputConnector: newInputHandles.find((h) => h.id === node.data.inputConnector?.id), + outputConnector: newOutputHandles.find((h) => h.id === node.data.outputConnector?.id), + }, + } + }) + + return { nodes: newNodes, edges: rung.edges } +} + +/** + * Layout pass: keep each rail's dynamic branch handle aligned with the Y of + * the block handle it serves. The rail handle's Y was set when the branch + * was first created; if the block has since moved (e.g. another element was + * added to the main rung and shifted the block right + recomputed its + * handle Ys), the rail handle would drift out of sync without this pass. + */ +export const updateRailForBranches = (rung: RungLadderState): { nodes: Node[]; edges: Edge[] } => { + if (rung.handleBranches.length === 0) return { nodes: rung.nodes, edges: rung.edges } + + // Build a `branchHandleId -> targetGlbY` map so the rail-rewrite below is + // a single pass over the rung's nodes. + const targetYs = new Map() + for (const branch of rung.handleBranches) { + const block = rung.nodes.find((n) => n.id === branch.blockId) as BlockNode | undefined + if (!block) continue + const blockHandle = + branch.direction === 'input' + ? block.data.inputHandles.find((h) => h.id === branch.handleId) + : block.data.outputHandles.find((h) => h.id === branch.handleId) + if (!blockHandle) continue + targetYs.set(buildRailBranchHandleId(branch.blockId, branch.handleId), blockHandle.glbPosition.y) + } + + if (targetYs.size === 0) return { nodes: rung.nodes, edges: rung.edges } + + const syncHandle = }>( + handle: T, + railY: number, + ): T => { + const targetY = typeof handle.id === 'string' ? targetYs.get(handle.id) : undefined + if (targetY === undefined) return handle + const yRel = targetY - railY + return { + ...handle, + glbPosition: { x: handle.glbPosition.x, y: targetY }, + relPosition: { x: handle.relPosition.x, y: yRel }, + style: { ...(handle.style ?? {}), top: yRel }, + } + } + + const newNodes = rung.nodes.map((node) => { + if (node.type !== 'powerRail') return node + const rail = node + const railY = rail.position.y + const updatedHandles = rail.data.handles.map((h) => syncHandle(h, railY)) + + // Recompute rail height to encompass every branch handle's relY. After + // applyDynamicBlockHandleOffsets shifts a block handle down, the rail + // handle's relY grows past the rail's previous height and the handle + // would render outside the rail's bounds — short-circuiting ReactFlow's + // edge endpoint detection and producing a bent wire. + const branchExtent = updatedHandles + .filter((h) => isRailBranchHandleId(h.id)) + .reduce((max, h) => Math.max(max, h.relPosition.y), 0) + const newHeight = Math.max(DEFAULT_POWER_RAIL_HEIGHT, branchExtent + DEFAULT_POWER_RAIL_CONNECTOR_Y) + + return { + ...rail, + height: newHeight, + measured: { width: rail.measured?.width ?? DEFAULT_POWER_RAIL_WIDTH, height: newHeight }, + data: { + ...rail.data, + handles: updatedHandles, + inputHandles: rail.data.variant === 'right' ? updatedHandles : rail.data.inputHandles, + outputHandles: rail.data.variant === 'left' ? updatedHandles : rail.data.outputHandles, + }, + } + }) + + return { nodes: newNodes, edges: rung.edges } +} + +/** + * Walk every branch on a given block and remove every element. Used by the + * main element-removal path when a block is deleted: the branches must + * collapse before (or as part of) the block's own removal so they don't + * leave orphan branch elements with edges to a non-existent block. + */ +export const removeAllBranchesForBlock = ( + rung: RungLadderState, + blockId: string, +): { nodes: Node[]; edges: Edge[]; handleBranches: HandleBranch[] } => { + let workingRung: { nodes: Node[]; edges: Edge[]; handleBranches: HandleBranch[] } = { + nodes: rung.nodes, + edges: rung.edges, + handleBranches: rung.handleBranches, + } + + const branchesForBlock = rung.handleBranches.filter((b) => b.blockId === blockId) + for (const branch of branchesForBlock) { + // Walk in reverse so each removeBranchElement still finds the branch in + // workingRung.handleBranches with a non-empty `nodeIds` array. + const idsCopy = [...branch.nodeIds] + for (let i = idsCopy.length - 1; i >= 0; i--) { + const nodeId = idsCopy[i] + const elementNode = workingRung.nodes.find((n) => n.id === nodeId) + if (!elementNode) continue + const result = removeBranchElement({ ...rung, ...workingRung }, elementNode) + workingRung = result + } + } + + return workingRung +} diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/index.ts index ce0dece9d..82574de81 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/index.ts @@ -1,10 +1,23 @@ import type { RungLadderState } from '@root/frontend/store/slices' +import type { HandleBranch } from '@root/middleware/shared/ports/types' import type { Edge, Node } from '@xyflow/react' import type { BasicNodeData, PlaceholderNode } from '../../../../../../_atoms/graphical-editor/ladder/utils/types' +import { toast } from '../../../../../../_features/[app]/toast/use-toast' import { disconnectNodes } from '../edges' import { isNodeOfType, removeNode } from '../nodes' import { updateDiagramElementsPosition } from './diagram' +import { + addPathToBranchParallel, + getBranch, + insertIntoBranch, + insertIntoBranchParallelPath, + reconcileAllBranchNodeIds, + removeAllBranchesForBlock, + removeBranchElement, + replaceVariableWithBranch, + startParallelInBranch, +} from './handle-branch' import { removeEmptyParallelConnections } from './parallel' import { startParallelConnection } from './parallel' import { removePlaceholderElements } from './placeholder' @@ -18,7 +31,7 @@ export const addNewElement = ( blockVariant?: T } | Node, -): { nodes: Node[]; edges: Edge[]; newNode?: Node } => { +): { nodes: Node[]; edges: Edge[]; handleBranches: HandleBranch[]; newNode?: Node } => { let newNodeData: Node | undefined let newNodes = [...rung.nodes] let newEdges = [...rung.edges] @@ -31,7 +44,131 @@ export const addNewElement = ( (node) => (node[1].type === 'placeholder' || node[1].type === 'parallelPlaceholder') && node[1].selected, ) ?? [undefined, undefined] if (!selectedPlaceholder || !selectedPlaceholderIndex) - return { nodes: removePlaceholderElements(rung.nodes), edges: rung.edges } + return { nodes: removePlaceholderElements(rung.nodes), edges: rung.edges, handleBranches: rung.handleBranches } + + /** + * Handle-branch placeholder routing. Three flavors: + * - `handleBranchTarget` without `insertIndex` AND default placeholder + * type → "create" placeholder over the existing Variable node slot. + * Routes to `replaceVariableWithBranch`. + * - `handleBranchTarget` with `insertIndex` AND default placeholder type + * → "splice" placeholder inside an existing branch's serial spine. + * Routes to `insertIntoBranch`. + * - `handleBranchTarget` with `insertIndex` AND `parallelPlaceholder` + * type → "parallel-create" placeholder under a spine element. Routes + * to `startParallelInBranch`. The placeholder's `relatedNode` is the + * spine element being parallelized. + */ + const branchTarget = (selectedPlaceholder as PlaceholderNode).data.handleBranchTarget + if (branchTarget) { + if (typeof newNode !== 'object' || !('elementType' in newNode)) { + return { nodes: removePlaceholderElements(rung.nodes), edges: rung.edges, handleBranches: rung.handleBranches } + } + + // Function blocks aren't supported inside handle branches — branch + // elements must be single-handle (contacts / coils). Reject the drop + // with a toast and clear the placeholders. + if (newNode.elementType === 'block') { + toast({ + title: 'Cannot add block to handle branch', + description: 'Handle branches only accept contacts and coils.', + variant: 'fail', + }) + return { nodes: removePlaceholderElements(rung.nodes), edges: rung.edges, handleBranches: rung.handleBranches } + } + + const isParallelInBranch = selectedPlaceholder.type === 'parallelPlaceholder' + const aboveElementId = isParallelInBranch + ? ((selectedPlaceholder).data.relatedNode?.id ?? '') + : '' + + // If the parallel-placeholder's spine element is already wrapped by an + // OPEN/CLOSE pair, add another OR-path to that existing parallel + // instead of starting a fresh OPEN/CLOSE pair (no nested parallels in + // branches, per design). + // + // "Inside an existing parallel" means: there is an unmatched OPEN + // before the target's index. Closed parallels (OPEN…CLOSE before the + // target) don't count — a spine element sitting AFTER a CLOSE is on + // the spine, not inside the parallel. + const branch = getBranch(rung, branchTarget.blockId, branchTarget.handleId, branchTarget.direction) + const existingParallel = + isParallelInBranch && branch + ? branch.nodeIds.findIndex((id) => id === aboveElementId) + : -1 + let isInsideExistingParallel = false + if (existingParallel !== -1 && branch) { + let depth = 0 + for (let i = 0; i < existingParallel; i++) { + const n = rung.nodes.find((n) => n.id === branch.nodeIds[i]) + if (n?.type !== 'parallel') continue + const ptype = (n.data as { type?: string }).type + if (ptype === 'open') depth++ + else if (ptype === 'close') depth-- + } + isInsideExistingParallel = depth > 0 + } + + // `parallelPathSplice` placeholders chain serial elements WITHIN an + // existing OR-path. Detect first; the rest of the routing tree is + // unchanged. + const result = branchTarget.parallelPathSplice + ? insertIntoBranchParallelPath(rung, { + blockId: branchTarget.blockId, + handleId: branchTarget.handleId, + direction: branchTarget.direction, + predecessorId: branchTarget.parallelPathSplice.predecessorId, + successorId: branchTarget.parallelPathSplice.successorId, + newElementType: newNode.elementType, + }) + : isParallelInBranch && isInsideExistingParallel + ? addPathToBranchParallel(rung, { + blockId: branchTarget.blockId, + handleId: branchTarget.handleId, + direction: branchTarget.direction, + spineNodeId: aboveElementId, + newElementType: newNode.elementType, + }) + : isParallelInBranch + ? startParallelInBranch(rung, { + blockId: branchTarget.blockId, + handleId: branchTarget.handleId, + direction: branchTarget.direction, + aboveElementId, + newElementType: newNode.elementType, + }) + : branchTarget.insertIndex === undefined + ? replaceVariableWithBranch(rung, { + blockId: branchTarget.blockId, + handleId: branchTarget.handleId, + direction: branchTarget.direction, + newElementType: newNode.elementType, + }) + : insertIntoBranch(rung, { + blockId: branchTarget.blockId, + handleId: branchTarget.handleId, + direction: branchTarget.direction, + insertIndex: branchTarget.insertIndex, + newElementType: newNode.elementType, + }) + + const layoutResult = updateDiagramElementsPosition( + { + ...rung, + nodes: result.nodes, + edges: result.edges, + handleBranches: result.handleBranches, + }, + rung.defaultBounds as [number, number], + ) + + return { + nodes: removePlaceholderElements(layoutResult.nodes), + edges: layoutResult.edges, + handleBranches: result.handleBranches, + newNode: result.newNode, + } + } /** * Check if the selected placeholder is a parallel placeholder @@ -90,30 +227,88 @@ export const addNewElement = ( /** * Return the updated rung */ - return { nodes: newNodes, edges: newEdges, newNode: newNodeData } + return { nodes: newNodes, edges: newEdges, handleBranches: rung.handleBranches, newNode: newNodeData } } -export const removeElement = (rung: RungLadderState, element: Node): { nodes: Node[]; edges: Edge[] } => { +export const removeElement = ( + rung: RungLadderState, + element: Node, +): { nodes: Node[]; edges: Edge[]; handleBranches: HandleBranch[] } => { + /** + * Branch-element removal: delegate to the handle-branch module's + * `removeBranchElement`. After it runs, collapse any now-empty OPEN/CLOSE + * pairs (in case the user removed the only element on a branch's + * parallel-path) and reconcile each branch's `nodeIds` with the live + * edge graph (the OPEN/CLOSE pair that just collapsed left dangling + * references in the spine). + */ + if ( + (element.type === 'contact' || element.type === 'coil' || element.type === 'parallel') && + element.data.branchContext + ) { + const removed = removeBranchElement(rung, element) + const collapsed = removeEmptyParallelConnections({ + ...rung, + nodes: removed.nodes, + edges: removed.edges, + handleBranches: removed.handleBranches, + }) + const reconciledHandleBranches = reconcileAllBranchNodeIds({ + ...rung, + nodes: collapsed.nodes, + edges: collapsed.edges, + handleBranches: removed.handleBranches, + }) + const layoutResult = updateDiagramElementsPosition( + { ...rung, nodes: collapsed.nodes, edges: collapsed.edges, handleBranches: reconciledHandleBranches }, + rung.defaultBounds as [number, number], + ) + return { nodes: layoutResult.nodes, edges: layoutResult.edges, handleBranches: reconciledHandleBranches } + } + + /** + * Block deletion cascade: remove every branch element on this block first, + * so the main-rung path doesn't leave orphan branch elements with edges to + * a non-existent block. + */ + let workingHandleBranches = rung.handleBranches + let workingNodesIn = rung.nodes + let workingEdgesIn = rung.edges + if (element.type === 'block') { + const branchResult = removeAllBranchesForBlock(rung, element.id) + workingNodesIn = branchResult.nodes + workingEdgesIn = branchResult.edges + workingHandleBranches = branchResult.handleBranches + } + const workingRung: RungLadderState = { + ...rung, + nodes: workingNodesIn, + edges: workingEdgesIn, + handleBranches: workingHandleBranches, + } + /** * Remove the selected element from the rung */ - let newNodes = removeNode(rung, element.id) + let newNodes = removeNode(workingRung, element.id) /** * Disconnect the element from the rung */ - const edgeToRemove = rung.edges.find( + const edgeToRemove = workingRung.edges.find( (e) => e.source === element.id && e.sourceHandle === (element.data as BasicNodeData).outputConnector?.id, ) - if (!edgeToRemove) return { nodes: rung.nodes, edges: rung.edges } - let newEdges = disconnectNodes(rung, edgeToRemove.source, edgeToRemove.target) + if (!edgeToRemove) { + return { nodes: workingRung.nodes, edges: workingRung.edges, handleBranches: workingHandleBranches } + } + let newEdges = disconnectNodes(workingRung, edgeToRemove.source, edgeToRemove.target) /** * Check if there is empty parallel connections * If there is, remove them */ const { nodes: checkedParallelNodes, edges: checkedParallelEdges } = removeEmptyParallelConnections({ - ...rung, + ...workingRung, nodes: newNodes, edges: newEdges, }) @@ -125,7 +320,7 @@ export const removeElement = (rung: RungLadderState, element: Node): { nodes: No */ const { nodes: updatedDiagramNodes, edges: updatedDiagramEdges } = updateDiagramElementsPosition( { - ...rung, + ...workingRung, nodes: newNodes, edges: newEdges, }, @@ -137,18 +332,21 @@ export const removeElement = (rung: RungLadderState, element: Node): { nodes: No /** * Return the updated rung */ - return { nodes: newNodes, edges: newEdges } + return { nodes: newNodes, edges: newEdges, handleBranches: workingHandleBranches } } -export const removeElements = (rung: RungLadderState, nodesToRemove: Node[]): { nodes: Node[]; edges: Edge[] } => { - if (!nodesToRemove || nodesToRemove.length === 0) return { nodes: rung.nodes, edges: rung.edges } +export const removeElements = ( + rung: RungLadderState, + nodesToRemove: Node[], +): { nodes: Node[]; edges: Edge[]; handleBranches: HandleBranch[] } => { + if (!nodesToRemove || nodesToRemove.length === 0) + return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } - const RungLadderState = { ...rung } + let workingRung: RungLadderState = { ...rung } for (const node of nodesToRemove) { - const { nodes, edges } = removeElement(RungLadderState, node) - RungLadderState.nodes = nodes - RungLadderState.edges = edges + const { nodes, edges, handleBranches } = removeElement(workingRung, node) + workingRung = { ...workingRung, nodes, edges, handleBranches } } - return { nodes: RungLadderState.nodes, edges: RungLadderState.edges } + return { nodes: workingRung.nodes, edges: workingRung.edges, handleBranches: workingRung.handleBranches } } diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/placeholder/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/placeholder/index.ts index 344a2950d..246603f44 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/placeholder/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/placeholder/index.ts @@ -3,6 +3,12 @@ import { newGraphicalEditorNodeID } from '@root/frontend/utils/new-graphical-edi import type { Node, ReactFlowInstance } from '@xyflow/react' import { nodesBuilder } from '../../../../../../../_atoms/graphical-editor/ladder/node-builders' +import { + renderHandleBranchCreationPlaceholders, + renderInBranchParallelPathPlaceholders, + renderInBranchParallelPlaceholders, + renderInBranchSplicePlaceholders, +} from '../handle-branch' import { getDeepestNodesInsideParallels, getNodesInsideAllParallels, getPlaceholderPositionBasedOnNode } from '../utils' export const removePlaceholderElements = (nodes: Node[]) => { @@ -26,6 +32,29 @@ export const renderPlaceholderElements = (rung: RungLadderState) => { nodes.forEach((node) => { let placeholders: Node[] = [] + + // Branch elements (contacts / coils / parallels with branchContext) own + // their own in-branch splice placeholders via + // `renderInBranchSplicePlaceholders`. Emitting the regular main-rail + // left / right / parallel placeholders for them would let the user drop + // a main-rail-style parallel onto a branch — Phase 4 handles that case + // explicitly via `startParallelInBranch`; until then, suppress. + if ( + (node.type === 'contact' || node.type === 'coil' || node.type === 'parallel') && + node.data.branchContext + ) { + placeholderNodes.push(node) + return + } + + // Standalone branch rails belong to a handle branch — drops onto them go + // through `renderHandleBranchCreationPlaceholders` / + // `renderInBranchSplicePlaceholders`, not the regular rail placeholders. + if (node.type === 'powerRail' && node.id.startsWith('branch-rail-')) { + placeholderNodes.push(node) + return + } + if ( node.type === 'placeholder' || node.type === 'parallelPlaceholder' || @@ -125,7 +154,22 @@ export const renderPlaceholderElements = (rung: RungLadderState) => { placeholderNodes.push(placeholders[0], node, placeholders[1]) }) - return placeholderNodes + + // Append handle-branch placeholders: + // - creation targets: one per BOOL block handle that currently hosts a + // Variable node and no branch yet. + // - splice targets: one per gap inside an existing branch's serial spine. + // - parallel targets: one bottom placeholder under each spine contact/ + // coil so the user can drop a new element to create an in-branch + // parallel. Suppressed for spine elements already inside a parallel + // (no nested parallels in branches). + return [ + ...placeholderNodes, + ...renderHandleBranchCreationPlaceholders(rung), + ...renderInBranchSplicePlaceholders(rung), + ...renderInBranchParallelPlaceholders(rung), + ...renderInBranchParallelPathPlaceholders(rung), + ] } /** diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts index 2ab82738c..5b0bb5e35 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts @@ -5,6 +5,14 @@ import type { CustomHandleProps } from '../../../../../../../_atoms/graphical-ed import { BasicNodeData, ParallelNode } from '../../../../../../../_atoms/graphical-editor/ladder/utils/types' import { getDefaultNodeStyle, isNodeOfType } from '../../nodes' +// Horizontal padding inserted between adjacent same-type parallel brackets +// (an outer OPEN and an inner OPEN, or an inner CLOSE and an outer CLOSE). +// Matches a contact's gap so the inner bracket's vertical wire sits the same +// distance from the outer's wire that a regular contact would — large enough +// to read as deliberate separation, small enough that the nested structure +// stays visually compact. +export const NESTED_PARALLEL_CLEARANCE = 45 + /** * Get the previous element by searching with edge in the rung * @@ -116,6 +124,12 @@ export const getElementPositionBasedOnPlaceholderElement = ( * @param previousElement * @param newElement * @param type: 'serial' | 'parallel' + * @param prevIsAlreadyNested When prev is itself a parallel of the same + * sub-type as its own predecessor (e.g. OPEN→OPEN→OPEN), the chain has + * already paid the bracket-clearance budget at its outermost boundary. + * Pass `true` so this call collapses onto prev's X instead of stacking + * another clearance — otherwise every "add parallel" wraps an extra + * layer and the spine funnels rightward. * * @returns { posX, posY, handleX, handleY } */ @@ -123,6 +137,7 @@ export const getNodePositionBasedOnPreviousNode = ( previousElement: Node, newElement: string | Node, type: 'serial' | 'parallel', + prevIsAlreadyNested: boolean = false, ): { posX: number posY: number @@ -143,6 +158,24 @@ export const getNodePositionBasedOnPreviousNode = ( previousElement.type === 'parallel' && (typeof newElement === 'string' ? newElement === 'parallel' : newElement.type === 'parallel') + // Two parallels of the same sub-type (OPEN→OPEN or CLOSE→CLOSE) can only + // appear adjacent when one is nested inside the other. Without horizontal + // separation the inner bracket renders at the same X as the outer's + // vertical wire and the two wires overlap visually. Reserve a contact- + // sized gap AND advance past the previous bracket's width so the inner + // OPEN's left wire (or inner CLOSE's right wire) sits clearly inside the + // outer's span instead of on top of it. + const parallelsAreNested = + parallelNodeCheckingParallelNode && + typeof newElement !== 'string' && + (previousElement as ParallelNode).data.type === (newElement as ParallelNode).data.type + + // If prev is already past a clearance boundary (its own predecessor was + // the same-type parallel), don't add another one. The visible separation + // belongs at the OUTERMOST bracket boundary; deeper levels collapse to + // the same X so a 3-deep nesting reads as one set of brackets, not three. + const collapseDeepNesting = parallelsAreNested && prevIsAlreadyNested + let gap = 0 if (parallelNodeCheckingParallelNode) { if ( @@ -152,6 +185,8 @@ export const getNodePositionBasedOnPreviousNode = ( previousElement.id !== (newElement as ParallelNode).data.parallelCloseReference) ) { gap = 100 + } else if (parallelsAreNested && !collapseDeepNesting) { + gap = NESTED_PARALLEL_CLEARANCE } } else { gap = previousElementStyle.gap + newNodeStyle.gap @@ -159,13 +194,20 @@ export const getNodePositionBasedOnPreviousNode = ( const offsetY = newNodeStyle.handle.y + // Nested parallels need the previous bracket's width added so the inner + // bracket sits past it instead of collapsing onto its X (the OPEN/CLOSE + // pair-collapse only applies when the two parallels are the same pair). + // When the chain is already past the outer clearance boundary, skip the + // width too so deeper levels share the same X as the first inner bracket. + const skipPrevWidth = parallelNodeCheckingParallelNode && (!parallelsAreNested || collapseDeepNesting) + const position = { - posX: previousElement.position.x + (!parallelNodeCheckingParallelNode ? previousElement.width || 0 : 0) + gap, + posX: previousElement.position.x + (skipPrevWidth ? 0 : previousElement.width || 0) + gap, posY: previousElement.type === (typeof newElement === 'string' ? newElement : newElement.type) ? previousElement.position.y : previousElementOutputHandle.glbPosition.y - offsetY, - handleX: previousElement.position.x + (!parallelNodeCheckingParallelNode ? previousElement.width || 0 : 0) + gap, + handleX: previousElement.position.x + (skipPrevWidth ? 0 : previousElement.width || 0) + gap, handleY: previousElementOutputHandle.glbPosition.y, } @@ -226,6 +268,12 @@ export const findDeepestParallelInsideParallel = (rung: RungLadderState, paralle return parallel as ParallelNode } +// Vertical room reserved between a parallel's spine row and its parallel +// path row. Mirrors the additive `verticalGap` used by `positionMainNodes` +// when stacking the parallel path Y, so a nested parallel's content extent +// rolls up correctly through its parents' height calculations. +const PARALLEL_PATH_VERTICAL_GAP = 80 + /** * Find all parallels depth and nodes of those parallels * @@ -234,7 +282,7 @@ export const findDeepestParallelInsideParallel = (rung: RungLadderState, paralle * @param depth - The depth of the parallel * @param parentNode - The parent node of the parallel * - * @returns object: { [key: string]: { parent: ParallelNode | undefined, parallels: { open: ParallelNode, close: ParallelNode }, depth: number, height: number, highestNode: Node, nodes: { serial: Node[], parallel: Node[] } } } + * @returns object: { [key: string]: { parent: ParallelNode | undefined, parallels: { open: ParallelNode, close: ParallelNode }, depth: number, height: number, contentExtent: number, highestNode: Node, nodes: { serial: Node[], parallel: Node[] } } } */ export const findAllParallelsDepthAndNodes = ( rung: RungLadderState, @@ -251,6 +299,11 @@ export const findAllParallelsDepthAndNodes = ( } depth: number height: number + // Total vertical extent (spine + every parallel-path row, including + // nested parallels' content). Used by parents to roll this parallel's + // depth into their own height calc so a sibling-path block lands + // below the deepest nested element instead of overlapping it. + contentExtent: number highestNode: Node nodes: { serial: Node[] @@ -266,6 +319,13 @@ export const findAllParallelsDepthAndNodes = ( const serialNodes = nodesInsideParallel.serial let highestNode = serialNodes[0] let serialHeight = highestNode.height ?? 0 + // Spine extent must also account for any nested parallel that lives in + // this spine — the nested OPEN's bracket sits on the spine row, but its + // own paths consume rows below. Without rolling that depth in here, the + // parent's parallel-path elements (e.g. a sibling block on a CLOSE-side + // path) land at the spine row + raw spine height and overlap the nested + // content. + let spineExtent = serialHeight for (const serialNode of serialNodes) { // If it is a parallel node, check if it is an open parallel // If it is, call the function recursively @@ -274,12 +334,15 @@ export const findAllParallelsDepthAndNodes = ( if (serialParallel.data.type === 'open') { const object = findAllParallelsDepthAndNodes(rung, serialParallel, depth, openParallel) objectParallel = { ...objectParallel, ...object } + const nestedExtent = object[serialParallel.id]?.contentExtent ?? 0 + if (nestedExtent > spineExtent) spineExtent = nestedExtent } } if (serialHeight < (serialNode.height ?? 0)) { serialHeight = serialNode.height ?? 0 highestNode = serialNode } + if (spineExtent < (serialNode.height ?? 0)) spineExtent = serialNode.height ?? 0 } let deepestDepth = 0 @@ -299,10 +362,38 @@ export const findAllParallelsDepthAndNodes = ( } } + // Path content extent: the parallel-path row contributes one row below + // the spine, plus whatever depth is contributed by any nested OPEN that + // lives on that path (e.g. when a contact on the path has been wrapped + // by a deeper parallel). Walk path elements; for nested OPENs read the + // already-computed contentExtent, for raw nodes use their own height. + let maxPathExtent = 0 + for (const pathNode of parallelNodes) { + if (pathNode.type === 'parallel') { + const par = pathNode as ParallelNode + if (par.data.type === 'open') { + const nestedExtent = objectParallel[par.id]?.contentExtent ?? 0 + if (nestedExtent > maxPathExtent) maxPathExtent = nestedExtent + continue + } + } + if ((pathNode.height ?? 0) > maxPathExtent) maxPathExtent = pathNode.height ?? 0 + } + + // contentExtent rolls spine + path stack + verticalGap so an outer parent + // can see this parallel's full vertical reach in one number. + const contentExtent = + parallelNodes.length > 0 ? spineExtent + PARALLEL_PATH_VERTICAL_GAP + maxPathExtent : spineExtent + objectParallel[openParallel.id] = { parent: parentNode, depth, - height: serialHeight, + // `height` is consumed by `positionMainNodes` to place a parallel-path + // element at `highestNode.y + height + verticalGap`. Use the extended + // spine extent so nested-parallel content in the spine pushes the + // sibling-path Y past it instead of overlapping it. + height: spineExtent, + contentExtent, highestNode, parallels: { open: openParallel, @@ -329,8 +420,8 @@ export const getDeepestNodesInsideParallels = (rung: RungLadderState): Node[] => const nodes: Node[] = [] parallels.forEach((parallel) => { const deepestParallel = findDeepestParallelInsideParallel(rung, parallel) - const { parallel: parallelNodes } = getNodesInsideParallel(rung, deepestParallel) - nodes.push(...parallelNodes) + const { serial, parallel: parallelNodes } = getNodesInsideParallel(rung, deepestParallel) + nodes.push(...serial, ...parallelNodes) }) return nodes } diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/index.ts index c3626ef08..3fcce34cf 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/index.ts @@ -8,6 +8,7 @@ import { } from '../../../../../../../_atoms/graphical-editor/ladder/node-builders' import { BlockNode, BlockVariant } from '../../../../../../../_atoms/graphical-editor/ladder/utils/types' import { buildEdge } from '../../edges' +import { hasBranchOnHandle } from '../handle-branch' export const renderVariableBlock = (rung: RungLadderState, block: Node) => { const variableElements: Node[] = [] @@ -27,6 +28,10 @@ export const renderVariableBlock = (rung: RungLadderStat : [] inputHandles.forEach((inputHandle) => { + // Skip handles that host a branch — the contacts/coils on the branch + // replace the Variable node visually and semantically. + if (hasBranchOnHandle(rung, blockElement.id, inputHandle.id as string, 'input')) return + const connectedVariable = ( Array.isArray(blockElement.data.connectedVariables) ? blockElement.data.connectedVariables : [] ).find((variable) => { @@ -69,6 +74,8 @@ export const renderVariableBlock = (rung: RungLadderStat }) outputHandles.forEach((outputHandle) => { + if (hasBranchOnHandle(rung, blockElement.id, outputHandle.id as string, 'output')) return + const connectedVariable = ( Array.isArray(blockElement.data.connectedVariables) ? blockElement.data.connectedVariables : [] ).find((variable) => { @@ -114,14 +121,13 @@ export const renderVariableBlock = (rung: RungLadderStat } export const removeVariableBlock = (rung: RungLadderState) => { + const variableNodeIds = new Set(rung.nodes.filter((node) => node.type === 'variable').map((node) => node.id)) const newNodes = rung.nodes.filter((node) => node.type !== 'variable') - const newEdges = rung.edges.filter( - (edge) => !edge.source.toLowerCase().includes('variable') && !edge.target.toLowerCase().includes('variable'), - ) + const newEdges = rung.edges.filter((edge) => !variableNodeIds.has(edge.source) && !variableNodeIds.has(edge.target)) return { nodes: newNodes, edges: newEdges } } -export const updateVariableBlockPosition = (rung: RungLadderState) => { +export const updateVariableBlockPosition = (rung: RungLadderState, _defaultBounds?: [number, number]) => { let newNodes = [...rung.nodes] let newEdges = [...rung.edges] diff --git a/src/frontend/store/slices/ladder/slice.ts b/src/frontend/store/slices/ladder/slice.ts index 041a97e16..64e442d12 100644 --- a/src/frontend/store/slices/ladder/slice.ts +++ b/src/frontend/store/slices/ladder/slice.ts @@ -2,6 +2,7 @@ import { addEdge, applyEdgeChanges, applyNodeChanges } from '@xyflow/react' import { produce } from 'immer' import { StateCreator } from 'zustand' +import { zodLadderFlowSchema } from '../../../../middleware/shared/ports/flow-schemas' import type { PLCVariable } from '../../../../middleware/shared/ports/types' import { defaultCustomNodesStyles, @@ -9,9 +10,39 @@ import { } from '../../../components/_atoms/graphical-editor/ladder/node-builders' import type { LadderBlockConnectedVariables } from '../../../components/_atoms/graphical-editor/ladder/utils/types' import { removeElements } from '../../../components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements' -import { LadderFlowSlice, LadderFlowState } from './types' +import { LadderFlowSlice, LadderFlowState, LadderFlowType } from './types' import { duplicateLadderRung } from './utils' +/** + * Run an incoming flow through Zod so every `.default(...)` clause on the + * schema fires before the data lands in the store. Caller-side TypeScript + * types document the expected shape, but they can't catch drift coming from + * disk (legacy projects), from snapshot restore, or from unknown third-party + * producers. This is the slice's runtime safety net — making the slice the + * single trust boundary instead of relying on every caller to remember to + * normalize first. + * + * On parse failure, returns the flow as-is. The legacy `connectedVariables` + * migration in addLadderFlow still runs, so worst case is the editor sees + * the unparsed shape and may surface a runtime error downstream — which is + * still better than silently corrupting state. + */ +const parseFlowOrPassthrough = (flow: LadderFlowType): LadderFlowType => { + const result = zodLadderFlowSchema.safeParse({ name: flow.name, rungs: flow.rungs }) + if (!result.success) { + console.warn(`Failed to parse ladder flow "${flow.name}":`, result.error.issues) + return flow + } + return { + name: result.data.name, + updated: flow.updated, + rungs: result.data.rungs.map((rung, i) => ({ + ...rung, + selectedNodes: flow.rungs[i]?.selectedNodes ?? [], + })), + } +} + export const createLadderFlowSlice: StateCreator = (setState) => ({ ladderFlows: [], @@ -19,7 +50,8 @@ export const createLadderFlowSlice: StateCreator { setState({ ladderFlows: [] }) }, - addLadderFlow: (flow) => { + addLadderFlow: (rawFlow) => { + const flow = parseFlowOrPassthrough(rawFlow) setState( produce(({ ladderFlows }: LadderFlowState) => { const flowIndex = ladderFlows.findIndex((f) => f.name === flow.name) @@ -134,21 +166,23 @@ export const createLadderFlowSlice: StateCreator { + const parsed = parseFlowOrPassthrough({ name: editorName, updated: true, rungs }) setState( produce(({ ladderFlows }: LadderFlowState) => { const flow = ladderFlows.find((flow) => flow.name === editorName) if (!flow) return - if (!Array.isArray(rungs)) return + if (!Array.isArray(parsed.rungs)) return // Validate each rung has required structure if ( - !rungs.every( + !parsed.rungs.every( (rung) => rung.id && Array.isArray(rung.nodes) && @@ -159,7 +193,7 @@ export const createLadderFlowSlice: StateCreator { + const flow = ladderFlows.find((flow) => flow.name === editorName) + if (!flow) return + + const rung = flow.rungs.find((rung) => rung.id === rungId) + if (!rung) return + + rung.handleBranches = handleBranches + flow.updated = true + }), + ) + }, + + updateRungData({ editorName, rungId, nodes, edges, handleBranches }) { + setState( + produce(({ ladderFlows }: LadderFlowState) => { + const flow = ladderFlows.find((flow) => flow.name === editorName) + if (!flow) return + + const rung = flow.rungs.find((rung) => rung.id === rungId) + if (!rung) return + + rung.nodes = nodes + rung.edges = edges + if (handleBranches !== undefined) rung.handleBranches = handleBranches + flow.updated = true + }), + ) + }, + /** * Control the flow viewport of the rung */ @@ -484,14 +550,17 @@ export const createLadderFlowSlice: StateCreator { + // Parse on entry so any drift in the snapshot (e.g. from a project version + // that didn't have `handleBranches`) is normalized before it reaches the store. + const parsedSnapshot = snapshot ? parseFlowOrPassthrough({ ...snapshot, name: editorName }) : null setState( produce(({ ladderFlows }: LadderFlowState) => { - if (snapshot) { + if (parsedSnapshot) { const flowIndex = ladderFlows.findIndex((ladderFlow) => ladderFlow.name === editorName) - const rungs = snapshot.rungs.map((rung) => ({ ...rung, selectedNodes: [] })) + const rungs = parsedSnapshot.rungs.map((rung) => ({ ...rung, selectedNodes: [] })) // Don't set updated: true — snapshot restore is managed by the undo/redo // handler which controls the saved flag directly. - const newFlow = { ...snapshot, name: editorName, rungs, updated: false } + const newFlow = { ...parsedSnapshot, name: editorName, rungs, updated: false } if (flowIndex === -1) { ladderFlows.push(newFlow) diff --git a/src/frontend/store/slices/ladder/types.ts b/src/frontend/store/slices/ladder/types.ts index e39def26c..ee5081afb 100644 --- a/src/frontend/store/slices/ladder/types.ts +++ b/src/frontend/store/slices/ladder/types.ts @@ -2,6 +2,11 @@ import { Connection, Edge, EdgeChange, Node, NodeChange } from '@xyflow/react' import { z } from 'zod' import { zodLadderFlowSchema, zodRungLadderStateSchema } from '../../../../middleware/shared/ports/flow-schemas' +import type { + HandleBranch, + RungLadderState as PortRungLadderState, +} from '../../../../middleware/shared/ports/types' +import type { RungNode } from '../../../components/_atoms/graphical-editor/ladder/utils/types' type ZodLadderRungType = z.infer type ZodLadderFlowType = z.infer @@ -14,9 +19,17 @@ type ZodLadderFlowState = z.infer const zodLadderNodeTypesSchema = z.enum(['block', 'contact', 'coil', 'parallel', 'powerRail', 'variable']) type ZodLadderNodeType = z.infer -import type { RungLadderState } from '../../../../middleware/shared/ports/types' - -export type { RungLadderState } +/** + * Editor-narrowed view of `RungLadderState`. The cross-platform port type + * keeps `nodes` / `selectedNodes` as the generic `Node[]` from `@xyflow/react` + * (the compiler adapter doesn't care about the discriminated union); the + * frontend tightens both to the `RungNode` discriminated union so call sites + * narrow on `node.type` instead of casting. + */ +export type RungLadderState = Omit & { + nodes: RungNode[] + selectedNodes: RungNode[] +} /** * Types used at the slice @@ -109,6 +122,43 @@ type LadderFlowActions = { }) => void addEdge: ({ edge, rungId, editorName }: { edge: Edge; rungId: string; editorName: string }) => void + /** + * Replace the per-rung handle-branch index. The structural data (the + * branch nodes / edges themselves) lives in `nodes` / `edges`; this action + * only updates the denormalized lookup. + */ + setHandleBranches: ({ + handleBranches, + rungId, + editorName, + }: { + handleBranches: HandleBranch[] + rungId: string + editorName: string + }) => void + + /** + * Atomic update of every per-rung field that an editor mutation touches — + * nodes, edges, and (optionally) handleBranches. Use this instead of a + * sequence of `setNodes` + `setEdges` + `setHandleBranches` calls when the + * three need to land as one store transition. The intermediate states from + * sequential setters can have ReactFlow render edges that reference + * not-yet-committed nodes, producing handle-not-found warnings. + */ + updateRungData: ({ + nodes, + edges, + handleBranches, + rungId, + editorName, + }: { + nodes: Node[] + edges: Edge[] + handleBranches?: HandleBranch[] + rungId: string + editorName: string + }) => void + /** * Control the flow viewport of the rung */ diff --git a/src/frontend/store/slices/ladder/utils/index.ts b/src/frontend/store/slices/ladder/utils/index.ts index 4b5248543..3f6cec061 100644 --- a/src/frontend/store/slices/ladder/utils/index.ts +++ b/src/frontend/store/slices/ladder/utils/index.ts @@ -1,6 +1,6 @@ import { Edge, Node } from '@xyflow/react' -import type { PLCVariable } from '../../../../../middleware/shared/ports/types' +import type { HandleBranch, PLCVariable } from '../../../../../middleware/shared/ports/types' import { nodesBuilder } from '../../../../components/_atoms/graphical-editor/ladder/node-builders' import type { LadderBlockConnectedVariables } from '../../../../components/_atoms/graphical-editor/ladder/utils/types' import type { @@ -16,6 +16,27 @@ import { generateNumericUUID } from '../../../../utils/generate-uuid' import { newGraphicalEditorNodeID } from '../../../../utils/new-graphical-editor-node-id' import { RungLadderState } from '../types' +/** + * Rail handles for handle branches use the id format + * `branch_${blockId}_${handleId}`. When a rung is duplicated and its blocks + * receive new ids, every reference to the old block id inside such a handle + * (or in an edge's `sourceHandle` / `targetHandle`) needs to be retargeted to + * the new block. Returns the input unchanged if it isn't a branch handle id. + */ +const remapBranchHandleId = ( + handleId: string | null | undefined, + blockIdMap: Record, +): string | null | undefined => { + if (typeof handleId !== 'string' || !handleId.startsWith('branch_')) return handleId + for (const [oldBlockId, newBlockId] of Object.entries(blockIdMap)) { + const prefix = `branch_${oldBlockId}_` + if (handleId.startsWith(prefix)) { + return `branch_${newBlockId}_${handleId.slice(prefix.length)}` + } + } + return handleId +} + export const duplicateLadderRung = (editorName: string, rung: RungLadderState): RungLadderState => { const nodeMaps: { [key: string]: Node } = rung.nodes.reduce( (acc, node) => { @@ -28,14 +49,23 @@ export const duplicateLadderRung = (editorName: string, rung: RungLadderState): {} as { [key: string]: Node }, ) + const blockIdMap: Record = {} + for (const node of rung.nodes) { + if (node.type === 'block') { + blockIdMap[node.id] = nodeMaps[node.id].id + } + } + const edgeMaps: { [key: string]: Edge } = rung.edges.reduce( (acc, edge) => { + const newSourceHandle = remapBranchHandleId(edge.sourceHandle, blockIdMap) ?? edge.sourceHandle + const newTargetHandle = remapBranchHandleId(edge.targetHandle, blockIdMap) ?? edge.targetHandle acc[edge.id] = { - id: `e_${nodeMaps[edge.source].id}_${nodeMaps[edge.target].id}__${edge.sourceHandle}_${edge.targetHandle}`, + id: `e_${nodeMaps[edge.source].id}_${nodeMaps[edge.target].id}__${newSourceHandle}_${newTargetHandle}`, source: nodeMaps[edge.source].id, target: nodeMaps[edge.target].id, - sourceHandle: edge.sourceHandle, - targetHandle: edge.targetHandle, + sourceHandle: newSourceHandle, + targetHandle: newTargetHandle, } return acc }, @@ -49,80 +79,108 @@ export const duplicateLadderRung = (editorName: string, rung: RungLadderState): id: nodeMaps[node.id].id, posX: node.position.x, posY: node.position.y, - handleX: (node as BlockNode).data.inputConnector?.glbPosition.x ?? 0, - handleY: (node as BlockNode).data.inputConnector?.glbPosition.y ?? 0, - variant: (node as BlockNode).data.variant, - executionControl: (node as BlockNode).data.executionControl, + handleX: node.data.inputConnector?.glbPosition.x ?? 0, + handleY: node.data.inputConnector?.glbPosition.y ?? 0, + variant: node.data.variant, + executionControl: node.data.executionControl, }) return { ...newBlock, data: { ...newBlock.data, - variable: - (node as BlockNode).data.variant.type === 'function-block' - ? { name: '' } - : node.data.variable, - connectedVariables: normalizeConnectedVariables((node as BlockNode).data.connectedVariables), + variable: node.data.variant.type === 'function-block' ? { name: '' } : node.data.variable, + connectedVariables: normalizeConnectedVariables(node.data.connectedVariables), }, } as BlockNode } case 'coil': { + const sourceCoil = node const newCoil = nodesBuilder.coil({ id: nodeMaps[node.id].id, posX: node.position.x, posY: node.position.y, - handleX: (node as CoilNode).data.inputConnector?.glbPosition.x ?? 0, - handleY: (node as CoilNode).data.inputConnector?.glbPosition.y ?? 0, - variant: (node as CoilNode).data.variant, + handleX: sourceCoil.data.inputConnector?.glbPosition.x ?? 0, + handleY: sourceCoil.data.inputConnector?.glbPosition.y ?? 0, + variant: sourceCoil.data.variant, }) return { ...newCoil, data: { ...newCoil.data, - variable: (node as CoilNode).data.variable, + variable: sourceCoil.data.variable, + branchContext: sourceCoil.data.branchContext + ? { + ...sourceCoil.data.branchContext, + blockId: blockIdMap[sourceCoil.data.branchContext.blockId] ?? sourceCoil.data.branchContext.blockId, + } + : undefined, }, } as CoilNode } case 'contact': { + const sourceContact = node const newContact = nodesBuilder.contact({ id: nodeMaps[node.id].id, posX: node.position.x, posY: node.position.y, - handleX: (node as ContactNode).data.inputConnector?.glbPosition.x ?? 0, - handleY: (node as ContactNode).data.inputConnector?.glbPosition.y ?? 0, - variant: (node as ContactNode).data.variant, + handleX: sourceContact.data.inputConnector?.glbPosition.x ?? 0, + handleY: sourceContact.data.inputConnector?.glbPosition.y ?? 0, + variant: sourceContact.data.variant, }) return { ...newContact, data: { ...newContact.data, - variable: (node as ContactNode).data.variable, + variable: sourceContact.data.variable, + branchContext: sourceContact.data.branchContext + ? { + ...sourceContact.data.branchContext, + blockId: + blockIdMap[sourceContact.data.branchContext.blockId] ?? sourceContact.data.branchContext.blockId, + } + : undefined, }, } as ContactNode } case 'parallel': { + const sourceParallel = node return { ...node, id: nodeMaps[node.id].id, data: { ...node.data, numericId: generateNumericUUID(), - parallelCloseReference: (node as ParallelNode).data.parallelCloseReference - ? nodeMaps[(node as ParallelNode).data.parallelCloseReference ?? ''].id + parallelCloseReference: sourceParallel.data.parallelCloseReference + ? nodeMaps[sourceParallel.data.parallelCloseReference ?? ''].id : undefined, - parallelOpenReference: (node as ParallelNode).data.parallelOpenReference - ? nodeMaps[(node as ParallelNode).data.parallelOpenReference ?? ''].id + parallelOpenReference: sourceParallel.data.parallelOpenReference + ? nodeMaps[sourceParallel.data.parallelOpenReference ?? ''].id + : undefined, + branchContext: sourceParallel.data.branchContext + ? { + ...sourceParallel.data.branchContext, + blockId: + blockIdMap[sourceParallel.data.branchContext.blockId] ?? sourceParallel.data.branchContext.blockId, + } : undefined, }, } as ParallelNode } case 'powerRail': { + const sourceRail = node + const remapHandle = (handle: T): T => ({ + ...handle, + id: (remapBranchHandleId(handle.id, blockIdMap) ?? handle.id) as T['id'], + }) return { ...node, id: nodeMaps[node.id].id, data: { ...node.data, numericId: generateNumericUUID(), + handles: sourceRail.data.handles.map(remapHandle), + inputHandles: sourceRail.data.inputHandles.map(remapHandle), + outputHandles: sourceRail.data.outputHandles.map(remapHandle), }, } as PowerRailNode } @@ -134,8 +192,8 @@ export const duplicateLadderRung = (editorName: string, rung: RungLadderState): ...node.data, numericId: generateNumericUUID(), block: { - ...(node as VariableNode).data.block, - id: nodeMaps[(node as VariableNode).data.block.id]?.id ?? (node as VariableNode).data.block.id, + ...node.data.block, + id: nodeMaps[node.data.block.id]?.id ?? node.data.block.id, }, }, } as VariableNode @@ -151,6 +209,14 @@ export const duplicateLadderRung = (editorName: string, rung: RungLadderState): id: edgeMaps[edge.id].id, source: edgeMaps[edge.id].source, target: edgeMaps[edge.id].target, + sourceHandle: edgeMaps[edge.id].sourceHandle, + targetHandle: edgeMaps[edge.id].targetHandle, + })) + + const newHandleBranches: HandleBranch[] = (rung.handleBranches ?? []).map((branch) => ({ + ...branch, + blockId: blockIdMap[branch.blockId] ?? branch.blockId, + nodeIds: branch.nodeIds.map((nodeId) => nodeMaps[nodeId]?.id ?? nodeId), })) const newRung = { @@ -161,6 +227,7 @@ export const duplicateLadderRung = (editorName: string, rung: RungLadderState): selectedNodes: [], nodes: newNodes, edges: newEdges, + handleBranches: newHandleBranches, } return newRung diff --git a/src/frontend/utils/PLC/xml-generator/codesys/language/ladder-xml.ts b/src/frontend/utils/PLC/xml-generator/codesys/language/ladder-xml.ts index f021205a6..0c95d0957 100644 --- a/src/frontend/utils/PLC/xml-generator/codesys/language/ladder-xml.ts +++ b/src/frontend/utils/PLC/xml-generator/codesys/language/ladder-xml.ts @@ -3,7 +3,6 @@ import { CoilNode } from '@root/frontend/components/_atoms/graphical-editor/ladd import { ContactNode } from '@root/frontend/components/_atoms/graphical-editor/ladder/contact' import { BasicNodeData, - ParallelNode, PowerRailNode, VariableNode, } from '@root/frontend/components/_atoms/graphical-editor/ladder/utils/types' @@ -20,286 +19,18 @@ import { } from '@root/middleware/shared/ports/xml-types/codesys/pous/languages/ladder-diagram' import { Node } from '@xyflow/react' -/** - * Find the connections of a node in a rung. - */ -const findNodeBasedOnParallelOpen = ( - parallelNode: ParallelNode, - rung: RungLadderState, - path: { - nodes: Node[] - parallels: ParallelNode[] - } = { nodes: [], parallels: [] }, -) => { - const { nodes: rungNodes, edges: rungEdges } = rung - - const edgeToParallelNode = rungEdges.find((edge) => edge.target === parallelNode.id)?.source - const sourceNodeOfParallelNode = rungNodes.find((node) => node.id === edgeToParallelNode) as Node - path.parallels.push(parallelNode) +import { findConnections as findRungConnections } from '../../rung-graph' - if (sourceNodeOfParallelNode.type !== 'parallel') { - path.nodes.push(sourceNodeOfParallelNode) - return path - } else if ((sourceNodeOfParallelNode as ParallelNode).data.type === 'close') { - return findNodesBasedOnParallelClose(sourceNodeOfParallelNode as ParallelNode, rung, path) - } else { - return findNodeBasedOnParallelOpen(sourceNodeOfParallelNode as ParallelNode, rung, path) - } -} +// CoDeSys-style XML uses `''` for the implicit `OUT` formal parameter; other +// handle ids pass through. +const formatFormalParameter = (rawId: string | undefined): string => (rawId === 'OUT' ? '' : rawId || '') -const findNodesBasedOnParallelClose = ( - parallelNode: ParallelNode, +const findConnections = ( + node: Node, rung: RungLadderState, - path: { - nodes: Node[] - parallels: ParallelNode[] - } = { nodes: [], parallels: [] }, -) => { - const { nodes: rungNodes, edges: rungEdges } = rung - - const edgesToParallelNode = rungEdges.filter((edge) => edge.target === parallelNode.id) - const serialNode = rungNodes.find((node) => - edgesToParallelNode.find( - (edge) => edge.source === node.id && edge.targetHandle === parallelNode.data.inputConnector?.id, - ), - ) as Node - - if (!path.nodes.includes(serialNode)) path.nodes.push(serialNode) - - const bottomNode = rungNodes.find((node) => - edgesToParallelNode.find( - (edge) => edge.source === node.id && edge.targetHandle === parallelNode.data.parallelInputConnector?.id, - ), - ) as Node - - path.parallels.push(parallelNode) - - if (bottomNode.type !== 'parallel') { - path.nodes.push(bottomNode) - return path - } - - return findNodesBasedOnParallelClose(bottomNode as ParallelNode, rung, path) -} - -const findConnections = (node: Node, rung: RungLadderState, offsetY: number = 0) => { - const { nodes: rungNodes, edges: rungEdges } = rung - - const connectedEdges = rungEdges.filter((edge) => edge.target === node.id) - if (!connectedEdges.length) return [] - - const connections = connectedEdges.map((edge) => { - const sourceNode = rungNodes.find((node) => node.id === edge.source) as Node - // If the source node is not found or it is a variable node, return undefined - if (!sourceNode || sourceNode.type === 'variable') return undefined - - // Node is not a parallel node - if (sourceNode.type !== 'parallel') { - return { - '@refLocalId': sourceNode.data.numericId, - '@formalParameter': - sourceNode.data.outputConnector?.id === 'OUT' ? '' : sourceNode.data.outputConnector?.id || '', - position: [ - // Final edge destination - { - '@x': node.data.inputConnector?.glbPosition.x ?? 0, - '@y': (node.data.inputConnector?.glbPosition.y ?? 0) + offsetY, - }, - // Initial edge source - { - '@x': sourceNode.data.outputConnector?.glbPosition.x ?? 0, - '@y': (sourceNode.data.outputConnector?.glbPosition.y ?? 0) + offsetY, - }, - ], - } - } - - // Node is a parallel node - const parallelNode = sourceNode as ParallelNode - - /** - * TODO: This is a temporary solution to find the connections of a parallel node. - * This should be refactored so that the lines are placed correctly - */ - - // If the parallel node is opening the connection - if (parallelNode.data.type === 'open') { - // Find the previous node of the parallel node - const { nodes, parallels } = findNodeBasedOnParallelOpen(parallelNode, rung) - const actualNode = node - - const lastParallelNode = parallels - .filter((parallel) => parallel.data.type === 'open') - .reverse() - .copyWithin(0, 1)[0] - const lastParallelSerialEdge = rungEdges.find( - (edge) => - edge.source === lastParallelNode.id && edge.sourceHandle === lastParallelNode.data.outputConnector?.id, - ) - - // If the node is connected serially to the parallel node - if (lastParallelSerialEdge && lastParallelSerialEdge.target === node.id) { - return nodes.map((node, index) => ({ - '@refLocalId': node.data.numericId, - '@formalParameter': node.data.outputConnector?.id === 'OUT' ? '' : node.data.outputConnector?.id || '', - position: - index === 0 - ? [ - // Final edge destination - { - '@x': actualNode.data.inputConnector?.glbPosition.x ?? 0, - '@y': (actualNode.data.inputConnector?.glbPosition.y ?? 0) + offsetY, - }, - // Initial edge source - { - '@x': node.data.outputConnector?.glbPosition.x ?? 0, - '@y': (node.data.outputConnector?.glbPosition.y ?? 0) + offsetY, - }, - ] - : [ - // Final edge destination - { - '@x': actualNode.data.inputConnector?.glbPosition.x ?? 0, - '@y': (actualNode.data.inputConnector?.glbPosition.y ?? 0) + offsetY, - }, - // Final position of parallel - { - '@x': lastParallelNode.data.parallelInputConnector?.glbPosition.x ?? 0, - '@y': (actualNode.data.inputConnector?.glbPosition.y ?? 0) + offsetY, - }, - // Initial position of parallel - { - '@x': lastParallelNode.data.parallelInputConnector?.glbPosition.x ?? 0, - '@y': (node.data.outputConnector?.glbPosition.y ?? 0) + offsetY, - }, - // Initial edge source - { - '@x': node.data.outputConnector?.glbPosition.x ?? 0, - '@y': (node.data.outputConnector?.glbPosition.y ?? 0) + offsetY, - }, - ], - })) - } - - // If the node is connected in parallel to the parallel node - // const sourceNodeOfParallelNode = nodes[0] - // return { - // '@refLocalId': sourceNodeOfParallelNode.data.numericId, - // '@formalParameter': sourceNodeOfParallelNode.data.outputConnector?.id, - // position: [ - // // Final edge destination - // { - // '@x': node.data.inputConnector?.glbPosition.x ?? 0, - // '@y': (node.data.inputConnector?.glbPosition.y ?? 0) + offsetY, - // }, - // // Final position of parallel - // { - // '@x': lastParallelNode.data.parallelOutputConnector?.glbPosition.x ?? 0, - // '@y': (node.data.inputConnector?.glbPosition.y ?? 0) + offsetY, - // }, - // // Initial position of parallel - // { - // '@x': lastParallelNode.data.parallelOutputConnector?.glbPosition.x ?? 0, - // '@y': (sourceNodeOfParallelNode.data.outputConnector?.glbPosition.y ?? 0) + offsetY, - // }, - // // Initial edge source - // { - // '@x': sourceNodeOfParallelNode.data.outputConnector?.glbPosition.x ?? 0, - // '@y': (sourceNodeOfParallelNode.data.outputConnector?.glbPosition.y ?? 0) + offsetY, - // }, - // ], - // } - - return nodes.map((node) => { - return { - '@refLocalId': node.data.numericId, - '@formalParameter': node.data.outputConnector?.id === 'OUT' ? '' : node.data.outputConnector?.id || '', - position: [ - // Final edge destination - { - '@x': actualNode.data.inputConnector?.glbPosition.x ?? 0, - '@y': (actualNode.data.inputConnector?.glbPosition.y ?? 0) + offsetY, - }, - // Final position of parallel - { - '@x': lastParallelNode.data.parallelInputConnector?.glbPosition.x ?? 0, - '@y': (actualNode.data.inputConnector?.glbPosition.y ?? 0) + offsetY, - }, - // Initial position of parallel - { - '@x': lastParallelNode.data.parallelInputConnector?.glbPosition.x ?? 0, - '@y': (node.data.outputConnector?.glbPosition.y ?? 0) + offsetY, - }, - // Initial edge source - { - '@x': node.data.outputConnector?.glbPosition.x ?? 0, - '@y': (node.data.outputConnector?.glbPosition.y ?? 0) + offsetY, - }, - ], - } - }) - } - - // If the parallel node is closing the connection - const { nodes, parallels } = findNodesBasedOnParallelClose(parallelNode, rung) - const actualNode = node - - const firstParallelNode = parallels[0] - const closeConnections = nodes.map((node, index) => { - return { - '@refLocalId': node.data.numericId, - '@formalParameter': node.data.outputConnector?.id === 'OUT' ? '' : node.data.outputConnector?.id || '', - position: - index === 0 - ? [ - // Final edge destination - { - '@x': actualNode.data.inputConnector?.glbPosition.x ?? 0, - '@y': (actualNode.data.inputConnector?.glbPosition.y ?? 0) + offsetY, - }, - // Initial edge source - { - '@x': node.data.outputConnector?.glbPosition.x ?? 0, - '@y': (node.data.outputConnector?.glbPosition.y ?? 0) + offsetY, - }, - ] - : [ - // Final edge destination - { - '@x': actualNode.data.inputConnector?.glbPosition.x ?? 0, - '@y': (actualNode.data.inputConnector?.glbPosition.y ?? 0) + offsetY, - }, - // Final position of parallel - { - '@x': firstParallelNode.data.parallelInputConnector?.glbPosition.x ?? 0, - '@y': (actualNode.data.inputConnector?.glbPosition.y ?? 0) + offsetY, - }, - // Initial position of parallel - { - '@x': firstParallelNode.data.parallelInputConnector?.glbPosition.x ?? 0, - '@y': (node.data.outputConnector?.glbPosition.y ?? 0) + offsetY, - }, - // Initial edge source - { - '@x': node.data.outputConnector?.glbPosition.x ?? 0, - '@y': (node.data.outputConnector?.glbPosition.y ?? 0) + offsetY, - }, - ], - } - }) - - return closeConnections - }) - - return connections.flat().filter((connection) => connection !== undefined) as { - '@refLocalId': string - '@formalParameter': string - position: { - '@x': number - '@y': number - }[] - }[] -} + offsetY: number = 0, + targetHandle?: string, +) => findRungConnections(node, rung, offsetY, { targetHandle, formatFormalParameter }) /** * Parse nodes to XML @@ -465,6 +196,28 @@ const blockToXml = ( } } + // Secondary input handles can be wired by a handle-branch — a contact / + // coil / parallel chain that lives on the rung and feeds this exact + // handle id. Without picking those up here, the branch is serialized as + // disconnected nodes and the handle reads as an unbound variable, so + // the compiled program never sees the branch's boolean signal. + const branchConnections = findConnections(block, rung, offsetY, handle.id) + if (branchConnections.length > 0) { + return { + '@formalParameter': handle.id || '', + connectionPointIn: { + connection: branchConnections.map((connection) => { + const connectionNode = rung.nodes.find((node) => node.data.numericId === connection['@refLocalId']) + const formalParameter = connectionNode?.type === 'block' ? connection['@formalParameter'] : undefined + return { + '@refLocalId': connection['@refLocalId'], + '@formalParameter': formalParameter, + } + }), + }, + } + } + // Check if the handle is connected to an existing variable node const variableNode = rung.nodes.find( (node) => diff --git a/src/frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.ts b/src/frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.ts index f7a0b2eb5..c6ba99b6d 100644 --- a/src/frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.ts +++ b/src/frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.ts @@ -4,7 +4,6 @@ import type { BlockVariant, CoilNode, ContactNode, - ParallelNode, PowerRailNode, VariableNode, } from '@root/frontend/components/_atoms/graphical-editor/ladder/utils/types' @@ -21,256 +20,14 @@ import { } from '@root/middleware/shared/ports/xml-types/old-editor/pous/languages/ladder-diagram' import { Node } from '@xyflow/react' -/** - * Find the connections of a node in a rung. - */ -const findNodeBasedOnParallelOpen = ( - parallelNode: ParallelNode, - rung: RungLadderState, - path: { - nodes: Node[] - parallels: ParallelNode[] - } = { nodes: [], parallels: [] }, -) => { - const { nodes: rungNodes, edges: rungEdges } = rung +import { findConnections as findRungConnections } from '../../rung-graph' - const edgeToParallelNode = rungEdges.find((edge) => edge.target === parallelNode.id)?.source - const sourceNodeOfParallelNode = rungNodes.find((node) => node.id === edgeToParallelNode) as Node - path.parallels.push(parallelNode) - - if (sourceNodeOfParallelNode.type !== 'parallel') { - path.nodes.push(sourceNodeOfParallelNode) - return path - } else if ((sourceNodeOfParallelNode as ParallelNode).data.type === 'close') { - return findNodesBasedOnParallelClose(sourceNodeOfParallelNode as ParallelNode, rung, path) - } else { - return findNodeBasedOnParallelOpen(sourceNodeOfParallelNode as ParallelNode, rung, path) - } -} - -const findNodesBasedOnParallelClose = ( - parallelNode: ParallelNode, +const findConnections = ( + node: Node, rung: RungLadderState, - path: { - nodes: Node[] - parallels: ParallelNode[] - } = { nodes: [], parallels: [] }, -) => { - const { nodes: rungNodes, edges: rungEdges } = rung - - const edgesToParallelNode = rungEdges.filter((edge) => edge.target === parallelNode.id) - const serialNode = rungNodes.find((node) => - edgesToParallelNode.find( - (edge) => edge.source === node.id && edge.targetHandle === parallelNode.data.inputConnector?.id, - ), - ) as Node - - if (!path.nodes.includes(serialNode)) path.nodes.push(serialNode) - - const bottomNode = rungNodes.find((node) => - edgesToParallelNode.find( - (edge) => edge.source === node.id && edge.targetHandle === parallelNode.data.parallelInputConnector?.id, - ), - ) as Node - - path.parallels.push(parallelNode) - - if (bottomNode.type !== 'parallel') { - path.nodes.push(bottomNode) - return path - } - - return findNodesBasedOnParallelClose(bottomNode as ParallelNode, rung, path) -} - -const findConnections = (node: Node, rung: RungLadderState, offsetY: number = 0) => { - const { nodes: rungNodes, edges: rungEdges } = rung - - const connectedEdges = rungEdges.filter((edge) => edge.target === node.id) - if (!connectedEdges.length) return [] - - const connections = connectedEdges.map((edge) => { - const sourceNode = rungNodes.find((node) => node.id === edge.source) as Node - // If the source node is not found or it is a variable node, return undefined - if (!sourceNode || sourceNode.type === 'variable') return undefined - - // Node is not a parallel node - if (sourceNode.type !== 'parallel') { - return { - '@refLocalId': sourceNode.data.numericId, - '@formalParameter': sourceNode.data.outputConnector?.id, - position: [ - // Final edge destination - { - '@x': node.data.inputConnector?.glbPosition.x ?? 0, - '@y': (node.data.inputConnector?.glbPosition.y ?? 0) + offsetY, - }, - // Initial edge source - { - '@x': sourceNode.data.outputConnector?.glbPosition.x ?? 0, - '@y': (sourceNode.data.outputConnector?.glbPosition.y ?? 0) + offsetY, - }, - ], - } - } - - // Node is a parallel node - const parallelNode = sourceNode as ParallelNode - - /** - * TODO: This is a temporary solution to find the connections of a parallel node. - * This should be refactored so that the lines are placed correctly - */ - - // If the parallel node is opening the connection - if (parallelNode.data.type === 'open') { - // Find the previous node of the parallel node - const { nodes, parallels } = findNodeBasedOnParallelOpen(parallelNode, rung) - const actualNode = node - - const lastParallelNode = parallels - .filter((parallel) => parallel.data.type === 'open') - .reverse() - .copyWithin(0, 1)[0] - const lastParallelSerialEdge = rungEdges.find( - (edge) => - edge.source === lastParallelNode.id && edge.sourceHandle === lastParallelNode.data.outputConnector?.id, - ) - - // If the node is connected serially to the parallel node - if (lastParallelSerialEdge && lastParallelSerialEdge.target === actualNode.id) { - return nodes.map((node, index) => ({ - '@refLocalId': node.data.numericId, - '@formalParameter': node.data.outputConnector?.id, - position: - index === 0 - ? [ - // Final edge destination - { - '@x': actualNode.data.inputConnector?.glbPosition.x ?? 0, - '@y': (actualNode.data.inputConnector?.glbPosition.y ?? 0) + offsetY, - }, - // Initial edge source - { - '@x': node.data.outputConnector?.glbPosition.x ?? 0, - '@y': (node.data.outputConnector?.glbPosition.y ?? 0) + offsetY, - }, - ] - : [ - // Final edge destination - { - '@x': actualNode.data.inputConnector?.glbPosition.x ?? 0, - '@y': (actualNode.data.inputConnector?.glbPosition.y ?? 0) + offsetY, - }, - // Final position of parallel - { - '@x': lastParallelNode.data.parallelInputConnector?.glbPosition.x ?? 0, - '@y': (actualNode.data.inputConnector?.glbPosition.y ?? 0) + offsetY, - }, - // Initial position of parallel - { - '@x': lastParallelNode.data.parallelInputConnector?.glbPosition.x ?? 0, - '@y': (node.data.outputConnector?.glbPosition.y ?? 0) + offsetY, - }, - // Initial edge source - { - '@x': node.data.outputConnector?.glbPosition.x ?? 0, - '@y': (node.data.outputConnector?.glbPosition.y ?? 0) + offsetY, - }, - ], - })) - } - - return nodes.map((node) => { - return { - '@refLocalId': node.data.numericId, - '@formalParameter': node.data.outputConnector?.id, - position: [ - // Final edge destination - { - '@x': actualNode.data.inputConnector?.glbPosition.x ?? 0, - '@y': (actualNode.data.inputConnector?.glbPosition.y ?? 0) + offsetY, - }, - // Final position of parallel - { - '@x': lastParallelNode.data.parallelInputConnector?.glbPosition.x ?? 0, - '@y': (actualNode.data.inputConnector?.glbPosition.y ?? 0) + offsetY, - }, - // Initial position of parallel - { - '@x': lastParallelNode.data.parallelInputConnector?.glbPosition.x ?? 0, - '@y': (node.data.outputConnector?.glbPosition.y ?? 0) + offsetY, - }, - // Initial edge source - { - '@x': node.data.outputConnector?.glbPosition.x ?? 0, - '@y': (node.data.outputConnector?.glbPosition.y ?? 0) + offsetY, - }, - ], - } - }) - } - - // If the parallel node is closing the connection - const { nodes, parallels } = findNodesBasedOnParallelClose(parallelNode, rung) - const actualNode = node - - const firstParallelNode = parallels[0] - const closeConnections = nodes.map((node, index) => { - return { - '@refLocalId': node.data.numericId, - '@formalParameter': node.data.outputConnector?.id, - position: - index === 0 - ? [ - // Final edge destination - { - '@x': actualNode.data.inputConnector?.glbPosition.x ?? 0, - '@y': (actualNode.data.inputConnector?.glbPosition.y ?? 0) + offsetY, - }, - // Initial edge source - { - '@x': node.data.outputConnector?.glbPosition.x ?? 0, - '@y': (node.data.outputConnector?.glbPosition.y ?? 0) + offsetY, - }, - ] - : [ - // Final edge destination - { - '@x': actualNode.data.inputConnector?.glbPosition.x ?? 0, - '@y': (actualNode.data.inputConnector?.glbPosition.y ?? 0) + offsetY, - }, - // Final position of parallel - { - '@x': firstParallelNode.data.parallelInputConnector?.glbPosition.x ?? 0, - '@y': (actualNode.data.inputConnector?.glbPosition.y ?? 0) + offsetY, - }, - // Initial position of parallel - { - '@x': firstParallelNode.data.parallelInputConnector?.glbPosition.x ?? 0, - '@y': (node.data.outputConnector?.glbPosition.y ?? 0) + offsetY, - }, - // Initial edge source - { - '@x': node.data.outputConnector?.glbPosition.x ?? 0, - '@y': (node.data.outputConnector?.glbPosition.y ?? 0) + offsetY, - }, - ], - } - }) - - return closeConnections - }) - - return connections.flat().filter((connection) => connection !== undefined) as { - '@refLocalId': string - '@formalParameter': string - position: { - '@x': number - '@y': number - }[] - }[] -} + offsetY: number = 0, + targetHandle?: string, +) => findRungConnections(node, rung, offsetY, { targetHandle }) /** * Parse nodes to XML @@ -400,6 +157,25 @@ const blockToXml = (block: BlockNode, rung: RungLadderState, offse } } + // Secondary input handles can be wired by a handle-branch — a contact / + // coil / parallel chain that lives on the rung and feeds this exact + // handle id. Without picking those up here, the branch is serialized + // as disconnected nodes and the handle reads as an unbound variable, + // so the compiled program never sees the branch's boolean signal. + const branchConnections = findConnections(block, rung, offsetY, handle.id) + if (branchConnections.length > 0) { + return { + '@formalParameter': handle.id || '', + connectionPointIn: { + relPosition: { + '@x': handle.relPosition.x || 0, + '@y': handle.relPosition.y || 0, + }, + connection: branchConnections, + }, + } + } + // Check if the handle is connected to an existing variable node const variableNode = rung.nodes.find( (node) => diff --git a/src/frontend/utils/PLC/xml-generator/rung-graph.ts b/src/frontend/utils/PLC/xml-generator/rung-graph.ts new file mode 100644 index 000000000..b8bebd18b --- /dev/null +++ b/src/frontend/utils/PLC/xml-generator/rung-graph.ts @@ -0,0 +1,303 @@ +import type { + BasicNodeData, + ParallelNode, +} from '@root/frontend/components/_atoms/graphical-editor/ladder/utils/types' +import type { RungLadderState } from '@root/frontend/store/slices' +import type { Node } from '@xyflow/react' + +/** + * Shared rung-graph traversal helpers used by every ladder XML dialect. + * + * The two dialect formatters (codesys and old-editor) emit different XML, but + * walk the same rung graph and resolve the same set of incoming connections + * for each node. This module owns that traversal so each dialect only handles + * the dialect-specific output shape. + */ + +type RungConnectionPosition = { '@x': number; '@y': number } + +export type RungConnection = { + '@refLocalId': string + '@formalParameter': string + position: RungConnectionPosition[] +} + +export type FindConnectionsOptions = { + /** When provided, only edges whose `targetHandle` matches are considered. */ + targetHandle?: string + /** + * Optional dialect-specific formatter for the source node's output connector id, + * applied when populating each connection's `@formalParameter`. Defaults to the + * raw id with `''` substituted for undefined. + */ + formatFormalParameter?: (rawId: string | undefined) => string +} + +const defaultFormatFormalParameter = (rawId: string | undefined): string => rawId ?? '' + +/** + * Walk backward from a parallel-open node, collecting the upstream serial nodes + * and the parallel nodes encountered along the way. + */ +export const findNodeBasedOnParallelOpen = ( + parallelNode: ParallelNode, + rung: RungLadderState, + path: { + nodes: Node[] + parallels: ParallelNode[] + } = { nodes: [], parallels: [] }, +) => { + const { nodes: rungNodes, edges: rungEdges } = rung + + const edgeToParallelNode = rungEdges.find((edge) => edge.target === parallelNode.id)?.source + const sourceNodeOfParallelNode = rungNodes.find((node) => node.id === edgeToParallelNode) as Node + path.parallels.push(parallelNode) + + if (sourceNodeOfParallelNode.type !== 'parallel') { + path.nodes.push(sourceNodeOfParallelNode) + return path + } else if ((sourceNodeOfParallelNode as ParallelNode).data.type === 'close') { + return findNodesBasedOnParallelClose(sourceNodeOfParallelNode as ParallelNode, rung, path) + } else { + return findNodeBasedOnParallelOpen(sourceNodeOfParallelNode as ParallelNode, rung, path) + } +} + +/** + * Walk backward from a parallel-close node, collecting both the serial-spine + * upstream and the parallel-path upstream nodes plus all parallel nodes seen. + */ +export const findNodesBasedOnParallelClose = ( + parallelNode: ParallelNode, + rung: RungLadderState, + path: { + nodes: Node[] + parallels: ParallelNode[] + } = { nodes: [], parallels: [] }, +) => { + const { nodes: rungNodes, edges: rungEdges } = rung + + const edgesToParallelNode = rungEdges.filter((edge) => edge.target === parallelNode.id) + const serialNode = rungNodes.find((node) => + edgesToParallelNode.find( + (edge) => edge.source === node.id && edge.targetHandle === parallelNode.data.inputConnector?.id, + ), + ) as Node + + if (!path.nodes.includes(serialNode)) path.nodes.push(serialNode) + + const bottomNode = rungNodes.find((node) => + edgesToParallelNode.find( + (edge) => edge.source === node.id && edge.targetHandle === parallelNode.data.parallelInputConnector?.id, + ), + ) as Node + + path.parallels.push(parallelNode) + + if (bottomNode.type !== 'parallel') { + path.nodes.push(bottomNode) + return path + } + + return findNodesBasedOnParallelClose(bottomNode as ParallelNode, rung, path) +} + +/** + * Find all incoming connections for a node, resolving parallel sources into + * waypoint chains. Skips edges whose source is a variable node (variables are + * resolved separately by each dialect's block / variable formatter). + * + * When `options.targetHandle` is supplied, only edges whose `targetHandle` + * matches are considered — required when a node has multiple input handles + * that may be wired to different sources (e.g. a function block with branch + * contacts on individual input handles). + */ +export const findConnections = ( + node: Node, + rung: RungLadderState, + offsetY: number = 0, + options: FindConnectionsOptions = {}, +): RungConnection[] => { + const { nodes: rungNodes, edges: rungEdges } = rung + const { targetHandle, formatFormalParameter = defaultFormatFormalParameter } = options + + const connectedEdges = rungEdges.filter( + (edge) => edge.target === node.id && (targetHandle === undefined || edge.targetHandle === targetHandle), + ) + if (!connectedEdges.length) return [] + + const connections = connectedEdges.map((edge) => { + const sourceNode = rungNodes.find((node) => node.id === edge.source) as Node + // If the source node is not found or it is a variable node, return undefined + if (!sourceNode || sourceNode.type === 'variable') return undefined + + // Node is not a parallel node + if (sourceNode.type !== 'parallel') { + return { + '@refLocalId': sourceNode.data.numericId, + '@formalParameter': formatFormalParameter(sourceNode.data.outputConnector?.id), + position: [ + // Final edge destination + { + '@x': node.data.inputConnector?.glbPosition.x ?? 0, + '@y': (node.data.inputConnector?.glbPosition.y ?? 0) + offsetY, + }, + // Initial edge source + { + '@x': sourceNode.data.outputConnector?.glbPosition.x ?? 0, + '@y': (sourceNode.data.outputConnector?.glbPosition.y ?? 0) + offsetY, + }, + ], + } + } + + // Node is a parallel node + const parallelNode = sourceNode as ParallelNode + + /** + * TODO: This is a temporary solution to find the connections of a parallel node. + * This should be refactored so that the lines are placed correctly + */ + + // If the parallel node is opening the connection + if (parallelNode.data.type === 'open') { + // Find the previous node of the parallel node + const { nodes, parallels } = findNodeBasedOnParallelOpen(parallelNode, rung) + const actualNode = node + + const lastParallelNode = parallels + .filter((parallel) => parallel.data.type === 'open') + .reverse() + .copyWithin(0, 1)[0] + const lastParallelSerialEdge = rungEdges.find( + (edge) => + edge.source === lastParallelNode.id && edge.sourceHandle === lastParallelNode.data.outputConnector?.id, + ) + + // If the node is connected serially to the parallel node + if (lastParallelSerialEdge && lastParallelSerialEdge.target === actualNode.id) { + return nodes.map((node, index) => ({ + '@refLocalId': node.data.numericId, + '@formalParameter': formatFormalParameter(node.data.outputConnector?.id), + position: + index === 0 + ? [ + // Final edge destination + { + '@x': actualNode.data.inputConnector?.glbPosition.x ?? 0, + '@y': (actualNode.data.inputConnector?.glbPosition.y ?? 0) + offsetY, + }, + // Initial edge source + { + '@x': node.data.outputConnector?.glbPosition.x ?? 0, + '@y': (node.data.outputConnector?.glbPosition.y ?? 0) + offsetY, + }, + ] + : [ + // Final edge destination + { + '@x': actualNode.data.inputConnector?.glbPosition.x ?? 0, + '@y': (actualNode.data.inputConnector?.glbPosition.y ?? 0) + offsetY, + }, + // Final position of parallel + { + '@x': lastParallelNode.data.parallelInputConnector?.glbPosition.x ?? 0, + '@y': (actualNode.data.inputConnector?.glbPosition.y ?? 0) + offsetY, + }, + // Initial position of parallel + { + '@x': lastParallelNode.data.parallelInputConnector?.glbPosition.x ?? 0, + '@y': (node.data.outputConnector?.glbPosition.y ?? 0) + offsetY, + }, + // Initial edge source + { + '@x': node.data.outputConnector?.glbPosition.x ?? 0, + '@y': (node.data.outputConnector?.glbPosition.y ?? 0) + offsetY, + }, + ], + })) + } + + return nodes.map((node) => { + return { + '@refLocalId': node.data.numericId, + '@formalParameter': formatFormalParameter(node.data.outputConnector?.id), + position: [ + // Final edge destination + { + '@x': actualNode.data.inputConnector?.glbPosition.x ?? 0, + '@y': (actualNode.data.inputConnector?.glbPosition.y ?? 0) + offsetY, + }, + // Final position of parallel + { + '@x': lastParallelNode.data.parallelInputConnector?.glbPosition.x ?? 0, + '@y': (actualNode.data.inputConnector?.glbPosition.y ?? 0) + offsetY, + }, + // Initial position of parallel + { + '@x': lastParallelNode.data.parallelInputConnector?.glbPosition.x ?? 0, + '@y': (node.data.outputConnector?.glbPosition.y ?? 0) + offsetY, + }, + // Initial edge source + { + '@x': node.data.outputConnector?.glbPosition.x ?? 0, + '@y': (node.data.outputConnector?.glbPosition.y ?? 0) + offsetY, + }, + ], + } + }) + } + + // If the parallel node is closing the connection + const { nodes, parallels } = findNodesBasedOnParallelClose(parallelNode, rung) + const actualNode = node + + const firstParallelNode = parallels[0] + const closeConnections = nodes.map((node, index) => { + return { + '@refLocalId': node.data.numericId, + '@formalParameter': formatFormalParameter(node.data.outputConnector?.id), + position: + index === 0 + ? [ + // Final edge destination + { + '@x': actualNode.data.inputConnector?.glbPosition.x ?? 0, + '@y': (actualNode.data.inputConnector?.glbPosition.y ?? 0) + offsetY, + }, + // Initial edge source + { + '@x': node.data.outputConnector?.glbPosition.x ?? 0, + '@y': (node.data.outputConnector?.glbPosition.y ?? 0) + offsetY, + }, + ] + : [ + // Final edge destination + { + '@x': actualNode.data.inputConnector?.glbPosition.x ?? 0, + '@y': (actualNode.data.inputConnector?.glbPosition.y ?? 0) + offsetY, + }, + // Final position of parallel + { + '@x': firstParallelNode.data.parallelInputConnector?.glbPosition.x ?? 0, + '@y': (actualNode.data.inputConnector?.glbPosition.y ?? 0) + offsetY, + }, + // Initial position of parallel + { + '@x': firstParallelNode.data.parallelInputConnector?.glbPosition.x ?? 0, + '@y': (node.data.outputConnector?.glbPosition.y ?? 0) + offsetY, + }, + // Initial edge source + { + '@x': node.data.outputConnector?.glbPosition.x ?? 0, + '@y': (node.data.outputConnector?.glbPosition.y ?? 0) + offsetY, + }, + ], + } + }) + + return closeConnections + }) + + return connections.flat().filter((connection) => connection !== undefined) as RungConnection[] +} diff --git a/src/middleware/shared/ports/flow-schemas.ts b/src/middleware/shared/ports/flow-schemas.ts index f21dbb5ab..c2b44a73f 100644 --- a/src/middleware/shared/ports/flow-schemas.ts +++ b/src/middleware/shared/ports/flow-schemas.ts @@ -15,27 +15,55 @@ const nodeSchema = z.object({ height: z.number(), }) .optional(), - draggable: z.boolean(), - selectable: z.boolean(), + // ReactFlow's emitted node updates may omit these; our builders always set + // them but legacy projects or third-party producers may not. + draggable: z.boolean().optional(), + selectable: z.boolean().optional(), data: z.any(), }) const edgeSchema = z.object({ id: z.string(), source: z.string(), - sourceHandle: z.string(), + // xyflow's `Edge` type allows `string | null | undefined` for handles. + // Strict `z.string()` rejects valid edges with null handles emitted by + // ReactFlow itself. + sourceHandle: z.string().nullable().optional(), target: z.string(), - targetHandle: z.string(), + targetHandle: z.string().nullable().optional(), type: z.string().optional(), }) +/** + * Per-rung index of handle branches — mini-rungs of contacts / coils that + * hang off a function-block input or output handle. The structural data + * (the contact / coil nodes themselves and the edges that wire them up) + * lives in `nodes` / `edges`; this index is a denormalized lookup that + * makes branch operations O(1) instead of O(rung). + * + * `nodeIds` is the SERIAL spine of the branch only — parallel-path elements + * (the OR alternative inside a branch's parallel) are reachable via edge + * traversal from the OPEN/CLOSE pair on the spine. + */ +const zodHandleBranchSchema = z.object({ + blockId: z.string(), + handleId: z.string(), + direction: z.enum(['input', 'output']), + nodeIds: z.array(z.string()), +}) + +// Default rung bounds — matches the value `startLadderRung` uses when creating +// a fresh rung. Falls back here only if a saved rung is missing the field. +const DEFAULT_RUNG_BOUNDS = [1530, 200] + const zodRungLadderStateSchema = z.object({ id: z.string(), comment: z.string().default(''), - defaultBounds: z.array(z.number()), - reactFlowViewport: z.array(z.number()), - nodes: z.array(nodeSchema), - edges: z.array(edgeSchema), + defaultBounds: z.array(z.number()).default(DEFAULT_RUNG_BOUNDS), + reactFlowViewport: z.array(z.number()).default(DEFAULT_RUNG_BOUNDS), + nodes: z.array(nodeSchema).default([]), + edges: z.array(edgeSchema).default([]), + handleBranches: z.array(zodHandleBranchSchema).default([]), }) const zodLadderFlowSchema = z.object({ @@ -59,6 +87,7 @@ export { nodeSchema, zodFBDFlowSchema, zodFBDRungStateSchema, + zodHandleBranchSchema, zodLadderFlowSchema, zodRungLadderStateSchema, } diff --git a/src/middleware/shared/ports/types.ts b/src/middleware/shared/ports/types.ts index e9c7745c4..b73e96484 100644 --- a/src/middleware/shared/ports/types.ts +++ b/src/middleware/shared/ports/types.ts @@ -776,6 +776,19 @@ export type FBDRungState = { edges: import('@xyflow/react').Edge[] } +/** + * Per-rung handle-branch index. Mirrors `zodHandleBranchSchema`. The + * structural truth lives in `nodes` / `edges`; this entry is a denormalized + * lookup of which contacts / coils hang off a given block handle. + */ +export type HandleBranch = { + blockId: string + handleId: string + direction: 'input' | 'output' + /** Serial spine only — parallel-path elements are reachable via edges. */ + nodeIds: string[] +} + /** * Ladder rung data — nodes + edges + layout for one Ladder rung. * Used by both the store slice and the compiler adapter. @@ -788,6 +801,7 @@ export type RungLadderState = { selectedNodes: import('@xyflow/react').Node[] nodes: import('@xyflow/react').Node[] edges: import('@xyflow/react').Edge[] + handleBranches: HandleBranch[] } // --------------------------------------------------------------------------- From 7619bb3e12091ae38e4c2dc99e25fadbbf7363df Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Thu, 7 May 2026 23:29:06 -0300 Subject: [PATCH 2/7] fix(ports): re-export HandleBranch / RungLadderState from ports barrel Mirror of openplc-web fix: the ports barrel didn't re-export the ladder-branch types, so TS resolved them as unknown across the rung layer and the build / lint jobs lit up. Adding the two type re-exports clears the cascade; the autofix sweep removes type assertions that were only needed while the types were broken. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../_atoms/graphical-editor/ladder/coil.tsx | 2 +- .../graphical-editor/ladder/contact.tsx | 2 +- .../elements/handle-branch/index.ts | 34 +++++++------- .../ladder-utils/elements/parallel/index.ts | 2 +- .../rung/ladder-utils/elements/utils/index.ts | 12 ++--- .../codesys/language/ladder-xml.ts | 44 +++++++++---------- .../old-editor/language/ladder-xml.ts | 26 +++++------ src/middleware/shared/ports/index.ts | 4 ++ 8 files changed, 65 insertions(+), 61 deletions(-) diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx index 66661153f..6529035ad 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/coil.tsx @@ -291,7 +291,7 @@ export const Coil = (block: CoilProps) => { rungId: rung.id, node: { ...node, - draggable: node.data.draggable as boolean, + draggable: node.data.draggable, }, }) return diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx index f9f67a074..22af7509e 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/contact.tsx @@ -289,7 +289,7 @@ export const Contact = (block: ContactProps) => { rungId: rung.id, node: { ...node, - draggable: node.data.draggable as boolean, + draggable: node.data.draggable, }, }) return diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/handle-branch/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/handle-branch/index.ts index be2c93535..64e2ed3be 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/handle-branch/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/handle-branch/index.ts @@ -71,7 +71,7 @@ export const buildBranchRailId = (blockId: string, handleId: string, direction: export const findBranchRail = (rung: RungLadderState, branch: HandleBranch): PowerRailNode | undefined => { const id = buildBranchRailId(branch.blockId, branch.handleId, branch.direction) const node = rung.nodes.find((n) => n.id === id) - return node?.type === 'powerRail' ? (node as PowerRailNode) : undefined + return node?.type === 'powerRail' ? (node) : undefined } export const hasBranchOnHandle = ( @@ -151,8 +151,8 @@ export const addRailBranchHandle = ( const blockHandle = block && block.type === 'block' ? params.direction === 'input' - ? (block as BlockNode).data.inputHandles.find((h) => h.id === params.handleId) - : (block as BlockNode).data.outputHandles.find((h) => h.id === params.handleId) + ? (block).data.inputHandles.find((h) => h.id === params.handleId) + : (block).data.outputHandles.find((h) => h.id === params.handleId) : undefined if (!blockHandle) return { nodes: rung.nodes } @@ -555,7 +555,7 @@ export const renderInBranchSplicePlaceholders = (rung: RungLadderState): Node[] if (!node) return if (node.type === 'parallel') { - const ptype = (node as ParallelNode).data.type + const ptype = (node).data.type if (ptype === 'open') { const leftX = node.position.x - SIDE_GAP - DEFAULT_PLACEHOLDER_WIDTH / 2 placeholders.push(buildSplicePlaceholder(branch, leftX, posY, handleY, idx, `${idx}_left`)) @@ -1144,17 +1144,17 @@ export const renderInBranchParallelPlaceholders = (rung: RungLadderState): Node[ branch.nodeIds.forEach((id) => { const node = rung.nodes.find((n) => n.id === id) if (node?.type === 'parallel') { - const ptype = (node as ParallelNode).data.type + const ptype = (node).data.type if (ptype === 'open') { depth++ - currentOpen = node as ParallelNode + currentOpen = node } else if (ptype === 'close') { depth-- currentOpen = null } } else if (depth > 0 && (node?.type === 'contact' || node?.type === 'coil')) { insideParallel.add(node.id) - if (currentOpen) aboveContactByOpen.set((currentOpen as ParallelNode).id, node) + if (currentOpen) aboveContactByOpen.set((currentOpen).id, node) } }) @@ -1215,8 +1215,8 @@ export const renderInBranchParallelPlaceholders = (rung: RungLadderState): Node[ branch.nodeIds.forEach((id, idx) => { const node = rung.nodes.find((n) => n.id === id) if (node?.type !== 'parallel') return - if ((node as ParallelNode).data.type !== 'open') return - const open = node as ParallelNode + if ((node).data.type !== 'open') return + const open = node const aboveContact = aboveContactByOpen.get(open.id) if (!aboveContact) return @@ -1233,7 +1233,7 @@ export const renderInBranchParallelPlaceholders = (rung: RungLadderState): Node[ // Bottom-most path = last start edge (highest pathIndex). const lastStartEdge = startEdges[startEdges.length - 1] - const bottomPath = walkParallelPath(rung, open, closeNode as ParallelNode, lastStartEdge) + const bottomPath = walkParallelPath(rung, open, closeNode, lastStartEdge) const aboveContactIdx = branch.nodeIds.indexOf(aboveContact.id) bottomPath.forEach((pNode, pIdx) => { if (pNode.type !== 'contact' && pNode.type !== 'coil') return @@ -1300,13 +1300,13 @@ export const renderInBranchParallelPathPlaceholders = (rung: RungLadderState): N branch.nodeIds.forEach((id) => { const node = rung.nodes.find((n) => n.id === id) if (node?.type !== 'parallel') return - if ((node as ParallelNode).data.type !== 'open') return - const open = node as ParallelNode + if ((node).data.type !== 'open') return + const open = node const closeId = open.data.parallelCloseReference if (!closeId) return const closeNode = rung.nodes.find((n) => n.id === closeId) if (!closeNode || closeNode.type !== 'parallel') return - const close = closeNode as ParallelNode + const close = closeNode const parallelOutputId = open.data.parallelOutputConnector?.id if (!parallelOutputId) return @@ -1839,7 +1839,7 @@ export const computeBranchSpanWidth = (rung: RungLadderState, branch: HandleBran ) let maxPathWidth = 0 for (const startEdge of startEdges) { - const pathNodes = walkParallelPath(rung, open, close as ParallelNode, startEdge) + const pathNodes = walkParallelPath(rung, open, close, startEdge) if (pathNodes.length === 0) continue const totalWidth = pathNodes.reduce((sum, n) => sum + (n.width ?? DEFAULT_CONTACT_BLOCK_WIDTH), 0) + @@ -1861,7 +1861,7 @@ export const computeBranchSpanWidth = (rung: RungLadderState, branch: HandleBran const n = rung.nodes.find((n2) => n2.id === branch.nodeIds[j]) if (!n) break if (n.type === 'parallel') { - const ptype = (n as ParallelNode).data.type + const ptype = (n).data.type if (ptype === 'close' && depth === 0) break if (ptype === 'open') depth++ else if (ptype === 'close') depth-- @@ -2254,7 +2254,7 @@ export const calculateBranchElementPositions = ( startEdges.forEach((startEdge) => { const close = rung.nodes.find((n) => n.id === closeId) if (!close || close.type !== 'parallel') return - const pathNodes = walkParallelPath(rung, open, close as ParallelNode, startEdge) + const pathNodes = walkParallelPath(rung, open, close, startEdge) if (pathNodes.length === 0) return // Path width = element widths + n*2*contact.gap. The 2*contact.gap // factor matches what the spine charges per element-pair (45 contact @@ -2280,7 +2280,7 @@ export const calculateBranchElementPositions = ( const n = rung.nodes.find((n2) => n2.id === branch.nodeIds[j]) if (!n) break if (n.type === 'parallel') { - const ptype = (n as ParallelNode).data.type + const ptype = (n).data.type if (ptype === 'close' && depth === 0) break if (ptype === 'open') depth++ else if (ptype === 'close') depth-- diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/parallel/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/parallel/index.ts index f71f337da..e3157ea01 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/parallel/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/parallel/index.ts @@ -294,7 +294,7 @@ export const removeEmptyParallelConnections = (rung: RungLadderState): { nodes: nodes.forEach((node) => { if (node.type === 'parallel') { - const parallelNode = node as ParallelNode + const parallelNode = node // check if it is an open parallel if (parallelNode.data.type === 'close') { const closeParallel = parallelNode diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts index 5b0bb5e35..e14a1593d 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts @@ -232,15 +232,15 @@ export const findParallelsInRung = (rung: RungLadderState): ParallelNode[] => { let isAnotherParallel = true let parallel: ParallelNode | undefined = undefined rung.nodes.forEach((node) => { - if (isAnotherParallel && node.type === 'parallel' && (node as ParallelNode).data.type === 'open') { + if (isAnotherParallel && node.type === 'parallel' && (node).data.type === 'open') { parallels.push(node) - parallel = node as ParallelNode + parallel = node isAnotherParallel = false } if ( !isAnotherParallel && node.type === 'parallel' && - (node as ParallelNode).data.type === 'close' && + (node).data.type === 'close' && parallel?.data.parallelCloseReference === node.id ) { isAnotherParallel = true @@ -261,8 +261,8 @@ export const findDeepestParallelInsideParallel = (rung: RungLadderState, paralle const parallelIndex = rung.nodes.findIndex((node) => node.id === parallel.id) for (let i = parallelIndex; i < rung.nodes.length; i++) { const node = rung.nodes[i] - if (node.type === 'parallel' && (node as ParallelNode).data.type === 'close') { - return node as ParallelNode + if (node.type === 'parallel' && (node).data.type === 'close') { + return node } } return parallel as ParallelNode @@ -435,7 +435,7 @@ export const getDeepestNodesInsideParallels = (rung: RungLadderState): Node[] => */ export const getNodesInsideAllParallels = (rung: RungLadderState): Node[] => { const closeParallels = rung.nodes.filter( - (node) => node.type === 'parallel' && (node as ParallelNode).data.type === 'close', + (node) => node.type === 'parallel' && (node).data.type === 'close', ) const nodes: Node[] = [] closeParallels.forEach((closeParallel) => { diff --git a/src/frontend/utils/PLC/xml-generator/codesys/language/ladder-xml.ts b/src/frontend/utils/PLC/xml-generator/codesys/language/ladder-xml.ts index 0c95d0957..f3b38e328 100644 --- a/src/frontend/utils/PLC/xml-generator/codesys/language/ladder-xml.ts +++ b/src/frontend/utils/PLC/xml-generator/codesys/language/ladder-xml.ts @@ -76,7 +76,7 @@ const contactToXML = ( const connections = findConnections(contact, rung, offsetY) const railConnection = connections.find((connection) => { - const rail = rung.nodes.find((node) => node.type === 'powerRail' && (node as PowerRailNode).data.variant === 'left') + const rail = rung.nodes.find((node) => node.type === 'powerRail' && (node).data.variant === 'left') if (rail?.data.numericId === connection['@refLocalId']) { return true } @@ -100,8 +100,8 @@ const contactToXML = ( const refLocalId = railConnection ? leftRailId.toString() : connection['@refLocalId'] const formalParameter = connectionNode?.type === 'block' - ? (connectionNode as BlockNode).data.variant.type === 'function' - ? (connectionNode as BlockNode).data.variant.name + ? (connectionNode).data.variant.type === 'function' + ? (connectionNode).data.variant.name : connection['@formalParameter'] : undefined return { @@ -119,7 +119,7 @@ const coilToXml = (coil: CoilNode, rung: RungLadderState, offsetY: number = 0, l const connections = findConnections(coil, rung, offsetY) const railConnection = connections.find((connection) => { - const rail = rung.nodes.find((node) => node.type === 'powerRail' && (node as PowerRailNode).data.variant === 'left') + const rail = rung.nodes.find((node) => node.type === 'powerRail' && (node).data.variant === 'left') if (rail?.data.numericId === connection['@refLocalId']) { return true } @@ -144,8 +144,8 @@ const coilToXml = (coil: CoilNode, rung: RungLadderState, offsetY: number = 0, l const refLocalId = railConnection ? leftRailId.toString() : connection['@refLocalId'] const formalParameter = connectionNode?.type === 'block' - ? (connectionNode as BlockNode).data.variant.type === 'function' - ? (connectionNode as BlockNode).data.variant.name + ? (connectionNode).data.variant.type === 'function' + ? (connectionNode).data.variant.name : connection['@formalParameter'] : undefined return { @@ -169,7 +169,7 @@ const blockToXml = ( // If the block is connected to a power rail, replace the refLocalId with the left rail id at connections const railConnection = connections.find((connection) => { - const rail = rung.nodes.find((node) => node.type === 'powerRail' && (node as PowerRailNode).data.variant === 'left') + const rail = rung.nodes.find((node) => node.type === 'powerRail' && (node).data.variant === 'left') if (rail?.data.numericId === connection['@refLocalId']) { return true } @@ -222,8 +222,8 @@ const blockToXml = ( const variableNode = rung.nodes.find( (node) => node.type === 'variable' && - (node as VariableNode).data.block.id === block.id && - (node as VariableNode).data.block.handleId === handle.id, + (node).data.block.id === block.id && + (node).data.block.handleId === handle.id, ) as Node if (!variableNode) return undefined @@ -250,7 +250,7 @@ const blockToXml = ( expression: handleIndex !== 0 ? connectedNode && connectedNode.type === 'variable' - ? (connectedNode as VariableNode).data.variable.name + ? (connectedNode).data.variable.name : '' : undefined, }, @@ -359,30 +359,30 @@ const ladderToXml = (rungs: RungLadderState[]) => { nodes.forEach((node) => { switch (node.type) { case 'powerRail': - if ((node as PowerRailNode).data.variant === 'left' && ladderXML.body.LD.leftPowerRail.length === 0) { - ladderXML.body.LD.leftPowerRail.push(leftRailToXML(node as PowerRailNode, offsetY)) - leftRailId = (node as PowerRailNode).data.numericId + if ((node).data.variant === 'left' && ladderXML.body.LD.leftPowerRail.length === 0) { + ladderXML.body.LD.leftPowerRail.push(leftRailToXML(node, offsetY)) + leftRailId = (node).data.numericId } else { if (ladderXML.body.LD.rightPowerRail.length === 0) { - ladderXML.body.LD.rightPowerRail.push(rightRailToXML(node as PowerRailNode, rung, offsetY)) + ladderXML.body.LD.rightPowerRail.push(rightRailToXML(node, rung, offsetY)) } } break case 'contact': - ladderXML.body.LD.contact.push(contactToXML(node as ContactNode, rung, offsetY, leftRailId)) + ladderXML.body.LD.contact.push(contactToXML(node, rung, offsetY, leftRailId)) break case 'coil': - ladderXML.body.LD.coil.push(coilToXml(node as CoilNode, rung, offsetY, leftRailId)) + ladderXML.body.LD.coil.push(coilToXml(node, rung, offsetY, leftRailId)) break case 'block': - ladderXML.body.LD.block.push(blockToXml(node as BlockNode, rung, offsetY, leftRailId)) + ladderXML.body.LD.block.push(blockToXml(node, rung, offsetY, leftRailId)) break case 'variable': - if ((node as VariableNode).data.variable.name === '') return - if ((node as VariableNode).data.variant === 'input') - ladderXML.body.LD.inVariable.push(inVariableToXML(node as VariableNode, offsetY)) - if ((node as VariableNode).data.variant === 'output') { - const outVarXML = outVariableToXML(node as VariableNode, rung, offsetY) + if ((node).data.variable.name === '') return + if ((node).data.variant === 'input') + ladderXML.body.LD.inVariable.push(inVariableToXML(node, offsetY)) + if ((node).data.variant === 'output') { + const outVarXML = outVariableToXML(node, rung, offsetY) if (outVarXML) ladderXML.body.LD.outVariable.push(outVarXML) } break diff --git a/src/frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.ts b/src/frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.ts index c6ba99b6d..6ba280b12 100644 --- a/src/frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.ts +++ b/src/frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.ts @@ -180,8 +180,8 @@ const blockToXml = (block: BlockNode, rung: RungLadderState, offse const variableNode = rung.nodes.find( (node) => node.type === 'variable' && - (node as VariableNode).data.block.id === block.id && - (node as VariableNode).data.block.handleId === handle.id, + (node).data.block.id === block.id && + (node).data.block.handleId === handle.id, ) as Node if (!variableNode) return undefined @@ -334,25 +334,25 @@ const ladderToXml = (rungs: RungLadderState[]) => { nodes.forEach((node) => { switch (node.type) { case 'powerRail': - if ((node as PowerRailNode).data.variant === 'left') - ladderXML.body.LD.leftPowerRail.push(leftRailToXML(node as PowerRailNode, offsetY)) - else ladderXML.body.LD.rightPowerRail.push(rightRailToXML(node as PowerRailNode, rung, offsetY)) + if ((node).data.variant === 'left') + ladderXML.body.LD.leftPowerRail.push(leftRailToXML(node, offsetY)) + else ladderXML.body.LD.rightPowerRail.push(rightRailToXML(node, rung, offsetY)) break case 'contact': - ladderXML.body.LD.contact.push(contactToXML(node as ContactNode, rung, offsetY)) + ladderXML.body.LD.contact.push(contactToXML(node, rung, offsetY)) break case 'coil': - ladderXML.body.LD.coil.push(coilToXml(node as CoilNode, rung, offsetY)) + ladderXML.body.LD.coil.push(coilToXml(node, rung, offsetY)) break case 'block': - ladderXML.body.LD.block.push(blockToXml(node as BlockNode, rung, offsetY)) + ladderXML.body.LD.block.push(blockToXml(node, rung, offsetY)) break case 'variable': - if ((node as VariableNode).data.variable.name === '') return - if ((node as VariableNode).data.variant === 'input') - ladderXML.body.LD.inVariable.push(inVariableToXML(node as VariableNode, offsetY)) - if ((node as VariableNode).data.variant === 'output') - ladderXML.body.LD.outVariable.push(outVariableToXML(node as VariableNode, rung, offsetY)) + if ((node).data.variable.name === '') return + if ((node).data.variant === 'input') + ladderXML.body.LD.inVariable.push(inVariableToXML(node, offsetY)) + if ((node).data.variant === 'output') + ladderXML.body.LD.outVariable.push(outVariableToXML(node, rung, offsetY)) break default: break diff --git a/src/middleware/shared/ports/index.ts b/src/middleware/shared/ports/index.ts index eba5e51d4..abdd08268 100644 --- a/src/middleware/shared/ports/index.ts +++ b/src/middleware/shared/ports/index.ts @@ -91,6 +91,8 @@ export type { DevicePin, // Debugger session FbInstanceInfo, + // Ladder branch index (denormalized lookup of contacts/coils on FB handles) + HandleBranch, // Console LogObject, Md5VerifyResult, @@ -118,6 +120,8 @@ export type { RecentProject, // Result wrappers Result, + // Ladder rung shared shape + RungLadderState, RuntimeLogEntry, RuntimeLogLevel, SerialPort, From 986b4a0ab0e405ebe95ce2c3e85745473e21d7cd Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Mon, 11 May 2026 12:36:29 -0300 Subject: [PATCH 3/7] sync(ladder): pull latest handle-branch changes from web Co-Authored-By: Claude Opus 4.7 (1M context) --- .../_atoms/graphical-editor/fbd/block.tsx | 6 +- .../graphical-editor/fbd/utils/utils.ts | 2 +- .../ladder/autocomplete/index.tsx | 29 +- .../_atoms/graphical-editor/ladder/block.tsx | 16 +- .../graphical-editor/ladder/utils/types.ts | 25 ++ .../graphical/elements/ladder/block/index.tsx | 8 +- .../graphical-editor/ladder/rung/body.tsx | 32 ++- .../ladder/rung/ladder-utils/edges.ts | 31 +- .../ladder-utils/elements/diagram/index.ts | 40 ++- .../elements/drag-n-drop/index.ts | 20 +- .../elements/handle-branch/index.ts | 265 +++++++----------- .../rung/ladder-utils/elements/index.ts | 56 ++-- .../ladder-utils/elements/parallel/index.ts | 38 +-- .../elements/placeholder/index.ts | 5 +- .../ladder-utils/elements/serial/index.ts | 27 +- .../rung/ladder-utils/elements/utils/index.ts | 14 +- .../elements/variable-block/index.ts | 4 +- .../ladder/rung/ladder-utils/nodes.ts | 17 +- src/frontend/store/slices/ladder/slice.ts | 20 +- src/frontend/store/slices/ladder/types.ts | 5 +- .../codesys/language/ladder-xml.ts | 32 +-- .../old-editor/language/ladder-xml.ts | 16 +- .../utils/PLC/xml-generator/rung-graph.ts | 9 +- src/middleware/shared/ports/ai-port.ts | 4 + 24 files changed, 355 insertions(+), 366 deletions(-) diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/block.tsx b/src/frontend/components/_atoms/graphical-editor/fbd/block.tsx index 76b58d56d..73e142ef3 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/block.tsx +++ b/src/frontend/components/_atoms/graphical-editor/fbd/block.tsx @@ -558,7 +558,7 @@ export const Block = (block: BlockProps) => { const libPou = pous.find((pou) => pou.name === libMatch.name) if (!libPou) return - const blockVariant = node.data.variant as BlockVariant + const blockVariant = (node.data as BlockNodeData).variant const newNodeVariables = (libPou.interface?.variables ?? []).map((variable) => { let newType switch (variable.type.definition) { @@ -632,10 +632,10 @@ export const Block = (block: BlockProps) => { const newNode = { ...updatedNewNode } - const originalNodeInputs = (node.data.variant as BlockVariant).variables.filter( + const originalNodeInputs = (node.data as BlockNodeData).variant.variables.filter( (variable) => variable.class === 'input' || variable.class === 'inOut', ) - const originalNodeSources = (node.data.variant as BlockVariant).variables.filter( + const originalNodeSources = (node.data as BlockNodeData).variant.variables.filter( (variable) => variable.class === 'output' || variable.class === 'inOut', ) diff --git a/src/frontend/components/_atoms/graphical-editor/fbd/utils/utils.ts b/src/frontend/components/_atoms/graphical-editor/fbd/utils/utils.ts index 5d94c4f20..a2e3c635a 100644 --- a/src/frontend/components/_atoms/graphical-editor/fbd/utils/utils.ts +++ b/src/frontend/components/_atoms/graphical-editor/fbd/utils/utils.ts @@ -25,7 +25,7 @@ export const getFBDPouVariablesRungNodeAndEdges = ( source: LadderFlowType['rungs'][0]['edges'] | undefined target: LadderFlowType['rungs'][0]['edges'] | undefined } - node: LadderFlowType['rungs'][0]['nodes'][0] | undefined + node: FBDFlowType['rung']['nodes'][0] | undefined } => { const pou = pous.find((pou) => pou.name === editor.meta.name) const rung = fbdFlows.find((flow) => flow.name === editor.meta.name)?.rung diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx index ef6671f52..ed2e41386 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx @@ -12,7 +12,14 @@ import { toast } from '../../../../_features/[app]/toast/use-toast' import { GraphicalEditorAutocomplete } from '../../autocomplete' import { getVariableRestrictionType } from '../../utils' import { getLadderPouVariablesRungNodeAndEdges } from '../utils' -import { BasicNodeData, BlockNodeData, BlockVariant, LadderBlockConnectedVariables, VariableNode } from '../utils/types' +import { + BasicNodeData, + BlockNodeData, + BlockVariant, + isRungNodeOfType, + LadderBlockConnectedVariables, + VariableNode, +} from '../utils/types' type VariablesBlockAutoCompleteProps = ComponentPropsWithRef<'div'> & { block: unknown @@ -121,11 +128,14 @@ const VariablesBlockAutoComplete = forwardRef node.id === (variableNode as VariableNode).data.block.id) + const relatedBlock = rung.nodes.find((node) => node.id === variableData.block.id) if (!relatedBlock) return const existingConnected = Array.isArray((relatedBlock.data as BlockNodeData).connectedVariables) @@ -133,15 +143,14 @@ const VariablesBlockAutoComplete = forwardRef - v.type !== variableNode.data.variant || v.handleId !== (variableNode as VariableNode).data.block.handleId, + (v) => v.type !== variableData.variant || v.handleId !== variableData.block.handleId, ), { - handleId: (variableNode as VariableNode).data.block.handleId, + handleId: variableData.block.handleId, handleTableId: (relatedBlock.data as BlockNodeData).variant.variables.find( - (v) => v.name === (variableNode as VariableNode).data.block.handleId, + (v) => v.name === variableData.block.handleId, )?.id, - type: (variableNode as VariableNode).data.variant, + type: variableData.variant, variable: variable, }, ] diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx b/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx index 849326013..1aa177982 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx +++ b/src/frontend/components/_atoms/graphical-editor/ladder/block.tsx @@ -266,7 +266,7 @@ export const BlockNodeElement = ({ } } - let newNodes = [...rung.nodes] + let newNodes: typeof rung.nodes = [...rung.nodes] let newEdges = [...rung.edges] /** @@ -287,7 +287,7 @@ export const BlockNodeElement = ({ variable: variable ?? { name: '' }, } - newNodes = newNodes.map((n) => (n.id === node.id ? newBlockNode : n)) + newNodes = newNodes.map((n) => (n.id === node.id ? newBlockNode : n)) as typeof newNodes edges.source?.forEach((edge) => { const newEdge = { @@ -312,7 +312,7 @@ export const BlockNodeElement = ({ // - drop branches whose handle disappeared or stopped being BOOL // - remap surviving branches' references to the new block id const reconciled = reconcileBranches( - { ...rung, nodes: newNodes, edges: newEdges }, + { ...rung, nodes: newNodes, edges: newEdges } as typeof rung, node.id, newBlockNode.id, libraryBlock as BlockVariant, @@ -324,7 +324,7 @@ export const BlockNodeElement = ({ nodes: reconciled.nodes, edges: reconciled.edges, handleBranches: reconciled.handleBranches, - }, + } as typeof rung, [rung.defaultBounds[0], rung.defaultBounds[1]], ) @@ -651,7 +651,7 @@ export const Block = (block: BlockProps) => { const libPou = pous.find((pou) => pou.name === libMatch.name) if (!libPou) return - const blockVariant = node.data.variant as BlockVariant + const blockVariant = (node.data as BlockNodeData).variant const newNodeVariables = (libPou.interface?.variables ?? []).map((variable) => { let newType switch (variable.type.definition) { @@ -749,10 +749,10 @@ export const Block = (block: BlockProps) => { const newBlockNode = { ...updatedNewNode } - let newNodes = [...rung.nodes] + let newNodes: typeof rung.nodes = [...rung.nodes] let newEdges = [...rung.edges] - newNodes = newNodes.map((n) => (n.id === node.id ? newBlockNode : n)) + newNodes = newNodes.map((n) => (n.id === node.id ? newBlockNode : n)) as typeof newNodes edges.source?.forEach((edge) => { const newEdge = { @@ -778,7 +778,7 @@ export const Block = (block: BlockProps) => { ...rung, nodes: newNodes, edges: newEdges, - }, + } as typeof rung, [rung.defaultBounds[0], rung.defaultBounds[1]], ) diff --git a/src/frontend/components/_atoms/graphical-editor/ladder/utils/types.ts b/src/frontend/components/_atoms/graphical-editor/ladder/utils/types.ts index 0e4778762..4f0dfac7f 100644 --- a/src/frontend/components/_atoms/graphical-editor/ladder/utils/types.ts +++ b/src/frontend/components/_atoms/graphical-editor/ladder/utils/types.ts @@ -232,3 +232,28 @@ export type RungNode = | PlaceholderNode | PowerRailNode | VariableNode + +/** + * Type predicate that narrows a generic `Node` (or a `RungNode`) to a specific + * `RungNode` union member by its `type` discriminator. Lets callers do + * `if (isRungNodeOfType(node, 'block')) { node.data.variant ... }` without an + * `as BlockNode<...>` cast. + * + * Implemented as overloads (one per union member) instead of a single generic + * `Extract` — TS's narrowing on the latter occasionally + * collapses to `never` when chained with other refinements; the explicit + * overload form is more robust. + */ +export function isRungNodeOfType(node: Node | RungNode, nodeType: 'block'): node is BlockNode +export function isRungNodeOfType(node: Node | RungNode, nodeType: 'coil'): node is CoilNode +export function isRungNodeOfType(node: Node | RungNode, nodeType: 'contact'): node is ContactNode +export function isRungNodeOfType(node: Node | RungNode, nodeType: 'parallel'): node is ParallelNode +export function isRungNodeOfType( + node: Node | RungNode, + nodeType: 'placeholder' | 'parallelPlaceholder', +): node is PlaceholderNode +export function isRungNodeOfType(node: Node | RungNode, nodeType: 'powerRail'): node is PowerRailNode +export function isRungNodeOfType(node: Node | RungNode, nodeType: 'variable'): node is VariableNode +export function isRungNodeOfType(node: Node | RungNode, nodeType: string): boolean { + return node.type === nodeType +} diff --git a/src/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/block/index.tsx b/src/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/block/index.tsx index 865634d76..79ab79762 100644 --- a/src/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/block/index.tsx +++ b/src/frontend/components/_features/[workspace]/editor/graphical/elements/ladder/block/index.tsx @@ -181,7 +181,7 @@ const BlockElement = ({ isOpen, onClose, selectedNode }: Block executionOrder: Number(formState.executionOrder), variable: selectedNode.data.variable, }, - }) + } as BlockNode) const newNodeDataVariant = newNode.data.variant as LadderBlockVariant const formName: string = newNodeDataVariant.name @@ -346,7 +346,7 @@ const BlockElement = ({ isOpen, onClose, selectedNode }: Block setNode({ ...newNode, data: { ...newNode.data, variable: selectedNode.data.variable }, - }) + } as BlockNode) } const handleClearForm = () => { @@ -411,10 +411,10 @@ const BlockElement = ({ isOpen, onClose, selectedNode }: Block } } - let newNodes = [...rung.nodes] + let newNodes: typeof rung.nodes = [...rung.nodes] let newEdges = [...rung.edges] - newNodes = newNodes.map((n) => (n.id === node.id ? newNode : n)) + newNodes = newNodes.map((n) => (n.id === node.id ? newNode : n)) as typeof newNodes edges.source?.forEach((edge) => { const newEdge = { diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx b/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx index 8e3b3295c..c1cbc9cce 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/body.tsx @@ -371,7 +371,7 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi nodes: rung.nodes.map((node) => ({ ...node, data: { ...node.data, hasDivergence: nodeDivergences.includes(`${rung.id}:${node.id}`) }, - })), + })) as RungLadderState['nodes'], }) updateReactFlowPanelExtent(rung) }, [rung.nodes]) @@ -439,7 +439,7 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi setReactFlowPanelExtent((extent) => [extent[0], [extent[1][0], extent[1][1] - 50]]) // Remove placeholders const nodes = removePlaceholderElements(rungLocal.nodes) - setRungLocal((rung) => ({ ...rung, nodes })) + setRungLocal((rung) => ({ ...rung, nodes }) as typeof rung) } } @@ -500,7 +500,7 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi if (!pouLibrary) { const nodes = removePlaceholderElements(rungLocal.nodes) - setRungLocal((rung) => ({ ...rung, nodes })) + setRungLocal((rung) => ({ ...rung, nodes }) as typeof rung) toast({ title: 'Can not add block', description: `The block type ${blockType} does not exist in the library`, @@ -546,10 +546,11 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi * Remove some nodes from the rung */ const handleRemoveNode = (nodes: FlowNode[]) => { - const { nodes: newNodes, edges: newEdges, handleBranches: newHandleBranches } = removeElements( - { ...rungLocal }, - nodes, - ) + const { + nodes: newNodes, + edges: newEdges, + handleBranches: newHandleBranches, + } = removeElements({ ...rungLocal }, nodes) captureAndPush(editor.meta.name) @@ -735,11 +736,14 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi } }) - setRungLocal((rung) => ({ - ...rung, - nodes: applyNodeChanges(changes, rungLocal.nodes), - selectedNodes: selectedNodes, - })) + setRungLocal( + (rung) => + ({ + ...rung, + nodes: applyNodeChanges(changes, rungLocal.nodes), + selectedNodes: selectedNodes, + }) as typeof rung, + ) }, [rungLocal, rung, dragging], ) @@ -773,7 +777,7 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi if (isFirstDragEnter) { const copyRungLocal = { ...rungLocal } const nodes = renderPlaceholderElements(copyRungLocal) - setRungLocal((rung) => ({ ...rung, nodes })) + setRungLocal((rung) => ({ ...rung, nodes }) as typeof rung) } }, [rung, rungLocal, setReactFlowPanelExtent, reactFlowPanelExtent, dragging, isDebuggerActive], @@ -815,7 +819,7 @@ export const RungBody = ({ rung, className, nodeDivergences = [], isDebuggerActi // If it is, remove the placeholder elements` const nodes = removePlaceholderElements(rungLocal.nodes) - setRungLocal((rung) => ({ ...rung, nodes })) + setRungLocal((rung) => ({ ...rung, nodes }) as typeof rung) }, [rung, rungLocal, setReactFlowPanelExtent, reactFlowPanelExtent, dragging, isDebuggerActive], ) diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/edges.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/edges.ts index 058bf690a..573e40c6a 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/edges.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/edges.ts @@ -1,7 +1,7 @@ import type { Edge, Node } from '@xyflow/react' import type { RungLadderState } from '../../../../../../store/slices/ladder' -import type { BasicNodeData, ParallelNode } from '../../../../../_atoms/graphical-editor/ladder/utils/types' +import type { BasicNodeData } from '../../../../../_atoms/graphical-editor/ladder/utils/types' import { isNodeOfType } from './nodes' type ConnectionOptions = { @@ -25,7 +25,8 @@ export const checkIfConnectedInParallel = ( node: Node, ): { parentNode: Node; connectedInParallel: boolean } => { const connectedInParallel = rung.edges.filter((edge) => edge.target === node.id) - const sourceNode = rung.nodes.find((node) => node.id === connectedInParallel[0].source) as Node + const sourceNode = rung.nodes.find((node) => node.id === connectedInParallel[0].source) + if (!sourceNode) return { parentNode: node, connectedInParallel: false } return { parentNode: sourceNode, connectedInParallel: isNodeOfType(sourceNode, 'parallel') } } @@ -51,18 +52,22 @@ export const connectNodes = ( options?: ConnectNodesOptions, ): Edge[] => { // Find the source edge - const sourceNode = rung.nodes.find((node) => node.id === sourceNodeId) as Node + const sourceNode = rung.nodes.find((node) => node.id === sourceNodeId) const sourceEdge = rung.edges.find((edge) => { if (edge.source !== sourceNodeId) return false if (options?.sourceEdgeLookupHandle !== undefined) { return edge.sourceHandle === options.sourceEdgeLookupHandle } - return type === 'parallel' && isNodeOfType(sourceNode, 'parallel') - ? edge.sourceHandle === (sourceNode as ParallelNode).data.parallelOutputConnector?.id - : isNodeOfType(sourceNode, 'parallel') && sourceNode.data.type === 'close' - ? edge.sourceHandle === (sourceNode as ParallelNode).data.parallelOutputConnector?.id || - (sourceNode as ParallelNode).data.outputConnector?.id - : edge.sourceHandle === (sourceNode.data as BasicNodeData).outputConnector?.id + if (!sourceNode) return false + if (isNodeOfType(sourceNode, 'parallel')) { + if (type === 'parallel') { + return edge.sourceHandle === sourceNode.data.parallelOutputConnector?.id + } + if (sourceNode.data.type === 'close') { + return edge.sourceHandle === sourceNode.data.parallelOutputConnector?.id || sourceNode.data.outputConnector?.id + } + } + return edge.sourceHandle === (sourceNode.data as BasicNodeData).outputConnector?.id }) const targetNode = rung.nodes.find((node) => node.id === targetNodeId) @@ -82,8 +87,8 @@ export const connectNodes = ( // If source node is a parallel and the operation type is serial, lets check if we need to update the source handle let newSourceHandle = sourceEdge.sourceHandle ?? undefined - if (type === 'serial' && isNodeOfType(sourceNode, 'parallel')) { - newSourceHandle = (sourceNode as ParallelNode).data.outputConnector?.id + if (type === 'serial' && sourceNode && isNodeOfType(sourceNode, 'parallel')) { + newSourceHandle = sourceNode.data.outputConnector?.id } // Update the target of the source edge @@ -101,9 +106,9 @@ export const connectNodes = ( if ( sourceEdgeNodeTarget && isNodeOfType(sourceEdgeNodeTarget, 'parallel') && - (sourceEdgeNodeTarget as ParallelNode).data.type === 'open' + sourceEdgeNodeTarget.data.type === 'open' ) { - const newTargetHandle = (sourceEdgeNodeTarget as ParallelNode).data.inputConnector?.id + const newTargetHandle = sourceEdgeNodeTarget.data.inputConnector?.id edges.push( buildEdge(targetNodeId, sourceEdge.target, { diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts index 00ebc738d..216bc8ac1 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts @@ -159,10 +159,7 @@ const positionMainNodes = (rung: RungLadderState): { nodes: Node[]; edges: Edge[ * The narrow on `node.type` lets TypeScript discriminate the union to * just the three node types that can carry `branchContext`. */ - if ( - (node.type === 'contact' || node.type === 'coil' || node.type === 'parallel') && - node.data.branchContext - ) { + if ((node.type === 'contact' || node.type === 'coil' || node.type === 'parallel') && node.data.branchContext) { newNodes.push(node) continue } @@ -177,7 +174,10 @@ const positionMainNodes = (rung: RungLadderState): { nodes: Node[]; edges: Edge[ /** * Find the previous nodes and edges of the current node */ - const { nodes: previousNodes, edges: previousEdges } = getPreviousElementsByEdge({ ...rung, nodes: newNodes }, node) + const { nodes: previousNodes, edges: previousEdges } = getPreviousElementsByEdge( + { ...rung, nodes: newNodes } as RungLadderState, + node, + ) if (!previousNodes || !previousEdges) return null /** @@ -190,10 +190,10 @@ const positionMainNodes = (rung: RungLadderState): { nodes: Node[]; edges: Edge[ * spine 49px-per-level rightward. */ const isSameTypeParallelOf = (n: Node | undefined, sub: 'open' | 'close'): boolean => - !!n && isNodeOfType(n, 'parallel') && (n as ParallelNode).data.type === sub + !!n && isNodeOfType(n, 'parallel') && n.data.type === sub const prevIsAlreadyNestedFor = (prev: Node): boolean => { if (!isNodeOfType(prev, 'parallel')) return false - const prevSubType = (prev as ParallelNode).data.type + const prevSubType = prev.data.type const prevPrevEdges = rung.edges.filter((e) => e.target === prev.id) for (const e of prevPrevEdges) { const prevPrev = newNodes.find((n) => n.id === e.source) @@ -210,8 +210,8 @@ const positionMainNodes = (rung: RungLadderState): { nodes: Node[]; edges: Edge[ const prevAlreadyNested = prevIsAlreadyNestedFor(previousNode) if ( isNodeOfType(previousNode, 'parallel') && - (previousNode as ParallelNode).data.type === 'open' && - previousEdges[0].sourceHandle === (previousNode as ParallelNode).data.parallelOutputConnector?.id + previousNode.data.type === 'open' && + previousEdges[0].sourceHandle === previousNode.data.parallelOutputConnector?.id ) { newNodePosition = getNodePositionBasedOnPreviousNode(previousNode, node, 'parallel', prevAlreadyNested) } else { @@ -267,19 +267,14 @@ const positionMainNodes = (rung: RungLadderState): { nodes: Node[]; edges: Edge[ // room (the verticalGap on its own only inserts a small margin // past the FB body). const baseVerticalGap = getDefaultNodeStyle({ node: objectParallel.highestNode }).verticalGap - const highestNodeHasBranch = rung.handleBranches.some( - (b) => b.blockId === objectParallel.highestNode.id, - ) + const highestNodeHasBranch = rung.handleBranches.some((b) => b.blockId === objectParallel.highestNode.id) const verticalGap = baseVerticalGap + (highestNodeHasBranch ? 80 : 0) const newPosY = objectParallel.highestNode.position.y + objectParallel.height + verticalGap - getDefaultNodeStyle({ node }).handle.y - const newHandleY = - objectParallel.highestNode.position.y + - objectParallel.height + - verticalGap + const newHandleY = objectParallel.highestNode.position.y + objectParallel.height + verticalGap newNodePosition = { ...newNodePosition, posY: newPosY, @@ -315,7 +310,7 @@ const positionMainNodes = (rung: RungLadderState): { nodes: Node[]; edges: Edge[ const owningOpenStale = findOwningOpenForNode(parallelsDepth, node.id) const owningOpen = owningOpenStale - ? newNodes.find((n) => n.id === owningOpenStale.id) ?? owningOpenStale + ? (newNodes.find((n) => n.id === owningOpenStale.id) ?? owningOpenStale) : undefined if (owningOpen) { const openRight = owningOpen.position.x + (owningOpen.width ?? 0) @@ -404,7 +399,7 @@ const positionMainNodes = (rung: RungLadderState): { nodes: Node[]; edges: Edge[ } newNodes.push(newNode) } else { - const parallelNode = node as ParallelNode + const parallelNode = node const newParallelNode: ParallelNode = { ...parallelNode, position: { x: newNodePosition.posX, y: newNodePosition.posY }, @@ -506,22 +501,19 @@ const layoutPasses: LayoutPass[] = [ * * @returns The new nodes */ -export const updateDiagramElementsPosition = ( - rung: RungLadderState, - defaultBounds: [number, number], -): LayoutResult => { +export const updateDiagramElementsPosition = (rung: RungLadderState, defaultBounds: [number, number]): LayoutResult => { // Pre-pass: grow each branched block's height to enclose its branch's // vertical extent (rail + parallel paths). `positionMainNodes` reads // `node.height` to decide where parallel sibling paths land on Y, so this // has to run BEFORE main-rung positioning. const inflated = inflateBlockHeightsForBranches(rung) - const rungWithInflatedHeights = { ...rung, nodes: inflated.nodes } + const rungWithInflatedHeights = { ...rung, nodes: inflated.nodes } as RungLadderState const positioned = positionMainNodes(rungWithInflatedHeights) if (!positioned) return { nodes: rung.nodes, edges: rung.edges } return layoutPasses.reduce( - (acc, pass) => pass({ ...rung, ...acc }, defaultBounds), + (acc, pass) => pass({ ...rung, ...acc } as RungLadderState, defaultBounds), positioned, ) } diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/drag-n-drop/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/drag-n-drop/index.ts index cac666bec..16b1f65a3 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/drag-n-drop/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/drag-n-drop/index.ts @@ -37,8 +37,8 @@ export const onElementDragStart = (rung: RungLadderState, draggedNode: Node) => const nodeIndex = rung.nodes.findIndex((n) => n.id === draggedNode.id) if (nodeIndex === -1) return rung - let newNodes = [...rung.nodes] - newNodes.splice(nodeIndex, 0, copycatNode) + let newNodes: RungLadderState['nodes'] = [...rung.nodes] + newNodes.splice(nodeIndex, 0, copycatNode as RungLadderState['nodes'][number]) /** * Find the edges that are connected to the dragged node @@ -56,7 +56,7 @@ export const onElementDragStart = (rung: RungLadderState, draggedNode: Node) => /** * Render the placeholder nodes */ - newNodes = renderPlaceholderElements({ ...rung, nodes: newNodes, edges: newEdges }) + newNodes = renderPlaceholderElements({ ...rung, nodes: newNodes, edges: newEdges }) as RungLadderState['nodes'] return { nodes: newNodes, edges: newEdges } } @@ -145,8 +145,12 @@ const prepareDropContext = (rung: RungLadderState, draggedNode: Node): DropConte */ const handleRestoreDrop = (ctx: DropContext): DropResult => { const { rung, copycatNode, draggedNode } = ctx - const nodes = [...rung.nodes] - nodes[nodes.indexOf(copycatNode)] = { ...draggedNode, id: draggedNode.id, dragging: false } + const nodes: RungLadderState['nodes'] = [...rung.nodes] + nodes[nodes.indexOf(copycatNode as RungLadderState['nodes'][number])] = { + ...draggedNode, + id: draggedNode.id, + dragging: false, + } as RungLadderState['nodes'][number] const edges = rung.edges.map((edge) => { if (edge.source === copycatNode.id) { @@ -160,7 +164,7 @@ const handleRestoreDrop = (ctx: DropContext): DropResult => { const restoredNodes = removePlaceholderElements(nodes) const layoutResult = updateDiagramElementsPosition( - { ...rung, nodes: restoredNodes, edges }, + { ...rung, nodes: restoredNodes, edges } as RungLadderState, rung.defaultBounds as [number, number], ) return { ...layoutResult, handleBranches: rung.handleBranches } @@ -180,7 +184,7 @@ const handleParallelDrop = (ctx: DropContext): DropResult => { }, draggedNode, ) - return removeElement({ ...rung, nodes: parallelNodes, edges: parallelEdges }, copycatNode) + return removeElement({ ...rung, nodes: parallelNodes, edges: parallelEdges } as RungLadderState, copycatNode) } /** @@ -197,7 +201,7 @@ const handleSerialDrop = (ctx: DropContext): DropResult => { }, draggedNode, ) - return removeElement({ ...rung, nodes: serialNodes, edges: serialEdges }, copycatNode) + return removeElement({ ...rung, nodes: serialNodes, edges: serialEdges } as RungLadderState, copycatNode) } /** diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/handle-branch/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/handle-branch/index.ts index 64e2ed3be..3664d654a 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/handle-branch/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/handle-branch/index.ts @@ -23,6 +23,7 @@ import { import type { BlockNode, BlockVariant, + BranchContext, CoilNode, ContactNode, HandleBranch, @@ -71,7 +72,7 @@ export const buildBranchRailId = (blockId: string, handleId: string, direction: export const findBranchRail = (rung: RungLadderState, branch: HandleBranch): PowerRailNode | undefined => { const id = buildBranchRailId(branch.blockId, branch.handleId, branch.direction) const node = rung.nodes.find((n) => n.id === id) - return node?.type === 'powerRail' ? (node) : undefined + return node?.type === 'powerRail' ? node : undefined } export const hasBranchOnHandle = ( @@ -151,8 +152,8 @@ export const addRailBranchHandle = ( const blockHandle = block && block.type === 'block' ? params.direction === 'input' - ? (block).data.inputHandles.find((h) => h.id === params.handleId) - : (block).data.outputHandles.find((h) => h.id === params.handleId) + ? block.data.inputHandles.find((h) => h.id === params.handleId) + : block.data.outputHandles.find((h) => h.id === params.handleId) : undefined if (!blockHandle) return { nodes: rung.nodes } @@ -160,10 +161,7 @@ export const addRailBranchHandle = ( if (rung.nodes.some((n) => n.id === branchRailId)) return { nodes: rung.nodes } // Position 200px from the block edge initially; reflowed by layout. - const initialX = - params.direction === 'input' - ? blockHandle.glbPosition.x - 200 - : blockHandle.glbPosition.x + 200 + const initialX = params.direction === 'input' ? blockHandle.glbPosition.x - 200 : blockHandle.glbPosition.x + 200 const railY = params.y - DEFAULT_POWER_RAIL_HEIGHT / 2 // For input branches the local rail acts as a SOURCE feeding the branch // element — same role the LEFT main rail plays. We pass connector='right' @@ -193,11 +191,7 @@ export const addRailBranchHandle = ( * Reverse of `addRailBranchHandle`. Removes the standalone branch rail node * entirely. */ -export const removeRailBranchHandle = ( - rung: RungLadderState, - blockId: string, - handleId: string, -): { nodes: Node[] } => { +export const removeRailBranchHandle = (rung: RungLadderState, blockId: string, handleId: string): { nodes: Node[] } => { // Branch direction is encoded in the rail's id; we don't have it here, // so try both possibilities. const inputId = buildBranchRailId(blockId, handleId, 'input') @@ -323,9 +317,9 @@ export const replaceVariableWithBranch = ( const variableNode = rung.nodes.find( (n) => n.type === 'variable' && - (n).data.block.id === params.blockId && - (n).data.block.handleId === params.handleId && - (n).data.variant === params.direction, + n.data.block.id === params.blockId && + n.data.block.handleId === params.handleId && + n.data.variant === params.direction, ) as VariableNode | undefined let workingNodes = variableNode ? rung.nodes.filter((n) => n.id !== variableNode.id) : [...rung.nodes] @@ -343,7 +337,7 @@ export const replaceVariableWithBranch = ( y: blockHandle.glbPosition.y, }, ) - workingNodes = railResult.nodes + workingNodes = railResult.nodes as typeof workingNodes // Build the new branch element. Wire it to the branch rail (the standalone // local rail piece we just created near the FB), NOT the main rail. @@ -372,16 +366,14 @@ export const replaceVariableWithBranch = ( handleId: params.handleId, direction: params.direction, }, - } + } as typeof newElement.data - workingNodes = [...workingNodes, newElement] + workingNodes = [...workingNodes, newElement] as typeof workingNodes // Wire the two edges that connect the new element to the rail and the block. const railBranchHandleId = buildRailBranchHandleId(params.blockId, params.handleId) - const elementInputId = - ((newElement as ContactNode | CoilNode).data.inputConnector?.id) ?? 'input' - const elementOutputId = - ((newElement as ContactNode | CoilNode).data.outputConnector?.id) ?? 'output' + const elementInputId = (newElement as ContactNode | CoilNode).data.inputConnector?.id ?? 'input' + const elementOutputId = (newElement as ContactNode | CoilNode).data.outputConnector?.id ?? 'output' const newEdges = params.direction === 'input' @@ -463,8 +455,8 @@ const resolveSpineElement = (rung: RungLadderState, nodeId: string | undefined): const node = rung.nodes.find((n) => n.id === nodeId) if (!node) return undefined if (node.type !== 'contact' && node.type !== 'coil' && node.type !== 'parallel') return undefined - const inputHandleId = (node.data.inputConnector?.id) ?? 'input' - const outputHandleId = (node.data.outputConnector?.id) ?? 'output' + const inputHandleId = node.data.inputConnector?.id ?? 'input' + const outputHandleId = node.data.outputConnector?.id ?? 'output' return { kind: 'element', nodeId: node.id, inputHandleId, outputHandleId } } @@ -474,11 +466,7 @@ const resolveSpineElement = (rung: RungLadderState, nodeId: string | undefined): * - nodeIds.length → the boundary on the "after" side * - -1 → the boundary on the "before" side */ -const resolveAnchorAtIndex = ( - rung: RungLadderState, - branch: HandleBranch, - index: number, -): SpineAnchor | undefined => { +const resolveAnchorAtIndex = (rung: RungLadderState, branch: HandleBranch, index: number): SpineAnchor | undefined => { if (index === -1) return resolveBranchEndpoint(rung, branch, 'before') if (index === branch.nodeIds.length) return resolveBranchEndpoint(rung, branch, 'after') return resolveSpineElement(rung, branch.nodeIds[index]) @@ -555,7 +543,7 @@ export const renderInBranchSplicePlaceholders = (rung: RungLadderState): Node[] if (!node) return if (node.type === 'parallel') { - const ptype = (node).data.type + const ptype = node.data.type if (ptype === 'open') { const leftX = node.position.x - SIDE_GAP - DEFAULT_PLACEHOLDER_WIDTH / 2 placeholders.push(buildSplicePlaceholder(branch, leftX, posY, handleY, idx, `${idx}_left`)) @@ -571,15 +559,11 @@ export const renderInBranchSplicePlaceholders = (rung: RungLadderState): Node[] // Left placeholder: routes to insertIndex = idx (insert before this // node in the spine). const leftX = node.position.x - SIDE_GAP - DEFAULT_PLACEHOLDER_WIDTH / 2 - placeholders.push( - buildSplicePlaceholder(branch, leftX, posY, handleY, idx, `${idx}_left`), - ) + placeholders.push(buildSplicePlaceholder(branch, leftX, posY, handleY, idx, `${idx}_left`)) // Right placeholder: routes to insertIndex = idx + 1 (insert after). const rightX = node.position.x + (node.width ?? 0) + SIDE_GAP - DEFAULT_PLACEHOLDER_WIDTH / 2 - placeholders.push( - buildSplicePlaceholder(branch, rightX, posY, handleY, idx + 1, `${idx}_right`), - ) + placeholders.push(buildSplicePlaceholder(branch, rightX, posY, handleY, idx + 1, `${idx}_right`)) }) }) @@ -684,10 +668,10 @@ export const insertIntoBranch = ( handleId: params.handleId, direction: params.direction, }, - } + } as typeof newElement.data - const newElementInputId = (newElement.data.inputConnector?.id) ?? 'input' - const newElementOutputId = (newElement.data.outputConnector?.id) ?? 'output' + const newElementInputId = newElement.data.inputConnector?.id ?? 'input' + const newElementOutputId = newElement.data.outputConnector?.id ?? 'output' // Remove the edge between the two surrounding anchors (it spans the gap // we're about to splice into). @@ -749,8 +733,8 @@ export const branchParallelPathCount = (rung: RungLadderState, branch: HandleBra for (const id of branch.nodeIds) { const node = rung.nodes.find((n) => n.id === id) if (node?.type !== 'parallel') continue - if ((node).data.type !== 'open') continue - const parallelOutputId = (node).data.parallelOutputConnector?.id + if (node.data.type !== 'open') continue + const parallelOutputId = node.data.parallelOutputConnector?.id if (!parallelOutputId) continue const paths = rung.edges.filter((e) => e.source === node.id && e.sourceHandle === parallelOutputId).length if (paths > maxPaths) maxPaths = paths @@ -777,7 +761,7 @@ const getEnclosingParallelPair = ( for (let i = idx - 1; i >= 0; i--) { const n = rung.nodes.find((node) => node.id === branch.nodeIds[i]) if (n?.type !== 'parallel') continue - const ptype = (n).data.type + const ptype = n.data.type if (ptype === 'close') depth++ else if (ptype === 'open') { if (depth === 0) { @@ -836,8 +820,7 @@ export const addPathToBranchParallel = ( direction: params.direction, } - const aboveX = - rung.nodes.find((n) => n.id === params.spineNodeId)?.position.x ?? blockHandle.glbPosition.x + const aboveX = rung.nodes.find((n) => n.id === params.spineNodeId)?.position.x ?? blockHandle.glbPosition.x const handleY = blockHandle.glbPosition.y const newElement = buildGenericNode({ @@ -848,9 +831,9 @@ export const addPathToBranchParallel = ( handleX: aboveX, handleY: handleY + DEFAULT_BLOCK_CONNECTOR_Y_OFFSET, }) - newElement.data = { ...newElement.data, branchContext } - const inId = (newElement.data.inputConnector?.id) ?? 'input' - const outId = (newElement.data.outputConnector?.id) ?? 'output' + newElement.data = { ...newElement.data, branchContext } as typeof newElement.data + const inId = newElement.data.inputConnector?.id ?? 'input' + const outId = newElement.data.outputConnector?.id ?? 'output' const newEdges = [ ...rung.edges, @@ -977,9 +960,9 @@ export const startParallelInBranch = ( newElement.data = { ...newElement.data, branchContext, - } - const newElementInputId = (newElement.data.inputConnector?.id) ?? 'input' - const newElementOutputId = (newElement.data.outputConnector?.id) ?? 'output' + } as typeof newElement.data + const newElementInputId = newElement.data.inputConnector?.id ?? 'input' + const newElementOutputId = newElement.data.outputConnector?.id ?? 'output' // Drop the existing predecessor → aboveElement and aboveElement → successor // edges; they're going to be rerouted through OPEN and CLOSE. @@ -1069,10 +1052,7 @@ const anchorInputHandleForSpineNode = (node: Node): string | undefined => { * collapses an OPEN/CLOSE pair — the topology survives but `nodeIds` may * still reference the now-removed OPEN/CLOSE ids. */ -export const reconcileBranchNodeIds = ( - rung: RungLadderState, - branch: HandleBranch, -): HandleBranch => { +export const reconcileBranchNodeIds = (rung: RungLadderState, branch: HandleBranch): HandleBranch => { // Pick the starting node (branch rail for input branches, block for output). const rail = findBranchRail(rung, branch) if (!rail) return branch @@ -1093,9 +1073,7 @@ export const reconcileBranchNodeIds = ( // Cap iterations defensively in case of an unexpected cycle. for (let safety = 0; safety < 1000; safety++) { - const outgoing = rung.edges.find( - (e) => e.source === currentNodeId && e.sourceHandle === currentSourceHandle, - ) + const outgoing = rung.edges.find((e) => e.source === currentNodeId && e.sourceHandle === currentSourceHandle) if (!outgoing) break if (outgoing.target === endNodeId) break if (visited.has(outgoing.target)) break @@ -1144,7 +1122,7 @@ export const renderInBranchParallelPlaceholders = (rung: RungLadderState): Node[ branch.nodeIds.forEach((id) => { const node = rung.nodes.find((n) => n.id === id) if (node?.type === 'parallel') { - const ptype = (node).data.type + const ptype = node.data.type if (ptype === 'open') { depth++ currentOpen = node @@ -1154,16 +1132,11 @@ export const renderInBranchParallelPlaceholders = (rung: RungLadderState): Node[ } } else if (depth > 0 && (node?.type === 'contact' || node?.type === 'coil')) { insideParallel.add(node.id) - if (currentOpen) aboveContactByOpen.set((currentOpen).id, node) + if (currentOpen) aboveContactByOpen.set(currentOpen.id, node) } }) - const emitBottom = ( - anchorNode: Node, - relatedNode: Node, - insertIndex: number, - suffix: string, - ) => { + const emitBottom = (anchorNode: Node, relatedNode: Node, insertIndex: number, suffix: string) => { const width = anchorNode.width ?? DEFAULT_CONTACT_BLOCK_WIDTH const posX = anchorNode.position.x + width / 2 - DEFAULT_PLACEHOLDER_WIDTH / 2 const posY = anchorNode.position.y + (anchorNode.height ?? DEFAULT_CONTACT_BLOCK_HEIGHT) + 10 @@ -1215,7 +1188,7 @@ export const renderInBranchParallelPlaceholders = (rung: RungLadderState): Node[ branch.nodeIds.forEach((id, idx) => { const node = rung.nodes.find((n) => n.id === id) if (node?.type !== 'parallel') return - if ((node).data.type !== 'open') return + if (node.data.type !== 'open') return const open = node const aboveContact = aboveContactByOpen.get(open.id) @@ -1226,9 +1199,7 @@ export const renderInBranchParallelPlaceholders = (rung: RungLadderState): Node[ const parallelOutputId = open.data.parallelOutputConnector?.id if (!parallelOutputId) return - const startEdges = rung.edges.filter( - (e) => e.source === open.id && e.sourceHandle === parallelOutputId, - ) + const startEdges = rung.edges.filter((e) => e.source === open.id && e.sourceHandle === parallelOutputId) if (startEdges.length === 0) return // Bottom-most path = last start edge (highest pathIndex). @@ -1256,12 +1227,7 @@ export const renderInBranchParallelPlaceholders = (rung: RungLadderState): Node[ * edges and ends when the next edge targets CLOSE on its parallel-input * handle. */ -const walkParallelPath = ( - rung: RungLadderState, - _open: ParallelNode, - close: ParallelNode, - startEdge: Edge, -): Node[] => { +const walkParallelPath = (rung: RungLadderState, _open: ParallelNode, close: ParallelNode, startEdge: Edge): Node[] => { const path: Node[] = [] let currentEdge: Edge | undefined = startEdge const visited = new Set() @@ -1300,7 +1266,7 @@ export const renderInBranchParallelPathPlaceholders = (rung: RungLadderState): N branch.nodeIds.forEach((id) => { const node = rung.nodes.find((n) => n.id === id) if (node?.type !== 'parallel') return - if ((node).data.type !== 'open') return + if (node.data.type !== 'open') return const open = node const closeId = open.data.parallelCloseReference if (!closeId) return @@ -1322,8 +1288,7 @@ export const renderInBranchParallelPathPlaceholders = (rung: RungLadderState): N pathNodes.forEach((pNode, idx) => { if (pNode.type !== 'contact' && pNode.type !== 'coil') return - const handleY = - pNode.position.y + (pNode.height ?? DEFAULT_CONTACT_BLOCK_HEIGHT) / 2 + const handleY = pNode.position.y + (pNode.height ?? DEFAULT_CONTACT_BLOCK_HEIGHT) / 2 const posY = handleY - DEFAULT_PLACEHOLDER_HEIGHT / 2 // Left placeholder: predecessor is OPEN if idx === 0, else previous path node. @@ -1335,19 +1300,9 @@ export const renderInBranchParallelPathPlaceholders = (rung: RungLadderState): N // Right placeholder: successor is CLOSE if idx is last, else next path node. const rightSucc = idx === pathNodes.length - 1 ? (close as Node) : pathNodes[idx + 1] - const rightX = - pNode.position.x + (pNode.width ?? 0) + SIDE_GAP - DEFAULT_PLACEHOLDER_WIDTH / 2 + const rightX = pNode.position.x + (pNode.width ?? 0) + SIDE_GAP - DEFAULT_PLACEHOLDER_WIDTH / 2 placeholders.push( - buildPathSplicePlaceholder( - branch, - open.id, - pNode.id, - rightSucc.id, - rightX, - posY, - handleY, - `${idx}_right`, - ), + buildPathSplicePlaceholder(branch, open.id, pNode.id, rightSucc.id, rightX, posY, handleY, `${idx}_right`), ) }) }) @@ -1444,9 +1399,9 @@ export const insertIntoBranchParallelPath = ( handleX: midX - DEFAULT_CONTACT_BLOCK_WIDTH / 2, handleY: refY, }) - newElement.data = { ...newElement.data, branchContext } - const inId = (newElement.data.inputConnector?.id) ?? 'input' - const outId = (newElement.data.outputConnector?.id) ?? 'output' + newElement.data = { ...newElement.data, branchContext } as typeof newElement.data + const inId = newElement.data.inputConnector?.id ?? 'input' + const outId = newElement.data.outputConnector?.id ?? 'output' const newEdges = rung.edges.filter((e) => e.id !== oldEdge.id) newEdges.push( @@ -1509,7 +1464,7 @@ export const removeBranchElement = ( if (element.type !== 'contact' && element.type !== 'coil' && element.type !== 'parallel') { return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } } - const ctx = element.data.branchContext + const ctx = element.data.branchContext as BranchContext | undefined if (!ctx) return { nodes: rung.nodes, edges: rung.edges, handleBranches: rung.handleBranches } const branch = getBranch(rung, ctx.blockId, ctx.handleId, ctx.direction) @@ -1627,7 +1582,7 @@ export const reconcileBranches = ( for (let i = idsCopy.length - 1; i >= 0; i--) { const elementNode = working.nodes.find((n) => n.id === idsCopy[i]) if (!elementNode) continue - working = removeBranchElement({ ...rung, ...working }, elementNode) + working = removeBranchElement({ ...rung, ...working } as RungLadderState, elementNode) } } @@ -1647,13 +1602,14 @@ export const reconcileBranches = ( ...h, id: (remapHandleId(h.id) ?? h.id) as T['id'], }) + const railData = node.data as PowerRailNode['data'] return { ...node, data: { ...node.data, - handles: node.data.handles.map(remapHandle), - inputHandles: node.data.inputHandles.map(remapHandle), - outputHandles: node.data.outputHandles.map(remapHandle), + handles: railData.handles.map(remapHandle), + inputHandles: railData.inputHandles.map(remapHandle), + outputHandles: railData.outputHandles.map(remapHandle), }, } } @@ -1661,13 +1617,14 @@ export const reconcileBranches = ( if ( (node.type === 'contact' || node.type === 'coil' || node.type === 'parallel') && node.data.branchContext && - node.data.branchContext.blockId === oldBlockId + (node.data.branchContext as BranchContext).blockId === oldBlockId ) { + const ctx = node.data.branchContext as BranchContext return { ...node, data: { ...node.data, - branchContext: { ...node.data.branchContext, blockId: newBlockId }, + branchContext: { ...ctx, blockId: newBlockId }, }, } } @@ -1733,11 +1690,8 @@ const BRANCH_PARALLEL_PATH_HEIGHT = 100 // need slot growth to fit between handles; only the block's overall // vertical extent has to enclose them, which the bottom-margin growth // handles directly. -const slotHeightForHandleIndex = ( - _rung: RungLadderState, - _block: BlockNode, - _index: number, -): number => DEFAULT_BLOCK_CONNECTOR_Y_OFFSET +const slotHeightForHandleIndex = (_rung: RungLadderState, _block: BlockNode, _index: number): number => + DEFAULT_BLOCK_CONNECTOR_Y_OFFSET // Re-derive the natural relY for an input/output handle from its index in // the inputHandles / outputHandles array. The natural value composes per- @@ -1767,8 +1721,7 @@ const styleGap = (node: Node | undefined): number => { // handle on the FB's left edge — a long wire there reads as wasted space. const BRANCH_BLOCK_SIDE_GAP = 25 -const branchGapFromBlock = (firstElement: Node | undefined): number => - BRANCH_BLOCK_SIDE_GAP + styleGap(firstElement) +const branchGapFromBlock = (firstElement: Node | undefined): number => BRANCH_BLOCK_SIDE_GAP + styleGap(firstElement) // Extra horizontal gap inserted between adjacent parallel pairs in the // spine (a CLOSE immediately followed by an OPEN). Parallel-style gap is // 0, so without this two consecutive parallel structures would touch each @@ -1801,14 +1754,12 @@ const branchGapBetween = (a: Node | undefined, b: Node | undefined): number => { export const computeBranchSpanWidth = (rung: RungLadderState, branch: HandleBranch): number => { const branchElements = branch.nodeIds .map((id) => rung.nodes.find((n) => n.id === id)) - .filter((n): n is Node => n !== undefined) + .filter((n): n is RungLadderState['nodes'][number] => n !== undefined) if (branchElements.length === 0) return 0 // Block-side first element; rail-side last element. Direction-dependent. - const firstNode = - branch.direction === 'input' ? branchElements[branchElements.length - 1] : branchElements[0] - const lastNode = - branch.direction === 'input' ? branchElements[0] : branchElements[branchElements.length - 1] + const firstNode = branch.direction === 'input' ? branchElements[branchElements.length - 1] : branchElements[0] + const lastNode = branch.direction === 'input' ? branchElements[0] : branchElements[branchElements.length - 1] let span = branchGapFromBlock(firstNode) for (let i = 0; i < branchElements.length; i++) { @@ -1827,16 +1778,14 @@ export const computeBranchSpanWidth = (rung: RungLadderState, branch: HandleBran // branch width, not just the un-stretched spine. for (let idx = 0; idx < branchElements.length; idx++) { const node = branchElements[idx] - if (node.type !== 'parallel' || (node as ParallelNode).data.type !== 'open') continue - const open = node as ParallelNode + if (node.type !== 'parallel' || node.data.type !== 'open') continue + const open = node const closeId = open.data.parallelCloseReference const parallelOutputId = open.data.parallelOutputConnector?.id if (!closeId || !parallelOutputId) continue const close = rung.nodes.find((n) => n.id === closeId) if (!close || close.type !== 'parallel') continue - const startEdges = rung.edges.filter( - (e) => e.source === open.id && e.sourceHandle === parallelOutputId, - ) + const startEdges = rung.edges.filter((e) => e.source === open.id && e.sourceHandle === parallelOutputId) let maxPathWidth = 0 for (const startEdge of startEdges) { const pathNodes = walkParallelPath(rung, open, close, startEdge) @@ -1861,7 +1810,7 @@ export const computeBranchSpanWidth = (rung: RungLadderState, branch: HandleBran const n = rung.nodes.find((n2) => n2.id === branch.nodeIds[j]) if (!n) break if (n.type === 'parallel') { - const ptype = (n).data.type + const ptype = n.data.type if (ptype === 'close' && depth === 0) break if (ptype === 'open') depth++ else if (ptype === 'close') depth-- @@ -1893,11 +1842,7 @@ const BRANCH_WIRE_WRAP_BUFFER = 10 * successor (output) far enough that the branch's local rail AND any wire * routed to/from the block clear each other on the main rung. */ -export const maxBranchSpanWidth = ( - rung: RungLadderState, - blockId: string, - direction: 'input' | 'output', -): number => { +export const maxBranchSpanWidth = (rung: RungLadderState, blockId: string, direction: 'input' | 'output'): number => { let max = 0 for (const branch of rung.handleBranches) { if (branch.blockId !== blockId) continue @@ -1919,13 +1864,8 @@ export const maxBranchSpanWidth = ( * * Returns `[branchLeft, branchRight]` in absolute coordinates. */ -const computeCompactBranchXRange = ( - rung: RungLadderState, - branch: HandleBranch, -): [number, number] | undefined => { - const block = rung.nodes.find( - (n): n is BlockNode => n.id === branch.blockId && n.type === 'block', - ) +const computeCompactBranchXRange = (rung: RungLadderState, branch: HandleBranch): [number, number] | undefined => { + const block = rung.nodes.find((n): n is BlockNode => n.id === branch.blockId && n.type === 'block') if (!block) return undefined const span = computeBranchSpanWidth(rung, branch) @@ -2008,7 +1948,7 @@ export const inflateBlockHeightsForBranches = (rung: RungLadderState): { nodes: return { ...node, height: requiredHeight, - measured: { width: node.measured?.width ?? (node.width ?? 0), height: requiredHeight }, + measured: { width: node.measured?.width ?? node.width ?? 0, height: requiredHeight }, } }) @@ -2040,9 +1980,7 @@ export const applyDynamicBlockHandleOffsets = (rung: RungLadderState): { nodes: const blockShifts = new Map() for (const branch of rung.handleBranches) { - const block = rung.nodes.find( - (n): n is BlockNode => n.id === branch.blockId && n.type === 'block', - ) + const block = rung.nodes.find((n): n is BlockNode => n.id === branch.blockId && n.type === 'block') if (!block) continue const handlesArr = branch.direction === 'input' ? block.data.inputHandles : block.data.outputHandles @@ -2071,8 +2009,7 @@ export const applyDynamicBlockHandleOffsets = (rung: RungLadderState): { nodes: // span. Without this, a branched handle's natural Y can sit so close // to the main wire that the rail visually touches it. const BRANCH_MIN_TOP_PADDING = 30 - let obstacleBottom = - naturalHandleY - RAIL_HALF - BRANCH_OBSTACLE_CLEARANCE + BRANCH_MIN_TOP_PADDING + let obstacleBottom = naturalHandleY - RAIL_HALF - BRANCH_OBSTACLE_CLEARANCE + BRANCH_MIN_TOP_PADDING for (const other of rung.nodes) { if (other.id === block.id) continue if (!isObstacleCandidate(other)) continue @@ -2112,19 +2049,25 @@ export const applyDynamicBlockHandleOffsets = (rung: RungLadderState): { nodes: const entry = blockShifts.get(node.id) - const rewriteHandlesArray = }>( + const rewriteHandlesArray = < + T extends { + id?: string | null + relPosition: { x: number; y: number } + glbPosition: { x: number; y: number } + style?: unknown + }, + >( arr: readonly T[], ): T[] => arr.map((h, index) => { const naturalRelY = naturalRelYForIndex(rung, node, index) - const shifted = - entry && naturalRelY >= entry.firstAffectedNaturalRelY ? naturalRelY + entry.shift : naturalRelY + const shifted = entry && naturalRelY >= entry.firstAffectedNaturalRelY ? naturalRelY + entry.shift : naturalRelY return { ...h, glbPosition: { x: h.glbPosition.x, y: node.position.y + shifted }, relPosition: { x: h.relPosition.x, y: shifted }, - style: { ...(h.style ?? {}), top: shifted }, - } + style: { ...((h.style ?? {}) as Record), top: shifted }, + } as T }) const newInputHandles = rewriteHandlesArray(node.data.inputHandles) @@ -2159,9 +2102,7 @@ export const applyDynamicBlockHandleOffsets = (rung: RungLadderState): { nodes: const railBottom = handleRelY + DEFAULT_POWER_RAIL_HEIGHT / 2 const paths = branchParallelPathCount(rung, branch) const pathsBottom = - paths > 0 - ? handleRelY + paths * BRANCH_PARALLEL_PATH_HEIGHT + DEFAULT_CONTACT_BLOCK_HEIGHT / 2 - : 0 + paths > 0 ? handleRelY + paths * BRANCH_PARALLEL_PATH_HEIGHT + DEFAULT_CONTACT_BLOCK_HEIGHT / 2 : 0 const branchBottom = Math.max(railBottom, pathsBottom) if (branchBottom > maxBranchBottomRelY) maxBranchBottomRelY = branchBottom } @@ -2173,7 +2114,7 @@ export const applyDynamicBlockHandleOffsets = (rung: RungLadderState): { nodes: return { ...node, height: newHeight, - measured: { width: node.measured?.width ?? (node.width ?? 0), height: newHeight }, + measured: { width: node.measured?.width ?? node.width ?? 0, height: newHeight }, data: { ...node.data, handles: newHandles, @@ -2228,7 +2169,7 @@ export const calculateBranchElementPositions = ( const branchElements = branch.nodeIds .map((id) => rung.nodes.find((n) => n.id === id)) - .filter((n): n is Node => n !== undefined) + .filter((n): n is RungLadderState['nodes'][number] => n !== undefined) if (branchElements.length === 0) return positions const blockHandleX = blockHandle.glbPosition.x @@ -2280,7 +2221,7 @@ export const calculateBranchElementPositions = ( const n = rung.nodes.find((n2) => n2.id === branch.nodeIds[j]) if (!n) break if (n.type === 'parallel') { - const ptype = (n).data.type + const ptype = n.data.type if (ptype === 'close' && depth === 0) break if (ptype === 'open') depth++ else if (ptype === 'close') depth-- @@ -2289,7 +2230,7 @@ export const calculateBranchElementPositions = ( naturalInterior += branchGapBetween(open, n) firstInside = false } - naturalInterior += (n.width ?? DEFAULT_CONTACT_BLOCK_WIDTH) + naturalInterior += n.width ?? DEFAULT_CONTACT_BLOCK_WIDTH const next = rung.nodes.find((n2) => n2.id === branch.nodeIds[j + 1]) if (next) naturalInterior += branchGapBetween(n, next) } @@ -2308,7 +2249,7 @@ export const calculateBranchElementPositions = ( const halfExtraPerEdge = new Map() branchElements.forEach((node, idx) => { if (node.type !== 'parallel') return - if ((node as ParallelNode).data.type !== 'open') return + if (node.data.type !== 'open') return const extra = spineSpanFor(idx, node) if (extra <= 0) return const half = extra / 2 @@ -2319,7 +2260,7 @@ export const calculateBranchElementPositions = ( for (let j = idx + 1; j < branchElements.length; j++) { const n = branchElements[j] if (n.type === 'parallel') { - const ptype = (n as ParallelNode).data.type + const ptype = n.data.type if (ptype === 'open') depth++ else if (ptype === 'close') { depth-- @@ -2378,7 +2319,8 @@ export const calculateBranchElementPositions = ( // Edge index between node (i) and next (i+1) is i. const extraGap = halfExtraPerEdge.get(i) ?? 0 leftEdge = leftEdge + width + branchGapBetween(node, next) + extraGap - if (i === branchElements.length - 1) rightmostElementRight = leftEdge - extraGap - branchGapBetween(node, next) + width + if (i === branchElements.length - 1) + rightmostElementRight = leftEdge - extraGap - branchGapBetween(node, next) + width } } @@ -2414,7 +2356,7 @@ export const calculateBranchElementPositions = ( for (let i = 0; i < branch.nodeIds.length; i++) { const node = rung.nodes.find((n) => n.id === branch.nodeIds[i]) if (!node || node.type !== 'parallel') continue - if ((node).data.type !== 'open') continue + if (node.data.type !== 'open') continue const open = node const closeId = open.data.parallelCloseReference @@ -2438,9 +2380,7 @@ export const calculateBranchElementPositions = ( ? Math.min(openPos.posX, closePos.posX) + (open.width ?? 4) : Math.min(openPos.posX, closePos.posX) + (close.width ?? 4) const rightBoundary = - branch.direction === 'input' - ? Math.max(openPos.posX, closePos.posX) - : Math.max(openPos.posX, closePos.posX) + branch.direction === 'input' ? Math.max(openPos.posX, closePos.posX) : Math.max(openPos.posX, closePos.posX) startEdges.forEach((startEdge, pathIndex) => { const pathNodes = walkParallelPath(rung, open, close, startEdge) @@ -2576,7 +2516,14 @@ export const updateRailForBranches = (rung: RungLadderState): { nodes: Node[]; e if (targetYs.size === 0) return { nodes: rung.nodes, edges: rung.edges } - const syncHandle = }>( + const syncHandle = < + T extends { + id?: string | null + glbPosition: { x: number; y: number } + relPosition: { x: number; y: number } + style?: unknown + }, + >( handle: T, railY: number, ): T => { @@ -2587,8 +2534,8 @@ export const updateRailForBranches = (rung: RungLadderState): { nodes: Node[]; e ...handle, glbPosition: { x: handle.glbPosition.x, y: targetY }, relPosition: { x: handle.relPosition.x, y: yRel }, - style: { ...(handle.style ?? {}), top: yRel }, - } + style: { ...((handle.style ?? {}) as Record), top: yRel }, + } as T } const newNodes = rung.nodes.map((node) => { @@ -2648,7 +2595,7 @@ export const removeAllBranchesForBlock = ( const nodeId = idsCopy[i] const elementNode = workingRung.nodes.find((n) => n.id === nodeId) if (!elementNode) continue - const result = removeBranchElement({ ...rung, ...workingRung }, elementNode) + const result = removeBranchElement({ ...rung, ...workingRung } as RungLadderState, elementNode) workingRung = result } } diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/index.ts index 82574de81..e5ad247d4 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/index.ts @@ -2,7 +2,11 @@ import type { RungLadderState } from '@root/frontend/store/slices' import type { HandleBranch } from '@root/middleware/shared/ports/types' import type { Edge, Node } from '@xyflow/react' -import type { BasicNodeData, PlaceholderNode } from '../../../../../../_atoms/graphical-editor/ladder/utils/types' +import type { + BasicNodeData, + PlaceholderNode, + RungNode, +} from '../../../../../../_atoms/graphical-editor/ladder/utils/types' import { toast } from '../../../../../../_features/[app]/toast/use-toast' import { disconnectNodes } from '../edges' import { isNodeOfType, removeNode } from '../nodes' @@ -78,9 +82,7 @@ export const addNewElement = ( } const isParallelInBranch = selectedPlaceholder.type === 'parallelPlaceholder' - const aboveElementId = isParallelInBranch - ? ((selectedPlaceholder).data.relatedNode?.id ?? '') - : '' + const aboveElementId = isParallelInBranch ? (selectedPlaceholder.data.relatedNode?.id ?? '') : '' // If the parallel-placeholder's spine element is already wrapped by an // OPEN/CLOSE pair, add another OR-path to that existing parallel @@ -92,10 +94,7 @@ export const addNewElement = ( // target) don't count — a spine element sitting AFTER a CLOSE is on // the spine, not inside the parallel. const branch = getBranch(rung, branchTarget.blockId, branchTarget.handleId, branchTarget.direction) - const existingParallel = - isParallelInBranch && branch - ? branch.nodeIds.findIndex((id) => id === aboveElementId) - : -1 + const existingParallel = isParallelInBranch && branch ? branch.nodeIds.findIndex((id) => id === aboveElementId) : -1 let isInsideExistingParallel = false if (existingParallel !== -1 && branch) { let depth = 0 @@ -158,7 +157,7 @@ export const addNewElement = ( nodes: result.nodes, edges: result.edges, handleBranches: result.handleBranches, - }, + } as RungLadderState, rung.defaultBounds as [number, number], ) @@ -184,14 +183,14 @@ export const addNewElement = ( rung, { index: parseInt(selectedPlaceholderIndex), - selected: selectedPlaceholder as PlaceholderNode, + selected: selectedPlaceholder, }, newNode, ) - newNodes = parallelNodes + newNodes = parallelNodes as RungLadderState['nodes'] newEdges = parallelEdges newNodeData = parellelNewNode - } else { + } else if (isNodeOfType(selectedPlaceholder, 'placeholder')) { const { nodes: serialNodes, edges: serialEdges, @@ -200,13 +199,15 @@ export const addNewElement = ( rung, { index: parseInt(selectedPlaceholderIndex), - selected: selectedPlaceholder as PlaceholderNode, + selected: selectedPlaceholder, }, newNode, ) - newNodes = serialNodes + newNodes = serialNodes as RungLadderState['nodes'] newEdges = serialEdges newNodeData = serialNewNode + } else { + return { nodes: removePlaceholderElements(rung.nodes), edges: rung.edges, handleBranches: rung.handleBranches } } /** @@ -217,11 +218,11 @@ export const addNewElement = ( ...rung, nodes: newNodes, edges: newEdges, - }, + } as RungLadderState, rung.defaultBounds as [number, number], ) - newNodes = updatedDiagramNodes + newNodes = updatedDiagramNodes as RungLadderState['nodes'] newEdges = updatedDiagramEdges /** @@ -252,15 +253,20 @@ export const removeElement = ( nodes: removed.nodes, edges: removed.edges, handleBranches: removed.handleBranches, - }) + } as RungLadderState) const reconciledHandleBranches = reconcileAllBranchNodeIds({ ...rung, nodes: collapsed.nodes, edges: collapsed.edges, handleBranches: removed.handleBranches, - }) + } as RungLadderState) const layoutResult = updateDiagramElementsPosition( - { ...rung, nodes: collapsed.nodes, edges: collapsed.edges, handleBranches: reconciledHandleBranches }, + { + ...rung, + nodes: collapsed.nodes, + edges: collapsed.edges, + handleBranches: reconciledHandleBranches, + } as RungLadderState, rung.defaultBounds as [number, number], ) return { nodes: layoutResult.nodes, edges: layoutResult.edges, handleBranches: reconciledHandleBranches } @@ -276,7 +282,7 @@ export const removeElement = ( let workingEdgesIn = rung.edges if (element.type === 'block') { const branchResult = removeAllBranchesForBlock(rung, element.id) - workingNodesIn = branchResult.nodes + workingNodesIn = branchResult.nodes as RungLadderState['nodes'] workingEdgesIn = branchResult.edges workingHandleBranches = branchResult.handleBranches } @@ -311,8 +317,8 @@ export const removeElement = ( ...workingRung, nodes: newNodes, edges: newEdges, - }) - newNodes = checkedParallelNodes + } as RungLadderState) + newNodes = checkedParallelNodes as RungNode[] newEdges = checkedParallelEdges /** @@ -323,10 +329,10 @@ export const removeElement = ( ...workingRung, nodes: newNodes, edges: newEdges, - }, + } as RungLadderState, rung.defaultBounds as [number, number], ) - newNodes = updatedDiagramNodes + newNodes = updatedDiagramNodes as RungNode[] newEdges = updatedDiagramEdges /** @@ -345,7 +351,7 @@ export const removeElements = ( let workingRung: RungLadderState = { ...rung } for (const node of nodesToRemove) { const { nodes, edges, handleBranches } = removeElement(workingRung, node) - workingRung = { ...workingRung, nodes, edges, handleBranches } + workingRung = { ...workingRung, nodes, edges, handleBranches } as RungLadderState } return { nodes: workingRung.nodes, edges: workingRung.edges, handleBranches: workingRung.handleBranches } diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/parallel/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/parallel/index.ts index e3157ea01..f9fb2cfe3 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/parallel/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/parallel/index.ts @@ -37,7 +37,7 @@ export const startParallelConnection = ( placeholder: { index: number; selected: PlaceholderNode }, node: Node | { elementType: string; blockVariant?: T }, ): { nodes: Node[]; edges: Edge[]; newNode?: Node } => { - let newNodes = [...rung.nodes] + let newNodes: Node[] = [...rung.nodes] let newEdges = [...rung.edges] /** @@ -148,7 +148,7 @@ export const startParallelConnection = ( */ const relatedNode = placeholder.selected.data.relatedNode as Node const { nodes: relatedElementPreviousElements, edges: relatedElementPreviousEdges } = getPreviousElementsByEdge( - { ...rung, nodes: newNodes, edges: newEdges }, + { ...rung, nodes: newNodes, edges: newEdges } as RungLadderState, relatedNode, ) if (!relatedElementPreviousElements || !relatedElementPreviousEdges) return { nodes: newNodes, edges: newEdges } @@ -159,7 +159,7 @@ export const startParallelConnection = ( // first insert the new element newNodes.splice(placeholder.index, 1, openParallelElement, newAboveElement, newElement, closeParallelElement) // then remove the old above node - newNodes = removeNode({ ...rung, nodes: newNodes }, aboveElement.id) + newNodes = removeNode({ ...rung, nodes: newNodes } as RungLadderState, aboveElement.id) // finally remove the placeholder nodes newNodes = removePlaceholderElements(newNodes) @@ -183,13 +183,13 @@ export const startParallelConnection = ( return ( isNodeOfType(firstElement, 'parallel') && - (firstElement as ParallelNode).data?.type === 'open' && - firstEdge.sourceHandle === (firstElement as ParallelNode).data?.parallelOutputConnector?.id + firstElement.data?.type === 'open' && + firstEdge.sourceHandle === firstElement.data?.parallelOutputConnector?.id ) })() newEdges = connectNodes( - { ...rung, nodes: newNodes, edges: newEdges }, + { ...rung, nodes: newNodes, edges: newEdges } as RungLadderState, aboveElementTargetEdges[0].source, openParallelElement.id, isPreviousConnectionParallel ? 'parallel' : 'serial', @@ -201,7 +201,7 @@ export const startParallelConnection = ( }, ) newEdges = connectNodes( - { ...rung, nodes: newNodes, edges: newEdges }, + { ...rung, nodes: newNodes, edges: newEdges } as RungLadderState, openParallelElement.id, newAboveElement.id, 'serial', @@ -211,7 +211,7 @@ export const startParallelConnection = ( }, ) newEdges = connectNodes( - { ...rung, nodes: newNodes, edges: newEdges }, + { ...rung, nodes: newNodes, edges: newEdges } as RungLadderState, newAboveElement.id, closeParallelElement.id, 'serial', @@ -233,13 +233,13 @@ export const startParallelConnection = ( return ( isNodeOfType(targetNode, 'parallel') && - (targetNode as ParallelNode).data?.type === 'close' && - targetEdge.targetHandle === (targetNode as ParallelNode).data?.parallelInputConnector?.id + targetNode.data?.type === 'close' && + targetEdge.targetHandle === targetNode.data?.parallelInputConnector?.id ) })() newEdges = connectNodes( - { ...rung, nodes: newNodes, edges: newEdges }, + { ...rung, nodes: newNodes, edges: newEdges } as RungLadderState, closeParallelElement.id, aboveElementSourceEdges[0].target, 'serial', @@ -253,7 +253,7 @@ export const startParallelConnection = ( // parallel connections newEdges = connectNodes( - { ...rung, nodes: newNodes, edges: newEdges }, + { ...rung, nodes: newNodes, edges: newEdges } as RungLadderState, openParallelElement.id, newElement.id, 'parallel', @@ -263,7 +263,7 @@ export const startParallelConnection = ( }, ) newEdges = connectNodes( - { ...rung, nodes: newNodes, edges: newEdges }, + { ...rung, nodes: newNodes, edges: newEdges } as RungLadderState, newElement.id, closeParallelElement.id, 'parallel', @@ -289,7 +289,7 @@ export const startParallelConnection = ( export const removeEmptyParallelConnections = (rung: RungLadderState): { nodes: Node[]; edges: Edge[] } => { const { nodes, edges } = rung - let newNodes = [...nodes] + let newNodes: Node[] = [...nodes] let newEdges = [...edges] nodes.forEach((node) => { @@ -304,7 +304,7 @@ export const removeEmptyParallelConnections = (rung: RungLadderState): { nodes: * Get the nodes inside the parallel connection */ const { serial: serialNodes, parallel: parallelNodes } = getNodesInsideParallel( - { ...rung, nodes: newNodes, edges: newEdges }, + { ...rung, nodes: newNodes, edges: newEdges } as RungLadderState, closeParallel, ) @@ -349,8 +349,8 @@ export const removeEmptyParallelConnections = (rung: RungLadderState): { nodes: newEdges = removeEdge(newEdges, openParallelTarget.id) newEdges = removeEdge(newEdges, closeParallelSource.id) - newNodes = removeNode({ ...rung, nodes: newNodes }, closeParallel.id) - newNodes = removeNode({ ...rung, nodes: newNodes }, openParallel.id) + newNodes = removeNode({ ...rung, nodes: newNodes } as RungLadderState, closeParallel.id) + newNodes = removeNode({ ...rung, nodes: newNodes } as RungLadderState, openParallel.id) return { nodes: newNodes, edges: newEdges } } @@ -409,8 +409,8 @@ export const removeEmptyParallelConnections = (rung: RungLadderState): { nodes: newEdges = removeEdge(newEdges, openParallelTarget.id) newEdges = removeEdge(newEdges, closeParallelSource.id) - newNodes = removeNode({ ...rung, nodes: newNodes }, closeParallel.id) - newNodes = removeNode({ ...rung, nodes: newNodes }, openParallel.id) + newNodes = removeNode({ ...rung, nodes: newNodes } as RungLadderState, closeParallel.id) + newNodes = removeNode({ ...rung, nodes: newNodes } as RungLadderState, openParallel.id) } } } diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/placeholder/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/placeholder/index.ts index 246603f44..4b8fb08af 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/placeholder/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/placeholder/index.ts @@ -39,10 +39,7 @@ export const renderPlaceholderElements = (rung: RungLadderState) => { // left / right / parallel placeholders for them would let the user drop // a main-rail-style parallel onto a branch — Phase 4 handles that case // explicitly via `startParallelInBranch`; until then, suppress. - if ( - (node.type === 'contact' || node.type === 'coil' || node.type === 'parallel') && - node.data.branchContext - ) { + if ((node.type === 'contact' || node.type === 'coil' || node.type === 'parallel') && node.data.branchContext) { placeholderNodes.push(node) return } diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/serial/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/serial/index.ts index 6a0b785df..27dcc8cae 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/serial/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/serial/index.ts @@ -3,7 +3,7 @@ import { newGraphicalEditorNodeID } from '@root/frontend/utils/new-graphical-edi import type { Edge, Node } from '@xyflow/react' import { checkIfElementIsNode } from '../../../../../../../_atoms/graphical-editor/ladder/node-builders' -import { ParallelNode, PlaceholderNode } from '../../../../../../../_atoms/graphical-editor/ladder/utils/types' +import { PlaceholderNode } from '../../../../../../../_atoms/graphical-editor/ladder/utils/types' import { connectNodes } from '../../edges' import { buildGenericNode, isNodeOfType } from '../../nodes' import { removePlaceholderElements } from '../placeholder' @@ -17,7 +17,7 @@ export const appendSerialConnection = ( }, node: Node | { elementType: string; blockVariant?: T }, ): { nodes: Node[]; edges: Edge[]; newNode?: Node } => { - let newNodes = [...rung.nodes] + let newNodes: Node[] = [...rung.nodes] let newEdges = [...rung.edges] /** @@ -49,7 +49,7 @@ export const appendSerialConnection = ( */ const relatedNode = placeholder.selected.data.relatedNode as Node const { nodes: relatedNodePreviousNodes, edges: relatedNodePreviousEdges } = getPreviousElementsByEdge( - { ...rung, nodes: newNodes, edges: newEdges }, + { ...rung, nodes: newNodes, edges: newEdges } as RungLadderState, relatedNode, ) if (!relatedNodePreviousNodes || !relatedNodePreviousEdges) return { nodes: newNodes, edges: newEdges } @@ -58,7 +58,7 @@ export const appendSerialConnection = ( * Get the previous node */ let previousNode = getPreviousElement( - { ...rung, nodes: newNodes, edges: newEdges }, + { ...rung, nodes: newNodes, edges: newEdges } as RungLadderState, newNodes.findIndex((n) => n.id === newElement.id), ) @@ -73,17 +73,26 @@ export const appendSerialConnection = ( relatedNodePreviousNodes.serial.length > 0 && // Check if the node is a open parallel node isNodeOfType(relatedNodePreviousNodes.serial[0], 'parallel') && - (relatedNodePreviousNodes.serial[0] as ParallelNode).data.type === 'open' && + relatedNodePreviousNodes.serial[0].data.type === 'open' && // If it is, check if the new element is being added to the left placeholder.selected.data.position === 'left' && // If it is, check if the new element is being added to the parallel output connector - relatedNodePreviousEdges[0].sourceHandle === - (relatedNodePreviousNodes.serial[0] as ParallelNode).data.parallelOutputConnector?.id + relatedNodePreviousEdges[0].sourceHandle === relatedNodePreviousNodes.serial[0].data.parallelOutputConnector?.id ) { previousNode = relatedNodePreviousNodes.serial[0] - newEdges = connectNodes({ ...rung, nodes: newNodes, edges: newEdges }, previousNode.id, newElement.id, 'parallel') + newEdges = connectNodes( + { ...rung, nodes: newNodes, edges: newEdges } as RungLadderState, + previousNode.id, + newElement.id, + 'parallel', + ) } else { - newEdges = connectNodes({ ...rung, nodes: newNodes, edges: newEdges }, previousNode.id, newElement.id, 'serial') + newEdges = connectNodes( + { ...rung, nodes: newNodes, edges: newEdges } as RungLadderState, + previousNode.id, + newElement.id, + 'serial', + ) } return { nodes: newNodes, edges: newEdges, newNode: newElement } diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts index e14a1593d..7afe6876f 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts @@ -53,8 +53,8 @@ export const getPreviousElementsByEdge = ( */ if ( isNodeOfType(node, 'parallel') && - (node as ParallelNode).data.type === 'close' && - e.targetHandle === (node as ParallelNode).data.parallelInputConnector?.id + node.data.type === 'close' && + e.targetHandle === node.data.parallelInputConnector?.id ) { lastNodes.nodes.parallel.push({ ...n }) return @@ -232,7 +232,7 @@ export const findParallelsInRung = (rung: RungLadderState): ParallelNode[] => { let isAnotherParallel = true let parallel: ParallelNode | undefined = undefined rung.nodes.forEach((node) => { - if (isAnotherParallel && node.type === 'parallel' && (node).data.type === 'open') { + if (isAnotherParallel && node.type === 'parallel' && node.data.type === 'open') { parallels.push(node) parallel = node isAnotherParallel = false @@ -240,7 +240,7 @@ export const findParallelsInRung = (rung: RungLadderState): ParallelNode[] => { if ( !isAnotherParallel && node.type === 'parallel' && - (node).data.type === 'close' && + node.data.type === 'close' && parallel?.data.parallelCloseReference === node.id ) { isAnotherParallel = true @@ -261,7 +261,7 @@ export const findDeepestParallelInsideParallel = (rung: RungLadderState, paralle const parallelIndex = rung.nodes.findIndex((node) => node.id === parallel.id) for (let i = parallelIndex; i < rung.nodes.length; i++) { const node = rung.nodes[i] - if (node.type === 'parallel' && (node).data.type === 'close') { + if (node.type === 'parallel' && node.data.type === 'close') { return node } } @@ -434,9 +434,7 @@ export const getDeepestNodesInsideParallels = (rung: RungLadderState): Node[] => * @returns Node[] */ export const getNodesInsideAllParallels = (rung: RungLadderState): Node[] => { - const closeParallels = rung.nodes.filter( - (node) => node.type === 'parallel' && (node).data.type === 'close', - ) + const closeParallels = rung.nodes.filter((node) => node.type === 'parallel' && node.data.type === 'close') const nodes: Node[] = [] closeParallels.forEach((closeParallel) => { const { serial, parallel: parallelNodes } = getNodesInsideParallel(rung, closeParallel) diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/index.ts index 3fcce34cf..5af783b92 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/variable-block/index.ts @@ -128,7 +128,7 @@ export const removeVariableBlock = (rung: RungLadderState) => { } export const updateVariableBlockPosition = (rung: RungLadderState, _defaultBounds?: [number, number]) => { - let newNodes = [...rung.nodes] + let newNodes: Node[] = [...rung.nodes] let newEdges = [...rung.edges] const { nodes: removedVariableNodes, edges: removedVariableEdges } = removeVariableBlock(rung) @@ -143,7 +143,7 @@ export const updateVariableBlockPosition = (rung: RungLadderState, _defaultBound ...rung, nodes: newNodes, edges: newEdges, - }, + } as RungLadderState, blockElement, ) newNodes = nodes diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/nodes.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/nodes.ts index a25ed80f2..97038cd8e 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/nodes.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/nodes.ts @@ -2,25 +2,30 @@ import type { Node } from '@xyflow/react' import type { RungLadderState } from '../../../../../../store/slices/ladder' import { defaultCustomNodesStyles, nodesBuilder } from '../../../../../_atoms/graphical-editor/ladder/node-builders' -import type { BuilderBasicProps } from '../../../../../_atoms/graphical-editor/ladder/utils/types' +import type { BuilderBasicProps, RungNode } from '../../../../../_atoms/graphical-editor/ladder/utils/types' +import { isRungNodeOfType } from '../../../../../_atoms/graphical-editor/ladder/utils/types' export const findNode = ( rung: RungLadderState, nodeId: string, -): { node: Node | undefined; position: number | undefined } => { +): { node: RungNode | undefined; position: number | undefined } => { return { node: rung.nodes.find((node) => node.id === nodeId), position: rung.nodes.findIndex((node) => node.id === nodeId), } } -export const removeNode = (rung: RungLadderState, nodeId: string): Node[] => { +export const removeNode = (rung: RungLadderState, nodeId: string): RungNode[] => { return rung.nodes.filter((node) => node.id !== nodeId) } -export const isNodeOfType = (node: Node, nodeType: string): boolean => { - return node.type === nodeType -} +/** + * Backwards-compatible alias for `isRungNodeOfType` (which lives at the atoms + * layer alongside `RungNode`). Existing molecules / features call this by + * `isNodeOfType` — re-export the same function here so call sites keep + * working without an import-path churn. + */ +export const isNodeOfType = isRungNodeOfType export const getDefaultNodeStyle = ({ node, nodeType }: { node?: Node; nodeType?: string }) => { return defaultCustomNodesStyles[node?.type ?? nodeType ?? 'mockNode'] diff --git a/src/frontend/store/slices/ladder/slice.ts b/src/frontend/store/slices/ladder/slice.ts index 64e442d12..371fbf53c 100644 --- a/src/frontend/store/slices/ladder/slice.ts +++ b/src/frontend/store/slices/ladder/slice.ts @@ -10,7 +10,7 @@ import { } from '../../../components/_atoms/graphical-editor/ladder/node-builders' import type { LadderBlockConnectedVariables } from '../../../components/_atoms/graphical-editor/ladder/utils/types' import { removeElements } from '../../../components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements' -import { LadderFlowSlice, LadderFlowState, LadderFlowType } from './types' +import { LadderFlowSlice, LadderFlowState, LadderFlowType, RungLadderState } from './types' import { duplicateLadderRung } from './utils' /** @@ -39,7 +39,7 @@ const parseFlowOrPassthrough = (flow: LadderFlowType): LadderFlowType => { rungs: result.data.rungs.map((rung, i) => ({ ...rung, selectedNodes: flow.rungs[i]?.selectedNodes ?? [], - })), + })) as RungLadderState[], } } @@ -154,7 +154,7 @@ export const createLadderFlowSlice: StateCreator defaultBounds ? reactFlowViewport : defaultBounds, - nodes: [...railNodes], + nodes: [...railNodes] as RungLadderState['nodes'], edges: [ { id: `e_${railNodes[0].id}_${railNodes[1].id}`, @@ -252,7 +252,7 @@ export const createLadderFlowSlice: StateCreator rung.id === rungId) if (!rung) return - rung.nodes = applyNodeChanges(changes, rung.nodes) + rung.nodes = applyNodeChanges(changes, rung.nodes) as RungLadderState['nodes'] }), ) }, @@ -292,7 +292,7 @@ export const createLadderFlowSlice: StateCreator rung.id === rungId) if (!rung) return - rung.nodes = nodes + rung.nodes = nodes as RungLadderState['nodes'] flow.updated = true }), ) @@ -309,7 +309,7 @@ export const createLadderFlowSlice: StateCreator n.id === nodeId) if (nodeIndex === -1) return - rung.nodes[nodeIndex] = node + rung.nodes[nodeIndex] = node as RungLadderState['nodes'][number] flow.updated = true }), ) @@ -323,7 +323,7 @@ export const createLadderFlowSlice: StateCreator rung.id === rungId) if (!rung) return - rung.nodes.push(node) + rung.nodes.push(node as RungLadderState['nodes'][number]) rung.nodes = rung.nodes.map((n) => { if (n.id === node.id) { return { @@ -351,7 +351,7 @@ export const createLadderFlowSlice: StateCreator 1) { rung.nodes = rung.nodes.map((node) => { @@ -495,7 +495,7 @@ export const createLadderFlowSlice: StateCreator rung.id === rungId) if (!rung) return - rung.nodes = nodes + rung.nodes = nodes as RungLadderState['nodes'] rung.edges = edges if (handleBranches !== undefined) rung.handleBranches = handleBranches flow.updated = true diff --git a/src/frontend/store/slices/ladder/types.ts b/src/frontend/store/slices/ladder/types.ts index ee5081afb..ea606f0b1 100644 --- a/src/frontend/store/slices/ladder/types.ts +++ b/src/frontend/store/slices/ladder/types.ts @@ -2,10 +2,7 @@ import { Connection, Edge, EdgeChange, Node, NodeChange } from '@xyflow/react' import { z } from 'zod' import { zodLadderFlowSchema, zodRungLadderStateSchema } from '../../../../middleware/shared/ports/flow-schemas' -import type { - HandleBranch, - RungLadderState as PortRungLadderState, -} from '../../../../middleware/shared/ports/types' +import type { HandleBranch, RungLadderState as PortRungLadderState } from '../../../../middleware/shared/ports/types' import type { RungNode } from '../../../components/_atoms/graphical-editor/ladder/utils/types' type ZodLadderRungType = z.infer diff --git a/src/frontend/utils/PLC/xml-generator/codesys/language/ladder-xml.ts b/src/frontend/utils/PLC/xml-generator/codesys/language/ladder-xml.ts index f3b38e328..e632282e4 100644 --- a/src/frontend/utils/PLC/xml-generator/codesys/language/ladder-xml.ts +++ b/src/frontend/utils/PLC/xml-generator/codesys/language/ladder-xml.ts @@ -76,7 +76,7 @@ const contactToXML = ( const connections = findConnections(contact, rung, offsetY) const railConnection = connections.find((connection) => { - const rail = rung.nodes.find((node) => node.type === 'powerRail' && (node).data.variant === 'left') + const rail = rung.nodes.find((node) => node.type === 'powerRail' && node.data.variant === 'left') if (rail?.data.numericId === connection['@refLocalId']) { return true } @@ -100,8 +100,8 @@ const contactToXML = ( const refLocalId = railConnection ? leftRailId.toString() : connection['@refLocalId'] const formalParameter = connectionNode?.type === 'block' - ? (connectionNode).data.variant.type === 'function' - ? (connectionNode).data.variant.name + ? connectionNode.data.variant.type === 'function' + ? connectionNode.data.variant.name : connection['@formalParameter'] : undefined return { @@ -119,7 +119,7 @@ const coilToXml = (coil: CoilNode, rung: RungLadderState, offsetY: number = 0, l const connections = findConnections(coil, rung, offsetY) const railConnection = connections.find((connection) => { - const rail = rung.nodes.find((node) => node.type === 'powerRail' && (node).data.variant === 'left') + const rail = rung.nodes.find((node) => node.type === 'powerRail' && node.data.variant === 'left') if (rail?.data.numericId === connection['@refLocalId']) { return true } @@ -144,8 +144,8 @@ const coilToXml = (coil: CoilNode, rung: RungLadderState, offsetY: number = 0, l const refLocalId = railConnection ? leftRailId.toString() : connection['@refLocalId'] const formalParameter = connectionNode?.type === 'block' - ? (connectionNode).data.variant.type === 'function' - ? (connectionNode).data.variant.name + ? connectionNode.data.variant.type === 'function' + ? connectionNode.data.variant.name : connection['@formalParameter'] : undefined return { @@ -169,7 +169,7 @@ const blockToXml = ( // If the block is connected to a power rail, replace the refLocalId with the left rail id at connections const railConnection = connections.find((connection) => { - const rail = rung.nodes.find((node) => node.type === 'powerRail' && (node).data.variant === 'left') + const rail = rung.nodes.find((node) => node.type === 'powerRail' && node.data.variant === 'left') if (rail?.data.numericId === connection['@refLocalId']) { return true } @@ -220,10 +220,7 @@ const blockToXml = ( // Check if the handle is connected to an existing variable node const variableNode = rung.nodes.find( - (node) => - node.type === 'variable' && - (node).data.block.id === block.id && - (node).data.block.handleId === handle.id, + (node) => node.type === 'variable' && node.data.block.id === block.id && node.data.block.handleId === handle.id, ) as Node if (!variableNode) return undefined @@ -250,7 +247,7 @@ const blockToXml = ( expression: handleIndex !== 0 ? connectedNode && connectedNode.type === 'variable' - ? (connectedNode).data.variable.name + ? connectedNode.data.variable.name : '' : undefined, }, @@ -359,9 +356,9 @@ const ladderToXml = (rungs: RungLadderState[]) => { nodes.forEach((node) => { switch (node.type) { case 'powerRail': - if ((node).data.variant === 'left' && ladderXML.body.LD.leftPowerRail.length === 0) { + if (node.data.variant === 'left' && ladderXML.body.LD.leftPowerRail.length === 0) { ladderXML.body.LD.leftPowerRail.push(leftRailToXML(node, offsetY)) - leftRailId = (node).data.numericId + leftRailId = node.data.numericId } else { if (ladderXML.body.LD.rightPowerRail.length === 0) { ladderXML.body.LD.rightPowerRail.push(rightRailToXML(node, rung, offsetY)) @@ -378,10 +375,9 @@ const ladderToXml = (rungs: RungLadderState[]) => { ladderXML.body.LD.block.push(blockToXml(node, rung, offsetY, leftRailId)) break case 'variable': - if ((node).data.variable.name === '') return - if ((node).data.variant === 'input') - ladderXML.body.LD.inVariable.push(inVariableToXML(node, offsetY)) - if ((node).data.variant === 'output') { + if (node.data.variable.name === '') return + if (node.data.variant === 'input') ladderXML.body.LD.inVariable.push(inVariableToXML(node, offsetY)) + if (node.data.variant === 'output') { const outVarXML = outVariableToXML(node, rung, offsetY) if (outVarXML) ladderXML.body.LD.outVariable.push(outVarXML) } diff --git a/src/frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.ts b/src/frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.ts index 6ba280b12..02d2f94db 100644 --- a/src/frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.ts +++ b/src/frontend/utils/PLC/xml-generator/old-editor/language/ladder-xml.ts @@ -178,10 +178,7 @@ const blockToXml = (block: BlockNode, rung: RungLadderState, offse // Check if the handle is connected to an existing variable node const variableNode = rung.nodes.find( - (node) => - node.type === 'variable' && - (node).data.block.id === block.id && - (node).data.block.handleId === handle.id, + (node) => node.type === 'variable' && node.data.block.id === block.id && node.data.block.handleId === handle.id, ) as Node if (!variableNode) return undefined @@ -334,8 +331,7 @@ const ladderToXml = (rungs: RungLadderState[]) => { nodes.forEach((node) => { switch (node.type) { case 'powerRail': - if ((node).data.variant === 'left') - ladderXML.body.LD.leftPowerRail.push(leftRailToXML(node, offsetY)) + if (node.data.variant === 'left') ladderXML.body.LD.leftPowerRail.push(leftRailToXML(node, offsetY)) else ladderXML.body.LD.rightPowerRail.push(rightRailToXML(node, rung, offsetY)) break case 'contact': @@ -348,11 +344,9 @@ const ladderToXml = (rungs: RungLadderState[]) => { ladderXML.body.LD.block.push(blockToXml(node, rung, offsetY)) break case 'variable': - if ((node).data.variable.name === '') return - if ((node).data.variant === 'input') - ladderXML.body.LD.inVariable.push(inVariableToXML(node, offsetY)) - if ((node).data.variant === 'output') - ladderXML.body.LD.outVariable.push(outVariableToXML(node, rung, offsetY)) + if (node.data.variable.name === '') return + if (node.data.variant === 'input') ladderXML.body.LD.inVariable.push(inVariableToXML(node, offsetY)) + if (node.data.variant === 'output') ladderXML.body.LD.outVariable.push(outVariableToXML(node, rung, offsetY)) break default: break diff --git a/src/frontend/utils/PLC/xml-generator/rung-graph.ts b/src/frontend/utils/PLC/xml-generator/rung-graph.ts index b8bebd18b..51a84a3a5 100644 --- a/src/frontend/utils/PLC/xml-generator/rung-graph.ts +++ b/src/frontend/utils/PLC/xml-generator/rung-graph.ts @@ -1,7 +1,4 @@ -import type { - BasicNodeData, - ParallelNode, -} from '@root/frontend/components/_atoms/graphical-editor/ladder/utils/types' +import type { BasicNodeData, ParallelNode } from '@root/frontend/components/_atoms/graphical-editor/ladder/utils/types' import type { RungLadderState } from '@root/frontend/store/slices' import type { Node } from '@xyflow/react' @@ -46,7 +43,7 @@ export const findNodeBasedOnParallelOpen = ( nodes: Node[] parallels: ParallelNode[] } = { nodes: [], parallels: [] }, -) => { +): { nodes: Node[]; parallels: ParallelNode[] } => { const { nodes: rungNodes, edges: rungEdges } = rung const edgeToParallelNode = rungEdges.find((edge) => edge.target === parallelNode.id)?.source @@ -74,7 +71,7 @@ export const findNodesBasedOnParallelClose = ( nodes: Node[] parallels: ParallelNode[] } = { nodes: [], parallels: [] }, -) => { +): { nodes: Node[]; parallels: ParallelNode[] } => { const { nodes: rungNodes, edges: rungEdges } = rung const edgesToParallelNode = rungEdges.filter((edge) => edge.target === parallelNode.id) diff --git a/src/middleware/shared/ports/ai-port.ts b/src/middleware/shared/ports/ai-port.ts index d3b7bbceb..a21dd3cf2 100644 --- a/src/middleware/shared/ports/ai-port.ts +++ b/src/middleware/shared/ports/ai-port.ts @@ -53,6 +53,10 @@ export type AITelemetryEventName = | 'completion_timeout' | 'chat_message' | 'chat_rating' + | 'conversation_created' + | 'conversation_loaded' + | 'conversation_renamed' + | 'conversation_deleted' // --------------------------------------------------------------------------- // Port interface From e1244ec142368cffb143cc02eeb8f3dc41bd73ae Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Tue, 12 May 2026 10:24:36 -0300 Subject: [PATCH 4/7] sync(ladder): pull parallel-bracket-collapse fix from web Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ladder-utils/elements/diagram/index.ts | 30 ++--------- .../rung/ladder-utils/elements/utils/index.ts | 51 ++++--------------- 2 files changed, 13 insertions(+), 68 deletions(-) diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts index 216bc8ac1..c4f39dd1a 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts @@ -180,42 +180,19 @@ const positionMainNodes = (rung: RungLadderState): { nodes: Node[]; edges: Edge[ ) if (!previousNodes || !previousEdges) return null - /** - * Detect whether `prevNode` (the immediate predecessor we'll feed into - * `getNodePositionBasedOnPreviousNode`) is itself the inner side of a - * same-type parallel chain — i.e. its own predecessor on the spine is - * another parallel of the same sub-type. When `node` is also a same- - * type parallel, the call collapses onto prev's X instead of stacking - * another clearance, so a 3-deep "add parallel" doesn't funnel the - * spine 49px-per-level rightward. - */ - const isSameTypeParallelOf = (n: Node | undefined, sub: 'open' | 'close'): boolean => - !!n && isNodeOfType(n, 'parallel') && n.data.type === sub - const prevIsAlreadyNestedFor = (prev: Node): boolean => { - if (!isNodeOfType(prev, 'parallel')) return false - const prevSubType = prev.data.type - const prevPrevEdges = rung.edges.filter((e) => e.target === prev.id) - for (const e of prevPrevEdges) { - const prevPrev = newNodes.find((n) => n.id === e.source) - if (isSameTypeParallelOf(prevPrev, prevSubType)) return true - } - return false - } - if (previousNodes.all.length === 1) { /** * Nodes that only have one edge connecting to them */ const previousNode = previousNodes.all[0] - const prevAlreadyNested = prevIsAlreadyNestedFor(previousNode) if ( isNodeOfType(previousNode, 'parallel') && previousNode.data.type === 'open' && previousEdges[0].sourceHandle === previousNode.data.parallelOutputConnector?.id ) { - newNodePosition = getNodePositionBasedOnPreviousNode(previousNode, node, 'parallel', prevAlreadyNested) + newNodePosition = getNodePositionBasedOnPreviousNode(previousNode, node, 'parallel') } else { - newNodePosition = getNodePositionBasedOnPreviousNode(previousNode, node, 'serial', prevAlreadyNested) + newNodePosition = getNodePositionBasedOnPreviousNode(previousNode, node, 'serial') } } else { /** @@ -234,8 +211,7 @@ const positionMainNodes = (rung: RungLadderState): { nodes: Node[]; edges: Edge[ let acc = newNodePosition for (let j = 0; j < previousNodes.all.length; j++) { const previousNode = previousNodes.all[j] - const prevAlreadyNested = prevIsAlreadyNestedFor(previousNode) - const position = getNodePositionBasedOnPreviousNode(previousNode, node, 'serial', prevAlreadyNested) + const position = getNodePositionBasedOnPreviousNode(previousNode, node, 'serial') acc = { posX: Math.max(acc.posX, position.posX), posY: Math.max(acc.posY, position.posY), diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts index 7afe6876f..d4b7285b2 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts @@ -5,14 +5,6 @@ import type { CustomHandleProps } from '../../../../../../../_atoms/graphical-ed import { BasicNodeData, ParallelNode } from '../../../../../../../_atoms/graphical-editor/ladder/utils/types' import { getDefaultNodeStyle, isNodeOfType } from '../../nodes' -// Horizontal padding inserted between adjacent same-type parallel brackets -// (an outer OPEN and an inner OPEN, or an inner CLOSE and an outer CLOSE). -// Matches a contact's gap so the inner bracket's vertical wire sits the same -// distance from the outer's wire that a regular contact would — large enough -// to read as deliberate separation, small enough that the nested structure -// stays visually compact. -export const NESTED_PARALLEL_CLEARANCE = 45 - /** * Get the previous element by searching with edge in the rung * @@ -124,12 +116,6 @@ export const getElementPositionBasedOnPlaceholderElement = ( * @param previousElement * @param newElement * @param type: 'serial' | 'parallel' - * @param prevIsAlreadyNested When prev is itself a parallel of the same - * sub-type as its own predecessor (e.g. OPEN→OPEN→OPEN), the chain has - * already paid the bracket-clearance budget at its outermost boundary. - * Pass `true` so this call collapses onto prev's X instead of stacking - * another clearance — otherwise every "add parallel" wraps an extra - * layer and the spine funnels rightward. * * @returns { posX, posY, handleX, handleY } */ @@ -137,7 +123,6 @@ export const getNodePositionBasedOnPreviousNode = ( previousElement: Node, newElement: string | Node, type: 'serial' | 'parallel', - prevIsAlreadyNested: boolean = false, ): { posX: number posY: number @@ -158,24 +143,12 @@ export const getNodePositionBasedOnPreviousNode = ( previousElement.type === 'parallel' && (typeof newElement === 'string' ? newElement === 'parallel' : newElement.type === 'parallel') - // Two parallels of the same sub-type (OPEN→OPEN or CLOSE→CLOSE) can only - // appear adjacent when one is nested inside the other. Without horizontal - // separation the inner bracket renders at the same X as the outer's - // vertical wire and the two wires overlap visually. Reserve a contact- - // sized gap AND advance past the previous bracket's width so the inner - // OPEN's left wire (or inner CLOSE's right wire) sits clearly inside the - // outer's span instead of on top of it. - const parallelsAreNested = - parallelNodeCheckingParallelNode && - typeof newElement !== 'string' && - (previousElement as ParallelNode).data.type === (newElement as ParallelNode).data.type - - // If prev is already past a clearance boundary (its own predecessor was - // the same-type parallel), don't add another one. The visible separation - // belongs at the OUTERMOST bracket boundary; deeper levels collapse to - // the same X so a 3-deep nesting reads as one set of brackets, not three. - const collapseDeepNesting = parallelsAreNested && prevIsAlreadyNested - + // Same-type nested parallels (OPEN→OPEN or CLOSE→CLOSE) collapse onto a + // single X via skipPrevWidth + gap=0 below, so all OPEN brackets in a + // multi-branch parallel share the leftmost X and all CLOSE brackets share + // the rightmost X. Visible result: one OPEN/CLOSE pair with N parallel + // paths between them, instead of a staircase that pushes the spine wire + // long. let gap = 0 if (parallelNodeCheckingParallelNode) { if ( @@ -185,8 +158,6 @@ export const getNodePositionBasedOnPreviousNode = ( previousElement.id !== (newElement as ParallelNode).data.parallelCloseReference) ) { gap = 100 - } else if (parallelsAreNested && !collapseDeepNesting) { - gap = NESTED_PARALLEL_CLEARANCE } } else { gap = previousElementStyle.gap + newNodeStyle.gap @@ -194,12 +165,10 @@ export const getNodePositionBasedOnPreviousNode = ( const offsetY = newNodeStyle.handle.y - // Nested parallels need the previous bracket's width added so the inner - // bracket sits past it instead of collapsing onto its X (the OPEN/CLOSE - // pair-collapse only applies when the two parallels are the same pair). - // When the chain is already past the outer clearance boundary, skip the - // width too so deeper levels share the same X as the first inner bracket. - const skipPrevWidth = parallelNodeCheckingParallelNode && (!parallelsAreNested || collapseDeepNesting) + // Skip prev's width for any parallel→parallel adjacency so brackets + // collapse: same-type nests overlap exactly, and paired OPEN→CLOSE + // (empty parallel) doesn't get pushed past the OPEN's width. + const skipPrevWidth = parallelNodeCheckingParallelNode const position = { posX: previousElement.position.x + (skipPrevWidth ? 0 : previousElement.width || 0) + gap, From f2903105e03a3fe4c67d70c3e5a32a6494ce00c7 Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Tue, 12 May 2026 14:00:17 -0300 Subject: [PATCH 5/7] sync(ladder): pull conditional bracket-collapse from web Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ladder-utils/elements/diagram/index.ts | 30 +++++++++++++-- .../rung/ladder-utils/elements/utils/index.ts | 37 ++++++++++++++----- 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts index c4f39dd1a..f7b752998 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts @@ -17,6 +17,7 @@ import { findAllParallelsDepthAndNodes, findParallelsInRung, getNodePositionBasedOnPreviousNode, + getNodesInsideParallel, getPreviousElementsByEdge, } from '../utils' import { updateVariableBlockPosition } from '../variable-block' @@ -124,6 +125,27 @@ const positionMainNodes = (rung: RungLadderState): { nodes: Node[]; edges: Edge[ const parallels = findParallelsInRung(rung) const parallelsDepth = parallels.map((parallel) => findAllParallelsDepthAndNodes(rung, parallel)) + /** + * Same-type parallel brackets (OPEN→OPEN, CLOSE→CLOSE) belong to nested + * parallels. The inner pair is `node` for OPEN→OPEN and `previousNode` + * for CLOSE→CLOSE. When the inner pair's serial spine is a single + * element, the nesting is just a multi-branch wrapper — collapse the + * brackets onto the same X so all branches read as siblings. Otherwise, + * the nesting is a genuine parallel-of-parallel and the brackets must + * stay visually distinct (handled by `getNodePositionBasedOnPreviousNode` + * applying NESTED_PARALLEL_CLEARANCE when this flag is false). + */ + const shouldCollapseAgainst = (prev: Node, current: Node): boolean => { + if (!isNodeOfType(prev, 'parallel') || !isNodeOfType(current, 'parallel')) return false + if (prev.data.type !== current.data.type) return false + const innerBracket = current.data.type === 'open' ? current : prev + const closeId = + innerBracket.data.type === 'open' ? innerBracket.data.parallelCloseReference : innerBracket.id + const innerClose = rung.nodes.find((n) => n.id === closeId) + if (!innerClose) return false + return getNodesInsideParallel(rung, innerClose).serial.length === 1 + } + /** * Iterate over the nodes and update their position */ @@ -185,14 +207,15 @@ const positionMainNodes = (rung: RungLadderState): { nodes: Node[]; edges: Edge[ * Nodes that only have one edge connecting to them */ const previousNode = previousNodes.all[0] + const collapse = shouldCollapseAgainst(previousNode, node) if ( isNodeOfType(previousNode, 'parallel') && previousNode.data.type === 'open' && previousEdges[0].sourceHandle === previousNode.data.parallelOutputConnector?.id ) { - newNodePosition = getNodePositionBasedOnPreviousNode(previousNode, node, 'parallel') + newNodePosition = getNodePositionBasedOnPreviousNode(previousNode, node, 'parallel', collapse) } else { - newNodePosition = getNodePositionBasedOnPreviousNode(previousNode, node, 'serial') + newNodePosition = getNodePositionBasedOnPreviousNode(previousNode, node, 'serial', collapse) } } else { /** @@ -211,7 +234,8 @@ const positionMainNodes = (rung: RungLadderState): { nodes: Node[]; edges: Edge[ let acc = newNodePosition for (let j = 0; j < previousNodes.all.length; j++) { const previousNode = previousNodes.all[j] - const position = getNodePositionBasedOnPreviousNode(previousNode, node, 'serial') + const collapse = shouldCollapseAgainst(previousNode, node) + const position = getNodePositionBasedOnPreviousNode(previousNode, node, 'serial', collapse) acc = { posX: Math.max(acc.posX, position.posX), posY: Math.max(acc.posY, position.posY), diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts index d4b7285b2..eb38524f1 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts @@ -5,6 +5,13 @@ import type { CustomHandleProps } from '../../../../../../../_atoms/graphical-ed import { BasicNodeData, ParallelNode } from '../../../../../../../_atoms/graphical-editor/ladder/utils/types' import { getDefaultNodeStyle, isNodeOfType } from '../../nodes' +// Horizontal padding inserted between adjacent same-type parallel brackets +// when the inner parallel has a non-trivial serial spine (i.e. a real +// parallel-of-parallel structure, not just a multi-branch wrapper). Makes +// the inner OPEN/CLOSE wires visibly distinct from the outer ones so the +// nested structure reads as nested. +export const NESTED_PARALLEL_CLEARANCE = 45 + /** * Get the previous element by searching with edge in the rung * @@ -116,6 +123,11 @@ export const getElementPositionBasedOnPlaceholderElement = ( * @param previousElement * @param newElement * @param type: 'serial' | 'parallel' + * @param collapseSameTypeBrackets When prev and new are same-type parallel + * brackets (OPEN→OPEN or CLOSE→CLOSE), pass `true` to overlap them at the + * same X (multi-branch case: inner parallel is a wrapper around a single + * spine element). Pass `false` to staircase by NESTED_PARALLEL_CLEARANCE + * so the inner brackets read as distinct nested structure. * * @returns { posX, posY, handleX, handleY } */ @@ -123,6 +135,7 @@ export const getNodePositionBasedOnPreviousNode = ( previousElement: Node, newElement: string | Node, type: 'serial' | 'parallel', + collapseSameTypeBrackets: boolean = false, ): { posX: number posY: number @@ -143,12 +156,11 @@ export const getNodePositionBasedOnPreviousNode = ( previousElement.type === 'parallel' && (typeof newElement === 'string' ? newElement === 'parallel' : newElement.type === 'parallel') - // Same-type nested parallels (OPEN→OPEN or CLOSE→CLOSE) collapse onto a - // single X via skipPrevWidth + gap=0 below, so all OPEN brackets in a - // multi-branch parallel share the leftmost X and all CLOSE brackets share - // the rightmost X. Visible result: one OPEN/CLOSE pair with N parallel - // paths between them, instead of a staircase that pushes the spine wire - // long. + const parallelsAreSameType = + parallelNodeCheckingParallelNode && + typeof newElement !== 'string' && + (previousElement as ParallelNode).data.type === (newElement as ParallelNode).data.type + let gap = 0 if (parallelNodeCheckingParallelNode) { if ( @@ -158,6 +170,8 @@ export const getNodePositionBasedOnPreviousNode = ( previousElement.id !== (newElement as ParallelNode).data.parallelCloseReference) ) { gap = 100 + } else if (parallelsAreSameType && !collapseSameTypeBrackets) { + gap = NESTED_PARALLEL_CLEARANCE } } else { gap = previousElementStyle.gap + newNodeStyle.gap @@ -165,10 +179,13 @@ export const getNodePositionBasedOnPreviousNode = ( const offsetY = newNodeStyle.handle.y - // Skip prev's width for any parallel→parallel adjacency so brackets - // collapse: same-type nests overlap exactly, and paired OPEN→CLOSE - // (empty parallel) doesn't get pushed past the OPEN's width. - const skipPrevWidth = parallelNodeCheckingParallelNode + // Skip prev's width when: + // - parallel→parallel, different sub-types (paired OPEN→CLOSE), or + // - parallel→parallel same-type AND collapsing (multi-branch case). + // For same-type staircase (real nesting), keep prev's width so the inner + // bracket sits past the outer's wire instead of overlapping it. + const skipPrevWidth = + parallelNodeCheckingParallelNode && (!parallelsAreSameType || collapseSameTypeBrackets) const position = { posX: previousElement.position.x + (skipPrevWidth ? 0 : previousElement.width || 0) + gap, From fa04fa95976044e11e8180623d5f52ebe41fe762 Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Tue, 12 May 2026 14:10:37 -0300 Subject: [PATCH 6/7] sync(ladder): pull wider nested-parallel clearance from web Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ladder/rung/ladder-utils/elements/utils/index.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts index eb38524f1..00673c45b 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts @@ -7,10 +7,11 @@ import { getDefaultNodeStyle, isNodeOfType } from '../../nodes' // Horizontal padding inserted between adjacent same-type parallel brackets // when the inner parallel has a non-trivial serial spine (i.e. a real -// parallel-of-parallel structure, not just a multi-branch wrapper). Makes -// the inner OPEN/CLOSE wires visibly distinct from the outer ones so the -// nested structure reads as nested. -export const NESTED_PARALLEL_CLEARANCE = 45 +// parallel-of-parallel structure, not just a multi-branch wrapper). Sized +// to fit the in-between placeholder (10px wide) plus its left/right gap +// (15px each) with breathing room, so the placeholder reads as deliberately +// placed between the outer and inner OPEN/CLOSE wires. +export const NESTED_PARALLEL_CLEARANCE = 90 /** * Get the previous element by searching with edge in the rung From f306987c6e41626da0829e9f4b6198895e36f662 Mon Sep 17 00:00:00 2001 From: Daniel Coutinho <60111446+dcoutinho1328@users.noreply.github.com> Date: Tue, 12 May 2026 14:21:46 -0300 Subject: [PATCH 7/7] sync(ladder): pull staircase+align approach from web Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ladder-utils/elements/diagram/index.ts | 86 +++++++++++++------ .../rung/ladder-utils/elements/utils/index.ts | 29 +++---- 2 files changed, 72 insertions(+), 43 deletions(-) diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts index f7b752998..da87e22fb 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram/index.ts @@ -19,6 +19,7 @@ import { getNodePositionBasedOnPreviousNode, getNodesInsideParallel, getPreviousElementsByEdge, + NESTED_PARALLEL_CLEARANCE, } from '../utils' import { updateVariableBlockPosition } from '../variable-block' @@ -126,24 +127,39 @@ const positionMainNodes = (rung: RungLadderState): { nodes: Node[]; edges: Edge[ const parallelsDepth = parallels.map((parallel) => findAllParallelsDepthAndNodes(rung, parallel)) /** - * Same-type parallel brackets (OPEN→OPEN, CLOSE→CLOSE) belong to nested - * parallels. The inner pair is `node` for OPEN→OPEN and `previousNode` - * for CLOSE→CLOSE. When the inner pair's serial spine is a single - * element, the nesting is just a multi-branch wrapper — collapse the - * brackets onto the same X so all branches read as siblings. Otherwise, - * the nesting is a genuine parallel-of-parallel and the brackets must - * stay visually distinct (handled by `getNodePositionBasedOnPreviousNode` - * applying NESTED_PARALLEL_CLEARANCE when this flag is false). + * For a parallel that wraps a multi-branch nested chain (every nested + * parallel inside has a single-element spine), compute the total X + * advance required so the OUTER spine element lands in the SAME column + * as the innermost spine. Without this, the spine contact sits flush + * against the outer OPEN while sibling branches in the nested parallels + * are pushed right by the bracket staircase — visually misaligning + * branches that the user intends as siblings. + * + * Walks parallel-output edges through nested same-type OPENs as long as + * each carries a single-element spine. Each level contributes one + * parallel-bracket width + NESTED_PARALLEL_CLEARANCE (the X advance the + * staircase introduced for that bracket pair). */ - const shouldCollapseAgainst = (prev: Node, current: Node): boolean => { - if (!isNodeOfType(prev, 'parallel') || !isNodeOfType(current, 'parallel')) return false - if (prev.data.type !== current.data.type) return false - const innerBracket = current.data.type === 'open' ? current : prev - const closeId = - innerBracket.data.type === 'open' ? innerBracket.data.parallelCloseReference : innerBracket.id - const innerClose = rung.nodes.find((n) => n.id === closeId) - if (!innerClose) return false - return getNodesInsideParallel(rung, innerClose).serial.length === 1 + const computeMultiBranchSpineAdvance = (openParallelNode: Node): number => { + let advance = 0 + let cur: Node = openParallelNode + // eslint-disable-next-line no-constant-condition + while (true) { + if (!isNodeOfType(cur, 'parallel') || cur.data.type !== 'open') break + const curId = cur.id + const curParallelOutId = cur.data.parallelOutputConnector?.id + const curWidth = cur.width ?? 0 + const parallelEdge = rung.edges.find((e) => e.source === curId && e.sourceHandle === curParallelOutId) + if (!parallelEdge) break + const next = rung.nodes.find((n) => n.id === parallelEdge.target) + if (!next || !isNodeOfType(next, 'parallel') || next.data.type !== 'open') break + const innerClose = rung.nodes.find((n) => n.id === next.data.parallelCloseReference) + if (!innerClose) break + if (getNodesInsideParallel(rung, innerClose).serial.length !== 1) break + advance += curWidth + NESTED_PARALLEL_CLEARANCE + cur = next + } + return advance } /** @@ -207,15 +223,38 @@ const positionMainNodes = (rung: RungLadderState): { nodes: Node[]; edges: Edge[ * Nodes that only have one edge connecting to them */ const previousNode = previousNodes.all[0] - const collapse = shouldCollapseAgainst(previousNode, node) - if ( + const onParallelOutput = isNodeOfType(previousNode, 'parallel') && previousNode.data.type === 'open' && previousEdges[0].sourceHandle === previousNode.data.parallelOutputConnector?.id + newNodePosition = getNodePositionBasedOnPreviousNode( + previousNode, + node, + onParallelOutput ? 'parallel' : 'serial', + ) + + /** + * Spine-alignment: when this `node` is the spine of an OUTER parallel + * whose parallel-path is a chain of multi-branch nested parallels, + * shift its X to match the deepest inner spine so all branch + * "siblings" align in the same column (image #73 layout). Skipped + * when `node` is itself a same-type nested OPEN — those are the + * staircase brackets we're aligning AGAINST, not aligning. + */ + if ( + !onParallelOutput && + isNodeOfType(previousNode, 'parallel') && + previousNode.data.type === 'open' && + !(isNodeOfType(node, 'parallel') && node.data.type === 'open') ) { - newNodePosition = getNodePositionBasedOnPreviousNode(previousNode, node, 'parallel', collapse) - } else { - newNodePosition = getNodePositionBasedOnPreviousNode(previousNode, node, 'serial', collapse) + const advance = computeMultiBranchSpineAdvance(previousNode) + if (advance > 0) { + newNodePosition = { + ...newNodePosition, + posX: newNodePosition.posX + advance, + handleX: newNodePosition.handleX + advance, + } + } } } else { /** @@ -234,8 +273,7 @@ const positionMainNodes = (rung: RungLadderState): { nodes: Node[]; edges: Edge[ let acc = newNodePosition for (let j = 0; j < previousNodes.all.length; j++) { const previousNode = previousNodes.all[j] - const collapse = shouldCollapseAgainst(previousNode, node) - const position = getNodePositionBasedOnPreviousNode(previousNode, node, 'serial', collapse) + const position = getNodePositionBasedOnPreviousNode(previousNode, node, 'serial') acc = { posX: Math.max(acc.posX, position.posX), posY: Math.max(acc.posY, position.posY), diff --git a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts index 00673c45b..bd77014b8 100644 --- a/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts +++ b/src/frontend/components/_molecules/graphical-editor/ladder/rung/ladder-utils/elements/utils/index.ts @@ -6,11 +6,10 @@ import { BasicNodeData, ParallelNode } from '../../../../../../../_atoms/graphic import { getDefaultNodeStyle, isNodeOfType } from '../../nodes' // Horizontal padding inserted between adjacent same-type parallel brackets -// when the inner parallel has a non-trivial serial spine (i.e. a real -// parallel-of-parallel structure, not just a multi-branch wrapper). Sized -// to fit the in-between placeholder (10px wide) plus its left/right gap -// (15px each) with breathing room, so the placeholder reads as deliberately -// placed between the outer and inner OPEN/CLOSE wires. +// (OPEN→OPEN or CLOSE→CLOSE). Sized to fit the in-between placeholder +// (10px wide) plus its left/right gap (15px each) with breathing room, so +// the placeholder reads as deliberately placed between the outer and inner +// bracket wires. export const NESTED_PARALLEL_CLEARANCE = 90 /** @@ -124,11 +123,6 @@ export const getElementPositionBasedOnPlaceholderElement = ( * @param previousElement * @param newElement * @param type: 'serial' | 'parallel' - * @param collapseSameTypeBrackets When prev and new are same-type parallel - * brackets (OPEN→OPEN or CLOSE→CLOSE), pass `true` to overlap them at the - * same X (multi-branch case: inner parallel is a wrapper around a single - * spine element). Pass `false` to staircase by NESTED_PARALLEL_CLEARANCE - * so the inner brackets read as distinct nested structure. * * @returns { posX, posY, handleX, handleY } */ @@ -136,7 +130,6 @@ export const getNodePositionBasedOnPreviousNode = ( previousElement: Node, newElement: string | Node, type: 'serial' | 'parallel', - collapseSameTypeBrackets: boolean = false, ): { posX: number posY: number @@ -171,7 +164,7 @@ export const getNodePositionBasedOnPreviousNode = ( previousElement.id !== (newElement as ParallelNode).data.parallelCloseReference) ) { gap = 100 - } else if (parallelsAreSameType && !collapseSameTypeBrackets) { + } else if (parallelsAreSameType) { gap = NESTED_PARALLEL_CLEARANCE } } else { @@ -180,13 +173,11 @@ export const getNodePositionBasedOnPreviousNode = ( const offsetY = newNodeStyle.handle.y - // Skip prev's width when: - // - parallel→parallel, different sub-types (paired OPEN→CLOSE), or - // - parallel→parallel same-type AND collapsing (multi-branch case). - // For same-type staircase (real nesting), keep prev's width so the inner - // bracket sits past the outer's wire instead of overlapping it. - const skipPrevWidth = - parallelNodeCheckingParallelNode && (!parallelsAreSameType || collapseSameTypeBrackets) + // Skip prev's width when parallel→parallel and different sub-types (the + // paired OPEN→CLOSE empty-parallel case). For same-type staircase + // (nested), keep prev's width so the inner bracket sits past the outer's + // wire instead of overlapping it. + const skipPrevWidth = parallelNodeCheckingParallelNode && !parallelsAreSameType const position = { posX: previousElement.position.x + (skipPrevWidth ? 0 : previousElement.width || 0) + gap,