% Input:       string records from language; frames and silhouettes from geometry.
% Output:      saved paths, crossing gaps, bead anchors, and string events.
% Owned state: the string registry and crossing, join, and gap ledgers.
% Invariants:  declared crossings exist; geometric crossings are declared.
% Next stage:  tenkz-render draws strings; the language stage supplies records.
% SPDX-License-Identifier: Apache-2.0
% Copyright the TNLean project; see LICENSE for the full terms.
%
% Curve model.  A string routes through waypoints as a Hobby spline: Hobby's
% algorithm (the METAFONT curve engine) chooses tangents and curvature
% globally, so a route declared by positions alone comes out graceful with
% no angle annotations.  This retires the out=/in= angle pair, the largest
% escape-hatch population in the shrink census (297 corpus occurrences).
% Closed strings are closed Hobby cycles: one smooth loop replaces the
% four-arc closure quartet.  Crossings are declared data: the under path is
% split at its geometric intersections with the over path and each cut end
% retreats by half the crossgap; the over path is drawn whole.  Storage and
% surgery are spath3; intersection counting is the pgf intersections engine;
% both are xelatex-clean and verified composable with Hobby splines here.
% The consumer loads the TikZ libraries hobby, spath3, and intersections.
% Requires: @ has letter catcode while this module is read; package loading
% and the standalone probes both establish that caller-owned scope.
%
% Units.  Waypoints and centres are caller-space TikZ numbers, written as
% braced pairs {x,y}; silhouette queries return pitch units and convert at
% the boundary through an explicit caller-space scale.  Every styling
% constant is a named metric ratio.

\ExplSyntaxOn

% ---------- diagnostics ------------------------------------------------------
\msg_new:nnnn {tenkz}{string-redeclared}
  { [TKZ-STRING-REDECLARED]~string~'#1'~already~exists }
  { Each~string~id~is~declared~once;~pick~a~fresh~name. }
\msg_new:nnnn {tenkz}{string-unknown}
  { [TKZ-STRING-UNKNOWN]~no~string~named~'#1' }
  { Declare~the~string~before~referencing~it. }
\msg_new:nnnn {tenkz}{string-reserved}
  { [TKZ-STRING-RESERVED]~string~id~'#1'~uses~a~reserved~prefix }
  { Pick~an~id~that~does~not~begin~with~'tenkz-bead-'. }
\msg_new:nnnn {tenkz}{string-path-format}
  { [TKZ-STRING-PATH]~unsupported~spath3~cubic~with~#1~tokens }
  { tenkz~expects~spath3's~12-token~cubic~segment~contract. }
\msg_new:nnnn {tenkz}{cross-not-found}
  { [TKZ-CROSS-NOT-FOUND]~'#1'~is~declared~under~'#2'~but~they~never~meet }
  { The~declared~crossing~has~no~geometric~intersection;~fix~the~route~or~
    remove~the~declaration. }
\msg_new:nnnn {tenkz}{cross-undeclared}
  { [TKZ-CROSS-UNDECLARED]~strings~'#1'~and~'#2'~intersect~with~no~
    declared~order }
  { Every~crossing~carries~a~declared~over/under;~add~one~or~reroute. }
\msg_new:nnnn {tenkz}{cross-multiple}
  { [TKZ-CROSS-MULTIPLE]~strings~'#1'~and~'#2'~meet~more~than~once }
  { Split~one~route~into~separately~named~strings~so~each~ordered~pair~
    has~one~geometric~crossing. }
\msg_new:nnnn {tenkz}{cross-selector}
  { [TKZ-CROSS-SELECTOR]~self-crossing~selector~'#2'~for~'#1'~is~invalid }
  { Select~each~post-split~component~boundary~once,~using~an~integer~from~
    1~through~the~penultimate~component. }
\msg_new:nnnn {tenkz}{join-not-endpoint}
  { [TKZ-JOIN-NOT-ENDPOINT]~strings~'#1'~and~'#2'~do~not~share~an~endpoint }
  { A~join~only~exempts~a~meeting~at~the~end~of~at~least~one~saved~route. }
\msg_new:nnnn {tenkz}{cross-disjoint}
  { [TKZ-CROSS-DISJOINT]~strings~'#1'~and~'#2'~are~model-proven~disjoint }
  { Remove~their~crossing~declaration~or~change~the~route~model. }
\msg_new:nnnn {tenkz}{join-disjoint}
  { [TKZ-JOIN-DISJOINT]~strings~'#1'~and~'#2'~are~model-proven~disjoint }
  { Remove~their~join~declaration~or~change~the~route~model. }
\msg_new:nnnn {tenkz}{cross-touchall-undeclared}
  { [TKZ-CROSS-TOUCHALL]~author~string~'#1'~crosses~#3~'#2'~with~no~
    declared~crossing }
  { A~calibrated~break~was~cut~into~the~string~so~the~unbroken~ink~does~
    not~read~as~a~contraction;~declare~an~explicit~crossing~to~silence~
    this~warning. }
\msg_new:nnnn {tenkz}{string-library}
  { [TKZ-STRING-LIBRARY]~TikZ~library~'#1'~is~not~loaded }
  { The~string~engine~needs~hobby,~spath3,~and~intersections. }

% ---------- state ------------------------------------------------------------
\seq_new:N \l__tenkz_string_ids_seq          % declaration order
\__tenkz_prop_new_indexed:N \l__tenkz_string_kind_prop       % id -> open|closed|wind|route
\prop_new:N \l__tenkz_string_source_prop     % id -> caller|generated
\seq_new:N \l__tenkz_string_cross_seq        % {under}{over} pairs
\__tenkz_prop_new_indexed:N \l__tenkz_string_cross_prop      % unordered pair -> declarations
\__tenkz_prop_new_indexed:N \l__tenkz_string_join_prop       % unordered pair -> endpoint joins
\__tenkz_prop_new_indexed:N \l__tenkz_string_touch_prop      % shared semantic wire ports
\__tenkz_prop_new_indexed:N \l__tenkz_string_touch_all_prop  % topology-owned overlap route
\__tenkz_prop_new_indexed:N \l__tenkz_string_touch_all_exempt_prop % route still checked
\__tenkz_prop_new_indexed:N \l__tenkz_string_author_prop     % id -> author-declared string
\__tenkz_prop_new_indexed:N \l__tenkz_string_disjoint_prop   % model-proven disjoint pairs
\seq_new:N \l__tenkz_string_gap_ids_seq      % under paths awaiting one gap pass
\seq_new:N \l__tenkz_string_self_gap_ids_seq % self paths awaiting branch gap
\seq_new:N \l__tenkz_string_processed_seq    % directed crossing pairs split
\seq_new:N \l__tenkz_string_segments_seq
\prop_new:N \l__tenkz_string_self_gap_prop   % self id -> component boundary
\prop_new:N \l__tenkz_string_external_gap_prop % under id -> component boundaries
\prop_new:N \l__tenkz_string_crossing_point_prop % {under}{over} -> {x}{y}
\prop_new:N \l__tenkz_string_selector_seen_prop
\seq_new:N \l__tenkz_string_before_components_seq
\seq_new:N \l__tenkz_string_after_components_seq
\seq_new:N \l__tenkz_string_pts_seq
\seq_new:N \l__tenkz_string_scan_seq
\seq_new:N \l__tenkz_string_endpoint_seq
\seq_new:N \l__tenkz_string_components_seq
\tl_new:N \l__tenkz_string_pts_tl
\tl_new:N \l__tenkz_string_first_tl
\tl_new:N \l__tenkz_string_last_tl
\tl_new:N \l__tenkz_string_tmp_tl
\tl_new:N \l__tenkz_string_self_path_tl
\tl_new:N \l__tenkz_string_self_final_tl
\tl_new:N \l__tenkz_string_self_initial_point_tl
\tl_new:N \l__tenkz_string_self_final_point_tl
\tl_new:N \l__tenkz_string_loop_path_tl
\tl_new:N \l__tenkz_string_loop_first_tl
\tl_new:N \l__tenkz_string_loop_second_tl
\tl_new:N \l__tenkz_string_loop_third_tl
\tl_new:N \l__tenkz_string_loop_reference_tl
\fp_new:N \l__tenkz_string_ax_fp
\fp_new:N \l__tenkz_string_ay_fp
\fp_new:N \l__tenkz_string_bx_fp
\fp_new:N \l__tenkz_string_by_fp
\fp_new:N \l__tenkz_string_cx_fp
\fp_new:N \l__tenkz_string_cy_fp
\fp_new:N \l__tenkz_string_denom_fp
\fp_new:N \l__tenkz_string_sum_fp
\fp_new:N \l__tenkz_string_disc_fp
\fp_new:N \l__tenkz_string_low_fp
\fp_new:N \l__tenkz_string_high_fp
\dim_new:N \l__tenkz_string_intersection_x_dim
\dim_new:N \l__tenkz_string_intersection_y_dim
\dim_new:N \l__tenkz_string_endpoint_x_dim
\dim_new:N \l__tenkz_string_endpoint_y_dim
\dim_new:N \l__tenkz_string_distance_dim
\dim_new:N \l__tenkz_string_nearest_dim
\dim_new:N \l__tenkz_string_bead_dx_dim
\dim_new:N \l__tenkz_string_bead_dy_dim
\int_new:N \l__tenkz_string_n_int
\int_new:N \l__tenkz_string_cubic_int
\int_new:N \l__tenkz_string_recount_int
\int_new:N \l__tenkz_string_declared_int
\int_new:N \l__tenkz_string_first_segment_int
\int_new:N \l__tenkz_string_real_segments_int
\int_new:N \l__tenkz_string_components_after_int
\int_new:N \l__tenkz_string_insertion_int
\bool_new:N \l__tenkz_string_closed_bool
\bool_new:N \l__tenkz_string_endpoint_meeting_bool

\cs_new_protected:Npn \__tenkz_string_reset:
  {
    \seq_clear:N \l__tenkz_string_ids_seq
    \prop_clear:N \l__tenkz_string_kind_prop
    \prop_clear:N \l__tenkz_string_source_prop
    \seq_clear:N \l__tenkz_string_cross_seq
    \prop_clear:N \l__tenkz_string_cross_prop
    \prop_clear:N \l__tenkz_string_join_prop
    \prop_clear:N \l__tenkz_string_touch_prop
    \prop_clear:N \l__tenkz_string_touch_all_prop
    \prop_clear:N \l__tenkz_string_touch_all_exempt_prop
    \prop_clear:N \l__tenkz_string_author_prop
    \prop_clear:N \l__tenkz_string_disjoint_prop
    \seq_clear:N \l__tenkz_string_gap_ids_seq
    \seq_clear:N \l__tenkz_string_self_gap_ids_seq
    \seq_clear:N \l__tenkz_string_processed_seq
    \prop_clear:N \l__tenkz_string_self_gap_prop
    \prop_clear:N \l__tenkz_string_external_gap_prop
    \prop_clear:N \l__tenkz_string_crossing_point_prop
  }

% Every unordered relation uses one canonical key.  The grouped form is
% unambiguous even when one string id is a prefix of another.
\cs_new:Npn \__tenkz_string_pair_key:nn #1#2
  {
    \str_compare:eNeTF {#1} > {#2}
      { {#2} {#1} }
      { {#1} {#2} }
  }

\cs_new_protected:Npn \__tenkz_string_pair_put:Nnn #1#2#3
  { \prop_put:Nen #1 { \__tenkz_string_pair_key:nn {#2} {#3} } { } }

\cs_new_protected:Npn \__tenkz_string_pair_count_incr:Nnn #1#2#3
  {
    \prop_get:NeNTF #1
      { \__tenkz_string_pair_key:nn {#2} {#3} }
      \l__tenkz_string_tmp_tl
      {
        \tl_set:Ne \l__tenkz_string_tmp_tl
          { \int_eval:n { \l__tenkz_string_tmp_tl + 1 } }
      }
      { \tl_set:Nn \l__tenkz_string_tmp_tl {1} }
    \prop_put:NeV #1
      { \__tenkz_string_pair_key:nn {#2} {#3} }
      \l__tenkz_string_tmp_tl
  }

\cs_new_protected:Npn \__tenkz_string_pair_count_get:NnnN #1#2#3#4
  {
    \prop_get:NeN #1
      { \__tenkz_string_pair_key:nn {#2} {#3} }
      \l__tenkz_string_tmp_tl
    \quark_if_no_value:NTF \l__tenkz_string_tmp_tl
      { \int_zero:N #4 }
      { \int_set:Nn #4 { \l__tenkz_string_tmp_tl } }
  }

% Traverse each unordered pair once in declaration order.  Crossing
% validation uses this shared scaling boundary.
\cs_new_protected:Npn \__tenkz_string_map_unordered_pairs:N #1
  {
    \seq_set_eq:NN \l__tenkz_string_scan_seq \l__tenkz_string_ids_seq
    \seq_map_inline:Nn \l__tenkz_string_ids_seq
      {
        \seq_pop_left:NN \l__tenkz_string_scan_seq \l__tenkz_string_tmp_tl
        \seq_map_inline:Nn \l__tenkz_string_scan_seq
          { #1 {##1} {####1} }
      }
  }

\cs_new_protected:Npn \__tenkz_string_require_libs:
  {
    \cs_if_exist:cF {l__tikzspath_prefix_tl}
      { \msg_error:nnn {tenkz}{string-library} {spath3} }
    \cs_if_exist:cF {hobby@this@opts}
      { \msg_error:nnn {tenkz}{string-library} {hobby} }
    \cs_if_exist:NF \pgfintersectionofpaths
      { \msg_error:nnn {tenkz}{string-library} {intersections} }
  }

\cs_new_protected:Npn \__tenkz_string_register:nn #1#2
  {
    \regex_match:nnT {\A tenkz-bead-} {#1}
      { \msg_error:nnn {tenkz}{string-reserved} {#1} }
    \prop_if_in:NnT \l__tenkz_string_kind_prop {#1}
      { \msg_error:nnn {tenkz}{string-redeclared} {#1} }
    \seq_put_right:Nn \l__tenkz_string_ids_seq {#1}
    \prop_put:Nnn \l__tenkz_string_kind_prop {#1} {#2}
    \prop_put:Nnn \l__tenkz_string_source_prop {#1} {caller}
  }

% Save a route owned by a non-string model record.  It is addressable by the
% same arclength and crossing queries as a string and joins the crossing
% police, while its owner retains the separate ink pass.
\cs_new_protected:Npn \__tenkz_string_save_route:nn #1#2
  {
    \__tenkz_string_register:nn {#1} {route}
    \prop_put:Nnn \l__tenkz_string_source_prop {#1} {generated}
    \path [ spath/save = {#1} ] #2 ;
  }
\cs_generate_variant:Nn \__tenkz_string_save_route:nn { nV , VV }

% load a token list of braced pairs {x,y}{x,y}... into the point seq
\cs_new_protected:Npn \__tenkz_string_load_pts:n #1
  {
    \seq_clear:N \l__tenkz_string_pts_seq
    \tl_map_inline:nn {#1}
      { \seq_put_right:Nn \l__tenkz_string_pts_seq {##1} }
  }

% ---------- path construction ------------------------------------------------
% An open string through braced pairs: first and last are endpoints, the
% interior are Hobby waypoints.  Two points degenerate to a straight bond,
% which the language stage forbids for kind=string; the engine tolerates it.
\cs_new_protected:Npn \__tenkz_string_declare_open:nn #1#2
  {
    \__tenkz_string_register:nn {#1} {open}
    \prop_put:Nnn \l__tenkz_string_source_prop {#1} {generated}
    \__tenkz_string_load_pts:n {#2}
    \int_set:Nn \l__tenkz_string_n_int
      { \seq_count:N \l__tenkz_string_pts_seq }
    \seq_pop_left:NN \l__tenkz_string_pts_seq \l__tenkz_string_first_tl
    \seq_pop_right:NN \l__tenkz_string_pts_seq \l__tenkz_string_last_tl
    \seq_set_map:NNn \l__tenkz_string_pts_seq \l__tenkz_string_pts_seq
      { ( ##1 ) }
    \tl_set:Ne \l__tenkz_string_pts_tl
      { \seq_use:Nn \l__tenkz_string_pts_seq { ~ .. ~ } }
    \seq_if_empty:NTF \l__tenkz_string_pts_seq
      {
        \use:e
          {
            \exp_not:N \path [ spath/save = #1 ]
              ( \exp_not:o \l__tenkz_string_first_tl ) --
              ( \exp_not:o \l__tenkz_string_last_tl ) ;
          }
      }
      {
        \use:e
          {
            \exp_not:N \path [ spath/save = #1 ]
              ( \exp_not:o \l__tenkz_string_first_tl )
              to [ curve~through = { \exp_not:o \l__tenkz_string_pts_tl } ]
              ( \exp_not:o \l__tenkz_string_last_tl ) ;
          }
      }
    \tenkz@event{string|id=#1|kind=open|pts=\int_use:N \l__tenkz_string_n_int}
  }

% An open string whose finished path arrives from the caller: the kernel's
% arc route states its curve as one cubic whose ends honour their faces, so
% the path is built where the faces are known and saved here unchanged.
\cs_new_protected:Npn \__tenkz_string_declare_curve:nn #1#2
  {
    \__tenkz_string_register:nn {#1} {open}
    \prop_put:Nnn \l__tenkz_string_source_prop {#1} {generated}
    \use:e { \exp_not:N \path [ spath/save = #1 ] \exp_not:n {#2} ; }
    \tenkz@event{string|id=#1|kind=open|pts=2}
  }
\cs_generate_variant:Nn \__tenkz_string_declare_curve:nn { VV }

% An open route whose intermediate points are topological hull turns.  Its
% faces stay straight so every boundary wire leaves through the face named by
% the route model; only the turns consume the caller's shared corner radius.
\cs_new_protected:Npn \__tenkz_string_declare_polyline:nnn #1#2#3
  {
    \__tenkz_string_register:nn {#1} {open}
    \prop_put:Nnn \l__tenkz_string_source_prop {#1} {generated}
    \__tenkz_string_load_pts:n {#2}
    \int_set:Nn \l__tenkz_string_n_int
      { \seq_count:N \l__tenkz_string_pts_seq }
    \seq_set_map:NNn \l__tenkz_string_pts_seq \l__tenkz_string_pts_seq
      { ( ##1 ) }
    \tl_set:Ne \l__tenkz_string_pts_tl
      { \seq_use:Nn \l__tenkz_string_pts_seq { ~ -- ~ } }
    \use:e
      {
        \exp_not:N \path [ spath/save = #1 , rounded~corners = \exp_not:n {#3} ]
          \exp_not:o \l__tenkz_string_pts_tl ;
      }
    \tenkz@event{string|id=#1|kind=open|pts=\int_use:N \l__tenkz_string_n_int}
  }
\cs_generate_variant:Nn \__tenkz_string_declare_polyline:nnn { nVn }

% The closed form of the same route: one cycle whose turns are squared off
% rather than rounded into a Hobby curve.  A ring the author drew as a
% rounded-corner square is this path, not the smooth cycle below.
\cs_new_protected:Npn \__tenkz_string_declare_closed_polyline:nnn #1#2#3
  {
    \__tenkz_string_register:nn {#1} {closed}
    \prop_put:Nnn \l__tenkz_string_source_prop {#1} {generated}
    \__tenkz_string_load_pts:n {#2}
    \int_set:Nn \l__tenkz_string_n_int
      { \seq_count:N \l__tenkz_string_pts_seq }
    \seq_set_map:NNn \l__tenkz_string_pts_seq \l__tenkz_string_pts_seq
      { ( ##1 ) }
    \tl_set:Ne \l__tenkz_string_pts_tl
      { \seq_use:Nn \l__tenkz_string_pts_seq { ~ -- ~ } }
    \use:e
      {
        \exp_not:N \path [ spath/save = #1 , rounded~corners = \exp_not:n {#3} ]
          \exp_not:o \l__tenkz_string_pts_tl ~ -- ~ cycle ;
      }
    \tenkz@event{string|id=#1|kind=closed|pts=\int_use:N \l__tenkz_string_n_int}
  }
\cs_generate_variant:Nn \__tenkz_string_declare_closed_polyline:nnn { nVn }

% A closed string: one smooth Hobby cycle through every listed point.
\cs_new_protected:Npn \__tenkz_string_declare_closed:nn #1#2
  {
    \__tenkz_string_register:nn {#1} {closed}
    \prop_put:Nnn \l__tenkz_string_source_prop {#1} {generated}
    \__tenkz_string_load_pts:n {#2}
    \int_set:Nn \l__tenkz_string_n_int
      { \seq_count:N \l__tenkz_string_pts_seq }
    \seq_pop_left:NN \l__tenkz_string_pts_seq \l__tenkz_string_first_tl
    \seq_set_map:NNn \l__tenkz_string_pts_seq \l__tenkz_string_pts_seq
      { ( ##1 ) }
    \tl_set:Ne \l__tenkz_string_pts_tl
      { \seq_use:Nn \l__tenkz_string_pts_seq { ~ .. ~ } }
    \use:e
      {
        \exp_not:N \path [ spath/save = #1 , use~Hobby~shortcut ]
          ( [ closed ] \exp_not:o \l__tenkz_string_first_tl )
          .. ~ \exp_not:o \l__tenkz_string_pts_tl ;
      }
    \tenkz@event{string|id=#1|kind=closed|pts=\int_use:N \l__tenkz_string_n_int}
  }

% A wound string on a circle of centre (#2,#3) and radius #4 in caller space:
% homotopy class (p,q) = (#5,#6), meridian amplitude #7 in caller space
% (pass 0 for a pure longitude).  The radius is modulated as
% r + a cos(360 q t) while the angle sweeps 360 p t -- the planar projection
% of a torus winding; samples are dense enough that the closed Hobby cycle
% is indistinguishable from the parametric curve.
\cs_new_protected:Npn \__tenkz_string_declare_wind:nnnnnnn #1#2#3#4#5#6#7
  {
    \__tenkz_string_register:nn {#1} {wind}
    \prop_put:Nnn \l__tenkz_string_source_prop {#1} {generated}
    \int_set:Nn \l__tenkz_string_n_int
      { \fp_eval:n { max( 12 , 8 * max( abs(#5) , abs(#6) , 1 ) ) } }
    \tl_clear:N \l__tenkz_string_pts_tl
    \int_step_inline:nnn { 1 } { \l__tenkz_string_n_int - 1 }
      {
        \tl_put_right:Ne \l__tenkz_string_pts_tl
          { .. ~ ( \__tenkz_string_wind_pt:nnnnnnn
              {##1} {#2}{#3}{#4}{#5}{#6}{#7} ) ~ }
      }
    \use:e
      {
        \exp_not:N \path [ spath/save = #1 , use~Hobby~shortcut ]
          ( [ closed ]
            \__tenkz_string_wind_pt:nnnnnnn {0} {#2}{#3}{#4}{#5}{#6}{#7} )
          \exp_not:o \l__tenkz_string_pts_tl ;
      }
    \tenkz@event{string|id=#1|kind=wind|class=#5,#6|
      pts=\int_use:N \l__tenkz_string_n_int}
  }
% sample i of n.  This is an oblique projection of the standard torus
% parametrisation.  The final sine term keeps a pure meridian (0,q) visible
% as an ellipse instead of collapsing it to a repeatedly traced line.
\cs_new:Npn \__tenkz_string_wind_pt:nnnnnnn #1#2#3#4#5#6#7
  {
    \fp_eval:n
      { round( #2 + (#4 + #7 * cosd( 360 * #6 * #1 / \l__tenkz_string_n_int ))
               * cosd( 360 * #5 * #1 / \l__tenkz_string_n_int ) , 5 ) }
    ,
    \fp_eval:n
      { round( #3
               + (#4 + \__tenkz_metric_ratio:n {stringtilt} * #7
                   * cosd( 360 * #6 * #1 / \l__tenkz_string_n_int ))
                 * sind( 360 * #5 * #1 / \l__tenkz_string_n_int )
               + #7 * sind( 360 * #6 * #1 / \l__tenkz_string_n_int ) , 5 ) }
  }

\cs_new_protected:Npn \__tenkz_string_under:nn #1#2
  { \__tenkz_string_under_at:nnn {#1} {#2} {1} }

% #3 selects the component boundary produced by the distinct-segment
% self-intersection split.  Ordinary two-string crossings ignore it.
\cs_new_protected:Npn \__tenkz_string_under_at:nnn #1#2#3
  {
    \prop_if_in:NnF \l__tenkz_string_kind_prop {#1}
      { \msg_error:nnn {tenkz}{string-unknown} {#1} }
    \prop_if_in:NnF \l__tenkz_string_kind_prop {#2}
      { \msg_error:nnn {tenkz}{string-unknown} {#2} }
    \seq_put_right:Nn \l__tenkz_string_cross_seq { {#1} {#2} }
    \__tenkz_string_pair_count_incr:Nnn
      \l__tenkz_string_cross_prop {#1} {#2}
    \str_if_eq:nnT {#1} {#2}
      {
        \prop_get:NnNTF
          \l__tenkz_string_self_gap_prop {#1} \l__tenkz_string_tmp_tl
          {
            \tl_put_right:Nn \l__tenkz_string_tmp_tl {,#3}
            \prop_put:NnV \l__tenkz_string_self_gap_prop
              {#1} \l__tenkz_string_tmp_tl
          }
          { \prop_put:Nnn \l__tenkz_string_self_gap_prop {#1} {#3} }
      }
  }

% Record an intentional endpoint join.  It consumes one geometric meeting
% when the undeclared-crossing police compares the two paths.
\cs_new_protected:Npn \__tenkz_string_join:nn #1#2
  {
    \prop_if_in:NnF \l__tenkz_string_kind_prop {#1}
      { \msg_error:nnn {tenkz}{string-unknown} {#1} }
    \prop_if_in:NnF \l__tenkz_string_kind_prop {#2}
      { \msg_error:nnn {tenkz}{string-unknown} {#2} }
    \__tenkz_string_pair_count_incr:Nnn
      \l__tenkz_string_join_prop {#1} {#2}
  }

% Two model wires may share a semantic port while their measured ink paths
% overlap near the glyph border.  That attachment is topology, not a crossing.
\cs_new_protected:Npn \__tenkz_string_touch:nn #1#2
  { \__tenkz_string_pair_put:Nnn \l__tenkz_string_touch_prop {#1} {#2} }

% A renderer-owned route may treat otherwise-unqualified overlaps as topology.
% Store that route-class fact once instead of expanding it to every route
% pair.  #2 names what kind of route this is (`bond` for an ordinary index
% wire, `leg` for a deferred physical leg) so the crossing police can name
% it correctly if it later has to warn about an author string crossing it
% undeclared; an unrecognized or absent kind falls back to neutral wording
% at the warn site rather than guessing.
\cs_new_protected:Npn \__tenkz_string_touch_all:nn #1#2
  { \prop_put:Nnn \l__tenkz_string_touch_all_prop {#1} {#2} }

% Collision-sensitive routes, notably skin pairings, retain geometric checks
% even beside a renderer-owned touch-all route.
\cs_new_protected:Npn \__tenkz_string_touch_all_exempt:n #1
  { \prop_put:Nnn \l__tenkz_string_touch_all_exempt_prop {#1} { } }

% An author-declared route (a caller's index-route string, e.g. a trace or
% marginal closure).  A touch-all route silences the ordinary crossing
% police against every other route, but an author string crossing a
% touch-all route -- typically a lattice bond -- is exactly the meeting the
% author never declared: it still gets policed, with a break and a warning
% in place of the pairwise skip.
\cs_new_protected:Npn \__tenkz_string_author:n #1
  { \prop_put:Nnn \l__tenkz_string_author_prop {#1} { } }

% Record a pair whose owning model proves cannot meet.  The crossing police
% can answer zero without asking the drawing engine to rediscover topology
% from potentially singular path arithmetic.
\cs_new_protected:Npn \__tenkz_string_disjoint:nn #1#2
  { \__tenkz_string_pair_put:Nnn \l__tenkz_string_disjoint_prop {#1} {#2} }

% intersection count of two saved paths through the pgf engine
\cs_new_protected:Npn \__tenkz_string_count:nnN #1#2#3
  {
    \pgfintersectionofpaths
      { \exp_args:Nc \pgfsetpath { \l__tikzspath_prefix_tl #1 } }
      { \exp_args:Nc \pgfsetpath { \l__tikzspath_prefix_tl #2 } }
    \int_set:Nn #3 { \pgfintersectionsolutions }
  }

% A crossing is symmetric to its reader, while the ledger keeps the declared
% under--over order used by surgery.
\cs_new_protected:Npn \__tenkz_string_crossing_point:nnN #1#2#3
  {
    \prop_get:NnNTF
      \l__tenkz_string_crossing_point_prop { {#1} {#2} } #3
      { }
      {
        \prop_get:NnN
          \l__tenkz_string_crossing_point_prop { {#2} {#1} } #3
      }
  }

% A planar cubic has at most one proper self-intersection.  If
% B(t) = A t^3 + B t^2 + C t + D and B(t) = B(s), t != s, then
% u = t+s solves A (u^2-ts) + B u + C = 0.  Cramer's rule gives u and
% the discriminant of the two parameters without an arbitrary subdivision.
\cs_new_protected:Npn \__tenkz_string_cubic_self_count_trusted:nN #1#2
  {
    \fp_set:Nn \l__tenkz_string_ax_fp
      {
        -\tl_item:nn {#1} {2} + 3 * \tl_item:nn {#1} {5}
        - 3 * \tl_item:nn {#1} {8} + \tl_item:nn {#1} {11}
      }
    \fp_set:Nn \l__tenkz_string_ay_fp
      {
        -\tl_item:nn {#1} {3} + 3 * \tl_item:nn {#1} {6}
        - 3 * \tl_item:nn {#1} {9} + \tl_item:nn {#1} {12}
      }
    \fp_set:Nn \l__tenkz_string_bx_fp
      {
        3 * (
          \tl_item:nn {#1} {2} - 2 * \tl_item:nn {#1} {5}
          + \tl_item:nn {#1} {8}
        )
      }
    \fp_set:Nn \l__tenkz_string_by_fp
      {
        3 * (
          \tl_item:nn {#1} {3} - 2 * \tl_item:nn {#1} {6}
          + \tl_item:nn {#1} {9}
        )
      }
    \fp_set:Nn \l__tenkz_string_cx_fp
      {3 * (\tl_item:nn {#1} {5} - \tl_item:nn {#1} {2})}
    \fp_set:Nn \l__tenkz_string_cy_fp
      {3 * (\tl_item:nn {#1} {6} - \tl_item:nn {#1} {3})}
    \fp_set:Nn \l__tenkz_string_denom_fp
      {
        \l__tenkz_string_ax_fp * \l__tenkz_string_by_fp
        - \l__tenkz_string_ay_fp * \l__tenkz_string_bx_fp
      }
    \int_zero:N #2
    \fp_compare:nT {abs(\l__tenkz_string_denom_fp) > 1e-8}
      {
        \fp_set:Nn \l__tenkz_string_sum_fp
          {
            (
              \l__tenkz_string_cx_fp * \l__tenkz_string_ay_fp
              - \l__tenkz_string_cy_fp * \l__tenkz_string_ax_fp
            ) / \l__tenkz_string_denom_fp
          }
        \fp_set:Nn \l__tenkz_string_disc_fp
          {
            4 * (
              \l__tenkz_string_bx_fp * \l__tenkz_string_cy_fp
              - \l__tenkz_string_by_fp * \l__tenkz_string_cx_fp
            ) / \l__tenkz_string_denom_fp
            - 3 * \l__tenkz_string_sum_fp ^ 2
          }
        \fp_compare:nT {\l__tenkz_string_disc_fp > 1e-8}
          {
            \bool_if:nT
              {
                \fp_compare_p:n
                  {
                    (
                      \l__tenkz_string_sum_fp
                      - sqrt(\l__tenkz_string_disc_fp)
                    ) / 2 > 0
                  }
                &&
                \fp_compare_p:n
                  {
                    (
                      \l__tenkz_string_sum_fp
                      + sqrt(\l__tenkz_string_disc_fp)
                    ) / 2 < 1
                  }
              }
              {\int_set:Nn #2 {1}}
          }
      }
  }

\cs_new_protected:Npn \__tenkz_string_cubic_self_count:nN #1#2
  {
    % The pinned spath3 exposes segments as move(x,y), control(x,y),
    % control(x,y), end(x,y): twelve tokens including the four actions.
    \int_compare:nNnTF {\tl_count:n {#1}} = {12}
      {\__tenkz_string_cubic_self_count_trusted:nN {#1} #2}
      {
        \int_zero:N #2
        \msg_error:nnx {tenkz}{string-path-format}
          {\int_eval:n {\tl_count:n {#1}}}
      }
  }

% Append one segment to the current component by dropping its repeated move.
\cs_new_protected:Npn \__tenkz_string_append_segment:n #1
  {
    \tl_set:Nn \l__tenkz_string_tmp_tl {#1}
    \tl_if_empty:NTF \l__tenkz_string_loop_path_tl
      {
        \tl_put_right:NV
          \l__tenkz_string_loop_path_tl \l__tenkz_string_tmp_tl
      }
      {
        \prg_replicate:nn {3}
          {
            \tl_set:Ne \l__tenkz_string_tmp_tl
              {\tl_tail:N \l__tenkz_string_tmp_tl}
          }
        \tl_put_right:NV
          \l__tenkz_string_loop_path_tl \l__tenkz_string_tmp_tl
      }
  }

% Replace every looping cubic by three components split at its two
% intersection parameters.  The two new component boundaries are exactly the
% crossing and therefore support the same selector-aware gaps as an ordinary
% self-intersection.
\cs_new_protected:Npn \__tenkz_string_prepare_cubic_loops:n #1
  {
    \tl_set_eq:Nc \l__tenkz_string_self_path_tl
      {\l__tikzspath_prefix_tl #1}
    \spath_open:N \l__tenkz_string_self_path_tl
    \spath_segments_to_seq:NV
      \l__tenkz_string_segments_seq \l__tenkz_string_self_path_tl
    \tl_clear:N \l__tenkz_string_loop_path_tl
    \seq_map_inline:Nn \l__tenkz_string_segments_seq
      {
        \tl_set:Nn \l__tenkz_string_tmp_tl {##1}
        \spath_finalaction:NV
          \l__tenkz_string_self_final_tl \l__tenkz_string_tmp_tl
        \tl_if_eq:NNTF
          \l__tenkz_string_self_final_tl \c_spath_curveto_tl
          {
            \__tenkz_string_cubic_self_count:nN
              {##1} \l__tenkz_string_cubic_int
            \int_compare:nNnTF {\l__tenkz_string_cubic_int} = {1}
              {
                \fp_set:Nn \l__tenkz_string_low_fp
                  {
                    (
                      \l__tenkz_string_sum_fp
                      - sqrt(\l__tenkz_string_disc_fp)
                    ) / 2
                  }
                \fp_set:Nn \l__tenkz_string_high_fp
                  {
                    (
                      \l__tenkz_string_sum_fp
                      + sqrt(\l__tenkz_string_disc_fp)
                    ) / 2
                  }
                \spath_split_curve:NNVV
                  \l__tenkz_string_loop_first_tl
                  \l__tenkz_string_loop_third_tl
                  \l__tenkz_string_tmp_tl
                  \l__tenkz_string_high_fp
                \fp_set:Nn \l__tenkz_string_low_fp
                  {
                    \l__tenkz_string_low_fp
                    / \l__tenkz_string_high_fp
                  }
                \spath_split_curve:NNVV
                  \l__tenkz_string_loop_first_tl
                  \l__tenkz_string_loop_second_tl
                  \l__tenkz_string_loop_first_tl
                  \l__tenkz_string_low_fp
                \exp_args:NV \__tenkz_string_append_segment:n
                  \l__tenkz_string_loop_first_tl
                \tl_put_right:NV
                  \l__tenkz_string_loop_path_tl
                  \l__tenkz_string_loop_second_tl
                \tl_put_right:NV
                  \l__tenkz_string_loop_path_tl
                  \l__tenkz_string_loop_third_tl
              }
              {\__tenkz_string_append_segment:n {##1}}
          }
          {\__tenkz_string_append_segment:n {##1}}
      }
    \tl_set_eq:cN
      {\l__tikzspath_prefix_tl #1} \l__tenkz_string_loop_path_tl
  }

% Split self-crossings while excluding component endpoints from the pairwise
% reference path.  Cubic-loop preparation intentionally creates coincident
% endpoints at the crossing; treating those seams as fresh intersections can
% make spath repeatedly split an already-split point.
\cs_new_protected:Npn \__tenkz_string_split_self_path:N #1
  {
    \spath_components_to_seq:NV
      \l__tenkz_string_components_seq #1
    \tl_clear:N \l__tenkz_string_loop_path_tl
    \seq_map_indexed_inline:Nn \l__tenkz_string_components_seq
      {
        \tl_set:Nn \l__tenkz_string_self_path_tl {##2}
        \spath_reallength:NV
          \l__tenkz_string_real_segments_int
          \l__tenkz_string_self_path_tl
        \int_compare:nNnT
          {\l__tenkz_string_real_segments_int} > {1}
          {
            \spath_split_component_at_self_intersections:N
              \l__tenkz_string_self_path_tl
          }
        \tl_clear:N \l__tenkz_string_loop_reference_tl
        \seq_map_indexed_inline:Nn \l__tenkz_string_components_seq
          {
            \int_compare:nNnF {####1} = {##1}
              {
                \tl_set:Nn \l__tenkz_string_tmp_tl {####2}
                \spath_shorten_at_start:Nn \l__tenkz_string_tmp_tl
                  {\dim_eval:n {\__tenkz_dim:n {crossgap} / 8}}
                \spath_shorten_at_end:Nn \l__tenkz_string_tmp_tl
                  {\dim_eval:n {\__tenkz_dim:n {crossgap} / 8}}
                \tl_put_right:NV
                  \l__tenkz_string_loop_reference_tl
                  \l__tenkz_string_tmp_tl
              }
          }
        \tl_if_empty:NF \l__tenkz_string_loop_reference_tl
          {
            \spath_split_path_at_intersections:NV
              \l__tenkz_string_self_path_tl
              \l__tenkz_string_loop_reference_tl
          }
        \tl_put_right:NV
          \l__tenkz_string_loop_path_tl
          \l__tenkz_string_self_path_tl
      }
    \tl_set_eq:NN #1 \l__tenkz_string_loop_path_tl
  }

% Count intersections on a disposable path copy.  Each individual cubic is
% first checked with spath's self-splitter; distinct non-adjacent segments are
% then compared pairwise.  Adjacent branches meet by construction; the first
% and last branches share a seam when either the path closes formally or an
% open route returns to its initial point.
\cs_new_protected:Npn \__tenkz_string_self_count:nN #1#2
  {
    \tl_set_eq:Nc \l__tenkz_string_self_path_tl
      { \l__tikzspath_prefix_tl #1 }
    \spath_finalaction:NV
      \l__tenkz_string_self_final_tl \l__tenkz_string_self_path_tl
    \spath_initialpoint:NV
      \l__tenkz_string_self_initial_point_tl
      \l__tenkz_string_self_path_tl
    \spath_finalpoint:NV
      \l__tenkz_string_self_final_point_tl
      \l__tenkz_string_self_path_tl
    \bool_set:Nn \l__tenkz_string_closed_bool
      {
        \tl_if_eq_p:NN
          \l__tenkz_string_self_final_tl \c_spath_closepath_tl
        ||
        \tl_if_eq_p:NN
          \l__tenkz_string_self_initial_point_tl
          \l__tenkz_string_self_final_point_tl
      }
    \spath_open:N \l__tenkz_string_self_path_tl
    \spath_segments_to_seq:NV
      \l__tenkz_string_segments_seq \l__tenkz_string_self_path_tl
    \spath_reallength:NV
      \l__tenkz_string_real_segments_int \l__tenkz_string_self_path_tl
    \int_set:Nn \l__tenkz_string_first_segment_int
      {
        \seq_count:N \l__tenkz_string_segments_seq
        - \l__tenkz_string_real_segments_int + 1
      }
    \int_zero:N #2
    \seq_map_indexed_inline:Nn \l__tenkz_string_segments_seq
      {
        \seq_map_indexed_inline:Nn \l__tenkz_string_segments_seq
          {
            \bool_if:nT
              {
                \int_compare_p:n
                  {##1 >= \l__tenkz_string_first_segment_int}
                &&
                \int_compare_p:n {##1 < ####1}
              }
              {
                \tl_set:Nn \l_tmpa_tl {##2}
                \tl_set:Nn \l_tmpb_tl {####2}
                % Consecutive branches have one expected meeting at their
                % shared waypoint.  Trim only that seam before intersecting,
                % so a second off-seam crossing is still policed.
                \int_compare:nNnT {##1 + 1} = {####1}
                  {
                    \spath_shorten_at_end:Nn \l_tmpa_tl
                      {\dim_eval:n {\__tenkz_dim:n {crossgap} / 8}}
                    \spath_shorten_at_start:Nn \l_tmpb_tl
                      {\dim_eval:n {\__tenkz_dim:n {crossgap} / 8}}
                  }
                \bool_if:nT
                  {
                    \l__tenkz_string_closed_bool
                    && \int_compare_p:n
                      {##1 == \l__tenkz_string_first_segment_int}
                    && \int_compare_p:n
                      {
                        ####1 == \seq_count:N
                          \l__tenkz_string_segments_seq
                      }
                  }
                  {
                    \spath_shorten_at_start:Nn \l_tmpa_tl
                      {\dim_eval:n {\__tenkz_dim:n {crossgap} / 8}}
                    \spath_shorten_at_end:Nn \l_tmpb_tl
                      {\dim_eval:n {\__tenkz_dim:n {crossgap} / 8}}
                  }
                \spath_intersect:NN \l_tmpa_tl \l_tmpb_tl
                \int_add:Nn #2 {\pgfintersectionsolutions}
              }
          }
      }
    \prop_get:NnN
      \l__tenkz_string_source_prop {#1} \l__tenkz_string_tmp_tl
    \str_if_eq:VnT \l__tenkz_string_tmp_tl {caller}
      {
        \seq_map_inline:Nn \l__tenkz_string_segments_seq
          {
            \tl_set:Nn \l__tenkz_string_tmp_tl {##1}
            \spath_finalaction:NV
              \l__tenkz_string_self_final_tl
              \l__tenkz_string_tmp_tl
            \tl_if_eq:NNT
              \l__tenkz_string_self_final_tl
              \c_spath_curveto_tl
              {
                \__tenkz_string_cubic_self_count:nN
                  {##1} \l__tenkz_string_cubic_int
                \int_add:Nn #2 {\l__tenkz_string_cubic_int}
              }
          }
      }
  }

\cs_new_protected:Npn \__tenkz_string_validate_self_selectors:n #1
  {
    \prop_get:NnNT \l__tenkz_string_self_gap_prop
      {#1} \l__tenkz_string_tmp_tl
      {
        \__tenkz_string_prepare_cubic_loops:n {#1}
        \tl_set_eq:Nc \l__tenkz_string_self_path_tl
          { \l__tikzspath_prefix_tl #1 }
        \__tenkz_string_split_self_path:N
          \l__tenkz_string_self_path_tl
        \spath_numberofcomponents:NV
          \l__tenkz_string_components_after_int
          \l__tenkz_string_self_path_tl
        \prop_get:NnN
          \l__tenkz_string_self_gap_prop
          {#1} \l__tenkz_string_tmp_tl
        \prop_clear:N \l__tenkz_string_selector_seen_prop
        \clist_map_inline:Vn \l__tenkz_string_tmp_tl
          {
            \regex_match:nnTF {\A[1-9][0-9]*\Z} {##1}
              {
                \bool_if:nTF
                  {
                    \int_compare_p:n
                      {##1 < \l__tenkz_string_components_after_int}
                    &&
                    ! \prop_if_in_p:Nn
                      \l__tenkz_string_selector_seen_prop {##1}
                  }
                  {
                    \prop_put:Nnn
                      \l__tenkz_string_selector_seen_prop {##1} {}
                  }
                  { \msg_error:nnnn {tenkz}{cross-selector} {#1} {##1} }
              }
              { \msg_error:nnnn {tenkz}{cross-selector} {#1} {##1} }
          }
      }
  }

% Police original paths first, then split every declared crossing, and only
% then insert gaps once per under path.  This preserves later intersections
% and prevents repeated gap passes from widening earlier cuts.
% intersecting undeclared pair is a hard error.  Runs inside the picture,
% after every declaration and before any string is drawn.
\cs_new_protected:Npn \__tenkz_string_resolve:
  {
    % Beads are semantic arclength points on the unsurgered route.  Freeze a
    % private copy before crossing gaps split and shorten the visible path.
    \seq_map_inline:Nn \l__tenkz_string_ids_seq
      {
        \cs_set_eq:cc
          { \l__tikzspath_prefix_tl tenkz-bead-##1 }
          { \l__tikzspath_prefix_tl ##1 }
      }
    \__tenkz_string_police:
    \seq_clear:N \l__tenkz_string_gap_ids_seq
    \seq_clear:N \l__tenkz_string_self_gap_ids_seq
    \seq_clear:N \l__tenkz_string_processed_seq
    \seq_map_inline:Nn \l__tenkz_string_cross_seq
      { \__tenkz_string_split_self_one:nn ##1 }
    \seq_clear:N \l__tenkz_string_processed_seq
    \seq_map_inline:Nn \l__tenkz_string_cross_seq
      { \__tenkz_string_split_external_one:nn ##1 }
    \seq_map_inline:Nn \l__tenkz_string_gap_ids_seq
      {
        \prop_get:NnN
          \l__tenkz_string_external_gap_prop {##1}
          \l__tenkz_string_tmp_tl
        \use:e
          {
            \exp_not:N \tikzset
              {
                spath/insert~gaps~after~components =
                  {##1}
                  {
                    { \dim_eval:n { \__tenkz_dim:n {crossgap} } }
                    { \exp_not:V \l__tenkz_string_tmp_tl }
                  }
              }
          }
      }
    \seq_map_inline:Nn \l__tenkz_string_self_gap_ids_seq
      {
        \prop_get:NnN
          \l__tenkz_string_self_gap_prop {##1} \l__tenkz_string_tmp_tl
        \tl_if_in:VnTF \l__tenkz_string_tmp_tl {,}
          {
            \use:e
              {
                \exp_not:N \tikzset
                  {
                    spath/insert~gaps~after~components =
                      {##1}
                      {
                        { \dim_eval:n { \__tenkz_dim:n {crossgap} } }
                        { \exp_not:V \l__tenkz_string_tmp_tl }
                      }
                  }
              }
          }
          {
            \tikzset
              {
                spath/insert~gaps~after~components =
                  {##1}
                  {
                    { \dim_eval:n { \__tenkz_dim:n {crossgap} } }
                    { \l__tenkz_string_tmp_tl }
                  }
              }
          }
      }
  }
\cs_new_protected:Npn \__tenkz_string_split_self_one:nn #1#2
  {
    \str_if_eq:nnT {#1} {#2}
      { \__tenkz_string_split_one:nn {#1}{#2} }
  }
\cs_new_protected:Npn \__tenkz_string_split_external_one:nn #1#2
  {
    \str_if_eq:nnF {#1} {#2}
      { \__tenkz_string_split_one:nn {#1}{#2} }
  }
\cs_new_protected:Npn \__tenkz_string_split_one:nn #1#2
  {
    \seq_if_in:NnF \l__tenkz_string_processed_seq { {#1} {#2} }
      {
        \seq_put_right:Nn \l__tenkz_string_processed_seq { {#1} {#2} }
        \__tenkz_string_split_new:nn {#1} {#2}
      }
  }
\cs_new_protected:Npn \__tenkz_string_split_new:nn #1#2
  {
    \str_if_eq:nnTF {#1} {#2}
      {
        \__tenkz_string_self_count:nN {#1} \l__tenkz_string_n_int
      }
      {
        \__tenkz_string_count:nnN {#1} {#2} \l__tenkz_string_n_int
      }
    \int_compare:nNnTF { \l__tenkz_string_n_int } = { 0 }
      { \msg_error:nnnn {tenkz}{cross-not-found} {#1} {#2} }
      {
        \str_if_eq:nnTF {#1} {#2}
          {
            \tl_set_eq:Nc \l__tenkz_string_self_path_tl
              {\l__tikzspath_prefix_tl #1}
            \__tenkz_string_split_self_path:N
              \l__tenkz_string_self_path_tl
            \tl_set_eq:cN
              {\l__tikzspath_prefix_tl #1}
              \l__tenkz_string_self_path_tl
            \seq_if_in:NnF \l__tenkz_string_self_gap_ids_seq {#1}
              { \seq_put_right:Nn \l__tenkz_string_self_gap_ids_seq {#1} }
          }
          {
            \tl_set_eq:Nc \l__tenkz_string_self_path_tl
              { \l__tikzspath_prefix_tl #1 }
            \spath_components_to_seq:NV
              \l__tenkz_string_before_components_seq
              \l__tenkz_string_self_path_tl
            \tikzset
              { spath/split~at~intersections~with = {#1} {#2} }
            \tl_set_eq:Nc \l__tenkz_string_self_path_tl
              { \l__tikzspath_prefix_tl #1 }
            \spath_components_to_seq:NV
              \l__tenkz_string_after_components_seq
              \l__tenkz_string_self_path_tl
            \int_zero:N \l__tenkz_string_insertion_int
            \int_compare:nNnT
              {\seq_count:N \l__tenkz_string_after_components_seq}
              >
              {\seq_count:N \l__tenkz_string_before_components_seq}
              {\__tenkz_string_find_inserted_boundary:nn {#1}{#2}}
            \int_compare:nNnT {\l__tenkz_string_insertion_int} > {0}
              {
                \__tenkz_string_shift_gap_prop:Nn
                  \l__tenkz_string_self_gap_prop {#1}
                \__tenkz_string_shift_gap_prop:Nn
                  \l__tenkz_string_external_gap_prop {#1}
                \__tenkz_string_append_external_gap:n {#1}
                \seq_if_in:NnF \l__tenkz_string_gap_ids_seq {#1}
                  { \seq_put_right:Nn \l__tenkz_string_gap_ids_seq {#1} }
              }
          }
        \tenkz@event{stringcross|under=#1|over=#2|
          hits=\int_use:N \l__tenkz_string_n_int}
      }
  }

\cs_new_protected:Npn \__tenkz_string_find_inserted_boundary:nn #1#2
  {
    \int_zero:N \l__tenkz_string_insertion_int
    \__tenkz_string_count:nnN {#1}{#2} \l__tenkz_string_recount_int
    \int_compare:nNnT {\l__tenkz_string_recount_int} > {0}
      {
        \pgf@process{\pgfpointintersectionsolution{1}}
        \dim_set:Nn \l__tenkz_string_intersection_x_dim {\pgf@x}
        \dim_set:Nn \l__tenkz_string_intersection_y_dim {\pgf@y}
        \prop_put:Nne
          \l__tenkz_string_crossing_point_prop { {#1} {#2} }
          {
            { \dim_use:N \l__tenkz_string_intersection_x_dim }
            { \dim_use:N \l__tenkz_string_intersection_y_dim }
          }
        \dim_set:Nn \l__tenkz_string_nearest_dim {\maxdimen}
        \seq_map_indexed_inline:Nn
          \l__tenkz_string_after_components_seq
          {
            \tl_set:Nn \l__tenkz_string_last_tl {##2}
            \spath_finalpoint:NV
              \l__tenkz_string_tmp_tl
              \l__tenkz_string_last_tl
            \dim_set:Nn \l__tenkz_string_endpoint_x_dim
              {\tl_item:Nn \l__tenkz_string_tmp_tl {1}}
            \dim_set:Nn \l__tenkz_string_endpoint_y_dim
              {\tl_item:Nn \l__tenkz_string_tmp_tl {2}}
            \dim_set:Nn \l__tenkz_string_distance_dim
              {
                \dim_abs:n
                  {
                    \l__tenkz_string_endpoint_x_dim
                    - \l__tenkz_string_intersection_x_dim
                  }
                +
                \dim_abs:n
                  {
                    \l__tenkz_string_endpoint_y_dim
                    - \l__tenkz_string_intersection_y_dim
                  }
              }
            \dim_compare:nNnT
              {\l__tenkz_string_distance_dim}
              <
              {\l__tenkz_string_nearest_dim}
              {
                \dim_set_eq:NN
                  \l__tenkz_string_nearest_dim
                  \l__tenkz_string_distance_dim
                \int_set:Nn \l__tenkz_string_insertion_int {##1}
              }
          }
      }
  }

\cs_new_protected:Npn \__tenkz_string_shift_gap_prop:Nn #1#2
  {
    \prop_get:NnNT #1 {#2} \l__tenkz_string_tmp_tl
      {
        \tl_clear:N \l__tenkz_string_pts_tl
        \clist_map_inline:Vn \l__tenkz_string_tmp_tl
          {
            \tl_if_empty:NF \l__tenkz_string_pts_tl
              { \tl_put_right:Nn \l__tenkz_string_pts_tl {,} }
            \int_compare:nNnTF
              {##1} < {\l__tenkz_string_insertion_int}
              { \tl_put_right:Nn \l__tenkz_string_pts_tl {##1} }
              {
                \tl_put_right:Ne \l__tenkz_string_pts_tl
                  { \int_eval:n {##1 + 1} }
              }
          }
        \prop_put:NnV #1 {#2} \l__tenkz_string_pts_tl
      }
  }

\cs_new_protected:Npn \__tenkz_string_append_external_gap:n #1
  {
    \prop_get:NnNTF
      \l__tenkz_string_external_gap_prop {#1}
      \l__tenkz_string_tmp_tl
      {
        \tl_put_right:Ne \l__tenkz_string_tmp_tl
          {,\int_use:N \l__tenkz_string_insertion_int}
        \prop_put:NnV \l__tenkz_string_external_gap_prop
          {#1} \l__tenkz_string_tmp_tl
      }
      {
        \prop_put:Nne \l__tenkz_string_external_gap_prop {#1}
          {\int_use:N \l__tenkz_string_insertion_int}
      }
  }
\cs_new_protected:Npn \__tenkz_string_police:
  {
    \seq_map_inline:Nn \l__tenkz_string_ids_seq
      { \__tenkz_string_police_self:n {##1} }
    \__tenkz_string_map_unordered_pairs:N \__tenkz_string_police_pair:nn
  }
\cs_new_protected:Npn \__tenkz_string_police_self:n #1
  {
    \__tenkz_string_pair_count_get:NnnN
      \l__tenkz_string_cross_prop {#1} {#1}
      \l__tenkz_string_declared_int
    \prop_get:NnN
      \l__tenkz_string_kind_prop {#1} \l__tenkz_string_tmp_tl
    % A winding is a generated projection of its declared homotopy class.
    % Its projection overlaps are not free string crossings; all caller-routed
    % strings are checked, including those with no self declaration.
    \str_if_eq:VnF \l__tenkz_string_tmp_tl {wind}
      {
        \__tenkz_string_self_count:nN {#1} \l__tenkz_string_n_int
        \int_compare:nNnTF
          { \l__tenkz_string_n_int } > { \l__tenkz_string_declared_int }
          { \msg_error:nnnn {tenkz}{cross-undeclared} {#1} {#1} }
          {
            \int_compare:nNnT
              { \l__tenkz_string_n_int } < { \l__tenkz_string_declared_int }
              { \msg_error:nnnn {tenkz}{cross-not-found} {#1} {#1} }
            \__tenkz_string_validate_self_selectors:n {#1}
          }
      }
  }
\cs_new_protected:Npn \__tenkz_string_police_pair:nn #1#2
  {
    \bool_set_false:N \l_tmpa_bool
    \prop_if_in:NeT \l__tenkz_string_disjoint_prop
      { \__tenkz_string_pair_key:nn {#1} {#2} }
      { \bool_set_true:N \l_tmpa_bool }
    \bool_if:NTF \l_tmpa_bool
      { \__tenkz_string_police_pair_disjoint:nn {#1} {#2} }
      {
        \prop_if_in:NeT \l__tenkz_string_touch_prop
          { \__tenkz_string_pair_key:nn {#1} {#2} }
          { \bool_set_true:N \l_tmpa_bool }
        \bool_if:NF \l_tmpa_bool
          {
            \bool_set_false:N \l_tmpb_bool
            \prop_if_in:NnF \l__tenkz_string_touch_all_exempt_prop {#1}
              {
                \prop_if_in:NnF \l__tenkz_string_touch_all_exempt_prop {#2}
                  {
                    \prop_if_in:NnT \l__tenkz_string_touch_all_prop {#1}
                      { \bool_set_true:N \l_tmpb_bool }
                    \prop_if_in:NnT \l__tenkz_string_touch_all_prop {#2}
                      { \bool_set_true:N \l_tmpb_bool }
                  }
              }
            \bool_if:NTF \l_tmpb_bool
              { \__tenkz_string_police_pair_touch_all:nn {#1} {#2} }
              { \__tenkz_string_police_pair_cross:nn {#1} {#2} }
          }
      }
  }
\cs_new_protected:Npn \__tenkz_string_police_pair_disjoint:nn #1#2
  {
    \prop_if_in:NeT \l__tenkz_string_cross_prop
      { \__tenkz_string_pair_key:nn {#1} {#2} }
      { \msg_error:nnnn {tenkz}{cross-disjoint} {#1} {#2} }
    \prop_if_in:NeT \l__tenkz_string_join_prop
      { \__tenkz_string_pair_key:nn {#1} {#2} }
      { \msg_error:nnnn {tenkz}{join-disjoint} {#1} {#2} }
  }
% A renderer-owned touch-all route (an ordinary index wire or a deferred
% physical leg -- see `\__tenkz_string_touch_all:nn`) silences the ordinary
% crossing police against every other route, which is right for the
% topology it owns but wrong for a route it does not: an author string is
% never touch-all's to silence.  If exactly one side of this pair is an
% author-declared string, police that pair on its own; a touch-all route
% beside anything else (another touch-all route, or a route with no author
% declaration at all) keeps the blanket skip.
\cs_new_protected:Npn \__tenkz_string_police_pair_touch_all:nn #1#2
  {
    \bool_set_false:N \l_tmpb_bool
    \prop_if_in:NnT \l__tenkz_string_author_prop {#1}
      {
        \prop_if_in:NnT \l__tenkz_string_touch_all_prop {#2}
          {
            \bool_set_true:N \l_tmpb_bool
            \__tenkz_string_police_pair_touch_all_route:nn {#1} {#2}
          }
      }
    \bool_if:NF \l_tmpb_bool
      {
        \prop_if_in:NnT \l__tenkz_string_author_prop {#2}
          {
            \prop_if_in:NnT \l__tenkz_string_touch_all_prop {#1}
              { \__tenkz_string_police_pair_touch_all_route:nn {#2} {#1} }
          }
      }
  }
% #1 is an author-declared string, #2 the touch-all route it was found to
% meet with no declared order -- an ordinary index wire (a lattice bond) or
% a deferred physical leg, per the kind `\__tenkz_string_touch_all:nn`
% recorded for #2.  A geometric meeting here is not the author's to leave
% unbroken: cutting the calibrated break into the string -- so the other
% route's own ink stays whole and the string reads as passing behind it --
% and warning, naming which kind of route #2 actually is (falling back to
% neutral wording if the kind was not recorded), is issue #5825's chosen
% middle ground; turning every such silent overlap into a hard error is
% future work the issue explicitly defers.
\cs_new_protected:Npn \__tenkz_string_police_pair_touch_all_route:nn #1#2
  {
    \__tenkz_string_pair_count_get:NnnN
      \l__tenkz_string_cross_prop {#1} {#2}
      \l__tenkz_string_declared_int
    \int_compare:nNnT { \l__tenkz_string_declared_int } = {0}
      {
        \__tenkz_string_count:nnN {#1} {#2} \l__tenkz_string_n_int
        \int_compare:nNnT { \l__tenkz_string_n_int } > {0}
          {
            \prop_get:NnNTF \l__tenkz_string_touch_all_prop {#2}
              \l__tenkz_string_tmp_tl
              { }
              { \tl_clear:N \l__tenkz_string_tmp_tl }
            \str_case:VnF \l__tenkz_string_tmp_tl
              {
                {bond}
                  {
                    \msg_warning:nnnnn {tenkz}{cross-touchall-undeclared}
                      {#1} {#2} {a~lattice~bond}
                  }
                {leg}
                  {
                    \msg_warning:nnnnn {tenkz}{cross-touchall-undeclared}
                      {#1} {#2} {a~physical~leg}
                  }
              }
              {
                \msg_warning:nnnnn {tenkz}{cross-touchall-undeclared}
                  {#1} {#2}
                  {a~renderer-owned~route~(a~lattice~bond~or~physical~leg)}
              }
            \__tenkz_string_under:nn {#1} {#2}
          }
      }
  }
\cs_new_protected:Npn \__tenkz_string_police_pair_cross:nn #1#2
  {
    \__tenkz_string_pair_count_get:NnnN
      \l__tenkz_string_cross_prop {#1} {#2}
      \l__tenkz_string_n_int
    \__tenkz_string_pair_count_get:NnnN
      \l__tenkz_string_join_prop {#1} {#2}
      \l__tenkz_string_declared_int
    \int_set_eq:NN \l_tmpa_int \l__tenkz_string_n_int
    \__tenkz_string_count:nnN {#1} {#2} \l__tenkz_string_n_int
    \int_compare:nNnT { \l__tenkz_string_n_int } > {1}
      { \msg_error:nnnn {tenkz}{cross-multiple} {#1} {#2} }
    \int_compare:nNnT { \l__tenkz_string_declared_int } > {0}
      {
        \__tenkz_string_endpoint_meeting:nnTF {#1} {#2}
          { \int_add:Nn \l_tmpa_int { \l__tenkz_string_declared_int } }
          { \msg_error:nnnn {tenkz}{join-not-endpoint} {#1} {#2} }
      }
    \int_compare:nNnTF { \l__tenkz_string_n_int } > { \l_tmpa_int }
      { \msg_error:nnnn {tenkz}{cross-undeclared} {#1} {#2} }
      {
        \int_compare:nNnT { \l__tenkz_string_n_int } < { \l_tmpa_int }
          { \msg_error:nnnn {tenkz}{cross-not-found} {#1} {#2} }
      }
  }

\prg_new_protected_conditional:Npnn
  \__tenkz_string_endpoint_meeting:nn #1#2 {T,F,TF}
  {
    \__tenkz_string_count:nnN {#1} {#2} \l__tenkz_string_n_int
    \seq_clear:N \l__tenkz_string_endpoint_seq
    \tl_set_eq:Nc \l__tenkz_string_self_path_tl
      { \l__tikzspath_prefix_tl #1 }
    \spath_initialpoint:NV
      \l__tenkz_string_self_initial_point_tl
      \l__tenkz_string_self_path_tl
    \seq_put_right:NV
      \l__tenkz_string_endpoint_seq
      \l__tenkz_string_self_initial_point_tl
    \spath_finalpoint:NV
      \l__tenkz_string_self_final_point_tl
      \l__tenkz_string_self_path_tl
    \seq_put_right:NV
      \l__tenkz_string_endpoint_seq
      \l__tenkz_string_self_final_point_tl
    \tl_set_eq:Nc \l__tenkz_string_self_path_tl
      { \l__tikzspath_prefix_tl #2 }
    \spath_initialpoint:NV
      \l__tenkz_string_self_initial_point_tl
      \l__tenkz_string_self_path_tl
    \seq_put_right:NV
      \l__tenkz_string_endpoint_seq
      \l__tenkz_string_self_initial_point_tl
    \spath_finalpoint:NV
      \l__tenkz_string_self_final_point_tl
      \l__tenkz_string_self_path_tl
    \seq_put_right:NV
      \l__tenkz_string_endpoint_seq
      \l__tenkz_string_self_final_point_tl
    \bool_set_false:N \l__tenkz_string_endpoint_meeting_bool
    \int_step_inline:nn { \l__tenkz_string_n_int }
      {
        \pgf@process { \pgfpointintersectionsolution {##1} }
        \seq_map_inline:Nn \l__tenkz_string_endpoint_seq
          {
            \bool_if:nT
              {
                \dim_compare_p:nNn
                  {
                    \dim_abs:n
                      { \pgf@x - \tl_item:nn {####1} {1} }
                  } < {1pt / 100}
                &&
                \dim_compare_p:nNn
                  {
                    \dim_abs:n
                      { \pgf@y - \tl_item:nn {####1} {2} }
                  } < {1pt / 100}
              }
              { \bool_set_true:N \l__tenkz_string_endpoint_meeting_bool }
          }
      }
    \bool_if:NTF \l__tenkz_string_endpoint_meeting_bool
      { \prg_return_true: } { \prg_return_false: }
  }

% ---------- beads ---------------------------------------------------------------
% Whether the saved route #1 bends.  The answer is read off the stored path,
% not off the spelling that produced it: a path assembled from straight
% segments is divided by its ends, while a bending one is only answered for
% along itself.  An id no route was saved under does not bend.
\prg_new_protected_conditional:Npnn \__tenkz_string_bends:n #1 {T,F,TF}
  {
    \prop_if_in:NnTF \l__tenkz_string_kind_prop {#1}
      {
        \tl_if_in:cVTF { \l__tikzspath_prefix_tl #1 } \c_spath_curveto_tl
          { \prg_return_true: } { \prg_return_false: }
      }
      { \prg_return_false: }
  }
\prg_generate_conditional_variant:Nnn \__tenkz_string_bends:n {V} {T,F,TF}

% Whether a route was ever saved under #1.  A caller that means to measure a
% route asks this first, so an id that names no ink is refused by the caller's
% own diagnostic rather than by the bead's.
\prg_new_protected_conditional:Npnn \__tenkz_string_inked:n #1 {T,F,TF}
  {
    \prop_if_in:NnTF \l__tenkz_string_kind_prop {#1}
      { \prg_return_true: } { \prg_return_false: }
  }
\prg_generate_conditional_variant:Nnn \__tenkz_string_inked:n {V} {T,F,TF}

% Freeze the pre-surgery route a bead reads.  Resolution may ask before the
% complete string pass freezes every route; both timings retain the same path.
\cs_new_protected:Npn \__tenkz_string_bead_path:n #1
  {
    \prop_if_in:NnF \l__tenkz_string_kind_prop {#1}
      { \msg_error:nnn {tenkz}{string-unknown} {#1} }
    \cs_if_exist:cF { \l__tikzspath_prefix_tl tenkz-bead-#1 }
      {
        \cs_set_eq:cc
          { \l__tikzspath_prefix_tl tenkz-bead-#1 }
          { \l__tikzspath_prefix_tl #1 }
      }
  }

% The point and path-forward tangent at arclength fraction #2 along string
% #1.  One PGF marking traversal owns both answers: spath coordinates count
% segments, while the marking decoration advances by actual rendered length.
% The two output dimensions are the page-space tangent vector; a degenerate
% route returns zero so its caller can retain conservative blindness.
\cs_new_protected:Npn \__tenkz_string_bead_frame:nnnNN #1#2#3#4#5
  {
    \__tenkz_string_bead_path:n {#1}
    \tenkz@string@beadframe {tenkz-bead-#1} {#2} {#3}
    \dim_set:Nn #4 { \tenkz@string@beadx pt }
    \dim_set:Nn #5 { \tenkz@string@beady pt }
  }
% Expose the same arclength point as a TikZ coordinate and event.
\cs_new_protected:Npn \__tenkz_string_bead:nnn #1#2#3
  {
    \__tenkz_string_bead_frame:nnnNN {#1} {#2} {#3}
      \l__tenkz_string_bead_dx_dim \l__tenkz_string_bead_dy_dim
    \pgfpointanchor {#3} {center}
    \tenkz@event{stringbead|id=#1|t=#2|
      x=\fp_eval:n { round( \dim_to_fp:n { \pgf@x } , 4 ) }pt|
      y=\fp_eval:n { round( \dim_to_fp:n { \pgf@y } , 4 ) }pt}
  }

% ---------- development ink -----------------------------------------------------
% The render stage owns string ink at integration; until then fixtures draw
% through this single, clearly marked primitive.
\cs_new_protected:Npn \__tenkz_string_dev_draw:nn #1#2
  {
    \prop_if_in:NnF \l__tenkz_string_kind_prop {#1}
      { \msg_error:nnn {tenkz}{string-unknown} {#1} }
    \draw [ spath/use = #1 , #2 ] ;
  }

\ExplSyntaxOff

% Markings positions are arclength fractions.  Keep this helper outside expl3
% so TikZ key punctuation is parsed with document catcodes.
\def\tenkz@string@beadpath#1#2#3{%
  \tenkz@string@beadframe{#1}{#2}{#3}%
}

% The marking frame's first matrix column is the rendered path tangent at the
% same arclength station used for the bead point.  Capture the column directly:
% starting a nested TikZ path here would inherit `decorate` and recurse.  PGF
% normalizes that column, so first verify the current segment's raw derivative;
% an ill-conditioned derivative must leave the initialized zero vector blind.
\def\tenkz@string@capturebeadframe{%
  \ifx\pgfdecorationcurrentinputsegment\pgfdecorationinputsegmentcurveto%
    \pgfpointcurveattime{\pgf@decorate@inputsegmenttime}%
      {\pgf@decorate@inputsegment@first}%
      {\pgf@decorate@inputsegment@supporta}%
      {\pgf@decorate@inputsegment@supportb}%
      {\pgf@decorate@inputsegment@last}%
    \pgf@x=\pgf@xa%
    \pgf@y=\pgf@ya%
    \advance\pgf@x by-\pgf@xb%
    \advance\pgf@y by-\pgf@yb%
  \else%
    \pgfpointdiff
      {\pgf@decorate@inputsegment@first}%
      {\pgf@decorate@inputsegment@last}%
  \fi%
  \ifdim\pgf@x<0pt \pgf@x=-\pgf@x \fi%
  \ifdim\pgf@y<0pt \pgf@y=-\pgf@y \fi%
  \advance\pgf@x by\pgf@y%
  % Arclength-to-curve-time conversion is approximate.  Treat a derivative
  % below one percent of its segment length as ill-conditioned rather than
  % normalizing numerical drift into an authoritative face.
  \pgf@xa=\pgfdecoratedinputsegmentlength\relax%
  \divide\pgf@xa by100\relax%
  \ifdim\pgf@x>\pgf@xa%
    \pgfgettransformentries%
      \tenkz@string@frameaa\tenkz@string@frameab%
      \tenkz@string@frameba\tenkz@string@framebb%
      \tenkz@string@frameshiftx\tenkz@string@frameshifty%
    \xdef\tenkz@string@beadx{\tenkz@string@frameaa}%
    \xdef\tenkz@string@beady{\tenkz@string@frameab}%
  \fi%
}
\def\tenkz@string@beadframe#1#2#3{%
  \gdef\tenkz@string@beadx{0}%
  \gdef\tenkz@string@beady{0}%
  \path[
    spath/use=#1,
    decoration={markings,mark=at position #2 with {%
      \coordinate (#3);%
      \tenkz@string@capturebeadframe
    }},
    decorate
  ];%
}

\endinput
