% Input:       frame declarations and placement requests from front ends; the metric registry.
% Output:      resolved coordinates, world angles, and silhouette support distances.
% Owned state: the frame table, the placement request store, and resolved positions.
% Invariants:  positions resolve topologically or error; nothing here emits ink.
% Next stage:  rendering turns resolved records into marks on the page.
% SPDX-License-Identifier: Apache-2.0
% Copyright the TNLean project; see LICENSE for the full terms.
%
% Geometry answers three questions and nothing else.  WHERE: frames map
% logical addresses to points (one 2x2 matrix plus offset, or a circle of
% stations), and a dependency-graph evaluator resolves relative placements
% in one topological pass -- a cycle is a coded error naming the cycle.
% WHICH WAY: a direction crosses a frame through its linear part, so a
% rotation is angle addition and a compass word is converted once.
% HOW FAR: a silhouette is a support function -- the reach of a rotated
% skin along a query direction -- and clearance questions fold max-support
% over record sets.  All lengths here are pitch factors; only rendering
% multiplies by the pitch dimension.

\ExplSyntaxOn

% ---------- messages -------------------------------------------------------
\msg_new:nnn {tenkz}{geom-unknown-frame}
  { [TKZ-GEOM-UNKNOWN-FRAME]~no~frame~named~'#1' }
\msg_new:nnn {tenkz}{geom-frame-kind}
  { [TKZ-GEOM-FRAME-KIND]~frame~'#1'~is~a~'#2'~frame;~#3~needs~a~'#4'~frame }
\msg_new:nnn {tenkz}{geom-compose}
  { [TKZ-GEOM-COMPOSE]~only~affine~frames~compose;~'#1'~is~not~affine }
\msg_new:nnn {tenkz}{geom-unknown-place}
  { [TKZ-GEOM-UNPLACED]~no~placement~named~'#1' }
\msg_new:nnn {tenkz}{geom-place-cycle}
  { [TKZ-PLACE-CYCLE]~placements~depend~on~themselves:~#1 }
\msg_new:nnn {tenkz}{geom-family}
  { [TKZ-GEOM-FAMILY]~no~silhouette~family~named~'#1' }
\msg_new:nnn {tenkz}{geom-compass}
  { [TKZ-GEOM-COMPASS]~no~compass~face~named~'#1';~expected~n,~e,~s,~or~w }
\msg_new:nnn {tenkz}{geom-singular-basis}
  { [TKZ-GEOM-SINGULAR-BASIS]~a~carrier~basis~must~have~nonzero~determinant }

% ---------- frames ----------------------------------------------------------
% A frame record is six fp fields (row-major matrix a b / c d, offset dx dy)
% for kind=affine, or fields n (stations), r (radius), start (degrees) for
% kind=circle.  A projected plane additionally owns one page-space transverse
% vector.  Frames are picture-local.
\__tenkz_prop_new_indexed:N \l__tenkz_geom_frame_prop     % "name/field" -> fp literal
\prop_new:N \l__tenkz_geom_framekind_prop % "name" -> affine | circle
\fp_const:Nn \c__tenkz_geom_plane_transverse_x_fp {0}
\fp_const:Nn \c__tenkz_geom_plane_transverse_y_fp {1}

\cs_new_protected:Npn \__tenkz_geom_frame_put:nnn #1#2#3
  { \prop_put:Nen \l__tenkz_geom_frame_prop { #1 / #2 } { \fp_eval:n {#3} } }

\cs_new_protected:Npn \__tenkz_geom_frame_declare_transverse:nnn #1#2#3
  {
    \__tenkz_geom_frame_put:nnn {#1} {transverse-x} {#2}
    \__tenkz_geom_frame_put:nnn {#1} {transverse-y} {#3}
  }

\cs_new_protected:Npn \__tenkz_geom_frame_declare_affine:nnnnnnn #1#2#3#4#5#6#7
  {
    \prop_put:Nnn \l__tenkz_geom_framekind_prop {#1} {affine}
    \__tenkz_geom_frame_put:nnn {#1} {a} {#2}
    \__tenkz_geom_frame_put:nnn {#1} {b} {#3}
    \__tenkz_geom_frame_put:nnn {#1} {c} {#4}
    \__tenkz_geom_frame_put:nnn {#1} {d} {#5}
    \__tenkz_geom_frame_put:nnn {#1} {dx} {#6}
    \__tenkz_geom_frame_put:nnn {#1} {dy} {#7}
  }
% The public plane word has one fixed projected basis.  Logical coordinates
% arrive as (row, column), with (1,1) at the origin: C=(1,0) and
% R=(-planeslant,-planerise).  Keeping this declaration beside the other
% frame maps gives every front end the same projection and leaves rendering
% responsible only for ink.
\cs_new_protected:Npn \__tenkz_geom_frame_declare_plane:n #1
  {
    \__tenkz_geom_frame_declare_affine:nnnnnnn {#1}
      { -\__tenkz_metric_ratio:n {planeslant} } {1}
      { -\__tenkz_metric_ratio:n {planerise} } {0}
      { \__tenkz_metric_ratio:n {planeslant} - 1 }
      { \__tenkz_metric_ratio:n {planerise} }
    % The projected carrier is the zx plane; its physical y axis stays
    % vertical on the page and is not a direction in the affine plane basis.
    \__tenkz_geom_frame_declare_transverse:nnn {#1}
      { \c__tenkz_geom_plane_transverse_x_fp }
      { \c__tenkz_geom_plane_transverse_y_fp }
  }
% flat is the identity; a rotation frame is one call with cosd/sind
\cs_new_protected:Npn \__tenkz_geom_frame_declare_rotation:nnnn #1#2#3#4
  {
    \__tenkz_geom_frame_declare_affine:nnnnnnn {#1}
      { cosd(#2) } { -sind(#2) } { sind(#2) } { cosd(#2) } {#3} {#4}
  }
\cs_new_protected:Npn \__tenkz_geom_frame_declare_circle:nnnn #1#2#3#4
  {
    \prop_put:Nnn \l__tenkz_geom_framekind_prop {#1} {circle}
    \__tenkz_geom_frame_put:nnn {#1} {n} {#2}
    \__tenkz_geom_frame_put:nnn {#1} {r} {#3}
    \__tenkz_geom_frame_put:nnn {#1} {start} {#4}
  }

\cs_new:Npn \__tenkz_geom_field:nn #1#2
  { \prop_item:Nn \l__tenkz_geom_frame_prop { #1 / #2 } }

% Return the signed page vector on the independent transverse axis.  Only a
% frame which declares that third carrier direction may call this service;
% flat and circular physical directions continue through their local bases.
\cs_new_protected:Npn \__tenkz_geom_frame_transverse:nnNN #1#2#3#4
  {
    \str_if_eq:nnTF {#2} {n}
      {
        \tl_set:Ne #3 { \__tenkz_geom_field:nn {#1} {transverse-x} }
        \tl_set:Ne #4 { \__tenkz_geom_field:nn {#1} {transverse-y} }
      }
      {
        \tl_set:Ne #3 { -\__tenkz_geom_field:nn {#1} {transverse-x} }
        \tl_set:Ne #4 { -\__tenkz_geom_field:nn {#1} {transverse-y} }
      }
  }
\cs_new_protected:Npn \__tenkz_geom_require_kind:nnnN #1#2#3#4
  {
    \bool_set_false:N #4
    \prop_get:NnN \l__tenkz_geom_framekind_prop {#1} \l__tenkz_geom_tmp_tl
    \quark_if_no_value:NTF \l__tenkz_geom_tmp_tl
      { \msg_error:nnn {tenkz}{geom-unknown-frame} {#1} }
      {
        \str_if_eq:VnTF \l__tenkz_geom_tmp_tl {#2}
          { \bool_set_true:N #4 }
          { \msg_error:nnxxxx {tenkz}{geom-frame-kind} {#1}
              { \l__tenkz_geom_tmp_tl } {#3} {#2} }
      }
  }
\tl_new:N \l__tenkz_geom_tmp_tl
\bool_new:N \l__tenkz_geom_kind_ok_bool

% new = outer applied after inner: M = Mo Mi, t = Mo ti + to
\cs_new_protected:Npn \__tenkz_geom_frame_compose:nnn #1#2#3
  {
    \__tenkz_geom_require_kind:nnnN {#2} {affine} {compose}
      \l__tenkz_geom_kind_ok_bool
    \bool_if:NT \l__tenkz_geom_kind_ok_bool
      {
        \__tenkz_geom_require_kind:nnnN {#3} {affine} {compose}
          \l__tenkz_geom_kind_ok_bool
      }
    \bool_if:NT \l__tenkz_geom_kind_ok_bool
      {
        \__tenkz_geom_frame_declare_affine:nnnnnnn {#1}
          { \__tenkz_geom_field:nn{#2}{a} * \__tenkz_geom_field:nn{#3}{a}
            + \__tenkz_geom_field:nn{#2}{b} * \__tenkz_geom_field:nn{#3}{c} }
          { \__tenkz_geom_field:nn{#2}{a} * \__tenkz_geom_field:nn{#3}{b}
            + \__tenkz_geom_field:nn{#2}{b} * \__tenkz_geom_field:nn{#3}{d} }
          { \__tenkz_geom_field:nn{#2}{c} * \__tenkz_geom_field:nn{#3}{a}
            + \__tenkz_geom_field:nn{#2}{d} * \__tenkz_geom_field:nn{#3}{c} }
          { \__tenkz_geom_field:nn{#2}{c} * \__tenkz_geom_field:nn{#3}{b}
            + \__tenkz_geom_field:nn{#2}{d} * \__tenkz_geom_field:nn{#3}{d} }
          { \__tenkz_geom_field:nn{#2}{a} * \__tenkz_geom_field:nn{#3}{dx}
            + \__tenkz_geom_field:nn{#2}{b} * \__tenkz_geom_field:nn{#3}{dy}
            + \__tenkz_geom_field:nn{#2}{dx} }
          { \__tenkz_geom_field:nn{#2}{c} * \__tenkz_geom_field:nn{#3}{dx}
            + \__tenkz_geom_field:nn{#2}{d} * \__tenkz_geom_field:nn{#3}{dy}
            + \__tenkz_geom_field:nn{#2}{dy} }
      }
  }

% apply a frame to a logical point; results are fp literals in pitch units.
% A circle frame reads the second coordinate as a station and the first as a
% step inward, so the flat frame's rightward column becomes travel along the
% ring and its downward row becomes travel toward the centre.  Both readings
% accept fractions: a port half a cell east of its atom is half a station
% further round.
\cs_new_protected:Npn \__tenkz_geom_apply:nnnNNN #1#2#3#4#5#6
  {
    \tl_clear:N #4
    \tl_clear:N #5
    \bool_set_false:N #6
    \prop_get:NnN \l__tenkz_geom_framekind_prop {#1} \l__tenkz_geom_kind_tl
    \quark_if_no_value:NTF \l__tenkz_geom_kind_tl
      { \msg_error:nnn {tenkz}{geom-unknown-frame} {#1} }
      {
        \bool_set_true:N #6
        \str_if_eq:VnTF \l__tenkz_geom_kind_tl {circle}
          {
            \tl_set:Ne #4
              { \fp_eval:n
                  { ( \__tenkz_geom_field:nn{#1}{r} - ( (#2) - 1 ) )
                    * cosd( \__tenkz_geom_station_angle:nn {#1} {#3} ) } }
            \tl_set:Ne #5
              { \fp_eval:n
                  { ( \__tenkz_geom_field:nn{#1}{r} - ( (#2) - 1 ) )
                    * sind( \__tenkz_geom_station_angle:nn {#1} {#3} ) } }
          }
          {
            \tl_set:Ne #4
              { \fp_eval:n { \__tenkz_geom_field:nn{#1}{a} * (#2)
                           + \__tenkz_geom_field:nn{#1}{b} * (#3)
                           + \__tenkz_geom_field:nn{#1}{dx} } }
            \tl_set:Ne #5
              { \fp_eval:n { \__tenkz_geom_field:nn{#1}{c} * (#2)
                           + \__tenkz_geom_field:nn{#1}{d} * (#3)
                           + \__tenkz_geom_field:nn{#1}{dy} } }
          }
      }
  }
\tl_new:N \l__tenkz_geom_kind_tl
\cs_new_protected:Npn \__tenkz_geom_apply:nnnNN #1#2#3#4#5
  {
    \__tenkz_geom_apply:nnnNNN {#1}{#2}{#3} #4 #5
      \l__tenkz_geom_kind_ok_bool
  }

% Recover the logical point under an affine frame.  Open-end projection uses
% this inverse so it can keep the neighbouring point's transverse logical
% coordinate before placing the tip on a projected margin; mixing a page x
% with a separately projected page y is valid only for an axis-aligned frame.
\cs_new_protected:Npn \__tenkz_geom_unapply:nnnNN #1#2#3#4#5
  {
    \tl_clear:N #4
    \tl_clear:N #5
    \__tenkz_geom_require_kind:nnnN {#1} {affine} {unapply}
      \l__tenkz_geom_kind_ok_bool
    \bool_if:NT \l__tenkz_geom_kind_ok_bool
      {
        \tl_set:Ne #4
          {
            \fp_eval:n
              {
                (
                  \__tenkz_geom_field:nn{#1}{d}
                    * ( (#2) - \__tenkz_geom_field:nn{#1}{dx} )
                  - \__tenkz_geom_field:nn{#1}{b}
                    * ( (#3) - \__tenkz_geom_field:nn{#1}{dy} )
                )
                /
                (
                  \__tenkz_geom_field:nn{#1}{a}
                    * \__tenkz_geom_field:nn{#1}{d}
                  - \__tenkz_geom_field:nn{#1}{b}
                    * \__tenkz_geom_field:nn{#1}{c}
                )
              }
          }
        \tl_set:Ne #5
          {
            \fp_eval:n
              {
                (
                  -\__tenkz_geom_field:nn{#1}{c}
                    * ( (#2) - \__tenkz_geom_field:nn{#1}{dx} )
                  + \__tenkz_geom_field:nn{#1}{a}
                    * ( (#3) - \__tenkz_geom_field:nn{#1}{dy} )
                )
                /
                (
                  \__tenkz_geom_field:nn{#1}{a}
                    * \__tenkz_geom_field:nn{#1}{d}
                  - \__tenkz_geom_field:nn{#1}{b}
                    * \__tenkz_geom_field:nn{#1}{c}
                )
              }
          }
      }
  }

\prg_new_conditional:Npnn \__tenkz_geom_frame_if_affine:n #1 { T, F, TF }
  {
    \prop_get:NnN \l__tenkz_geom_framekind_prop {#1} \l__tenkz_geom_kind_tl
    \str_if_eq:VnTF \l__tenkz_geom_kind_tl {affine}
      { \prg_return_true: }
      { \prg_return_false: }
  }

% a direction crosses the linear part; shear and scale bend it honestly
\cs_new_protected:Npn \__tenkz_geom_dir:nnN #1#2#3
  {
    \tl_clear:N #3
    \__tenkz_geom_require_kind:nnnN {#1} {affine} {dir}
      \l__tenkz_geom_kind_ok_bool
    \bool_if:NT \l__tenkz_geom_kind_ok_bool
      {
        \tl_set:Ne #3
          { \fp_eval:n
              { atand
                  ( \__tenkz_geom_field:nn{#1}{c} * cosd(#2)
                    + \__tenkz_geom_field:nn{#1}{d} * sind(#2) ,
                    \__tenkz_geom_field:nn{#1}{a} * cosd(#2)
                    + \__tenkz_geom_field:nn{#1}{b} * sind(#2) ) } }
      }
  }

% A carrier basis is the complete local east/north pair on the page.  Keeping
% both vectors, rather than one turn angle, preserves shear and unequal scale.
% The generic apply/unapply pair below is shared by every consumer which
% stores geometry in carrier-local coordinates.
\cs_new_protected:Npn \__tenkz_geom_basis_apply:nnnnnnNN
    #1#2#3#4#5#6#7#8
  {
    \tl_set:Ne #7 { \fp_eval:n { (#1) * (#5) + (#3) * (#6) } }
    \tl_set:Ne #8 { \fp_eval:n { (#2) * (#5) + (#4) * (#6) } }
  }
% Carry a local direction through a complete carrier basis and read its page
% bearing.  A scalar turn cannot answer this question under shear or unequal
% axis scale; the two basis vectors are the one source of direction geometry.
\tl_new:N \l__tenkz_geom_bearing_x_tl
\tl_new:N \l__tenkz_geom_bearing_y_tl
\cs_new_protected:Npn \__tenkz_geom_basis_bearing:nnnnnN
    #1#2#3#4#5#6
  {
    \__tenkz_geom_basis_apply:nnnnnnNN
      {#1}{#2}{#3}{#4}
      { cosd(#5) } { sind(#5) }
      \l__tenkz_geom_bearing_x_tl \l__tenkz_geom_bearing_y_tl
    \tl_set:Ne #6
      {
        \fp_eval:n
          {
            atand(
              \l__tenkz_geom_bearing_y_tl ,
              \l__tenkz_geom_bearing_x_tl )
          }
      }
  }
\cs_new_protected:Npn \__tenkz_geom_basis_unapply:nnnnnnNN
    #1#2#3#4#5#6#7#8
  {
    \fp_compare:nNnTF { (#1) * (#4) - (#2) * (#3) } = {0}
      {
        \tl_clear:N #7
        \tl_clear:N #8
        \msg_error:nn {tenkz}{geom-singular-basis}
      }
      {
        \tl_set:Ne #7
          {
            \fp_eval:n
              { ( (#4) * (#5) - (#3) * (#6) )
                  / ( (#1) * (#4) - (#2) * (#3) ) }
          }
        \tl_set:Ne #8
          {
            \fp_eval:n
              { ( -(#2) * (#5) + (#1) * (#6) )
                  / ( (#1) * (#4) - (#2) * (#3) ) }
          }
      }
  }

% The dual basis reads page vectors as local coordinates.  These covectors
% also let support folds measure a rendered silhouette directly in local
% coordinates without first replacing it by an axis-aligned page box.
\cs_new_protected:Npn \__tenkz_geom_basis_dual:nnnnNNNN
    #1#2#3#4#5#6#7#8
  {
    \fp_compare:nNnTF { (#1) * (#4) - (#2) * (#3) } = {0}
      {
        \tl_clear:N #5
        \tl_clear:N #6
        \tl_clear:N #7
        \tl_clear:N #8
        \msg_error:nn {tenkz}{geom-singular-basis}
      }
      {
        \tl_set:Ne #5
          { \fp_eval:n { (#4) / ( (#1) * (#4) - (#2) * (#3) ) } }
        \tl_set:Ne #6
          { \fp_eval:n { -(#3) / ( (#1) * (#4) - (#2) * (#3) ) } }
        \tl_set:Ne #7
          { \fp_eval:n { -(#2) / ( (#1) * (#4) - (#2) * (#3) ) } }
        \tl_set:Ne #8
          { \fp_eval:n { (#1) / ( (#1) * (#4) - (#2) * (#3) ) } }
      }
  }

% Distance from the centre of an axis-aligned rectangle to its boundary along
% a ray.  The half-extents #2,#3 and result #4 share one coordinate system;
% #1 is the ray angle in that system.
\cs_new_protected:Npn \__tenkz_geom_rect_ray_reach:nnnN #1#2#3#4
  {
    \fp_compare:nNnTF { cosd(#1) } = {0}
      { \tl_set:Ne #4 { \fp_eval:n { (#3) / abs(sind(#1)) } } }
      {
        \fp_compare:nNnTF { sind(#1) } = {0}
          { \tl_set:Ne #4 { \fp_eval:n { (#2) / abs(cosd(#1)) } } }
          {
            \tl_set:Ne #4
              {
                \fp_eval:n
                  {
                    min(
                      (#2) / abs(cosd(#1)) ,
                      (#3) / abs(sind(#1)) )
                  }
              }
          }
      }
  }

% Update the entry/exit parameters of a ray against one axis-aligned slab.
% A zero direction component either leaves that axis unconstrained or proves
% that the ray misses the slab.
\tl_new:N \l__tenkz_geom_ray_near_tl
\tl_new:N \l__tenkz_geom_ray_far_tl
\tl_new:N \l__tenkz_geom_ray_enter_tl
\tl_new:N \l__tenkz_geom_ray_exit_tl
\tl_new:N \l__tenkz_geom_ray_t_tl
\bool_new:N \l__tenkz_geom_ray_hit_bool
\cs_new_protected:Npn \__tenkz_geom_ray_slab:nnnn #1#2#3#4
  {
    \fp_compare:nNnTF {#2} = {0}
      {
        \bool_lazy_or:nnT
          { \fp_compare_p:nNn {#1} < {#3} }
          { \fp_compare_p:nNn {#1} > {#4} }
          { \bool_set_false:N \l__tenkz_geom_ray_hit_bool }
      }
      {
        \tl_set:Ne \l__tenkz_geom_ray_near_tl
          {
            \fp_eval:n
              { min(((#3) - (#1)) / (#2), ((#4) - (#1)) / (#2)) }
          }
        \tl_set:Ne \l__tenkz_geom_ray_far_tl
          {
            \fp_eval:n
              { max(((#3) - (#1)) / (#2), ((#4) - (#1)) / (#2)) }
          }
        \tl_if_empty:NTF \l__tenkz_geom_ray_enter_tl
          {
            \tl_set_eq:NN
              \l__tenkz_geom_ray_enter_tl \l__tenkz_geom_ray_near_tl
          }
          {
            \tl_set:Ne \l__tenkz_geom_ray_enter_tl
              {
                \fp_eval:n
                  {
                    max(
                      \l__tenkz_geom_ray_enter_tl ,
                      \l__tenkz_geom_ray_near_tl )
                  }
              }
          }
        \tl_if_empty:NTF \l__tenkz_geom_ray_exit_tl
          {
            \tl_set_eq:NN
              \l__tenkz_geom_ray_exit_tl \l__tenkz_geom_ray_far_tl
          }
          {
            \tl_set:Ne \l__tenkz_geom_ray_exit_tl
              {
                \fp_eval:n
                  {
                    min(
                      \l__tenkz_geom_ray_exit_tl ,
                      \l__tenkz_geom_ray_far_tl )
                  }
              }
          }
      }
  }

% The first forward intersection of a ray with an axis-aligned rectangle.
% Positions and bounds are expressed in one local coordinate system; #3 is
% the ray angle in that system.  An interior origin returns its first exit;
% an exterior origin returns its first entry.  Both outputs are empty when
% the forward ray misses the rectangle.
\cs_new_protected:Npn \__tenkz_geom_rect_ray_intersect:nnnnnnnNN
    #1#2#3#4#5#6#7#8#9
  {
    \bool_set_true:N \l__tenkz_geom_ray_hit_bool
    \tl_clear:N \l__tenkz_geom_ray_enter_tl
    \tl_clear:N \l__tenkz_geom_ray_exit_tl
    \tl_clear:N \l__tenkz_geom_ray_t_tl
    \__tenkz_geom_ray_slab:nnnn {#1}{cosd(#3)}{#4}{#5}
    \__tenkz_geom_ray_slab:nnnn {#2}{sind(#3)}{#6}{#7}
    \bool_if:NT \l__tenkz_geom_ray_hit_bool
      {
        \bool_lazy_or:nnT
          {
            \fp_compare_p:nNn
              { \l__tenkz_geom_ray_enter_tl }
              > { \l__tenkz_geom_ray_exit_tl }
          }
          { \fp_compare_p:nNn { \l__tenkz_geom_ray_exit_tl } < {0} }
          { \bool_set_false:N \l__tenkz_geom_ray_hit_bool }
      }
    \bool_if:NTF \l__tenkz_geom_ray_hit_bool
      {
        \tl_set:Ne \l__tenkz_geom_ray_t_tl
          {
            \fp_eval:n
              {
                \l__tenkz_geom_ray_enter_tl >= 0
                  ? \l__tenkz_geom_ray_enter_tl
                  : \l__tenkz_geom_ray_exit_tl
              }
          }
        \tl_set:Ne #8
          { \fp_eval:n { (#1) + \l__tenkz_geom_ray_t_tl * cosd(#3) } }
        \tl_set:Ne #9
          { \fp_eval:n { (#2) + \l__tenkz_geom_ray_t_tl * sind(#3) } }
      }
      { \tl_clear:N #8 \tl_clear:N #9 }
  }

% The local east/north basis supplied by one frame address.  Affine frames
% use E=(b,d), N=(-a,-c); circle frames retain their orthonormal tangent and
% outward-normal basis at the named station.
\cs_new_protected:Npn \__tenkz_geom_local_basis:nnnNNNN #1#2#3#4#5#6#7
  {
    \prop_get:NnN \l__tenkz_geom_framekind_prop {#1} \l__tenkz_geom_kind_tl
    \quark_if_no_value:NTF \l__tenkz_geom_kind_tl
      {
        \msg_error:nnn {tenkz}{geom-unknown-frame} {#1}
        \tl_set:Nn #4 {1}
        \tl_set:Nn #5 {0}
        \tl_set:Nn #6 {0}
        \tl_set:Nn #7 {1}
      }
      {
        \str_if_eq:VnTF \l__tenkz_geom_kind_tl {circle}
          {
            \__tenkz_geom_turn:nnnN {#1}{#2}{#3} \l__tenkz_geom_tmp_tl
            \tl_set:Ne #4 { \fp_eval:n { cosd(\l__tenkz_geom_tmp_tl) } }
            \tl_set:Ne #5 { \fp_eval:n { sind(\l__tenkz_geom_tmp_tl) } }
            \tl_set:Ne #6 { \fp_eval:n { -sind(\l__tenkz_geom_tmp_tl) } }
            \tl_set:Ne #7 { \fp_eval:n { cosd(\l__tenkz_geom_tmp_tl) } }
          }
          {
            \tl_set:Ne #4 { \__tenkz_geom_field:nn{#1}{b} }
            \tl_set:Ne #5 { \__tenkz_geom_field:nn{#1}{d} }
            \tl_set:Ne #6 { \fp_eval:n { -\__tenkz_geom_field:nn{#1}{a} } }
            \tl_set:Ne #7 { \fp_eval:n { -\__tenkz_geom_field:nn{#1}{c} } }
          }
      }
  }

% Smallest conservative centre-bearing default-bead support margin between
% distinct basis-member families in one finite affine cell window.  Geometry
% owns the exhaustive silhouette calculation, while its caller supplies the
% bead-body radius, page-owned gap, and an opaque model-realization callback.
% Member offsets are already expressed in the caller's local east/north units.  With
% dr/dc equal to member j's cell minus
% member i's cell, their local displacement is
%   east  = dc + east_j - east_i,
%   north = -dr + north_j - north_i.
% For a nonzero centre vector, the required separation is the support of both
% affine-carried beads in that direction plus the page-space gap.  At exact
% coincidence there is no direction, so the largest affine body support
% defines a conservative direction-free convention.  A live authored span can
% have canonical row or column zero while still intersecting the declared
% 1..rows by 1..cols population window.  The complete translation domain is
% therefore -rows..rows by -cols..cols, one step wider on every side than the
% differences between ordinary in-window cells.  The strict minimum margin and
% fixed traversal order make the reported witness deterministic.
% The output is empty when no realized candidate has a negative margin.
\prop_new:N \l__tenkz_geom_basis_spacing_east_prop
\prop_new:N \l__tenkz_geom_basis_spacing_north_prop
\prop_new:N \l__tenkz_geom_basis_spacing_result_prop
\int_new:N \l__tenkz_geom_basis_spacing_rows_int
\int_new:N \l__tenkz_geom_basis_spacing_cols_int
\int_new:N \l__tenkz_geom_basis_spacing_count_int
\int_new:N \l__tenkz_geom_basis_spacing_a_int
\int_new:N \l__tenkz_geom_basis_spacing_b_int
\int_new:N \l__tenkz_geom_basis_spacing_dr_int
\int_new:N \l__tenkz_geom_basis_spacing_dc_int
\tl_new:N \l__tenkz_geom_basis_spacing_radius_tl
\tl_new:N \l__tenkz_geom_basis_spacing_gap_tl
\tl_new:N \l__tenkz_geom_basis_spacing_ex_tl
\tl_new:N \l__tenkz_geom_basis_spacing_ey_tl
\tl_new:N \l__tenkz_geom_basis_spacing_nx_tl
\tl_new:N \l__tenkz_geom_basis_spacing_ny_tl
\tl_new:N \l__tenkz_geom_basis_spacing_east_a_tl
\tl_new:N \l__tenkz_geom_basis_spacing_east_b_tl
\tl_new:N \l__tenkz_geom_basis_spacing_north_a_tl
\tl_new:N \l__tenkz_geom_basis_spacing_north_b_tl
\tl_new:N \l__tenkz_geom_basis_spacing_east_delta_tl
\tl_new:N \l__tenkz_geom_basis_spacing_north_delta_tl
\tl_new:N \l__tenkz_geom_basis_spacing_x_tl
\tl_new:N \l__tenkz_geom_basis_spacing_y_tl
\tl_new:N \l__tenkz_geom_basis_spacing_dist_square_tl
\tl_new:N \l__tenkz_geom_basis_spacing_dist_tl
\tl_new:N \l__tenkz_geom_basis_spacing_floor_tl
\tl_new:N \l__tenkz_geom_basis_spacing_clearance_tl
\bool_new:N \l__tenkz_geom_basis_spacing_kind_ok_bool
\cs_new_protected:Npn \__tenkz_geom_basis_spacing_query:nnnnnTF
  #1#2#3#4#5#6#7 {#7}

% Largest stretch of a complete two-dimensional basis.  This is the largest
% singular value, written from the trace and determinant of M^T M so exact
% centre coincidence has a direction-independent bead support.
\cs_new:Npn \__tenkz_geom_basis_max_stretch:nnnn #1#2#3#4
  {
    sqrt(
      (
        (#1)^2 + (#2)^2 + (#3)^2 + (#4)^2
        + sqrt(
            max(
              0,
              ( (#1)^2 + (#2)^2 + (#3)^2 + (#4)^2 )^2
              - 4 * ( (#1) * (#4) - (#2) * (#3) )^2 ) )
      ) / 2 )
  }

\cs_new_protected:Npn \__tenkz_geom_basis_spacing_save:
  {
    \prop_put:Nne \l__tenkz_geom_basis_spacing_result_prop {member-a}
      { \int_use:N \l__tenkz_geom_basis_spacing_a_int }
    \prop_put:Nne \l__tenkz_geom_basis_spacing_result_prop {member-b}
      { \int_use:N \l__tenkz_geom_basis_spacing_b_int }
    \prop_put:Nne \l__tenkz_geom_basis_spacing_result_prop {dr}
      { \int_use:N \l__tenkz_geom_basis_spacing_dr_int }
    \prop_put:Nne \l__tenkz_geom_basis_spacing_result_prop {dc}
      { \int_use:N \l__tenkz_geom_basis_spacing_dc_int }
    \prop_put:Nne \l__tenkz_geom_basis_spacing_result_prop {dist}
      { \l__tenkz_geom_basis_spacing_dist_tl }
    \prop_put:Nne \l__tenkz_geom_basis_spacing_result_prop {floor}
      { \l__tenkz_geom_basis_spacing_floor_tl }
    \prop_put:Nne \l__tenkz_geom_basis_spacing_result_prop {clearance}
      { \l__tenkz_geom_basis_spacing_clearance_tl }
  }

\cs_new_protected:Npn \__tenkz_geom_basis_spacing_save_if_realized:
  {
    \__tenkz_geom_basis_spacing_query:nnnnnTF
      {realized}
      { \int_use:N \l__tenkz_geom_basis_spacing_a_int }
      { \int_use:N \l__tenkz_geom_basis_spacing_b_int }
      { \int_use:N \l__tenkz_geom_basis_spacing_dr_int }
      { \int_use:N \l__tenkz_geom_basis_spacing_dc_int }
      { \__tenkz_geom_basis_spacing_save: }
      { }
  }

\cs_new_protected:Npn \__tenkz_geom_basis_spacing_candidate_measure:
  {
    \__tenkz_geom_basis_apply:nnnnnnNN
      { \l__tenkz_geom_basis_spacing_ex_tl }
      { \l__tenkz_geom_basis_spacing_ey_tl }
      { \l__tenkz_geom_basis_spacing_nx_tl }
      { \l__tenkz_geom_basis_spacing_ny_tl }
      {
        \l__tenkz_geom_basis_spacing_dc_int
        + \l__tenkz_geom_basis_spacing_east_delta_tl
      }
      {
        -\l__tenkz_geom_basis_spacing_dr_int
        + \l__tenkz_geom_basis_spacing_north_delta_tl
      }
      \l__tenkz_geom_basis_spacing_x_tl
      \l__tenkz_geom_basis_spacing_y_tl
    \tl_set:Ne \l__tenkz_geom_basis_spacing_dist_square_tl
      {
        \fp_eval:n
          {
            ( \l__tenkz_geom_basis_spacing_x_tl ) ^ 2
            + ( \l__tenkz_geom_basis_spacing_y_tl ) ^ 2
          }
      }
    \fp_compare:nNnTF
      { \l__tenkz_geom_basis_spacing_dist_square_tl } = {0}
      {
        \tl_set:Nn \l__tenkz_geom_basis_spacing_dist_tl {0}
        \tl_set:Ne \l__tenkz_geom_basis_spacing_floor_tl
          {
            \fp_eval:n
              {
                \l__tenkz_geom_basis_spacing_gap_tl
                + 2 * \l__tenkz_geom_basis_spacing_radius_tl
                  * \__tenkz_geom_basis_max_stretch:nnnn
                      { \l__tenkz_geom_basis_spacing_ex_tl }
                      { \l__tenkz_geom_basis_spacing_ey_tl }
                      { \l__tenkz_geom_basis_spacing_nx_tl }
                      { \l__tenkz_geom_basis_spacing_ny_tl }
              }
          }
      }
      {
        \tl_set:Ne \l__tenkz_geom_basis_spacing_dist_tl
          {
            \fp_eval:n
              { sqrt(\l__tenkz_geom_basis_spacing_dist_square_tl) }
          }
        \tl_set:Ne \l__tenkz_geom_basis_spacing_floor_tl
          {
            \fp_eval:n
              {
                \l__tenkz_geom_basis_spacing_gap_tl
                + 2 * \__tenkz_geom_support_basis:nnnnnnnnn
                    {circle}
                    { \l__tenkz_geom_basis_spacing_radius_tl }
                    { \l__tenkz_geom_basis_spacing_radius_tl }
                    {0}
                    { \l__tenkz_geom_basis_spacing_ex_tl }
                    { \l__tenkz_geom_basis_spacing_ey_tl }
                    { \l__tenkz_geom_basis_spacing_nx_tl }
                    { \l__tenkz_geom_basis_spacing_ny_tl }
                  {
                    atand(
                      \l__tenkz_geom_basis_spacing_y_tl,
                      \l__tenkz_geom_basis_spacing_x_tl )
                  }
              }
          }
      }
    \tl_set:Ne \l__tenkz_geom_basis_spacing_clearance_tl
      {
        \fp_eval:n
          {
            \l__tenkz_geom_basis_spacing_dist_tl
            - \l__tenkz_geom_basis_spacing_floor_tl
          }
      }
    \fp_compare:nNnT
      { \l__tenkz_geom_basis_spacing_clearance_tl } < {0}
      {
        \prop_if_empty:NTF \l__tenkz_geom_basis_spacing_result_prop
          { \__tenkz_geom_basis_spacing_save_if_realized: }
          {
            \fp_compare:nNnT
              { \l__tenkz_geom_basis_spacing_clearance_tl } <
              {
                \prop_item:Nn
                  \l__tenkz_geom_basis_spacing_result_prop {clearance}
              }
              { \__tenkz_geom_basis_spacing_save_if_realized: }
          }
      }
  }

\cs_new_protected:Npn \__tenkz_geom_basis_spacing_candidate:
  {
    \__tenkz_geom_basis_spacing_query:nnnnnTF
      {candidate}
      { \int_use:N \l__tenkz_geom_basis_spacing_a_int }
      { \int_use:N \l__tenkz_geom_basis_spacing_b_int }
      { \int_use:N \l__tenkz_geom_basis_spacing_dr_int }
      { \int_use:N \l__tenkz_geom_basis_spacing_dc_int }
      { \__tenkz_geom_basis_spacing_candidate_measure: }
      { }
  }

\cs_new_protected:Npn \__tenkz_geom_basis_spacing_dc:n #1
  {
    \int_set:Nn \l__tenkz_geom_basis_spacing_dc_int {#1}
    \__tenkz_geom_basis_spacing_candidate:
  }

\cs_new_protected:Npn \__tenkz_geom_basis_spacing_dr:n #1
  {
    \int_set:Nn \l__tenkz_geom_basis_spacing_dr_int {#1}
    \int_step_function:nnN
      { -\l__tenkz_geom_basis_spacing_cols_int }
      { \l__tenkz_geom_basis_spacing_cols_int }
      \__tenkz_geom_basis_spacing_dc:n
  }

\cs_new_protected:Npn \__tenkz_geom_basis_spacing_b:n #1
  {
    \int_set:Nn \l__tenkz_geom_basis_spacing_b_int {#1}
    \prop_get:NeN \l__tenkz_geom_basis_spacing_east_prop {#1}
      \l__tenkz_geom_basis_spacing_east_b_tl
    \prop_get:NeN \l__tenkz_geom_basis_spacing_north_prop {#1}
      \l__tenkz_geom_basis_spacing_north_b_tl
    \tl_set:Ne \l__tenkz_geom_basis_spacing_east_delta_tl
      {
        \fp_eval:n
          {
            \l__tenkz_geom_basis_spacing_east_b_tl
            - \l__tenkz_geom_basis_spacing_east_a_tl
          }
      }
    \tl_set:Ne \l__tenkz_geom_basis_spacing_north_delta_tl
      {
        \fp_eval:n
          {
            \l__tenkz_geom_basis_spacing_north_b_tl
            - \l__tenkz_geom_basis_spacing_north_a_tl
          }
      }
    \int_step_function:nnN
      { -\l__tenkz_geom_basis_spacing_rows_int }
      { \l__tenkz_geom_basis_spacing_rows_int }
      \__tenkz_geom_basis_spacing_dr:n
  }

\cs_new_protected:Npn \__tenkz_geom_basis_spacing_a:n #1
  {
    \int_set:Nn \l__tenkz_geom_basis_spacing_a_int {#1}
    \prop_get:NeN \l__tenkz_geom_basis_spacing_east_prop {#1}
      \l__tenkz_geom_basis_spacing_east_a_tl
    \prop_get:NeN \l__tenkz_geom_basis_spacing_north_prop {#1}
      \l__tenkz_geom_basis_spacing_north_a_tl
    \int_step_function:nnN
      { \l__tenkz_geom_basis_spacing_a_int + 1 }
      { \l__tenkz_geom_basis_spacing_count_int }
      \__tenkz_geom_basis_spacing_b:n
  }

\cs_new_protected:Npn \__tenkz_geom_basis_spacing:nnnnnNNNN
    #1#2#3#4#5#6#7#8#9
  {
    \prop_clear:N #9
    \prop_clear:N \l__tenkz_geom_basis_spacing_result_prop
    \__tenkz_geom_require_kind:nnnN {#1} {affine} {basis-spacing}
      \l__tenkz_geom_basis_spacing_kind_ok_bool
    \bool_if:NT \l__tenkz_geom_basis_spacing_kind_ok_bool
      {
        \int_set:Nn \l__tenkz_geom_basis_spacing_rows_int {#2}
        \int_set:Nn \l__tenkz_geom_basis_spacing_cols_int {#3}
        \tl_set:Ne \l__tenkz_geom_basis_spacing_radius_tl { \fp_eval:n {#4} }
        \tl_set:Ne \l__tenkz_geom_basis_spacing_gap_tl { \fp_eval:n {#5} }
        \prop_set_eq:NN \l__tenkz_geom_basis_spacing_east_prop #6
        \prop_set_eq:NN \l__tenkz_geom_basis_spacing_north_prop #7
        \cs_set_eq:NN \__tenkz_geom_basis_spacing_query:nnnnnTF #8
        \int_set:Nn \l__tenkz_geom_basis_spacing_count_int
          { \prop_count:N \l__tenkz_geom_basis_spacing_east_prop }
        \__tenkz_geom_local_basis:nnnNNNN {#1}{1}{1}
          \l__tenkz_geom_basis_spacing_ex_tl
          \l__tenkz_geom_basis_spacing_ey_tl
          \l__tenkz_geom_basis_spacing_nx_tl
          \l__tenkz_geom_basis_spacing_ny_tl
        \int_step_function:nnN {1}
          { \l__tenkz_geom_basis_spacing_count_int - 1 }
          \__tenkz_geom_basis_spacing_a:n
        \prop_set_eq:NN #9 \l__tenkz_geom_basis_spacing_result_prop
      }
  }

% The page bearing of one direction in the local east/north axes of a frame
% address.  Both affine and circular carriers answer through the same complete
% basis; consumers do not inspect frame kinds or reproduce projection formulae.
\tl_new:N \l__tenkz_geom_bearing_ex_tl
\tl_new:N \l__tenkz_geom_bearing_ey_tl
\tl_new:N \l__tenkz_geom_bearing_nx_tl
\tl_new:N \l__tenkz_geom_bearing_ny_tl
\cs_new_protected:Npn \__tenkz_geom_local_bearing:nnnnN #1#2#3#4#5
  {
    \__tenkz_geom_local_basis:nnnNNNN {#1}{#2}{#3}
      \l__tenkz_geom_bearing_ex_tl \l__tenkz_geom_bearing_ey_tl
      \l__tenkz_geom_bearing_nx_tl \l__tenkz_geom_bearing_ny_tl
    \__tenkz_geom_basis_bearing:nnnnnN
      { \l__tenkz_geom_bearing_ex_tl }
      { \l__tenkz_geom_bearing_ey_tl }
      { \l__tenkz_geom_bearing_nx_tl }
      { \l__tenkz_geom_bearing_ny_tl }
      {#4} #5
  }

% The axes a frame gives a record.  An address on a carrier is read in the
% carrier's axes, and the ink the frame measures is measured in them, so
% every stage that turns ink or resolves a face asks this one question: by
% what angle does the frame turn the axes at this address?  For an affine
% frame the answer is its linear part -- the image of the logical direction
% the flat frame sends to page east, the same everywhere -- and for a circle
% frame it is the tangent, which varies by station.  A shear is not a turn:
% under an affine frame that scales its axes unequally this angle is the
% turn of one axis only, and the other follows the matrix.
\cs_new_protected:Npn \__tenkz_geom_turn:nnnN #1#2#3#4
  {
    \prop_get:NnN \l__tenkz_geom_framekind_prop {#1} \l__tenkz_geom_kind_tl
    \quark_if_no_value:NTF \l__tenkz_geom_kind_tl
      { \msg_error:nnn {tenkz}{geom-unknown-frame} {#1} \tl_set:Nn #4 {0} }
      {
        \str_if_eq:VnTF \l__tenkz_geom_kind_tl {circle}
          {
            \tl_set:Ne #4
              { \fp_eval:n
                  { \__tenkz_geom_station_angle:nn {#1} {#3} - 90 } }
          }
          {
            \tl_set:Ne #4
              { \fp_eval:n
                  { atand( \__tenkz_geom_field:nn{#1}{d} ,
                           \__tenkz_geom_field:nn{#1}{b} ) } }
          }
      }
  }

% compass faces convert once, at the parse boundary
\cs_new:Npn \__tenkz_geom_compass_angle:n #1
  {
    \str_case:nnF {#1}
      { {n}{90} {e}{0} {s}{270} {w}{180} }
      { \msg_expandable_error:nnn {tenkz}{geom-compass} {#1} 0 }
  }
\cs_new_protected:Npn \__tenkz_geom_port_angle:nnN #1#2#3
  { \__tenkz_geom_dir:nnN {#1} { \__tenkz_geom_compass_angle:n {#2} } #3 }

% station k of a circle frame: points sit clockwise from `start`, and the
% travel tangent trails the radius by a quarter turn
\cs_new:Npn \__tenkz_geom_station_angle:nn #1#2
  {
    \__tenkz_geom_field:nn{#1}{start}
    - 360 * ( (#2) - 1 ) / \__tenkz_geom_field:nn{#1}{n}
  }
\cs_new_protected:Npn \__tenkz_geom_station:nnNNN #1#2#3#4#5
  {
    \tl_clear:N #3
    \tl_clear:N #4
    \tl_clear:N #5
    \__tenkz_geom_require_kind:nnnN {#1} {circle} {station}
      \l__tenkz_geom_kind_ok_bool
    \bool_if:NT \l__tenkz_geom_kind_ok_bool
      {
        \tl_set:Ne \l__tenkz_geom_tmp_tl
          { \fp_eval:n { \__tenkz_geom_station_angle:nn {#1} {#2} } }
        \tl_set:Ne #3
          { \fp_eval:n
              { \__tenkz_geom_field:nn{#1}{r} * cosd(\l__tenkz_geom_tmp_tl) } }
        \tl_set:Ne #4
          { \fp_eval:n
              { \__tenkz_geom_field:nn{#1}{r} * sind(\l__tenkz_geom_tmp_tl) } }
        \tl_set:Ne #5
          { \fp_eval:n { \l__tenkz_geom_tmp_tl - 90 } }
      }
  }

% ---------- the placement graph ---------------------------------------------
% A request is stored unresolved and evaluated on demand; evaluation walks
% dependencies depth-first, marks the path, and a revisit names the cycle.
\__tenkz_prop_new_indexed:N \l__tenkz_geom_req_prop    % id -> {type}{arg}{arg}{arg}
\__tenkz_prop_new_indexed:N \l__tenkz_geom_x_prop      % id -> fp literal (pitch units)
\__tenkz_prop_new_indexed:N \l__tenkz_geom_y_prop
\__tenkz_prop_new_indexed:N \l__tenkz_geom_state_prop  % id -> visiting
\seq_new:N  \l__tenkz_geom_trail_seq
\tl_new:N \l__tenkz_geom_xa_tl \tl_new:N \l__tenkz_geom_ya_tl
\bool_new:N \l__tenkz_geom_ok_bool

\cs_new_protected:Npn \__tenkz_geom_reset:
  {
    \prop_clear:N \l__tenkz_geom_frame_prop
    \prop_clear:N \l__tenkz_geom_framekind_prop
    \prop_clear:N \l__tenkz_geom_req_prop
    \prop_clear:N \l__tenkz_geom_x_prop
    \prop_clear:N \l__tenkz_geom_y_prop
    \prop_clear:N \l__tenkz_geom_state_prop
    \seq_clear:N \l__tenkz_geom_trail_seq
  }

\cs_new_protected:Npn \__tenkz_geom_place_cell:nnnn #1#2#3#4
  { \prop_put:Nnn \l__tenkz_geom_req_prop {#1} { {cell}{#2}{#3}{#4} } }
\cs_generate_variant:Nn \__tenkz_geom_place_cell:nnnn { nnee }
\cs_new_protected:Npn \__tenkz_geom_place_rel:nnnn #1#2#3#4
  { \prop_put:Nnn \l__tenkz_geom_req_prop {#1} { {rel}{#2}{#3}{#4} } }
\cs_generate_variant:Nn \__tenkz_geom_place_rel:nnnn { nnee, nVee, neee }
\cs_new_protected:Npn \__tenkz_geom_place_offset:nnnn #1#2#3#4
  { \prop_put:Nnn \l__tenkz_geom_req_prop {#1} { {offset}{#2}{#3}{#4} } }
\cs_generate_variant:Nn \__tenkz_geom_place_offset:nnnn { nVee }
\cs_new_protected:Npn \__tenkz_geom_place_mid:nnn #1#2#3
  { \prop_put:Nnn \l__tenkz_geom_req_prop {#1} { {mid}{#2}{#3}{} } }
\cs_generate_variant:Nn \__tenkz_geom_place_mid:nnn { nVV, nee }
\cs_new_protected:Npn \__tenkz_geom_place_onseg:nnnn #1#2#3#4
  { \prop_put:Nnn \l__tenkz_geom_req_prop {#1} { {onseg}{#2}{#3}{#4} } }

\cs_new_protected:Npn \__tenkz_geom_store:nnn #1#2#3
  {
    \prop_put:Nne \l__tenkz_geom_x_prop {#1} { \fp_eval:n {#2} }
    \prop_put:Nne \l__tenkz_geom_y_prop {#1} { \fp_eval:n {#3} }
    \prop_remove:Nn \l__tenkz_geom_state_prop {#1}
  }

\cs_new_protected:Npn \__tenkz_geom_need:nN #1#2
  {
    \bool_set_false:N #2
    \prop_if_in:NnTF \l__tenkz_geom_x_prop {#1}
      { \bool_set_true:N #2 }
      {
        \prop_if_in:NnTF \l__tenkz_geom_state_prop {#1}
          {
            \msg_error:nne {tenkz}{geom-place-cycle}
              { \seq_use:Nn \l__tenkz_geom_trail_seq { ~->~ } ~->~ #1 }
          }
          {
            \prop_put:Nnn \l__tenkz_geom_state_prop {#1} {visiting}
            \seq_put_right:Nn \l__tenkz_geom_trail_seq {#1}
            \prop_get:NnN \l__tenkz_geom_req_prop {#1} \l__tenkz_geom_tmp_tl
            \quark_if_no_value:NTF \l__tenkz_geom_tmp_tl
              { \msg_error:nnn {tenkz}{geom-unknown-place} {#1} }
              {
                \exp_last_unbraced:NV \__tenkz_geom_eval:nnnnnN
                  \l__tenkz_geom_tmp_tl {#1} #2
              }
            \prop_remove:Nn \l__tenkz_geom_state_prop {#1}
            \seq_pop_right:NN \l__tenkz_geom_trail_seq \l__tenkz_geom_tmp_tl
          }
      }
  }
\cs_new_protected:Npn \__tenkz_geom_need:n #1
  { \__tenkz_geom_need:nN {#1} \l__tenkz_geom_ok_bool }

% #1 type, #2-#4 request args, #5 the id being resolved, #6 success
\cs_new_protected:Npn \__tenkz_geom_eval:nnnnnN #1#2#3#4#5#6
  {
    \str_case:nn {#1}
      {
        {cell}
          {
            \__tenkz_geom_apply:nnnNNN {#2} {#3} {#4}
              \l__tenkz_geom_xa_tl \l__tenkz_geom_ya_tl #6
            \bool_if:NT #6
              {
                \__tenkz_geom_store:nnn {#5}
                  { \l__tenkz_geom_xa_tl } { \l__tenkz_geom_ya_tl }
              }
          }
        {rel}
          {
            \__tenkz_geom_need:nN {#2} #6
            \bool_if:NT #6
              {
                \__tenkz_geom_store:nnn {#5}
                  { \prop_item:Nn \l__tenkz_geom_x_prop {#2} + (#4) * cosd(#3) }
                  { \prop_item:Nn \l__tenkz_geom_y_prop {#2} + (#4) * sind(#3) }
              }
          }
        {offset}
          {
            \__tenkz_geom_need:nN {#2} #6
            \bool_if:NT #6
              {
                \__tenkz_geom_store:nnn {#5}
                  { \prop_item:Nn \l__tenkz_geom_x_prop {#2} + (#3) }
                  { \prop_item:Nn \l__tenkz_geom_y_prop {#2} + (#4) }
              }
          }
        {mid}
          {
            \__tenkz_geom_need:nN {#2} #6
            \bool_if:NT #6
              { \__tenkz_geom_need:nN {#3} #6 }
            \bool_if:NT #6
              {
                \__tenkz_geom_store:nnn {#5}
                  { ( \prop_item:Nn \l__tenkz_geom_x_prop {#2}
                    + \prop_item:Nn \l__tenkz_geom_x_prop {#3} ) / 2 }
                  { ( \prop_item:Nn \l__tenkz_geom_y_prop {#2}
                    + \prop_item:Nn \l__tenkz_geom_y_prop {#3} ) / 2 }
              }
          }
        {onseg}
          {
            \__tenkz_geom_need:nN {#2} #6
            \bool_if:NT #6
              { \__tenkz_geom_need:nN {#3} #6 }
            \bool_if:NT #6
              {
                \__tenkz_geom_store:nnn {#5}
                  { \prop_item:Nn \l__tenkz_geom_x_prop {#2}
                    + (#4) * ( \prop_item:Nn \l__tenkz_geom_x_prop {#3}
                             - \prop_item:Nn \l__tenkz_geom_x_prop {#2} ) }
                  { \prop_item:Nn \l__tenkz_geom_y_prop {#2}
                    + (#4) * ( \prop_item:Nn \l__tenkz_geom_y_prop {#3}
                             - \prop_item:Nn \l__tenkz_geom_y_prop {#2} ) }
              }
          }
      }
  }

\cs_new_protected:Npn \__tenkz_geom_resolve:
  {
    \prop_map_inline:Nn \l__tenkz_geom_req_prop
      { \__tenkz_geom_need:n {##1} }
  }
\cs_new_protected:Npn \__tenkz_geom_xy:nNN #1#2#3
  {
    \__tenkz_geom_need:nN {#1} \l__tenkz_geom_ok_bool
    \bool_if:NTF \l__tenkz_geom_ok_bool
      {
        \prop_get:NnN \l__tenkz_geom_x_prop {#1} #2
        \prop_get:NnN \l__tenkz_geom_y_prop {#1} #3
      }
      { \tl_clear:N #2 \tl_clear:N #3 }
  }

% ---------- silhouettes as support functions --------------------------------
% Wire-endpoint contract for the coming weight vocabulary: each strand of a
% double/bundle wire trims against the support ALONG ITS OWN OFFSET LINE,
% never the centerline -- for a circle of radius r and lateral offset g the
% endpoint sits at (+-g, -sqrt(r^2 - g^2)).  The support query answers this
% generally; no per-shape anchor tables.
% Support of a skin rotated by theta, queried along direction d, in pitch
% units.  Arguments are family, half-extents, corner radius, theta, and d.
% A roundrect caller supplies the radius of the live rendered skin, normalized
% to pitch units; the other families ignore that slot.  Families: circle
% (hx = radius), rect (half-extents), roundrect (rect shrunk by its actual
% corner radius, then re-inflated), triangle (isoceles, apex east: vertex max
% is exact for polygons), triwest (the same isoceles triangle turned half a
% circle about its own centre, so its apex points west).  The half turn is
% one sign: the apex stands at +hx on the record's own east axis and the two
% base corners at -hx, and the triangle is symmetric about that axis, so
% reading the established formula with the apex extent negated IS the
% mirrored silhouette.  One formula therefore serves both orientations, in
% the turned record here and in the affine record below.
\cs_new:Npn \__tenkz_geom_support:nnnnnn #1#2#3#4#5#6
  {
    \str_case:nnF {#1}
      {
        {circle} { (#2) }
        {rect}
          { abs( (#2) * cosd((#6)-(#5)) ) + abs( (#3) * sind((#6)-(#5)) ) }
        {roundrect}
          {
            abs( ( (#2) - \__tenkz_geom_rr:nnn{#4}{#2}{#3} )
              * cosd((#6)-(#5)) )
            + abs( ( (#3) - \__tenkz_geom_rr:nnn{#4}{#2}{#3} )
              * sind((#6)-(#5)) )
            + \__tenkz_geom_rr:nnn{#4}{#2}{#3}
          }
        {triangle}
          {
            max(
              (#2) * cosd((#6)-(#5)) ,
              - (#2) * cosd((#6)-(#5)) + (#3) * sind((#6)-(#5)) ,
              - (#2) * cosd((#6)-(#5)) - (#3) * sind((#6)-(#5)) )
          }
        {triwest}
          { \__tenkz_geom_support:nnnnnn {triangle}{-(#2)}{#3}{#4}{#5}{#6} }
      }
      { \msg_expandable_error:nnn {tenkz}{geom-family} {#1} 0 }
  }
\cs_new:Npn \__tenkz_geom_rr:nnn #1#2#3
  { min( (#1) , (#2) , (#3) ) }

% Radial reach of a rounded rectangle from its centre along bearing #4: the
% distance at which the outgoing ray meets the boundary.  This is the
% port-trim query, distinct from the support fold above -- a support in a
% direction is attained off-ray at a corner, while a wire endpoint stands ON
% the ray.  A flat-side hit divides the half-extent by its direction cosine;
% a cap hit solves the ray's quadratic against the corner disc.  Half-extents
% #1, #2 and corner radius #3 share one unit; the reach lands in #5 in that
% unit.  The side tests keep every division away from a vanishing cosine: a
% ray parallel to a side can only leave through the other pair of sides or a
% cap.
\fp_new:N \l__tenkz_geom_ray_u_fp
\fp_new:N \l__tenkz_geom_ray_v_fp
\fp_new:N \l__tenkz_geom_ray_r_fp
\fp_new:N \l__tenkz_geom_ray_cx_fp
\fp_new:N \l__tenkz_geom_ray_cy_fp
\fp_new:N \l__tenkz_geom_ray_b_fp
\cs_new_protected:Npn \__tenkz_geom_roundrect_ray:nnnnN #1#2#3#4#5
  {
    \fp_set:Nn \l__tenkz_geom_ray_u_fp { abs( cosd(#4) ) }
    \fp_set:Nn \l__tenkz_geom_ray_v_fp { abs( sind(#4) ) }
    \fp_set:Nn \l__tenkz_geom_ray_r_fp { \__tenkz_geom_rr:nnn {#3}{#1}{#2} }
    \fp_set:Nn \l__tenkz_geom_ray_cx_fp
      { (#1) - \l__tenkz_geom_ray_r_fp }
    \fp_set:Nn \l__tenkz_geom_ray_cy_fp
      { (#2) - \l__tenkz_geom_ray_r_fp }
    \fp_compare:nNnTF
      { (#1) * \l__tenkz_geom_ray_v_fp }
      >
      { \l__tenkz_geom_ray_cy_fp * \l__tenkz_geom_ray_u_fp }
      {
        \fp_compare:nNnTF
          { (#2) * \l__tenkz_geom_ray_u_fp }
          >
          { \l__tenkz_geom_ray_cx_fp * \l__tenkz_geom_ray_v_fp }
          {
            \fp_set:Nn \l__tenkz_geom_ray_b_fp
              {
                \l__tenkz_geom_ray_cx_fp * \l__tenkz_geom_ray_u_fp
                + \l__tenkz_geom_ray_cy_fp * \l__tenkz_geom_ray_v_fp
              }
            \fp_set:Nn #5
              {
                \l__tenkz_geom_ray_b_fp
                + sqrt( max( 0 ,
                    \l__tenkz_geom_ray_b_fp ^ 2
                    - \l__tenkz_geom_ray_cx_fp ^ 2
                    - \l__tenkz_geom_ray_cy_fp ^ 2
                    + \l__tenkz_geom_ray_r_fp ^ 2 ) )
              }
          }
          { \fp_set:Nn #5 { (#2) / \l__tenkz_geom_ray_v_fp } }
      }
      { \fp_set:Nn #5 { (#1) / \l__tenkz_geom_ray_u_fp } }
  }

% Support of one primitive transported through a complete east/north basis.
% This is a distinct contract from the scalar-turn function above: an affine
% support record is
%   {affine}{family}{hx}{hy}{radius}{ex}{ey}{nx}{ny}{cx}{cy}.
% The explicit tag keeps a basis out of the rotated record's angle slot and
% lets one obstacle sequence contain established rotated and affine shapes.
\cs_new:Npn \__tenkz_geom_support_basis:nnnnnnnnn
    #1#2#3#4#5#6#7#8#9
  {
    \str_case:enF {#1}
      {
        {circle}
          {
            (#2) * sqrt(
              ( (#5) * cosd(#9) + (#6) * sind(#9) ) ^ 2
              + ( (#7) * cosd(#9) + (#8) * sind(#9) ) ^ 2 )
          }
        {rect}
          {
            abs( (#2) * ( (#5) * cosd(#9) + (#6) * sind(#9) ) )
            + abs( (#3) * ( (#7) * cosd(#9) + (#8) * sind(#9) ) )
          }
        {roundrect}
          {
            abs(
              ( (#2) - \__tenkz_geom_rr:nnn{#4}{#2}{#3} )
              * ( (#5) * cosd(#9) + (#6) * sind(#9) ) )
            + abs(
                ( (#3) - \__tenkz_geom_rr:nnn{#4}{#2}{#3} )
                * ( (#7) * cosd(#9) + (#8) * sind(#9) ) )
            + \__tenkz_geom_rr:nnn{#4}{#2}{#3}
              * sqrt(
                  ( (#5) * cosd(#9) + (#6) * sind(#9) ) ^ 2
                  + ( (#7) * cosd(#9) + (#8) * sind(#9) ) ^ 2 )
          }
        {triangle}
          {
            max(
              (#2) * ( (#5) * cosd(#9) + (#6) * sind(#9) ) ,
              - (#2) * ( (#5) * cosd(#9) + (#6) * sind(#9) )
                + (#3) * ( (#7) * cosd(#9) + (#8) * sind(#9) ) ,
              - (#2) * ( (#5) * cosd(#9) + (#6) * sind(#9) )
                - (#3) * ( (#7) * cosd(#9) + (#8) * sind(#9) ) )
          }
        {triwest}
          {
            \__tenkz_geom_support_basis:nnnnnnnnn
              {triangle}{-(#2)}{#3}{#4}{#5}{#6}{#7}{#8}{#9}
          }
      }
      { \msg_expandable_error:nnn {tenkz}{geom-family} {#1} 0 }
  }

% fold max-support over {family}{hx}{hy}{radius}{theta}{cx}{cy} records:
% each contributes its centre's reach plus its shape's support
\cs_new_protected:Npn \__tenkz_geom_support_max:nnN #1#2#3
  {
    \tl_set:Nn #3 { -10000 }
    \seq_map_inline:cn {#1}
      { \__tenkz_geom_support_max_record:nnN {##1} {#2} #3 }
  }
\cs_new_protected:Npn \__tenkz_geom_support_max_record:nnN #1#2#3
  { \__tenkz_geom_support_max_record_aux:w #1 \q_stop {#2} #3 }
\cs_new_protected:Npn \__tenkz_geom_support_max_record_aux:w
    #1#2\q_stop #3#4
  {
    \str_if_eq:nnTF {#1}{affine}
      { \__tenkz_geom_support_max_affine_setup:w #2 \q_stop {#3} #4 }
      { \__tenkz_geom_support_max_one:nnnnnnnnN {#1} #2 {#3} #4 }
  }
\cs_new_protected:Npn \__tenkz_geom_support_max_one:nnnnnnnnN
    #1#2#3#4#5#6#7#8#9
  {
    \tl_set:Ne #9
      { \fp_eval:n
          { max( #9 ,
                 (#6) * cosd(#8) + (#7) * sind(#8)
                 + \__tenkz_geom_support:nnnnnn
                     {#1}{#2}{#3}{#4}{#5}{#8} ) } }
  }
\tl_new:N \l__tenkz_geom_support_family_tl
\tl_new:N \l__tenkz_geom_support_hx_tl
\tl_new:N \l__tenkz_geom_support_hy_tl
\tl_new:N \l__tenkz_geom_support_radius_tl
\tl_new:N \l__tenkz_geom_support_shape_tl
\cs_new_protected:Npn \__tenkz_geom_support_max_affine_setup:w
    #1#2#3#4#5\q_stop #6#7
  {
    \tl_set:Nn \l__tenkz_geom_support_family_tl {#1}
    \tl_set:Nn \l__tenkz_geom_support_hx_tl {#2}
    \tl_set:Nn \l__tenkz_geom_support_hy_tl {#3}
    \tl_set:Nn \l__tenkz_geom_support_radius_tl {#4}
    \__tenkz_geom_support_max_affine_finish:w #5 \q_stop {#6} #7
  }
\cs_new_protected:Npn \__tenkz_geom_support_max_affine_finish:w
    #1#2#3#4#5#6\q_stop #7#8
  {
    \tl_set:Ne #8
      {
        \fp_eval:n
          {
            max(
              #8 ,
              (#5) * cosd(#7) + (#6) * sind(#7)
              + \__tenkz_geom_support_basis:nnnnnnnnn
                  { \l__tenkz_geom_support_family_tl }
                  { \l__tenkz_geom_support_hx_tl }
                  { \l__tenkz_geom_support_hy_tl }
                  { \l__tenkz_geom_support_radius_tl }
                  {#1}{#2}{#3}{#4}{#7} )
          }
      }
  }

% Fold support against an arbitrary page covector (gx,gy).  The result is in
% the covector's coordinate units, so a dual-basis row returns an exact local
% coordinate even when the carrier is sheared or scaled.
\tl_new:N \l__tenkz_geom_support_gx_tl
\tl_new:N \l__tenkz_geom_support_gy_tl
\cs_new_protected:Npn \__tenkz_geom_support_max_linear:nnnN #1#2#3#4
  {
    \tl_set:Nn \l__tenkz_geom_support_gx_tl {#2}
    \tl_set:Nn \l__tenkz_geom_support_gy_tl {#3}
    \tl_set:Nn #4 { -10000 }
    \seq_map_inline:cn {#1}
      {
        \__tenkz_geom_support_max_linear_record:nN {##1} #4
      }
  }
\cs_new_protected:Npn \__tenkz_geom_support_max_linear_record:nN #1#2
  { \__tenkz_geom_support_max_linear_record_aux:w #1 \q_stop #2 }
\cs_new_protected:Npn \__tenkz_geom_support_max_linear_record_aux:w
    #1#2\q_stop #3
  {
    \str_if_eq:nnTF {#1}{affine}
      { \__tenkz_geom_support_max_linear_affine_setup:w #2 \q_stop #3 }
      { \__tenkz_geom_support_max_linear_one:nnnnnnnN {#1} #2 #3 }
  }
\cs_new_protected:Npn \__tenkz_geom_support_max_linear_one:nnnnnnnN
    #1#2#3#4#5#6#7#8
  {
    \tl_set:Ne #8
      {
        \fp_eval:n
          {
            max(
              #8 ,
              (#6) * \l__tenkz_geom_support_gx_tl
                + (#7) * \l__tenkz_geom_support_gy_tl
              + sqrt(
                  ( \l__tenkz_geom_support_gx_tl ) ^ 2
                  + ( \l__tenkz_geom_support_gy_tl ) ^ 2 )
                * \__tenkz_geom_support:nnnnnn
                    {#1}{#2}{#3}{#4}{#5}
                    {
                      atand(
                        \l__tenkz_geom_support_gy_tl ,
                        \l__tenkz_geom_support_gx_tl )
                    }
            )
          }
      }
  }
\cs_new_protected:Npn \__tenkz_geom_support_max_linear_affine_setup:w
    #1#2#3#4#5\q_stop #6
  {
    \tl_set:Nn \l__tenkz_geom_support_family_tl {#1}
    \tl_set:Nn \l__tenkz_geom_support_hx_tl {#2}
    \tl_set:Nn \l__tenkz_geom_support_hy_tl {#3}
    \tl_set:Nn \l__tenkz_geom_support_radius_tl {#4}
    \__tenkz_geom_support_max_linear_affine_finish:w #5 \q_stop #6
  }
\cs_new_protected:Npn \__tenkz_geom_support_max_linear_affine_finish:w
    #1#2#3#4#5#6\q_stop #7
  {
    \tl_set:Ne \l__tenkz_geom_support_shape_tl
      {
        \fp_eval:n
          {
            \__tenkz_geom_support_basis:nnnnnnnnn
              { \l__tenkz_geom_support_family_tl }
              { \l__tenkz_geom_support_hx_tl }
              { \l__tenkz_geom_support_hy_tl }
              { \l__tenkz_geom_support_radius_tl }
              {#1}{#2}{#3}{#4}
              {
                atand(
                  \l__tenkz_geom_support_gy_tl ,
                  \l__tenkz_geom_support_gx_tl )
              }
          }
      }
    \tl_set:Ne #7
      {
        \fp_eval:n
          {
            max(
              #7 ,
              (#5) * \l__tenkz_geom_support_gx_tl
                + (#6) * \l__tenkz_geom_support_gy_tl
              + sqrt(
                  ( \l__tenkz_geom_support_gx_tl ) ^ 2
                  + ( \l__tenkz_geom_support_gy_tl ) ^ 2 )
                * \l__tenkz_geom_support_shape_tl
            )
          }
      }
  }

% ---------- probes -----------------------------------------------------------
% Test fixtures assert geometry through the event stream; six decimals keep
% ordinary coordinates stable.  A signed decision margin retains nine so a
% strict comparison remains visible when its two displayed operands coincide.
\cs_new_protected:Npn \__tenkz_geom_probe:nn #1#2
  { \tenkz@event{geomprobe|id=#1|#2} }
\cs_new:Npn \__tenkz_geom_round:n #1
  { \fp_eval:n { round( #1 , 6 ) } }
\cs_new:Npn \__tenkz_geom_round_margin:n #1
  { \fp_eval:n { round( #1 , 9 ) } }

\ExplSyntaxOff
\endinput
