Web Path Geometry

Problem Statement

A web-handling system routes a continuous strip of material (paper, film, foil) through a sequence of cylindrical rollers. In the geometric model each roller is a circle — center $C_i = (x_i, y_i)$, radius $r_i$ — and the web is a continuous path consisting of straight segments tangent to consecutive rollers, connected by circular arcs where the web wraps around each roller.

The core tasks are:

  1. Given a fixed threading sequence and roller positions, compute the exact web path: tangent segments, contact points, and wrap arcs.
  2. As rollers move, detect when a roller disengages (its wrap arc goes to zero or negative) and re-route the web directly between its neighbors.
  3. Detect re-engagement when a disengaged roller returns to a position where it would again intercept the web.

This document covers the geometry and algorithms for tasks 1 and 2. Task 3 is left for a future iteration.

Common Tangent Lines

A common tangent to two circles is a line simultaneously tangent to both. For two circles with centers $C_1, C_2$ and radii $r_1, r_2$ at distance $d = |C_2 - C_1|$, there are up to four such lines.

Families

Let $\hat{u} = (C_2 - C_1)/d$ be the unit vector along the line of centers, and $\hat{v} = \hat{u}^{\perp}$ its CCW perpendicular. The tangent normal $\hat{n}$ satisfies:

$$\hat{n} = \rho\,\hat{u} + \sigma\sqrt{1-\rho^2}\,\hat{v}, \qquad \sigma \in \{+1, -1\}$$

where:

$$\rho = \frac{p_1 r_1 - p_2 r_2}{d}$$

The polarity signs $p_i \in \{+1,-1\}$ encode which side of the roller the web contacts (see §4). When $p_1 = p_2$, this gives an external (non-crossing) tangent. When $p_1 \neq p_2$, an internal (crossing) tangent.

The tangent points are:

$$T_1 = C_1 + r_1\,\hat{n}, \qquad T_2 = C_2 + r_2\,\hat{n}$$

The two branches ($\sigma = \pm 1$) correspond to the two tangent lines of the given family. The correct branch is selected by the branch propagation algorithm in §6.

If $|\rho| > 1$, no real tangent exists — the circles are too large relative to their separation for this polarity combination. This is a secondary disengagement condition: a roller can disengage not only by crossing the chord between its neighbors, but also by growing large enough that no tangent can connect it to a neighbor.

Degeneracies

ConditionMeaningHandling
$d = 0$Coincident centersReturn null; diagnostic
$|\rho| > 1$No real tangentReturn null; diagnostic
$\rho = \pm 1$ ($\cos A = 0$)Single tangent (circles tangent to each other)Both branches coincide; valid

Centers of Similitude

The two families of tangent lines each pass through a fixed point on the line of centers called a center of similitude (or homothetic center).

$$S_\text{ext} = \frac{r_2\,C_1 - r_1\,C_2}{r_2 - r_1} \qquad (r_1 \neq r_2)$$ $$S_\text{int} = \frac{r_2\,C_1 + r_1\,C_2}{r_1 + r_2}$$

All external tangent lines pass through $S_\text{ext}$; all internal tangents through $S_\text{int}$. When $r_1 = r_2$, external tangents are parallel and $S_\text{ext}$ is the point at infinity in the direction of $\hat{u}^{\perp}$.

Monge's Theorem. For any three circles, the three external centers of similitude are collinear. More generally, the six centers of similitude (three external, three internal) of three circles lie three-by-three on four lines, one of which contains all three $S_\text{ext}$ points.

Polarity and Threading

The threading of the web through the roller sequence determines which of the four common tangents is the active one for each consecutive pair. We encode this choice in a per-roller scalar called polarity.

Definition

Polarity is purely topological — it does not reference gravity or any global orientation. For interior roller $i$ with predecessor $C_{i-1}$ and successor $C_{i+1}$:

$$p_i = \text{sign}\!\left[(C_i - C_{i-1}) \times (C_{i+1} - C_{i-1})\right]$$

where $\times$ is the 2D cross product $(a_x b_y - a_y b_x)$. This is the sign of the signed area of the triangle $C_{i-1} \to C_i \to C_{i+1}$.

  • +1 — roller center is to the left of the chord $C_{i-1} \to C_{i+1}$ (CCW side)
  • −1 — roller center is to the right (CW side)

Properties

Polarity is inferred once at threading time from the initial roller positions, and never automatically updated when rollers move. This reflects the physical reality: once the web is threaded, the contact side on each roller is determined by the threading, not by subsequent motion.

Terminal rollers (first and last in the sequence) have no neighbors on one side, so they have no wrap angle. They inherit the polarity of their one interior neighbor, so the tangent formula always receives a real $\pm 1$ on both ends with no special-casing.

User control. The UI allows manual flipping of any roller's polarity, which immediately changes the tangent family for adjacent segments and may trigger re-engagement or disengagement. This user-driven change is distinct from automatic polarity updates — the user's choice is respected across all subsequent frames.

Degeneracy. If $p_i = 0$ at threading time (three consecutive roller centers are collinear), polarity is undefined. This configuration should be rejected with a diagnostic. At runtime, $\sigma_i = 0$ is treated as the disengagement threshold.

Wrap Angle

At interior roller $i$, the web arrives at the incoming tangent point $T_\text{in}$ (from segment $i-1$) and departs from the outgoing tangent point $T_\text{out}$ (to segment $i$). The wrap angle is the arc swept from $T_\text{in}$ to $T_\text{out}$ around the roller:

$$\theta_i = \text{sweep}(\alpha_\text{in},\, \alpha_\text{out},\, p_i)$$

where $\alpha = \text{atan2}(T - C_i)$ and $\text{sweep}$ measures the arc in the CCW direction for $p_i = +1$, CW for $p_i = -1$. The sweep direction always uses the frozen threading polarity $p_i$, regardless of the current live cross-product sign $\sigma_i$. This ensures consistent wrap direction even when polarity changes due to user interaction.

The engagement condition is:

$$0 < \theta_i < \pi$$

This enforces the minor-arc constraint: the web contacts the roller over less than a semicircle. Larger wrap angles indicate a branch error (the algorithm has selected the wrong tangent branch); zero or negative wrap indicates the roller should disengage.

Branch Selection Algorithm

Each tangent segment between two active rollers $(C_i, C_j)$ has two candidate branches ($\sigma = \pm 1$). The branches are computed independently per pair, so a naive approach can produce inconsistent normals at shared rollers — the visual crossing defect we observed.

The correct approach propagates the branch through the chain:

Algorithm

Segment 0 (C₀ → C₁):
  Compute both branches.
  Pick the branch where T₁ lands on the p₀ side of C₀.
  (This is the polarity anchor — the only place p is used directly for selection.)

Segment k > 0 (Cₖ → Cₖ₊₁):
  Compute both branches.
  For each branch b:
    aOut = atan2(b.T1 - Cₖ)
    aIn  = atan2(prevSeg.T2 - Cₖ)
    θ    = sweepAngle(aIn, aOut, pₖ)
    onCorrectSide = (b.n̂·v̂_seg < 0)

  Pick the branch with θ < π AND onCorrectSide.
  If no valid branch → Cₖ disengages.
      

This unifies branch selection and disengagement detection into a single criterion. The two conditions ($\theta < \pi$ and polarity-independent `onCorrectSide`) simultaneously confirm that the correct branch was selected and that the roller is genuinely engaged.

Convergence

When a roller disengages, we remove it from the active list and restart the chain computation from segment 0. Each restart removes at least one roller. With at most $n-2$ interior rollers, the algorithm converges in at most $n-2$ iterations. In practice, one or two passes suffice.

Disengagement

Roller $i$ disengages when it can no longer contribute a valid minor-arc wrap. There are two geometric conditions:

  1. Cross-product sign flip (same-polarity neighbors): When all three active rollers (prev, current, next) share the same polarity ($p_\text{prev} = p_i = p_\text{next}$), the live sigma $\sigma_i = \text{sign}[(C_i - C_\text{prev}) \times (C_\text{next} - C_\text{prev})]$ is compared directly to $p_i$. If they disagree, the roller is ejected immediately — the chord between neighbors is a reliable proxy for the tangent line boundary in this all-same-polarity case.
  2. Mixed-polarity neighbors: When the roller's polarity differs from its same-polarity neighbors ($p_\text{prev} = p_\text{next} \neq p_i$), the chord test is skipped. The branch selection algorithm ($\theta < \pi$ + `onCorrectSide`) handles disengagement naturally at the correct tangent line boundary.
  3. No real tangent ($|\rho| > 1$): the roller's radius is too large relative to its distance from a neighbor for any tangent to exist. Geometrically, the roller overlaps the region between its neighbors.

These conditions are caught by the branch propagation algorithm: if no branch gives $\theta < \pi$ and passes `onCorrectSide`, the roller is removed from the active set regardless of which condition triggered it.

Cascade

Removing a disengaged roller changes the effective neighbors of adjacent rollers, which may cause them to also fail the engagement check. The algorithm handles this by restarting after each removal — the cascade is resolved automatically over successive iterations.

Re-engagement (future)

A disengaged roller re-engages when its center crosses back across the chord between its current active neighbors. The detection is a half-plane test: compute the signed distance of $C_i$ from the line through the two active neighbors. When the sign matches $p_i$, the roller is eligible to re-enter the active set. After re-insertion, branch propagation is rerun to confirm consistency.

Data Structures

Roller Record

{
  x, y:     number    // center coordinates — mutable
  r:        number    // radius — mutable
  name:     string    // display label
  p:        ±1        // polarity — fixed at threading time
  terminal: boolean   // true for first/last — UI display only
  engaged:  boolean   // computed fresh each frame by computePath()
}
      

Segment Record

{
  T1: { x, y }   // tangent point on the first roller
  T2: { x, y }   // tangent point on the second roller
  nx, ny: number // unit normal components
}
      

computePath() Return Value

{
  activeIdx: number[]         // indices of engaged rollers in sequence order
  segs:      (Segment|null)[] // segs[k]: segment from activeIdx[k] to activeIdx[k+1]
  wraps:     (number|null)[]  // wraps[k]: wrap angle at activeIdx[k] (radians)
  sigmas:    (number|null)[]  // sigmas[k]: live cross-product sign
  engaged:   boolean[]        // engaged[i] for every roller
  diags:     string[]         // diagnostic messages
}
      

All arrays in the return value are indexed by active position $k$, not by roller index $i$. The mapping $k \to i$ is activeIdx[k].

Terminology note: "Engaged" means the roller physically contacts the web in the current frame. "Active" refers to the ordered subsequence of engaged rollers. The `activeIdx` array maps from active position $k$ to roller index $i$. The `engaged` array is indexed by roller index and indicates which rollers are globally engaged.

Active List

Currently a flat array (O(n) operations). For large systems the natural upgrade is a doubly-linked list (O(1) insert/remove). For systems of hundreds of rollers with frequent updates, an advanced data structure may be needed — but for typical web-handling systems (5–20 rollers), the array is sufficient.