@@ -9,14 +9,43 @@ namespace FpbMapper.Conversion;
99/// </summary>
1010public static class CaexToFpbJson
1111{
12+ /// <summary>
13+ /// Convert the first InstanceHierarchy in <paramref name="doc"/> to FPB.JS JSON.
14+ /// Kept for legacy callers (Web mapper, file-based export). Multi-IH users
15+ /// should call the <see cref="Convert(CAEXDocument, InstanceHierarchyType)"/>
16+ /// overload and target each IH explicitly.
17+ /// </summary>
1218 public static ConversionResult < string > Convert ( CAEXDocument doc )
1319 {
14- var warnings = new List < string > ( ) ;
1520 var caex = doc . CAEXFile ;
16-
17- // Find the first InstanceHierarchy
1821 var ih = caex . InstanceHierarchy . FirstOrDefault ( )
1922 ?? throw new InvalidOperationException ( "No InstanceHierarchy found" ) ;
23+ return Convert ( doc , ih ) ;
24+ }
25+
26+ /// <summary>
27+ /// Find every InstanceHierarchy in <paramref name="doc"/> that holds at least
28+ /// one FPD_Process IE. Plugins surface one viewer-tab per IH in the returned
29+ /// order so the user can edit each FPD process independently.
30+ /// </summary>
31+ public static IReadOnlyList < InstanceHierarchyType > FindFpdInstanceHierarchies ( CAEXDocument doc )
32+ {
33+ if ( doc ? . CAEXFile == null ) return Array . Empty < InstanceHierarchyType > ( ) ;
34+ var processSuc = ElementToSuc [ "fpb:Process" ] ;
35+ return doc . CAEXFile . InstanceHierarchy
36+ . Where ( ih => ih . InternalElement . Any ( ie => ie . RefBaseSystemUnitPath == processSuc ) )
37+ . ToList ( ) ;
38+ }
39+
40+ /// <summary>
41+ /// Convert a specific InstanceHierarchy to FPB.JS JSON. Use when a document
42+ /// holds several independent FPD models that the plugin renders side-by-side.
43+ /// </summary>
44+ public static ConversionResult < string > Convert ( CAEXDocument doc , InstanceHierarchyType ih )
45+ {
46+ var warnings = new List < string > ( ) ;
47+ if ( ih == null )
48+ throw new ArgumentNullException ( nameof ( ih ) ) ;
2049
2150 // Collect all FPD_Process InternalElements (flat in IH)
2251 var allProcessIEs = ih . InternalElement
@@ -29,6 +58,7 @@ public static ConversionResult<string> Convert(CAEXDocument doc)
2958 // Build refObj lookups
3059 var processRefObjMap = new Dictionary < string , string > ( ) ; // process AML ID -> parent PO ID
3160 var poRefObjMap = new Dictionary < string , string > ( ) ; // PO AML ID -> child process AML ID
61+ var poToProcessAmlId = new Dictionary < string , string > ( ) ; // PO AML ID -> parent process AML ID
3262
3363 foreach ( var procIE in allProcessIEs )
3464 {
@@ -40,6 +70,7 @@ public static ConversionResult<string> Convert(CAEXDocument doc)
4070 {
4171 if ( ie . RefBaseSystemUnitPath == ElementToSuc [ "fpb:ProcessOperator" ] )
4272 {
73+ poToProcessAmlId [ ie . ID ] = procIE . ID ;
4374 var poRefProcess = GetRefProcessValue ( ie ) ;
4475 if ( poRefProcess != null )
4576 poRefObjMap [ ie . ID ] = poRefProcess ;
@@ -86,11 +117,25 @@ public static ConversionResult<string> Convert(CAEXDocument doc)
86117 var proc = ( Dictionary < string , object > ) entry [ "process" ] ;
87118
88119 // isDecomposedProcessOperator: resolve AML PO ID -> FPB.JS PO ID
120+ // parent should point at the process containing the parent PO, NOT the PO itself.
121+ // If any lookup fails we still emit a brace-free ID via NormalizeId so the
122+ // JSON stays consistent with Phase 2.A (no {xxx} sneaks through).
89123 if ( proc . TryGetValue ( "isDecomposedProcessOperator" , out var iDPO ) && iDPO is string idpo && ! string . IsNullOrEmpty ( idpo ) )
90124 {
91- if ( amlToFpbId . TryGetValue ( idpo , out var poFpbId ) )
92- proc [ "isDecomposedProcessOperator" ] = poFpbId ;
93- proc [ "parent" ] = proc [ "isDecomposedProcessOperator" ] ;
125+ proc [ "isDecomposedProcessOperator" ] = amlToFpbId . TryGetValue ( idpo , out var poFpbId )
126+ ? poFpbId
127+ : NormalizeId ( idpo ) ;
128+
129+ if ( poToProcessAmlId . TryGetValue ( idpo , out var parentProcAmlId )
130+ && processIdMap . TryGetValue ( parentProcAmlId , out var parentProcFpbId ) )
131+ {
132+ proc [ "parent" ] = parentProcFpbId ;
133+ }
134+ else
135+ {
136+ proc [ "parent" ] = proc [ "isDecomposedProcessOperator" ] ;
137+ }
138+
94139 proc [ "id" ] = proc [ "isDecomposedProcessOperator" ] ;
95140 }
96141
@@ -165,7 +210,7 @@ private static void ParseProcess(
165210 if ( systemLimitIE != null )
166211 {
167212 var slName = ParseShortName ( systemLimitIE ) ?? systemLimitIE . Name ?? "SystemLimit" ;
168- systemLimitId = NewId ( ) ;
213+ systemLimitId = NormalizeId ( systemLimitIE . ID ) ;
169214 var slVisual = ParseViewInformation ( systemLimitIE ) ;
170215
171216 var slData = new Dictionary < string , object >
@@ -186,8 +231,8 @@ private static void ParseProcess(
186231 }
187232 }
188233
189- // Assign process ID
190- var processId = NewId ( ) ;
234+ // Assign process ID — take the AML ID (stripped) so round-trips stay stable.
235+ var processId = NormalizeId ( processIE . ID ) ;
191236 processIdMap [ processIE . ID ] = processId ;
192237
193238 var processRefObj = GetRefObjValue ( processIE ) ;
@@ -210,7 +255,8 @@ private static void ParseProcess(
210255 if ( ! SucToElement . TryGetValue ( sucPath , out var fpbType ) ) continue ;
211256 if ( fpbType == "fpb:SystemLimit" || fpbType == "fpb:Process" ) continue ;
212257
213- var elemId = NewId ( ) ;
258+ // Element ID takes the AML ID (stripped) so UpdateInPlace can match later.
259+ var elemId = NormalizeId ( ie . ID ) ;
214260 var name = ParseShortName ( ie ) ?? ie . Name ?? "" ;
215261
216262 elementIdMap [ ie . ID ] = elemId ;
@@ -282,7 +328,9 @@ private static void ParseProcess(
282328 elementVisualInformation . Add ( visual ) ;
283329 }
284330
285- elementsContainerIds . Add ( elemId ) ;
331+ // TechnicalResource lives outside the SystemLimit, not inside it
332+ if ( fpbType != "fpb:TechnicalResource" )
333+ elementsContainerIds . Add ( elemId ) ;
286334 if ( StateTypes . Contains ( fpbType ) ) stateIds . Add ( elemId ) ;
287335 if ( fpbType == "fpb:ProcessOperator" ) poIds . Add ( elemId ) ;
288336 }
@@ -309,7 +357,9 @@ private static void ParseProcess(
309357 var outSide = sideA . Direction == "out" ? sideA : sideB ;
310358 var inSide = sideA . Direction == "in" ? sideA : sideB ;
311359
312- var flowId = NewId ( ) ;
360+ // Flow ID takes the InternalLink ID (stripped) so updates can match.
361+ // CAEX InternalLink IDs are optional — NormalizeId falls back to NewId().
362+ var flowId = NormalizeId ( link . ID ) ;
313363 var flowType = outSide . FlowType ;
314364
315365 var flowData = new Dictionary < string , object >
@@ -365,7 +415,9 @@ private static void ParseProcess(
365415 if ( ! tgtList . Contains ( ( string ) sourceElem [ "id" ] ) ) tgtList . Add ( ( string ) sourceElem [ "id" ] ) ;
366416 }
367417
368- elementsContainerIds . Add ( flowId ) ;
418+ // Usage connects to TechnicalResources outside the SystemLimit
419+ if ( flowType != "fpb:Usage" )
420+ elementsContainerIds . Add ( flowId ) ;
369421 }
370422
371423 // Compute inTandemWith
@@ -406,7 +458,9 @@ private static void ParseProcess(
406458 [ "id" ] = processId ,
407459 [ "elementsContainer" ] = systemLimitId != null
408460 ? new List < string > ( new [ ] { systemLimitId } . Concat (
409- elementDataInformation . Where ( e => ( string ) e [ "$type" ] == "fpb:TechnicalResource" )
461+ elementDataInformation
462+ . Where ( e => ( string ) e [ "$type" ] == "fpb:TechnicalResource"
463+ || ( string ) e [ "$type" ] == "fpb:Usage" )
410464 . Select ( e => ( string ) e [ "id" ] ) ) )
411465 : new List < string > ( ) ,
412466 [ "isDecomposedProcessOperator" ] = parentPOId ?? ( object ) "" ,
@@ -642,6 +696,27 @@ private static string ExtractInterfaceId(string refPartnerSide)
642696
643697 private static string NewId ( ) => Guid . NewGuid ( ) . ToString ( "B" ) ;
644698
699+ /// <summary>
700+ /// Convert an AML element ID (typically CAEX B-format like "{xxx-yyy}") to the
701+ /// raw FPB.JS-side ID format (no braces). Preserves IDs for downstream round-trips
702+ /// — UpdateInPlace in FpbJsonToCaex can match by the same value back to the AML
703+ /// element. Falls back to a fresh raw GUID if the AML ID is null/empty.
704+ /// </summary>
705+ /// <remarks>
706+ /// NOTE: the fallback path must NOT call <see cref="NewId"/>, which formats as
707+ /// "{xxx-yyy}". If we leaked a braced ID into the JSON output, FPB.JS would
708+ /// store it internally with braces and the next UpdateInPlace lookup would
709+ /// miss every connection (linkIndex keys are bare). This bit users hard on
710+ /// AML files whose InternalLinks had no explicit ID.
711+ /// </remarks>
712+ private static string NormalizeId ( string ? amlId )
713+ {
714+ if ( string . IsNullOrEmpty ( amlId ) ) return Guid . NewGuid ( ) . ToString ( ) ; // bare, no braces
715+ if ( amlId . Length >= 2 && amlId [ 0 ] == '{' && amlId [ ^ 1 ] == '}' )
716+ return amlId . Substring ( 1 , amlId . Length - 2 ) ;
717+ return amlId ;
718+ }
719+
645720 private class InterfaceInfo
646721 {
647722 public string ElementId { get ; set ; } = "" ;
0 commit comments