diff --git a/CHANGELOG.md b/CHANGELOG.md index 75d435b..9f93261 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,12 @@ * Add focus outlines around nodes and edges. ([#27][#27]) * Update `stroke_style: dashed` to mean `dasharray:4`. ([#27][#27]) * Fix duplication of tailwind classes on edges. ([#28][#28]) +* Fix edge path routing issues regarding cross-container edges, spacers, and nested `NodeRank`s. ([#29][#29]) [#26]: https://github.com/azriel91/disposition/pull/26 [#27]: https://github.com/azriel91/disposition/pull/27 [#28]: https://github.com/azriel91/disposition/pull/28 +[#29]: https://github.com/azriel91/disposition/pull/29 ## 0.1.0 (2026-04-11) diff --git a/Cargo.lock b/Cargo.lock index 6d58e86..17f8594 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -30,6 +30,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -47,9 +65,9 @@ dependencies = [ [[package]] name = "annotate-snippets" -version = "0.12.13" +version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74fc7650eedcb2fee505aad48491529e408f0e854c2d9f63eb86c1361b9b3f93" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" dependencies = [ "anstyle", "memchr", @@ -68,6 +86,23 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "arraydeque" version = "0.5.1" @@ -83,6 +118,15 @@ dependencies = [ "serde", ] +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "askama_escape" version = "0.13.0" @@ -173,11 +217,54 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + [[package]] name = "axum" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "axum-macros", @@ -204,7 +291,7 @@ dependencies = [ "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.29.0", "tower", "tower-layer", "tower-service", @@ -255,9 +342,9 @@ dependencies = [ [[package]] name = "axum-macros" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", @@ -276,6 +363,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -284,13 +377,22 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" dependencies = [ "serde_core", ] +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + [[package]] name = "block" version = "0.1.6" @@ -324,18 +426,36 @@ dependencies = [ "objc2 0.6.4", ] +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" + [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -351,7 +471,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cairo-sys-rs", "glib", "libc", @@ -376,7 +496,7 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "polling", "rustix", "slab", @@ -397,11 +517,13 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.58" +version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -509,7 +631,7 @@ version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad36507aeb7e16159dfe68db81ccc27571c3ccd4b76fb2fb72fc59e7a4b1b64c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "block", "cocoa-foundation", "core-foundation 0.10.1", @@ -525,13 +647,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "block", "core-foundation 0.10.1", "core-graphics-types", "objc", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.7" @@ -601,11 +729,12 @@ checksum = "b0664d2867b4a32697dfe655557f5c3b187e9b605b38612a748e5ec99811d160" [[package]] name = "const_format" -version = "0.2.35" +version = "0.2.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" dependencies = [ "const_format_proc_macros", + "konst", ] [[package]] @@ -727,7 +856,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "core-foundation 0.10.1", "core-graphics-types", "foreign-types 0.5.0", @@ -740,7 +869,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "core-foundation 0.10.1", "core-graphics-types", "foreign-types 0.5.0", @@ -753,7 +882,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "core-foundation 0.10.1", "libc", ] @@ -785,6 +914,25 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -813,7 +961,7 @@ version = "0.29.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" dependencies = [ - "cssparser-macros 0.6.1", + "cssparser-macros", "dtoa-short", "itoa", "matches", @@ -824,18 +972,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "cssparser" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" -dependencies = [ - "cssparser-macros 0.7.0", - "dtoa-short", - "itoa", - "smallvec", -] - [[package]] name = "cssparser-macros" version = "0.6.1" @@ -846,16 +982,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "cssparser-macros" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a2a99df6e410a8ff4245aa2006499ea662245f967cc7c0a38c83ef8eb44dbf" -dependencies = [ - "quote", - "syn 2.0.117", -] - [[package]] name = "cursor-icon" version = "1.2.0" @@ -912,9 +1038,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "deranged" @@ -979,9 +1105,9 @@ dependencies = [ [[package]] name = "dioxus" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d5b0aec58753daee127a5fe2d1a40b0db8cebc0b8a7f97b34df2492cb90d78e" +checksum = "7c01ecf7ddbae18a419ad3d83c486101a85ffc5740ea09cdd0f09a30dc12170d" dependencies = [ "dioxus-asset-resolver", "dioxus-cli-config", @@ -1012,9 +1138,9 @@ dependencies = [ [[package]] name = "dioxus-asset-resolver" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c240c4f092024b26e200ecd64723009173cf5bc2e5083c9feb778c077eb5741b" +checksum = "69387edbbc60c7cb93ad96d8cc7a22b49a76e21643380b89b1c49a78d347ff60" dependencies = [ "dioxus-cli-config", "http", @@ -1033,9 +1159,9 @@ dependencies = [ [[package]] name = "dioxus-cli-config" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86a13d42c5defcea333bdbae1dc5d64d078acd0fda1d8a1441c37e06be5146e3" +checksum = "c000f584ddf608e2b272b3074bf11512a474eeeb2eb85a1915f276ce5c4a8615" dependencies = [ "wasm-bindgen", ] @@ -1055,9 +1181,9 @@ dependencies = [ [[package]] name = "dioxus-config-macro" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ba1d68a05a8a15293ba65d45c7a3263356f3eedf1a3e599440683f3eb014637" +checksum = "7637091592978fbfdb45a16b26bd99fd97fb1bd7e31c6a963530e00c022af321" dependencies = [ "proc-macro2", "quote", @@ -1065,15 +1191,15 @@ dependencies = [ [[package]] name = "dioxus-config-macros" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f43f2d511d3c3c439a2fb7f863668b84caf8e0d2440cbfbcbb28521e26ba7f44" +checksum = "54f9ed8fc1a215ad34bb8dbae42a4ea54efbcd26ca9006bbe5cca78e511bf25f" [[package]] name = "dioxus-core" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb3dd61889e6a09daec93d44db86047fb8e6603beedcf9351b8528582254e075" +checksum = "45887100ff0cf89abeb8b659808294fda48cd53f3b424e36407dedffcfea830b" dependencies = [ "anyhow", "const_format", @@ -1093,9 +1219,9 @@ dependencies = [ [[package]] name = "dioxus-core-macro" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8577c4d9a8cc23423c4d2137319044b03ab940e4b2790dd25f4f06601bd32d9a" +checksum = "370c63663dff0f24df5dfea643ca239283542c6b228a302f69b32e1d36762b7f" dependencies = [ "convert_case 0.8.0", "dioxus-rsx", @@ -1106,16 +1232,17 @@ dependencies = [ [[package]] name = "dioxus-core-types" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b99d7d199aad72431b549759550002e7d72c8a257eba500dca9fbdb2122de103" +checksum = "36963eab106b169737762f9cd5ee5fd97f585989dcb2d8e30a596e97a6999009" [[package]] name = "dioxus-desktop" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1df90224b51e0246bedeffe166e8ae782440dfaf45e329532a1d71afc79e0732" +checksum = "662cd78c73ca3f17346adbf2d64757df40dd0ce20536c05123097fd31828d2bd" dependencies = [ + "anyhow", "async-trait", "base64", "bytes", @@ -1136,6 +1263,7 @@ dependencies = [ "futures-util", "generational-box", "global-hotkey", + "image", "infer", "jni 0.21.1", "lazy-js-bundle", @@ -1147,7 +1275,7 @@ dependencies = [ "objc", "objc_id", "percent-encoding", - "rand 0.9.2", + "rand 0.9.4", "rfd", "rustc-hash 2.1.2", "serde", @@ -1167,9 +1295,9 @@ dependencies = [ [[package]] name = "dioxus-devtools" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27e7212436a581ce058d7554f1383916bd18a68ebd6015b0b4c2e9ecb0d5535" +checksum = "2349cedbdf1b429df1f1bea61fdee0ad3dae7b2548eedfbeca82710122a57da0" dependencies = [ "dioxus-cli-config", "dioxus-core", @@ -1187,9 +1315,9 @@ dependencies = [ [[package]] name = "dioxus-devtools-types" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aa24ed651b97e0b423270bf07a0f1b7dc0e0fa1f1dc26407cd2a118d6bf9de5" +checksum = "0ab9b0f7565d1916b70915f59b89ea8054ef0a9d67a364a32bbee68ef5f3818d" dependencies = [ "dioxus-core", "serde", @@ -1198,9 +1326,9 @@ dependencies = [ [[package]] name = "dioxus-document" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24685cb51cc6227ea606c49dfe531836f362c49183d3007241afcd8827498401" +checksum = "37e3a5bec7ffc999ff23446a487eb5cd86111d1574a23533dd3f8b3c69a53a22" dependencies = [ "dioxus-core", "dioxus-core-macro", @@ -1217,9 +1345,9 @@ dependencies = [ [[package]] name = "dioxus-fullstack" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5940c870751b6273a23b7c0e16d80039f45604d68d9b86c91e27b09edeabeb9e" +checksum = "37f0558edb88af5ad47275ae36a7f06317163ba482db377c26d7d8590b5cd0f6" dependencies = [ "anyhow", "async-stream", @@ -1265,7 +1393,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-stream", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tokio-util", "tower", "tower-http", @@ -1282,9 +1410,9 @@ dependencies = [ [[package]] name = "dioxus-fullstack-core" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28333274cfc8e5fe547ab04258c2511350c4930a07af9616d365dc4ba7b22d8f" +checksum = "cc634b28b4b1e3eab1e8df4f98510e2d2fa39d686321467f977213155e86ed2b" dependencies = [ "anyhow", "axum-core", @@ -1310,9 +1438,9 @@ dependencies = [ [[package]] name = "dioxus-fullstack-macro" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53f7e5a9fa7f657aa519a07aced8b8936f3ae8a246d94855d497d8cce59b9533" +checksum = "85a8fe7da549859fae00c7f4bf11a2aab734ae7ef6f98f280dce9bea1f3326ec" dependencies = [ "const_format", "convert_case 0.8.0", @@ -1324,9 +1452,9 @@ dependencies = [ [[package]] name = "dioxus-history" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "010b446322b3f9176476579fa61c7552f0430abbeec418cab543482da6ca4363" +checksum = "1a15232302d1933015fcf2d6fe9e286ad36f6e9c205a546089a0f326023bb0d2" dependencies = [ "dioxus-core", "tracing", @@ -1334,9 +1462,9 @@ dependencies = [ [[package]] name = "dioxus-hooks" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e7a6ba279050cc161e1215c6db0bd15915c9314ec2916d7b22c113a3039536" +checksum = "4534f91cf6305204b948bdec130076ac9ecc7c22faab29475b76870558bf73ea" dependencies = [ "dioxus-core", "dioxus-signals", @@ -1350,9 +1478,9 @@ dependencies = [ [[package]] name = "dioxus-html" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0715e38cc6537aef5b79d0ddc1f4d7a56c2f4debe46b127eee24d8aa5dafd2d" +checksum = "e03d6ad4040b667f2b2eefcb678840e630938c09bf9ec39b04ea4d1d96d90d44" dependencies = [ "async-trait", "bytes", @@ -1377,9 +1505,9 @@ dependencies = [ [[package]] name = "dioxus-html-internal-macro" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff6b7918b0908c8719a6165b4e3c362da4fd311fc7cb48720eddd8a45b2ddfc6" +checksum = "584e2772127ab00f0d5e1d4d9795f39fecebc828ece0b7a02349d438bc1b1ce7" dependencies = [ "convert_case 0.8.0", "proc-macro2", @@ -1389,9 +1517,9 @@ dependencies = [ [[package]] name = "dioxus-interpreter-js" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8ce1cf487007f90d0ec4ec87dff111d74ac04fca0918f9dcc4e80dc3b0531b2" +checksum = "11999d6eb5bb179a9512dad30e5de408aab66f2cb65de9098c9fbe02927e2978" dependencies = [ "dioxus-core", "dioxus-core-types", @@ -1409,9 +1537,9 @@ dependencies = [ [[package]] name = "dioxus-liveview" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9407df2eec82681fa2195282762dddacc40563445df36b3ad1df9d69d4eaa073" +checksum = "7344b8f174967c7d2f6ad0103d680ab57daea83ebe3368f7f011c402fd6aaf77" dependencies = [ "axum", "dioxus-cli-config", @@ -1437,9 +1565,9 @@ dependencies = [ [[package]] name = "dioxus-logger" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4742b16791a71eb4db2d0747f15c50b278b27369b3d93e5a4d6ec2570bcb9bc" +checksum = "a28ccdfe36d2cb830a2784e40f7e6f7199805a2c6da99bd65b1ca308f11aed28" dependencies = [ "dioxus-cli-config", "tracing", @@ -1449,9 +1577,9 @@ dependencies = [ [[package]] name = "dioxus-router" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae50f5efa8d6f936c0c3bb85d7a55f6f19290f106290e331d1136d964e832fe6" +checksum = "38e47f62d680429badfcb99bf5dec17ee92b0cb9623f264e36bc003a1359bfdc" dependencies = [ "dioxus-cli-config", "dioxus-core", @@ -1470,9 +1598,9 @@ dependencies = [ [[package]] name = "dioxus-router-macro" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae9beca02f6baca4b223256805536dc92e77a1541bb2331723100f66aae79332" +checksum = "6f83fb667d27e256f8c9eca49963fbace66a8722cb64ee15a10ffc97d092357e" dependencies = [ "base16", "digest", @@ -1485,9 +1613,9 @@ dependencies = [ [[package]] name = "dioxus-rsx" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "344621f6dc435e76fbe272da09988d0118cf35cc2aa88ebb5ae7c1317a36e57c" +checksum = "2106afda239a4c7c22ffa1ca19117011225fc1c735c139c0a5b765996aa8bb1d" dependencies = [ "proc-macro2", "proc-macro2-diagnostics", @@ -1498,9 +1626,9 @@ dependencies = [ [[package]] name = "dioxus-server" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d04f3e741d9b866f38c20f368fdf84226b27ca341fa0108cf2e0bf6cdb40c7e7" +checksum = "b5ba2095c16f847d3f680a94cc9b0637d190aace651ecfad0feda180da13634b" dependencies = [ "anyhow", "async-trait", @@ -1544,7 +1672,7 @@ dependencies = [ "subsecond", "thiserror 2.0.18", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.28.0", "tokio-util", "tower", "tower-http", @@ -1556,9 +1684,9 @@ dependencies = [ [[package]] name = "dioxus-signals" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "409bf65d243443416650945f22cd6caf2a6bb13ae0347a50ec5852adb1961072" +checksum = "3705754f5e043deec9fc7af0d159f18e5b21c02c47d255c7e477f31368f0b6d2" dependencies = [ "dioxus-core", "futures-channel", @@ -1572,9 +1700,9 @@ dependencies = [ [[package]] name = "dioxus-ssr" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43f16c0c648d1a650be65a16bc24a719519352ab94e6205cceaa300d9c9c5f88" +checksum = "d261c5c9907b84fb1ed52f59f46d68c84a4ae860a65cc5effd0cea740ee428af" dependencies = [ "askama_escape", "dioxus-core", @@ -1584,9 +1712,9 @@ dependencies = [ [[package]] name = "dioxus-stores" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "245ec4f84348e5be77451bd204181998b8bc0995b48ff3adb2db0e0ec430dab4" +checksum = "64bec7b21c86b1360ec965a07a53a2c96b7caee3465049e1c299a45024e87614" dependencies = [ "dioxus-core", "dioxus-signals", @@ -1596,9 +1724,9 @@ dependencies = [ [[package]] name = "dioxus-stores-macro" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd9da8e9a1cc2d8bff387e0b99f09f2590b71f67d5d73ab343b2cc9d17990d92" +checksum = "40a5875e9f890f27b1cc3e5b56c1e23601211470315a1fb8627c4ca4f3b2be9a" dependencies = [ "convert_case 0.8.0", "proc-macro2", @@ -1608,9 +1736,9 @@ dependencies = [ [[package]] name = "dioxus-web" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac92ef863bc5333440021e8ec3e538a39598c9c960daeaab66ab10ba940b5e0" +checksum = "bc0a0be76b404e8242a597db0fb239d05f8dee4e7856bc1fc7144f7e244822fd" dependencies = [ "dioxus-cli-config", "dioxus-core", @@ -1666,7 +1794,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -1903,11 +2031,17 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "emojis" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50c1c1870b766fc398e5f0526498d09c94b6de15be5fd769a28bbc804fb1b05d" +checksum = "afd562179989ec12b5c82f63a9423be4d4f0c5651930837074488ef4d07f2886" dependencies = [ "phf 0.13.1", ] @@ -1964,18 +2098,18 @@ dependencies = [ [[package]] name = "enumset" -version = "1.1.10" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25b07a8dfbbbfc0064c0a6bdf9edcf966de6b1c33ce344bdeca3b41615452634" +checksum = "7f96a4a12fe60ac746ae295a1a4ecb5bb02debc20856506c8635288065f142de" dependencies = [ "enumset_derive", ] [[package]] name = "enumset_derive" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f43e744e4ea338060faee68ed933e46e722fb7f3617e722a5772d7e856d8b3ce" +checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a" dependencies = [ "darling", "proc-macro2", @@ -1983,6 +2117,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -2015,11 +2169,32 @@ dependencies = [ "serde", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" [[package]] name = "fdeflate" @@ -2319,9 +2494,9 @@ dependencies = [ [[package]] name = "generational-box" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ede46ff252793f9b6ef752c506ba8600c69d73cad2ef9bbf2e6dee85019a3bc" +checksum = "8cd0d825b8d339701ad330dbcd6399519ced4d143484954daf6e3185dace4f77" dependencies = [ "parking_lot", "tracing", @@ -2400,6 +2575,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio" version = "0.18.4" @@ -2438,7 +2623,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "futures-channel", "futures-core", "futures-executor", @@ -2565,11 +2750,21 @@ dependencies = [ "system-deps", ] +[[package]] +name = "granit-parser" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7e736dfe3881c53a7dce0685eb18202d0d9fe6911782f9870946eb9ee89d778" +dependencies = [ + "arraydeque", + "smallvec", +] + [[package]] name = "grid" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9e2d4c0a8296178d8802098410ca05d86b17a10bb5ab559b3fb404c1f948220" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" [[package]] name = "gtk" @@ -2625,9 +2820,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" dependencies = [ "atomic-waker", "bytes", @@ -2814,15 +3009,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -3007,14 +3201,54 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40fac9d56ed6437b198fddba683305e8e2d651aa42647f00f5ae542e7f5c94a2" + [[package]] name = "indexmap" version = "2.14.0" @@ -3036,6 +3270,17 @@ dependencies = [ "cfb", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "inventory" version = "0.3.24" @@ -3052,13 +3297,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] -name = "iri-string" -version = "0.7.12" +name = "itertools" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ - "memchr", - "serde", + "either", ] [[package]] @@ -3164,11 +3408,21 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" -version = "0.3.94" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", @@ -3182,18 +3436,33 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "serde", "unicode-segmentation", ] +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" dependencies = [ - "cssparser 0.29.6", + "cssparser", "html5ever", "indexmap", "selectors", @@ -3213,9 +3482,9 @@ dependencies = [ [[package]] name = "lazy-js-bundle" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60d7adc10cb9440d17fa67e467febdfc98931338773d11bfee81809af54d0697" +checksum = "ccafada6c9541db44db758619236f2748f6e1bdaa84d04ded858567cd1e89321" [[package]] name = "lazy_static" @@ -3229,6 +3498,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libappindicator" version = "0.9.0" @@ -3255,9 +3530,19 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.184" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] [[package]] name = "libloading" @@ -3281,9 +3566,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.15" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ "libc", ] @@ -3357,13 +3642,22 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" name = "longest-increasing-subsequence" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3bd0dd2cd90571056fdb71f6275fada10131182f84899f4b2a916e565d81d86" +checksum = "b3bd0dd2cd90571056fdb71f6275fada10131182f84899f4b2a916e565d81d86" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] [[package]] name = "lru" -version = "0.16.3" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" dependencies = [ "hashbrown 0.16.1", ] @@ -3402,9 +3696,9 @@ dependencies = [ [[package]] name = "manganis" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "492da8d77990281eabe6ded633e7b0cf805c5cf7a023a99abed8811edc872d6f" +checksum = "8bfcf56309de35b48b8780ea097ace5c3b773a617b52edc49dfc9a63a7d9dc43" dependencies = [ "const-serialize 0.7.2", "const-serialize 0.8.0-alpha.0", @@ -3418,9 +3712,9 @@ dependencies = [ [[package]] name = "manganis-core" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1b84cc2951f3b119702fab499b9b1aec3f454929c62feca55b895b82c628308" +checksum = "a24d6be68f594495aea60850a284029d585d7b7839b26096c1b6d758f8518648" dependencies = [ "const-serialize 0.7.2", "const-serialize 0.8.0-alpha.0", @@ -3432,9 +3726,9 @@ dependencies = [ [[package]] name = "manganis-macro" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d2e60d36758b201b6ebb8a31aff6b013e58924eeb6d3cbf19aea764f51d69e4" +checksum = "5e782a10318d707c0833e31876ded8acf91287eee0010af8392559af614c7226" dependencies = [ "dunce", "macro-string", @@ -3490,6 +3784,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "memchr" version = "2.8.0" @@ -3582,6 +3886,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.17.2" @@ -3598,7 +3912,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation 0.3.2", "once_cell", - "png", + "png 0.17.16", "thiserror 2.0.18", "windows-sys 0.60.2", ] @@ -3643,7 +3957,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "jni-sys 0.3.1", "log", "ndk-sys", @@ -3673,6 +3987,15 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + [[package]] name = "nodrop" version = "0.1.14" @@ -3685,12 +4008,68 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3763,7 +4142,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -3779,7 +4158,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "block2 0.6.2", "objc2 0.6.4", "objc2-core-foundation", @@ -3792,7 +4171,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -3804,7 +4183,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "dispatch2", "objc2 0.6.4", ] @@ -3815,7 +4194,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "objc2-core-foundation", ] @@ -3852,7 +4231,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -3864,7 +4243,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "block2 0.6.2", "objc2 0.6.4", "objc2-core-foundation", @@ -3876,7 +4255,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -3888,7 +4267,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -3901,7 +4280,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -3913,7 +4292,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "block2 0.6.2", "objc2 0.6.4", "objc2-app-kit 0.3.2", @@ -3938,15 +4317,14 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "openssl" -version = "0.10.76" +version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cfg-if", "foreign-types 0.3.2", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -3970,9 +4348,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.112" +version = "0.9.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" dependencies = [ "cc", "libc", @@ -3988,9 +4366,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "ordermap" -version = "1.1.0" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfa78c92071bbd3628c22b1a964f7e0eb201dc1456555db072beb1662ecd6715" +checksum = "7f7476a5b122ff1fce7208e7ee9dccd0a516e835f5b8b19b8f3c98a34cf757c1" dependencies = [ "indexmap", "serde", @@ -4045,6 +4423,18 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -4128,7 +4518,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" dependencies = [ "phf_shared 0.10.0", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -4138,7 +4528,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -4202,7 +4592,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ - "siphasher 1.0.2", + "siphasher 1.0.3", ] [[package]] @@ -4211,23 +4601,23 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ - "siphasher 1.0.2", + "siphasher 1.0.3", ] [[package]] name = "pin-project" -version = "1.1.11" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.11" +version = "1.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" dependencies = [ "proc-macro2", "quote", @@ -4242,9 +4632,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "png" @@ -4259,6 +4649,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -4364,7 +4767,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.10+spec-1.1.0", + "toml_edit 0.25.11+spec-1.1.0", ] [[package]] @@ -4418,6 +4821,25 @@ dependencies = [ "version_check", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "psl-types" version = "2.0.11" @@ -4434,11 +4856,32 @@ dependencies = [ "psl-types", ] +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" -version = "0.39.2" +version = "0.39.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +checksum = "721da970c312655cde9b4ffe0547f20a8494866a4af5ff51f18b7c633d0c870b" dependencies = [ "memchr", ] @@ -4472,7 +4915,7 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand 0.9.4", "ring", "rustc-hash 2.1.2", "rustls", @@ -4535,9 +4978,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -4546,9 +4989,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -4629,6 +5072,56 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.4", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + [[package]] name = "raw-window-handle" version = "0.5.2" @@ -4641,13 +5134,33 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", ] [[package]] @@ -4681,18 +5194,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - [[package]] name = "regex-automata" version = "0.4.14" @@ -4778,6 +5279,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + [[package]] name = "ring" version = "0.17.14" @@ -4819,7 +5326,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", @@ -4828,9 +5335,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "once_cell", "ring", @@ -4842,9 +5349,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -4852,9 +5359,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", @@ -4882,17 +5389,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "saphyr-parser-bw" -version = "0.0.611" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dec0c833db75dc98957956b303fe447ffc5eb13f2325ef4c2350f7f3aa69e3" -dependencies = [ - "arraydeque", - "smallvec", - "thiserror 2.0.18", -] - [[package]] name = "schannel" version = "0.1.29" @@ -4946,7 +5442,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -4970,7 +5466,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" dependencies = [ "bitflags 1.3.2", - "cssparser 0.29.6", + "cssparser", "derive_more 0.99.20", "fxhash", "log", @@ -4983,9 +5479,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "send_wrapper" @@ -5008,19 +5504,18 @@ dependencies = [ [[package]] name = "serde-saphyr" -version = "0.0.23" +version = "0.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09fbdfe7a27a1b1633dfc0c4c8e65940b8d819c5ddb9cca48ebc3223b00c8b14" +checksum = "dcc7fe48e34d02a97bc8e6253b8b91e5a47fe2c47eaacb5149cefbb69922eaf0" dependencies = [ "ahash", "annotate-snippets", "base64", "encoding_rs_io", "getrandom 0.3.4", + "granit-parser", "nohash-hasher", "num-traits", - "regex", - "saphyr-parser-bw", "serde", "smallvec", "zmij", @@ -5227,6 +5722,15 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -5241,9 +5745,9 @@ checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -5305,7 +5809,7 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "calloop", "calloop-wayland-source", "cursor-icon", @@ -5412,9 +5916,9 @@ dependencies = [ [[package]] name = "subsecond" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dbb9f2928b6654ccc28d4ddfef5213e97ed66afed4907774d049b376c62a838" +checksum = "9cc79674bd55726e6b123204403389400229a95fe4a3b2c5453dada70b06ca95" dependencies = [ "js-sys", "libc", @@ -5431,9 +5935,9 @@ dependencies = [ [[package]] name = "subsecond-types" -version = "0.7.4" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388bb28e6ddbee717745963b8932d9a6e24a5d3c93350655f733e938de04d81f" +checksum = "e9798bfed58797aed51c672aa99810aac30a50d3120ecfdcf28c13784e9a8f1c" dependencies = [ "serde", ] @@ -5492,7 +5996,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -5522,12 +6026,11 @@ dependencies = [ [[package]] name = "taffy" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96fb9d22ffe63c7aa8996275aa0017404b513619bb6cf6309d9f822095afb414" +checksum = "aea22054047c16c3f34d3ac473a2170be1424b1115b2a3adcf28cfb067c88859" dependencies = [ "arrayvec", - "cssparser 0.37.0", "grid", "serde", "slotmap", @@ -5539,7 +6042,7 @@ version = "0.34.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "block2 0.6.2", "core-foundation 0.10.1", "core-graphics 0.25.0", @@ -5662,6 +6165,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.47" @@ -5720,9 +6237,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.50.0" +version = "1.52.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" dependencies = [ "bytes", "libc", @@ -5735,9 +6252,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -5778,6 +6295,18 @@ dependencies = [ "tungstenite 0.28.0", ] +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.29.0", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -5873,14 +6402,14 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.10+spec-1.1.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a82418ca169e235e6c399a84e395ab6debeb3bc90edc959bf0f48647c6a32d1b" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.1", + "winnow 1.0.2", ] [[package]] @@ -5889,7 +6418,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.1", + "winnow 1.0.2", ] [[package]] @@ -5916,11 +6445,11 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "bytes", "futures-core", "futures-util", @@ -5929,7 +6458,6 @@ dependencies = [ "http-body-util", "http-range-header", "httpdate", - "iri-string", "mime", "mime_guess", "percent-encoding", @@ -5939,7 +6467,7 @@ dependencies = [ "tower", "tower-layer", "tower-service", - "tracing", + "url", ] [[package]] @@ -6038,7 +6566,7 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation 0.3.2", "once_cell", - "png", + "png 0.17.16", "thiserror 2.0.18", "windows-sys 0.60.2", ] @@ -6060,7 +6588,7 @@ dependencies = [ "http", "httparse", "log", - "rand 0.9.2", + "rand 0.9.4", "sha1", "thiserror 2.0.18", "utf-8", @@ -6078,13 +6606,29 @@ dependencies = [ "httparse", "log", "native-tls", - "rand 0.9.2", + "rand 0.9.4", "rustls", "sha1", "thiserror 2.0.18", "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "sha1", + "thiserror 2.0.18", +] + [[package]] name = "typed-builder" version = "0.23.2" @@ -6107,9 +6651,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "unicase" @@ -6185,14 +6729,25 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.23.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "js-sys", "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -6266,11 +6821,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -6279,14 +6834,14 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -6297,9 +6852,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.67" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ "js-sys", "wasm-bindgen", @@ -6307,9 +6862,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6317,9 +6872,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -6330,9 +6885,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.117" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -6378,7 +6933,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -6404,7 +6959,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "rustix", "wayland-backend", "wayland-scanner", @@ -6416,7 +6971,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "cursor-icon", "wayland-backend", ] @@ -6438,7 +6993,7 @@ version = "0.32.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "wayland-backend", "wayland-client", "wayland-scanner", @@ -6450,7 +7005,7 @@ version = "20250721.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -6463,7 +7018,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -6476,7 +7031,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.11.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -6508,9 +7063,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.94" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", @@ -6528,9 +7083,9 @@ dependencies = [ [[package]] name = "webbrowser" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe985f41e291eecef5e5c0770a18d28390addb03331c043964d9e916453d6f16" +checksum = "0fc95580916af1e68ff6a7be07446fc5db73ebf71cf092de939bbf5f7e189f72" dependencies = [ "core-foundation 0.10.1", "jni 0.22.4", @@ -6588,9 +7143,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" dependencies = [ "rustls-pki-types", ] @@ -6631,6 +7186,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -7081,9 +7642,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" dependencies = [ "memchr", ] @@ -7097,6 +7658,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -7146,7 +7713,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.0", + "bitflags 2.11.1", "indexmap", "log", "serde", @@ -7302,6 +7869,12 @@ version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yansi" version = "1.0.1" @@ -7416,3 +7989,27 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index fabd348..5f3b321 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,9 +82,9 @@ disposition_taffy_model = { path = "crate/taffy_model", version = "0.1.0" } # external crates base64 = "0.22.1" -dioxus = "0.7.4" +dioxus = "0.7.9" dioxus-clipboard = "0.3.0" -emojis = "0.8.0" +emojis = "0.8.1" encre-css = "0.20.1" enum-iterator = "2.3.0" getrandom = "0.4.2" @@ -94,13 +94,13 @@ indexmap = "2.14.0" kurbo = "0.13.0" linesweeper = "0.3.0" miette = "7.6.0" -ordermap = "1.1.0" +ordermap = "1.2.0" pretty_assertions = "1.4.1" schemars = "1.2.1" serde = "1.0" serde_json = "1.0" -serde-saphyr = "0.0.23" -taffy = "0.10.0" +serde-saphyr = "0.0.26" +taffy = "0.10.1" thiserror = "2.0.18" typed-builder = "0.23.2" unicode-segmentation = "1.13.2" diff --git a/app/playground/src/components/editor/theme_styles_editor/css_class_partials_card.rs b/app/playground/src/components/editor/theme_styles_editor/css_class_partials_card.rs index 7b2196b..e49ffd9 100644 --- a/app/playground/src/components/editor/theme_styles_editor/css_class_partials_card.rs +++ b/app/playground/src/components/editor/theme_styles_editor/css_class_partials_card.rs @@ -263,7 +263,7 @@ pub fn CssClassPartialsCard( } } } else { - // BaseDiagram source — show read-only indicator + // BaseDiagram source -- show read-only indicator div { class: "text-xs text-gray-500 italic", "From disposition's base styles" diff --git a/crate/input_ir_rt/src/edge_id_generator.rs b/crate/input_ir_rt/src/edge_id_generator.rs new file mode 100644 index 0000000..ea63ef0 --- /dev/null +++ b/crate/input_ir_rt/src/edge_id_generator.rs @@ -0,0 +1,17 @@ +use disposition_ir_model::edge::EdgeId; +use disposition_model_common::{edge::EdgeGroupId, Id}; + +/// Generates `EdgeId`s for edges in an edge group. +pub struct EdgeIdGenerator; + +impl EdgeIdGenerator { + /// Generates an `EdgeId` from an edge group ID and edge index. + /// + /// Format: `"{edge_group_id}__{edge_index}"` + pub fn generate<'id>(edge_group_id: &EdgeGroupId<'id>, edge_index: usize) -> EdgeId<'id> { + let edge_id_str = format!("{edge_group_id}__{edge_index}"); + Id::try_from(edge_id_str) + .expect("edge ID should be valid") + .into() + } +} diff --git a/crate/input_ir_rt/src/input_to_ir_diagram_mapper.rs b/crate/input_ir_rt/src/input_to_ir_diagram_mapper.rs index 11db6f9..0b1eb62 100644 --- a/crate/input_ir_rt/src/input_to_ir_diagram_mapper.rs +++ b/crate/input_ir_rt/src/input_to_ir_diagram_mapper.rs @@ -30,11 +30,12 @@ use disposition_model_common::{ use crate::node_ranks_calculator::NodeRanksCalculator; use self::{ - css_theme_vars::CssThemeVars, tailwind_classes_builder::TailwindClassesBuilder, - theme_attr_resolver::ThemeAttrResolver, + css_theme_vars::CssThemeVars, node_nesting_infos_builder::NodeNestingInfosBuilder, + tailwind_classes_builder::TailwindClassesBuilder, theme_attr_resolver::ThemeAttrResolver, }; mod css_theme_vars; +mod node_nesting_infos_builder; mod tailwind_class_state; mod tailwind_classes_builder; mod tailwind_color_shade; @@ -145,9 +146,13 @@ impl InputToIrDiagramMapper { // 12. Build ProcessStepEntities from step_thing_interactions let process_step_entities = Self::build_process_step_entities(processes); - // 13. Compute NodeRanks from dependency edges - let node_ranks = - NodeRanksCalculator::calculate(&node_hierarchy, &edge_groups, &ir_entity_types); + // 13. Compute NodeNestingInfos from node_hierarchy + let node_nesting_infos = NodeNestingInfosBuilder::build(&node_hierarchy); + + // 14. Compute NodeRanksNested from dependency edges, using nesting infos to + // attribute cross-container edges to the correct level + let node_ranks_nested = + NodeRanksCalculator::calculate(&edge_groups, &ir_entity_types, &node_nesting_infos); let diagram = IrDiagram { nodes, @@ -160,7 +165,8 @@ impl InputToIrDiagramMapper { entity_types: ir_entity_types, tailwind_classes, node_layouts, - node_ranks, + node_ranks_nested, + node_nesting_infos, node_shapes, process_step_entities, render_options: *render_options, diff --git a/crate/input_ir_rt/src/input_to_ir_diagram_mapper/node_nesting_infos_builder.rs b/crate/input_ir_rt/src/input_to_ir_diagram_mapper/node_nesting_infos_builder.rs new file mode 100644 index 0000000..c983d8f --- /dev/null +++ b/crate/input_ir_rt/src/input_to_ir_diagram_mapper/node_nesting_infos_builder.rs @@ -0,0 +1,47 @@ +use disposition_ir_model::node::{NodeHierarchy, NodeId, NodeNestingInfo, NodeNestingInfos}; + +/// Builds the [`NodeNestingInfos`] map from a [`NodeHierarchy`]. +/// +/// Walks the hierarchy tree recursively, recording each node's depth +/// (nesting level), index path, and ancestor chain. +pub(crate) struct NodeNestingInfosBuilder; + +impl NodeNestingInfosBuilder { + /// Computes the nesting info for all nodes in the hierarchy. + /// + /// Walks the hierarchy tree recursively, recording each node's depth + /// (nesting level), index path, and ancestor chain. + pub(crate) fn build<'id>(node_hierarchy: &NodeHierarchy<'id>) -> NodeNestingInfos<'id> { + let mut result = NodeNestingInfos::new(); + Self::build_recursive(node_hierarchy, &[], &[], &mut result); + result + } + + /// Recursive helper for building the nesting info map. + fn build_recursive<'id>( + hierarchy: &NodeHierarchy<'id>, + parent_path: &[usize], + parent_ancestor_chain: &[NodeId<'id>], + result: &mut NodeNestingInfos<'id>, + ) { + for (index, (node_id, child_hierarchy)) in hierarchy.iter().enumerate() { + let mut nesting_path = parent_path.to_vec(); + nesting_path.push(index); + + let mut ancestor_chain = parent_ancestor_chain.to_vec(); + ancestor_chain.push(node_id.clone()); + + result.insert( + node_id.clone(), + NodeNestingInfo { + nesting_path: nesting_path.clone(), + ancestor_chain: ancestor_chain.clone(), + }, + ); + + if !child_hierarchy.is_empty() { + Self::build_recursive(child_hierarchy, &nesting_path, &ancestor_chain, result); + } + } + } +} diff --git a/crate/input_ir_rt/src/ir_to_taffy_builder.rs b/crate/input_ir_rt/src/ir_to_taffy_builder.rs index 1575b4b..c997788 100644 --- a/crate/input_ir_rt/src/ir_to_taffy_builder.rs +++ b/crate/input_ir_rt/src/ir_to_taffy_builder.rs @@ -5,7 +5,8 @@ use disposition_ir_model::{ entity::{EntityDescs, EntityType, EntityTypes}, layout::{FlexDirection as ModelFlexDirection, NodeLayout, NodeLayouts}, node::{ - NodeHierarchy, NodeId, NodeInbuilt, NodeNames, NodeRank, NodeRanks, NodeShape, NodeShapes, + NodeHierarchy, NodeId, NodeInbuilt, NodeNames, NodeNestingInfos, NodeRank, NodeRanksNested, + NodeShape, NodeShapes, }, IrDiagram, }; @@ -25,6 +26,7 @@ use taffy::{prelude::TaffyZero, JustifyContent, JustifyItems}; use typed_builder::TypedBuilder; use self::{ + edge_lca_sibling_distance::EdgeLcaSiblingDistance, edge_spacer_builder::EdgeSpacerBuilder, taffy_node_build_context::{NodeMeasureContext, TaffyNodeBuildContext, TaffyWrapperNodeStyles}, text_measure::{ @@ -33,6 +35,7 @@ use self::{ }, }; +mod edge_lca_sibling_distance; mod edge_spacer_builder; mod taffy_node_build_context; mod text_measure; @@ -131,7 +134,8 @@ impl IrToTaffyBuilder<'_> { entity_types, tailwind_classes: _, node_layouts, - node_ranks, + node_ranks_nested, + node_nesting_infos, node_shapes, process_step_entities: _, render_options: _, @@ -151,7 +155,8 @@ impl IrToTaffyBuilder<'_> { node_hierarchy, entity_types, node_shapes, - node_ranks, + node_ranks_nested, + node_nesting_infos, node_id_to_taffy: &mut node_id_to_taffy, taffy_id_to_node: &mut taffy_id_to_node, }; @@ -160,7 +165,6 @@ impl IrToTaffyBuilder<'_> { taffy_node_build_context, processes_included, edge_groups, - node_hierarchy, ); let mut thing_rank_to_taffy_ids = node_rank_to_nodes_by_entity_type .get(&EntityType::ThingDefault) @@ -186,8 +190,8 @@ impl IrToTaffyBuilder<'_> { edge_spacer_taffy_nodes.extend(EdgeSpacerBuilder::build( &mut taffy_tree, edge_groups, - node_hierarchy, - node_ranks, + node_nesting_infos, + node_ranks_nested, entity_types, &EntityType::ThingDefault, &mut thing_rank_to_taffy_ids, @@ -196,8 +200,8 @@ impl IrToTaffyBuilder<'_> { edge_spacer_taffy_nodes.extend(EdgeSpacerBuilder::build( &mut taffy_tree, edge_groups, - node_hierarchy, - node_ranks, + node_nesting_infos, + node_ranks_nested, entity_types, &EntityType::TagDefault, &mut tag_rank_to_taffy_ids, @@ -206,8 +210,8 @@ impl IrToTaffyBuilder<'_> { edge_spacer_taffy_nodes.extend(EdgeSpacerBuilder::build( &mut taffy_tree, edge_groups, - node_hierarchy, - node_ranks, + node_nesting_infos, + node_ranks_nested, entity_types, &EntityType::ProcessDefault, &mut process_rank_to_taffy_ids, @@ -630,7 +634,6 @@ impl IrToTaffyBuilder<'_> { taffy_node_build_context: TaffyNodeBuildContext<'_>, processes_included: &ProcessesIncluded, edge_groups: &EdgeGroups<'static>, - node_hierarchy_full: &NodeHierarchy<'static>, ) -> ( Map, Map, EdgeSpacerTaffyNodes>, @@ -642,7 +645,8 @@ impl IrToTaffyBuilder<'_> { node_hierarchy, entity_types, node_shapes, - node_ranks, + node_ranks_nested, + node_nesting_infos, node_id_to_taffy, taffy_id_to_node, } = taffy_node_build_context; @@ -692,23 +696,22 @@ impl IrToTaffyBuilder<'_> { node_layouts, node_shapes, entity_types, - node_ranks, + node_ranks_nested, + node_nesting_infos, node_id_to_taffy, taffy_id_to_node, child_hierarchy, node_id, entity_type, edge_groups, - node_hierarchy_full, ); edge_spacer_taffy_nodes.extend(nested_edge_spacer_taffy_nodes); wrapper_node_id }; let ir_node_id = NodeId::from(node_id.clone()); - let rank = node_ranks - .get(&ir_node_id) - .copied() + let rank = node_ranks_nested + .node_rank_for(&ir_node_id, node_nesting_infos) .unwrap_or(NodeRank::new(0)); entity_type_to_node_rank_to_taffy_node_ids @@ -736,7 +739,6 @@ impl IrToTaffyBuilder<'_> { fn build_taffy_child_nodes_for_node_by_rank( taffy_node_build_context: TaffyNodeBuildContext<'_>, edge_groups: &EdgeGroups<'static>, - node_hierarchy_full: &NodeHierarchy<'static>, ) -> ( NodeRankToTaffyNodeId, Map, EdgeSpacerTaffyNodes>, @@ -748,7 +750,8 @@ impl IrToTaffyBuilder<'_> { node_hierarchy, entity_types, node_shapes, - node_ranks, + node_ranks_nested, + node_nesting_infos, node_id_to_taffy, taffy_id_to_node, } = taffy_node_build_context; @@ -785,23 +788,22 @@ impl IrToTaffyBuilder<'_> { node_layouts, node_shapes, entity_types, - node_ranks, + node_ranks_nested, + node_nesting_infos, node_id_to_taffy, taffy_id_to_node, child_hierarchy, node_id, entity_type, edge_groups, - node_hierarchy_full, ); edge_spacer_taffy_nodes.extend(nested_edge_spacer_taffy_nodes); wrapper_node_id }; let ir_node_id = NodeId::from(node_id.clone()); - let rank = node_ranks - .get(&ir_node_id) - .copied() + let rank = node_ranks_nested + .node_rank_for(&ir_node_id, node_nesting_infos) .unwrap_or(NodeRank::new(0)); rank_to_taffy_ids @@ -928,14 +930,14 @@ impl IrToTaffyBuilder<'_> { node_layouts: &NodeLayouts<'static>, node_shapes: &NodeShapes<'static>, entity_types: &EntityTypes<'static>, - node_ranks: &NodeRanks<'static>, + node_ranks_nested: &NodeRanksNested<'static>, + node_nesting_infos: &NodeNestingInfos<'static>, node_id_to_taffy: &mut Map, NodeToTaffyNodeIds>, taffy_id_to_node: &mut Map>, child_hierarchy: &NodeHierarchy<'static>, node_id: &Id<'static>, entity_type: &EntityType, edge_groups: &EdgeGroups<'static>, - node_hierarchy_full: &NodeHierarchy<'static>, ) -> (taffy::NodeId, Map, EdgeSpacerTaffyNodes>) { let ir_node_id = NodeId::from(node_id.clone()); let mut edge_spacer_taffy_nodes: Map, EdgeSpacerTaffyNodes> = Map::new(); @@ -963,16 +965,13 @@ impl IrToTaffyBuilder<'_> { node_hierarchy: child_hierarchy, entity_types, node_shapes, - node_ranks, + node_ranks_nested, + node_nesting_infos, node_id_to_taffy, taffy_id_to_node, }; let (mut rank_to_taffy_ids, nested_edge_spacer_taffy_nodes) = - Self::build_taffy_child_nodes_for_node_by_rank( - taffy_node_build_context, - edge_groups, - node_hierarchy_full, - ); + Self::build_taffy_child_nodes_for_node_by_rank(taffy_node_build_context, edge_groups); edge_spacer_taffy_nodes.extend(nested_edge_spacer_taffy_nodes); // === Insert spacer nodes for edges nested within this node === // @@ -985,8 +984,8 @@ impl IrToTaffyBuilder<'_> { edge_spacer_taffy_nodes.extend(EdgeSpacerBuilder::build( taffy_tree, edge_groups, - node_hierarchy_full, - node_ranks, + node_nesting_infos, + node_ranks_nested, entity_types, target_entity_type, &mut rank_to_taffy_ids, @@ -1003,11 +1002,11 @@ impl IrToTaffyBuilder<'_> { edge_spacer_taffy_nodes.extend(EdgeSpacerBuilder::build_cross_container_spacers( taffy_tree, edge_groups, - node_hierarchy_full, - node_ranks, + node_nesting_infos, + node_ranks_nested, + &mut rank_to_taffy_ids, &ir_node_id, child_hierarchy, - &mut rank_to_taffy_ids, )); // === Build Rank-Based Child Containers === // diff --git a/crate/input_ir_rt/src/ir_to_taffy_builder/edge_lca_sibling_distance.rs b/crate/input_ir_rt/src/ir_to_taffy_builder/edge_lca_sibling_distance.rs new file mode 100644 index 0000000..88214b0 --- /dev/null +++ b/crate/input_ir_rt/src/ir_to_taffy_builder/edge_lca_sibling_distance.rs @@ -0,0 +1,39 @@ +/// Information about the distance between two nodes' lowest common ancestor. +/// +/// Technically this is the sibling distance of the first divergent children of +/// the LCA. +/// +/// # Examples +/// +/// ```yaml +/// # node hierarchy +/// outer: +/// a: { a0: { a1: {} } } +/// b: {} +/// c: { c0: {} } +/// ``` +/// +/// In this example, when comparing `a1` and `c0`: +/// +/// 1. The LCA is `outer`. +/// 2. The `NodeNestingInfo::ancestor_chain` for `a1` is `[outer, a, a0, a1]`. +/// 3. The `NodeNestingInfo::ancestor_chain` for `c0` is `[outer, c, c0]`. +/// 4. The LCA depth is `1` (shared `outer`). +/// 5. At index `1`, the sibling nodes to compare are `a` and `c` (index 1 of +/// both ancestor chains). +/// 6. The sibling distance between `a` and `c` is `2`, which is the LCA sibling +/// distance between `a1` and `c0`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EdgeLcaSiblingDistance { + /// The node hierarchy depth of the lowest common ancestor (LCA). + /// + /// # Examples + /// + /// * `[a, a01]` and `[c, c01]` -> LCA depth `0` (diverge immediately). + /// * `[outer, a, a01]` and `[outer, b]` -> LCA depth `1` (share `outer`). + /// * `[outer, inner, x]` and `[outer, inner, y]` -> LCA depth `2` (share + /// `outer` and `inner`). + pub lca_depth: usize, + /// The distance between the LCA and the sibling edge. + pub distance: usize, +} diff --git a/crate/input_ir_rt/src/ir_to_taffy_builder/edge_spacer_builder.rs b/crate/input_ir_rt/src/ir_to_taffy_builder/edge_spacer_builder.rs index 4627213..50eb984 100644 --- a/crate/input_ir_rt/src/ir_to_taffy_builder/edge_spacer_builder.rs +++ b/crate/input_ir_rt/src/ir_to_taffy_builder/edge_spacer_builder.rs @@ -1,17 +1,33 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use disposition_ir_model::{ edge::{Edge, EdgeGroups, EdgeId}, entity::{EntityType, EntityTypes}, - node::{NodeHierarchy, NodeId, NodeRank, NodeRanks}, + node::{NodeHierarchy, NodeId, NodeNestingInfo, NodeNestingInfos, NodeRank, NodeRanksNested}, }; -use disposition_model_common::{edge::EdgeGroupId, Id, Map}; +use disposition_model_common::{edge::EdgeGroupId, Map}; use disposition_taffy_model::{ taffy::{self, Size, Style, TaffyTree}, EdgeSpacerCtx, EdgeSpacerTaffyNodes, TaffyNodeCtx, }; use taffy::AlignSelf; +use crate::EdgeIdGenerator; + +pub use self::{ + edge_spacer_build_decider::EdgeSpacerBuildDecider, + edge_spacer_build_decision::EdgeSpacerBuildDecision, + edge_spacer_build_decision_build::EdgeSpacerBuildDecisionBuild, + edge_spacer_build_decision_skip::EdgeSpacerBuildDecisionSkip, + lca_depth_calculator::LcaDepthCalculator, +}; + +mod edge_spacer_build_decider; +mod edge_spacer_build_decision; +mod edge_spacer_build_decision_build; +mod edge_spacer_build_decision_skip; +mod lca_depth_calculator; + const EDGE_SPACER_LENGTH: f32 = 5.0; /// Builds spacer taffy nodes for edges that cross multiple ranks. @@ -23,33 +39,6 @@ const EDGE_SPACER_LENGTH: f32 = 5.0; /// being drawn over other nodes. pub(crate) struct EdgeSpacerBuilder; -// === Supporting types === // - -/// Information about a node's position in the hierarchy. -/// -/// # Examples -/// -/// A node `b` nested inside `a` at position 0 would have: -/// -/// ```text -/// NodeNestingInfo { -/// nesting_path: [0, 0], -/// ancestor_chain: [NodeId("a"), NodeId("b")], -/// } -/// ``` -#[derive(Clone, Debug)] -struct NodeNestingInfo { - /// Sequence of sibling indices at each level from root to this node. - /// - /// For example, `[2, 0]` means "third top-level node, first child". - nesting_path: Vec, - /// Sequence of `NodeId`s from root to this node (inclusive). - /// - /// For example, for node `c01` inside `c0`, this would be - /// `[NodeId("c0"), NodeId("c01")]`. - ancestor_chain: Vec>, -} - // === EdgeSpacerBuilder === // impl EdgeSpacerBuilder { @@ -63,16 +52,13 @@ impl EdgeSpacerBuilder { pub(crate) fn build( taffy_tree: &mut TaffyTree, edge_groups: &EdgeGroups<'static>, - node_hierarchy: &NodeHierarchy<'static>, - node_ranks: &NodeRanks<'static>, + node_nesting_infos: &NodeNestingInfos<'static>, + node_ranks_nested: &NodeRanksNested<'static>, entity_types: &EntityTypes<'static>, target_entity_type: &EntityType, rank_to_taffy_ids: &mut BTreeMap>, lca_node_id: Option<&NodeId<'static>>, ) -> Map, EdgeSpacerTaffyNodes> { - // === Compute nesting info for all nodes === // - let node_nesting_info_map = Self::node_nesting_info_map_build(node_hierarchy); - // === Find cross-rank edges and compute spacer placements === // let mut edge_spacer_taffy_nodes: Map, EdgeSpacerTaffyNodes> = Map::new(); @@ -80,28 +66,31 @@ impl EdgeSpacerBuilder { // so subsequent insertions account for prior spacers. let mut rank_spacer_counts: BTreeMap> = BTreeMap::new(); - for (edge_group_id, edge_group) in edge_groups.iter() { - for (edge_index, edge) in edge_group.iter().enumerate() { - let edge_id = Self::edge_id_generate(edge_group_id, edge_index); - - let spacer_nodes = Self::edge_spacers_build( - taffy_tree, - edge, - &edge_id, - &node_nesting_info_map, - node_ranks, - entity_types, - target_entity_type, - rank_to_taffy_ids, - &mut rank_spacer_counts, - lca_node_id, - ); - - if let Some(spacer_nodes) = spacer_nodes { - edge_spacer_taffy_nodes.insert(edge_id, spacer_nodes); - } - } - } + edge_groups.iter().for_each(|(edge_group_id, edge_group)| { + edge_group + .iter() + .enumerate() + .for_each(|(edge_index, edge)| { + let edge_id = EdgeIdGenerator::generate(edge_group_id, edge_index); + + let spacer_nodes = Self::edge_spacers_build( + taffy_tree, + edge, + &edge_id, + node_nesting_infos, + node_ranks_nested, + entity_types, + target_entity_type, + rank_to_taffy_ids, + &mut rank_spacer_counts, + lca_node_id, + ); + + if let Some(spacer_nodes) = spacer_nodes { + edge_spacer_taffy_nodes.insert(edge_id, spacer_nodes); + } + }); + }); edge_spacer_taffy_nodes } @@ -119,31 +108,30 @@ impl EdgeSpacerBuilder { /// /// * `taffy_tree`: The taffy tree to insert spacer nodes into. /// * `edge_groups`: All edge groups in the diagram. - /// * `node_hierarchy`: The full node hierarchy. + /// * `node_nesting_infos`: The precomputed nesting info map for all nodes. /// * `node_ranks`: Node ranks for all nodes. - /// * `container_node_id`: The ID of the container node being built. - /// * `child_hierarchy`: The children of `container_node_id`. + /// * `container_node_id`: The ID of the node which is a parent of the node + /// that the edge is connected to. + /// * `container_node_hierarchy`: The children of `container_node_id`. /// * `rank_to_taffy_ids`: Mutable reference to the container's /// rank-to-taffy-node mapping, for inserting spacer nodes. #[allow(clippy::too_many_arguments)] pub(crate) fn build_cross_container_spacers( taffy_tree: &mut TaffyTree, edge_groups: &EdgeGroups<'static>, - node_hierarchy: &NodeHierarchy<'static>, - node_ranks: &NodeRanks<'static>, - container_node_id: &NodeId<'static>, - child_hierarchy: &NodeHierarchy<'static>, + node_nesting_infos: &NodeNestingInfos<'static>, + node_ranks_nested: &NodeRanksNested<'static>, rank_to_taffy_ids: &mut BTreeMap>, + container_node_id: &NodeId<'static>, + container_node_hierarchy: &NodeHierarchy<'static>, ) -> Map, EdgeSpacerTaffyNodes> { - let node_nesting_info_map = Self::node_nesting_info_map_build(node_hierarchy); - // Collect direct child IDs of this container. - let direct_child_ids: Vec> = child_hierarchy + let container_node_direct_child_ids: Vec> = container_node_hierarchy .iter() .map(|(child_id, _)| child_id.clone()) .collect(); - if direct_child_ids.len() <= 1 { + if container_node_direct_child_ids.len() <= 1 { // No siblings to route around. return Map::new(); } @@ -151,138 +139,175 @@ impl EdgeSpacerBuilder { let mut edge_spacer_taffy_nodes: Map, EdgeSpacerTaffyNodes> = Map::new(); let mut rank_spacer_counts: BTreeMap> = BTreeMap::new(); - for (edge_group_id, edge_group) in edge_groups.iter() { - for (edge_index, edge) in edge_group.iter().enumerate() { - let edge_id = Self::edge_id_generate(edge_group_id, edge_index); - - let Some(nesting_info_from) = node_nesting_info_map.get(&edge.from) else { - continue; - }; - let Some(nesting_info_to) = node_nesting_info_map.get(&edge.to) else { - continue; - }; - - // === LCA sibling distance guard === // - // - // Only insert cross-container spacers when the edge's - // from-node and to-node diverge at the LCA level with - // at least one intermediate sibling between them. - // A sibling distance of 1 means the two divergent - // ancestors are adjacent, so the edge does not cross - // over any other node. - let lca_sibling_distance = - Self::lca_sibling_distance(nesting_info_from, nesting_info_to); - if lca_sibling_distance < 2 { - continue; - } + edge_groups.iter().for_each(|(edge_group_id, edge_group)| { + edge_group + .iter() + .enumerate() + .for_each(|(edge_index, edge)| { + Self::build_cross_container_spacers_for_edge( + taffy_tree, + edge_group_id, + node_nesting_infos, + node_ranks_nested, + rank_to_taffy_ids, + container_node_id, + &container_node_direct_child_ids, + &mut edge_spacer_taffy_nodes, + &mut rank_spacer_counts, + edge_index, + edge, + ) + }); + }); - // Determine if exactly one endpoint is inside this container - // and the other is outside. - let from_inside = nesting_info_from.ancestor_chain.contains(container_node_id); - let to_inside = nesting_info_to.ancestor_chain.contains(container_node_id); - - // We want edges where one is inside and one is outside. - // Also skip if the container itself is one of the endpoints - // (ancestor_chain includes self, so check that the inside - // endpoint is not the container itself). - if from_inside == to_inside { - continue; - } + edge_spacer_taffy_nodes + } - // Determine which endpoint is inside and which is outside. - let inside_nesting_info = if from_inside { - nesting_info_from - } else { - nesting_info_to - }; - - // Find which direct child of this container is the ancestor - // of the inside endpoint. The ancestor chain includes the - // inside endpoint itself, so we look for the container in the - // chain and take the next element. - let container_depth_in_chain = inside_nesting_info - .ancestor_chain - .iter() - .position(|id| id == container_node_id); - let Some(container_depth) = container_depth_in_chain else { - continue; - }; - let target_child_id = inside_nesting_info.ancestor_chain.get(container_depth + 1); - let Some(target_child_id) = target_child_id else { - // The inside endpoint IS the container node — skip. - continue; - }; - - // Find the index of the target child among the direct - // children. - let target_child_index = - direct_child_ids.iter().position(|id| id == target_child_id); - let Some(_target_child_index) = target_child_index else { - continue; - }; - - // Insert spacers alongside each sibling of the target child. - let spacer_style = Style { - min_size: Size { - width: taffy::Dimension::length(EDGE_SPACER_LENGTH), - height: taffy::Dimension::length(EDGE_SPACER_LENGTH), - }, - align_self: Some(AlignSelf::Stretch), - ..Default::default() - }; - - let mut spacer_taffy_nodes = EdgeSpacerTaffyNodes::new(); - - for sibling_id in &direct_child_ids { - if sibling_id == target_child_id { - continue; - } + /// Builds cross-container spacers for a single edge. + /// + /// # Parameters + /// + /// * `taffy_tree`: The taffy tree to insert spacer nodes into. + /// * `edge_groups`: All edge groups in the diagram. + /// * `node_nesting_infos`: The precomputed nesting info map for all nodes. + /// * `node_ranks`: Node ranks for all nodes. + /// * `rank_to_taffy_ids`: Mutable reference to the container's + /// rank-to-taffy-node mapping, for inserting spacer nodes. + /// * `container_node_id`: The ID of the node which is a parent of the node + /// that the edge is connected to. + /// * `container_node_direct_child_ids`: The children of + /// `container_node_id`. + /// * `edge_spacer_taffy_nodes`: Map to keep track of the spacer taffy nodes + /// inserted for each edge. + /// * `rank_spacer_counts`: Map to keep track of the number of spacers + /// inserted for each rank within an edge group. + /// * `edge_index`: Index of the edge within its edge group. + /// * `edge`: Edge to build spacers for. + #[allow(clippy::too_many_arguments)] + fn build_cross_container_spacers_for_edge<'id>( + taffy_tree: &mut TaffyTree, + edge_group_id: &EdgeGroupId<'static>, + node_nesting_infos: &NodeNestingInfos<'static>, + node_ranks_nested: &NodeRanksNested<'static>, + rank_to_taffy_ids: &mut BTreeMap>, + container_node_id: &NodeId<'static>, + container_node_direct_child_ids: &Vec>, + edge_spacer_taffy_nodes: &mut Map, EdgeSpacerTaffyNodes>, + rank_spacer_counts: &mut BTreeMap>, + edge_index: usize, + edge: &Edge<'id>, + ) { + let edge_spacer_build_decision = EdgeSpacerBuildDecider::decide( + node_nesting_infos, + container_node_id, + container_node_direct_child_ids, + edge, + ); + let node_id_of_container_direct_child_that_contains_edge = match edge_spacer_build_decision + { + EdgeSpacerBuildDecision::Skip(_edge_spacer_build_decision_skip) => return, + EdgeSpacerBuildDecision::Build(EdgeSpacerBuildDecisionBuild { target_child_id }) => { + target_child_id + } + }; - let sibling_rank = node_ranks - .get(sibling_id) - .copied() - .unwrap_or(NodeRank::new(0)); - - let spacer_taffy_node_id = taffy_tree - .new_leaf_with_context( - spacer_style.clone(), - TaffyNodeCtx::EdgeSpacer(EdgeSpacerCtx { - edge_id: edge_id.clone(), - rank: sibling_rank, - }), - ) - .expect("Expected to create cross-container spacer leaf node."); - - // Insert into rank_to_taffy_ids at the sibling's rank. - let taffy_ids = rank_to_taffy_ids.entry(sibling_rank).or_default(); - let spacer_counts = rank_spacer_counts.entry(sibling_rank).or_default(); - - if spacer_counts.len() < taffy_ids.len() + 1 { - spacer_counts.resize(taffy_ids.len() + 1, 0); - } + // Insert spacers alongside each sibling of the target child. + let spacer_style = Style { + min_size: Size { + width: taffy::Dimension::length(EDGE_SPACER_LENGTH), + height: taffy::Dimension::length(EDGE_SPACER_LENGTH), + }, + align_self: Some(AlignSelf::Stretch), + ..Default::default() + }; + + let edge_id = EdgeIdGenerator::generate(edge_group_id, edge_index); + + let mut spacer_taffy_nodes = EdgeSpacerTaffyNodes::new(); + + // Only create spacers for siblings that rank strictly below the target + // child, i.e. the siblings that are between the container's entry point + // and the target in the layout order. Siblings at the same rank as the + // target are placed side-by-side and do not block the incoming edge; + // siblings at higher ranks (further into the container) are beyond the + // target and also do not need to be routed around. + let target_rank = node_ranks_nested + .ranks_for(Some(container_node_id)) + .and_then(|r| { + r.get(node_id_of_container_direct_child_that_contains_edge) + .copied() + }) + .unwrap_or(NodeRank::new(0)); - // Place the spacer at the end of the rank's children. - taffy_ids.push(spacer_taffy_node_id); + // Track which ranks have already been assigned a spacer for this + // edge. Multiple siblings at the same rank occupy the same layout + // row, so one spacer is sufficient to route around the entire row. + let mut ranks_with_spacers: BTreeSet = BTreeSet::new(); - spacer_taffy_nodes - .cross_container_spacer_taffy_node_ids - .push(spacer_taffy_node_id); + container_node_direct_child_ids + .iter() + .for_each(|sibling_id| { + if sibling_id == node_id_of_container_direct_child_that_contains_edge { + return; } - if !spacer_taffy_nodes - .cross_container_spacer_taffy_node_ids - .is_empty() - { - edge_spacer_taffy_nodes - .entry(edge_id) - .or_default() - .cross_container_spacer_taffy_node_ids - .extend(spacer_taffy_nodes.cross_container_spacer_taffy_node_ids); + // Only insert spacers for siblings at ranks that are strictly + // before the target rank, i.e. between the container entry point + // and the target. Siblings at the same or higher rank are not + // blocking the edge path. + let sibling_rank = node_ranks_nested + .ranks_for(Some(container_node_id)) + .and_then(|r| r.get(sibling_id).copied()) + .unwrap_or(NodeRank::new(0)); + + if sibling_rank >= target_rank { + return; } - } - } - edge_spacer_taffy_nodes + // Only create one spacer per rank group -- multiple siblings + // at the same rank are in the same layout row, so a single + // spacer is sufficient for routing around the entire row. + if !ranks_with_spacers.insert(sibling_rank) { + return; + } + + // Create the taffy spacer node. + let spacer_taffy_node_id = taffy_tree + .new_leaf_with_context( + spacer_style.clone(), + TaffyNodeCtx::EdgeSpacer(EdgeSpacerCtx { + edge_id: edge_id.clone(), + rank: sibling_rank, + }), + ) + .expect("Expected to create cross-container spacer leaf node."); + + // Insert into rank_to_taffy_ids at the sibling's rank. + let taffy_ids = rank_to_taffy_ids.entry(sibling_rank).or_default(); + let spacer_counts = rank_spacer_counts.entry(sibling_rank).or_default(); + + if spacer_counts.len() < taffy_ids.len() + 1 { + spacer_counts.resize(taffy_ids.len() + 1, 0); + } + + // Place the spacer at the end of the rank's children. + taffy_ids.push(spacer_taffy_node_id); + + spacer_taffy_nodes + .cross_container_spacer_taffy_node_ids + .push(spacer_taffy_node_id); + }); + + if !spacer_taffy_nodes + .cross_container_spacer_taffy_node_ids + .is_empty() + { + edge_spacer_taffy_nodes + .entry(edge_id) + .or_default() + .cross_container_spacer_taffy_node_ids + .extend(spacer_taffy_nodes.cross_container_spacer_taffy_node_ids); + } } /// Builds spacer taffy nodes for a single edge if it crosses ranks. @@ -303,20 +328,20 @@ impl EdgeSpacerBuilder { taffy_tree: &mut TaffyTree, edge: &Edge<'static>, edge_id: &EdgeId<'static>, - node_nesting_info_map: &Map, NodeNestingInfo>, - node_ranks: &NodeRanks<'static>, + node_nesting_infos: &NodeNestingInfos<'static>, + node_ranks_nested: &NodeRanksNested<'static>, entity_types: &EntityTypes<'static>, target_entity_type: &EntityType, rank_to_taffy_ids: &mut BTreeMap>, rank_spacer_counts: &mut BTreeMap>, lca_node_id: Option<&NodeId<'static>>, ) -> Option { - let nesting_info_from = node_nesting_info_map.get(&edge.from)?; - let nesting_info_to = node_nesting_info_map.get(&edge.to)?; + let nesting_info_from = node_nesting_infos.get(&edge.from)?; + let nesting_info_to = node_nesting_infos.get(&edge.to)?; // === Check that the edge's top-level ancestors match the target entity type // === // - let lca_depth = Self::lca_depth(nesting_info_from, nesting_info_to); + let lca_depth = LcaDepthCalculator::calculate(nesting_info_from, nesting_info_to); let divergent_from = nesting_info_from.ancestor_chain.get(lca_depth)?; let divergent_to = nesting_info_to.ancestor_chain.get(lca_depth)?; @@ -334,7 +359,7 @@ impl EdgeSpacerBuilder { // === Find divergent ancestors and their ranks === // let (rank_low, rank_high) = - Self::divergent_ancestor_ranks(nesting_info_from, nesting_info_to, node_ranks)?; + Self::divergent_ancestor_ranks(nesting_info_from, nesting_info_to, node_ranks_nested)?; // Only insert spacers for edges crossing ranks. if rank_low == rank_high { @@ -383,8 +408,10 @@ impl EdgeSpacerBuilder { let mut spacer_taffy_nodes = EdgeSpacerTaffyNodes::new(); - // Insert spacers at each intermediate rank (exclusive of endpoints). - for rank_value in (rank_low.value() + 1)..rank_high.value() { + // Insert spacers at each intermediate rank exclusive of endpoints (low and high + // rank both exclusive). + let rank_low_plus_one = rank_low.value() + 1; + (rank_low_plus_one..rank_high.value()).for_each(|rank_value| { let rank = NodeRank::new(rank_value); let spacer_taffy_node_id = taffy_tree @@ -429,87 +456,13 @@ impl EdgeSpacerBuilder { spacer_taffy_nodes .rank_to_spacer_taffy_node_id .insert(rank, spacer_taffy_node_id); - } + }); Some(spacer_taffy_nodes) } // === Ancestor chain and LCA === // - /// Returns the depth of the lowest common ancestor (LCA) of two nodes. - /// - /// The LCA depth is the length of the longest common prefix of the two - /// nodes' `ancestor_chain`s. A depth of `0` means they diverge at the - /// top level (no shared ancestor within the hierarchy). - /// - /// # Examples - /// - /// * `[a, a01]` and `[c, c01]` -> LCA depth `0` (diverge immediately). - /// * `[outer, a, a01]` and `[outer, b, b01]` -> LCA depth `1` (share - /// `outer`). - /// * `[outer, inner, x]` and `[outer, inner, y]` -> LCA depth `2` (share - /// `outer` and `inner`). - fn lca_depth(info_from: &NodeNestingInfo, info_to: &NodeNestingInfo) -> usize { - let max_compare = info_from - .ancestor_chain - .len() - .min(info_to.ancestor_chain.len()); - let mut depth = 0; - for i in 0..max_compare { - if info_from.ancestor_chain[i] == info_to.ancestor_chain[i] { - depth = i + 1; - } else { - break; - } - } - depth - } - - /// Returns the sibling distance between the divergent ancestors of - /// two nodes at their lowest common ancestor (LCA) level. - /// - /// The sibling distance is the absolute difference of the sibling - /// indices of the two nodes' divergent ancestors -- i.e. the first - /// nodes in each ancestor chain where the chains differ. - /// - /// A distance of 0 means both nodes share the same divergent - /// ancestor (or one is an ancestor of the other). - /// A distance of 1 means the divergent ancestors are adjacent - /// siblings -- no intermediate node lies between them. - /// A distance of 2 or more means at least one sibling node sits - /// between the two divergent ancestors, so an edge connecting the - /// two nodes would visually cross over that intermediate sibling. - /// - /// # Examples - /// - /// Given hierarchy: - /// - /// ```text - /// outer: - /// A: { A_child: { A_grandchild: {} } } - /// B: { B_child: {} } - /// C: { C_child: {} } - /// ``` - /// - /// * `A_grandchild` and `B_child` -> LCA is `outer`, divergent ancestors - /// are `A` (index 0) and `B` (index 1), distance = 1. - /// * `A_grandchild` and `C_child` -> LCA is `outer`, divergent ancestors - /// are `A` (index 0) and `C` (index 2), distance = 2. - fn lca_sibling_distance(info_from: &NodeNestingInfo, info_to: &NodeNestingInfo) -> usize { - let lca_depth = Self::lca_depth(info_from, info_to); - - // Get the sibling index at the divergence depth for each node. - let index_from = info_from.nesting_path.get(lca_depth).copied(); - let index_to = info_to.nesting_path.get(lca_depth).copied(); - - match (index_from, index_to) { - (Some(a), Some(b)) => a.abs_diff(b), - // One chain is a prefix of the other (one node is an - // ancestor of the other) -- no divergent siblings. - _ => 0, - } - } - /// Finds the ranks of the "divergent ancestors" for an edge's two /// endpoints. /// @@ -536,23 +489,24 @@ impl EdgeSpacerBuilder { /// ancestor (one chain is a prefix of the other), since no /// cross-rank spacer is meaningful in that case. fn divergent_ancestor_ranks( - info_from: &NodeNestingInfo, - info_to: &NodeNestingInfo, - node_ranks: &NodeRanks<'static>, + info_from: &NodeNestingInfo<'_>, + info_to: &NodeNestingInfo<'_>, + node_ranks_nested: &NodeRanksNested<'static>, ) -> Option<(NodeRank, NodeRank)> { - let lca_depth = Self::lca_depth(info_from, info_to); - - // The divergent ancestor for each endpoint is the node at - // `ancestor_chain[lca_depth]` -- the first node after the shared - // prefix. + let lca_depth = LcaDepthCalculator::calculate(info_from, info_to); let divergent_from = info_from.ancestor_chain.get(lca_depth)?; let divergent_to = info_to.ancestor_chain.get(lca_depth)?; - let rank_from = node_ranks + let lca_container = lca_depth + .checked_sub(1) + .map(|i| &info_from.ancestor_chain[i]); + let container_ranks = node_ranks_nested.ranks_for(lca_container)?; + + let rank_from = container_ranks .get(divergent_from) .copied() .unwrap_or(NodeRank::new(0)); - let rank_to = node_ranks + let rank_to = container_ranks .get(divergent_to) .copied() .unwrap_or(NodeRank::new(0)); @@ -562,57 +516,9 @@ impl EdgeSpacerBuilder { } else { (rank_to, rank_from) }; - Some((rank_low, rank_high)) } - // === Nesting info === // - - /// Computes the nesting info for all nodes in the hierarchy. - /// - /// Walks the hierarchy tree recursively, recording each node's depth - /// (nesting level), index path, and ancestor chain. - fn node_nesting_info_map_build( - node_hierarchy: &NodeHierarchy<'static>, - ) -> Map, NodeNestingInfo> { - let mut result = Map::new(); - Self::node_nesting_info_map_build_recursive(node_hierarchy, &[], &[], &mut result); - result - } - - /// Recursive helper for building the nesting info map. - fn node_nesting_info_map_build_recursive( - hierarchy: &NodeHierarchy<'static>, - parent_path: &[usize], - parent_ancestor_chain: &[NodeId<'static>], - result: &mut Map, NodeNestingInfo>, - ) { - for (index, (node_id, child_hierarchy)) in hierarchy.iter().enumerate() { - let mut nesting_path = parent_path.to_vec(); - nesting_path.push(index); - - let mut ancestor_chain = parent_ancestor_chain.to_vec(); - ancestor_chain.push(node_id.clone()); - - result.insert( - node_id.clone(), - NodeNestingInfo { - nesting_path: nesting_path.clone(), - ancestor_chain: ancestor_chain.clone(), - }, - ); - - if !child_hierarchy.is_empty() { - Self::node_nesting_info_map_build_recursive( - child_hierarchy, - &nesting_path, - &ancestor_chain, - result, - ); - } - } - } - // === Insertion index computation === // /// Computes the base insertion index from the nesting info of two nodes. @@ -626,10 +532,10 @@ impl EdgeSpacerBuilder { /// the chains differ, ensuring the spacer is placed between the /// correct subtrees. fn insertion_base_index_compute( - nesting_info_from: &NodeNestingInfo, - nesting_info_to: &NodeNestingInfo, + nesting_info_from: &NodeNestingInfo<'_>, + nesting_info_to: &NodeNestingInfo<'_>, ) -> usize { - let lca_depth = Self::lca_depth(nesting_info_from, nesting_info_to); + let lca_depth = LcaDepthCalculator::calculate(nesting_info_from, nesting_info_to); // Get the sibling index at the divergence depth for each node. // This is the position of each node's subtree among the children @@ -670,19 +576,4 @@ impl EdgeSpacerBuilder { let effective = base_index + spacers_at_or_before; effective.min(current_len) } - - // === Edge ID generation === // - - /// Generates an `EdgeId` from an edge group ID and edge index. - /// - /// Format: `"{edge_group_id}__{edge_index}"` - fn edge_id_generate( - edge_group_id: &EdgeGroupId<'static>, - edge_index: usize, - ) -> EdgeId<'static> { - let edge_id_str = format!("{edge_group_id}__{edge_index}"); - Id::try_from(edge_id_str) - .expect("edge ID should be valid") - .into() - } } diff --git a/crate/input_ir_rt/src/ir_to_taffy_builder/edge_spacer_builder/edge_spacer_build_decider.rs b/crate/input_ir_rt/src/ir_to_taffy_builder/edge_spacer_builder/edge_spacer_build_decider.rs new file mode 100644 index 0000000..8f3199f --- /dev/null +++ b/crate/input_ir_rt/src/ir_to_taffy_builder/edge_spacer_builder/edge_spacer_build_decider.rs @@ -0,0 +1,229 @@ +use disposition_ir_model::{ + edge::Edge, + node::{NodeId, NodeNestingInfo, NodeNestingInfos}, +}; + +use crate::ir_to_taffy_builder::{ + edge_spacer_builder::{ + EdgeSpacerBuildDecision, EdgeSpacerBuildDecisionBuild, EdgeSpacerBuildDecisionSkip, + LcaDepthCalculator, + }, + EdgeLcaSiblingDistance, +}; + +/// Decides whether edge spacers should be built for the given edge. +pub struct EdgeSpacerBuildDecider; + +impl EdgeSpacerBuildDecider { + /// Returns whether edge spacers should be built for the given edge. + /// + /// # Parameters + /// + /// * `node_nesting_infos`: The precomputed nesting info map for all nodes. + /// * `container_node_id`: The ID of the node which is a parent of the node + /// that the edge is connected to. + /// * `container_node_direct_child_ids`: The children of + /// `container_node_id`. + /// * `edge`: Edge to build spacers for. + pub fn decide<'f, 'id>( + node_nesting_infos: &'f NodeNestingInfos<'id>, + container_node_id: &NodeId<'id>, + container_node_direct_child_ids: &Vec>, + edge: &Edge<'id>, + ) -> EdgeSpacerBuildDecision<'f, 'id> { + let Some(node_nesting_info_from) = node_nesting_infos.get(&edge.from) else { + return EdgeSpacerBuildDecision::Skip( + EdgeSpacerBuildDecisionSkip::NestingInfoFromNotFound { + node_id: edge.from.clone(), + }, + ); + }; + let Some(node_nesting_info_to) = node_nesting_infos.get(&edge.to) else { + return EdgeSpacerBuildDecision::Skip( + EdgeSpacerBuildDecisionSkip::NestingInfoToNotFound { + node_id: edge.to.clone(), + }, + ); + }; + + // === LCA sibling distance guard === // + // + // Only insert cross-container spacers when the edge's + // from-node and to-node diverge at the LCA level with + // at least one intermediate sibling between them. + // A sibling distance of 1 means the two divergent + // ancestors are adjacent, so the edge does not cross + // over any other node. + let edge_lca_sibling_distance = + Self::edge_lca_sibling_distance(node_nesting_info_from, node_nesting_info_to); + if edge_lca_sibling_distance.distance < 2 { + return EdgeSpacerBuildDecision::Skip( + EdgeSpacerBuildDecisionSkip::NoIntermediateLcaSiblings { + node_id_from: edge.from.clone(), + node_id_to: edge.to.clone(), + node_nesting_info_from: node_nesting_info_from.clone(), + node_nesting_info_to: node_nesting_info_to.clone(), + edge_lca_sibling_distance, + }, + ); + } + + // Determine if exactly one endpoint is inside this container + // and the other is outside. + let container_node_contains_node_from = node_nesting_info_from + .ancestor_chain + .contains(container_node_id); + let container_node_contains_node_to = node_nesting_info_to + .ancestor_chain + .contains(container_node_id); + + // We create a spacer node for edges where one node is inside the container and + // one is outside. + match ( + container_node_contains_node_from, + container_node_contains_node_to, + ) { + (true, true) => { + return EdgeSpacerBuildDecision::Skip( + EdgeSpacerBuildDecisionSkip::ContainerNodeContainsBothFromAndToNodes { + node_id_container: container_node_id.clone(), + node_id_from: edge.from.clone(), + node_id_to: edge.to.clone(), + }, + ) + } + (false, false) => { + return EdgeSpacerBuildDecision::Skip( + EdgeSpacerBuildDecisionSkip::ContainerNodeContainsNeitherFromAndToNodes { + node_id_container: container_node_id.clone(), + node_id_from: edge.from.clone(), + node_id_to: edge.to.clone(), + }, + ) + } + // Continue checking if the edge needs a spacer across the container. + (true, false) | (false, true) => {} + } + + // Determine which endpoint is inside and which is outside. + let node_nesting_info_of_contained_node = if container_node_contains_node_from { + node_nesting_info_from + } else { + node_nesting_info_to + }; + + // Find which direct child of this container is the ancestor of the inside + // endpoint. The ancestor chain includes the inside endpoint itself, so we look + // for the container in the chain and take the next element. + // + // # Example + // + // ```yaml + // node hierarchy: + // a: + // a0: + // a00: + // a000: {} + // a1: + // a10: {} + // ``` + // + // For `a000`, the `ancestor_chain` is `["a", "a0", "a00", "a000"]`. + // + // The container depth of `a0` in the chain is `1` (the index of `a0` in the + // chain). + let container_depth_in_chain = node_nesting_info_of_contained_node + .ancestor_chain + .iter() + .position(|id| id == container_node_id); + let container_depth = container_depth_in_chain + .expect("We just confirmed the `container_node` is in this node's `ancestor_chain`."); + + // Skip creating the spacer node if the container itself is one of the endpoints + // (ancestor_chain includes self, so check that the inside endpoint is not the + // container itself). + // + // The `target_child_id` is the node ID of the direct child of the container + // node, which *may* be the inside endpoint. + let target_child_id = node_nesting_info_of_contained_node + .ancestor_chain + .get(container_depth + 1); + let target_child_id = match target_child_id { + Some(target_child_id) => target_child_id, + None => { + // The container node is the deepest element, i.e. the inside endpoint IS the + // container node, so skip creating a spacer node. + return EdgeSpacerBuildDecision::Skip( + EdgeSpacerBuildDecisionSkip::ContainerNodeIsFromOrToNode { + node_id_from: edge.from.clone(), + node_id_to: edge.to.clone(), + }, + ); + } + }; + + // Find the index of the target child among the direct children. + let _target_child_index = container_node_direct_child_ids + .iter() + .position(|id| id == target_child_id) + .expect("`target_child_id` was just looked up from the `ancestor_chain` at `container_depth + 1`."); + + EdgeSpacerBuildDecision::Build(EdgeSpacerBuildDecisionBuild { target_child_id }) + } + + /// Returns the sibling distance between the divergent ancestors of + /// two nodes at their lowest common ancestor (LCA) level. + /// + /// The sibling distance is the absolute difference of the sibling + /// indices of the two nodes' divergent ancestors -- i.e. the first + /// nodes in each ancestor chain where the chains differ. + /// + /// A distance of 0 means both nodes share the same divergent + /// ancestor (or one is an ancestor of the other). + /// A distance of 1 means the divergent ancestors are adjacent + /// siblings -- no intermediate node lies between them. + /// A distance of 2 or more means at least one sibling node sits + /// between the two divergent ancestors, so an edge connecting the + /// two nodes would visually cross over that intermediate sibling. + /// + /// # Examples + /// + /// Given hierarchy: + /// + /// ```text + /// outer: + /// a: { a_child: { a_grandchild: {} } } + /// b: { b_child: {} } + /// c: { c_child: {} } + /// ``` + /// + /// * `a_grandchild` and `b_child` -> LCA is `outer`, divergent ancestors + /// are `a` (index 0) and `b` (index 1), distance = 1. + /// * `a_grandchild` and `c_child` -> LCA is `outer`, divergent ancestors + /// are `a` (index 0) and `c` (index 2), distance = 2. + fn edge_lca_sibling_distance( + node_nesting_info_from: &NodeNestingInfo<'_>, + node_nesting_info_to: &NodeNestingInfo<'_>, + ) -> EdgeLcaSiblingDistance { + let lca_depth = LcaDepthCalculator::calculate(node_nesting_info_from, node_nesting_info_to); + + // Get the sibling indices at the divergence depth for each node. + // + // i.e. get the indices of the nodes where the hierarchy first diverges. + let from_sibling_ancestor_index = + node_nesting_info_from.nesting_path.get(lca_depth).copied(); + let to_sibling_ancestor_index = node_nesting_info_to.nesting_path.get(lca_depth).copied(); + + let distance = match (from_sibling_ancestor_index, to_sibling_ancestor_index) { + (Some(a), Some(b)) => a.abs_diff(b), + // One chain is a prefix of the other (one node is an + // ancestor of the other) -- no divergent siblings. + _ => 0, + }; + + EdgeLcaSiblingDistance { + lca_depth, + distance, + } + } +} diff --git a/crate/input_ir_rt/src/ir_to_taffy_builder/edge_spacer_builder/edge_spacer_build_decision.rs b/crate/input_ir_rt/src/ir_to_taffy_builder/edge_spacer_builder/edge_spacer_build_decision.rs new file mode 100644 index 0000000..47f6e84 --- /dev/null +++ b/crate/input_ir_rt/src/ir_to_taffy_builder/edge_spacer_builder/edge_spacer_build_decision.rs @@ -0,0 +1,12 @@ +use crate::ir_to_taffy_builder::edge_spacer_builder::{ + EdgeSpacerBuildDecisionBuild, EdgeSpacerBuildDecisionSkip, +}; + +/// Represents the decision to build or skip the edge spacer. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum EdgeSpacerBuildDecision<'f, 'id> { + /// Skip building the edge spacer and use the default spacing instead. + Skip(EdgeSpacerBuildDecisionSkip<'id>), + /// Build the edge spacer using the specified spacing. + Build(EdgeSpacerBuildDecisionBuild<'f, 'id>), +} diff --git a/crate/input_ir_rt/src/ir_to_taffy_builder/edge_spacer_builder/edge_spacer_build_decision_build.rs b/crate/input_ir_rt/src/ir_to_taffy_builder/edge_spacer_builder/edge_spacer_build_decision_build.rs new file mode 100644 index 0000000..b458460 --- /dev/null +++ b/crate/input_ir_rt/src/ir_to_taffy_builder/edge_spacer_builder/edge_spacer_build_decision_build.rs @@ -0,0 +1,7 @@ +use disposition_ir_model::node::NodeId; + +/// Parameters for building the edge spacer. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EdgeSpacerBuildDecisionBuild<'f, 'id> { + pub target_child_id: &'f NodeId<'id>, +} diff --git a/crate/input_ir_rt/src/ir_to_taffy_builder/edge_spacer_builder/edge_spacer_build_decision_skip.rs b/crate/input_ir_rt/src/ir_to_taffy_builder/edge_spacer_builder/edge_spacer_build_decision_skip.rs new file mode 100644 index 0000000..cdd2b46 --- /dev/null +++ b/crate/input_ir_rt/src/ir_to_taffy_builder/edge_spacer_builder/edge_spacer_build_decision_skip.rs @@ -0,0 +1,51 @@ +use disposition_ir_model::node::{NodeId, NodeNestingInfo}; + +use crate::ir_to_taffy_builder::EdgeLcaSiblingDistance; + +/// Reason for not building the edge spacer. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum EdgeSpacerBuildDecisionSkip<'id> { + /// The nesting info for the from node was not found. + /// + /// In general this is a bug, because the `NodeNestingInfo` is calculated in + /// `InputToIrDiagramMapper` for every node. + NestingInfoFromNotFound { node_id: NodeId<'id> }, + /// The nesting info for the to node was not found. + /// + /// In general this is a bug, because the `NodeNestingInfo` is calculated in + /// `InputToIrDiagramMapper` for every node. + NestingInfoToNotFound { node_id: NodeId<'id> }, + NoIntermediateLcaSiblings { + /// ID of the node that the edge begins from. + node_id_from: NodeId<'id>, + /// ID of the node that the edge ends at. + node_id_to: NodeId<'id>, + /// The `NodeNestingInfo` for the `from` node. + node_nesting_info_from: NodeNestingInfo<'id>, + /// The `NodeNestingInfo` for the `to` node. + node_nesting_info_to: NodeNestingInfo<'id>, + /// The distance between the LCA sibling of the `from` node and the `to` + /// node. + edge_lca_sibling_distance: EdgeLcaSiblingDistance, + }, + /// The container node contains both the `from` and the `to` node, so skip + /// creating the spacer node. + ContainerNodeContainsBothFromAndToNodes { + node_id_container: NodeId<'id>, + node_id_from: NodeId<'id>, + node_id_to: NodeId<'id>, + }, + /// The container node contains neither the `from` nor the `to` node, so + /// skip creating the spacer node. + ContainerNodeContainsNeitherFromAndToNodes { + node_id_container: NodeId<'id>, + node_id_from: NodeId<'id>, + node_id_to: NodeId<'id>, + }, + /// The container node is one of the endpoints, so skip creating the spacer + /// node. + ContainerNodeIsFromOrToNode { + node_id_from: NodeId<'id>, + node_id_to: NodeId<'id>, + }, +} diff --git a/crate/input_ir_rt/src/ir_to_taffy_builder/edge_spacer_builder/lca_depth_calculator.rs b/crate/input_ir_rt/src/ir_to_taffy_builder/edge_spacer_builder/lca_depth_calculator.rs new file mode 100644 index 0000000..ca602f7 --- /dev/null +++ b/crate/input_ir_rt/src/ir_to_taffy_builder/edge_spacer_builder/lca_depth_calculator.rs @@ -0,0 +1,38 @@ +use std::ops::ControlFlow; + +use disposition_ir_model::node::NodeNestingInfo; + +/// Calculates the depth of the lowest common ancestor (LCA) of two nodes. +pub struct LcaDepthCalculator; + +impl LcaDepthCalculator { + /// Returns the depth of the lowest common ancestor (LCA) of two nodes. + /// + /// The LCA depth is the length of the longest common prefix of the two + /// nodes' `ancestor_chain`s. A depth of `0` means they diverge at the + /// top level (no shared ancestor within the hierarchy). + /// + /// # Examples + /// + /// * `[a, a01]` and `[c, c01]` -> LCA depth `0` (diverge immediately). + /// * `[outer, a, a01]` and `[outer, b]` -> LCA depth `1` (share `outer`). + /// * `[outer, inner, x]` and `[outer, inner, y]` -> LCA depth `2` (share + /// `outer` and `inner`). + pub fn calculate(info_from: &NodeNestingInfo<'_>, info_to: &NodeNestingInfo<'_>) -> usize { + let max_compare = info_from + .ancestor_chain + .len() + .min(info_to.ancestor_chain.len()); + let mut depth = 0; + let (ControlFlow::Continue(()) | ControlFlow::Break(())) = + (0..max_compare).try_for_each(|i| { + if info_from.ancestor_chain[i] == info_to.ancestor_chain[i] { + depth = i + 1; + ControlFlow::Continue(()) + } else { + ControlFlow::Break(()) + } + }); + depth + } +} diff --git a/crate/input_ir_rt/src/ir_to_taffy_builder/taffy_node_build_context.rs b/crate/input_ir_rt/src/ir_to_taffy_builder/taffy_node_build_context.rs index 1502fb2..7493cb6 100644 --- a/crate/input_ir_rt/src/ir_to_taffy_builder/taffy_node_build_context.rs +++ b/crate/input_ir_rt/src/ir_to_taffy_builder/taffy_node_build_context.rs @@ -1,7 +1,7 @@ use disposition_ir_model::{ entity::EntityTypes, layout::{LeafLayout, NodeLayouts}, - node::{NodeHierarchy, NodeId, NodeNames, NodeRanks, NodeShapes}, + node::{NodeHierarchy, NodeId, NodeNames, NodeNestingInfos, NodeRanksNested, NodeShapes}, }; use disposition_model_common::{entity::EntityDescs, Map}; use disposition_taffy_model::{ @@ -20,7 +20,8 @@ pub(crate) struct TaffyNodeBuildContext<'ctx> { pub(crate) node_hierarchy: &'ctx NodeHierarchy<'static>, pub(crate) entity_types: &'ctx EntityTypes<'static>, pub(crate) node_shapes: &'ctx NodeShapes<'static>, - pub(crate) node_ranks: &'ctx NodeRanks<'static>, + pub(crate) node_ranks_nested: &'ctx NodeRanksNested<'static>, + pub(crate) node_nesting_infos: &'ctx NodeNestingInfos<'static>, pub(crate) node_id_to_taffy: &'ctx mut Map, NodeToTaffyNodeIds>, pub(crate) taffy_id_to_node: &'ctx mut Map>, } diff --git a/crate/input_ir_rt/src/lib.rs b/crate/input_ir_rt/src/lib.rs index 2390d73..6e05324 100644 --- a/crate/input_ir_rt/src/lib.rs +++ b/crate/input_ir_rt/src/lib.rs @@ -4,10 +4,11 @@ pub use disposition_input_ir_model::EdgeAnimationActive; pub use disposition_input_rt::id_parse; pub use crate::{ - input_diagram_merger::InputDiagramMerger, + edge_id_generator::EdgeIdGenerator, input_diagram_merger::InputDiagramMerger, input_diagram_theme_sources::InputDiagramThemeSources, input_to_ir_diagram_mapper::InputToIrDiagramMapper, ir_to_taffy_builder::IrToTaffyBuilder, - string_xml_escaper::StringXmlEscaper, svg_elements_to_svg_mapper::SvgElementsToSvgMapper, + node_ranks_calculator::NodeRanksCalculator, string_xml_escaper::StringXmlEscaper, + svg_elements_to_svg_mapper::SvgElementsToSvgMapper, taffy_to_svg_elements_mapper::TaffyToSvgElementsMapper, theme_value_source::ThemeValueSource, }; @@ -16,6 +17,7 @@ pub use crate::{ const NOTO_SANS_MONO_TTF: &[u8] = include_bytes!("../fonts/noto_sans_mono/NotoSansMono-Regular.ttf"); +mod edge_id_generator; mod input_diagram_merger; mod input_diagram_theme_sources; mod input_to_ir_diagram_mapper; diff --git a/crate/input_ir_rt/src/node_ranks_calculator.rs b/crate/input_ir_rt/src/node_ranks_calculator.rs index 4822c5d..eb1517b 100644 --- a/crate/input_ir_rt/src/node_ranks_calculator.rs +++ b/crate/input_ir_rt/src/node_ranks_calculator.rs @@ -1,20 +1,25 @@ use disposition_ir_model::{ edge::EdgeGroups, entity::{EntityType, EntityTypes}, - node::{NodeHierarchy, NodeId, NodeRank, NodeRanks}, + node::{NodeId, NodeNestingInfos, NodeRank, NodeRanks, NodeRanksNested}, }; use disposition_model_common::{Id, Map}; -/// Computes [`NodeRanks`] from dependency edges in an [`IrDiagram`]. +/// Computes [`NodeRanksNested`] from dependency edges in an [`IrDiagram`]. /// /// Dependency edges indicate that the `to` node should be positioned after -/// the `from` node. This calculator performs a rank assignment so that: +/// the `from` node. This calculator performs hierarchy-aware rank assignment: /// -/// * Nodes with no incoming dependency edges receive rank `0`. -/// * Each node's rank is one greater than the maximum rank of its predecessors. -/// * Nodes that are part of a cycle (strongly connected component) share the -/// same rank -- the cycle is contracted into a single logical node for -/// ranking purposes. +/// * Ranks are computed independently for each hierarchy level (root and each +/// container node). +/// * Dependency edges that cross container boundaries are attributed to the +/// lowest common ancestor (LCA) of the two endpoints, using the first +/// divergent sibling ancestors at that level as the effective edge. +/// * Nodes with no incoming dependency edges at their level receive rank `0`. +/// * Each node's rank is one greater than the maximum rank of its predecessors +/// at the same level. +/// * Nodes that are part of a cycle share the same rank -- the cycle is +/// contracted into a single logical node for ranking purposes. /// /// Only **dependency** edges (not interaction edges) are considered for rank /// computation. @@ -23,17 +28,17 @@ use disposition_model_common::{Id, Map}; /// /// # Examples /// -/// Given edges `A -> B -> C`, the resulting ranks would be: +/// Given edges `A -> B -> C` all at the same level, the resulting ranks are: /// /// * `A`: 0 /// * `B`: 1 /// * `C`: 2 /// -/// Given edges `A -> B`, `B -> A`, `B -> C`, the resulting ranks would be: +/// Given `a -> b`, `b_child_0 -> b_child_1`, `b_child_0 -> c_child`: /// -/// * `A`: 0 (part of cycle with B) -/// * `B`: 0 (part of cycle with A) -/// * `C`: 1 +/// * Root level -- `a: 0`, `b: 1`, `c: 2` (lifted from `b_child_0 -> c_child`) +/// * `b`'s level -- `b_child_0: 0`, `b_child_1: 1` +/// * `c`'s level -- `c_child: 0` (edge is at root level, not `c`'s level) #[derive(Clone, Copy, Debug)] pub struct NodeRanksCalculator; @@ -50,54 +55,153 @@ struct TarjanState { } impl NodeRanksCalculator { - /// Computes node ranks from the given edge groups and entity types. + /// Computes hierarchy-aware node ranks from dependency edges. /// - /// Only edges whose edge group has a dependency entity type are used for - /// rank computation. All nodes present in `node_hierarchy` are included - /// in the output, defaulting to rank `0` if they have no incoming - /// dependency edges. + /// Ranks are computed per hierarchy level using `node_nesting_infos` to + /// group nodes and attribute cross-container edges to their LCA level. /// /// # Parameters /// - /// * `node_hierarchy`: The full node hierarchy -- used to discover all node - /// IDs that should receive a rank. /// * `edge_groups`: All edge groups in the diagram. /// * `entity_types`: Entity types used to distinguish dependency edges from /// interaction edges. + /// * `node_nesting_infos`: Nesting information for each node, used to build + /// the container-to-children map and compute LCA-level edge attribution. pub fn calculate<'id>( - node_hierarchy: &NodeHierarchy<'id>, edge_groups: &EdgeGroups<'id>, entity_types: &EntityTypes<'id>, - ) -> NodeRanks<'id> { - // === Collect All Node IDs === // - let mut all_node_ids: Vec> = Vec::new(); - Self::node_ids_collect(node_hierarchy, &mut all_node_ids); - - if all_node_ids.is_empty() { - return NodeRanks::new(); + node_nesting_infos: &NodeNestingInfos<'id>, + ) -> NodeRanksNested<'id> { + if node_nesting_infos.is_empty() { + return NodeRanksNested::new(); } + // === Build Container-to-Children Map === // + let container_to_children = Self::container_to_children_build(node_nesting_infos); + // === Collect Dependency Edges === // let dependency_edges = Self::dependency_edges_collect(edge_groups, entity_types); - if dependency_edges.is_empty() { - // No dependency edges -- all nodes get rank 0. - return all_node_ids - .into_iter() - .map(|node_id| (node_id, NodeRank::new(0))) - .collect(); + // === Lift Edges to LCA Level === // + let lca_level_edges = Self::lca_level_edges_build(&dependency_edges, node_nesting_infos); + + // === Compute Ranks Per Level === // + let mut root = NodeRanks::new(); + let mut containers: Map, NodeRanks<'id>> = Map::new(); + + let empty_edges: Vec<(NodeId<'id>, NodeId<'id>)> = Vec::new(); + for (container, children) in &container_to_children { + let edges = lca_level_edges.get(container).unwrap_or(&empty_edges); + let ranks = Self::ranks_compute(children, edges); + match container { + None => root = ranks, + Some(container_id) => { + containers.insert(container_id.clone(), ranks); + } + } } - // === Compute Ranks via SCC Contraction === // - Self::ranks_compute(&all_node_ids, &dependency_edges) + NodeRanksNested { root, containers } + } + + /// Builds a map from container node (or `None` for root) to its direct + /// children. + /// + /// Each node's parent is the second-to-last element of its + /// `ancestor_chain`. Top-level nodes (chain length 1) belong to the root + /// level, keyed by `None`. + fn container_to_children_build<'id>( + node_nesting_infos: &NodeNestingInfos<'id>, + ) -> Map>, Vec>> { + let mut container_to_children: Map>, Vec>> = Map::new(); + for (node_id, nesting_info) in node_nesting_infos.iter() { + let chain = &nesting_info.ancestor_chain; + let parent = chain + .len() + .checked_sub(2) + .map(|parent_idx| chain[parent_idx].clone()); + container_to_children + .entry(parent) + .or_default() + .push(node_id.clone()); + } + container_to_children + } + + /// Lifts each dependency edge to the LCA-level edge between the divergent + /// sibling ancestors of the two endpoints. + /// + /// Groups resulting LCA-level edges by their LCA container (`None` for + /// root). + fn lca_level_edges_build<'id>( + dependency_edges: &[(NodeId<'id>, NodeId<'id>)], + node_nesting_infos: &NodeNestingInfos<'id>, + ) -> Map>, Vec<(NodeId<'id>, NodeId<'id>)>> { + let mut lca_level_edges: Map>, Vec<(NodeId<'id>, NodeId<'id>)>> = + Map::new(); + for (from_id, to_id) in dependency_edges { + if let Some((lca_container, divergent_from, divergent_to)) = + Self::lca_level_edge_compute(from_id, to_id, node_nesting_infos) + { + lca_level_edges + .entry(lca_container) + .or_default() + .push((divergent_from, divergent_to)); + } + } + lca_level_edges } - /// Recursively collects all node IDs from a `NodeHierarchy`. - fn node_ids_collect<'id>(node_hierarchy: &NodeHierarchy<'id>, node_ids: &mut Vec>) { - for (node_id, child_hierarchy) in node_hierarchy.iter() { - node_ids.push(node_id.clone()); - Self::node_ids_collect(child_hierarchy, node_ids); + /// Computes the LCA container and divergent ancestors for a single edge. + /// + /// Returns `None` if either endpoint is absent from `node_nesting_infos`, + /// if one node is an ancestor of the other (edge is within a subtree), or + /// if the two divergent ancestors are the same node (self-loop at LCA + /// level). + /// + /// # Return value + /// + /// `Some((lca_container, divergent_from, divergent_to))` where: + /// + /// * `lca_container` -- `None` for root-level, `Some(id)` for a container. + /// * `divergent_from`, `divergent_to` -- the first ancestors of `from` and + /// `to` that differ under the LCA, at depth `lca_depth` in their + /// respective `ancestor_chain`s. + fn lca_level_edge_compute<'id>( + from_id: &NodeId<'id>, + to_id: &NodeId<'id>, + node_nesting_infos: &NodeNestingInfos<'id>, + ) -> Option<(Option>, NodeId<'id>, NodeId<'id>)> { + let info_from = node_nesting_infos.get(from_id)?; + let info_to = node_nesting_infos.get(to_id)?; + + let chain_from = &info_from.ancestor_chain; + let chain_to = &info_to.ancestor_chain; + + let lca_depth = chain_from + .iter() + .zip(chain_to.iter()) + .take_while(|(a, b)| a == b) + .count(); + + // If one node is an ancestor of the other, skip the edge. + if lca_depth >= chain_from.len() || lca_depth >= chain_to.len() { + return None; } + + let divergent_from = chain_from[lca_depth].clone(); + let divergent_to = chain_to[lca_depth].clone(); + + // Skip self-loops at LCA level (divergent ancestors are the same node). + if divergent_from == divergent_to { + return None; + } + + let lca_container = lca_depth + .checked_sub(1) + .map(|lca_idx| chain_from[lca_idx].clone()); + + Some((lca_container, divergent_from, divergent_to)) } /// Extracts dependency edges from edge groups, filtering out interaction @@ -159,6 +263,18 @@ impl NodeRanksCalculator { all_node_ids: &[NodeId<'id>], dependency_edges: &[(NodeId<'id>, NodeId<'id>)], ) -> NodeRanks<'id> { + if all_node_ids.is_empty() { + return NodeRanks::new(); + } + + if dependency_edges.is_empty() { + // No dependency edges -- all nodes get rank 0. + return all_node_ids + .iter() + .map(|node_id| (node_id.clone(), NodeRank::new(0))) + .collect(); + } + // === Assign Each Node a Numeric Index === // let mut node_to_index: Map, usize> = Map::new(); for (i, node_id) in all_node_ids.iter().enumerate() { diff --git a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper.rs b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper.rs index 35d7825..91a32ab 100644 --- a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper.rs +++ b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper.rs @@ -13,6 +13,7 @@ use self::{ edge_path_builder_pass_1::EdgePathBuilderPass1, edge_path_builder_pass_2::EdgePathBuilderPass2, edge_path_locus_calculator::EdgePathLocusCalculator, + edge_spacer_coordinates_calculator::EdgeSpacerCoordinatesCalculator, process_step_heights::ProcessStepsHeight, process_step_heights_calculator::ProcessStepHeightsCalculator, string_char_replacer::StringCharReplacer, @@ -31,6 +32,7 @@ mod edge_model; mod edge_path_builder_pass_1; mod edge_path_builder_pass_2; mod edge_path_locus_calculator; +mod edge_spacer_coordinates_calculator; mod ortho_protrusion_calculator; mod process_step_heights; mod process_step_heights_calculator; diff --git a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_animation_calculator.rs b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_animation_calculator.rs index 794bde7..c5e1ceb 100644 --- a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_animation_calculator.rs +++ b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_animation_calculator.rs @@ -35,6 +35,7 @@ impl EdgeAnimationCalculator { path: _, path_length, preceding_visible_segments_lengths, + ortho_protrusion_params: _, } = edge_path_info; let path_length = *path_length; diff --git a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_model.rs b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_model.rs index a366090..26d754c 100644 --- a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_model.rs +++ b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_model.rs @@ -2,6 +2,7 @@ use disposition_ir_model::{ edge::{Edge, EdgeId}, node::NodeId, }; +use disposition_svg_model::OrthoProtrusionParams; use kurbo::BezPath; /// Represents a face/side of a rectangular node. @@ -130,6 +131,11 @@ pub(super) struct EdgePathInfo<'edge, 'id> { pub(super) path: BezPath, pub(super) path_length: f64, pub(super) preceding_visible_segments_lengths: f64, + /// Orthogonal protrusion parameters used when building this edge's path. + /// + /// Contains the computed from/to protrusion lengths and per-spacer + /// protrusion depths. Zero for non-orthogonal (curved) edges. + pub(super) ortho_protrusion_params: OrthoProtrusionParams, } /// Parameters for edge `stroke-dasharray` animation generation. diff --git a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_path_builder_pass_1.rs b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_path_builder_pass_1.rs index 1cc3b5a..ebe72e6 100644 --- a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_path_builder_pass_1.rs +++ b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_path_builder_pass_1.rs @@ -235,10 +235,13 @@ impl EdgePathBuilderPass1 { /// For self-loops both faces are `NodeFace::Bottom`. /// For contained edges both faces are `None` (no face-based offset /// applies). + /// For cycle edges (`is_cycle_edge = true`), clockwise routing faces are + /// chosen instead of the nearest-face heuristic. pub(super) fn faces_select( rank_dir: RankDir, from_info: &SvgNodeInfo, to_info: &SvgNodeInfo, + is_cycle_edge: bool, ) -> Option<(NodeFace, NodeFace)> { if from_info.node_id == to_info.node_id { // Self-loop: both endpoints touch the bottom face. @@ -248,9 +251,64 @@ impl EdgePathBuilderPass1 { // Contained edges bypass face-based contact points. return None; } + if is_cycle_edge { + // Cycle edges route clockwise around the outside of the nodes + // rather than connecting their nearest faces. + return Some(Self::cycle_edge_faces_select(from_info, to_info)); + } Some(Self::select_edge_faces(rank_dir, from_info, to_info)) } + /// Selects edge faces for same-rank (cycle) edges, routing clockwise. + /// + /// The clockwise routing rule is purely geometric -- it does not depend + /// on `RankDir`: + /// + /// * `from` left of `to` (`dx > 0`): protrude from `from.Top`, turn right + /// (east), move, turn right (south) into `to.Top`. + /// * `from` right of `to` (`dx < 0`): protrude from `from.Bottom`, turn + /// right (west), move, turn right (north) into `to.Bottom`. + /// * `from` directly above `to` (`|dy| > |dx|`, `dy > 0`): protrude from + /// `from.Right`, route down the right side, enter `to.Right`. + /// * `from` directly below `to` (`|dy| > |dx|`, `dy < 0`): protrude from + /// `from.Left`, route up the left side, enter `to.Left`. + pub(super) fn cycle_edge_faces_select( + from_info: &SvgNodeInfo, + to_info: &SvgNodeInfo, + ) -> (NodeFace, NodeFace) { + let from_center_x = from_info.x + from_info.width / 2.0; + let to_center_x = to_info.x + to_info.width / 2.0; + let from_center_y = from_info.y + from_info.height_collapsed / 2.0; + let to_center_y = to_info.y + to_info.height_collapsed / 2.0; + + let dx = to_center_x - from_center_x; + let dy = to_center_y - from_center_y; + + if dx.abs() >= dy.abs() { + // Primarily horizontal relationship. + if dx > 0.0 { + // `from` is to the left of `to`: + // protrude from `from.Top`, arc right above both, enter `to.Top`. + (NodeFace::Top, NodeFace::Top) + } else { + // `from` is to the right of `to` (or same x, dx <= 0): + // protrude from `from.Bottom`, arc left below both, enter `to.Bottom`. + (NodeFace::Bottom, NodeFace::Bottom) + } + } else { + // Primarily vertical relationship (same column). + if dy > 0.0 { + // `from` is above `to` (smaller y = higher on screen): + // protrude from `from.Right`, route right side going down, enter `to.Right`. + (NodeFace::Right, NodeFace::Right) + } else { + // `from` is below `to`: + // protrude from `from.Left`, route left side going up, enter `to.Left`. + (NodeFace::Left, NodeFace::Left) + } + } + } + /// Applies a pixel offset along a face. /// /// For `Left` / `Right` faces the offset shifts vertically. diff --git a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_path_builder_pass_2.rs b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_path_builder_pass_2.rs index bb18e1f..772d8ed 100644 --- a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_path_builder_pass_2.rs +++ b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_path_builder_pass_2.rs @@ -11,9 +11,11 @@ use crate::taffy_to_svg_elements_mapper::{ }, }; +use disposition_svg_model::OrthoProtrusionParams; + use self::{ edge_path_builder_pass_2_curve::EdgePathBuilderPass2Curve, - edge_path_builder_pass_2_ortho::{EdgePathBuilderPass2Ortho, OrthoProtrusionParams}, + edge_path_builder_pass_2_ortho::EdgePathBuilderPass2Ortho, }; mod edge_path_builder_pass_2_curve; @@ -73,6 +75,10 @@ impl EdgePathBuilderPass2 { /// 150.0, exit_y: 205.0 }]`. /// * `ortho_protrusion`: precomputed protrusion lengths for orthogonal edge /// endpoints. Ignored when `edge_curvature` is `Curved`. + /// * `face_override`: when `Some`, overrides the automatic face selection. + /// This is used to propagate the cycle-aware faces chosen in pass 1 so + /// that pass 2 produces a consistent path. When `None` the faces are + /// re-derived from the relative node positions. #[allow(clippy::too_many_arguments)] pub(super) fn build( edge_curvature: EdgeCurvature, @@ -83,6 +89,7 @@ impl EdgePathBuilderPass2 { face_offset: EdgeFaceOffset, spacers: &[SpacerCoordinates], ortho_protrusion: &OrthoProtrusionParams, + face_override: Option<(NodeFace, NodeFace)>, ) -> BezPath { // Handle self-loop case (curvature mode and spacers ignored). if from_info.node_id == to_info.node_id { @@ -109,9 +116,11 @@ impl EdgePathBuilderPass2 { let from_geom = EdgePathBuilderPass1::node_edge_geometry(from_info); let to_geom = EdgePathBuilderPass1::node_edge_geometry(to_info); - // Determine which faces to use based on relative positions. - let (from_face, to_face) = - EdgePathBuilderPass1::select_edge_faces(rank_dir, from_info, to_info); + // Determine which faces to use based on relative positions, or use + // the pre-computed override from pass 1 (e.g. for cycle edges). + let (from_face, to_face) = face_override.unwrap_or_else(|| { + EdgePathBuilderPass1::select_edge_faces(rank_dir, from_info, to_info) + }); // Get base connection points. let (mut start_x, mut start_y) = diff --git a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_path_builder_pass_2/edge_path_builder_pass_2_ortho.rs b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_path_builder_pass_2/edge_path_builder_pass_2_ortho.rs index 3114e58..fdf07c5 100644 --- a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_path_builder_pass_2/edge_path_builder_pass_2_ortho.rs +++ b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_path_builder_pass_2/edge_path_builder_pass_2_ortho.rs @@ -1,3 +1,4 @@ +use disposition_svg_model::OrthoProtrusionParams; use kurbo::{BezPath, Point}; use crate::taffy_to_svg_elements_mapper::{ @@ -17,83 +18,6 @@ const ARC_RADIUS: f32 = 4.0; /// Equal to `(4.0 / 3.0) * (sqrt(2) - 1)`, approximately `0.5522847498`. const KAPPA: f32 = 0.552_284_8; -/// Protrusion lengths for the entry and exit sides of a single spacer. -/// -/// The entry-side protrusion extends the path past the spacer's entry -/// boundary (away from the spacer, into the gap before it). The -/// exit-side protrusion extends the path past the spacer's exit -/// boundary (away from the spacer, into the gap after it). -/// -/// Protrusion depths are assigned by `OrthoProtrusionCalculator` so -/// that edges sharing the same inter-rank gap use distinct depths. -/// -/// # Example values -/// -/// ```text -/// SpacerProtrusionParams { entry_protrusion: 5.0, exit_protrusion: 8.0 } -/// ``` -#[derive(Clone, Copy, Debug, Default)] -pub(in crate::taffy_to_svg_elements_mapper) struct SpacerProtrusionParams { - /// Protrusion length in pixels on the entry side of the spacer. - /// - /// `0.0` means no protrusion on the entry side. - pub(in crate::taffy_to_svg_elements_mapper) entry_protrusion: f32, - - /// Protrusion length in pixels on the exit side of the spacer. - /// - /// `0.0` means no protrusion on the exit side. - pub(in crate::taffy_to_svg_elements_mapper) exit_protrusion: f32, -} - -/// Protrusion lengths for the from-node and to-node endpoints of an -/// orthogonal edge path, plus per-spacer protrusion depths. -/// -/// A protrusion is a short stub that exits the node face perpendicular -/// to the face line before the main orthogonal routing begins. This -/// separates parallel edges that share the same node face. -/// -/// Spacer protrusions serve the same purpose at intermediate spacer -/// boundaries: they extend the path past the spacer so that the -/// routing leg between spacers does not run along a node face, and -/// multiple edges crossing the same inter-rank gap use distinct -/// depths. -/// -/// # Example values -/// -/// ```text -/// OrthoProtrusionParams { -/// from_protrusion: 12.0, -/// to_protrusion: 8.0, -/// spacer_protrusions: vec![ -/// SpacerProtrusionParams { entry_protrusion: 12.0, exit_protrusion: 5.0 }, -/// ], -/// } -/// ``` -/// -/// An edge whose from-node is close to the face midpoint gets a longer -/// `from_protrusion`; an edge further from the midpoint gets a shorter -/// one. Each spacer's entry and exit protrusions are computed -/// independently based on the edges sharing that specific rank gap. -#[derive(Clone, Debug, Default)] -pub(in crate::taffy_to_svg_elements_mapper) struct OrthoProtrusionParams { - /// Protrusion length in pixels at the from-node endpoint. - /// - /// `0.0` means no protrusion (the path routes directly from the - /// contact point). - pub(in crate::taffy_to_svg_elements_mapper) from_protrusion: f32, - - /// Protrusion length in pixels at the to-node endpoint. - /// - /// `0.0` means no protrusion. - pub(in crate::taffy_to_svg_elements_mapper) to_protrusion: f32, - - /// Per-spacer protrusion depths, indexed in the same order as the - /// `spacers` slice passed to `build_spacer_edge_path`. - /// - /// When the edge has no spacers, this is empty. - pub(in crate::taffy_to_svg_elements_mapper) spacer_protrusions: Vec, -} - /// Builds pass-2 edge paths using orthogonal (90-degree) lines with /// rounded arc corners between spacers. /// @@ -315,9 +239,25 @@ impl EdgePathBuilderPass2Ortho { } // --- From-node protrusion tip --- // - if protrusion.from_protrusion > 1e-3 { + // + // Cap the from-protrusion so that its tip does not cross the + // to-protrusion tip. This can happen for deeply-nested nodes + // when the slot-assigned from-protrusion plus the + // divergent-sibling-adjusted to-protrusion exceeds the full + // node-to-node gap. + let from_protrusion_eff = Self::from_protrusion_capped( + start_x, + start_y, + end_x, + end_y, + from_face, + to_face, + protrusion.from_protrusion, + protrusion.to_protrusion, + ); + if from_protrusion_eff > 1e-3 { let (eff_start_x, eff_start_y) = - Self::protrusion_offset(start_x, start_y, from_face, protrusion.from_protrusion); + Self::protrusion_offset(start_x, start_y, from_face, from_protrusion_eff); waypoints.push(Waypoint { x: eff_start_x, y: eff_start_y, @@ -410,9 +350,32 @@ impl EdgePathBuilderPass2Ortho { let disp_uy = dy / dist; let dot_p = disp_ux * p_dx + disp_uy * p_dy; - if dot_p.abs() > 0.95 { - // Nearly collinear with the departure direction -- just - // draw a straight line. + // Two cases warrant a straight line: + // + // 1. `dot_p > 0.95`: the displacement is nearly collinear with the departure + // direction (same direction, small angular difference). + // + // 2. `is_same_axis`: both waypoints lie on the same routing axis -- no + // perpendicular component (same x for a vertical departure, same y for a + // horizontal departure). Such segments are always straight lines regardless + // of whether dot_p is positive or negative (e.g. the return leg from a + // protrusion tip back to the node contact point travels against the + // face-outward direction, giving dot_p ≈ -1, yet still requires a straight + // line). + // + // The previous check `dot_p.abs() > 0.95` incorrectly treated + // nearly anti-collinear displacements with a non-zero + // perpendicular component as "straight", causing a diagonal + // line instead of an orthogonal Z/S bend when the protrusion + // tips were on opposite sides of the routing axis. + let is_same_axis = if p_is_vertical { + dx.abs() < 1e-3 + } else { + dy.abs() < 1e-3 + }; + if dot_p > 0.95 || is_same_axis { + // Collinear in departure direction, or on the same routing + // axis -- draw a straight line. path.line_to(Point::new(qx as f64, qy as f64)); return; } @@ -423,28 +386,84 @@ impl EdgePathBuilderPass2Ortho { // L-shaped path with one turn suffices. if p_is_vertical == q_is_vertical { // === Z/S-shape: two turns === // - // - // Both directions are the same axis (both vertical or both - // horizontal). Route with three legs and two corners. - // - // The bend is placed at the `wp_to` coordinate (the - // from-node / spacer-exit side, since waypoints are - // collected in reverse order). This means the protrusion - // length directly controls the distance from the - // from-node face to the bend, keeping the routing - // segment on the from-node side of the gap. - // - // For vertical departure and arrival: go vertically to - // qy, turn horizontally to qx. - // - // For horizontal departure and arrival: go horizontally - // to qx, turn vertically to qy. if p_is_vertical { - // Offset the bend from qy back toward py by - // ARC_RADIUS so that leg 3 has enough length for the - // second rounded corner arc. - let sign = if py < qy { -1.0 } else { 1.0 }; + // Both directions are vertical: route via a horizontal mid-leg. + // + // Special case: both tips at the same Y with opposite departure + // directions (e.g. a `Bottom` from-tip and a `Top` to-tip that + // `from_protrusion_capped` placed at the same Y). A Z/S U-bend + // would exit above p, cross to qx, then descend back to qy. + // But the continuation from q (an `is_same_axis` return leg) + // immediately reverses direction, creating a V-spike. Draw a + // straight horizontal line instead. + if (py - qy).abs() < 1e-3 && p_dy * q_dy < 0.0 { + path.line_to(Point::new(qx as f64, qy as f64)); + return; + } + // + // When `py == qy` the departure direction disambiguates which + // side to bend toward -- otherwise the standard relative- + // position heuristic is used. + let sign = if (py - qy).abs() < 1e-3 { + // Same y: use departure direction to bend away from nodes. + if p_dy < 0.0 { + -1.0 + } else { + 1.0 + } + } else if py < qy { + -1.0 + } else { + 1.0 + }; let bend_y = qy + sign * ARC_RADIUS; + // Guard: when the gap between p and q is smaller than + // `ARC_RADIUS`, the formula above can place the bend past + // p (against p's departure direction), making leg 1 travel + // in the wrong direction. + // + // The fix places the bend at the midpoint between the two + // tips when they are on the expected side of each other + // (typical case: py > qy for an upward-departing p). This + // keeps the bend strictly inside the routing gap so that + // both Leg 1 and Leg 3 travel in the correct direction -- + // no backward loop occurs in the visual arrow direction. + // + // When p and q are on the same side (unusual case, e.g. + // py <= qy for an upward-departing p), the bend is placed + // ARC_RADIUS beyond p in its departure direction so Leg 1 + // is still correct, even though Leg 3 may arrive at q in + // the opposite direction. + // + // # Example + // + // `py = 155.0, qy = 152.696, p_dy = -1.0`: sign = +1, so + // `bend_y = 156.696`, which is below p (155). The guard + // detects `bend_y >= py` and resets to the midpoint + // `(py + qy) / 2 = 153.848`, keeping the bend between + // both protrusion tips so leg 1 travels upward from p and + // leg 3 arrives at q going upward as well. + let bend_y = if p_dy < 0.0 && bend_y >= py { + // p departs upward; bend must be above p (bend_y < py). + if py > qy { + // Typical: q is above p on screen (qy < py in SVG). + // Midpoint is inside the gap -- both legs travel upward. + (py + qy) / 2.0 + } else { + // Unusual: q is at or below p on screen. + // Place the bend ARC_RADIUS above p. + py - ARC_RADIUS + } + } else if p_dy > 0.0 && bend_y <= py { + // p departs downward; symmetric case. + if py < qy { + (py + qy) / 2.0 + } else { + py + ARC_RADIUS + } + } else { + bend_y + }; let corner1_x = px; let corner1_y = bend_y; let corner2_x = qx; @@ -453,11 +472,53 @@ impl EdgePathBuilderPass2Ortho { path, px, py, corner1_x, corner1_y, corner2_x, corner2_y, qx, qy, ); } else { - // Offset the bend from qx back toward px by - // ARC_RADIUS so that leg 3 has enough length for the - // second rounded corner arc. - let sign = if px < qx { -1.0 } else { 1.0 }; + // Both directions are horizontal: route via a vertical mid-leg. + // + // Horizontal analogue of the vertical V-spike guard above: + // same X with opposite departure directions -> straight line. + if (px - qx).abs() < 1e-3 && p_dx * q_dx < 0.0 { + path.line_to(Point::new(qx as f64, qy as f64)); + return; + } + // + // When `px == qx` the departure direction disambiguates which + // side to bend toward -- otherwise the standard relative- + // position heuristic is used. + let sign = if (px - qx).abs() < 1e-3 { + // Same x: use departure direction to bend away from nodes. + if p_dx < 0.0 { + -1.0 + } else { + 1.0 + } + } else if px < qx { + -1.0 + } else { + 1.0 + }; let bend_x = qx + sign * ARC_RADIUS; + // Guard: same logic as the vertical case but on the + // horizontal axis. When the gap is smaller than + // `ARC_RADIUS`, place the bend at the midpoint between + // the two tips (or ARC_RADIUS beyond p for the unusual + // case where p and q are on the same horizontal side). + let bend_x = if p_dx < 0.0 && bend_x >= px { + // p departs leftward; bend must be left of p (bend_x < px). + if px > qx { + (px + qx) / 2.0 + } else { + px - ARC_RADIUS + } + } else if p_dx > 0.0 && bend_x <= px { + // p departs rightward; symmetric case. + if px < qx { + (px + qx) / 2.0 + } else { + px + ARC_RADIUS + } + } else { + bend_x + }; let corner1_x = bend_x; let corner1_y = py; let corner2_x = bend_x; @@ -487,6 +548,50 @@ impl EdgePathBuilderPass2Ortho { } } + /// Caps the from-protrusion depth so that its tip does not overshoot + /// the to-protrusion tip for aligned opposite-face pairs. + /// + /// For face pairs where both protrusions extend toward each other along + /// the same axis (Bottom-Top, Top-Bottom, Left-Right, Right-Left), the + /// from-protrusion tip must not cross the to-protrusion tip. When + /// `from_protrusion + to_protrusion` exceeds the node-to-node gap, the + /// from-protrusion is reduced so that both tips meet at the same axis + /// coordinate. + /// + /// For non-aligned face pairs (e.g. Bottom-Bottom cycle edges, or + /// perpendicular L-shaped pairs) the from-protrusion is returned + /// unchanged. + /// + /// # Example values + /// + /// * `start_y = 172.0, end_y = 325.0, from_face = Bottom, to_face = Top, + /// from_protrusion = 73.44, to_protrusion = 110.0`: `gap = 153.0`, sum `= + /// 183.44 > 153.0` -> returns `43.0`. + #[allow(clippy::too_many_arguments)] + fn from_protrusion_capped( + start_x: f32, + start_y: f32, + end_x: f32, + end_y: f32, + from_face: NodeFace, + to_face: NodeFace, + from_protrusion: f32, + to_protrusion: f32, + ) -> f32 { + let gap = match (from_face, to_face) { + (NodeFace::Bottom, NodeFace::Top) => end_y - start_y, + (NodeFace::Top, NodeFace::Bottom) => start_y - end_y, + (NodeFace::Right, NodeFace::Left) => end_x - start_x, + (NodeFace::Left, NodeFace::Right) => start_x - end_x, + _ => return from_protrusion, + }; + if gap > 1e-3 && from_protrusion + to_protrusion > gap + 1e-3 { + (gap - to_protrusion).max(0.0) + } else { + from_protrusion + } + } + /// Returns the outward unit direction vector for a node face. /// /// # Example values diff --git a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_spacer_coordinates_calculator.rs b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_spacer_coordinates_calculator.rs new file mode 100644 index 0000000..37c2b3a --- /dev/null +++ b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_spacer_coordinates_calculator.rs @@ -0,0 +1,87 @@ +use disposition_model_common::RankDir; +use disposition_taffy_model::TaffyNodeCtx; +use taffy::TaffyTree; + +use crate::taffy_to_svg_elements_mapper::edge_path_builder_pass_1::SpacerCoordinates; + +/// Computes absolute spacer coordinates for a single taffy node. +/// +/// See [`EdgeSpacerCoordinatesCalculator::calculate`] for details. +pub struct EdgeSpacerCoordinatesCalculator; + +impl EdgeSpacerCoordinatesCalculator { + /// Computes absolute spacer coordinates for a single taffy node. + /// + /// Walks up the taffy tree to accumulate the absolute position, then + /// returns `SpacerCoordinates` with entry and exit points that + /// depend on `rank_dir`: + /// + /// * `RankDir::TopToBottom`: entry at top midpoint (smallest y), exit at + /// bottom midpoint (largest y). + /// * `RankDir::BottomToTop`: entry at bottom midpoint (largest y), exit at + /// top midpoint (smallest y). + /// * `RankDir::LeftToRight`: entry at left midpoint (smallest x), exit at + /// right midpoint (largest x). + /// * `RankDir::RightToLeft`: entry at right midpoint (largest x), exit at + /// left midpoint (smallest x). + pub fn calculate( + rank_dir: RankDir, + taffy_tree: &TaffyTree, + taffy_node_id: taffy::NodeId, + ) -> Option { + let layout = taffy_tree.layout(taffy_node_id).ok()?; + + // === Absolute Coordinates === // + let mut x_acc = layout.location.x; + let mut y_acc = layout.location.y; + let mut current_node_id = taffy_node_id; + while let Some(parent_taffy_node_id) = taffy_tree.parent(current_node_id) { + let Ok(parent_layout) = taffy_tree.layout(parent_taffy_node_id) else { + break; + }; + x_acc += parent_layout.location.x; + y_acc += parent_layout.location.y; + current_node_id = parent_taffy_node_id; + } + + let cx = x_acc + layout.size.width / 2.0; + let cy = y_acc + layout.size.height / 2.0; + let left_x = x_acc; + let right_x = x_acc + layout.size.width; + let top_y = y_acc; + let bottom_y = y_acc + layout.size.height; + + let spacer_coordinates = match rank_dir { + // Vertical flow: entry/exit share the same x (center), + // differ in y. + RankDir::TopToBottom => SpacerCoordinates { + entry_x: cx, + entry_y: top_y, + exit_x: cx, + exit_y: bottom_y, + }, + RankDir::BottomToTop => SpacerCoordinates { + entry_x: cx, + entry_y: bottom_y, + exit_x: cx, + exit_y: top_y, + }, + // Horizontal flow: entry/exit share the same y (center), + // differ in x. + RankDir::LeftToRight => SpacerCoordinates { + entry_x: left_x, + entry_y: cy, + exit_x: right_x, + exit_y: cy, + }, + RankDir::RightToLeft => SpacerCoordinates { + entry_x: right_x, + entry_y: cy, + exit_x: left_x, + exit_y: cy, + }, + }; + + Some(spacer_coordinates) + } +} diff --git a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/ortho_protrusion_calculator.rs b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/ortho_protrusion_calculator.rs index 6443fc7..4e36955 100644 --- a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/ortho_protrusion_calculator.rs +++ b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/ortho_protrusion_calculator.rs @@ -1,14 +1,15 @@ -use disposition_ir_model::node::{NodeId, NodeRank}; -use disposition_model_common::{Map, RankDir}; -use disposition_svg_model::SvgNodeInfo; +use disposition_ir_model::{ + entity::EntityTypes, + node::{NodeId, NodeNestingInfos, NodeRank, NodeRanksNested}, +}; +use disposition_model_common::{entity::EntityType, Map, RankDir}; +use disposition_svg_model::{OrthoProtrusionParams, SpacerProtrusionParams, SvgNodeInfo}; use disposition_taffy_model::{taffy::TaffyTree, EdgeSpacerTaffyNodes, TaffyNodeCtx}; use crate::taffy_to_svg_elements_mapper::{ edge_model::{EdgeContactPointOffsets, NodeFace, NodeIdAndFace}, edge_path_builder_pass_1::SpacerCoordinates, - edge_path_builder_pass_2::edge_path_builder_pass_2_ortho::{ - OrthoProtrusionParams, SpacerProtrusionParams, - }, + EdgeSpacerCoordinatesCalculator, }; use super::svg_edge_infos_builder::{EdgeGroupPass1, EdgePass1Info}; @@ -143,6 +144,20 @@ struct RankGapKey { rank_high: NodeRank, } +/// The high-level category of a node for grouping protrusion calculations. +/// +/// Protrusions and spacer computations are independent per category: +/// thing-node edges only consider other thing nodes when computing rank +/// gap boundaries and sibling extents; tag-node edges only consider tag nodes; +/// process-node edges only consider process and process step nodes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum NodeCategory { + Thing, + Tag, + Process, + Other, +} + impl OrthoProtrusionCalculator { /// Calculates protrusion parameters for every edge in every group. /// @@ -170,6 +185,9 @@ impl OrthoProtrusionCalculator { disposition_ir_model::edge::EdgeId<'id>, EdgeSpacerTaffyNodes, >, + node_nesting_infos: &NodeNestingInfos<'id>, + node_ranks_nested: &NodeRanksNested<'id>, + entity_types: &EntityTypes<'id>, ) -> Vec> { // === Step 1: Resolve spacer coordinates and initialize output === // // @@ -225,15 +243,36 @@ impl OrthoProtrusionCalculator { let rank_to = pass1_info.rank_to; // Determine rank ordering. - let (rank_low, rank_high) = if rank_from <= rank_to { + let (_rank_low, _rank_high) = if rank_from <= rank_to { (rank_from, rank_to) } else { (rank_to, rank_from) }; - // Skip self-loops and same-rank edges (no rank gap to - // protrude into). - if rank_low == rank_high { + // Cycle edges: register their endpoints in the adjacent rank + // gap so protrusion depths are distributed proportionally. + // Left/Right face pairs fall through to the MIN_PROTRUSION_PX + // safety net in Step 6. + if pass1_info.is_cycle_edge { + Self::cycle_edge_collect_rank_gap_entries( + group_idx, + edge_idx, + pass1_info, + from_slot_indices[edge_idx], + face_offsets_by_node_face, + svg_node_info_map, + node_nesting_infos, + node_ranks_nested, + entity_types, + &mut rank_gap_entries, + ); + continue; + } + + // Same-rank non-cycle edges (adjacent siblings, tag/process + // nodes) use normal face routing with zero protrusion and do + // not register rank-gap entries. + if rank_from == rank_to { continue; } @@ -605,6 +644,46 @@ impl OrthoProtrusionCalculator { } } + // === Step 5: Enforce minimum protrusions to clear divergent ancestor siblings + // === // + // + // For edges where the from/to nodes are at different nesting levels, + // the protrusion for each endpoint must be large enough to clear all + // sibling nodes of the endpoint's Divergent ancestor at the LCA level. + // + // The "Divergent ancestor" of a node is the ancestor that is a direct + // child of the LCA of the from and to nodes for this edge. + // + // Note: the TO endpoint adjustment is skipped when the edge has + // cross-container spacers. In that case the spacer already handles + // the routing inside the container, so the to_protrusion only needs + // to reach the spacer exit (not exit the container entirely). + Self::protrusions_adjust_for_divergent_siblings( + all_pass1_groups, + &all_spacer_coordinates, + node_nesting_infos, + node_ranks_nested, + svg_node_info_map, + entity_types, + &mut result, + ); + + // === Step 6: Finalise protrusion depths for cycle edges === // + // + // For cycle edges registered in the adjacent rank gap (Step 2), equalise + // from and to protrusions to produce symmetric U-shaped arcs. + // + // For unregistered cycle edges (boundary ranks or Left/Right faces), + // group by routing direction and assign stacked depths + // `(N * MIN_PROTRUSION_PX down to 1 * MIN_PROTRUSION_PX)` so that edges in + // the same group do not overlap. + Self::protrusions_assign_cycle_edges( + all_pass1_groups, + from_slot_indices_all, + face_offsets_by_node_face, + &mut result, + ); + result } @@ -1060,8 +1139,11 @@ impl OrthoProtrusionCalculator { .rank_to_spacer_taffy_node_id .iter() .filter_map(|(rank, &taffy_node_id)| { - let coords = - Self::spacer_absolute_coordinates(rank_dir, taffy_tree, taffy_node_id)?; + let coords = EdgeSpacerCoordinatesCalculator::calculate( + rank_dir, + taffy_tree, + taffy_node_id, + )?; Some((*rank, coords)) }) .collect(); @@ -1071,7 +1153,7 @@ impl OrthoProtrusionCalculator { .cross_container_spacer_taffy_node_ids .iter() .filter_map(|&taffy_node_id| { - Self::spacer_absolute_coordinates(rank_dir, taffy_tree, taffy_node_id) + EdgeSpacerCoordinatesCalculator::calculate(rank_dir, taffy_tree, taffy_node_id) }) .collect(); @@ -1108,80 +1190,6 @@ impl OrthoProtrusionCalculator { all_spacers } - /// Computes absolute spacer coordinates for a single taffy node. - /// - /// Walks up the taffy tree to accumulate the absolute position, then - /// returns `SpacerCoordinates` with entry and exit points that - /// depend on `rank_dir`: - /// - /// * `RankDir::TopToBottom`: entry at top midpoint (smallest y), exit at - /// bottom midpoint (largest y). - /// * `RankDir::BottomToTop`: entry at bottom midpoint (largest y), exit at - /// top midpoint (smallest y). - /// * `RankDir::LeftToRight`: entry at left midpoint (smallest x), exit at - /// right midpoint (largest x). - /// * `RankDir::RightToLeft`: entry at right midpoint (largest x), exit at - /// left midpoint (smallest x). - fn spacer_absolute_coordinates( - rank_dir: RankDir, - taffy_tree: &TaffyTree, - taffy_node_id: taffy::NodeId, - ) -> Option { - let layout = taffy_tree.layout(taffy_node_id).ok()?; - - let mut x_acc = layout.location.x; - let mut y_acc = layout.location.y; - let mut current_node_id = taffy_node_id; - while let Some(parent_taffy_node_id) = taffy_tree.parent(current_node_id) { - let Ok(parent_layout) = taffy_tree.layout(parent_taffy_node_id) else { - break; - }; - x_acc += parent_layout.location.x; - y_acc += parent_layout.location.y; - current_node_id = parent_taffy_node_id; - } - - let cx = x_acc + layout.size.width / 2.0; - let cy = y_acc + layout.size.height / 2.0; - let left_x = x_acc; - let right_x = x_acc + layout.size.width; - let top_y = y_acc; - let bottom_y = y_acc + layout.size.height; - - let spacer_coordinates = match rank_dir { - // Vertical flow: entry/exit share the same x (center), - // differ in y. - RankDir::TopToBottom => SpacerCoordinates { - entry_x: cx, - entry_y: top_y, - exit_x: cx, - exit_y: bottom_y, - }, - RankDir::BottomToTop => SpacerCoordinates { - entry_x: cx, - entry_y: bottom_y, - exit_x: cx, - exit_y: top_y, - }, - // Horizontal flow: entry/exit share the same y (center), - // differ in x. - RankDir::LeftToRight => SpacerCoordinates { - entry_x: left_x, - entry_y: cy, - exit_x: right_x, - exit_y: cy, - }, - RankDir::RightToLeft => SpacerCoordinates { - entry_x: right_x, - entry_y: cy, - exit_x: left_x, - exit_y: cy, - }, - }; - - Some(spacer_coordinates) - } - /// Returns the cross-axis coordinate of a node for a given face. /// /// For `Top` / `Bottom` faces the cross-axis is horizontal (X). @@ -1214,4 +1222,628 @@ impl OrthoProtrusionCalculator { NodeFace::Right => (info.x + info.width, info.y + info.height_collapsed / 2.0), } } + + /// Finalises protrusion depths for same-rank (cycle) edges. + /// + /// After gap-based protrusion assignment in Step 2–3, some cycle edges may + /// have a `from_protrusion` assigned (those whose `Top`/`Bottom` face + /// registered in an adjacent rank gap), while others still have zero + /// (boundary-rank edges, or `Left`/`Right` face edges with no rank gap). + /// + /// This step handles both: + /// + /// 1. **Registered cycle edges** (`from_protrusion > 0`): copies + /// `from_protrusion` to `to_protrusion` so both endpoints protrude + /// equally, creating a symmetric U-shaped routing arc. Applies + /// `MIN_PROTRUSION_PX` as a floor. + /// + /// 2. **Unregistered cycle edges** (`from_protrusion == 0`): groups edges + /// by `(from_face, rank_from)` -- all edges routing in the same + /// direction at the same rank. Within each group, sorts by face offset + /// then cross-axis coordinate (same ordering as `protrusions_assign` for + /// single-side entries). Assigns stacked depths: + /// - N edges in group -> depths `[N * MIN, (N-1) * MIN, .., MIN]` + /// - Sets `from_protrusion = to_protrusion = depth` for each edge. + fn protrusions_assign_cycle_edges<'id>( + all_pass1_groups: &[EdgeGroupPass1<'_, 'id>], + from_slot_indices_all: &[Vec>], + face_offsets_by_node_face: &Map, EdgeContactPointOffsets>, + result: &mut [Vec], + ) { + struct UnregisteredEntry { + group_idx: usize, + edge_idx: usize, + face_offset: f32, + cross_axis: f32, + from_face: NodeFace, + rank_from: NodeRank, + } + + let mut unregistered: Vec = Vec::new(); + + for (group_idx, group) in all_pass1_groups.iter().enumerate() { + let from_slot_indices = &from_slot_indices_all[group_idx]; + for (edge_idx, pass1_info) in group.pass1_infos.iter().enumerate() { + // Only cycle edges with a valid face. + if !pass1_info.is_cycle_edge { + continue; + } + let Some(from_face) = pass1_info.from_face else { + continue; + }; + + let params = &mut result[group_idx][edge_idx]; + + if params.from_protrusion > 0.0 { + // Registered in adjacent rank gap: equalize from and to, apply + // MIN floor. + let depth = params.from_protrusion.max(MIN_PROTRUSION_PX); + params.from_protrusion = depth; + params.to_protrusion = depth; + } else { + // Unregistered: collect for group stacking. + let face_offset = Self::face_offset_resolve( + pass1_info, + from_slot_indices[edge_idx], + true, + face_offsets_by_node_face, + ); + let cross_axis = Self::cross_axis_coord( + pass1_info.from_node_x, + pass1_info.from_node_y, + from_face, + ); + unregistered.push(UnregisteredEntry { + group_idx, + edge_idx, + face_offset, + cross_axis, + from_face, + rank_from: pass1_info.rank_from, + }); + } + } + } + + if unregistered.is_empty() { + return; + } + + // Sort: group by (face discriminant, rank_from), then within group by + // (face_offset ascending, cross_axis ascending). This produces the same + // ordering as `protrusions_assign` for single-side entries. + unregistered.sort_by(|a, b| { + let face_a = match a.from_face { + NodeFace::Top => 0u8, + NodeFace::Bottom => 1, + NodeFace::Left => 2, + NodeFace::Right => 3, + }; + let face_b = match b.from_face { + NodeFace::Top => 0u8, + NodeFace::Bottom => 1, + NodeFace::Left => 2, + NodeFace::Right => 3, + }; + let face_cmp = face_a.cmp(&face_b); + if face_cmp != std::cmp::Ordering::Equal { + return face_cmp; + } + let rank_cmp = a.rank_from.cmp(&b.rank_from); + if rank_cmp != std::cmp::Ordering::Equal { + return rank_cmp; + } + let off_cmp = a + .face_offset + .partial_cmp(&b.face_offset) + .unwrap_or(std::cmp::Ordering::Equal); + if off_cmp != std::cmp::Ordering::Equal { + return off_cmp; + } + a.cross_axis + .partial_cmp(&b.cross_axis) + .unwrap_or(std::cmp::Ordering::Equal) + }); + + // Assign stacked depths within each group. + let mut group_start = 0; + while group_start < unregistered.len() { + // Find the end of the current group (same face + rank). + let group_end = { + let key_face = unregistered[group_start].from_face; + let key_rank = unregistered[group_start].rank_from; + unregistered[group_start..] + .iter() + .position(|e| e.from_face != key_face || e.rank_from != key_rank) + .map(|rel| group_start + rel) + .unwrap_or(unregistered.len()) + }; + + let group = &unregistered[group_start..group_end]; + let n = group.len(); + let max_prot = n as f32 * MIN_PROTRUSION_PX; + + for (k, entry) in group.iter().enumerate() { + // Slot 0 (first sorted entry) -> longest protrusion (n * MIN). + // Slot N-1 (last sorted entry) -> shortest protrusion (1 * MIN). + let depth = if n == 1 { + MIN_PROTRUSION_PX + } else { + max_prot - k as f32 * (max_prot - MIN_PROTRUSION_PX) / (n - 1) as f32 + }; + let params = &mut result[entry.group_idx][entry.edge_idx]; + params.from_protrusion = depth; + params.to_protrusion = depth; + } + + group_start = group_end; + } + } + + /// Registers the cycle edge's **from-endpoint** in the adjacent rank gap + /// so protrusion depths are distributed proportionally to the available gap + /// space. + /// + /// For cycle edges (`rank_from == rank_to`), both endpoints are at the same + /// rank and use the same face. Depending on the face, the from-endpoint is + /// registered in an adjacent gap: + /// + /// - `Top` face at rank R -> register in gap `(R-1, R)` on the `High` side. + /// Skipped if R == 0 (no gap above). + /// - `Bottom` face at rank R -> register in gap `(R, R+1)` on the `Low` + /// side. + /// - `Left` / `Right` faces -> return early; + /// `protrusions_assign_cycle_edges` (Step 6) handles the fallback. + /// + /// Only the from-endpoint is registered here. + /// `protrusions_assign_cycle_edges` (Step 6) later copies + /// `from_protrusion` to `to_protrusion` so both endpoints protrude equally, + /// producing a symmetric U-shaped arc. + /// + /// This allows multiple cycle edges sharing the same gap to receive + /// proportionally distributed protrusion depths rather than all getting the + /// same fixed minimum. + #[allow(clippy::too_many_arguments)] + fn cycle_edge_collect_rank_gap_entries<'id>( + group_idx: usize, + edge_idx: usize, + pass1_info: &EdgePass1Info<'_, 'id>, + from_slot_index: Option, + face_offsets_by_node_face: &Map, EdgeContactPointOffsets>, + svg_node_info_map: &Map<&NodeId<'id>, &SvgNodeInfo<'id>>, + node_nesting_infos: &NodeNestingInfos<'id>, + node_ranks_nested: &NodeRanksNested<'id>, + entity_types: &EntityTypes<'id>, + rank_gap_entries: &mut Map>, + ) { + // Step 1: Skip if from_face or to_face is None. + let Some(from_face) = pass1_info.from_face else { + return; + }; + let Some(_to_face) = pass1_info.to_face else { + return; + }; + + // For cycle edges, both endpoints are at the same rank. + let rank = pass1_info.rank_from; + + // Steps 2–4: Determine gap key, gap side, and adjacent rank based on + // face. Left/Right faces have no applicable rank gap. + let (gap_key, gap_side, adjacent_rank) = match from_face { + NodeFace::Top => { + // Protrudes upward into gap (R-1, R). Skip if R == 0. + if rank.value() == 0 { + return; + } + let adjacent_rank = NodeRank::new(rank.value() - 1); + ( + RankGapKey { + rank_low: adjacent_rank, + rank_high: rank, + }, + GapSide::High, + adjacent_rank, + ) + } + NodeFace::Bottom => { + // Protrudes downward into gap (R, R+1). + let adjacent_rank = NodeRank::new(rank.value() + 1); + ( + RankGapKey { + rank_low: rank, + rank_high: adjacent_rank, + }, + GapSide::Low, + adjacent_rank, + ) + } + NodeFace::Left | NodeFace::Right => { + // No rank gap applicable; protrusions_assign_cycle_edges + // handles the fallback for these faces. + return; + } + }; + + // Step 5: Find parent container scope from the from-node's nesting + // info. The second-to-last element of the ancestor chain is the + // immediate parent container (None for root-level nodes). + let parent_container = node_nesting_infos + .get(&pass1_info.edge.from) + .and_then(|ni| { + ni.ancestor_chain + .len() + .checked_sub(2) + .map(|i| &ni.ancestor_chain[i]) + }); + + // Step 6: Look up ranks in scope for the parent container. + let Some(ranks_in_scope) = node_ranks_nested.ranks_for(parent_container) else { + return; + }; + + // Look up from node layout info. + let Some(&from_info) = svg_node_info_map.get(&pass1_info.edge.from) else { + return; + }; + // Guard: if the to-node is missing from layout, skip registration. + if svg_node_info_map.get(&pass1_info.edge.to).is_none() { + return; + } + + // Step 7: Compute the adjacent rank boundary. + // + // For `Top` face: adjacent rank R-1 is visually above; we want the + // maximum bottom edge (y + height_collapsed) of all R-1 nodes. + // For `Bottom` face: adjacent rank R+1 is visually below; we want the + // minimum top edge (y) of all R+1 nodes. + let from_category = Self::node_category(&pass1_info.edge.from, entity_types); + let adjacent_boundary_opt: Option = ranks_in_scope + .iter() + .filter(|&(_, rank)| *rank == adjacent_rank) + .filter(|(node_id, _)| Self::node_category(node_id, entity_types) == from_category) + .filter_map(|(node_id, _)| svg_node_info_map.get(node_id).copied()) + .fold(None, |acc, info| { + let coord = match from_face { + NodeFace::Top => info.y + info.height_collapsed, + NodeFace::Bottom => info.y, + _ => unreachable!(), + }; + Some(match acc { + None => coord, + Some(existing) => match from_face { + NodeFace::Top => existing.max(coord), + NodeFace::Bottom => existing.min(coord), + _ => unreachable!(), + }, + }) + }); + + let Some(adjacent_boundary) = adjacent_boundary_opt else { + // No adjacent-rank nodes found; no gap exists to register in. + return; + }; + + // Step 8: Compute from_rank_gap_px (distance from from-node face to + // the adjacent rank boundary). Return if the gap is zero or negative + // (nodes overlap or touch). + let from_rank_gap_px = match from_face { + NodeFace::Top => from_info.y - adjacent_boundary, + NodeFace::Bottom => adjacent_boundary - (from_info.y + from_info.height_collapsed), + _ => unreachable!(), + }; + if from_rank_gap_px <= 0.0 { + return; + } + + // Step 9: Resolve from face offset. + let from_offset = + Self::face_offset_resolve(pass1_info, from_slot_index, true, face_offsets_by_node_face); + + // Step 10: Compute from cross-axis coordinate. + let cross_axis_from = + Self::cross_axis_coord(pass1_info.from_node_x, pass1_info.from_node_y, from_face); + + // Step 11: Register the from-endpoint in the rank gap. + // `protrusions_assign_cycle_edges` (Step 6) will copy from_protrusion + // to to_protrusion afterward to produce a symmetric U-shaped arc. + rank_gap_entries + .entry(gap_key) + .or_default() + .push(RankGapEntry { + pass1_group_index: group_idx, + edge_index: edge_idx, + endpoint_kind: RankGapEndpointKind::FromEndpoint, + gap_side, + cross_axis_coord: cross_axis_from, + face_offset: from_offset, + rank_gap_px: from_rank_gap_px, + }); + } + + /// Adjusts protrusion values to clear sibling nodes of the Divergent + /// ancestor of each edge endpoint. + /// + /// For each edge, the from-endpoint's protrusion must be large enough + /// that the routing segment is in the gap between the tallest sibling + /// node (at the same rank as the Divergent ancestor) and the to-node. + /// Symmetric logic applies for the to-endpoint. + fn protrusions_adjust_for_divergent_siblings<'id>( + all_pass1_groups: &[EdgeGroupPass1<'_, 'id>], + all_spacer_coordinates: &[Vec>], + node_nesting_infos: &NodeNestingInfos<'id>, + node_ranks_nested: &NodeRanksNested<'id>, + svg_node_info_map: &Map<&NodeId<'id>, &SvgNodeInfo<'id>>, + entity_types: &EntityTypes<'id>, + result: &mut [Vec], + ) { + for (group_idx, group) in all_pass1_groups.iter().enumerate() { + for (edge_idx, pass1_info) in group.pass1_infos.iter().enumerate() { + // Same-rank non-cycle edges (adjacent siblings, tag/process + // nodes) use direct nearest-face routing with zero protrusion. + // Divergent-sibling adjustment does not apply to them. + if pass1_info.rank_from == pass1_info.rank_to && !pass1_info.is_cycle_edge { + continue; + } + // === From endpoint === // + if let Some(from_face) = pass1_info.from_face { + let min_from = Self::min_protrusion_divergent_sibling_extent( + &pass1_info.edge.from, + &pass1_info.edge.to, + from_face, + node_nesting_infos, + node_ranks_nested, + svg_node_info_map, + entity_types, + ); + if min_from > 0.0 { + let params = &mut result[group_idx][edge_idx]; + params.from_protrusion = params.from_protrusion.max(min_from); + } + } + + // === To endpoint === // + // + // When the edge has cross-container spacers, the spacer + // already handles routing inside the to-node's container. + // The to_protrusion only needs to reach the spacer exit, + // not exit the entire container. Applying the + // divergent-sibling adjustment in this case would force + // the protrusion all the way to the container's far + // boundary, causing the path to overshoot the spacer + // and produce a zigzag. + let edge_has_spacers = all_spacer_coordinates + .get(group_idx) + .and_then(|g| g.get(edge_idx)) + .map(|spacers| !spacers.is_empty()) + .unwrap_or(false); + + if !edge_has_spacers && let Some(to_face) = pass1_info.to_face { + let min_to = Self::min_protrusion_divergent_sibling_extent( + &pass1_info.edge.to, + &pass1_info.edge.from, + to_face, + node_nesting_infos, + node_ranks_nested, + svg_node_info_map, + entity_types, + ); + if min_to > 0.0 { + let params = &mut result[group_idx][edge_idx]; + params.to_protrusion = params.to_protrusion.max(min_to); + } + } + } + } + } + + /// Computes the minimum protrusion needed for `node_id`'s endpoint to + /// clear all sibling nodes of the node's Divergent ancestor at the LCA + /// level. + /// + /// The Divergent ancestor is the ancestor of `node_id` that is a direct + /// child of the LCA of (`node_id`, `other_node_id`). + /// + /// # Parameters + /// + /// * `node_id`: the endpoint node whose protrusion is being computed. + /// * `other_node_id`: the opposite endpoint of the edge (used to find the + /// LCA). + /// * `face`: the face at which `node_id` protrudes. + fn min_protrusion_divergent_sibling_extent<'id>( + node_id: &NodeId<'id>, + other_node_id: &NodeId<'id>, + face: NodeFace, + node_nesting_infos: &NodeNestingInfos<'id>, + node_ranks_nested: &NodeRanksNested<'id>, + svg_node_info_map: &Map<&NodeId<'id>, &SvgNodeInfo<'id>>, + entity_types: &EntityTypes<'id>, + ) -> f32 { + // 1. Compute LCA depth. + let lca_depth = Self::lca_depth(node_id, other_node_id, node_nesting_infos); + + // 2. Find divergent ancestor of node_id. + let Some(div_ancestor_id) = + Self::divergent_ancestor_id(node_id, lca_depth, node_nesting_infos) + else { + return 0.0; + }; + + // 3. Find parent container of divergent ancestor (None = root level). + let div_ancestor_parent = node_nesting_infos.get(div_ancestor_id).and_then(|ni| { + ni.ancestor_chain + .len() + .checked_sub(2) + .map(|i| &ni.ancestor_chain[i]) + }); + + // 4. Get rank of divergent ancestor in its parent container. + let Some(ranks) = node_ranks_nested.ranks_for(div_ancestor_parent) else { + return 0.0; + }; + let Some(&div_ancestor_rank) = ranks.get(div_ancestor_id) else { + return 0.0; + }; + + // 5. Collect all same-rank siblings of the same node category (including the + // divergent ancestor). Nodes from other categories (e.g. process nodes) are + // excluded so that thing-node edges are not routed around process nodes. + let node_cat = Self::node_category(node_id, entity_types); + let same_rank_siblings: Vec<&NodeId<'id>> = ranks + .iter() + .filter(|&(_, rank)| *rank == div_ancestor_rank) + .filter(|(id, _)| Self::node_category(id, entity_types) == node_cat) + .map(|(id, _)| id) + .collect(); + + // 6. Get face coordinate of node_id (the coordinate of the face in the + // rank/protrusion direction). + let Some(&node_info) = svg_node_info_map.get(node_id) else { + return 0.0; + }; + let node_face_coord = Self::face_coord_for_endpoint(node_info, face); + + // 7. Find extreme sibling coordinate in the protrusion direction. + let Some(sibling_extreme) = + Self::same_rank_sibling_extreme(&same_rank_siblings, face, svg_node_info_map) + else { + return 0.0; + }; + + // 8. Compute minimum protrusion: face_sign * (sibling_extreme - + // node_face_coord). For Bottom/Right faces (sign = +1): min = + // sibling_extreme - node_face_coord. For Top/Left faces (sign = -1): min = + // node_face_coord - sibling_extreme. + let face_sign: f32 = match face { + NodeFace::Bottom | NodeFace::Right => 1.0, + NodeFace::Top | NodeFace::Left => -1.0, + }; + (face_sign * (sibling_extreme - node_face_coord)).max(0.0) + } + + /// Returns the coordinate of a node's face along the protrusion axis. + /// + /// For `Bottom` face: the bottom edge y-coordinate. + /// For `Top` face: the top edge y-coordinate. + /// For `Right` face: the right edge x-coordinate. + /// For `Left` face: the left edge x-coordinate. + fn face_coord_for_endpoint(info: &SvgNodeInfo<'_>, face: NodeFace) -> f32 { + match face { + NodeFace::Bottom => info.y + info.height_collapsed, + NodeFace::Top => info.y, + NodeFace::Right => info.x + info.width, + NodeFace::Left => info.x, + } + } + + /// Returns the extreme coordinate of the same-rank sibling nodes in the + /// protrusion direction. + /// + /// For `Bottom`/`Right` faces: returns the maximum far-edge coordinate + /// (max of bottom edges or right edges across all siblings). + /// + /// For `Top`/`Left` faces: returns the minimum near-edge coordinate + /// (min of top edges or left edges across all siblings). + /// + /// Returns `None` if no sibling has a known layout in `svg_node_info_map`. + fn same_rank_sibling_extreme<'id>( + sibling_ids: &[&NodeId<'id>], + face: NodeFace, + svg_node_info_map: &Map<&NodeId<'id>, &SvgNodeInfo<'id>>, + ) -> Option { + let mut extreme: Option = None; + for id in sibling_ids { + let Some(&info) = svg_node_info_map.get(*id) else { + continue; + }; + let coord = Self::face_coord_for_endpoint(info, face); + extreme = Some(match extreme { + None => coord, + Some(existing) => match face { + NodeFace::Bottom | NodeFace::Right => existing.max(coord), + NodeFace::Top | NodeFace::Left => existing.min(coord), + }, + }); + } + extreme + } + + /// Returns the depth of the Lowest Common Ancestor (LCA) of two nodes. + /// + /// The LCA depth is the number of common ancestors the two nodes share. + /// A depth of 0 means the LCA is the diagram root (no shared ancestors), + /// so the Divergent ancestors are the nodes' first ancestors (or themselves + /// for root-level nodes). + /// + /// # Examples + /// + /// For root-level nodes `a` and `b`: returns `0` (no common ancestors). + /// + /// For siblings `c/child_0` and `c/child_1`: returns `1` (one common + /// ancestor: `c`). + fn lca_depth<'id>( + from_id: &NodeId<'id>, + to_id: &NodeId<'id>, + node_nesting_infos: &NodeNestingInfos<'id>, + ) -> usize { + let from_chain = match node_nesting_infos.get(from_id) { + Some(info) => info.ancestor_chain.as_slice(), + None => return 0, + }; + let to_chain = match node_nesting_infos.get(to_id) { + Some(info) => info.ancestor_chain.as_slice(), + None => return 0, + }; + + // Exclude the nodes themselves (last element of each chain). + let from_ancestors = from_chain.len().saturating_sub(1); + let to_ancestors = to_chain.len().saturating_sub(1); + + from_chain[..from_ancestors] + .iter() + .zip(to_chain[..to_ancestors].iter()) + .take_while(|(a, b)| a == b) + .count() + } + + /// Returns the divergent ancestor ID of `node_id` at the given LCA depth. + /// + /// The divergent ancestor is the ancestor of `node_id` that is a direct + /// child of the LCA. For root-level nodes (LCA is the diagram root, depth + /// = 0), this is the node's first ancestor (or itself if root-level). + /// + /// Returns `None` if the node is not found in `node_nesting_infos` or the + /// ancestor chain does not have an element at `lca_depth`. + fn divergent_ancestor_id<'a, 'id>( + node_id: &'a NodeId<'id>, + lca_depth: usize, + node_nesting_infos: &'a NodeNestingInfos<'id>, + ) -> Option<&'a NodeId<'id>> { + let chain = &node_nesting_infos.get(node_id)?.ancestor_chain; + chain.get(lca_depth) + } + + /// Returns the [`NodeCategory`] of a node from its entity types. + /// + /// - `ThingDefault` nodes are [`NodeCategory::Thing`]. + /// - `TagDefault` nodes are [`NodeCategory::Tag`]. + /// - `ProcessDefault` and `ProcessStepDefault` nodes are + /// [`NodeCategory::Process`]. + /// - All other nodes are [`NodeCategory::Other`]. + fn node_category<'id>(node_id: &NodeId<'id>, entity_types: &EntityTypes<'id>) -> NodeCategory { + entity_types + .get(node_id.as_ref()) + .map_or(NodeCategory::Other, |types| { + if types.contains(&EntityType::ThingDefault) { + NodeCategory::Thing + } else if types.contains(&EntityType::TagDefault) { + NodeCategory::Tag + } else if types.contains(&EntityType::ProcessDefault) + || types.contains(&EntityType::ProcessStepDefault) + { + NodeCategory::Process + } else { + NodeCategory::Other + } + }) + } } diff --git a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/svg_edge_infos_builder.rs b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/svg_edge_infos_builder.rs index 3cd72ef..e95bfe6 100644 --- a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/svg_edge_infos_builder.rs +++ b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/svg_edge_infos_builder.rs @@ -2,30 +2,32 @@ use disposition_input_ir_model::EdgeAnimationActive; use disposition_ir_model::{ edge::{Edge, EdgeGroup, EdgeId}, entity::EntityTypes, - node::{NodeId, NodeRank, NodeRanks}, + node::{NodeId, NodeNestingInfos, NodeRank, NodeRanksNested}, IrDiagram, }; use disposition_model_common::{ edge::EdgeCurvature, entity::EntityType, theme::Css, Id, Map, RankDir, }; -use disposition_svg_model::{SvgEdgeInfo, SvgNodeInfo}; +use disposition_svg_model::{OrthoProtrusionParams, SvgEdgeInfo, SvgNodeInfo}; use disposition_taffy_model::{taffy::TaffyTree, EdgeSpacerTaffyNodes, TaffyNodeCtx}; use kurbo::Shape; use disposition_ir_model::entity::EntityTailwindClasses; use disposition_model_common::edge::EdgeGroupId; -use crate::taffy_to_svg_elements_mapper::{ - edge_face_contact_tracker::EdgeFaceContactTracker, - edge_model::{ - EdgeAnimationParams, EdgeContactPointOffsets, EdgePathInfo, EdgeType, NodeFace, - NodeIdAndFace, PathBounds, PathMidpoint, +use crate::{ + taffy_to_svg_elements_mapper::{ + edge_face_contact_tracker::EdgeFaceContactTracker, + edge_model::{ + EdgeAnimationParams, EdgeContactPointOffsets, EdgePathInfo, EdgeType, NodeFace, + NodeIdAndFace, PathBounds, PathMidpoint, + }, + edge_path_builder_pass_1::{EdgeFaceOffset, SpacerCoordinates}, + ortho_protrusion_calculator::OrthoProtrusionCalculator, + ArrowHeadBuilder, EdgeAnimationCalculator, EdgePathBuilderPass1, EdgePathBuilderPass2, + EdgePathLocusCalculator, EdgeSpacerCoordinatesCalculator, StringCharReplacer, }, - edge_path_builder_pass_1::{EdgeFaceOffset, SpacerCoordinates}, - edge_path_builder_pass_2::edge_path_builder_pass_2_ortho::OrthoProtrusionParams, - ortho_protrusion_calculator::OrthoProtrusionCalculator, - ArrowHeadBuilder, EdgeAnimationCalculator, EdgePathBuilderPass1, EdgePathBuilderPass2, - EdgePathLocusCalculator, StringCharReplacer, + EdgeIdGenerator, }; /// Builds [`SvgEdgeInfo`]s for all edges in the diagram from edge groups and @@ -123,7 +125,8 @@ impl SvgEdgeInfosBuilder { edge_group, entity_types, svg_node_info_map, - &ir_diagram.node_ranks, + &ir_diagram.node_ranks_nested, + &ir_diagram.node_nesting_infos, &mut face_contact_tracker, ); all_pass1_groups.push(edge_group_pass1); @@ -160,6 +163,9 @@ impl SvgEdgeInfosBuilder { svg_node_info_map, taffy_tree, edge_spacer_taffy_nodes, + &ir_diagram.node_nesting_infos, + &ir_diagram.node_ranks_nested, + entity_types, ); // === Global Pass 2: rebuild paths with offsets, emit SvgEdgeInfos === // @@ -245,6 +251,7 @@ impl SvgEdgeInfosBuilder { path, path_length: _, preceding_visible_segments_lengths: _, + ortho_protrusion_params, } = edge_path_info; let path_d = path.to_svg(); @@ -286,6 +293,7 @@ impl SvgEdgeInfosBuilder { arrow_head_path_d, locus_path_d, tooltip, + ortho_protrusion_params, )); }); } @@ -300,13 +308,15 @@ impl SvgEdgeInfosBuilder { /// /// The returned `EdgeGroupPass1` contains everything needed for /// pass 2 to rebuild the paths with offsets. + #[allow(clippy::too_many_arguments)] fn build_edge_pass1_infos<'edge, 'id>( rank_dir: RankDir, edge_group_id: &'edge EdgeGroupId<'id>, edge_group: &'edge EdgeGroup<'id>, entity_types: &'edge EntityTypes<'id>, svg_node_info_map: &'edge Map<&NodeId<'id>, &SvgNodeInfo<'id>>, - node_ranks: &NodeRanks<'id>, + node_ranks_nested: &NodeRanksNested<'id>, + node_nesting_infos: &NodeNestingInfos<'id>, face_contact_tracker: &mut EdgeFaceContactTracker<'id>, ) -> EdgeGroupPass1<'edge, 'id> { let edge_animation_params = EdgeAnimationParams::default(); @@ -322,12 +332,46 @@ impl SvgEdgeInfosBuilder { continue; }; - let edge_id = Self::generate_edge_id(edge_group_id, edge_index); + let edge_id = EdgeIdGenerator::generate(edge_group_id, edge_index); let edge_type = Self::edge_type_determine(&edge_id, entity_types); + // Compute rank distance before face selection so that same-rank + // (cycle) edges can use clockwise face routing. Adjacent siblings + // with the same direct parent, and edges involving tag, process, or + // process step nodes always use normal face selection regardless of + // rank. + // + // Use LCA-level ranks so that cross-container edges (e.g. a top-level + // node connecting to a nested node) are not incorrectly classified as + // cycle edges. A simple local-context comparison can give false + // positives: both endpoints might have rank 0 in their respective + // parent containers while sitting at visually different positions in + // the diagram (because their containers have different root-level + // ranks). + let (rank_from, rank_to) = Self::nodes_lca_ranks_compute( + &edge.from, + &edge.to, + node_ranks_nested, + node_nesting_infos, + ) + .unwrap_or_else(|| { + let rank_from = node_ranks_nested + .node_rank_for(&edge.from, node_nesting_infos) + .unwrap_or_default(); + let rank_to = node_ranks_nested + .node_rank_for(&edge.to, node_nesting_infos) + .unwrap_or_default(); + (rank_from, rank_to) + }); + let rank_distance = rank_to.value().abs_diff(rank_from.value()); + let is_same_rank = rank_from == rank_to; + let is_cycle_edge = is_same_rank + && !Self::nodes_adjacent_siblings_are(&edge.from, &edge.to, node_nesting_infos); + // Build the path with zero offsets to determine natural coordinates. let path = EdgePathBuilderPass1::build(rank_dir, from_info, to_info, edge_type); - let faces = EdgePathBuilderPass1::faces_select(rank_dir, from_info, to_info); + let faces = + EdgePathBuilderPass1::faces_select(rank_dir, from_info, to_info, is_cycle_edge); let (from_face, to_face) = match faces { Some((from_face, to_face)) => (Some(from_face), Some(to_face)), @@ -346,11 +390,6 @@ impl SvgEdgeInfosBuilder { let path_midpoint = Self::path_midpoint_compute(&path); let path_bounds = Self::path_bounds_compute(&path); - // Compute rank distance between from and to nodes. - let rank_from = node_ranks.get(&edge.from).copied().unwrap_or_default(); - let rank_to = node_ranks.get(&edge.to).copied().unwrap_or_default(); - let rank_distance = rank_to.value().abs_diff(rank_from.value()); - // Store to-node coordinates for tie-breaking during sorting. let to_node_x = to_info.x; let to_node_y = to_info.y; @@ -373,6 +412,7 @@ impl SvgEdgeInfosBuilder { rank_to, from_node_x, from_node_y, + is_cycle_edge, }); } @@ -658,6 +698,9 @@ impl SvgEdgeInfosBuilder { face_offset, &spacer_coordinates, ortho_protrusion, + // Pass the faces computed in pass 1 (which uses cycle-aware + // face selection) so that pass 2 uses the same faces. + pass1_info.from_face.zip(pass1_info.to_face), ); let path_length = { let accuracy = 1.0; @@ -672,6 +715,7 @@ impl SvgEdgeInfosBuilder { path_length, preceding_visible_segments_lengths: pass1_info.edge_index as f64 * visible_segments_length, + ortho_protrusion_params: ortho_protrusion.clone(), } }) .collect::>() @@ -707,8 +751,11 @@ impl SvgEdgeInfosBuilder { .rank_to_spacer_taffy_node_id .iter() .filter_map(|(rank, &taffy_node_id)| { - let coords = - Self::spacer_absolute_coordinates(rank_dir, taffy_tree, taffy_node_id)?; + let coords = EdgeSpacerCoordinatesCalculator::calculate( + rank_dir, + taffy_tree, + taffy_node_id, + )?; Some((*rank, coords)) }) .collect(); @@ -718,7 +765,7 @@ impl SvgEdgeInfosBuilder { .cross_container_spacer_taffy_node_ids .iter() .filter_map(|&taffy_node_id| { - Self::spacer_absolute_coordinates(rank_dir, taffy_tree, taffy_node_id) + EdgeSpacerCoordinatesCalculator::calculate(rank_dir, taffy_tree, taffy_node_id) }) .collect(); @@ -755,81 +802,6 @@ impl SvgEdgeInfosBuilder { all_spacers } - /// Computes absolute spacer coordinates for a single taffy node. - /// - /// Walks up the taffy tree to accumulate the absolute position, then - /// returns `SpacerCoordinates` with entry and exit points that - /// depend on `rank_dir`: - /// - /// * `RankDir::TopToBottom`: entry at top midpoint (smallest y), exit at - /// bottom midpoint (largest y). - /// * `RankDir::BottomToTop`: entry at bottom midpoint (largest y), exit at - /// top midpoint (smallest y). - /// * `RankDir::LeftToRight`: entry at left midpoint (smallest x), exit at - /// right midpoint (largest x). - /// * `RankDir::RightToLeft`: entry at right midpoint (largest x), exit at - /// left midpoint (smallest x). - fn spacer_absolute_coordinates( - rank_dir: RankDir, - taffy_tree: &TaffyTree, - taffy_node_id: taffy::NodeId, - ) -> Option { - let layout = taffy_tree.layout(taffy_node_id).ok()?; - - // === Absolute Coordinates === // - let mut x_acc = layout.location.x; - let mut y_acc = layout.location.y; - let mut current_node_id = taffy_node_id; - while let Some(parent_taffy_node_id) = taffy_tree.parent(current_node_id) { - let Ok(parent_layout) = taffy_tree.layout(parent_taffy_node_id) else { - break; - }; - x_acc += parent_layout.location.x; - y_acc += parent_layout.location.y; - current_node_id = parent_taffy_node_id; - } - - let cx = x_acc + layout.size.width / 2.0; - let cy = y_acc + layout.size.height / 2.0; - let left_x = x_acc; - let right_x = x_acc + layout.size.width; - let top_y = y_acc; - let bottom_y = y_acc + layout.size.height; - - let spacer_coordinates = match rank_dir { - // Vertical flow: entry/exit share the same x (center), - // differ in y. - RankDir::TopToBottom => SpacerCoordinates { - entry_x: cx, - entry_y: top_y, - exit_x: cx, - exit_y: bottom_y, - }, - RankDir::BottomToTop => SpacerCoordinates { - entry_x: cx, - entry_y: bottom_y, - exit_x: cx, - exit_y: top_y, - }, - // Horizontal flow: entry/exit share the same y (center), - // differ in x. - RankDir::LeftToRight => SpacerCoordinates { - entry_x: left_x, - entry_y: cy, - exit_x: right_x, - exit_y: cy, - }, - RankDir::RightToLeft => SpacerCoordinates { - entry_x: right_x, - entry_y: cy, - exit_x: left_x, - exit_y: cy, - }, - }; - - Some(spacer_coordinates) - } - /// Determines the `EdgeType` for an edge based on the entity types /// associated with its edge ID. fn edge_type_determine(edge_id: &EdgeId<'_>, entity_types: &EntityTypes<'_>) -> EdgeType { @@ -872,6 +844,110 @@ impl SvgEdgeInfosBuilder { .unwrap_or(EdgeType::Unpaired) } + /// Returns `true` if the two nodes are adjacent siblings with the same + /// direct parent. + /// + /// Two nodes are adjacent siblings when: + /// + /// - They are at the same nesting depth (equal `nesting_path` lengths). + /// - All ancestor chain entries except the last are identical (same + /// parent). + /// - Their sibling indices (last `nesting_path` element) differ by exactly + /// 1. + fn nodes_adjacent_siblings_are<'id>( + node_id_from: &NodeId<'id>, + node_id_to: &NodeId<'id>, + node_nesting_infos: &NodeNestingInfos<'id>, + ) -> bool { + let Some(info_from) = node_nesting_infos.get(node_id_from) else { + return false; + }; + let Some(info_to) = node_nesting_infos.get(node_id_to) else { + return false; + }; + + let len = info_from.nesting_path.len(); + if len == 0 || len != info_to.nesting_path.len() { + return false; + } + + // Same parent: all ancestor chain entries except the last must match. + let parent_len = len.saturating_sub(1); + if info_from.ancestor_chain[..parent_len] != info_to.ancestor_chain[..parent_len] { + return false; + } + + // Adjacent: sibling indices differ by exactly 1. + let idx_from = info_from.nesting_path[len - 1]; + let idx_to = info_to.nesting_path[len - 1]; + idx_from.abs_diff(idx_to) == 1 + } + + /// Computes the ranks of the two nodes' divergent ancestors at their + /// lowest common ancestor (LCA) level. + /// + /// This is used to determine whether two nodes are truly at the same + /// visual rank in the diagram, accounting for hierarchy nesting. + /// + /// For nodes in the same container the result is identical to their + /// local `node_rank_for` values. For cross-container edges the local + /// ranks can give false positives (both endpoints at rank 0 in their + /// respective parent contexts even though their containers are at + /// different root-level ranks). + /// + /// Returns `None` when: + /// * either node is not found in `node_nesting_infos`, or + /// * one node is an ancestor of the other (contained edge -- handled + /// separately by `is_node_contained_in`). + fn nodes_lca_ranks_compute<'id>( + node_id_from: &NodeId<'id>, + node_id_to: &NodeId<'id>, + node_ranks_nested: &NodeRanksNested<'id>, + node_nesting_infos: &NodeNestingInfos<'id>, + ) -> Option<(NodeRank, NodeRank)> { + let info_from = node_nesting_infos.get(node_id_from)?; + let info_to = node_nesting_infos.get(node_id_to)?; + + // Find the LCA depth: length of the common prefix of ancestor chains. + let max_compare = info_from + .ancestor_chain + .len() + .min(info_to.ancestor_chain.len()); + let mut lca_depth = 0; + for i in 0..max_compare { + if info_from.ancestor_chain[i] == info_to.ancestor_chain[i] { + lca_depth = i + 1; + } else { + break; + } + } + + let divergent_from = info_from.ancestor_chain.get(lca_depth)?; + let divergent_to = info_to.ancestor_chain.get(lca_depth)?; + + // If both diverge to the same node, one is an ancestor of the other. + if divergent_from == divergent_to { + return None; + } + + // Get the LCA container (None means root level). + let lca_container = lca_depth + .checked_sub(1) + .map(|i| &info_from.ancestor_chain[i]); + let container_ranks = node_ranks_nested.ranks_for(lca_container)?; + + let rank_from = container_ranks + .get(divergent_from) + .copied() + .unwrap_or_default(); + let rank_to = container_ranks + .get(divergent_to) + .copied() + .unwrap_or_default(); + + Some((rank_from, rank_to)) + } + /// Computes the midpoint of a `BezPath` as the mean of its anchor /// points (MoveTo, LineTo, and the final point of CurveTo / QuadTo /// elements). @@ -1094,14 +1170,6 @@ impl SvgEdgeInfosBuilder { .into_static(); tailwind_classes.insert(arrow_head_entity_id, arrow_head_classes); } - - /// Generates an edge ID from the edge group ID and edge index. - fn generate_edge_id(edge_group_id: &EdgeGroupId<'_>, edge_index: usize) -> EdgeId<'static> { - let edge_id_str = format!("{edge_group_id}__{edge_index}"); - Id::try_from(edge_id_str) - .expect("edge ID should be valid") - .into() - } } // === Supporting types === // @@ -1242,6 +1310,16 @@ pub(super) struct EdgePass1Info<'edge, 'id> { /// /// `30.0` pub(super) from_node_y: f32, + /// Whether this edge uses cycle (clockwise) face routing. + /// + /// `true` when all of the following hold: + /// - `rank_from == rank_to` (same rank). + /// - The `from` and `to` nodes are not adjacent siblings with the same + /// direct parent. + /// - Neither endpoint is a tag, process, or process step node. + /// + /// When `false` the nearest-face heuristic is used instead. + pub(super) is_cycle_edge: bool, } /// All pass-1 data for a single edge group. diff --git a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/svg_node_info_builder.rs b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/svg_node_info_builder.rs index 93f8b55..a728552 100644 --- a/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/svg_node_info_builder.rs +++ b/crate/input_ir_rt/src/taffy_to_svg_elements_mapper/svg_node_info_builder.rs @@ -72,6 +72,10 @@ impl SvgNodeInfoBuilder { } else { None }; + let node_rank = ir_diagram + .node_ranks_nested + .node_rank_for(node_id, &ir_diagram.node_nesting_infos) + .unwrap_or_else(|| panic!("node_rank not found for node_id: {:?}", node_id)); let node_shape = ir_diagram.node_shapes.get(node_id).unwrap_or(default_shape); let path_d_collapsed = SvgNodeRectPathBuilder::build(width, height_collapsed, node_shape); @@ -150,6 +154,7 @@ impl SvgNodeInfoBuilder { if let Some(circle) = circle_info { SvgNodeInfo::with_circle( node_id.clone(), + node_rank, tab_index, x, y, @@ -164,6 +169,7 @@ impl SvgNodeInfoBuilder { } else { SvgNodeInfo::new( node_id.clone(), + node_rank, tab_index, x, y, diff --git a/crate/ir_model/src/ir_diagram.rs b/crate/ir_model/src/ir_diagram.rs index aa02640..08f1c63 100644 --- a/crate/ir_model/src/ir_diagram.rs +++ b/crate/ir_model/src/ir_diagram.rs @@ -5,7 +5,10 @@ use crate::{ edge::EdgeGroups, entity::{EntityDescs, EntityTailwindClasses, EntityTypes}, layout::NodeLayouts, - node::{NodeCopyText, NodeHierarchy, NodeNames, NodeOrdering, NodeRanks, NodeShapes}, + node::{ + NodeCopyText, NodeHierarchy, NodeNames, NodeNestingInfos, NodeOrdering, NodeRanksNested, + NodeShapes, + }, process::ProcessStepEntities, }; @@ -154,13 +157,21 @@ pub struct IrDiagram<'id> { #[serde(default, skip_serializing_if = "NodeLayouts::is_empty")] pub node_layouts: NodeLayouts<'id>, - /// Computed ranks for nodes based on dependency edges. + /// Hierarchy-aware computed ranks for nodes based on dependency edges. /// - /// Nodes with higher ranks are positioned further along the flex direction - /// axis (further down for column layouts, further right for row layouts). - /// Nodes without any dependency edges default to rank `0`. - #[serde(default, skip_serializing_if = "NodeRanks::is_empty")] - pub node_ranks: NodeRanks<'id>, + /// Holds a rank map for the root level and for each container node. + /// Within each level, nodes with higher ranks are positioned further along + /// the flex direction axis. Dependency edges that cross container + /// boundaries are attributed to the lowest common ancestor (LCA) level. + #[serde(default, skip_serializing_if = "NodeRanksNested::is_empty")] + pub node_ranks_nested: NodeRanksNested<'id>, + + /// Nesting information for each node in the hierarchy. + /// + /// Contains each node's ancestor chain and sibling index path from the + /// root. Used to compute edge spacer positions for cross-rank edges. + #[serde(default, skip_serializing_if = "NodeNestingInfos::is_empty")] + pub node_nesting_infos: NodeNestingInfos<'id>, /// Shape configuration for each node. /// @@ -213,7 +224,8 @@ impl<'id> IrDiagram<'id> { entity_types: self.entity_types.into_static(), tailwind_classes: self.tailwind_classes.into_static(), node_layouts: self.node_layouts.into_static(), - node_ranks: self.node_ranks.into_static(), + node_ranks_nested: self.node_ranks_nested.into_static(), + node_nesting_infos: self.node_nesting_infos.into_static(), node_shapes: self.node_shapes.into_static(), process_step_entities: self.process_step_entities.into_static(), render_options: self.render_options, diff --git a/crate/ir_model/src/node.rs b/crate/ir_model/src/node.rs index 33473b7..ebf42d8 100644 --- a/crate/ir_model/src/node.rs +++ b/crate/ir_model/src/node.rs @@ -1,7 +1,8 @@ pub use self::{ node_copy_text::NodeCopyText, node_hierarchy::NodeHierarchy, node_id::NodeId, - node_inbuilt::NodeInbuilt, node_names::NodeNames, node_ordering::NodeOrdering, - node_rank::NodeRank, node_ranks::NodeRanks, node_shape::NodeShape, + node_inbuilt::NodeInbuilt, node_names::NodeNames, node_nesting_info::NodeNestingInfo, + node_nesting_infos::NodeNestingInfos, node_ordering::NodeOrdering, node_rank::NodeRank, + node_ranks::NodeRanks, node_ranks_nested::NodeRanksNested, node_shape::NodeShape, node_shape_circle::NodeShapeCircle, node_shape_rect::NodeShapeRect, node_shapes::NodeShapes, }; @@ -10,9 +11,12 @@ mod node_hierarchy; mod node_id; mod node_inbuilt; mod node_names; +mod node_nesting_info; +mod node_nesting_infos; mod node_ordering; mod node_rank; mod node_ranks; +mod node_ranks_nested; mod node_shape; mod node_shape_circle; mod node_shape_rect; diff --git a/crate/ir_model/src/node/node_nesting_info.rs b/crate/ir_model/src/node/node_nesting_info.rs new file mode 100644 index 0000000..2d595fc --- /dev/null +++ b/crate/ir_model/src/node/node_nesting_info.rs @@ -0,0 +1,57 @@ +use serde::{Deserialize, Serialize}; + +use crate::node::NodeId; + +/// Information about a node's position in the hierarchy. +/// +/// Captures where a node sits in the node hierarchy, including the path of +/// sibling indices from the root to the node, and the sequence of ancestor +/// `NodeId`s. +/// +/// # Examples +/// +/// A node `proc_app_dev_step_repository_clone` nested inside `proc_app_dev` +/// at position 0 would have: +/// +/// ```yaml +/// proc_app_dev_step_repository_clone: +/// nesting_path: +/// - 2 +/// - 0 +/// ancestor_chain: +/// - proc_app_dev +/// - proc_app_dev_step_repository_clone +/// ``` +#[cfg_attr( + all(feature = "schemars", not(feature = "test")), + derive(schemars::JsonSchema) +)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +pub struct NodeNestingInfo<'id> { + /// Sequence of sibling indices at each level from root to this node. + /// + /// For example, `[2, 0]` means "third top-level node, first child". + pub nesting_path: Vec, + /// Sequence of `NodeId`s from root to this node (inclusive). + /// + /// For example, for node `c01` inside `c0`, this would be + /// `[NodeId("c0"), NodeId("c01")]`. + pub ancestor_chain: Vec>, +} + +impl<'id> NodeNestingInfo<'id> { + /// Converts this `NodeNestingInfo` into one with a `'static` lifetime. + /// + /// If any inner `Cow` is borrowed, this will clone the string to create + /// an owned version. + pub fn into_static(self) -> NodeNestingInfo<'static> { + NodeNestingInfo { + nesting_path: self.nesting_path, + ancestor_chain: self + .ancestor_chain + .into_iter() + .map(NodeId::into_static) + .collect(), + } + } +} diff --git a/crate/ir_model/src/node/node_nesting_infos.rs b/crate/ir_model/src/node/node_nesting_infos.rs new file mode 100644 index 0000000..8fbab42 --- /dev/null +++ b/crate/ir_model/src/node/node_nesting_infos.rs @@ -0,0 +1,102 @@ +use std::ops::{Deref, DerefMut}; + +use disposition_model_common::{Id, Map}; +use serde::{Deserialize, Serialize}; + +use crate::node::{NodeId, NodeNestingInfo}; + +/// Map of node IDs to their nesting information. +/// +/// Captures the hierarchy position for each node, including the path of +/// sibling indices from the root and the sequence of ancestor `NodeId`s. +/// +/// # Example +/// +/// ```yaml +/// node_nesting_infos: +/// t_aws: +/// nesting_path: +/// - 5 +/// ancestor_chain: +/// - t_aws +/// t_aws_iam: +/// nesting_path: +/// - 5 +/// - 0 +/// ancestor_chain: +/// - t_aws +/// - t_aws_iam +/// ``` +#[cfg_attr( + all(feature = "schemars", not(feature = "test")), + derive(schemars::JsonSchema) +)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] +pub struct NodeNestingInfos<'id>(Map, NodeNestingInfo<'id>>); + +impl<'id> NodeNestingInfos<'id> { + /// Returns a new empty `NodeNestingInfos` map. + pub fn new() -> Self { + Self::default() + } + + /// Returns a new `NodeNestingInfos` map with the given preallocated + /// capacity. + pub fn with_capacity(capacity: usize) -> Self { + Self(Map::with_capacity(capacity)) + } + + /// Returns the underlying map. + pub fn into_inner(self) -> Map, NodeNestingInfo<'id>> { + self.0 + } + + /// Returns true if the map is empty. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Returns true if this contains nesting info for a node with the given ID. + pub fn contains_key(&self, id: &IdT) -> bool + where + IdT: AsRef>, + { + self.0.contains_key(id.as_ref()) + } + + /// Converts this `NodeNestingInfos` into one with a `'static` lifetime. + pub fn into_static(self) -> NodeNestingInfos<'static> { + NodeNestingInfos( + self.0 + .into_iter() + .map(|(node_id, nesting_info)| (node_id.into_static(), nesting_info.into_static())) + .collect(), + ) + } +} + +impl<'id> Deref for NodeNestingInfos<'id> { + type Target = Map, NodeNestingInfo<'id>>; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl<'id> DerefMut for NodeNestingInfos<'id> { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl<'id> From, NodeNestingInfo<'id>>> for NodeNestingInfos<'id> { + fn from(inner: Map, NodeNestingInfo<'id>>) -> Self { + Self(inner) + } +} + +impl<'id> FromIterator<(NodeId<'id>, NodeNestingInfo<'id>)> for NodeNestingInfos<'id> { + fn from_iter, NodeNestingInfo<'id>)>>(iter: I) -> Self { + Self(Map::from_iter(iter)) + } +} diff --git a/crate/ir_model/src/node/node_ranks_nested.rs b/crate/ir_model/src/node/node_ranks_nested.rs new file mode 100644 index 0000000..ddf891a --- /dev/null +++ b/crate/ir_model/src/node/node_ranks_nested.rs @@ -0,0 +1,139 @@ +use disposition_model_common::Map; +use serde::{Deserialize, Serialize}; + +use crate::node::{NodeId, NodeNestingInfos, NodeRank, NodeRanks}; + +/// Hierarchy-aware node rank maps. +/// +/// Holds a [`NodeRanks`] for the root level (direct children of the diagram +/// root) and for each container node that has direct children. Ranks are +/// computed independently per level. +/// +/// Dependency edges that cross container boundaries are attributed to the +/// lowest common ancestor (LCA) of the two endpoints. At that level, the +/// first divergent sibling ancestors are used to determine the rank +/// relationship. +/// +/// # Fields +/// +/// * `root` -- ranks for the top-level nodes (direct children of the diagram +/// root). +/// * `containers` -- ranks for each container node's direct children; keyed by +/// the container `NodeId`. Only containers with at least one child are +/// included. +/// +/// # Example +/// +/// For a hierarchy and edges: +/// +/// ```yaml +/// node_hierarchy: +/// a: { a_child: {} } +/// b: { b_child_0: {}, b_child_1: {} } +/// c: { c_child: {} } +/// +/// edges: +/// edge_a_b: { from: a, to: b } +/// edge_b_child_0__b_child_1: { from: b_child_0, to: b_child_1 } +/// edge_b_child_0__c_child: { from: b_child_0, to: c_child } +/// ``` +/// +/// The resulting `NodeRanksNested` would be: +/// +/// ```yaml +/// node_ranks_nested: +/// root: +/// a: 0 +/// b: 1 +/// c: 2 # lifted from edge_b_child_0__c_child (LCA = root, b -> c) +/// containers: +/// a: +/// a_child: 0 +/// b: +/// b_child_0: 0 +/// b_child_1: 1 +/// c: +/// c_child: 0 # edge_b_child_0__c_child is at root level; ignored here +/// ``` +#[cfg_attr( + all(feature = "schemars", not(feature = "test")), + derive(schemars::JsonSchema) +)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize, Serialize)] +pub struct NodeRanksNested<'id> { + /// Ranks for the top-level nodes (direct children of the diagram root). + /// + /// Computed from dependency edges whose LCA is the diagram root (i.e. + /// edges between top-level nodes or edges whose endpoints' first divergent + /// sibling ancestors are both top-level nodes). + #[serde(default, skip_serializing_if = "NodeRanks::is_empty")] + pub root: NodeRanks<'id>, + + /// Ranks for each container node's direct children. + /// + /// Only container nodes with at least one direct child are included. + /// For each container, the ranks are computed from dependency edges whose + /// LCA is that container -- i.e. edges between siblings of that container. + #[serde(default, skip_serializing_if = "Map::is_empty")] + pub containers: Map, NodeRanks<'id>>, +} + +impl<'id> NodeRanksNested<'id> { + /// Returns a new empty `NodeRanksNested`. + pub fn new() -> Self { + Self::default() + } + + /// Returns true if both `root` and `containers` are empty. + pub fn is_empty(&self) -> bool { + self.root.is_empty() && self.containers.is_empty() + } + + /// Returns the [`NodeRanks`] for the given container, if any. + /// + /// Pass `None` to retrieve the root-level ranks. Pass `Some(container_id)` + /// to retrieve the ranks for direct children of the given container. + /// + /// Always returns `Some` for `None` (root) regardless of whether `root` + /// is empty. Returns `None` when a container is not found. + pub fn ranks_for(&self, container: Option<&NodeId<'id>>) -> Option<&NodeRanks<'id>> { + match container { + None => Some(&self.root), + Some(container_id) => self.containers.get(container_id), + } + } + + /// Returns the [`NodeRank`] for the given node using its parent container. + /// + /// Looks up the node's parent container from `node_nesting_infos` (the + /// second-to-last element of its `ancestor_chain`, or `None` for root-level + /// nodes), then retrieves the rank from the corresponding [`NodeRanks`]. + /// + /// Returns `None` if the node is not found in `node_nesting_infos` or has + /// no rank entry in its container's [`NodeRanks`]. + pub fn node_rank_for( + &self, + node_id: &NodeId<'id>, + node_nesting_infos: &NodeNestingInfos<'id>, + ) -> Option { + let nesting_info = node_nesting_infos.get(node_id)?; + let chain = &nesting_info.ancestor_chain; + let parent = chain.len().checked_sub(2).map(|i| &chain[i]); + self.ranks_for(parent)?.get(node_id).copied() + } + + /// Converts this `NodeRanksNested` into one with a `'static` lifetime. + /// + /// If any inner `Cow` is borrowed, this will clone the string to create + /// an owned version. + pub fn into_static(self) -> NodeRanksNested<'static> { + NodeRanksNested { + root: self.root.into_static(), + containers: self + .containers + .into_iter() + .map(|(node_id, ranks)| (node_id.into_static(), ranks.into_static())) + .collect(), + } + } +} diff --git a/crate/svg_model/src/lib.rs b/crate/svg_model/src/lib.rs index ef05b5c..ab35755 100644 --- a/crate/svg_model/src/lib.rs +++ b/crate/svg_model/src/lib.rs @@ -1,11 +1,14 @@ //! Data types for disposition to represent SVG elements. pub use crate::{ - svg_edge_info::SvgEdgeInfo, svg_elements::SvgElements, svg_node_info::SvgNodeInfo, - svg_node_info_circle::SvgNodeInfoCircle, svg_process_info::SvgProcessInfo, - svg_text_span::SvgTextSpan, + ortho_protrusion_params::OrthoProtrusionParams, + spacer_protrusion_params::SpacerProtrusionParams, svg_edge_info::SvgEdgeInfo, + svg_elements::SvgElements, svg_node_info::SvgNodeInfo, svg_node_info_circle::SvgNodeInfoCircle, + svg_process_info::SvgProcessInfo, svg_text_span::SvgTextSpan, }; +mod ortho_protrusion_params; +mod spacer_protrusion_params; mod svg_edge_info; mod svg_elements; mod svg_node_info; diff --git a/crate/svg_model/src/ortho_protrusion_params.rs b/crate/svg_model/src/ortho_protrusion_params.rs new file mode 100644 index 0000000..d72675c --- /dev/null +++ b/crate/svg_model/src/ortho_protrusion_params.rs @@ -0,0 +1,56 @@ +use serde::{Deserialize, Serialize}; + +use crate::SpacerProtrusionParams; + +/// Protrusion lengths for the from-node and to-node endpoints of an +/// orthogonal edge path, plus per-spacer protrusion depths. +/// +/// A protrusion is a short stub that exits the node face perpendicular +/// to the face line before the main orthogonal routing begins. This +/// separates parallel edges that share the same node face. +/// +/// Spacer protrusions serve the same purpose at intermediate spacer +/// boundaries: they extend the path past the spacer so that the +/// routing leg between spacers does not run along a node face, and +/// multiple edges crossing the same inter-rank gap use distinct +/// depths. +/// +/// # Example values +/// +/// ```rust,ignore +/// OrthoProtrusionParams { +/// from_protrusion: 12.0, +/// to_protrusion: 8.0, +/// spacer_protrusions: vec![ +/// SpacerProtrusionParams { entry_protrusion: 12.0, exit_protrusion: 5.0 }, +/// ], +/// } +/// ``` +/// +/// An edge whose from-node is close to the face midpoint gets a longer +/// `from_protrusion`; an edge further from the midpoint gets a shorter +/// one. Each spacer's entry and exit protrusions are computed +/// independently based on the edges sharing that specific rank gap. +#[cfg_attr( + all(feature = "schemars", not(feature = "test")), + derive(schemars::JsonSchema) +)] +#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)] +pub struct OrthoProtrusionParams { + /// Protrusion length in pixels at the from-node endpoint. + /// + /// `0.0` means no protrusion (the path routes directly from the + /// contact point). + pub from_protrusion: f32, + + /// Protrusion length in pixels at the to-node endpoint. + /// + /// `0.0` means no protrusion. + pub to_protrusion: f32, + + /// Per-spacer protrusion depths, indexed in the same order as the + /// `spacers` slice passed to `build_spacer_edge_path`. + /// + /// When the edge has no spacers, this is empty. + pub spacer_protrusions: Vec, +} diff --git a/crate/svg_model/src/spacer_protrusion_params.rs b/crate/svg_model/src/spacer_protrusion_params.rs new file mode 100644 index 0000000..b0fe724 --- /dev/null +++ b/crate/svg_model/src/spacer_protrusion_params.rs @@ -0,0 +1,33 @@ +use serde::{Deserialize, Serialize}; + +/// Protrusion lengths for the entry and exit sides of a single spacer. +/// +/// The entry-side protrusion extends the path past the spacer's entry +/// boundary (away from the spacer, into the gap before it). The +/// exit-side protrusion extends the path past the spacer's exit +/// boundary (away from the spacer, into the gap after it). +/// +/// Protrusion depths are assigned by `OrthoProtrusionCalculator` so +/// that edges sharing the same inter-rank gap use distinct depths. +/// +/// # Example values +/// +/// ```rust,ignore +/// SpacerProtrusionParams { entry_protrusion: 5.0, exit_protrusion: 8.0 } +/// ``` +#[cfg_attr( + all(feature = "schemars", not(feature = "test")), + derive(schemars::JsonSchema) +)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Deserialize, Serialize)] +pub struct SpacerProtrusionParams { + /// Protrusion length in pixels on the entry side of the spacer. + /// + /// `0.0` means no protrusion on the entry side. + pub entry_protrusion: f32, + + /// Protrusion length in pixels on the exit side of the spacer. + /// + /// `0.0` means no protrusion on the exit side. + pub exit_protrusion: f32, +} diff --git a/crate/svg_model/src/svg_edge_info.rs b/crate/svg_model/src/svg_edge_info.rs index 96512ee..0c93c36 100644 --- a/crate/svg_model/src/svg_edge_info.rs +++ b/crate/svg_model/src/svg_edge_info.rs @@ -2,6 +2,8 @@ use disposition_ir_model::{edge::EdgeId, node::NodeId}; use disposition_model_common::edge::EdgeGroupId; use serde::{Deserialize, Serialize}; +use crate::OrthoProtrusionParams; + /// Information to render SVG elements for edges. /// /// This includes: @@ -14,7 +16,7 @@ use serde::{Deserialize, Serialize}; all(feature = "schemars", not(feature = "test")), derive(schemars::JsonSchema) )] -#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] pub struct SvgEdgeInfo<'id> { /// ID of the edge this `SvgEdgeInfo` represents. pub edge_id: EdgeId<'id>, @@ -44,6 +46,16 @@ pub struct SvgEdgeInfo<'id> { /// When non-empty, rendered as a `` element inside the edge's `<g>` /// element. Example value: `"Sends a request to the API server."`. pub tooltip: String, + /// Orthogonal protrusion parameters computed for this edge. + /// + /// Contains the from/to protrusion lengths and per-spacer protrusion + /// depths used when building the edge's orthogonal path. Zero for + /// non-orthogonal (curved) edges. + /// + /// Example value: `OrthoProtrusionParams { from_protrusion: 12.0, + /// to_protrusion: 8.0, .. }` + #[serde(default)] + pub ortho_protrusion_params: OrthoProtrusionParams, } impl<'id> SvgEdgeInfo<'id> { @@ -58,6 +70,7 @@ impl<'id> SvgEdgeInfo<'id> { arrow_head_path_d: String, locus_path_d: String, tooltip: String, + ortho_protrusion_params: OrthoProtrusionParams, ) -> Self { Self { edge_id, @@ -68,6 +81,7 @@ impl<'id> SvgEdgeInfo<'id> { arrow_head_path_d, locus_path_d, tooltip, + ortho_protrusion_params, } } } diff --git a/crate/svg_model/src/svg_node_info.rs b/crate/svg_model/src/svg_node_info.rs index ae06e09..d2b7517 100644 --- a/crate/svg_model/src/svg_node_info.rs +++ b/crate/svg_model/src/svg_node_info.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use disposition_ir_model::node::NodeId; +use disposition_ir_model::node::{NodeId, NodeRank}; use serde::{Deserialize, Serialize}; use crate::{SvgNodeInfoCircle, SvgTextSpan}; @@ -23,6 +23,9 @@ use crate::{SvgNodeInfoCircle, SvgTextSpan}; pub struct SvgNodeInfo<'id> { /// ID of the IR node this `SvgNodeInfo` represents. pub node_id: NodeId<'id>, + /// Rank of the node, not used in rendering but useful for understanding the + /// node's position in the graph. + pub node_rank: NodeRank, /// Tab index for keyboard navigation. pub tab_index: u32, /// X coordinate (absolute position). @@ -70,6 +73,7 @@ impl<'id> SvgNodeInfo<'id> { #[allow(clippy::too_many_arguments)] pub fn new( node_id: NodeId<'id>, + node_rank: NodeRank, tab_index: u32, x: f32, y: f32, @@ -82,6 +86,7 @@ impl<'id> SvgNodeInfo<'id> { ) -> Self { Self { node_id, + node_rank, tab_index, x, y, @@ -100,6 +105,7 @@ impl<'id> SvgNodeInfo<'id> { #[allow(clippy::too_many_arguments)] pub fn with_circle( node_id: NodeId<'id>, + node_rank: NodeRank, tab_index: u32, x: f32, y: f32, @@ -113,6 +119,7 @@ impl<'id> SvgNodeInfo<'id> { ) -> Self { Self { node_id, + node_rank, tab_index, x, y, diff --git a/doc/src/edge_paths.md b/doc/src/edge_paths.md index b741dd1..2c1015d 100644 --- a/doc/src/edge_paths.md +++ b/doc/src/edge_paths.md @@ -3,9 +3,9 @@ 1. Nodes are connected to other nodes via edges 2. Nodes are laid out in a flex layout with recursive flex layout containers 3. Between nodes, "spacer nodes" may be inserted, which serve as coordinate markers for edge paths, so that when edge paths are calculated, the path is routed through spacer nodes to avoid drawing lines over the diagram nodes. -4. Nodes also have a `NodeRank`, which is "the highest rank of nodes connected to this node, plus one". If there are no nodes connected to this node, the `NodeRank` is `0`. +4. Nodes have a `NodeRank` that positions them along the rank axis within their container level. Ranks are stored in [`NodeRanksNested`](crate/ir_model/src/node/node_ranks_nested.rs), which holds a rank map for the root level and for each container node's direct children. Dependency edges that cross container boundaries are attributed to the lowest common ancestor (LCA) level. See [Node Rank Calculation](#node-rank-calculation) for details. 5. Part of the information gathered for calculating spacer nodes is collecting a `BTreeMap<NodeRank, Vec<taffy::NodeId>>`. -6. Calculation of where to place spacer nodes is done in [`ir_to_taffy_builder.rs`](crate/input_ir_rt/src/ir_to_taffy_builder.rs), in `fn build_taffy_child_nodes_for_node_by_rank`, called by `fn build_taffy_nodes_for_node_with_child_hierarchy`. +6. Calculation of where to place spacer nodes is done in [`ir_to_taffy_builder.rs`](crate/input_ir_rt/src/ir_to_taffy_builder.rs), in `fn build_taffy_child_nodes_for_node_by_rank`, called by `fn build_taffy_nodes_for_node_with_child_hierarchy`. Cross-container spacers (alongside sibling children of a container that an edge passes through) are only inserted for siblings whose rank is **strictly less than** the target child's rank. Siblings at the same rank as the target are side-by-side and do not block the incoming edge path; siblings at higher ranks are beyond the target and are similarly not in the path. In particular, edges that connect to a rank-0 node inside a container require no cross-container spacers at all, because there are no siblings between the container entry and rank 0. Furthermore, **at most one spacer is created per rank group**: if multiple siblings share the same rank (and therefore occupy the same layout row), a single spacer is sufficient to route the edge around the entire row -- creating one spacer per sibling would cause the path builder to zigzag through redundant waypoints. 7. Edge path calculation is done in two passes. 8. Both passes are called in [`svg_edge_infos_builder.rs`](crate/input_ir_rt/src/taffy_to_svg_elements_mapper/svg_edge_infos_builder.rs) 9. The first pass calculates a path between the from-node and to-node without taking into account spacer nodes, and the information from this first path is used in subsequent calculations. This is defined in [`edge_path_builder_pass_1.rs`](crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_path_builder_pass_1.rs) @@ -17,6 +17,89 @@ 15. The second pass computes the edge paths with the offsets and protrusion, which should result in paths that are visually non-overlapping with other paths and node content, creating visual clarity. +## Node Rank Calculation + +Node ranks are stored in [`NodeRanksNested`](crate/ir_model/src/node/node_ranks_nested.rs) and computed by [`NodeRanksCalculator`](crate/input_ir_rt/src/node_ranks_calculator.rs). Ranks are hierarchy-aware: each container node has its own [`NodeRanks`](crate/ir_model/src/node/node_ranks.rs) for its direct children, computed independently from other levels. + + +### Why hierarchy-aware ranks exist + +In a nested node hierarchy, dependency edges may connect nodes at different nesting depths. A flat rank assignment would assign ranks globally, conflating unrelated nodes at different levels. By computing `NodeRanks` per level, each container's children are ranked relative to their siblings only, which correctly drives their layout position within their container. + + +### Concepts + +- **Level**: a set of sibling nodes sharing the same parent container. The root level consists of the top-level nodes in the diagram. Each container node defines its own level for its direct children. +- **Container**: a node that has at least one direct child. Its direct children form a level with their own `NodeRanks`. +- **LCA (Lowest Common Ancestor)**: for two nodes `A` and `B`, the deepest node in the hierarchy that is an ancestor of both. For same-level siblings, the LCA is their shared parent. For top-level nodes, the LCA is the diagram root. +- **Divergent ancestor**: given two nodes and their LCA, the divergent ancestor of a node is the direct child of the LCA that is an ancestor of (or equal to) that node. It is the element at depth `lca_depth` in the node's `ancestor_chain`. +- **LCA-level edge**: for an edge `(from, to)`, the corresponding edge `(divergent_from, divergent_to)` between the two divergent ancestors at the LCA. Cross-container edges are "lifted" to the LCA level. + + +### Algorithm + +`NodeRanksNested` is computed in four steps: + +1. **Build container-to-children map.** Using [`NodeNestingInfos`](crate/ir_model/src/node/node_nesting_infos.rs), group each node under its parent container: the second-to-last element of its `ancestor_chain`, or `None` for top-level nodes. + +2. **Collect dependency edges.** Dependency edges are extracted from `EdgeGroups` by checking entity types for `DependencyEdge*` variants. + +3. **Lift edges to LCA level.** For each dependency edge `(from, to)`: + - Look up `NodeNestingInfo` for both endpoints. + - Compute the LCA depth: the length of the common prefix of the two `ancestor_chain`s. + - Derive `divergent_from = ancestor_chain_from[lca_depth]` and `divergent_to = ancestor_chain_to[lca_depth]`. + - Derive `lca_container = ancestor_chain_from[lca_depth - 1]` (or `None` if `lca_depth == 0`). + - Skip edges where one node is an ancestor of the other (`lca_depth >= min chain length`). + - Group the LCA-level edge `(divergent_from, divergent_to)` under `lca_container`. + +4. **Compute ranks per level.** For each container and its direct children, run the SCC-based longest-path rank assignment using only the LCA-level edges for that container. Nodes in cycles receive the same rank. + + +### Example + +For the hierarchy and edges: + +```yaml +node_hierarchy: + a: { a_child: {} } + b: { b_child_0: {}, b_child_1: {} } + c: { c_child: {} } + d: { d_child: {} } + +edges: + edge_dep_a__b: { from: a, to: b } + edge_dep_b_child_0__b_child_1: { from: b_child_0, to: b_child_1 } + edge_dep_b_child_0__c_child: { from: b_child_0, to: c_child } +``` + +Edge attribution: + +- `edge_dep_a__b`: `ancestor_chain(a) = [a]`, `ancestor_chain(b) = [b]`, LCA depth = 0, LCA = root, LCA-level edge = `(a, b)` at root level. +- `edge_dep_b_child_0__b_child_1`: `ancestor_chain(b_child_0) = [b, b_child_0]`, `ancestor_chain(b_child_1) = [b, b_child_1]`, LCA depth = 1, LCA = `b`, LCA-level edge = `(b_child_0, b_child_1)` at `b`'s level. +- `edge_dep_b_child_0__c_child`: `ancestor_chain(b_child_0) = [b, b_child_0]`, `ancestor_chain(c_child) = [c, c_child]`, LCA depth = 0, LCA = root, divergent ancestors = `b` and `c`, LCA-level edge = `(b, c)` at root level. + +Resulting `NodeRanksNested`: + +```yaml +node_ranks_nested: + root: + a: 0 + b: 1 + c: 2 # LCA-lifted from edge_dep_b_child_0__c_child: b -> c + d: 0 + containers: + a: + a_child: 0 # no edges in a's level + b: + b_child_0: 0 + b_child_1: 1 + c: + c_child: 0 # edge_dep_b_child_0__c_child is at root level, not c's level + d: + d_child: 0 # no edges in d's level +``` + + ## Offset Calculation Offset calculation spreads multiple edge contact points along a node face so they do not all touch at the same coordinate. The offset for each edge is a signed pixel distance from the face midpoint. Offsets are computed in `fn face_offsets_compute` in [`svg_edge_infos_builder.rs`](crate/input_ir_rt/src/taffy_to_svg_elements_mapper/svg_edge_infos_builder.rs), using [`edge_face_contact_tracker.rs`](crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_face_contact_tracker.rs) for the per-face arithmetic. @@ -82,6 +165,13 @@ Protrusion calculation is implemented in [`ortho_protrusion_calculator.rs`](crat 20. **Rank gap entry**: a `RankGapEntry` record representing one endpoint in one rank gap. It stores: the edge's group/index, which endpoint kind it is (`FromEndpoint`, `ToEndpoint`, `SpacerEntry`, `SpacerExit`), which gap side, the cross-axis coordinate (perpendicular to the rank direction), the face offset (slot offset from face midpoint), and the pixel distance of the rank gap. 21. **Crossing edge**: an edge that has entries on **both** sides of the same rank gap (e.g. a from-endpoint on the `Low` side and a spacer entry on the `High` side of the same gap). Crossing edges need special treatment to avoid their routing midpoints coinciding. 22. **Cross-axis coordinate**: the coordinate perpendicular to the rank direction. For `Top`/`Bottom` faces (vertical rank flow) this is the X coordinate; for `Left`/`Right` faces (horizontal rank flow) this is the Y coordinate. Used to sort endpoints spatially within a gap. +23. **Node category**: a coarse grouping of node entity types used to keep protrusion and rank-gap calculations independent across unrelated node groups. The categories are: + - `Thing` -- `ThingDefault` nodes. + - `Tag` -- `TagDefault` nodes. + - `Process` -- `ProcessDefault` and `ProcessStepDefault` nodes. + - `Other` -- nodes with no recognised entity type. + + When computing rank-gap boundaries (for cycle edges) or divergent-sibling extents (for all edges), only nodes within the **same category** as the edge's from-node are considered. This prevents thing-node edges from being routed around process nodes, and tag-node edges from being routed around thing nodes, even when those unrelated nodes share the same rank in the layout. Implemented by `OrthoProtrusionCalculator::node_category` and applied in `cycle_edge_collect_rank_gap_entries` and `min_protrusion_divergent_sibling_extent`. ### Algorithm overview (`fn calculate`) @@ -96,8 +186,8 @@ The algorithm in `OrthoProtrusionCalculator::calculate` has four steps: For each edge across all groups: - - The from-endpoint is registered in the rank gap between the from-node's rank and the adjacent rank toward the to-node. - - The to-endpoint is registered in the rank gap between the to-node's rank and the adjacent rank toward the from-node. + - **Same-rank (cycle) edges** with `Top` or `Bottom` faces are handled by `cycle_edge_collect_rank_gap_entries`. Both the from-endpoint and the to-endpoint are registered as same-side entries in the adjacent rank gap: `Top` face -> gap `(rank-1, rank)` on the `High` side; `Bottom` face -> gap `(rank, rank+1)` on the `Low` side. The `rank_gap_px` for each endpoint is the pixel distance from the node's face to the nearest boundary of the adjacent rank (the maximum bottom edge of rank-R-1 nodes for `Top`, or the minimum top edge of rank-R+1 nodes for `Bottom`). **Only nodes of the same category as the from-node** are included in this boundary search, so thing-node cycle edges are not pushed out by process nodes at the same rank. Cycle edges with `Left` or `Right` faces are skipped here and fall through to the `MIN_PROTRUSION_PX` safety net in Step 6. + - **Non-cycle edges**: The from-endpoint is registered in the rank gap between the from-node's rank and the adjacent rank toward the to-node. The to-endpoint is registered in the rank gap between the to-node's rank and the adjacent rank toward the from-node. - Each intermediate spacer contributes two entries: its entry side protrudes into the gap before it, and its exit side protrudes into the gap after it. The first spacer's entry shares the same gap as the from-endpoint (opposite side), and the last spacer's exit shares the same gap as the to-endpoint (opposite side). - Each entry records its `GapSide`, cross-axis coordinate, face offset, and rank gap pixel distance (computed by `rank_gap_px` for node endpoints or `spacer_gap_px` for spacer-to-spacer gaps). @@ -109,6 +199,12 @@ The algorithm in `OrthoProtrusionCalculator::calculate` has four steps: If the first spacer's entry protrusion or the last spacer's exit protrusion was not assigned (because the node face was `None`), it falls back to the from/to protrusion value as a safety net. +27. **Step 5: Enforce minimum protrusions to clear divergent ancestor siblings (`fn protrusions_adjust_for_divergent_siblings`).** + + For edges where the from/to nodes are at different nesting levels, each endpoint's protrusion must be large enough to clear all same-rank sibling nodes of the endpoint's **Divergent ancestor** at the LCA level. **Only nodes of the same category as the endpoint node** are considered as siblings, so a thing-node endpoint is not made to clear process nodes that happen to share the same rank. + + The FROM endpoint adjustment is always applied. The **TO endpoint adjustment is skipped when the edge has cross-container spacers**: in that case the spacer already handles routing inside the to-node's container, so the `to_protrusion` only needs to reach the spacer exit (not exit the container's far boundary). Applying the adjustment with a spacer present would force `to_protrusion` all the way to the container boundary, causing the path to overshoot the spacer, re-enter the container from the outside, and produce a visual zigzag. + ### Protrusion depth assignment (`fn protrusions_assign`) @@ -148,13 +244,14 @@ This function assigns protrusion depths to all endpoints within a single rank ga ### Helper functions -33. **`rank_gap_px`**: computes the pixel distance in the rank direction for one endpoint. For the from-endpoint, this is the distance from the from-node's face center to the first spacer entry (or to-node if no spacers). For the to-endpoint, it is the distance from the to-node's face center to the last spacer exit (or from-node if no spacers). +33. **`rank_gap_px`**: computes the pixel distance in the rank direction for one non-cycle endpoint. For the from-endpoint, this is the distance from the from-node's face center to the first spacer entry (or to-node if no spacers). For the to-endpoint, it is the distance from the to-node's face center to the last spacer exit (or from-node if no spacers). Cycle edge endpoints use a different computation in `cycle_edge_collect_rank_gap_entries` (see below). 34. **`spacer_gap_px`**: computes the pixel distance between two consecutive spacers along the rank axis (from the exit of one spacer to the entry of the next). 35. **`spacer_gap_key`**: computes the `RankGapKey` for the gap between two consecutive spacers by interpolating ranks between the from-node and to-node. 36. **`face_offset_resolve`**: resolves the face offset (slot offset) for a single endpoint from `face_offsets_by_node_face`. Spacer endpoints have a face offset of `0.0`. 37. **`cross_axis_coord`**: returns the cross-axis coordinate: X for `Top`/`Bottom` faces, Y for `Left`/`Right` faces. 38. **`axis_distance`**: computes the absolute distance along the rank axis between two points: `|by - ay|` for `Top`/`Bottom` faces, `|bx - ax|` for `Left`/`Right` faces. 39. **`protrusion_write`**: writes the computed protrusion depth into the correct slot in the output (`from_protrusion`, `to_protrusion`, or `spacer_protrusions[i].entry_protrusion` / `exit_protrusion`), dispatching on `RankGapEndpointKind`. +40. **`node_category`**: maps a node's entity types to a [`NodeCategory`](#concepts) variant (`Thing`, `Tag`, `Process`, or `Other`). Used in `cycle_edge_collect_rank_gap_entries` and `min_protrusion_divergent_sibling_extent` to filter out unrelated node types from rank-gap and sibling-extent calculations. ### How protrusions are consumed @@ -163,13 +260,140 @@ This function assigns protrusion depths to all endpoints within a single rank ga 41. At each node endpoint, if the protrusion is non-zero, an extra waypoint is added at `(contact_x + face_outward_dx * protrusion, contact_y + face_outward_dy * protrusion)`, extending the path perpendicular to the face before the main routing segment begins. 42. At each spacer, four waypoints are added: the exit + exit protrusion, the exit, the entry, and the entry - entry protrusion. The protrusion waypoints push the routing legs away from node faces so that parallel edges sharing the same gap run at distinct depths. +### Protrusion-tip crossing guard (`fn from_protrusion_capped`) + +43. For edges between deeply-nested nodes the slot-assigned `from_protrusion` can exceed the available gap between the two outer containers. When the divergent-sibling adjustment (Step 5) simultaneously raises `to_protrusion` to clear the destination container hierarchy, the sum `from_protrusion + to_protrusion` can exceed the full node-to-node gap. This places the from-tip *past* the to-tip in the routing direction -- the tips cross -- so any Z/S segment drawn between them would re-enter the destination container. + + **Example (0002 doubly-nested diagram):** + - `t_alice_inner` bottom face: y = 172. + - `t_charlie_inner` top face: y = 325. Gap = 153 px. + - Slot-assigned `from_protrusion = 73.44`, divergent-sibling `to_protrusion = 110.0`. + - Sum = 183.44 > 153 -- tips would cross: from-tip at y = 245.44 is below to-tip at y = 215. + + The fix is applied in `build_ortho_edge_path` via a helper `fn from_protrusion_capped`. For aligned opposite-face pairs (Bottom-Top, Top-Bottom, Left-Right, Right-Left), it computes `gap = |end_axis - start_axis|` and, when `from_protrusion + to_protrusion > gap`, caps the from-protrusion to `(gap - to_protrusion).max(0.0)`. For other face-pair combinations (cycle edges, L-shaped routing) it returns the from-protrusion unchanged. + + After the cap, both protrusion tips meet at the same axis coordinate. The V-spike guard in `connect_waypoints` (see item 45) then replaces the Z/S U-bend between the tips with a straight horizontal line -- no direction reversal occurs at the meeting point. + +### Same-axis collinear check in `connect_waypoints` + +44. `connect_waypoints` uses a dot-product check to detect when two consecutive waypoints are collinear with the departure direction and should be joined by a straight line rather than a Z/S or L-shaped bend. The original check was `dot_p.abs() > 0.95`, which accepted both collinear (`dot_p > 0.95`) and *anti*-collinear (`dot_p < -0.95`) cases. + + The anti-collinear case includes two fundamentally different situations: + + - **Same-axis return** -- the return leg from a protrusion tip back to the node contact point. Both points have the same x-coordinate (for a vertical face) or same y-coordinate (for a horizontal face). The displacement is purely backward (dot_p = -1), and a straight line is correct. + + - **Anti-collinear with perpendicular offset** -- the protrusion tips cross (see above) or the path connects two tips at different positions with a large backward component. `dot_p` approaches -1 when the perpendicular offset is small relative to the backward displacement. A straight diagonal line is *incorrect* here; an orthogonal Z/S bend is required. + + The fix replaces `dot_p.abs() > 0.95` with `dot_p > 0.95 || is_same_axis`, where `is_same_axis` is `true` when both waypoints share the same routing axis (no perpendicular component): `|dx| < 1e-3` for a vertical departure, `|dy| < 1e-3` for a horizontal departure. This correctly draws straight lines for same-axis returns while routing the anti-collinear-with-offset case through the Z/S logic. + +### V-spike guard for opposite-direction tips at the same axis coordinate + +45. After `from_protrusion_capped` lands both protrusion tips at the same Y (or X for horizontal routing), the two tip waypoints have **opposite** departure directions: the to-tip departs upward (`Top` face, `dir = (0,-1)`) and the from-tip departs downward (`Bottom` face, `dir = (0,+1)`). The standard Z/S U-bend logic would route: + + 1. **Leg 1** -- upward from the to-tip `p = (px, py)` to a bend at `(px, py - ARC_RADIUS)`. + 2. **Leg 2** -- horizontally across to `(qx, py - ARC_RADIUS)`. + 3. **Leg 3** -- downward from the bend back to the from-tip `q = (qx, qy)` (same Y as p). + + But the continuation from q (the `is_same_axis` return leg toward the from-contact point) immediately travels **upward**, reversing direction. The resulting down-then-up **V-spike** at q is visually incoherent. + + The fix is in `connect_waypoints`: at the start of the vertical Z/S branch, before computing the bend point, a guard fires when `|py - qy| < 1e-3` **and** `p_dy * q_dy < 0` (opposite vertical directions). In that case a **straight horizontal line** is drawn from p to q and the function returns early. The horizontal analogue applies for the horizontal Z/S branch (`|px - qx| < 1e-3`, `p_dx * q_dx < 0`). + + **Example (0002 doubly-nested diagram):** after capping, `from_protrusion_eff = 43`, placing the from-tip at `(88.5, 215)` with `dir = (0,+1)` and the to-tip at `(97, 215)` with `dir = (0,-1)`. The guard fires and the complete path becomes: + + ``` + M97,325 L97,215 L88.5,215 L88.5,172 + ``` + + an orthogonal Z-shape (up 110 px stub, left 8.5 px cross, up 43 px stub) with no direction reversals. + ## Spacer Coordinate Direction Awareness 43. Spacer nodes are 5x5 px taffy leaf nodes inserted at intermediate ranks. After taffy computes the layout, each spacer's absolute position is resolved into a `SpacerCoordinates { entry_x, entry_y, exit_x, exit_y }`, representing the entry and exit points that the edge path passes through. -44. The entry and exit points of a spacer depend on the diagram's `RankDir`. This is implemented in `fn spacer_absolute_coordinates` in both [`svg_edge_infos_builder.rs`](crate/input_ir_rt/src/taffy_to_svg_elements_mapper/svg_edge_infos_builder.rs) and [`ortho_protrusion_calculator.rs`](crate/input_ir_rt/src/taffy_to_svg_elements_mapper/ortho_protrusion_calculator.rs): +44. The entry and exit points of a spacer depend on the diagram's `RankDir`. This is implemented in `fn calculate` in [`edge_spacer_coordinates_calculator.rs`](crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_spacer_coordinates_calculator.rs): - `TopToBottom` -- entry at the top midpoint (smallest y), exit at the bottom midpoint (largest y). The path passes vertically downward through the spacer. - `BottomToTop` -- entry at the bottom midpoint (largest y), exit at the top midpoint (smallest y). The path passes vertically upward through the spacer. - `LeftToRight` -- entry at the left midpoint (smallest x), exit at the right midpoint (largest x). The path passes horizontally rightward through the spacer. - `RightToLeft` -- entry at the right midpoint (largest x), exit at the left midpoint (smallest x). The path passes horizontally leftward through the spacer. 45. When cross-container spacers are merged with rank-based spacers, they are sorted by the main-axis coordinate (`entry_y` for vertical flows, `entry_x` for horizontal flows) so that the spacers appear in the correct visual order along the edge path. This sorting is implemented in `fn spacer_coordinates_from_spacers` in [`svg_edge_infos_builder.rs`](crate/input_ir_rt/src/taffy_to_svg_elements_mapper/svg_edge_infos_builder.rs) and `fn spacer_coordinates_resolve` in [`ortho_protrusion_calculator.rs`](crate/input_ir_rt/src/taffy_to_svg_elements_mapper/ortho_protrusion_calculator.rs). + + +## Cycle Edge Routing + +46. Edges between nodes at the **same `NodeRank`** (cycle edges) need special treatment for two reasons. First, they need clockwise face selection to route around the outside of nodes (rather than connecting nearest faces, which would route through nodes). Second, their protrusions must be distributed using the adjacent rank gap's available space, so that multiple cycle edges sharing the same gap get distinct protrusion depths instead of all collapsing to the same fixed minimum. Without special handling the Z/S routing bend falls exactly at the node face boundary and the segment overlaps the node. +47. Same-rank edges are detected in `build_edge_pass1_infos` in [`svg_edge_infos_builder.rs`](crate/input_ir_rt/src/taffy_to_svg_elements_mapper/svg_edge_infos_builder.rs) by comparing the ranks of the `from` and `to` nodes at their **LCA (Lowest Common Ancestor) level** before face selection. Using local context ranks (each node's rank within its own parent container) would give false positives for cross-container edges: two nodes in different containers can both have rank 0 in their respective parent contexts while sitting at visually different positions in the diagram. The LCA-level ranks avoid this by comparing the ranks of the *divergent ancestors* -- the direct children of the LCA that are ancestors of (or equal to) each node. When `rank_from == rank_to` at the LCA level, the `is_same_rank` flag is set to `true` and passed to `faces_select`. The `is_cycle_edge` flag is set to `true` only when all of the following conditions hold: + - `rank_from == rank_to` at the LCA level (same visual rank), + - the two nodes are **not** adjacent siblings (nesting-path index difference > 1). + + +### Clockwise face selection (`fn cycle_edge_faces_select`) + +48. When `is_same_rank` is `true`, `faces_select` delegates to `cycle_edge_faces_select` in [`edge_path_builder_pass_1.rs`](crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_path_builder_pass_1.rs). This function returns a pair of node faces that routes the edge **clockwise around the outside** of the involved nodes. +49. The selection is purely geometric and does not depend on the `RankDir`: + + | Relative position of `from` vs `to` | `from` face | `to` face | Routing path | + |--------------------------------------|-------------|-----------|----------------------------------------------| + | `from` left of `to` (`dx > 0`) | `Top` | `Top` | Protrude up, arc right above, enter top | + | `from` right of `to` (`dx < 0`) | `Bottom` | `Bottom` | Protrude down, arc left below, enter bottom | + | `from` above `to` (`|dy| > |dx|`, `dy > 0`) | `Right` | `Right` | Protrude right, arc down right side, enter right | + | `from` below `to` (`|dy| > |dx|`, `dy < 0`) | `Left` | `Left` | Protrude left, arc up left side, enter left | + + The tie-breaking condition `dx.abs() >= dy.abs()` means that when the horizontal displacement equals the vertical displacement, the horizontal rule applies. + + +### Gap-based protrusion for cycle edges (`fn cycle_edge_collect_rank_gap_entries`) + +50. For cycle edges with `Top` or `Bottom` faces, only the **from-endpoint** is registered in the adjacent rank gap in Step 2 of `calculate` (not the to-endpoint). This lets it compete for protrusion slots alongside non-cycle edges in the same gap. Step 6 (`fn protrusions_assign_cycle_edges`) then copies the assigned depth to the to-endpoint, producing a symmetric U-shaped arc. + + - **`rank_gap_px` for cycle edges**: the pixel distance is computed directly from layout coordinates, not from the distance to the other endpoint. The **adjacent rank boundary** is found by iterating over all nodes at the adjacent rank within the same scope (`node_ranks_nested.ranks_for(parent_container)`), then taking: + - For `Top` face: the **maximum** `y + height_collapsed` (bottom edge) of adjacent rank-R-1 nodes. + - For `Bottom` face: the **minimum** `y` (top edge) of adjacent rank-R+1 nodes. + + Then `rank_gap_px = node.y - adjacent_boundary` (Top) or `adjacent_boundary - (node.y + node.height_collapsed)` (Bottom). If no adjacent-rank nodes exist, or the computed gap is non-positive, the endpoint is not registered (falls through to Step 6). + + - **Gap side**: the from-endpoint of a cycle edge is on the `High` side for `Top` face, or `Low` side for `Bottom` face. This makes it a `single_side` entry in `protrusions_assign`, receiving a unique slot. + + - **Sharing the gap with non-cycle edges**: if non-cycle edges also have endpoints in the same gap (e.g. an edge from rank R-1 to rank R contributes its to-endpoint on the `High` side of gap (R-1, R)), all entries compete together for slots. The tightest `rank_gap_px` across all entries determines the maximum protrusion via `MAX_GAP_FRACTION = 0.48`. This ensures protrusions never exceed 48% of the actual gap, leaving room for the routing segment, arrowhead, and other entries on the opposite side. + +### Cycle edge protrusion finalisation (`fn protrusions_assign_cycle_edges`) + +51. After gap-based assignment (Steps 2–5), Step 6 calls `protrusions_assign_cycle_edges` which handles two cases: + + **Case A -- registered cycle edges** (`from_protrusion > 0`): the from-endpoint was assigned a depth by the gap-based step. This depth is copied to `to_protrusion` (with `MIN_PROTRUSION_PX` as a floor) so both endpoints protrude equally, producing a symmetric U-shaped arc. + + **Case B -- unregistered cycle edges** (`from_protrusion == 0`): edges that returned early from `cycle_edge_collect_rank_gap_entries` (boundary ranks, no adjacent rank nodes, or `Left`/`Right` faces). These are grouped by `(from_face, rank_from)` -- all edges routing in the same direction at the same rank -- then sorted by face offset then cross-axis coordinate (matching the ordering in `protrusions_assign`). Within each group of N edges, depths are assigned: + + - `slot[0]` (first sorted entry) -> `N × MIN_PROTRUSION_PX` (longest, outermost arc) + - `slot[N-1]` (last sorted entry) -> `1 × MIN_PROTRUSION_PX` (shortest, innermost arc) + - Intermediate slots evenly spaced between `N × MIN` and `1 × MIN` + + Both `from_protrusion` and `to_protrusion` are set to the same assigned depth. + +52. The stacking order -- first-sorted entry gets the longest protrusion -- mirrors the slot assignment in `protrusions_assign` for single-side entries. This matches the face-offset + cross-axis sorting used for the adjacent-rank case, so the visual layering is consistent whether or not an adjacent rank exists. + + +### Z/S bend direction for same-coordinate protrusion tips + +53. The `connect_waypoints` function in [`edge_path_builder_pass_2_ortho.rs`](crate/input_ir_rt/src/taffy_to_svg_elements_mapper/edge_path_builder_pass_2/edge_path_builder_pass_2_ortho.rs) connects two consecutive waypoints with a Z/S-shaped three-leg segment when both waypoints have the same axis orientation (both vertical or both horizontal). The bend point is determined by a sign computed from the relative positions of the two waypoints. +54. For cycle edges using `Top`/`Top` or `Bottom`/`Bottom` faces, both protrusion tips end up at the same Y coordinate (because both nodes are at the same `y` in the typical same-rank layout). The standard heuristic (`sign = if py < qy { -1 } else { 1 }`) would pick `sign = 1` when `py == qy`, placing the bend below the protrusion tips and **inside** the node bounding boxes. +55. To fix this, when the two waypoints are at the **same coordinate on the routing axis** (`|py - qy| < 1e-3` for vertical, `|px - qx| < 1e-3` for horizontal), the bend direction is determined from the **departure direction** (`p_dir`) of the source waypoint instead: + - Vertical Z/S: if `p_dy < 0.0` (face points upward, e.g. `Top`) then `sign = -1` (bend upward); if `p_dy > 0.0` (face points downward, e.g. `Bottom`) then `sign = +1` (bend downward). + - Horizontal Z/S: if `p_dx < 0.0` (face points leftward, e.g. `Left`) then `sign = -1` (bend leftward); if `p_dx > 0.0` (face points rightward, e.g. `Right`) then `sign = +1` (bend rightward). + + This ensures the U-shaped bend of the routing segment is placed entirely outside the node bounding boxes. + +### Small-gap guard for Z/S bends + +56. A second edge case arises when the gap between the two protrusion tips is **smaller than `ARC_RADIUS`**. The standard formula `bend = qy + sign * ARC_RADIUS` (for vertical) or `bend = qx + sign * ARC_RADIUS` (for horizontal) can then place the bend **past `p`** in the direction opposite to `p`'s departure, making Leg 1 travel against the departure direction. + + **Example**: in a `TopToBottom` diagram with an edge from a nested node `alice` (inside `alice_outer`) to another nested node `charlie_1` (inside `charlie_outer`), the to-protrusion tip `p = (70, 155)` is at the top of `charlie_outer` and the from-protrusion tip `q = (58, 152.696)` is 36.7 px below alice's bottom face. The gap `py - qy = 2.304 < ARC_RADIUS = 4.0`, so with `sign = +1` the formula gives `bend_y = qy + ARC_RADIUS = 156.696 > py = 155`. Leg 1 then goes **downward** from `p` (into `charlie_outer`) even though `p.dir = (0, -1)` says the path should depart **upward**. + +57. There is a second failure mode: placing the bend **above both tips** (e.g. `bend_y = min(py, qy) - ARC_RADIUS = 148.696`) fixes Leg 1 (which now travels upward from `p`), but makes Leg 3 travel **downward** from the bend to `q`. Since the next path segment continues upward from `q` toward the from-node, this creates a sharp direction reversal (V-spike) at `q`. In the visual arrow direction the edge loops backward -- going upward past `q` before returning downward to `p`. + +58. The guard after the sign/bend computation detects the Leg-1 failure and recomputes the bend. For the **typical case** where `p` and `q` are on opposite sides of each other in the departure direction (e.g. `py > qy` for an upward-departing `p`), the bend is reset to the **midpoint** `(py + qy) / 2`. This places the bend strictly inside the routing gap between the two containers, so both Leg 1 and Leg 3 travel in the correct direction and no backward loop appears: + + - Vertical, upward departure (`p_dy < 0.0`), `bend_y >= py` **and** `py > qy`: reset `bend_y = (py + qy) / 2`. + - Vertical, downward departure (`p_dy > 0.0`), `bend_y <= py` **and** `py < qy`: reset `bend_y = (py + qy) / 2`. + - Horizontal: symmetric conditions on `p_dx`, `bend_x`, `px`, and `qx`. + + For the **unusual case** where `p` and `q` are on the same side (e.g. `py <= qy` for an upward-departing `p`, which does not arise in normal `TopToBottom` routing), the bend is placed `ARC_RADIUS` beyond `p` in its departure direction so Leg 1 is still correct. diff --git a/workspace_tests/src/example_ir.yaml b/workspace_tests/src/example_ir.yaml index 71b3b40..f50ca0e 100644 --- a/workspace_tests/src/example_ir.yaml +++ b/workspace_tests/src/example_ir.yaml @@ -115,49 +115,49 @@ node_ordering: t_localhost_repo_target_dist_dir: 18 edge_groups: edge_dep_t_localhost__t_github_user_repo__pull: - - from: t_localhost - to: t_github_user_repo - - from: t_github_user_repo - to: t_localhost + - from: t_localhost + to: t_github_user_repo + - from: t_github_user_repo + to: t_localhost edge_dep_t_localhost__t_github_user_repo__push: - - from: t_localhost - to: t_github_user_repo + - from: t_localhost + to: t_github_user_repo edge_dep_t_localhost__t_localhost__within: - - from: t_localhost - to: t_localhost + - from: t_localhost + to: t_localhost edge_dep_t_github_user_repo__t_github_user_repo__within: - - from: t_github_user_repo - to: t_github_user_repo - - from: t_github_user_repo - to: t_github_user_repo + - from: t_github_user_repo + to: t_github_user_repo + - from: t_github_user_repo + to: t_github_user_repo edge_dep_t_github_user_repo__t_aws_ecr_repo__push: - - from: t_github_user_repo - to: t_aws_ecr_repo + - from: t_github_user_repo + to: t_aws_ecr_repo edge_dep_t_aws_ecr_repo__t_aws_ecs_cluster__push: - - from: t_aws_ecr_repo - to: t_aws_ecs_cluster + - from: t_aws_ecr_repo + to: t_aws_ecs_cluster edge_ix_t_localhost__t_github_user_repo__pull: - - from: t_localhost - to: t_github_user_repo - - from: t_github_user_repo - to: t_localhost + - from: t_localhost + to: t_github_user_repo + - from: t_github_user_repo + to: t_localhost edge_ix_t_localhost__t_github_user_repo__push: - - from: t_localhost - to: t_github_user_repo + - from: t_localhost + to: t_github_user_repo edge_ix_t_localhost__t_localhost__within: - - from: t_localhost - to: t_localhost + - from: t_localhost + to: t_localhost edge_ix_t_github_user_repo__t_github_user_repo__within: - - from: t_github_user_repo - to: t_github_user_repo - - from: t_github_user_repo - to: t_github_user_repo + - from: t_github_user_repo + to: t_github_user_repo + - from: t_github_user_repo + to: t_github_user_repo edge_ix_t_github_user_repo__t_aws_ecr_repo__push: - - from: t_github_user_repo - to: t_aws_ecr_repo + - from: t_github_user_repo + to: t_aws_ecr_repo edge_ix_t_aws_ecr_repo__t_aws_ecs_cluster__push: - - from: t_aws_ecr_repo - to: t_aws_ecs_cluster + - from: t_aws_ecr_repo + to: t_aws_ecs_cluster entity_descs: t_localhost: User's computer edge_ix_t_localhost__t_github_user_repo__pull: Fetch from GitHub @@ -175,151 +175,151 @@ entity_tooltips: proc_app_release_step_gh_actions_build: Github Actions will build the image. proc_app_release_step_tag_and_push: |- When the PR is merged, tag the commit and push the tag to GitHub. - + ```bash git tag 0.3.0 git push origin 0.3.0 ``` - + The build will push the new version to ECR automatically. proc_app_release_step_gh_actions_publish: Github Actions will publish the image to AWS ECR. proc_i12e_region_tier_app_deploy_step_ecs_cluster_update: Deploy or update the existing ECS cluster with the new image. entity_types: _root: - - container_inbuilt + - container_inbuilt _processes_container: - - container_inbuilt + - container_inbuilt _things_and_processes_container: - - container_inbuilt + - container_inbuilt _things_container: - - container_inbuilt + - container_inbuilt _tags_container: - - container_inbuilt + - container_inbuilt t_aws: - - type_thing_default - - type_organisation + - type_thing_default + - type_organisation t_aws_iam: - - type_thing_default - - type_service + - type_thing_default + - type_service t_aws_iam_ecs_policy: - - type_thing_default + - type_thing_default t_aws_ecr: - - type_thing_default - - type_service + - type_thing_default + - type_service t_aws_ecr_repo: - - type_thing_default + - type_thing_default t_aws_ecr_repo_image_1: - - type_thing_default - - type_docker_image + - type_thing_default + - type_docker_image t_aws_ecr_repo_image_2: - - type_thing_default - - type_docker_image + - type_thing_default + - type_docker_image t_aws_ecs: - - type_thing_default - - type_service + - type_thing_default + - type_service t_aws_ecs_cluster: - - type_thing_default + - type_thing_default t_aws_ecs_cluster_task: - - type_thing_default + - type_thing_default t_github: - - type_thing_default - - type_organisation + - type_thing_default + - type_organisation t_github_user_repo: - - type_thing_default + - type_thing_default t_localhost: - - type_thing_default + - type_thing_default t_localhost_repo: - - type_thing_default + - type_thing_default t_localhost_repo_src: - - type_thing_default + - type_thing_default t_localhost_repo_target: - - type_thing_default + - type_thing_default t_localhost_repo_target_file_zip: - - type_thing_default + - type_thing_default t_localhost_repo_target_dist_dir: - - type_thing_default + - type_thing_default tag_app_development: - - type_tag_default + - type_tag_default tag_deployment: - - type_tag_default + - type_tag_default proc_app_dev: - - type_process_default + - type_process_default proc_app_dev_step_repository_clone: - - type_process_step_default + - type_process_step_default proc_app_dev_step_project_build: - - type_process_step_default + - type_process_step_default proc_app_release: - - type_process_default + - type_process_default proc_app_release_step_crate_version_update: - - type_process_step_default + - type_process_step_default proc_app_release_step_pull_request_open: - - type_process_step_default + - type_process_step_default proc_app_release_step_tag_and_push: - - type_process_step_default + - type_process_step_default proc_app_release_step_gh_actions_build: - - type_process_step_default + - type_process_step_default proc_app_release_step_gh_actions_publish: - - type_process_step_default + - type_process_step_default proc_i12e_region_tier_app_deploy: - - type_process_default + - type_process_default proc_i12e_region_tier_app_deploy_step_ecs_cluster_update: - - type_process_step_default + - type_process_step_default edge_dep_t_localhost__t_github_user_repo__pull: - - type_dependency_edge_cyclic_default + - type_dependency_edge_cyclic_default edge_dep_t_localhost__t_github_user_repo__pull__0: - - type_dependency_edge_cyclic_forward_default + - type_dependency_edge_cyclic_forward_default edge_dep_t_localhost__t_github_user_repo__pull__1: - - type_dependency_edge_cyclic_forward_default + - type_dependency_edge_cyclic_forward_default edge_dep_t_localhost__t_github_user_repo__push: - - type_dependency_edge_sequence_default + - type_dependency_edge_sequence_default edge_dep_t_localhost__t_github_user_repo__push__0: - - type_dependency_edge_sequence_forward_default + - type_dependency_edge_sequence_forward_default edge_dep_t_localhost__t_localhost__within: - - type_dependency_edge_cyclic_default + - type_dependency_edge_cyclic_default edge_dep_t_localhost__t_localhost__within__0: - - type_dependency_edge_cyclic_forward_default + - type_dependency_edge_cyclic_forward_default edge_dep_t_github_user_repo__t_github_user_repo__within: - - type_dependency_edge_symmetric_default + - type_dependency_edge_symmetric_default edge_dep_t_github_user_repo__t_github_user_repo__within__0: - - type_dependency_edge_symmetric_forward_default + - type_dependency_edge_symmetric_forward_default edge_dep_t_github_user_repo__t_github_user_repo__within__1: - - type_dependency_edge_symmetric_reverse_default + - type_dependency_edge_symmetric_reverse_default edge_dep_t_github_user_repo__t_aws_ecr_repo__push: - - type_dependency_edge_sequence_default + - type_dependency_edge_sequence_default edge_dep_t_github_user_repo__t_aws_ecr_repo__push__0: - - type_dependency_edge_sequence_forward_default + - type_dependency_edge_sequence_forward_default edge_dep_t_aws_ecr_repo__t_aws_ecs_cluster__push: - - type_dependency_edge_sequence_default + - type_dependency_edge_sequence_default edge_dep_t_aws_ecr_repo__t_aws_ecs_cluster__push__0: - - type_dependency_edge_sequence_forward_default + - type_dependency_edge_sequence_forward_default edge_ix_t_localhost__t_github_user_repo__pull: - - type_interaction_edge_symmetric_default + - type_interaction_edge_symmetric_default edge_ix_t_localhost__t_github_user_repo__pull__0: - - type_interaction_edge_symmetric_forward_default + - type_interaction_edge_symmetric_forward_default edge_ix_t_localhost__t_github_user_repo__pull__1: - - type_interaction_edge_symmetric_reverse_default + - type_interaction_edge_symmetric_reverse_default edge_ix_t_localhost__t_github_user_repo__push: - - type_interaction_edge_sequence_default + - type_interaction_edge_sequence_default edge_ix_t_localhost__t_github_user_repo__push__0: - - type_interaction_edge_sequence_forward_default + - type_interaction_edge_sequence_forward_default edge_ix_t_localhost__t_localhost__within: - - type_interaction_edge_cyclic_default + - type_interaction_edge_cyclic_default edge_ix_t_localhost__t_localhost__within__0: - - type_interaction_edge_cyclic_forward_default + - type_interaction_edge_cyclic_forward_default edge_ix_t_github_user_repo__t_github_user_repo__within: - - type_interaction_edge_symmetric_default + - type_interaction_edge_symmetric_default edge_ix_t_github_user_repo__t_github_user_repo__within__0: - - type_interaction_edge_symmetric_forward_default + - type_interaction_edge_symmetric_forward_default edge_ix_t_github_user_repo__t_github_user_repo__within__1: - - type_interaction_edge_symmetric_reverse_default + - type_interaction_edge_symmetric_reverse_default edge_ix_t_github_user_repo__t_aws_ecr_repo__push: - - type_interaction_edge_sequence_default + - type_interaction_edge_sequence_default edge_ix_t_github_user_repo__t_aws_ecr_repo__push__0: - - type_interaction_edge_sequence_forward_default + - type_interaction_edge_sequence_forward_default edge_ix_t_aws_ecr_repo__t_aws_ecs_cluster__push: - - type_interaction_edge_sequence_default + - type_interaction_edge_sequence_default edge_ix_t_aws_ecr_repo__t_aws_ecs_cluster__push__0: - - type_interaction_edge_sequence_forward_default + - type_interaction_edge_sequence_forward_default tailwind_classes: t_aws: | visible @@ -1210,7 +1210,7 @@ tailwind_classes: focus:stroke-[var(--tw-blue-500-500)] active:stroke-[var(--tw-blue-600-400)] [&>text]:fill-[var(--tw-neutral-900-100)] - + peer-[:focus-within]/proc_app_dev_step_repository_clone:visible peer-[:focus-within]/proc_app_release_step_pull_request_open:visible edge_ix_t_localhost__t_github_user_repo__pull__1: | @@ -1225,7 +1225,7 @@ tailwind_classes: focus:stroke-[var(--tw-blue-500-500)] active:stroke-[var(--tw-blue-600-400)] [&>text]:fill-[var(--tw-neutral-900-100)] - + peer-[:focus-within]/proc_app_dev_step_repository_clone:visible peer-[:focus-within]/proc_app_release_step_pull_request_open:visible edge_ix_t_localhost__t_github_user_repo__push__0: | @@ -1240,7 +1240,7 @@ tailwind_classes: focus:stroke-[var(--tw-violet-800-200)] active:stroke-[var(--tw-violet-900-100)] [&>text]:fill-[var(--tw-neutral-950-50)] - + peer-[:focus-within]/proc_app_release_step_tag_and_push:visible edge_ix_t_localhost__t_localhost__within__0: | invisible @@ -1254,7 +1254,7 @@ tailwind_classes: focus:stroke-[var(--tw-violet-800-200)] active:stroke-[var(--tw-violet-900-100)] [&>text]:fill-[var(--tw-neutral-950-50)] - + peer-[:focus-within]/proc_app_dev_step_project_build:visible peer-[:focus-within]/proc_app_release_step_crate_version_update:visible edge_ix_t_github_user_repo__t_github_user_repo__within__0: | @@ -1269,7 +1269,7 @@ tailwind_classes: focus:stroke-[var(--tw-violet-800-200)] active:stroke-[var(--tw-violet-900-100)] [&>text]:fill-[var(--tw-neutral-950-50)] - + peer-[:focus-within]/proc_app_release_step_gh_actions_build:visible edge_ix_t_github_user_repo__t_github_user_repo__within__1: | invisible @@ -1283,7 +1283,7 @@ tailwind_classes: focus:stroke-[var(--tw-violet-800-200)] active:stroke-[var(--tw-violet-900-100)] [&>text]:fill-[var(--tw-neutral-950-50)] - + peer-[:focus-within]/proc_app_release_step_gh_actions_build:visible edge_ix_t_github_user_repo__t_aws_ecr_repo__push__0: | invisible @@ -1297,7 +1297,7 @@ tailwind_classes: focus:stroke-[var(--tw-violet-800-200)] active:stroke-[var(--tw-violet-900-100)] [&>text]:fill-[var(--tw-neutral-950-50)] - + peer-[:focus-within]/proc_app_release_step_gh_actions_publish:visible edge_ix_t_aws_ecr_repo__t_aws_ecs_cluster__push__0: | invisible @@ -1311,7 +1311,7 @@ tailwind_classes: focus:stroke-[var(--tw-violet-800-200)] active:stroke-[var(--tw-violet-900-100)] [&>text]:fill-[var(--tw-neutral-950-50)] - + peer-[:focus-within]/proc_i12e_region_tier_app_deploy_step_ecs_cluster_update:visible node_layouts: _root: @@ -1728,38 +1728,285 @@ node_layouts: margin_right: 0.0 margin_bottom: 0.0 margin_left: 0.0 -node_ranks: - tag_app_development: 0 - tag_deployment: 0 - proc_app_dev: 0 - proc_app_dev_step_repository_clone: 0 - proc_app_dev_step_project_build: 0 - proc_app_release: 0 - proc_app_release_step_crate_version_update: 0 - proc_app_release_step_pull_request_open: 0 - proc_app_release_step_tag_and_push: 0 - proc_app_release_step_gh_actions_build: 0 - proc_app_release_step_gh_actions_publish: 0 - proc_i12e_region_tier_app_deploy: 0 - proc_i12e_region_tier_app_deploy_step_ecs_cluster_update: 0 - t_aws: 0 - t_aws_iam: 0 - t_aws_iam_ecs_policy: 0 - t_aws_ecr: 0 - t_aws_ecr_repo: 1 - t_aws_ecr_repo_image_1: 0 - t_aws_ecr_repo_image_2: 0 - t_aws_ecs: 0 - t_aws_ecs_cluster: 2 - t_aws_ecs_cluster_task: 0 - t_github: 0 - t_github_user_repo: 0 - t_localhost: 0 - t_localhost_repo: 0 - t_localhost_repo_src: 0 - t_localhost_repo_target: 0 - t_localhost_repo_target_file_zip: 0 - t_localhost_repo_target_dist_dir: 0 +node_ranks_nested: + root: + tag_app_development: 0 + tag_deployment: 0 + proc_app_dev: 0 + proc_app_release: 0 + proc_i12e_region_tier_app_deploy: 0 + t_aws: 1 + t_github: 0 + t_localhost: 0 + containers: + proc_app_dev: + proc_app_dev_step_repository_clone: 0 + proc_app_dev_step_project_build: 0 + proc_app_release: + proc_app_release_step_crate_version_update: 0 + proc_app_release_step_pull_request_open: 0 + proc_app_release_step_tag_and_push: 0 + proc_app_release_step_gh_actions_build: 0 + proc_app_release_step_gh_actions_publish: 0 + proc_i12e_region_tier_app_deploy: + proc_i12e_region_tier_app_deploy_step_ecs_cluster_update: 0 + t_aws: + t_aws_iam: 0 + t_aws_ecr: 0 + t_aws_ecs: 1 + t_aws_iam: + t_aws_iam_ecs_policy: 0 + t_aws_ecr: + t_aws_ecr_repo: 0 + t_aws_ecr_repo: + t_aws_ecr_repo_image_1: 0 + t_aws_ecr_repo_image_2: 0 + t_aws_ecs: + t_aws_ecs_cluster: 0 + t_aws_ecs_cluster: + t_aws_ecs_cluster_task: 0 + t_github: + t_github_user_repo: 0 + t_localhost: + t_localhost_repo: 0 + t_localhost_repo: + t_localhost_repo_src: 0 + t_localhost_repo_target: 0 + t_localhost_repo_target: + t_localhost_repo_target_file_zip: 0 + t_localhost_repo_target_dist_dir: 0 +node_nesting_infos: + tag_app_development: + nesting_path: + - 0 + ancestor_chain: + - tag_app_development + tag_deployment: + nesting_path: + - 1 + ancestor_chain: + - tag_deployment + proc_app_dev: + nesting_path: + - 2 + ancestor_chain: + - proc_app_dev + proc_app_dev_step_repository_clone: + nesting_path: + - 2 + - 0 + ancestor_chain: + - proc_app_dev + - proc_app_dev_step_repository_clone + proc_app_dev_step_project_build: + nesting_path: + - 2 + - 1 + ancestor_chain: + - proc_app_dev + - proc_app_dev_step_project_build + proc_app_release: + nesting_path: + - 3 + ancestor_chain: + - proc_app_release + proc_app_release_step_crate_version_update: + nesting_path: + - 3 + - 0 + ancestor_chain: + - proc_app_release + - proc_app_release_step_crate_version_update + proc_app_release_step_pull_request_open: + nesting_path: + - 3 + - 1 + ancestor_chain: + - proc_app_release + - proc_app_release_step_pull_request_open + proc_app_release_step_tag_and_push: + nesting_path: + - 3 + - 2 + ancestor_chain: + - proc_app_release + - proc_app_release_step_tag_and_push + proc_app_release_step_gh_actions_build: + nesting_path: + - 3 + - 3 + ancestor_chain: + - proc_app_release + - proc_app_release_step_gh_actions_build + proc_app_release_step_gh_actions_publish: + nesting_path: + - 3 + - 4 + ancestor_chain: + - proc_app_release + - proc_app_release_step_gh_actions_publish + proc_i12e_region_tier_app_deploy: + nesting_path: + - 4 + ancestor_chain: + - proc_i12e_region_tier_app_deploy + proc_i12e_region_tier_app_deploy_step_ecs_cluster_update: + nesting_path: + - 4 + - 0 + ancestor_chain: + - proc_i12e_region_tier_app_deploy + - proc_i12e_region_tier_app_deploy_step_ecs_cluster_update + t_aws: + nesting_path: + - 5 + ancestor_chain: + - t_aws + t_aws_iam: + nesting_path: + - 5 + - 0 + ancestor_chain: + - t_aws + - t_aws_iam + t_aws_iam_ecs_policy: + nesting_path: + - 5 + - 0 + - 0 + ancestor_chain: + - t_aws + - t_aws_iam + - t_aws_iam_ecs_policy + t_aws_ecr: + nesting_path: + - 5 + - 1 + ancestor_chain: + - t_aws + - t_aws_ecr + t_aws_ecr_repo: + nesting_path: + - 5 + - 1 + - 0 + ancestor_chain: + - t_aws + - t_aws_ecr + - t_aws_ecr_repo + t_aws_ecr_repo_image_1: + nesting_path: + - 5 + - 1 + - 0 + - 0 + ancestor_chain: + - t_aws + - t_aws_ecr + - t_aws_ecr_repo + - t_aws_ecr_repo_image_1 + t_aws_ecr_repo_image_2: + nesting_path: + - 5 + - 1 + - 0 + - 1 + ancestor_chain: + - t_aws + - t_aws_ecr + - t_aws_ecr_repo + - t_aws_ecr_repo_image_2 + t_aws_ecs: + nesting_path: + - 5 + - 2 + ancestor_chain: + - t_aws + - t_aws_ecs + t_aws_ecs_cluster: + nesting_path: + - 5 + - 2 + - 0 + ancestor_chain: + - t_aws + - t_aws_ecs + - t_aws_ecs_cluster + t_aws_ecs_cluster_task: + nesting_path: + - 5 + - 2 + - 0 + - 0 + ancestor_chain: + - t_aws + - t_aws_ecs + - t_aws_ecs_cluster + - t_aws_ecs_cluster_task + t_github: + nesting_path: + - 6 + ancestor_chain: + - t_github + t_github_user_repo: + nesting_path: + - 6 + - 0 + ancestor_chain: + - t_github + - t_github_user_repo + t_localhost: + nesting_path: + - 7 + ancestor_chain: + - t_localhost + t_localhost_repo: + nesting_path: + - 7 + - 0 + ancestor_chain: + - t_localhost + - t_localhost_repo + t_localhost_repo_src: + nesting_path: + - 7 + - 0 + - 0 + ancestor_chain: + - t_localhost + - t_localhost_repo + - t_localhost_repo_src + t_localhost_repo_target: + nesting_path: + - 7 + - 0 + - 1 + ancestor_chain: + - t_localhost + - t_localhost_repo + - t_localhost_repo_target + t_localhost_repo_target_file_zip: + nesting_path: + - 7 + - 0 + - 1 + - 0 + ancestor_chain: + - t_localhost + - t_localhost_repo + - t_localhost_repo_target + - t_localhost_repo_target_file_zip + t_localhost_repo_target_dist_dir: + nesting_path: + - 7 + - 0 + - 1 + - 1 + ancestor_chain: + - t_localhost + - t_localhost_repo + - t_localhost_repo_target + - t_localhost_repo_target_dist_dir node_shapes: t_aws: rect: @@ -1925,21 +2172,21 @@ node_shapes: radius: 12.0 process_step_entities: proc_app_dev_step_repository_clone: - - edge_ix_t_localhost__t_github_user_repo__pull + - edge_ix_t_localhost__t_github_user_repo__pull proc_app_dev_step_project_build: - - edge_ix_t_localhost__t_localhost__within + - edge_ix_t_localhost__t_localhost__within proc_app_release_step_crate_version_update: - - edge_ix_t_localhost__t_localhost__within + - edge_ix_t_localhost__t_localhost__within proc_app_release_step_pull_request_open: - - edge_ix_t_localhost__t_github_user_repo__pull + - edge_ix_t_localhost__t_github_user_repo__pull proc_app_release_step_tag_and_push: - - edge_ix_t_localhost__t_github_user_repo__push + - edge_ix_t_localhost__t_github_user_repo__push proc_app_release_step_gh_actions_build: - - edge_ix_t_github_user_repo__t_github_user_repo__within + - edge_ix_t_github_user_repo__t_github_user_repo__within proc_app_release_step_gh_actions_publish: - - edge_ix_t_github_user_repo__t_aws_ecr_repo__push + - edge_ix_t_github_user_repo__t_aws_ecr_repo__push proc_i12e_region_tier_app_deploy_step_ecs_cluster_update: - - edge_ix_t_aws_ecr_repo__t_aws_ecs_cluster__push + - edge_ix_t_aws_ecr_repo__t_aws_ecs_cluster__push css: |- svg { --tw-blue-200-800: oklch(88.2% 0.059 254.128); diff --git a/workspace_tests/src/input_diagram/0001_nested_node_edge_protrusion.yaml b/workspace_tests/src/input_diagram/0001_nested_node_edge_protrusion.yaml new file mode 100644 index 0000000..43fff74 --- /dev/null +++ b/workspace_tests/src/input_diagram/0001_nested_node_edge_protrusion.yaml @@ -0,0 +1,21 @@ +things: + t_alice_outer: Alice Outer + t_alice: Alice + t_bob: Bob + t_charlie: Charlie +thing_hierarchy: + t_alice_outer: + t_alice: {} + t_bob: {} + t_charlie: {} +thing_dependencies: + edge_dep_alice_charlie: + kind: sequence + things: + - t_alice + - t_charlie + edge_dep_bob_charlie: + kind: sequence + things: + - t_bob + - t_charlie diff --git a/workspace_tests/src/input_diagram/0002_nested_x2_node_edge_protrusion.yaml b/workspace_tests/src/input_diagram/0002_nested_x2_node_edge_protrusion.yaml new file mode 100644 index 0000000..2139372 --- /dev/null +++ b/workspace_tests/src/input_diagram/0002_nested_x2_node_edge_protrusion.yaml @@ -0,0 +1,20 @@ +things: + t_alice_outer: Alice Outer + t_alice: Alice + t_alice_inner: Alice Inner + t_charlie_outer: Charlie Outer + t_charlie: Charlie + t_charlie_inner: Charlie Inner +thing_hierarchy: + t_alice_outer: + t_alice: + t_alice_inner: {} + t_charlie_outer: + t_charlie: + t_charlie_inner: {} +thing_dependencies: + edge_dep_alice_inner_charlie_inner: + kind: sequence + things: + - t_alice_inner + - t_charlie_inner diff --git a/workspace_tests/src/input_diagram/0003_edges_symmetric_2_nodes.yaml b/workspace_tests/src/input_diagram/0003_edges_symmetric_2_nodes.yaml new file mode 100644 index 0000000..5d17ca4 --- /dev/null +++ b/workspace_tests/src/input_diagram/0003_edges_symmetric_2_nodes.yaml @@ -0,0 +1,12 @@ +things: + t_alice: Alice + t_bob: Bob +thing_hierarchy: + t_alice: {} + t_bob: {} +thing_dependencies: + edge_dep_bob_alice: + kind: symmetric + things: + - t_bob + - t_alice diff --git a/workspace_tests/src/input_diagram/0004_edges_symmetric_3_nodes.yaml b/workspace_tests/src/input_diagram/0004_edges_symmetric_3_nodes.yaml new file mode 100644 index 0000000..2f63b54 --- /dev/null +++ b/workspace_tests/src/input_diagram/0004_edges_symmetric_3_nodes.yaml @@ -0,0 +1,15 @@ +things: + t_alice: Alice + t_bob: Bob + t_charlie: Charlie +thing_hierarchy: + t_alice: {} + t_bob: {} + t_charlie: {} +thing_dependencies: + edge_dep_bob_alice: + kind: symmetric + things: + - t_bob + - t_alice + - t_charlie diff --git a/workspace_tests/src/input_diagram/0005_tag_nodes_cyclic_edge.yaml b/workspace_tests/src/input_diagram/0005_tag_nodes_cyclic_edge.yaml new file mode 100644 index 0000000..45f653e --- /dev/null +++ b/workspace_tests/src/input_diagram/0005_tag_nodes_cyclic_edge.yaml @@ -0,0 +1,25 @@ +things: + t_alice: Alice + t_bob: Bob + t_charlie: Charlie +thing_hierarchy: + t_alice: {} + t_bob: {} + t_charlie: {} +thing_dependencies: + edge_dep_bob_alice_charlie: + kind: symmetric + things: + - t_bob + - t_alice + - t_charlie + edge_dep_tags_cyclic: + kind: cyclic + things: + - tag_a + - tag_b + - tag_c +tags: + tag_a: Tag A + tag_b: Tag B + tag_c: Tag C diff --git a/workspace_tests/src/input_diagram/0006_process_step_nodes_cyclic_edge.yaml b/workspace_tests/src/input_diagram/0006_process_step_nodes_cyclic_edge.yaml new file mode 100644 index 0000000..1ed07f2 --- /dev/null +++ b/workspace_tests/src/input_diagram/0006_process_step_nodes_cyclic_edge.yaml @@ -0,0 +1,24 @@ +things: + t_alice: Alice + t_bob: Bob + t_charlie: Charlie +thing_hierarchy: + t_alice: {} + t_bob: {} + t_charlie: {} +thing_dependencies: + edge_dep_bob_alice_charlie: + kind: symmetric + things: + - t_bob + - t_alice + - t_charlie +processes: + proc_test: + name: Test Process + steps: + proc_test_step_a: Step A + proc_test_step_b: Step B + proc_test_step_c: Step C +render_options: + rank_dir: left_to_right diff --git a/workspace_tests/src/input_diagram/0007_edge_from_node_to_nested_node.yaml b/workspace_tests/src/input_diagram/0007_edge_from_node_to_nested_node.yaml new file mode 100644 index 0000000..e17db1a --- /dev/null +++ b/workspace_tests/src/input_diagram/0007_edge_from_node_to_nested_node.yaml @@ -0,0 +1,30 @@ +things: + t_alice_outer: Alice Outer + t_alice: Alice + t_bob: Bob + t_charlie_outer: Charlie Outer + t_charlie_1: Charlie 1 + t_charlie_2: Charlie 2 +thing_hierarchy: + t_alice_outer: + t_alice: {} + t_bob: {} + t_charlie_outer: + t_charlie_1: {} + t_charlie_2: {} +thing_dependencies: + edge_dep_alice_charlie_1: + kind: sequence + things: + - t_alice + - t_charlie_1 + edge_dep_bob_charlie_1: + kind: sequence + things: + - t_bob + - t_charlie_1 + edge_dep_bob_charlie_2: + kind: sequence + things: + - t_bob + - t_charlie_2 diff --git a/workspace_tests/src/input_diagram/0008_edge_from_node_to_nested_rank_1_node.yaml b/workspace_tests/src/input_diagram/0008_edge_from_node_to_nested_rank_1_node.yaml new file mode 100644 index 0000000..3c85c1f --- /dev/null +++ b/workspace_tests/src/input_diagram/0008_edge_from_node_to_nested_rank_1_node.yaml @@ -0,0 +1,42 @@ +things: + t_alice_outer: Alice Outer + t_alice: Alice + t_bob: Bob + t_charlie_outer: Charlie Outer + t_charlie_1: Charlie 1 + t_charlie_2: Charlie 2 + t_charlie_3: Charlie 3 +thing_hierarchy: + t_alice_outer: + t_alice: {} + t_bob: {} + t_charlie_outer: + t_charlie_1: {} + t_charlie_2: {} + t_charlie_3: {} +thing_dependencies: + edge_dep_alice_charlie_1: + kind: sequence + things: + - t_alice + - t_charlie_1 + edge_dep_alice_charlie_3: + kind: sequence + things: + - t_alice + - t_charlie_3 + edge_dep_bob_charlie_1: + kind: sequence + things: + - t_bob + - t_charlie_1 + edge_dep_bob_charlie_2: + kind: sequence + things: + - t_bob + - t_charlie_2 + edge_dep_charlie_2_charlie_3: + kind: sequence + things: + - t_charlie_2 + - t_charlie_3 diff --git a/workspace_tests/src/input_ir_rt.rs b/workspace_tests/src/input_ir_rt.rs index 5a1f6ef..dd799cd 100644 --- a/workspace_tests/src/input_ir_rt.rs +++ b/workspace_tests/src/input_ir_rt.rs @@ -3,9 +3,26 @@ pub(crate) const EXAMPLE_INPUT: &str = include_str!("example_input.yaml"); pub(crate) const EXAMPLE_INPUT_MERGED: &str = include_str!("example_input_merged.yaml"); pub(crate) const EXAMPLE_IR: &str = include_str!("example_ir.yaml"); +pub(crate) const INPUT_DIAGRAM_0001_NESTED_NODE_EDGE_PROTRUSION: &str = + include_str!("input_diagram/0001_nested_node_edge_protrusion.yaml"); +pub(crate) const INPUT_DIAGRAM_0002_NESTED_NODE_EDGE_PROTRUSION: &str = + include_str!("input_diagram/0002_nested_x2_node_edge_protrusion.yaml"); +pub(crate) const INPUT_DIAGRAM_0003_EDGES_SYMMETRIC_2_NODES: &str = + include_str!("input_diagram/0003_edges_symmetric_2_nodes.yaml"); +pub(crate) const INPUT_DIAGRAM_0004_EDGES_SYMMETRIC_3_NODES: &str = + include_str!("input_diagram/0004_edges_symmetric_3_nodes.yaml"); +pub(crate) const INPUT_DIAGRAM_0005_TAG_NODES_CYCLIC_EDGE: &str = + include_str!("input_diagram/0005_tag_nodes_cyclic_edge.yaml"); +pub(crate) const INPUT_DIAGRAM_0006_PROCESS_STEP_NODES_CYCLIC_EDGE: &str = + include_str!("input_diagram/0006_process_step_nodes_cyclic_edge.yaml"); +pub(crate) const INPUT_DIAGRAM_0007_EDGE_FROM_NODE_TO_NESTED_NODE: &str = + include_str!("input_diagram/0007_edge_from_node_to_nested_node.yaml"); +pub(crate) const INPUT_DIAGRAM_0008_EDGE_FROM_NODE_TO_NESTED_RANK_1_NODE: &str = + include_str!("input_diagram/0008_edge_from_node_to_nested_rank_1_node.yaml"); mod input_diagram_merger; mod input_to_ir_diagram_mapper; mod ir_to_taffy_builder; +mod node_ranks_calculator; mod svg_elements_to_svg_mapper; mod taffy_to_svg_elements_mapper; diff --git a/workspace_tests/src/input_ir_rt/ir_to_taffy_builder.rs b/workspace_tests/src/input_ir_rt/ir_to_taffy_builder.rs index 979d1ea..0e64059 100644 --- a/workspace_tests/src/input_ir_rt/ir_to_taffy_builder.rs +++ b/workspace_tests/src/input_ir_rt/ir_to_taffy_builder.rs @@ -44,8 +44,8 @@ fn test_example_ir_mapping_to_taffy_tree_and_root() -> Result<(), TaffyError> { assert_taffy_measurements( taffy_node_mappings_lg, MeasurementsExpected { - diagram_width: 1024.0, - diagram_height: 521.0, + diagram_width: 912.0, + diagram_height: 598.0, }, )?; diff --git a/workspace_tests/src/input_ir_rt/node_ranks_calculator.rs b/workspace_tests/src/input_ir_rt/node_ranks_calculator.rs new file mode 100644 index 0000000..9d2c52a --- /dev/null +++ b/workspace_tests/src/input_ir_rt/node_ranks_calculator.rs @@ -0,0 +1,258 @@ +use disposition::{ + ir_model::{ + edge::{Edge, EdgeGroup, EdgeGroups}, + entity::EntityType, + node::{NodeId, NodeNestingInfo, NodeNestingInfos, NodeRank, NodeRanks, NodeRanksNested}, + }, + model_common::{edge::EdgeGroupId, entity::EntityTypes, Id, Set}, +}; +use disposition_input_ir_rt::NodeRanksCalculator; +use pretty_assertions::assert_eq; + +// === Helpers === // + +/// Constructs a [`NodeId`] from a `&'static str`. +fn node_id(s: &'static str) -> NodeId<'static> { + NodeId::from(Id::new(s).expect("test node ID must be valid")) +} + +/// Constructs an [`EdgeGroupId`] from a `&'static str`. +fn edge_group_id(s: &'static str) -> EdgeGroupId<'static> { + EdgeGroupId::from(Id::new(s).expect("test edge group ID must be valid")) +} + +/// Constructs a [`NodeNestingInfo`] from an ordered list of ancestor IDs. +/// +/// The last element is the node itself; earlier elements are its ancestors +/// from root downward. The `nesting_path` is set to ascending indices, which +/// is sufficient for the rank calculator (it only uses `ancestor_chain`). +fn nesting_info(ancestors: &[&'static str]) -> NodeNestingInfo<'static> { + NodeNestingInfo { + nesting_path: (0..ancestors.len()).collect(), + ancestor_chain: ancestors.iter().copied().map(node_id).collect(), + } +} + +/// Builds [`NodeNestingInfos`] from a list of `(node_id, [ancestor, ..., +/// self])` pairs. +fn nesting_infos(entries: &[(&'static str, &[&'static str])]) -> NodeNestingInfos<'static> { + entries + .iter() + .map(|(id, ancestors)| (node_id(id), nesting_info(ancestors))) + .collect() +} + +/// Builds dependency [`EdgeGroups`] and matching [`EntityTypes`] from a list +/// of `(group_id, from, to)` triples. +/// +/// Each triple produces one `DependencyEdgeSequenceDefault` edge group +/// recognised by [`NodeRanksCalculator`]. +fn dep_edge_groups( + triples: &[(&'static str, &'static str, &'static str)], +) -> (EdgeGroups<'static>, EntityTypes<'static>) { + let mut edge_groups = EdgeGroups::new(); + let mut entity_types = EntityTypes::new(); + for (group_id_str, from_str, to_str) in triples { + let gid = edge_group_id(group_id_str); + let id: Id<'static> = gid.clone().into_inner(); + edge_groups.insert( + gid, + EdgeGroup::from(vec![Edge::new(node_id(from_str), node_id(to_str))]), + ); + entity_types.insert( + id, + Set::from_iter([EntityType::DependencyEdgeSequenceDefault]), + ); + } + (edge_groups, entity_types) +} + +/// Asserts that the root-level ranks in a [`NodeRanksNested`] exactly match +/// `expected`. +fn assert_root_ranks(result: &NodeRanksNested, expected: &[(&'static str, u32)]) { + let expected_ranks: NodeRanks = expected + .iter() + .map(|(id, rank)| (node_id(id), NodeRank::new(*rank))) + .collect(); + assert_eq!(expected_ranks, result.root); +} + +/// Asserts that the ranks for the named container in a [`NodeRanksNested`] +/// exactly match `expected`. +fn assert_container_ranks( + result: &NodeRanksNested, + container: &'static str, + expected: &[(&'static str, u32)], +) { + let expected_ranks: NodeRanks = expected + .iter() + .map(|(id, rank)| (node_id(id), NodeRank::new(*rank))) + .collect(); + let actual = result + .containers + .get(&node_id(container)) + .unwrap_or_else(|| panic!("container '{container}' not found in node_ranks_nested")); + assert_eq!(&expected_ranks, actual); +} + +// === Tests === // + +/// Case 1: root-level `NodeRank`s increase when edges are between root-level +/// siblings. +/// +/// Hierarchy: `a`, `b` (leaf nodes, no children) +/// Edge: `a -> b` +/// Expected root ranks: `a: 0`, `b: 1` +/// No containers (neither node has children). +#[test] +fn test_node_ranks_root_level_sibling_edges() { + let node_nesting_infos = nesting_infos(&[("a", &["a"]), ("b", &["b"])]); + let (edge_groups, entity_types) = dep_edge_groups(&[("edge_a_b", "a", "b")]); + + let result = NodeRanksCalculator::calculate(&edge_groups, &entity_types, &node_nesting_infos); + + assert_root_ranks(&result, &[("a", 0), ("b", 1)]); + assert!( + result.containers.is_empty(), + "expected no containers because neither node has children" + ); +} + +/// Case 2: root-level `NodeRank`s increase when an edge connects children of +/// different root-level siblings. +/// +/// Hierarchy: `a: {a_child}`, `b: {b_child}` +/// Edge: `a_child -> b_child` +/// LCA = root, divergent ancestors = `a`, `b` +/// Expected root ranks: `a: 0`, `b: 1` (edge is lifted to root level) +/// Expected container ranks: `a: {a_child: 0}`, `b: {b_child: 0}` +/// (within each container no sibling-level edge exists; both children rank 0) +#[test] +fn test_node_ranks_root_level_lifted_from_child_edges() { + let node_nesting_infos = nesting_infos(&[ + ("a", &["a"]), + ("a_child", &["a", "a_child"]), + ("b", &["b"]), + ("b_child", &["b", "b_child"]), + ]); + let (edge_groups, entity_types) = + dep_edge_groups(&[("edge_a_child_b_child", "a_child", "b_child")]); + + let result = NodeRanksCalculator::calculate(&edge_groups, &entity_types, &node_nesting_infos); + + assert_root_ranks(&result, &[("a", 0), ("b", 1)]); + assert_container_ranks(&result, "a", &[("a_child", 0)]); + assert_container_ranks(&result, "b", &[("b_child", 0)]); +} + +/// Case 3: nested-level `NodeRank`s increase when edges connect nested +/// siblings that share the same parent. +/// +/// Hierarchy: `p: {p_a, p_b}` +/// Edge: `p_a -> p_b` +/// LCA = `p`, divergent ancestors = `p_a`, `p_b` +/// Expected root ranks: `p: 0` +/// Expected container ranks for `p`: `p_a: 0`, `p_b: 1` +#[test] +fn test_node_ranks_nested_level_same_parent_edge() { + let node_nesting_infos = nesting_infos(&[ + ("p", &["p"]), + ("p_a", &["p", "p_a"]), + ("p_b", &["p", "p_b"]), + ]); + let (edge_groups, entity_types) = dep_edge_groups(&[("edge_p_a_p_b", "p_a", "p_b")]); + + let result = NodeRanksCalculator::calculate(&edge_groups, &entity_types, &node_nesting_infos); + + assert_root_ranks(&result, &[("p", 0)]); + assert_container_ranks(&result, "p", &[("p_a", 0), ("p_b", 1)]); +} + +/// Case 4: nested-level `NodeRank`s do NOT increase for nodes when an edge +/// connects nested siblings under different parents. +/// +/// Hierarchy: `p1: {p1_a}`, `p2: {p2_a}` +/// Edge: `p1_a -> p2_a` +/// LCA = root, divergent ancestors = `p1`, `p2` +/// Expected root ranks: `p1: 0`, `p2: 1` (root level IS affected) +/// Expected container ranks: `p1: {p1_a: 0}`, `p2: {p2_a: 0}` +/// (no sibling-level edge exists inside either container; both children rank +/// 0) +#[test] +fn test_node_ranks_nested_level_different_parent_edge() { + let node_nesting_infos = nesting_infos(&[ + ("p1", &["p1"]), + ("p1_a", &["p1", "p1_a"]), + ("p2", &["p2"]), + ("p2_a", &["p2", "p2_a"]), + ]); + let (edge_groups, entity_types) = dep_edge_groups(&[("edge_p1_a_p2_a", "p1_a", "p2_a")]); + + let result = NodeRanksCalculator::calculate(&edge_groups, &entity_types, &node_nesting_infos); + + assert_root_ranks(&result, &[("p1", 0), ("p2", 1)]); + assert_container_ranks(&result, "p1", &[("p1_a", 0)]); + assert_container_ranks(&result, "p2", &[("p2_a", 0)]); +} + +/// Case 5: multi-nested-level `NodeRank`s increase when edges connect +/// siblings sharing the same deeply-nested parent. +/// +/// Hierarchy: `outer: {inner: {inner_a, inner_b}}` +/// Edge: `inner_a -> inner_b` +/// LCA = `inner`, divergent ancestors = `inner_a`, `inner_b` +/// Expected root ranks: `outer: 0` +/// Expected container ranks for `outer`: `inner: 0` +/// Expected container ranks for `inner`: `inner_a: 0`, `inner_b: 1` +#[test] +fn test_node_ranks_multi_nested_same_parent_edge() { + let node_nesting_infos = nesting_infos(&[ + ("outer", &["outer"]), + ("inner", &["outer", "inner"]), + ("inner_a", &["outer", "inner", "inner_a"]), + ("inner_b", &["outer", "inner", "inner_b"]), + ]); + let (edge_groups, entity_types) = + dep_edge_groups(&[("edge_inner_a_inner_b", "inner_a", "inner_b")]); + + let result = NodeRanksCalculator::calculate(&edge_groups, &entity_types, &node_nesting_infos); + + assert_root_ranks(&result, &[("outer", 0)]); + assert_container_ranks(&result, "outer", &[("inner", 0)]); + assert_container_ranks(&result, "inner", &[("inner_a", 0), ("inner_b", 1)]); +} + +/// Case 6: multi-nested-level `NodeRank`s do NOT increase inside inner +/// containers when an edge connects deeply-nested siblings under different +/// top-level parents. +/// +/// Hierarchy: `outer_x: {inner_x: {x_child}}`, `outer_y: {inner_y: {y_child}}` +/// Edge: `x_child -> y_child` +/// LCA = root, divergent ancestors = `outer_x`, `outer_y` +/// Expected root ranks: `outer_x: 0`, `outer_y: 1` (root level IS affected) +/// Expected container ranks for `outer_x`: `inner_x: 0` +/// Expected container ranks for `inner_x`: `x_child: 0` +/// Expected container ranks for `outer_y`: `inner_y: 0` +/// Expected container ranks for `inner_y`: `y_child: 0` +/// (only the root level is affected; all inner containers retain rank 0) +#[test] +fn test_node_ranks_multi_nested_different_top_level_parent_edge() { + let node_nesting_infos = nesting_infos(&[ + ("outer_x", &["outer_x"]), + ("inner_x", &["outer_x", "inner_x"]), + ("x_child", &["outer_x", "inner_x", "x_child"]), + ("outer_y", &["outer_y"]), + ("inner_y", &["outer_y", "inner_y"]), + ("y_child", &["outer_y", "inner_y", "y_child"]), + ]); + let (edge_groups, entity_types) = + dep_edge_groups(&[("edge_x_child_y_child", "x_child", "y_child")]); + + let result = NodeRanksCalculator::calculate(&edge_groups, &entity_types, &node_nesting_infos); + + assert_root_ranks(&result, &[("outer_x", 0), ("outer_y", 1)]); + assert_container_ranks(&result, "outer_x", &[("inner_x", 0)]); + assert_container_ranks(&result, "inner_x", &[("x_child", 0)]); + assert_container_ranks(&result, "outer_y", &[("inner_y", 0)]); + assert_container_ranks(&result, "inner_y", &[("y_child", 0)]); +} 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 9da8a11..84b221b 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 @@ -1,15 +1,27 @@ use disposition::{ + input_ir_model::IrDiagramAndIssues, + input_model::InputDiagram, ir_model::IrDiagram, model_common::{id, Id}, + svg_model::SvgElements, taffy_model::{taffy::TaffyError, DimensionAndLod}, }; -use disposition_input_ir_rt::{EdgeAnimationActive, IrToTaffyBuilder, TaffyToSvgElementsMapper}; +use disposition_input_ir_rt::{ + EdgeAnimationActive, InputDiagramMerger, InputToIrDiagramMapper, IrToTaffyBuilder, + TaffyToSvgElementsMapper, +}; -use crate::input_ir_rt::EXAMPLE_IR; +use crate::input_ir_rt::{ + EXAMPLE_IR, INPUT_DIAGRAM_0001_NESTED_NODE_EDGE_PROTRUSION, + INPUT_DIAGRAM_0002_NESTED_NODE_EDGE_PROTRUSION, INPUT_DIAGRAM_0003_EDGES_SYMMETRIC_2_NODES, + INPUT_DIAGRAM_0004_EDGES_SYMMETRIC_3_NODES, INPUT_DIAGRAM_0005_TAG_NODES_CYCLIC_EDGE, + INPUT_DIAGRAM_0006_PROCESS_STEP_NODES_CYCLIC_EDGE, + INPUT_DIAGRAM_0007_EDGE_FROM_NODE_TO_NESTED_NODE, + INPUT_DIAGRAM_0008_EDGE_FROM_NODE_TO_NESTED_RANK_1_NODE, +}; /// Helper: build `SvgElements` from the example IR fixture. -fn build_svg_elements_from_example_ir( -) -> impl Iterator<Item = disposition::svg_model::SvgElements<'static>> { +fn build_svg_elements_from_example_ir() -> impl Iterator<Item = SvgElements<'static>> { let ir_example = serde_saphyr::from_str::<IrDiagram>(EXAMPLE_IR).unwrap(); let ir_to_taffy_builder = IrToTaffyBuilder::builder() .with_ir_diagram(&ir_example) @@ -651,3 +663,1194 @@ fn test_process_infos_map_structure() -> Result<(), TaffyError> { Ok(()) } + +/// Helper: run the full input-diagram -> IR -> taffy -> SVG pipeline for the +/// given input diagram. +fn build_svg_elements_for_diagram( + input_diagram: &str, +) -> impl Iterator<Item = SvgElements<'static>> { + let overlay_diagram = serde_saphyr::from_str::<InputDiagram>(input_diagram).unwrap(); + let merged = InputDiagramMerger::merge(InputDiagram::base(), &overlay_diagram); + let IrDiagramAndIssues { diagram, .. } = InputToIrDiagramMapper::map(&merged); + let diagram: IrDiagram<'static> = diagram.into_static(); + let ir_to_taffy_builder = IrToTaffyBuilder::builder() + .with_ir_diagram(&diagram) + .with_dimension_and_lods(vec![DimensionAndLod::default_2xl()]) + .build(); + let taffy_results: Vec<_> = ir_to_taffy_builder + .build() + .expect("Expected taffy_node_mappings to be built.") + .collect(); + taffy_results + .into_iter() + .map(move |taffy_node_mappings| { + TaffyToSvgElementsMapper::map( + &diagram, + &taffy_node_mappings, + EdgeAnimationActive::Always, + ) + }) + .collect::<Vec<_>>() + .into_iter() +} + +/// The from-protrusion for `edge_dep_bob_charlie__0` must be large enough to +/// clear all sibling nodes at the same rank as `t_bob`'s Divergent ancestor. +/// +/// In this diagram, `t_bob` and `t_alice_outer` share rank 0 at the root level. +/// `t_alice_outer` is taller than `t_bob` (it contains a child node). The edge +/// from `t_bob` to `t_charlie` must protrude far enough downward that its +/// routing horizontal segment falls in the gap between `t_alice_outer`'s +/// bottom edge and `t_charlie`'s top edge -- not through `t_alice_outer`. +#[test] +fn test_nested_node_edge_protrusion_from_bob_clears_alice_outer() { + for svg_elements in + build_svg_elements_for_diagram(INPUT_DIAGRAM_0001_NESTED_NODE_EDGE_PROTRUSION) + { + // Find the relevant nodes. + let alice_outer = svg_elements + .svg_node_infos + .iter() + .find(|n| n.node_id.as_str() == "t_alice_outer") + .expect("Expected t_alice_outer in svg_node_infos"); + let bob = svg_elements + .svg_node_infos + .iter() + .find(|n| n.node_id.as_str() == "t_bob") + .expect("Expected t_bob in svg_node_infos"); + + // Compute the expected minimum from_protrusion for t_bob. + // The protrusion from t_bob's bottom face (y + height) must reach at + // least t_alice_outer's bottom face so the routing segment is in the + // gap below all rank-0 siblings. + let alice_outer_bottom = alice_outer.y + alice_outer.height_collapsed; + let bob_bottom = bob.y + bob.height_collapsed; + let expected_min_from_protrusion = (alice_outer_bottom - bob_bottom).max(0.0); + + // Find the edge from t_bob to t_charlie. + let bob_charlie_edge = svg_elements + .svg_edge_infos + .iter() + .find(|e| e.from_node_id.as_str() == "t_bob" && e.to_node_id.as_str() == "t_charlie") + .expect("Expected edge from t_bob to t_charlie"); + + assert!( + bob_charlie_edge.ortho_protrusion_params.from_protrusion + >= expected_min_from_protrusion, + "from_protrusion {:.2} for edge t_bob->t_charlie should be >= {:.2} \ + (t_alice_outer bottom {:.2} - t_bob bottom {:.2})", + bob_charlie_edge.ortho_protrusion_params.from_protrusion, + expected_min_from_protrusion, + alice_outer_bottom, + bob_bottom, + ); + } +} + +/// The from-protrusion for `edge_dep_bob_charlie__0` must push the routing +/// horizontal segment below `t_alice_outer`'s bottom edge. +/// +/// The horizontal routing y-coordinate for an orthogonal edge is: +/// `routing_y = bob_bottom + from_protrusion + arc_radius` +/// +/// This must be > `alice_outer_bottom` for the path not to overlap +/// `t_alice_outer`. Because `from_protrusion >= alice_outer_bottom - +/// bob_bottom`, we have `routing_y > alice_outer_bottom`. +#[test] +fn test_nested_node_edge_bob_charlie_routing_clears_alice_outer() { + // Arc radius used by the orthogonal path builder for rounded corners. + // This constant matches the `ARC_RADIUS` in + // `edge_path_builder_pass_2_ortho.rs`. + const ARC_RADIUS: f32 = 4.0; + + for svg_elements in + build_svg_elements_for_diagram(INPUT_DIAGRAM_0001_NESTED_NODE_EDGE_PROTRUSION) + { + let alice_outer = svg_elements + .svg_node_infos + .iter() + .find(|n| n.node_id.as_str() == "t_alice_outer") + .expect("Expected t_alice_outer in svg_node_infos"); + let bob = svg_elements + .svg_node_infos + .iter() + .find(|n| n.node_id.as_str() == "t_bob") + .expect("Expected t_bob in svg_node_infos"); + + let alice_outer_bottom = alice_outer.y + alice_outer.height_collapsed; + let bob_bottom = bob.y + bob.height_collapsed; + + let bob_charlie_edge = svg_elements + .svg_edge_infos + .iter() + .find(|e| e.from_node_id.as_str() == "t_bob" && e.to_node_id.as_str() == "t_charlie") + .expect("Expected edge from t_bob to t_charlie"); + + // The horizontal routing segment is at: + // routing_y = bob_bottom + from_protrusion + ARC_RADIUS + // For the routing to clear t_alice_outer, we need routing_y > + // alice_outer_bottom. + let from_protrusion = bob_charlie_edge.ortho_protrusion_params.from_protrusion; + let routing_y = bob_bottom + from_protrusion + ARC_RADIUS; + + assert!( + routing_y > alice_outer_bottom, + "Routing y {:.2} (bob_bottom {:.2} + from_protrusion {:.2} + arc_radius {:.2}) \ + must be > alice_outer_bottom {:.2} so the path does not overlap t_alice_outer", + routing_y, + bob_bottom, + from_protrusion, + ARC_RADIUS, + alice_outer_bottom, + ); + } +} + +// === Cycle edge routing tests === // + +/// Parse all SVG path endpoint coordinates (from `M` and `L` commands) from a +/// path `d` attribute string. +/// +/// Returns a `Vec<(f32, f32)>` of `(x, y)` pairs. +fn parse_path_endpoints(path_d: &str) -> Vec<(f32, f32)> { + let mut result = Vec::new(); + // Iterate over whitespace-separated tokens. + let tokens: Vec<&str> = path_d.split_whitespace().collect(); + let mut i = 0; + while i < tokens.len() { + let token = tokens[i]; + match token { + "M" | "L" => { + if let Some(coords) = tokens.get(i + 1) { + if let Some((x, y)) = parse_coord_pair(coords) { + result.push((x, y)); + } + i += 2; + continue; + } + } + "C" => { + // Curve: ctrl1 ctrl2 endpoint -- record all three pairs. + for offset in 1..=3 { + if let Some(coords) = tokens.get(i + offset) { + if let Some((x, y)) = parse_coord_pair(coords) { + result.push((x, y)); + } + } + } + i += 4; + continue; + } + _ => { + // Single token may be a coordinate pair if it contains a comma. + if token.contains(',') { + if let Some((x, y)) = parse_coord_pair(token) { + result.push((x, y)); + } + } + } + } + i += 1; + } + result +} + +/// Parse a `"x,y"` token into a `(f32, f32)` pair. +fn parse_coord_pair(s: &str) -> Option<(f32, f32)> { + let mut parts = s.splitn(2, ','); + let x: f32 = parts.next()?.parse().ok()?; + let y: f32 = parts.next()?.parse().ok()?; + Some((x, y)) +} + +// === Tag and process step node routing tests === // + +/// Builds `SvgElements` from the tag-nodes cyclic edge fixture. +/// +/// The fixture has 3 tags (`tag_a`, `tag_b`, `tag_c`) connected by a cyclic +/// edge group (`edge_dep_tags_cyclic`), producing edges `tag_a -> tag_b`, +/// `tag_b -> tag_c`, `tag_c -> tag_a`. All three tags end up at the same rank +/// due to the cycle. +fn build_svg_elements_from_tag_nodes_cyclic_edge() -> impl Iterator<Item = SvgElements<'static>> { + build_svg_elements_for_diagram(INPUT_DIAGRAM_0005_TAG_NODES_CYCLIC_EDGE) +} + +/// Builds `SvgElements` from the process-step-nodes cyclic edge fixture. +/// +/// The fixture has: +/// - 3 thing nodes (`t_alice`, `t_bob`, `t_charlie`) connected by a symmetric +/// edge group. +/// - A process `proc_test` with 3 steps (`proc_test_step_a`, +/// `proc_test_step_b`, `proc_test_step_c`) connected by a cyclic edge group +/// (`edge_dep_proc_steps_cyclic`). All three steps end up at the same rank +/// due to the cycle. +fn build_svg_elements_from_process_step_nodes_cyclic_edge( +) -> impl Iterator<Item = SvgElements<'static>> { + build_svg_elements_for_diagram(INPUT_DIAGRAM_0006_PROCESS_STEP_NODES_CYCLIC_EDGE) +} + +/// Tag nodes use cycle routing around other tag nodes, and nothing else. +/// +/// The fixture has 3 tags at the same rank connected by a cyclic edge group. +/// The wrapping edge `tag_c -> tag_a` (positions 2 and 0, diff = 2) triggers +/// cycle routing. +#[test] +fn test_tag_node_edges_protrusion_is_zero() { + for svg_elements in build_svg_elements_from_tag_nodes_cyclic_edge() { + // tag_a -> tag_b + let edge_tag_a_b = svg_elements + .svg_edge_infos + .iter() + .find(|edge_info| edge_info.edge_id.as_str() == "edge_dep_tags_cyclic__0") + .expect("Expected edge to exist."); + // tag_b -> tag_c + let edge_tag_b_c = svg_elements + .svg_edge_infos + .iter() + .find(|edge_info| edge_info.edge_id.as_str() == "edge_dep_tags_cyclic__1") + .expect("Expected edge to exist."); + // tag_c -> tag_a + let edge_tag_c_a = svg_elements + .svg_edge_infos + .iter() + .find(|edge_info| edge_info.edge_id.as_str() == "edge_dep_tags_cyclic__2") + .expect("Expected edge to exist."); + + assert_eq!( + 0.0, + edge_tag_a_b.ortho_protrusion_params.from_protrusion, + "Tag-node edge {:?} ({} -> {}) from_protrusion {:.2} should be 0 \ + (direct edge)", + edge_tag_a_b.edge_id, + edge_tag_a_b.from_node_id, + edge_tag_a_b.to_node_id, + edge_tag_a_b.ortho_protrusion_params.from_protrusion, + ); + assert_eq!( + 0.0, + edge_tag_a_b.ortho_protrusion_params.to_protrusion, + "Tag-node edge {:?} ({} -> {}) to_protrusion {:.2} should be 0 \ + (direct edge)", + edge_tag_a_b.edge_id, + edge_tag_a_b.from_node_id, + edge_tag_a_b.to_node_id, + edge_tag_a_b.ortho_protrusion_params.to_protrusion, + ); + + assert_eq!( + 0.0, + edge_tag_b_c.ortho_protrusion_params.from_protrusion, + "Tag-node edge {:?} ({} -> {}) from_protrusion {:.2} should be 0 \ + (direct edge)", + edge_tag_b_c.edge_id, + edge_tag_b_c.from_node_id, + edge_tag_b_c.to_node_id, + edge_tag_b_c.ortho_protrusion_params.from_protrusion, + ); + assert_eq!( + 0.0, + edge_tag_b_c.ortho_protrusion_params.to_protrusion, + "Tag-node edge {:?} ({} -> {}) to_protrusion {:.2} should be 0 \ + (direct edge)", + edge_tag_b_c.edge_id, + edge_tag_b_c.from_node_id, + edge_tag_b_c.to_node_id, + edge_tag_b_c.ortho_protrusion_params.to_protrusion, + ); + + assert!( + edge_tag_c_a.ortho_protrusion_params.from_protrusion > 0.0, + "Tag-node edge {:?} ({} -> {}) from_protrusion {:.2} should be greater than 0 \ + (loops around b)", + edge_tag_c_a.edge_id, + edge_tag_c_a.from_node_id, + edge_tag_c_a.to_node_id, + edge_tag_c_a.ortho_protrusion_params.from_protrusion, + ); + assert!( + edge_tag_c_a.ortho_protrusion_params.to_protrusion > 0.0, + "Tag-node edge {:?} ({} -> {}) to_protrusion {:.2} should be greater than 0 \ + (loops around b)", + edge_tag_c_a.edge_id, + edge_tag_c_a.from_node_id, + edge_tag_c_a.to_node_id, + edge_tag_c_a.ortho_protrusion_params.to_protrusion, + ); + } +} + +/// In a `LeftToRight` diagram containing both thing nodes and a process node, +/// thing-node cycle edges must be routed using only thing-node sibling extents +/// -- not clearing process nodes. +/// +/// In the `0006` fixture, thing nodes sit in a single vertical column at +/// `x = 20`, `width = 83` (right face at `x = 103`). The process node starts +/// further to the right. The non-adjacent same-rank edge `alice -> charlie` +/// uses Right/Right face routing and protrudes to the right. Before the +/// grouping fix, the protrusion was computed as 179 px (clearing the process +/// node's right edge). After the fix, the protrusion is based only on +/// thing-node sibling extents, so every path coordinate remains to the left +/// of the process node. +#[test] +fn test_thing_node_cycle_edges_not_routed_around_process_node() { + for svg_elements in build_svg_elements_from_process_step_nodes_cyclic_edge() { + let Some(proc_node) = svg_elements + .svg_node_infos + .iter() + .find(|n| n.node_id.as_str() == "proc_test") + else { + continue; + }; + // Any thing-node edge path coordinate must stay strictly to the left + // of the process node's left boundary. + let process_left_x = proc_node.x; + + for edge in &svg_elements.svg_edge_infos { + // Only check edges where both endpoints are thing nodes + // (process_id is None for thing/tag nodes; Some for process nodes + // and process step nodes). + let from_node = svg_elements + .svg_node_infos + .iter() + .find(|n| n.node_id == edge.from_node_id); + let to_node = svg_elements + .svg_node_infos + .iter() + .find(|n| n.node_id == edge.to_node_id); + let is_thing_edge = from_node.map_or(false, |n| n.process_id.is_none()) + && to_node.map_or(false, |n| n.process_id.is_none()); + if !is_thing_edge { + continue; + } + + let coords = parse_path_endpoints(&edge.path_d); + for (x, _y) in &coords { + assert!( + *x < process_left_x, + "Thing-node edge {:?} ({} -> {}) has path point x={:.2} >= \ + process left boundary x={:.2}; edge is being routed around \ + the process node instead of only around thing nodes", + edge.edge_id, + edge.from_node_id, + edge.to_node_id, + x, + process_left_x, + ); + } + } + } +} + +/// Builds `SvgElements` from the 2-node symmetric edge fixture. +fn build_svg_elements_from_symmetric_2_nodes() -> impl Iterator<Item = SvgElements<'static>> { + build_svg_elements_for_diagram(INPUT_DIAGRAM_0003_EDGES_SYMMETRIC_2_NODES) +} + +/// Builds `SvgElements` from the 3-node symmetric edge fixture. +fn build_svg_elements_from_symmetric_3_nodes() -> impl Iterator<Item = SvgElements<'static>> { + build_svg_elements_for_diagram(INPUT_DIAGRAM_0004_EDGES_SYMMETRIC_3_NODES) +} + +/// For the 2-node symmetric edge diagram, edges between adjacent siblings +/// must have zero protrusion. +/// +/// `t_alice` (position 0) and `t_bob` (position 1) are adjacent siblings with +/// the same direct parent. Adjacent siblings use normal face-selection routing +/// (connecting the two closest `NodeFace`s) instead of clockwise cycle routing, +/// so no protrusion is needed and both `from_protrusion` and `to_protrusion` +/// must be exactly 0. +#[test] +fn test_adjacent_siblings_protrusion_is_zero() { + for svg_elements in build_svg_elements_from_symmetric_2_nodes() { + for edge in &svg_elements.svg_edge_infos { + assert_eq!( + edge.ortho_protrusion_params.from_protrusion, 0.0, + "Adjacent-sibling edge {:?} from_protrusion {:.2} should be 0 \ + (normal routing, no cycle protrusion)", + edge.edge_id, edge.ortho_protrusion_params.from_protrusion, + ); + assert_eq!( + edge.ortho_protrusion_params.to_protrusion, 0.0, + "Adjacent-sibling edge {:?} to_protrusion {:.2} should be 0 \ + (normal routing, no cycle protrusion)", + edge.edge_id, edge.ortho_protrusion_params.to_protrusion, + ); + } + } +} + +/// For the 2-node symmetric edge diagram, no edge path coordinate must fall +/// strictly inside any node bounding box. +/// +/// With normal (nearest-face) routing for adjacent siblings: +/// - `alice -> bob` (`alice.x < bob.x`) uses `Right`/`Left` faces: path +/// segments travel between alice's right edge and bob's left edge. +/// - `bob -> alice` (`bob.x > alice.x`) uses `Left`/`Right` faces: path +/// segments travel between bob's left edge and alice's right edge. +#[test] +fn test_cycle_edges_2_nodes_no_overlap_with_nodes() { + for svg_elements in build_svg_elements_from_symmetric_2_nodes() { + for edge in &svg_elements.svg_edge_infos { + let from_node = svg_elements + .svg_node_infos + .iter() + .find(|n| n.node_id == edge.from_node_id) + .expect("from node"); + let to_node = svg_elements + .svg_node_infos + .iter() + .find(|n| n.node_id == edge.to_node_id) + .expect("to node"); + + let coords = parse_path_endpoints(&edge.path_d); + for (x, y) in coords { + // Check against every node in the diagram. + for node in &svg_elements.svg_node_infos { + let node_x_min = node.x; + let node_x_max = node.x + node.width; + let node_y_min = node.y; + let node_y_max = node.y + node.height_collapsed; + + let strictly_inside = + x > node_x_min && x < node_x_max && y > node_y_min && y < node_y_max; + + assert!( + !strictly_inside, + "Edge {:?} ({} -> {}) has path point ({:.2}, {:.2}) inside node {:?} \ + bounding box x=[{:.2},{:.2}] y=[{:.2},{:.2}]", + edge.edge_id, + from_node.node_id, + to_node.node_id, + x, + y, + node.node_id, + node_x_min, + node_x_max, + node_y_min, + node_y_max, + ); + } + } + } + } +} + +/// For the 3-node symmetric edge diagram, non-adjacent same-rank edges must +/// have a non-zero protrusion. +/// +/// The edge group uses `things: [t_bob, t_alice, t_charlie]` with +/// `kind: symmetric`, producing edges: `t_bob -> t_alice`, `t_alice -> +/// t_charlie`, `t_charlie -> t_alice`, `t_alice -> t_bob`. The hierarchy +/// positions are `t_alice=0`, `t_bob=1`, `t_charlie=2`. +/// +/// Adjacent-sibling edges (`t_bob <-> t_alice`, position diff = 1) use normal +/// routing and have zero protrusion. Non-adjacent edges (`t_alice <-> +/// t_charlie`, position diff = 2) are true cycle edges and must protrude by at +/// least `MIN_PROTRUSION_PX` (3.0 px). +#[test] +fn test_cycle_edges_3_nodes_protrusion_non_zero() { + const MIN_PROTRUSION_PX: f32 = 3.0; + + for svg_elements in build_svg_elements_from_symmetric_3_nodes() { + for edge in &svg_elements.svg_edge_infos { + // Only check non-adjacent same-rank edges (t_alice <-> t_charlie). + let is_non_adjacent_cycle_edge = (edge.from_node_id.as_str() == "t_alice" + && edge.to_node_id.as_str() == "t_charlie") + || (edge.from_node_id.as_str() == "t_charlie" + && edge.to_node_id.as_str() == "t_alice"); + if !is_non_adjacent_cycle_edge { + continue; + } + assert!( + edge.ortho_protrusion_params.from_protrusion >= MIN_PROTRUSION_PX, + "Non-adjacent cycle edge {:?} from_protrusion {:.2} should be >= {:.2}", + edge.edge_id, + edge.ortho_protrusion_params.from_protrusion, + MIN_PROTRUSION_PX, + ); + assert!( + edge.ortho_protrusion_params.to_protrusion >= MIN_PROTRUSION_PX, + "Non-adjacent cycle edge {:?} to_protrusion {:.2} should be >= {:.2}", + edge.edge_id, + edge.ortho_protrusion_params.to_protrusion, + MIN_PROTRUSION_PX, + ); + } + } +} + +/// For the 3-node symmetric edge diagram, no edge path coordinate must fall +/// strictly inside any node bounding box. +/// +/// For the 3-node symmetric edge diagram, each edge's `from_protrusion` must +/// equal its `to_protrusion` (symmetric U-shaped arc), and edges that route in +/// the same direction (same face) must have distinct protrusion depths so their +/// routing segments do not overlap. +#[test] +fn test_cycle_edges_3_nodes_symmetric_and_distinct_protrusions() { + for svg_elements in build_svg_elements_from_symmetric_3_nodes() { + // Verify from == to for every edge. + for edge in &svg_elements.svg_edge_infos { + assert_eq!( + edge.ortho_protrusion_params.from_protrusion, + edge.ortho_protrusion_params.to_protrusion, + "Edge {:?} from_protrusion {:.2} != to_protrusion {:.2}", + edge.edge_id, + edge.ortho_protrusion_params.from_protrusion, + edge.ortho_protrusion_params.to_protrusion, + ); + } + + // Verify that not all cycle edges have the same protrusion depth. + // With 3+ edges in the diagram there must be at least two distinct + // protrusion values (edges in the same direction group are stacked). + let mut protrusions: Vec<f32> = svg_elements + .svg_edge_infos + .iter() + .map(|e| e.ortho_protrusion_params.from_protrusion) + .collect(); + protrusions.sort_by(|a, b| a.partial_cmp(b).unwrap()); + protrusions.dedup(); + assert!( + protrusions.len() > 1, + "All cycle edges have the same protrusion {:.2}; expected distinct values", + protrusions[0], + ); + } +} + +/// All nodes are in the same column (`x = 20`, `width = 83`). Downward edges +/// route to the right (`x >= node.x + node.width`) and upward edges route to +/// the left (`x <= node.x`). +#[test] +fn test_cycle_edges_3_nodes_no_overlap_with_nodes() { + for svg_elements in build_svg_elements_from_symmetric_3_nodes() { + for edge in &svg_elements.svg_edge_infos { + let from_node = svg_elements + .svg_node_infos + .iter() + .find(|n| n.node_id == edge.from_node_id) + .expect("from node"); + let to_node = svg_elements + .svg_node_infos + .iter() + .find(|n| n.node_id == edge.to_node_id) + .expect("to node"); + + let coords = parse_path_endpoints(&edge.path_d); + for (x, y) in coords { + for node in &svg_elements.svg_node_infos { + let node_x_min = node.x; + let node_x_max = node.x + node.width; + let node_y_min = node.y; + let node_y_max = node.y + node.height_collapsed; + + let strictly_inside = + x > node_x_min && x < node_x_max && y > node_y_min && y < node_y_max; + + assert!( + !strictly_inside, + "Edge {:?} ({} -> {}) has path point ({:.2}, {:.2}) inside node {:?} \ + bounding box x=[{:.2},{:.2}] y=[{:.2},{:.2}]", + edge.edge_id, + from_node.node_id, + to_node.node_id, + x, + y, + node.node_id, + node_x_min, + node_x_max, + node_y_min, + node_y_max, + ); + } + } + } + } +} + +// === Edge from node to nested node (0007) === // + +/// Loads `0007_edge_from_node_to_nested_node.yaml` and returns one +/// `SvgElements` per LOD. +fn build_svg_elements_from_edge_from_node_to_nested_node( +) -> impl Iterator<Item = SvgElements<'static>> { + build_svg_elements_for_diagram(INPUT_DIAGRAM_0007_EDGE_FROM_NODE_TO_NESTED_NODE) +} + +/// An edge from `t_alice` to `t_charlie_1` connects to a node at rank 0 inside +/// its parent container `t_charlie_outer`. The edge should NOT route through a +/// cross-container spacer alongside `t_charlie_2`, because both +/// `t_charlie_1` and `t_charlie_2` are at rank 0 (side-by-side) -- there are +/// no intermediate siblings between the container entry and the target. +#[test] +fn test_edge_to_nested_rank_0_node_has_no_cross_container_spacer() { + for svg_elements in build_svg_elements_from_edge_from_node_to_nested_node() { + let alice_charlie_1_edge = svg_elements + .svg_edge_infos + .iter() + .find(|e| { + e.from_node_id.as_str() == "t_alice" && e.to_node_id.as_str() == "t_charlie_1" + }) + .expect("Expected edge from t_alice to t_charlie_1"); + + assert!( + alice_charlie_1_edge + .ortho_protrusion_params + .spacer_protrusions + .is_empty(), + "Expected no spacer protrusions for edge t_alice -> t_charlie_1 \ + (t_charlie_1 is at rank 0 inside t_charlie_outer, so no siblings \ + are between the container entry and the target): \ + spacer_protrusions = {:?}", + alice_charlie_1_edge + .ortho_protrusion_params + .spacer_protrusions, + ); + } +} + +/// An edge from `t_bob` (top-level, rank 0) to `t_charlie_1` (rank 0 inside +/// `t_charlie_outer`, which is rank 1 at root level) should use normal face +/// routing -- Bottom of `t_bob` to Top of `t_charlie_1` -- not cycle-edge +/// clockwise routing. +/// +/// The incorrect behaviour (before the fix) was to compare only the local +/// context rank of each node, both of which happen to be 0, triggering the +/// same-rank cycle edge detection. The fix uses LCA-level ranks instead: +/// `t_bob` is rank 0 and `t_charlie_outer` (the divergent ancestor of +/// `t_charlie_1` at the root LCA level) is rank 1, so the edge is correctly +/// classified as a forward edge. +#[test] +fn test_edge_from_toplevel_to_nested_rank_0_node_uses_normal_face_routing() { + for svg_elements in build_svg_elements_from_edge_from_node_to_nested_node() { + let bob = svg_elements + .svg_node_infos + .iter() + .find(|n| n.node_id.as_str() == "t_bob") + .expect("Expected t_bob in svg_node_infos"); + let charlie_1 = svg_elements + .svg_node_infos + .iter() + .find(|n| n.node_id.as_str() == "t_charlie_1") + .expect("Expected t_charlie_1 in svg_node_infos"); + + let bob_charlie_1_edge = svg_elements + .svg_edge_infos + .iter() + .find(|e| e.from_node_id.as_str() == "t_bob" && e.to_node_id.as_str() == "t_charlie_1") + .expect("Expected edge from t_bob to t_charlie_1"); + + // The path is built from to-node to from-node in the SVG direction. + // For a Bottom (t_bob) -> Top (t_charlie_1) edge the first SVG `M` + // command should have y near t_charlie_1's top face (charlie_1.y) + // and the final `L` command should be near t_bob's bottom face + // (bob.y + bob.height_collapsed). + // + // Note: kurbo generates concatenated path commands (e.g. `M80,210` + // rather than `M 80,210`), so we parse the first/last tokens directly + // rather than using the generic `parse_path_endpoints` helper. + let path_tokens: Vec<&str> = bob_charlie_1_edge.path_d.split_whitespace().collect(); + assert!( + !path_tokens.is_empty(), + "Expected non-empty path for edge t_bob -> t_charlie_1" + ); + + let parse_suffixed = |s: &str, prefix: char| -> Option<(f32, f32)> { + let s = s.strip_prefix(prefix)?; + let (x_str, y_str) = s.split_once(',')?; + Some((x_str.parse().ok()?, y_str.parse().ok()?)) + }; + + // Allow a tolerance for protrusion stubs and face offsets. + let tolerance = 20.0_f32; + + let (_, first_y) = path_tokens + .first() + .and_then(|t| parse_suffixed(t, 'M')) + .expect("Path should start with M command (e.g. M80,210)"); + let expected_first_y = charlie_1.y; // Top face of t_charlie_1 + assert!( + (first_y - expected_first_y).abs() <= tolerance, + "First path point y={first_y:.2} should be near t_charlie_1 top face \ + y={expected_first_y:.2} (tolerance {tolerance:.0} px). \ + Got cycle-edge routing instead of Bottom->Top routing. \ + path_d = {:?}, ortho_protrusion_params = {:?}", + bob_charlie_1_edge.path_d, + bob_charlie_1_edge.ortho_protrusion_params, + ); + + let (_, last_y) = path_tokens + .last() + .and_then(|t| parse_suffixed(t, 'L').or_else(|| parse_suffixed(t, 'M'))) + .expect("Path should end with an L or M command"); + let expected_last_y = bob.y + bob.height_collapsed; // Bottom face of t_bob + assert!( + (last_y - expected_last_y).abs() <= tolerance, + "Last path point y={last_y:.2} should be near t_bob bottom face \ + y={expected_last_y:.2} (tolerance {tolerance:.0} px). \ + path_d = {:?}", + bob_charlie_1_edge.path_d, + ); + } +} + +/// For `edge_dep_alice_charlie_1`, the Z/S routing segment connecting the +/// two protrusion tips must stay within the gap between the two containers. +/// +/// When the gap between the two protrusion tips is smaller than `ARC_RADIUS`, +/// the bend placement must be chosen carefully: +/// +/// - **First bug**: bend placed *below* the to-protrusion tip (inside +/// `t_charlie_outer`) -- the path dipped into the container before routing +/// through the gap, creating an upward curve that contradicts the downward +/// flow direction. +/// +/// - **Second bug**: bend placed *above* the from-protrusion tip (outside the +/// gap, further up than necessary) -- the path then had to loop back downward +/// to reach the from-protrusion tip, creating a visible backward movement in +/// the arrow (visual) direction. +/// +/// The correct fix places the bend at the *midpoint* between the two +/// protrusion tips, keeping it strictly inside the routing gap. This ensures +/// both Leg 1 (from the to-protrusion tip) and Leg 3 (arriving at the +/// from-protrusion tip) travel in the same upward direction, matching the +/// edge's overall flow. +#[test] +fn test_edge_from_nested_routing_stays_within_gap() { + for svg_elements in build_svg_elements_from_edge_from_node_to_nested_node() { + let charlie_outer = svg_elements + .svg_node_infos + .iter() + .find(|n| n.node_id.as_str() == "t_charlie_outer") + .expect("Expected t_charlie_outer in svg_node_infos"); + + let alice_charlie_1_edge = svg_elements + .svg_edge_infos + .iter() + .find(|e| { + e.from_node_id.as_str() == "t_alice" && e.to_node_id.as_str() == "t_charlie_1" + }) + .expect("Expected edge from t_alice to t_charlie_1"); + + let charlie_outer_top_y = charlie_outer.y; + + // The path is built in SVG order from the to-node (charlie_1, at the + // bottom) to the from-node (alice, at the top). All coordinates + // between the first (charlie_1 contact y) and the last (alice contact + // y) are the routing segment. + let all_coords = parse_path_endpoints(&alice_charlie_1_edge.path_d); + + // The from-protrusion tip is the second-to-last coordinate (just + // before the alice contact point). Its y is the upper bound of the + // routing gap -- no intermediate point should overshoot above it. + let from_protrusion_tip_y = all_coords + .get(all_coords.len().wrapping_sub(2)) + .map(|&(_, y)| y) + .unwrap_or(0.0); + + // Skip the first (charlie_1 contact) and last (alice contact). + let intermediate_coords = all_coords + .iter() + .skip(1) + .take(all_coords.len().saturating_sub(2)); + + for &(x, y) in intermediate_coords { + // No intermediate point should fall below charlie_outer's top -- + // that means the Z/S dipped into the destination container. + assert!( + y <= charlie_outer_top_y + 0.5, + "Intermediate routing coordinate ({x:.3}, {y:.3}) is below \ + t_charlie_outer's top boundary (y={charlie_outer_top_y:.3}). \ + The Z/S bend was placed inside the destination container. \ + path_d = {:?}", + alice_charlie_1_edge.path_d, + ); + + // No intermediate point should overshoot above the from-protrusion + // tip -- that means the Z/S looped backward in the visual + // (arrow) direction, going further up than needed and then + // reversing to reach the from-protrusion tip. + assert!( + y >= from_protrusion_tip_y - 0.5, + "Intermediate routing coordinate ({x:.3}, {y:.3}) overshoots \ + above the from-protrusion tip (y={from_protrusion_tip_y:.3}). \ + The Z/S bend was placed outside the routing gap, causing a \ + backward loop in the visual arrow direction. \ + path_d = {:?}", + alice_charlie_1_edge.path_d, + ); + } + } +} + +/// Loads `0008_edge_from_node_to_nested_rank_1_node.yaml` and returns one +/// `SvgElements` per LOD. +fn build_svg_elements_from_edge_from_node_to_nested_rank_1_node( +) -> impl Iterator<Item = SvgElements<'static>> { + build_svg_elements_for_diagram(INPUT_DIAGRAM_0008_EDGE_FROM_NODE_TO_NESTED_RANK_1_NODE) +} + +/// In `0008`, `t_charlie_3` is at rank 1 inside `t_charlie_outer` (because +/// `edge_dep_charlie_2_charlie_3` promotes `t_charlie_3` to rank 1 within +/// that container). An edge from `t_alice` to `t_charlie_3` therefore needs +/// cross-container spacers alongside the rank-0 siblings (`t_charlie_1` and +/// `t_charlie_2`) so that the edge path routes correctly around them. +#[test] +fn test_edge_to_nested_rank_1_node_has_cross_container_spacers() { + for svg_elements in build_svg_elements_from_edge_from_node_to_nested_rank_1_node() { + let alice_charlie_3_edge = svg_elements + .svg_edge_infos + .iter() + .find(|e| { + e.from_node_id.as_str() == "t_alice" && e.to_node_id.as_str() == "t_charlie_3" + }) + .expect("Expected edge from t_alice to t_charlie_3"); + + assert!( + !alice_charlie_3_edge + .ortho_protrusion_params + .spacer_protrusions + .is_empty(), + "Expected cross-container spacer protrusions for edge \ + t_alice -> t_charlie_3 (t_charlie_3 is at rank 1 inside \ + t_charlie_outer; rank-0 siblings t_charlie_1 and t_charlie_2 \ + should produce spacers so the edge routes around them)", + ); + } +} + +/// In `0008`, the `t_alice -> t_charlie_3` edge has one cross-container +/// spacer inside `t_charlie_outer` that routes around the rank-0 siblings. +/// The to_protrusion from `t_charlie_3`'s Top face should be small enough +/// to only reach the spacer exit (inside `t_charlie_outer`), NOT overshoot +/// all the way to `t_charlie_outer`'s top boundary. Overshooting causes the +/// path to re-enter the container from the outside and produces a zigzag. +#[test] +fn test_edge_to_nested_rank_1_node_to_protrusion_stays_within_container() { + for svg_elements in build_svg_elements_from_edge_from_node_to_nested_rank_1_node() { + let charlie_outer = svg_elements + .svg_node_infos + .iter() + .find(|n| n.node_id.as_str() == "t_charlie_outer") + .expect("Expected t_charlie_outer"); + let charlie_3 = svg_elements + .svg_node_infos + .iter() + .find(|n| n.node_id.as_str() == "t_charlie_3") + .expect("Expected t_charlie_3"); + let alice_charlie_3_edge = svg_elements + .svg_edge_infos + .iter() + .find(|e| { + e.from_node_id.as_str() == "t_alice" && e.to_node_id.as_str() == "t_charlie_3" + }) + .expect("Expected edge from t_alice to t_charlie_3"); + + // The maximum sensible to_protrusion is the distance from + // t_charlie_3's Top face (y = charlie_3.y) to the first + // spacer exit, which is well inside t_charlie_outer. The + // container-exit distance (charlie_3.y - charlie_outer.y) + // is the pathological over-shoot value that causes the zigzag. + let container_exit_distance = charlie_3.y - charlie_outer.y; + let to_protrusion = alice_charlie_3_edge.ortho_protrusion_params.to_protrusion; + assert!( + to_protrusion < container_exit_distance, + "to_protrusion ({to_protrusion:.2}) should be less than the \ + container-exit distance ({container_exit_distance:.2} = \ + charlie_3.y {:.2} - charlie_outer.y {:.2}). \ + A to_protrusion equal to the container-exit distance means the \ + path overshoots t_charlie_outer's top boundary, re-enters the \ + container from outside, and produces a zigzag.", + charlie_3.y, + charlie_outer.y, + ); + } +} + +/// In `0008`, `t_charlie_3` is at rank 1 inside `t_charlie_outer`, and the +/// only lower rank (rank 0) contains two siblings: `t_charlie_1` and +/// `t_charlie_2`. The edge from `t_alice` to `t_charlie_3` should route +/// around the rank-0 row as a whole -- one spacer per rank group is +/// sufficient, so exactly one spacer protrusion should be recorded. +#[test] +fn test_edge_to_nested_rank_1_node_has_exactly_one_cross_container_spacer() { + for svg_elements in build_svg_elements_from_edge_from_node_to_nested_rank_1_node() { + let alice_charlie_3_edge = svg_elements + .svg_edge_infos + .iter() + .find(|e| { + e.from_node_id.as_str() == "t_alice" && e.to_node_id.as_str() == "t_charlie_3" + }) + .expect("Expected edge from t_alice to t_charlie_3"); + + let spacer_count = alice_charlie_3_edge + .ortho_protrusion_params + .spacer_protrusions + .len(); + assert_eq!( + spacer_count, + 1, + "Expected exactly one cross-container spacer protrusion for edge \ + t_alice -> t_charlie_3. Both rank-0 siblings t_charlie_1 and \ + t_charlie_2 belong to the same rank group and should share one \ + spacer. Got {spacer_count} spacer(s): {:?}", + alice_charlie_3_edge + .ortho_protrusion_params + .spacer_protrusions, + ); + } +} + +/// Edges to `t_charlie_1` (rank 0 in `t_charlie_outer`) should have no +/// cross-container spacers, even in the presence of a rank-1 sibling +/// (`t_charlie_3`). +#[test] +fn test_edge_to_nested_rank_0_node_has_no_spacers_in_complex_diagram() { + for svg_elements in build_svg_elements_from_edge_from_node_to_nested_rank_1_node() { + // alice -> charlie_1 edge + let alice_charlie_1_edge = svg_elements + .svg_edge_infos + .iter() + .find(|e| { + e.from_node_id.as_str() == "t_alice" && e.to_node_id.as_str() == "t_charlie_1" + }) + .expect("Expected edge from t_alice to t_charlie_1"); + + assert!( + alice_charlie_1_edge + .ortho_protrusion_params + .spacer_protrusions + .is_empty(), + "Expected no spacer protrusions for edge t_alice -> t_charlie_1 \ + in the 0008 diagram (t_charlie_1 is at rank 0): \ + spacer_protrusions = {:?}", + alice_charlie_1_edge + .ortho_protrusion_params + .spacer_protrusions, + ); + + // bob -> charlie_1 edge: also no spacers + let bob_charlie_1_edge = svg_elements + .svg_edge_infos + .iter() + .find(|e| e.from_node_id.as_str() == "t_bob" && e.to_node_id.as_str() == "t_charlie_1") + .expect("Expected edge from t_bob to t_charlie_1"); + + assert!( + bob_charlie_1_edge + .ortho_protrusion_params + .spacer_protrusions + .is_empty(), + "Expected no spacer protrusions for edge t_bob -> t_charlie_1 \ + in the 0008 diagram (t_charlie_1 is at rank 0): \ + spacer_protrusions = {:?}", + bob_charlie_1_edge + .ortho_protrusion_params + .spacer_protrusions, + ); + } +} + +/// The edge from `t_alice_inner` to `t_charlie_inner` in the doubly-nested +/// diagram must route orthogonally without entering `t_charlie_outer`'s +/// interior. +/// +/// Two bugs could cause intermediate routing coordinates to fall below +/// `t_charlie_outer`'s top: +/// +/// 1. The `connect_waypoints` collinear check using `dot_p.abs() > 0.95` +/// incorrectly treated the nearly anti-collinear displacement between the +/// two protrusion tips as "straight", drawing a diagonal line instead of an +/// orthogonal Z/S bend. +/// +/// 2. The from-protrusion (73.44 px) plus the to-protrusion (110.0 px) summed +/// to 183.44 px, exceeding the node-to-node gap (153 px). The +/// from-protrusion tip was placed inside `t_charlie_outer` (at y=245.44), +/// below the to-protrusion tip (at y=215.0). +/// +/// After the fix the from-protrusion is capped to 43 px (= 153 - 110), so +/// both tips meet at `t_charlie_outer`'s top boundary (y=215). The V-spike +/// guard in `connect_waypoints` (see +/// `test_nested_x2_node_edge_routing_no_upward_detour`) then replaces the Z/S +/// U-bend between the tips with a straight horizontal line, so no intermediate +/// coordinate falls below `t_charlie_outer.y`. +#[test] +fn test_nested_x2_node_edge_routing_stays_above_charlie_outer() { + for svg_elements in + build_svg_elements_for_diagram(INPUT_DIAGRAM_0002_NESTED_NODE_EDGE_PROTRUSION) + { + let charlie_outer = svg_elements + .svg_node_infos + .iter() + .find(|n| n.node_id.as_str() == "t_charlie_outer") + .expect("Expected t_charlie_outer in svg_node_infos"); + + let alice_inner_charlie_inner_edge = svg_elements + .svg_edge_infos + .iter() + .find(|e| { + e.from_node_id.as_str() == "t_alice_inner" + && e.to_node_id.as_str() == "t_charlie_inner" + }) + .expect("Expected edge from t_alice_inner to t_charlie_inner"); + + let charlie_outer_top_y = charlie_outer.y; + + // The path is built in SVG order from the to-node (t_charlie_inner, + // at the bottom) to the from-node (t_alice_inner, at the top). The + // first coordinate is t_charlie_inner's contact point (inside + // t_charlie_outer) and the last is t_alice_inner's contact point + // (above all containers). + // + // All *intermediate* coordinates represent the routing segment + // connecting the two protrusion tips. None of them should fall below + // t_charlie_outer's top, which would indicate the Z/S bend dipped + // into the destination container. + let all_coords = parse_path_endpoints(&alice_inner_charlie_inner_edge.path_d); + + let intermediate_coords = all_coords + .iter() + .skip(1) + .take(all_coords.len().saturating_sub(2)); + + for &(x, y) in intermediate_coords { + assert!( + y <= charlie_outer_top_y + 0.5, + "Intermediate routing coordinate ({x:.3}, {y:.3}) is below \ + t_charlie_outer's top boundary (y={charlie_outer_top_y:.3}). \ + The Z/S bend dipped into the destination container. \ + path_d = {:?}", + alice_inner_charlie_inner_edge.path_d, + ); + } + } +} + +/// The edge from `t_alice_inner` to `t_charlie_inner` in the doubly-nested +/// diagram must not create a V-spike at the `t_charlie_outer` boundary. +/// +/// After `from_protrusion_capped` places both protrusion tips at y=215 +/// (t_charlie_outer's top), the naive Z/S U-bend would route: upward from +/// the to-tip at (97,215) to y=211, across to x=88.5, then back down to +/// the from-tip at (88.5,215). The `is_same_axis` return leg then +/// immediately travels upward to (88.5,172), reversing direction and +/// creating an incoherent V-spike. +/// +/// The fix in `connect_waypoints` detects vertical tips at the same Y with +/// opposite departure directions and draws a straight horizontal line instead. +/// No intermediate coordinate should appear above `t_charlie_outer`'s top +/// (y < charlie_outer.y - 0.5 would indicate an upward detour). +#[test] +fn test_nested_x2_node_edge_routing_no_upward_detour() { + for svg_elements in + build_svg_elements_for_diagram(INPUT_DIAGRAM_0002_NESTED_NODE_EDGE_PROTRUSION) + { + let charlie_outer = svg_elements + .svg_node_infos + .iter() + .find(|n| n.node_id.as_str() == "t_charlie_outer") + .expect("Expected t_charlie_outer in svg_node_infos"); + + let alice_inner_charlie_inner_edge = svg_elements + .svg_edge_infos + .iter() + .find(|e| { + e.from_node_id.as_str() == "t_alice_inner" + && e.to_node_id.as_str() == "t_charlie_inner" + }) + .expect("Expected edge from t_alice_inner to t_charlie_inner"); + + let charlie_outer_top_y = charlie_outer.y; + + // The path is built in SVG order: to-node first, from-node last. + // After the fix, all intermediate points lie at exactly y=215. + // Before the fix they included arc control points at y≈211--213 + // (the U-bend detour) that are above t_charlie_outer's top. + let all_coords = parse_path_endpoints(&alice_inner_charlie_inner_edge.path_d); + let intermediate_coords = all_coords + .iter() + .skip(1) + .take(all_coords.len().saturating_sub(2)); + + for &(x, y) in intermediate_coords { + assert!( + y >= charlie_outer_top_y - 0.5, + "Intermediate routing coordinate ({x:.3}, {y:.3}) is above \ + t_charlie_outer's top boundary (y={charlie_outer_top_y:.3}). \ + The path detours above the boundary, indicating a V-spike. \ + path_d = {:?}", + alice_inner_charlie_inner_edge.path_d, + ); + } + } +} + +/// In `0008`, edges from `t_bob` to `t_charlie_1` (rank 0 inside +/// `t_charlie_outer`) should use normal Bottom -> Top face routing, not +/// cycle-edge routing, even though both nodes have local rank 0 in their +/// respective parent contexts. +#[test] +fn test_edge_from_toplevel_to_nested_rank_0_node_uses_normal_routing_complex_diagram() { + for svg_elements in build_svg_elements_from_edge_from_node_to_nested_rank_1_node() { + let bob = svg_elements + .svg_node_infos + .iter() + .find(|n| n.node_id.as_str() == "t_bob") + .expect("Expected t_bob"); + let charlie_1 = svg_elements + .svg_node_infos + .iter() + .find(|n| n.node_id.as_str() == "t_charlie_1") + .expect("Expected t_charlie_1"); + + let bob_charlie_1_edge = svg_elements + .svg_edge_infos + .iter() + .find(|e| e.from_node_id.as_str() == "t_bob" && e.to_node_id.as_str() == "t_charlie_1") + .expect("Expected edge from t_bob to t_charlie_1"); + + let path_tokens: Vec<&str> = bob_charlie_1_edge.path_d.split_whitespace().collect(); + assert!( + !path_tokens.is_empty(), + "Expected non-empty path for edge t_bob -> t_charlie_1" + ); + + let parse_suffixed = |s: &str, prefix: char| -> Option<(f32, f32)> { + let s = s.strip_prefix(prefix)?; + let (x_str, y_str) = s.split_once(',')?; + Some((x_str.parse().ok()?, y_str.parse().ok()?)) + }; + + let tolerance = 20.0_f32; + + // Path starts at to-node (t_charlie_1 top face). + let (_, first_y) = path_tokens + .first() + .and_then(|t| parse_suffixed(t, 'M')) + .expect("Path should start with M command (e.g. M80,210)"); + let expected_first_y = charlie_1.y; + assert!( + (first_y - expected_first_y).abs() <= tolerance, + "First path point y={first_y:.2} should be near t_charlie_1 top face \ + y={expected_first_y:.2} (tolerance {tolerance:.0} px). \ + Cycle-edge routing produces a different starting y. \ + path_d = {:?}", + bob_charlie_1_edge.path_d, + ); + + // Path ends at from-node (t_bob bottom face). + let (_, last_y) = path_tokens + .last() + .and_then(|t| parse_suffixed(t, 'L').or_else(|| parse_suffixed(t, 'M'))) + .expect("Path should end with an L or M command"); + let expected_last_y = bob.y + bob.height_collapsed; + assert!( + (last_y - expected_last_y).abs() <= tolerance, + "Last path point y={last_y:.2} should be near t_bob bottom face \ + y={expected_last_y:.2} (tolerance {tolerance:.0} px). \ + path_d = {:?}", + bob_charlie_1_edge.path_d, + ); + } +}