From fcd4108bb5e5abae32fff7fc12d481ff107bd2f4 Mon Sep 17 00:00:00 2001 From: Azriel Hoh Date: Sat, 11 Jul 2026 21:12:44 +1200 Subject: [PATCH 1/6] Change `0062_edges_from_higher_rank_to_lower_rank.yaml` to use orthogonal interaction edges. --- .../0062_edges_from_higher_rank_to_lower_rank.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workspace_tests/src/input_diagram/0062_edges_from_higher_rank_to_lower_rank.yaml b/workspace_tests/src/input_diagram/0062_edges_from_higher_rank_to_lower_rank.yaml index 31e2e119..860b5992 100644 --- a/workspace_tests/src/input_diagram/0062_edges_from_higher_rank_to_lower_rank.yaml +++ b/workspace_tests/src/input_diagram/0062_edges_from_higher_rank_to_lower_rank.yaml @@ -222,4 +222,4 @@ entity_types: - type_interaction_edge_symmetric_reverse_default render_options: - interactions_edge_curvature: curved + interactions_edge_curvature: orthogonal From 84e951b2cd5df76794592efd86d50e9986b51cfd Mon Sep 17 00:00:00 2001 From: Azriel Hoh Date: Sat, 11 Jul 2026 21:13:28 +1200 Subject: [PATCH 2/6] Use `\n` line endings in `0062_edges_from_higher_rank_to_lower_rank.yaml`. --- ..._edges_from_higher_rank_to_lower_rank.yaml | 450 +++++++++--------- 1 file changed, 225 insertions(+), 225 deletions(-) diff --git a/workspace_tests/src/input_diagram/0062_edges_from_higher_rank_to_lower_rank.yaml b/workspace_tests/src/input_diagram/0062_edges_from_higher_rank_to_lower_rank.yaml index 860b5992..72955d21 100644 --- a/workspace_tests/src/input_diagram/0062_edges_from_higher_rank_to_lower_rank.yaml +++ b/workspace_tests/src/input_diagram/0062_edges_from_higher_rank_to_lower_rank.yaml @@ -1,225 +1,225 @@ -things: - t_localhost: - t_localhost_app: - t_localhost_app_web_app: {} - t_localhost_app_mediapipe: {} - t_localhost_app_target: - t_localhost_app_target_footage: {} - t_localhost_app_target_pose: {} - t_localhost_postgres: - t_localhost_postgres_app: {} - t_github: - t_github_app: {} - t_aws: - t_aws_ecr: - t_aws_ecr_mediapipe: {} - t_aws_ecr_app: - t_aws_ecr_app_image_1: {} - t_aws_az1: - t_aws_az1_subnet_mgmt: - t_aws_az1_subnet_mgmt_vpc_private_link: {} - t_aws_az1_subnet_mgmt_bastion: {} - t_aws_az1_subnet_tier_public: {} - t_aws_az1_subnet_tier_private: - t_aws_az1_subnet_tier_private_lambda_mediapipe: {} - t_aws_rds: - t_aws_rds_tier: {} - t_aws_s3: - t_aws_s3_tier: - t_aws_s3_tier_footage: {} - t_aws_s3_tier_stills: {} - -thing_names: - t_localhost: πŸ§‘πŸ»β€πŸ’» Developer machine - t_localhost_app: πŸƒπŸ»β€βž‘οΈ App - t_localhost_app_web_app: 🌐 Web app - t_localhost_app_mediapipe: πŸ“½οΈ Mediapipe app - t_localhost_app_target: "`target` (working dir)" - t_localhost_app_target_footage: footage - t_localhost_app_target_pose: pose - t_localhost_postgres: πŸ—„οΈ Postgres - t_localhost_postgres_app: πŸ—„οΈ App local database - t_github: πŸ™ GitHub - t_github_app: πŸƒπŸ»β€βž‘οΈ App - t_aws: ☁️ AWS - t_aws_ecr: πŸ—„οΈ ECR - t_aws_ecr_mediapipe: πŸ“½οΈ Mediapipe - t_aws_ecr_app: πŸƒπŸ»β€βž‘οΈ App - t_aws_ecr_app_image_1: πŸ“€ App v0.0.1 - t_aws_az1: 🏒 Availability Zone 1 (e.g. ap-southeast-2a) - t_aws_az1_subnet_mgmt: πŸ•ΈοΈ Management Subnet - t_aws_az1_subnet_mgmt_vpc_private_link: 🚟 VPC Link - t_aws_az1_subnet_mgmt_bastion: πŸ’» Bastion host - t_aws_az1_subnet_tier_public: πŸ•ΈοΈ Tier Public Subnet - t_aws_az1_subnet_tier_private: πŸ•ΈοΈ Tier Private Subnet - t_aws_az1_subnet_tier_private_lambda_mediapipe: Ξ» Mediapipe Lambda - t_aws_rds: πŸ—„οΈ RDS - t_aws_rds_tier: πŸ—„οΈ App tier database - t_aws_s3: πŸͺ£ S3 - t_aws_s3_tier: πŸͺ£ Tier Bucket - t_aws_s3_tier_footage: 🎞️ Footage - t_aws_s3_tier_stills: πŸ“· Stills (screenshots) - -thing_interactions: - edge_ix__local_dev_process_footage: - kind: sequence - things: - - t_localhost_app_mediapipe - - t_localhost_app_target_footage - - t_localhost_app_mediapipe - - t_localhost_app_mediapipe - - t_localhost_postgres_app - - t_localhost_app_mediapipe - - edge_ix__aws_process_footage: - kind: sequence - things: - - t_aws_az1_subnet_tier_private_lambda_mediapipe - - t_aws_s3_tier_footage - - t_aws_az1_subnet_tier_private_lambda_mediapipe - - t_aws_az1_subnet_tier_private_lambda_mediapipe - - t_aws_rds_tier - - t_aws_az1_subnet_tier_private_lambda_mediapipe - - edge_ix__t_localhost_app__t_localhost_app__tag: - kind: cyclic - things: - - t_localhost_app - edge_ix__t_localhost_app__t_localhost_app__push: - kind: symmetric - things: - - t_localhost_app - - t_github_app - edge_ix__t_localhost_app__t_localhost_app__publish: - kind: symmetric - things: - - t_github_app - - t_aws_ecr_app - -thing_layout_edges: - edge_dep__t_localhost_app_web_app__t_localhost_app_target: - from: t_localhost_app_web_app - to: t_localhost_app_target - edge_dep__t_localhost__t_aws: - from: t_localhost - to: t_aws - edge_dep__t_aws_rds__t_aws_az1: - from: t_aws_rds - to: t_aws_az1 - edge_dep__t_aws_az1_subnet_mgmt__t_aws_az1_subnet_tier_public: - from: t_aws_az1_subnet_mgmt - to: t_aws_az1_subnet_tier_public - edge_dep__t_aws_az1_subnet_tier_public__t_aws_az1_subnet_tier_private: - from: t_aws_az1_subnet_tier_public - to: t_aws_az1_subnet_tier_private - -thing_descs: - t_localhost_app_web_app: |- - Web app frontend and API to view\ - and interact with data. - - t_localhost_app_mediapipe: |- - Mediapipe app to process video\ - into pose. - - t_aws: |- - Limit to one region,\ - e.g. ap-southeast-2 or ap-southeast-7\ - (check available services) - t_aws_ecr: |- - Docker container registry. - - Stores docker containers that have been built\ - from code. - t_aws_ecr_mediapipe: |- - Needed because Mediapipe is too\ - large to upload to lambda\ - individually. - t_aws_ecr_app: Server side API app. - t_aws_az1: |- - Resources are replicated per AZ in the region. - - Exceptions are the management subnet resources,\ - where you may only have one,\ - e.g. VPC private link. - t_aws_az1_subnet_mgmt_vpc_private_link: |- - Allows internal network route to AWS\ - resources without going through the\ - internet. - t_aws_az1_subnet_mgmt_bastion: |- - Allows SSH connections in so developers\ - can connect to AWS resources if needed. - t_aws_az1_subnet_tier_public: |- - Replicated per availability zone. - - Has route to internet. - t_aws_az1_subnet_tier_private: |- - Replicated per availability zone. - - No route to internet. -processes: - proc_app_local_dev: - name: Local development - steps: - proc_app_local_dev_step_process_video: Process Video into Pose - proc_app_local_dev_step_process_pose: Process Pose into Analytics - step_thing_interactions: - proc_app_local_dev_step_process_video: - - edge_ix__local_dev_process_footage - - proc_app_publish: - name: Publish App app - steps: - proc_app_publish_step_git_tag: "`git tag v0.x.x`" - proc_app_publish_step_git_push: "`git push v0.x.x`" - proc_app_publish_step_git_ecr_publish: ECR publish - step_thing_interactions: - proc_app_publish_step_git_tag: - - edge_ix__t_localhost_app__t_localhost_app__tag - proc_app_publish_step_git_push: - - edge_ix__t_localhost_app__t_localhost_app__push - proc_app_publish_step_git_ecr_publish: - - edge_ix__t_localhost_app__t_localhost_app__publish - - proc_footage_process: - name: Process footage into pose - steps: - proc_footage_process_step_trigger: Triggered by upload / periodic queue - proc_footage_process_step_process: Process video into pose - proc_footage_process_step_trigger_pose_process: Trigger Pose -> Analytics workflow - step_thing_interactions: - proc_footage_process_step_trigger: - - edge_ix__aws_process_footage - -edge_labels: - edge_ix__t_localhost_app__t_localhost_app__tag__0: - from: git tag v0.x.x - edge_ix__t_localhost_app__t_localhost_app__push__0: - from: git push origin v0.x.x - - edge_ix__local_dev_process_footage__0: - from: Fetch footage - edge_ix__local_dev_process_footage__2: - from: "Process footage\\\ninto pose" - edge_ix__local_dev_process_footage__3: - from: Send pose to database - - edge_ix__aws_process_footage__0: - from: Fetch footage - edge_ix__aws_process_footage__2: - from: "Process footage\\\ninto pose" - edge_ix__aws_process_footage__3: - from: Send pose to database - -entity_types: - edge_ix__local_dev_process_footage__1: - - type_interaction_edge_symmetric_reverse_default - edge_ix__local_dev_process_footage__4: - - type_interaction_edge_symmetric_reverse_default - edge_ix__aws_process_footage__1: - - type_interaction_edge_symmetric_reverse_default - edge_ix__aws_process_footage__4: - - type_interaction_edge_symmetric_reverse_default - -render_options: - interactions_edge_curvature: orthogonal +things: + t_localhost: + t_localhost_app: + t_localhost_app_web_app: {} + t_localhost_app_mediapipe: {} + t_localhost_app_target: + t_localhost_app_target_footage: {} + t_localhost_app_target_pose: {} + t_localhost_postgres: + t_localhost_postgres_app: {} + t_github: + t_github_app: {} + t_aws: + t_aws_ecr: + t_aws_ecr_mediapipe: {} + t_aws_ecr_app: + t_aws_ecr_app_image_1: {} + t_aws_az1: + t_aws_az1_subnet_mgmt: + t_aws_az1_subnet_mgmt_vpc_private_link: {} + t_aws_az1_subnet_mgmt_bastion: {} + t_aws_az1_subnet_tier_public: {} + t_aws_az1_subnet_tier_private: + t_aws_az1_subnet_tier_private_lambda_mediapipe: {} + t_aws_rds: + t_aws_rds_tier: {} + t_aws_s3: + t_aws_s3_tier: + t_aws_s3_tier_footage: {} + t_aws_s3_tier_stills: {} + +thing_names: + t_localhost: πŸ§‘πŸ»β€πŸ’» Developer machine + t_localhost_app: πŸƒπŸ»β€βž‘οΈ App + t_localhost_app_web_app: 🌐 Web app + t_localhost_app_mediapipe: πŸ“½οΈ Mediapipe app + t_localhost_app_target: "`target` (working dir)" + t_localhost_app_target_footage: footage + t_localhost_app_target_pose: pose + t_localhost_postgres: πŸ—„οΈ Postgres + t_localhost_postgres_app: πŸ—„οΈ App local database + t_github: πŸ™ GitHub + t_github_app: πŸƒπŸ»β€βž‘οΈ App + t_aws: ☁️ AWS + t_aws_ecr: πŸ—„οΈ ECR + t_aws_ecr_mediapipe: πŸ“½οΈ Mediapipe + t_aws_ecr_app: πŸƒπŸ»β€βž‘οΈ App + t_aws_ecr_app_image_1: πŸ“€ App v0.0.1 + t_aws_az1: 🏒 Availability Zone 1 (e.g. ap-southeast-2a) + t_aws_az1_subnet_mgmt: πŸ•ΈοΈ Management Subnet + t_aws_az1_subnet_mgmt_vpc_private_link: 🚟 VPC Link + t_aws_az1_subnet_mgmt_bastion: πŸ’» Bastion host + t_aws_az1_subnet_tier_public: πŸ•ΈοΈ Tier Public Subnet + t_aws_az1_subnet_tier_private: πŸ•ΈοΈ Tier Private Subnet + t_aws_az1_subnet_tier_private_lambda_mediapipe: Ξ» Mediapipe Lambda + t_aws_rds: πŸ—„οΈ RDS + t_aws_rds_tier: πŸ—„οΈ App tier database + t_aws_s3: πŸͺ£ S3 + t_aws_s3_tier: πŸͺ£ Tier Bucket + t_aws_s3_tier_footage: 🎞️ Footage + t_aws_s3_tier_stills: πŸ“· Stills (screenshots) + +thing_interactions: + edge_ix__local_dev_process_footage: + kind: sequence + things: + - t_localhost_app_mediapipe + - t_localhost_app_target_footage + - t_localhost_app_mediapipe + - t_localhost_app_mediapipe + - t_localhost_postgres_app + - t_localhost_app_mediapipe + + edge_ix__aws_process_footage: + kind: sequence + things: + - t_aws_az1_subnet_tier_private_lambda_mediapipe + - t_aws_s3_tier_footage + - t_aws_az1_subnet_tier_private_lambda_mediapipe + - t_aws_az1_subnet_tier_private_lambda_mediapipe + - t_aws_rds_tier + - t_aws_az1_subnet_tier_private_lambda_mediapipe + + edge_ix__t_localhost_app__t_localhost_app__tag: + kind: cyclic + things: + - t_localhost_app + edge_ix__t_localhost_app__t_localhost_app__push: + kind: symmetric + things: + - t_localhost_app + - t_github_app + edge_ix__t_localhost_app__t_localhost_app__publish: + kind: symmetric + things: + - t_github_app + - t_aws_ecr_app + +thing_layout_edges: + edge_dep__t_localhost_app_web_app__t_localhost_app_target: + from: t_localhost_app_web_app + to: t_localhost_app_target + edge_dep__t_localhost__t_aws: + from: t_localhost + to: t_aws + edge_dep__t_aws_rds__t_aws_az1: + from: t_aws_rds + to: t_aws_az1 + edge_dep__t_aws_az1_subnet_mgmt__t_aws_az1_subnet_tier_public: + from: t_aws_az1_subnet_mgmt + to: t_aws_az1_subnet_tier_public + edge_dep__t_aws_az1_subnet_tier_public__t_aws_az1_subnet_tier_private: + from: t_aws_az1_subnet_tier_public + to: t_aws_az1_subnet_tier_private + +thing_descs: + t_localhost_app_web_app: |- + Web app frontend and API to view\ + and interact with data. + + t_localhost_app_mediapipe: |- + Mediapipe app to process video\ + into pose. + + t_aws: |- + Limit to one region,\ + e.g. ap-southeast-2 or ap-southeast-7\ + (check available services) + t_aws_ecr: |- + Docker container registry. + + Stores docker containers that have been built\ + from code. + t_aws_ecr_mediapipe: |- + Needed because Mediapipe is too\ + large to upload to lambda\ + individually. + t_aws_ecr_app: Server side API app. + t_aws_az1: |- + Resources are replicated per AZ in the region. + + Exceptions are the management subnet resources,\ + where you may only have one,\ + e.g. VPC private link. + t_aws_az1_subnet_mgmt_vpc_private_link: |- + Allows internal network route to AWS\ + resources without going through the\ + internet. + t_aws_az1_subnet_mgmt_bastion: |- + Allows SSH connections in so developers\ + can connect to AWS resources if needed. + t_aws_az1_subnet_tier_public: |- + Replicated per availability zone. + + Has route to internet. + t_aws_az1_subnet_tier_private: |- + Replicated per availability zone. + + No route to internet. +processes: + proc_app_local_dev: + name: Local development + steps: + proc_app_local_dev_step_process_video: Process Video into Pose + proc_app_local_dev_step_process_pose: Process Pose into Analytics + step_thing_interactions: + proc_app_local_dev_step_process_video: + - edge_ix__local_dev_process_footage + + proc_app_publish: + name: Publish App app + steps: + proc_app_publish_step_git_tag: "`git tag v0.x.x`" + proc_app_publish_step_git_push: "`git push v0.x.x`" + proc_app_publish_step_git_ecr_publish: ECR publish + step_thing_interactions: + proc_app_publish_step_git_tag: + - edge_ix__t_localhost_app__t_localhost_app__tag + proc_app_publish_step_git_push: + - edge_ix__t_localhost_app__t_localhost_app__push + proc_app_publish_step_git_ecr_publish: + - edge_ix__t_localhost_app__t_localhost_app__publish + + proc_footage_process: + name: Process footage into pose + steps: + proc_footage_process_step_trigger: Triggered by upload / periodic queue + proc_footage_process_step_process: Process video into pose + proc_footage_process_step_trigger_pose_process: Trigger Pose -> Analytics workflow + step_thing_interactions: + proc_footage_process_step_trigger: + - edge_ix__aws_process_footage + +edge_labels: + edge_ix__t_localhost_app__t_localhost_app__tag__0: + from: git tag v0.x.x + edge_ix__t_localhost_app__t_localhost_app__push__0: + from: git push origin v0.x.x + + edge_ix__local_dev_process_footage__0: + from: Fetch footage + edge_ix__local_dev_process_footage__2: + from: "Process footage\\\ninto pose" + edge_ix__local_dev_process_footage__3: + from: Send pose to database + + edge_ix__aws_process_footage__0: + from: Fetch footage + edge_ix__aws_process_footage__2: + from: "Process footage\\\ninto pose" + edge_ix__aws_process_footage__3: + from: Send pose to database + +entity_types: + edge_ix__local_dev_process_footage__1: + - type_interaction_edge_symmetric_reverse_default + edge_ix__local_dev_process_footage__4: + - type_interaction_edge_symmetric_reverse_default + edge_ix__aws_process_footage__1: + - type_interaction_edge_symmetric_reverse_default + edge_ix__aws_process_footage__4: + - type_interaction_edge_symmetric_reverse_default + +render_options: + interactions_edge_curvature: orthogonal From df0ac18689d8f7f0ff76d9466a3e9461fe1af270 Mon Sep 17 00:00:00 2001 From: Azriel Hoh Date: Sat, 11 Jul 2026 21:53:25 +1200 Subject: [PATCH 3/6] Partial work to reduce edge paths overlapping same-rank higher-sibling-index nodes. --- crate/input_ir_rt/src/edge_face_assigner.rs | 195 +++++++++++++++++- .../taffy_to_svg_elements_mapper.rs | 73 +++++++ 2 files changed, 258 insertions(+), 10 deletions(-) diff --git a/crate/input_ir_rt/src/edge_face_assigner.rs b/crate/input_ir_rt/src/edge_face_assigner.rs index babf802f..7a9e5d1d 100644 --- a/crate/input_ir_rt/src/edge_face_assigner.rs +++ b/crate/input_ir_rt/src/edge_face_assigner.rs @@ -25,11 +25,19 @@ use crate::EdgeIdGenerator; /// | Self-loop (`from == to`) | rank-dir face | `None` | /// | Contained (from is ancestor of to) | rank-dir face | opposite | /// | Contained (to is ancestor of from) | opposite | rank-dir face | -/// | Cycle edge adjacent siblings (same LCA rank) | rank-dir face | opposite | -/// | Cycle edge (same LCA rank) | clockwise by sibling index | clockwise | +/// | Cycle edge, adjacent divergent siblings | direct cross-axis face* | opposite* | +/// | Cycle edge, non-adjacent divergent siblings | clockwise by sibling index | clockwise | /// | Forward edge (`lca_rank_from < lca_rank_to`) | rank-dir face | opposite | /// | Reverse edge (`lca_rank_from > lca_rank_to`) | opposite | rank-dir face | /// +/// \* The adjacent-divergent-sibling case assumes the endpoint sits flush +/// against the shared boundary between the two divergent ancestors. When an +/// endpoint (or one of its ancestors up to its divergent ancestor) is not the +/// extremal same-rank sibling in its own parent container -- i.e. a later or +/// earlier same-rank sibling would sit between it and that boundary -- that +/// side falls back to the plain rank-dir face instead, to avoid cutting +/// through the intervening sibling. See `cycle_faces`'s doc comment. +/// /// **Rank-direction face** for a forward edge's `from` node: /// /// | `RankDir` | `from_face` | `to_face` | @@ -60,6 +68,14 @@ struct LcaInfo<'id> { sibling_index_from: usize, /// Sibling index of `divergent_to` within the LCA container. sibling_index_to: usize, + /// Depth (index into `ancestor_chain`) of the divergent ancestors. + /// + /// Used to bound the recursive sibling-extremal check in + /// `EdgeFaceAssigner::sibling_is_extremal_recursive` -- ancestors at or + /// above this depth are not re-checked, since their relative order was + /// already established by the `sibling_index_from`/`sibling_index_to` + /// comparison. + lca_depth: usize, } impl EdgeFaceAssigner { @@ -129,8 +145,10 @@ impl EdgeFaceAssigner { let (from_face, to_face) = if rank_from == rank_to { // Case 3: Cycle edge -- same LCA rank. Self::cycle_faces( - lca_info.sibling_index_from, - lca_info.sibling_index_to, + edge, + &lca_info, + node_nesting_infos, + node_ranks_nested, rank_dir, ) } else if rank_from < rank_to { @@ -194,6 +212,7 @@ impl EdgeFaceAssigner { lca_container, sibling_index_from, sibling_index_to, + lca_depth, }) } @@ -247,14 +266,28 @@ impl EdgeFaceAssigner { /// |---|---|---| /// | `LeftToRight` / `RightToLeft` | `(Right, Right)` | `(Left, Left)` | /// | `TopToBottom` / `BottomToTop` | `(Top, Top)` | `(Bottom, Bottom)` | - fn cycle_faces( - sibling_index_from: usize, - sibling_index_to: usize, + /// + /// For the adjacent-sibling case (3a), the direct cross-axis connection + /// assumes the two LCA-level divergent ancestors are flush against each + /// other. That only holds for the endpoint node itself when it (and every + /// ancestor between it and the divergent ancestor) is the extremal + /// (last/first) same-rank sibling in its own parent container -- + /// otherwise a later/earlier sibling sits between the endpoint and the + /// shared boundary, and the edge would cut through it. When that check + /// fails, `cycle_faces_adjacent_overlap_avoid` falls back to the plain + /// rank-direction face (`forward_faces`) for that side instead. + fn cycle_faces<'id>( + edge: &Edge<'id>, + lca_info: &LcaInfo<'id>, + node_nesting_infos: &NodeNestingInfos<'id>, + node_ranks_nested: &NodeRanksNested<'id>, rank_dir: RankDir, ) -> (NodeFace, NodeFace) { - let sibling_index_from_cmp_to = sibling_index_from.cmp(&sibling_index_to); - let sibling_index_abs_diff = sibling_index_from.abs_diff(sibling_index_to); - match (rank_dir, sibling_index_from_cmp_to, sibling_index_abs_diff) { + let sibling_index_from_cmp_to = lca_info.sibling_index_from.cmp(&lca_info.sibling_index_to); + let sibling_index_abs_diff = lca_info + .sibling_index_from + .abs_diff(lca_info.sibling_index_to); + let faces = match (rank_dir, sibling_index_from_cmp_to, sibling_index_abs_diff) { // 3a: adjacent siblings don't use clockwise edges (RankDir::LeftToRight | RankDir::RightToLeft, Ordering::Less, 1) => { (NodeFace::Bottom, NodeFace::Top) @@ -290,9 +323,151 @@ impl EdgeFaceAssigner { Ordering::Equal | Ordering::Greater, _, ) => (NodeFace::Bottom, NodeFace::Bottom), + }; + + if sibling_index_abs_diff == 1 { + Self::cycle_faces_adjacent_overlap_avoid( + edge, + lca_info, + faces, + node_nesting_infos, + node_ranks_nested, + rank_dir, + ) + } else { + faces } } + /// Resolves the adjacent-sibling (3a) `(from_face, to_face)` pair + /// returned by `cycle_faces`, replacing either face with the plain + /// rank-direction face when the corresponding endpoint is not the + /// extremal same-rank sibling recursively up to its LCA-level divergent + /// ancestor -- see `cycle_faces`'s doc comment for why. + fn cycle_faces_adjacent_overlap_avoid<'id>( + edge: &Edge<'id>, + lca_info: &LcaInfo<'id>, + faces: (NodeFace, NodeFace), + node_nesting_infos: &NodeNestingInfos<'id>, + node_ranks_nested: &NodeRanksNested<'id>, + rank_dir: RankDir, + ) -> (NodeFace, NodeFace) { + let (naive_from_face, naive_to_face) = faces; + let (fallback_from_face, fallback_to_face) = Self::forward_faces(rank_dir); + + let from_face = Self::cycle_faces_adjacent_face_resolve( + &edge.from, + naive_from_face, + fallback_from_face, + lca_info.lca_depth, + rank_dir, + node_nesting_infos, + node_ranks_nested, + ); + let to_face = Self::cycle_faces_adjacent_face_resolve( + &edge.to, + naive_to_face, + fallback_to_face, + lca_info.lca_depth, + rank_dir, + node_nesting_infos, + node_ranks_nested, + ); + + (from_face, to_face) + } + + /// Resolves the face for one endpoint of an adjacent-sibling cycle edge. + /// + /// Returns `naive_face` when `node_id` (and every ancestor between it and + /// the LCA-level divergent ancestor at `lca_depth`) is the extremal + /// (last/first) same-rank sibling in its immediate parent container -- + /// otherwise returns `fallback_face`. + fn cycle_faces_adjacent_face_resolve<'id>( + node_id: &NodeId<'id>, + naive_face: NodeFace, + fallback_face: NodeFace, + lca_depth: usize, + rank_dir: RankDir, + node_nesting_infos: &NodeNestingInfos<'id>, + node_ranks_nested: &NodeRanksNested<'id>, + ) -> NodeFace { + let (last_face, _first_face) = Self::cross_axis_extremal_faces(rank_dir); + let want_last = naive_face == last_face; + + let Some(nesting_info) = node_nesting_infos.get(node_id) else { + return naive_face; + }; + + if Self::sibling_is_extremal_recursive( + &nesting_info.ancestor_chain, + lca_depth, + node_ranks_nested, + want_last, + ) { + naive_face + } else { + fallback_face + } + } + + /// Returns the `(last_face, first_face)` pair along the cross (sibling) + /// axis for `rank_dir` -- the faces `cycle_faces`'s adjacent-sibling (3a) + /// branch uses for a node positioned after / before its LCA-level + /// sibling. + fn cross_axis_extremal_faces(rank_dir: RankDir) -> (NodeFace, NodeFace) { + match rank_dir { + RankDir::TopToBottom | RankDir::BottomToTop => (NodeFace::Right, NodeFace::Left), + RankDir::LeftToRight | RankDir::RightToLeft => (NodeFace::Bottom, NodeFace::Top), + } + } + + /// Returns whether the node at the end of `ancestor_chain` is the + /// extremal (last, if `want_last`, else first) same-rank sibling in its + /// immediate parent's rank container, recursively for every ancestor + /// between (exclusive) `lca_depth` and the node itself (inclusive). + /// + /// Ancestors at or above `lca_depth` are not checked, since their + /// relative order was already established by the LCA-level sibling index + /// comparison in `cycle_faces`. Returns `true` trivially when the node + /// itself is at `lca_depth` (nothing to check). + fn sibling_is_extremal_recursive<'id>( + ancestor_chain: &[NodeId<'id>], + lca_depth: usize, + node_ranks_nested: &NodeRanksNested<'id>, + want_last: bool, + ) -> bool { + (lca_depth + 1..ancestor_chain.len()).all(|depth| { + let parent_id = &ancestor_chain[depth - 1]; + let child_id = &ancestor_chain[depth]; + + let Some(parent_ranks) = node_ranks_nested.ranks_for(Some(parent_id)) else { + return false; + }; + let Some(child_rank) = parent_ranks.get(child_id).copied() else { + return false; + }; + + let mut same_rank_position = None; + let mut same_rank_count = 0; + for (sibling_id, sibling_rank) in parent_ranks.iter() { + if *sibling_rank != child_rank { + continue; + } + if sibling_id == child_id { + same_rank_position = Some(same_rank_count); + } + same_rank_count += 1; + } + + match same_rank_position { + Some(position) if want_last => position + 1 == same_rank_count, + Some(position) => position == 0, + None => false, + } + }) + } + /// Computes the [`EdgeFaceAssignment`] for a contained edge (one endpoint /// is an ancestor of the other). /// diff --git a/workspace_tests/src/input_ir_rt/taffy_to_svg_elements_mapper.rs b/workspace_tests/src/input_ir_rt/taffy_to_svg_elements_mapper.rs index e9ff2f96..1c830b77 100644 --- a/workspace_tests/src/input_ir_rt/taffy_to_svg_elements_mapper.rs +++ b/workspace_tests/src/input_ir_rt/taffy_to_svg_elements_mapper.rs @@ -6401,3 +6401,76 @@ fn test_0062_curved_edge_does_not_dip_back_to_notch_spacer() { } } } + +// === Same-rank cycle edge with a later same-rank sibling (0062 push edge) === // + +/// `edge_ix__t_localhost_app__t_localhost_app__push__0` runs from +/// `t_localhost_app` (rank 0, sibling 0 of 2 inside `t_localhost`, alongside +/// `t_localhost_postgres`) to `t_github_app` (rank 0, the sole child of +/// `t_github`). `t_localhost` and `t_github` are adjacent root-level +/// siblings, so `EdgeFaceAssigner::cycle_faces` picks the direct cross-axis +/// face pair for `TopToBottom` (`Right`/`Left`). +/// +/// That direct connection assumes the two divergent ancestors are flush +/// against each other, which only holds for `t_github_app` (the sole, hence +/// first, child of `t_github`) -- not for `t_localhost_app`, which has +/// `t_localhost_postgres` as a later same-rank sibling inside `t_localhost`. +/// Exiting via `Right` used to cut straight through `t_localhost_postgres`. +/// +/// `EdgeFaceAssigner::cycle_faces_adjacent_overlap_avoid` falls back to the +/// plain rank-direction face (`Bottom`, for `TopToBottom`) for +/// `t_localhost_app`'s side when this check fails, routing the edge through +/// the rank gap instead -- the same mechanism already used (and proven not +/// to overlap siblings) for ordinary forward/reverse edges. +#[test] +fn test_0062_push_edge_from_non_extremal_sibling_does_not_overlap_later_sibling() { + fn path_points(path_d: &str) -> Vec<(f32, f32)> { + path_d + .split([' ', 'M', 'L', 'C']) + .filter_map(|tok| { + let (x, y) = tok.split_once(',')?; + Some((x.trim().parse::().ok()?, y.trim().parse::().ok()?)) + }) + .collect() + } + + for svg_elements in + build_svg_elements_for_diagram(INPUT_DIAGRAM_0062_EDGES_FROM_HIGHER_RANK_TO_LOWER_RANK) + { + let postgres = svg_elements + .svg_node_infos + .iter() + .find(|node_info| node_info.node_id.as_str() == "t_localhost_postgres") + .expect("Expected t_localhost_postgres in svg_node_infos"); + let postgres_left = postgres.x; + let postgres_right = postgres.x + postgres.width; + let postgres_top = postgres.y; + let postgres_bottom = postgres.y + postgres.height_collapsed; + + let edge = svg_elements + .svg_edge_infos + .iter() + .find(|svg_edge_info| { + svg_edge_info.edge_id.as_str() + == "edge_ix__t_localhost_app__t_localhost_app__push__0" + }) + .expect("Expected edge_ix__t_localhost_app__t_localhost_app__push__0 to exist."); + + for (x, y) in path_points(&edge.path_d) { + assert!( + !(x > postgres_left + 1.0 + && x < postgres_right - 1.0 + && y > postgres_top + 1.0 + && y < postgres_bottom - 1.0), + "edge_ix__t_localhost_app__t_localhost_app__push__0's path point \ + ({x:.2}, {y:.2}) lies inside t_localhost_postgres's box \ + (x: {postgres_left:.2}..{postgres_right:.2}, \ + y: {postgres_top:.2}..{postgres_bottom:.2}); the edge must exit \ + t_localhost_app via its rank-direction face (not the cross-axis \ + face), since t_localhost_postgres is a later same-rank sibling. \ + path: {}", + edge.path_d, + ); + } + } +} From 44792b6fd4d5d0ae2bbcc33f38b1fbe5a60fed62 Mon Sep 17 00:00:00 2001 From: Azriel Hoh Date: Sat, 11 Jul 2026 22:19:06 +1200 Subject: [PATCH 4/6] Update edge routing for same-rank non-adjacent divergent ancestor siblings to use the exit `NodeFace` for both `from` and `to` nodes. --- crate/input_ir_rt/src/edge_face_assigner.rs | 84 +++++++++++-------- .../taffy_to_svg_elements_mapper.rs | 83 ++++++++++++------ 2 files changed, 108 insertions(+), 59 deletions(-) diff --git a/crate/input_ir_rt/src/edge_face_assigner.rs b/crate/input_ir_rt/src/edge_face_assigner.rs index 7a9e5d1d..99d56c2c 100644 --- a/crate/input_ir_rt/src/edge_face_assigner.rs +++ b/crate/input_ir_rt/src/edge_face_assigner.rs @@ -30,13 +30,16 @@ use crate::EdgeIdGenerator; /// | Forward edge (`lca_rank_from < lca_rank_to`) | rank-dir face | opposite | /// | Reverse edge (`lca_rank_from > lca_rank_to`) | opposite | rank-dir face | /// -/// \* The adjacent-divergent-sibling case assumes the endpoint sits flush -/// against the shared boundary between the two divergent ancestors. When an -/// endpoint (or one of its ancestors up to its divergent ancestor) is not the -/// extremal same-rank sibling in its own parent container -- i.e. a later or -/// earlier same-rank sibling would sit between it and that boundary -- that -/// side falls back to the plain rank-dir face instead, to avoid cutting -/// through the intervening sibling. See `cycle_faces`'s doc comment. +/// \* The adjacent-divergent-sibling case assumes each endpoint sits flush +/// against the shared boundary between the two divergent ancestors. When +/// either endpoint (or one of its ancestors up to its divergent ancestor) is +/// not the extremal same-rank sibling in its own parent container -- i.e. a +/// later or earlier same-rank sibling would sit between it and that boundary +/// -- *both* faces fall back to the same plain rank-dir face (`from_face`'s +/// rank-dir value, for both ends) instead of the cross-axis pair, to avoid +/// cutting through the intervening sibling and to avoid a path that must +/// double back from one endpoint's rank-dir face to the other's untouched +/// cross-axis face. See `cycle_faces`'s doc comment. /// /// **Rank-direction face** for a forward edge's `from` node: /// @@ -340,10 +343,26 @@ impl EdgeFaceAssigner { } /// Resolves the adjacent-sibling (3a) `(from_face, to_face)` pair - /// returned by `cycle_faces`, replacing either face with the plain - /// rank-direction face when the corresponding endpoint is not the - /// extremal same-rank sibling recursively up to its LCA-level divergent - /// ancestor -- see `cycle_faces`'s doc comment for why. + /// returned by `cycle_faces`. + /// + /// Keeps the naive cross-axis pair (e.g. `(Right, Left)`) when both + /// endpoints are the extremal same-rank sibling recursively up to their + /// LCA-level divergent ancestor -- see `cycle_faces`'s doc comment for + /// why that's required. + /// + /// Otherwise, at least one endpoint is not flush against the shared + /// boundary the naive faces assume. Rather than falling back + /// independently per side (which can pair one node's rank-direction face + /// with the other's untouched cross-axis face, e.g. `(Bottom, Left)`, + /// forcing the path to double back from below one node to the side of + /// the other -- a visually confusing near-180-degree turn), both ends + /// are routed via the *same* rank-direction face + /// (`forward_faces(rank_dir).0`, e.g. `Bottom` for `TopToBottom`, + /// regardless of which endpoint plays the `from`/`to` role for this + /// particular edge instance). This matches how a genuinely-ranked + /// forward/reverse edge already routes through the rank gap, and keeps + /// both directions of a symmetric edge pair (e.g. `..._push__0` and + /// `..._push__1`) using the same pair of faces. fn cycle_faces_adjacent_overlap_avoid<'id>( edge: &Edge<'id>, lca_info: &LcaInfo<'id>, @@ -353,62 +372,61 @@ impl EdgeFaceAssigner { rank_dir: RankDir, ) -> (NodeFace, NodeFace) { let (naive_from_face, naive_to_face) = faces; - let (fallback_from_face, fallback_to_face) = Self::forward_faces(rank_dir); - let from_face = Self::cycle_faces_adjacent_face_resolve( + let from_is_extremal = Self::cycle_faces_adjacent_endpoint_is_extremal( &edge.from, naive_from_face, - fallback_from_face, lca_info.lca_depth, rank_dir, node_nesting_infos, node_ranks_nested, ); - let to_face = Self::cycle_faces_adjacent_face_resolve( + let to_is_extremal = Self::cycle_faces_adjacent_endpoint_is_extremal( &edge.to, naive_to_face, - fallback_to_face, lca_info.lca_depth, rank_dir, node_nesting_infos, node_ranks_nested, ); - (from_face, to_face) + if from_is_extremal && to_is_extremal { + (naive_from_face, naive_to_face) + } else { + let (fallback_face, _) = Self::forward_faces(rank_dir); + (fallback_face, fallback_face) + } } - /// Resolves the face for one endpoint of an adjacent-sibling cycle edge. - /// - /// Returns `naive_face` when `node_id` (and every ancestor between it and - /// the LCA-level divergent ancestor at `lca_depth`) is the extremal - /// (last/first) same-rank sibling in its immediate parent container -- - /// otherwise returns `fallback_face`. - fn cycle_faces_adjacent_face_resolve<'id>( + /// Returns whether `node_id`'s naive adjacent-sibling cycle face + /// (`naive_face`) is safe to use -- i.e. `node_id` (and every ancestor + /// between it and the LCA-level divergent ancestor at `lca_depth`) is + /// the extremal (last/first) same-rank sibling in its immediate + /// parent's rank container. + fn cycle_faces_adjacent_endpoint_is_extremal<'id>( node_id: &NodeId<'id>, naive_face: NodeFace, - fallback_face: NodeFace, lca_depth: usize, rank_dir: RankDir, node_nesting_infos: &NodeNestingInfos<'id>, node_ranks_nested: &NodeRanksNested<'id>, - ) -> NodeFace { + ) -> bool { let (last_face, _first_face) = Self::cross_axis_extremal_faces(rank_dir); let want_last = naive_face == last_face; let Some(nesting_info) = node_nesting_infos.get(node_id) else { - return naive_face; + // Defensive: preserve the naive face when nesting info is + // unexpectedly missing (should not occur -- both endpoints were + // already resolved successfully in `lca_info_compute`). + return true; }; - if Self::sibling_is_extremal_recursive( + Self::sibling_is_extremal_recursive( &nesting_info.ancestor_chain, lca_depth, node_ranks_nested, want_last, - ) { - naive_face - } else { - fallback_face - } + ) } /// Returns the `(last_face, first_face)` pair along the cross (sibling) diff --git a/workspace_tests/src/input_ir_rt/taffy_to_svg_elements_mapper.rs b/workspace_tests/src/input_ir_rt/taffy_to_svg_elements_mapper.rs index 1c830b77..7d691881 100644 --- a/workspace_tests/src/input_ir_rt/taffy_to_svg_elements_mapper.rs +++ b/workspace_tests/src/input_ir_rt/taffy_to_svg_elements_mapper.rs @@ -6417,11 +6417,14 @@ fn test_0062_curved_edge_does_not_dip_back_to_notch_spacer() { /// `t_localhost_postgres` as a later same-rank sibling inside `t_localhost`. /// Exiting via `Right` used to cut straight through `t_localhost_postgres`. /// -/// `EdgeFaceAssigner::cycle_faces_adjacent_overlap_avoid` falls back to the -/// plain rank-direction face (`Bottom`, for `TopToBottom`) for -/// `t_localhost_app`'s side when this check fails, routing the edge through -/// the rank gap instead -- the same mechanism already used (and proven not -/// to overlap siblings) for ordinary forward/reverse edges. +/// `EdgeFaceAssigner::cycle_faces_adjacent_overlap_avoid` routes both ends +/// via the same rank-direction face (`Bottom`, for `TopToBottom`) once +/// either endpoint fails this check -- not just `t_localhost_app`'s side -- +/// so the path dips below both containers and rises into the other +/// endpoint from the same face, rather than pairing one node's +/// rank-direction face with the other's untouched cross-axis face (which +/// produced a visually confusing near-180-degree turn). This holds for both +/// directions of the symmetric edge pair (`push__0` and `push__1`). #[test] fn test_0062_push_edge_from_non_extremal_sibling_does_not_overlap_later_sibling() { fn path_points(path_d: &str) -> Vec<(f32, f32)> { @@ -6437,6 +6440,17 @@ fn test_0062_push_edge_from_non_extremal_sibling_does_not_overlap_later_sibling( for svg_elements in build_svg_elements_for_diagram(INPUT_DIAGRAM_0062_EDGES_FROM_HIGHER_RANK_TO_LOWER_RANK) { + let node_bottom = |node_id: &str| -> f32 { + let node_info = svg_elements + .svg_node_infos + .iter() + .find(|node_info| node_info.node_id.as_str() == node_id) + .unwrap_or_else(|| panic!("Expected {node_id} in svg_node_infos")); + node_info.y + node_info.height_collapsed + }; + let localhost_app_bottom = node_bottom("t_localhost_app"); + let github_app_bottom = node_bottom("t_github_app"); + let postgres = svg_elements .svg_node_infos .iter() @@ -6447,30 +6461,47 @@ fn test_0062_push_edge_from_non_extremal_sibling_does_not_overlap_later_sibling( let postgres_top = postgres.y; let postgres_bottom = postgres.y + postgres.height_collapsed; - let edge = svg_elements - .svg_edge_infos - .iter() - .find(|svg_edge_info| { - svg_edge_info.edge_id.as_str() - == "edge_ix__t_localhost_app__t_localhost_app__push__0" - }) - .expect("Expected edge_ix__t_localhost_app__t_localhost_app__push__0 to exist."); - - for (x, y) in path_points(&edge.path_d) { + for edge_id in [ + "edge_ix__t_localhost_app__t_localhost_app__push__0", + "edge_ix__t_localhost_app__t_localhost_app__push__1", + ] { + let edge = svg_elements + .svg_edge_infos + .iter() + .find(|svg_edge_info| svg_edge_info.edge_id.as_str() == edge_id) + .unwrap_or_else(|| panic!("Expected {edge_id} to exist.")); + + let points = path_points(&edge.path_d); + let (_, start_y) = *points.first().expect("Expected a start point"); + let (_, end_y) = *points.last().expect("Expected an end point"); + let (from_bottom, to_bottom) = if edge.from_node_id.as_str() == "t_localhost_app" { + (localhost_app_bottom, github_app_bottom) + } else { + (github_app_bottom, localhost_app_bottom) + }; assert!( - !(x > postgres_left + 1.0 - && x < postgres_right - 1.0 - && y > postgres_top + 1.0 - && y < postgres_bottom - 1.0), - "edge_ix__t_localhost_app__t_localhost_app__push__0's path point \ - ({x:.2}, {y:.2}) lies inside t_localhost_postgres's box \ - (x: {postgres_left:.2}..{postgres_right:.2}, \ - y: {postgres_top:.2}..{postgres_bottom:.2}); the edge must exit \ - t_localhost_app via its rank-direction face (not the cross-axis \ - face), since t_localhost_postgres is a later same-rank sibling. \ - path: {}", + (start_y - from_bottom).abs() < 1.0 && (end_y - to_bottom).abs() < 1.0, + "{edge_id} should exit and enter via both nodes' Bottom face \ + (from {from_bottom:.2}, to {to_bottom:.2}), but the path starts \ + at y={start_y:.2} and ends at y={end_y:.2}; path: {}", edge.path_d, ); + + for (x, y) in points { + assert!( + !(x > postgres_left + 1.0 + && x < postgres_right - 1.0 + && y > postgres_top + 1.0 + && y < postgres_bottom - 1.0), + "{edge_id}'s path point ({x:.2}, {y:.2}) lies inside \ + t_localhost_postgres's box (x: {postgres_left:.2}.. \ + {postgres_right:.2}, y: {postgres_top:.2}..{postgres_bottom:.2}); \ + the edge must exit t_localhost_app via its rank-direction face \ + (not the cross-axis face), since t_localhost_postgres is a later \ + same-rank sibling. path: {}", + edge.path_d, + ); + } } } } From ae0ee8c93c39f9d00c8c54e53c103b4a7655085b Mon Sep 17 00:00:00 2001 From: Azriel Hoh Date: Sat, 11 Jul 2026 22:20:42 +1200 Subject: [PATCH 5/6] Update `CHANGELOG.md`. --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04a7527a..fcb7a4fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,7 @@ * Suggest custom entity types as a completion option when editing `entity_types..`. ([#63][#63]) * Interaction edge that is currently animated has its halo more opaque, inactive edges have a more transparent halo. ([#64][#64]) * Reduce halo outline animation duration to `0.3` seconds per 100 pixels. ([#64][#64]) +* Update edge routing for same-rank non-adjacent divergent ancestor siblings to use the exit `NodeFace` for both `from` and `to` nodes. ([#65][#65]) [#42]: https://github.com/azriel91/disposition/pull/42 [#43]: https://github.com/azriel91/disposition/pull/43 @@ -95,6 +96,7 @@ [#62]: https://github.com/azriel91/disposition/pull/62 [#63]: https://github.com/azriel91/disposition/pull/63 [#64]: https://github.com/azriel91/disposition/pull/64 +[#65]: https://github.com/azriel91/disposition/pull/65 ## 0.3.0 (2026-06-07) From 731b59dc64d2f0e0095f00c477fd3bb695a8a88c Mon Sep 17 00:00:00 2001 From: Azriel Hoh Date: Sat, 11 Jul 2026 22:24:03 +1200 Subject: [PATCH 6/6] Run `cargo fmt --all`. --- .../src/input_ir_rt/taffy_to_svg_elements_mapper.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/workspace_tests/src/input_ir_rt/taffy_to_svg_elements_mapper.rs b/workspace_tests/src/input_ir_rt/taffy_to_svg_elements_mapper.rs index 7d691881..10e769df 100644 --- a/workspace_tests/src/input_ir_rt/taffy_to_svg_elements_mapper.rs +++ b/workspace_tests/src/input_ir_rt/taffy_to_svg_elements_mapper.rs @@ -6402,7 +6402,8 @@ fn test_0062_curved_edge_does_not_dip_back_to_notch_spacer() { } } -// === Same-rank cycle edge with a later same-rank sibling (0062 push edge) === // +// === Same-rank cycle edge with a later same-rank sibling (0062 push edge) === +// // /// `edge_ix__t_localhost_app__t_localhost_app__push__0` runs from /// `t_localhost_app` (rank 0, sibling 0 of 2 inside `t_localhost`, alongside