Code
viewof cross_theta_deg = Inputs.range([0, 89], {value: 25, step: 1, label: "θ (degrees)"})Domain Partitioning via Cross Fields
A PDE based approach to multi-domain partitioning and quadrilateral meshing — Kowalski, Ledoux, Frey (2012)
21st Int. Meshing Roundtable, Sandia Natl. Labs.
This page walks through every section of the paper in detail. The core idea: propagate boundary geometric information into the domain interior by solving a PDE, producing a smooth cross field whose topological structure (singularities and separatrices) partitions the domain into four-sided regions — each meshable by structured transfinite interpolation.
Try the interactive NACA airfoil demo
“In numerous computational engineering applications, such as automobile crash simulations, structural mechanics, neutronics or fluid-structure interactions, quadrilateral meshes are preferred over triangular meshes.”
The paper identifies four desirable properties of quad meshes:
For multi-domain simulations (e.g., fluid-structure interaction), a fifth property is conformity along material interfaces.
“Block-structured meshes have one main advantage over other kinds of meshes: they can theoretically adapt to any given domain while maintaining the good element quality associated with structured meshes.”
The key insight is that block-structured meshes combine the best of both worlds:
The challenge: how to automatically partition an arbitrary domain into four-sided regions? Previous attempts using the medial axis are numerically unstable and don’t produce four-sided regions (even a square gets triangular parts).
The paper’s approach has four stages:
“The main idea of our method consists in using a unit vector field prescribed on \(\partial\Omega\) that we propagate on the whole \(\Omega\).”
A regular vertex \(P\) of a quad mesh is 4-valent. The four edges form two sets of opposite edges, each tracing a curve. The two curves intersect orthogonally at \(P\). Their tangent directions form a cross — the directionality of the mesh at \(P\).
A cross is a set of 4 unit vectors related by \(\pi/2\) rotations, fully described by a single angle \(\theta\):
\[ C_\theta = \left\{ \mathbf{v}_k = \left(\cos\!\left(\theta + \frac{k\pi}{2}\right), \sin\!\left(\theta + \frac{k\pi}{2}\right)\right)^T,\; 0 \le k \le 3 \right\}, \quad \theta \in \left[0, \frac{\pi}{2}\right). \]
The representation vector is \(\mathbf{u} = (\cos\theta, \sin\theta)^T\). This is not one of the cross arms — it encodes which of the infinitely many equivalent descriptions we pick.
{
const w = 420, h = 420, cx = w/2, cy = h/2, r = 130;
const theta = cross_theta_deg * Math.PI / 180;
const svg = d3.create("svg").attr("viewBox", `0 0 ${w} ${h}`).style("width", "100%").style("max-width", w + "px");
// Unit circle
const circ = d3.range(0, 2*Math.PI + 0.01, 0.02).map(a => [cx + r*Math.cos(a), cy - r*Math.sin(a)]);
svg.append("path").attr("d", d3.line()(circ)).attr("fill", "none").attr("stroke", "#ddd").attr("stroke-width", 1);
// Axes
svg.append("line").attr("x1", cx - r - 15).attr("y1", cy).attr("x2", cx + r + 15).attr("y2", cy).attr("stroke", "#eee");
svg.append("line").attr("x1", cx).attr("y1", cy - r - 15).attr("x2", cx).attr("y2", cy + r + 15).attr("stroke", "#eee");
// Cross arms
const armColors = ["steelblue", "#e74c3c", "steelblue", "#e74c3c"];
const armLabels = ["v0", "v1", "v2", "v3"];
for (let k = 0; k < 4; k++) {
const a = theta + k * Math.PI / 2;
const dx = r * Math.cos(a), dy = -r * Math.sin(a);
svg.append("line").attr("x1", cx).attr("y1", cy).attr("x2", cx + dx).attr("y2", cy + dy)
.attr("stroke", armColors[k]).attr("stroke-width", 2.5).attr("stroke-linecap", "round");
// Arrowhead
const hl = 10, ha = 0.4;
const ba = Math.atan2(-dy, dx) + Math.PI;
svg.append("polygon")
.attr("points", `${cx+dx},${cy+dy} ${cx+dx+hl*Math.cos(ba+ha)},${cy+dy-hl*Math.sin(ba+ha)} ${cx+dx+hl*Math.cos(ba-ha)},${cy+dy-hl*Math.sin(ba-ha)}`)
.attr("fill", armColors[k]);
svg.append("text").attr("x", cx + 1.15*dx).attr("y", cy + 1.15*dy)
.attr("text-anchor", "middle").attr("dominant-baseline", "middle")
.attr("font-size", 13).attr("fill", armColors[k]).text(armLabels[k]);
}
// Representation vector (orange, thicker)
const repDx = r * 0.75 * Math.cos(theta), repDy = -r * 0.75 * Math.sin(theta);
svg.append("line").attr("x1", cx).attr("y1", cy).attr("x2", cx + repDx).attr("y2", cy + repDy)
.attr("stroke", "orange").attr("stroke-width", 4).attr("stroke-dasharray", "6,3");
svg.append("text").attr("x", cx + repDx * 1.2).attr("y", cy + repDy * 1.2)
.attr("text-anchor", "middle").attr("font-size", 14).attr("fill", "orange").attr("font-weight", "bold").text("u");
// Angle arc
if (theta > 0.02) {
const arcPts = d3.range(0, theta + 0.01, 0.02).map(a => [cx + 35*Math.cos(a), cy - 35*Math.sin(a)]);
svg.append("path").attr("d", d3.line()(arcPts)).attr("fill", "none").attr("stroke", "orange").attr("stroke-width", 2);
svg.append("text").attr("x", cx + 48*Math.cos(theta/2)).attr("y", cy - 48*Math.sin(theta/2))
.attr("text-anchor", "middle").attr("font-size", 14).attr("fill", "orange").text("θ");
}
svg.append("text").attr("x", cx).attr("y", 18).attr("text-anchor", "middle").attr("font-size", 15).attr("fill", "#333")
.text(`Cross (θ = ${cross_theta_deg}°) with representation vector u`);
return svg.node();
}Key property: the cross field is independent of the reference axis. Rotating the coordinate system by \(\theta_0\) rotates each cross by \(-\theta_0\), and each representation vector by \(-4\theta_0\) (because of the 4-fold symmetry), leaving the problem globally unchanged.
“An important point to notice is that the cross field is not related to the reference axis used to compute angles.”
The goal is to smoothly propagate boundary orientation information into the interior. This is analogous to the steady-state of a conduction (heat) problem:
\[ \frac{\partial \mathbf{u}}{\partial t} - \text{div}(k\nabla \mathbf{u}) = 0 \]
With \(k\) constant, at steady state we get the Laplace equation. Minimizing the Dirichlet energy gives us the smoothest possible field:
\[ \begin{cases} J(\mathbf{u}) = \int_\Omega |\nabla \mathbf{u}|^2\, dx, \\ \mathbf{u}(x) = \mathbf{u}_0(x) \quad \forall x \in \partial\Omega. \end{cases} \tag{Problem 3} \]
where \(\mathbf{u}\) is the representation vector field and \(\mathbf{u}_0\) is the Dirichlet boundary condition — the representation vector of the cross defined by tangent and normal directions at each boundary point.
Boundary conditions: At smooth boundary points, the cross is aligned with the tangent/normal. At \(C^0\) corners (sharp features), the representation vector is defined as the average of the two incident geometric edges’ representation vectors.
Problem 3 is a standard elliptic PDE — easy to solve. However, the solution may produce vectors with varying norms. Since only directions matter (not magnitudes), we add a non-linear constraint:
\[ \begin{cases} J(\mathbf{u}) = \int_\Omega |\nabla \mathbf{u}|^2\, dx, \\ \mathbf{u}(x) = \mathbf{u}_0(x) \quad \forall x \in \partial\Omega, \\ |\mathbf{u}(x)| = 1 \quad \forall x \in \Omega. \end{cases} \tag{Problem 4} \]
“Unlike Problem 2, Problem 4 is ill-posed, as a solution may not exist.”
The unit norm constraint prevents the field from having zeros. But the unconstrained solution may have zeros (where the field “wants” to vanish — these become singularities). The paper handles this through a two-step approach.
First, ignore the norm constraint and solve Problem 3. Splitting \(\mathbf{u} = \tilde{\mathbf{u}} + \mathbf{u}_0\), where \(\tilde{\mathbf{u}}\) vanishes on \(\partial\Omega\), the weak form is:
\[ \forall \mathbf{v} \in V, \quad \int_\Omega \nabla\tilde{\mathbf{u}} \nabla\mathbf{v}\, dx = -\int_\Omega \nabla\mathbf{u}_0 \nabla\mathbf{v}\, dx \]
with \(V = H^1_0(\Omega)\). The Lax-Milgram theorem guarantees existence and uniqueness.
Discretization: Galerkin FEM on the triangulation \(T_h\) with \(P_1\)-Lagrange elements (piecewise linear). This yields a symmetric positive-definite linear system \(Ax = b\), solved by conjugate gradient.
This is exactly what the “Solve linear” button does in the NACA airfoil demo — it solves the graph Laplacian (the discrete analog of the Dirichlet energy minimization) with Dirichlet conditions at constrained boundary vertices. The Gauss-Seidel iteration used there is equivalent to solving \(Ax = b\) iteratively.
The linear solution from Sec. 2.3 gives a good direction field, but vectors may not have unit norm. The paper refines this through an iterative process — alternating between a PDE solve and normalization.
At each step \(n\), given a current solution \(\mathbf{v}^n\) with \(|\mathbf{v}^n(p)| = 1\) at each mesh point \(p\), we add a linear constraint for step \(n+1\):
\[ \mathbf{v}^{n+1}(p) \cdot \mathbf{v}^n(p) = 1 \]
This is a linear approximation of \(|\mathbf{v}^{n+1}| = 1\) near the current solution. Using Lagrange multipliers \(\lambda_p\), this leads to a saddle-point system:
\[ \begin{pmatrix} A & C \\ C & 0 \end{pmatrix} \begin{pmatrix} u \\ \lambda \end{pmatrix} = \begin{pmatrix} b \\ 1 \end{pmatrix} \]
where \(C\) is a diagonal matrix encoding the constraint \(\mathbf{v}^{n+1} \cdot \mathbf{v}^n = 1\) at each interior point, with entries \(C(2p, 2p) = \alpha\) and \(C(2p+1, 2p+1) = \beta\) for \(\mathbf{v}^n(p) = (\alpha, \beta)\).
Algorithm: Solve the constrained system, then normalize all vectors to unit length. This guarantees \(|\mathbf{v}^{n+1}| \geq 1\) (the constraint ensures the component along \(\mathbf{v}^n\) is 1, so the full vector is at least unit length). Normalization then reduces \(J\). Repeat until convergence.
“The triangulation \(T_h\) that we use to solve the Problem 4 has no significative influence on the structure of the generated directionality field with regard to the convergence and the stability of the procedure.”
“The general idea of our approach consists in analyzing the topology of this cross field. This means identifying its singularities, or zeroes, and connecting them, thus emphasizing its underlying structure.”
At this stage we have a smooth, unit-length cross field \(CF\) over the triangulation. The next step is to extract its topological skeleton — singularities connected by separatrices — which partitions the domain.
Definition: A point \(x \in \Omega\) where \(\mathbf{v}(x) = 0\) is a singularity of the representation vector field. In practice, these are detected as triangles where the piecewise-linear interpolation of \(\mathbf{v}\) passes through zero.
The Poincare index measures the “topological charge” of a singularity:
\[ i_x = \frac{1}{2\pi} \oint_\gamma d\phi, \quad \text{with } \phi = \arctan\frac{v_1}{v_2}. \]
This counts the number of full rotations the vector field makes as you traverse a closed curve \(\gamma\) around the singularity.
Proposition 1: For a piecewise-linear vector field defined at triangle vertices with all non-zero values, all singularities are of first order — their index is either \(+1\) or \(-1\).
{
const w = 400, h = 400, cx = w/2, cy = h/2;
const svg = d3.create("svg").attr("viewBox", `0 0 ${w} ${h}`).style("width", "100%").style("max-width", w + "px");
const isPos = singType.startsWith("index +1");
const valence = isPos ? 3 : 5;
const indexVal = isPos ? +1 : -1;
// Draw crosses around the singularity
const nCrosses = 12;
const ringR = 140;
const crossR = 22;
// Singularity marker
svg.append("circle").attr("cx", cx).attr("cy", cy).attr("r", 6).attr("fill", "#e74c3c");
svg.append("text").attr("x", cx).attr("y", cy - 15).attr("text-anchor", "middle")
.attr("font-size", 13).attr("fill", "#e74c3c").attr("font-weight", "bold")
.text(`index = ${indexVal > 0 ? "+" : ""}${indexVal}`);
for (let i = 0; i < nCrosses; i++) {
const phi = 2 * Math.PI * i / nCrosses;
const px = cx + ringR * Math.cos(phi);
const py = cy - ringR * Math.sin(phi);
// Cross orientation: for index +1, the cross rotates by +2pi going around once
// In rep vector space: angle = indexVal * phi (for the cross field, index of rep vec = 4*index of cross)
// For a +1 singularity of the cross field, rep vector index = +1, so rep rotates by 2pi
// Cross itself rotates by pi/2 going around once
const crossAngle = indexVal * phi / 4 + Math.PI / 8;
for (let k = 0; k < 4; k++) {
const a = crossAngle + k * Math.PI / 2;
svg.append("line")
.attr("x1", px + crossR * Math.cos(a)).attr("y1", py - crossR * Math.sin(a))
.attr("x2", px - crossR * Math.cos(a)).attr("y2", py + crossR * Math.sin(a))
.attr("stroke", k % 2 === 0 ? "steelblue" : "#888")
.attr("stroke-width", 1.8).attr("stroke-linecap", "round");
}
}
// Rotation arrow showing accumulated angle
const arrowR = ringR + 35;
const arcPts = d3.range(0, 1.8 * Math.PI, 0.02).map(a => [cx + arrowR * Math.cos(a), cy - arrowR * Math.sin(a)]);
svg.append("path").attr("d", d3.line()(arcPts)).attr("fill", "none").attr("stroke", "#999").attr("stroke-width", 1).attr("stroke-dasharray", "4,3");
svg.append("text").attr("x", cx).attr("y", h - 10).attr("text-anchor", "middle").attr("font-size", 14).attr("fill", "#333")
.text(isPos ? "3 separatrices (valence 3)" : "5 separatrices (valence 5)");
return svg.node();
}The connection between representation vector field singularities and cross field structure:
A streamline of a vector field \(\mathbf{v}\) is a curve \(\gamma(s)\) whose tangent everywhere matches the field:
\[ \forall\, s \in [0,1],\quad \partial\gamma(s) \times \mathbf{v}(\gamma(s)) = 0 \]
For cross fields, a streamline can follow any of the 4 arms of the cross at each point:
\[ \forall\, s \in [0,1],\; \exists\, i \in [1..4],\quad \partial\gamma(s) \times \mathbf{v}_i(\gamma(s)) = 0 \]
A separatrix is a special streamline that ends at a singularity: \(\exists\, t \in [0,1],\; \gamma(t) = S_0\) where \(S_0\) is a singularity.
To trace a separatrix from singularity \(S_0\) located in triangle \(T_0\):
\[ \exists\, k \in [0..3],\quad \frac{\mathbf{v}_k}{|\mathbf{v}_k|} = \frac{\mathbf{u}(P)}{|\mathbf{u}(P)|} \]
The Streamlines visualization in the NACA airfoil demo traces cross field streamlines using exactly this approach — Euler integration through the mesh, selecting the closest cross arm at each step for direction continuity.
Combining the separatrices from all singularities (field singularities + geometric singularities at boundary corners) produces a partition of \(\Omega\).
“Every separatrix ends either on a singularity or on \(\partial\Omega\).”
Geometric singularities: The \(C^0\) corners of \(\partial\Omega\) are treated as additional singularities. Separatrices are traced from these corners into the domain using the same integration process.
Proposition 2: The set of separatrices of the geometric and field singularities leads to a partition of \(\Omega\) into 3- or 4-sided regions. The 3-sided regions necessarily contain a singularity.
Proof sketch: Every region is singularity-free (by construction, singularities are only at corners). Inside each region, the cross field is regular, so a parametrization \(f(u,v)\) exists. The boundaries inherit the cross field’s two orthogonal families of streamlines. A region can only be 3-sided if one edge degenerates to a point (a singularity where one family of streamlines converges).
Once we have four-sided regions, each is meshed independently via transfinite bilinear interpolation — a standard technique that maps the unit square \([0,1]^2\) to the curved quadrilateral region, producing a structured quad mesh within each block.
The pipeline demo in Section 6 traces streamlines through the cross field, but how exactly does the algorithm decide which direction to follow at each point? This section unpacks the math and the numerical scheme step by step.
Recall the streamline condition for a vector field (Definition 3 in the paper):
\[ \forall\, s \in [0,1], \quad \partial\gamma(s) \times \mathbf{v}(\gamma(s)) = 0 \]
The tangent of the curve \(\gamma\) at every point must be parallel to the field value at that point. For a cross field with four arms, we relax this to allow any of the four directions (Definition 4):
\[ \forall\, s \in [0,1],\; \exists\, i \in [1..4], \quad \partial\gamma(s) \times \mathbf{v}_i(\gamma(s)) = 0 \]
This means the streamline follows whichever cross arm is locally aligned with its current heading. The challenge is computing the field value at an arbitrary point inside a triangle — and maintaining directional continuity as the streamline crosses from one triangle to the next.
Given a triangle with vertices \(S_1, S_2, S_3\) having representation vectors \(\mathbf{u}_1, \mathbf{u}_2, \mathbf{u}_3\), the field at any interior point \(P\) is interpolated using barycentric coordinates \((\lambda_1, \lambda_2, \lambda_3)\):
\[ \mathbf{u}(P) = \lambda_1 \mathbf{u}_1 + \lambda_2 \mathbf{u}_2 + \lambda_3 \mathbf{u}_3 \]
Since each representation vector encodes the cross angle \(\theta\) as \(\mathbf{u} = (\cos 4\theta, \sin 4\theta)\), the interpolation happens in representation space — not in angle space directly (averaging angles would fail near \(0/\pi\) wraparound). The cross angle at \(P\) is recovered as:
\[ \theta(P) = \frac{1}{4}\operatorname{atan2}\!\Big(\sum_k \lambda_k \sin 4\theta_k,\; \sum_k \lambda_k \cos 4\theta_k\Big) \]
This gives the four cross arms at \(P\) as:
\[ \mathbf{v}_k(P) = \left(\cos\!\left(\theta(P) + \frac{k\pi}{2}\right),\; \sin\!\left(\theta(P) + \frac{k\pi}{2}\right)\right), \quad k = 0,1,2,3 \]
At each integration step, we must pick one of the four arms to follow. The rule is simple: choose the arm whose direction is closest to the previous step’s direction \(\mathbf{d}_i\):
\[ k^* = \arg\max_{k \in \{0,1,2,3\}} \; \mathbf{v}_k(P) \cdot \mathbf{d}_i \]
This ensures the streamline doesn’t “jump” between arms as it crosses triangles — it smoothly follows one family of curves.
Try it below — click inside the triangle to place a probe point, and drag the green arrow to change the “previous direction.” Watch how the selected arm (highlighted) changes as you rotate the incoming direction.
viewof v0_angle_deg = Inputs.range([0, 89], {value: 10, step: 1, label: "Vertex A angle (°)"})
viewof v1_angle_deg = Inputs.range([0, 89], {value: 45, step: 1, label: "Vertex B angle (°)"})
viewof v2_angle_deg = Inputs.range([0, 89], {value: 75, step: 1, label: "Vertex C angle (°)"})
viewof prevDir_deg = Inputs.range([0, 359], {value: 30, step: 1, label: "Previous direction (°)"}){
const W = 560, H = 480;
const svg = d3.create("svg").attr("viewBox", `0 0 ${W} ${H}`).style("width", "100%").style("max-width", W + "px");
// Triangle vertices (large equilateral, centered)
const triCx = W / 2, triCy = H / 2 + 20;
const triR = 170;
const verts = [
{ x: triCx + triR * Math.cos(Math.PI/2), y: triCy - triR * Math.sin(Math.PI/2), label: "A", angle: v0_angle_deg * Math.PI / 180 },
{ x: triCx + triR * Math.cos(Math.PI/2 + 2*Math.PI/3), y: triCy - triR * Math.sin(Math.PI/2 + 2*Math.PI/3), label: "B", angle: v1_angle_deg * Math.PI / 180 },
{ x: triCx + triR * Math.cos(Math.PI/2 + 4*Math.PI/3), y: triCy - triR * Math.sin(Math.PI/2 + 4*Math.PI/3), label: "C", angle: v2_angle_deg * Math.PI / 180 }
];
// Draw triangle
const triPath = `M${verts[0].x},${verts[0].y} L${verts[1].x},${verts[1].y} L${verts[2].x},${verts[2].y} Z`;
svg.append("path").attr("d", triPath).attr("fill", "rgba(200,220,240,0.2)").attr("stroke", "#2c3e50").attr("stroke-width", 1.5);
// Draw crosses at vertices
const vertColors = ["steelblue", "#e74c3c", "#2c3e50"];
const armLen = 28;
verts.forEach((v, idx) => {
for (let k = 0; k < 4; k++) {
const a = v.angle + k * Math.PI / 2;
svg.append("line")
.attr("x1", v.x + armLen * Math.cos(a)).attr("y1", v.y - armLen * Math.sin(a))
.attr("x2", v.x - armLen * Math.cos(a)).attr("y2", v.y + armLen * Math.sin(a))
.attr("stroke", vertColors[idx]).attr("stroke-width", 2).attr("stroke-linecap", "round");
}
svg.append("text").attr("x", v.x + (v.x - triCx) * 0.22).attr("y", v.y + (v.y - triCy) * 0.22)
.attr("text-anchor", "middle").attr("dominant-baseline", "middle")
.attr("font-size", 15).attr("font-weight", "bold").attr("fill", vertColors[idx])
.text(`${v.label} (${Math.round(v.angle * 180 / Math.PI)}°)`);
});
// Probe point at centroid (can be extended to click-to-place)
const px = (verts[0].x + verts[1].x + verts[2].x) / 3;
const py = (verts[0].y + verts[1].y + verts[2].y) / 3;
// Barycentric coordinates
const d = (verts[1].y - verts[2].y)*(verts[0].x - verts[2].x) + (verts[2].x - verts[1].x)*(verts[0].y - verts[2].y);
const lam0 = ((verts[1].y - verts[2].y)*(px - verts[2].x) + (verts[2].x - verts[1].x)*(py - verts[2].y)) / d;
const lam1 = ((verts[2].y - verts[0].y)*(px - verts[2].x) + (verts[0].x - verts[2].x)*(py - verts[2].y)) / d;
const lam2 = 1 - lam0 - lam1;
// Interpolate in (cos4θ, sin4θ) space
const rc = lam0 * Math.cos(4*verts[0].angle) + lam1 * Math.cos(4*verts[1].angle) + lam2 * Math.cos(4*verts[2].angle);
const rs = lam0 * Math.sin(4*verts[0].angle) + lam1 * Math.sin(4*verts[1].angle) + lam2 * Math.sin(4*verts[2].angle);
const interpAngle = Math.atan2(rs, rc) / 4;
// Draw probe point
svg.append("circle").attr("cx", px).attr("cy", py).attr("r", 5).attr("fill", "orange");
// Barycentric coordinate labels
svg.append("text").attr("x", px + 10).attr("y", py - 25).attr("font-size", 11).attr("fill", "#666")
.text(`λ = (${lam0.toFixed(2)}, ${lam1.toFixed(2)}, ${lam2.toFixed(2)})`);
svg.append("text").attr("x", px + 10).attr("y", py - 10).attr("font-size", 11).attr("fill", "orange").attr("font-weight", "bold")
.text(`θ(P) = ${(interpAngle * 180 / Math.PI).toFixed(1)}°`);
// Draw interpolated cross arms at probe point
const prevDirRad = prevDir_deg * Math.PI / 180;
const probeArmLen = 40;
let bestK = 0, bestDot = -Infinity;
for (let k = 0; k < 4; k++) {
const a = interpAngle + k * Math.PI / 2;
const dot = Math.cos(a) * Math.cos(prevDirRad) + (-Math.sin(a)) * (-Math.sin(prevDirRad));
if (dot > bestDot) { bestDot = dot; bestK = k; }
}
for (let k = 0; k < 4; k++) {
const a = interpAngle + k * Math.PI / 2;
const isSelected = (k === bestK);
svg.append("line")
.attr("x1", px + probeArmLen * Math.cos(a)).attr("y1", py - probeArmLen * Math.sin(a))
.attr("x2", px - probeArmLen * Math.cos(a)).attr("y2", py + probeArmLen * Math.sin(a))
.attr("stroke", isSelected ? "#27ae60" : "rgba(0,0,0,0.25)")
.attr("stroke-width", isSelected ? 3.5 : 1.5)
.attr("stroke-linecap", "round");
if (isSelected) {
// Arrowhead on selected arm
const tipX = px + probeArmLen * Math.cos(a), tipY = py - probeArmLen * Math.sin(a);
const ba = Math.atan2(probeArmLen * Math.sin(a), probeArmLen * Math.cos(a)) + Math.PI;
const hl = 10, ha = 0.4;
svg.append("polygon")
.attr("points", `${tipX},${tipY} ${tipX+hl*Math.cos(ba+ha)},${tipY-hl*Math.sin(ba+ha)} ${tipX+hl*Math.cos(ba-ha)},${tipY-hl*Math.sin(ba-ha)}`)
.attr("fill", "#27ae60");
svg.append("text").attr("x", tipX + 12*Math.cos(a)).attr("y", tipY - 12*Math.sin(a))
.attr("text-anchor", "middle").attr("font-size", 11).attr("fill", "#27ae60").attr("font-weight", "bold")
.text("selected");
}
}
// Draw previous direction arrow (green dashed)
const pdLen = 55;
const pdTipX = px + pdLen * Math.cos(prevDirRad), pdTipY = py - pdLen * Math.sin(prevDirRad);
svg.append("line").attr("x1", px).attr("y1", py).attr("x2", pdTipX).attr("y2", pdTipY)
.attr("stroke", "#27ae60").attr("stroke-width", 2.5).attr("stroke-dasharray", "6,4");
const pdba = Math.atan2(pdLen*Math.sin(prevDirRad), pdLen*Math.cos(prevDirRad)) + Math.PI;
svg.append("polygon")
.attr("points", `${pdTipX},${pdTipY} ${pdTipX+8*Math.cos(pdba+0.4)},${pdTipY-8*Math.sin(pdba+0.4)} ${pdTipX+8*Math.cos(pdba-0.4)},${pdTipY-8*Math.sin(pdba-0.4)}`)
.attr("fill", "#27ae60");
svg.append("text").attr("x", pdTipX + 15*Math.cos(prevDirRad)).attr("y", pdTipY - 15*Math.sin(prevDirRad))
.attr("text-anchor", "middle").attr("font-size", 11).attr("fill", "#27ae60")
.text("d_prev");
// Representation vector (orange dashed)
const repLen = 35;
svg.append("line").attr("x1", px).attr("y1", py)
.attr("x2", px + repLen*Math.cos(interpAngle)).attr("y2", py - repLen*Math.sin(interpAngle))
.attr("stroke", "orange").attr("stroke-width", 3).attr("stroke-dasharray", "5,3");
// Title
svg.append("text").attr("x", W/2).attr("y", 18).attr("text-anchor", "middle").attr("font-size", 14).attr("fill", "#333")
.text("Cross field interpolation at probe point P (orange dot)");
return svg.node();
}Without arm selection, the streamline would randomly jump between the four cross directions at each step, producing a jagged, meaningless path. By always picking the arm closest to the previous heading, the algorithm commits to one “family” of streamlines and traces it smoothly. The orthogonal family is obtained by starting with a direction rotated by \(\pi/2\).
The paper uses Heun’s method (an improved Euler scheme) to trace streamlines through the triangulation. Given an entry point \(X_i\) on an edge of triangle \(T\) with direction \(\mathbf{d}_i\):
“We use the Heun’s method, an easy-to-use variation of a Runge-Kutta method, to integrate the ODE \(Y = f(X)\) over \(T\), starting from \(X_i\).”
Predictor (Euler step): Take a full step in the current direction to get a tentative exit point: \[ \tilde{X} = X_i + h \cdot \mathbf{v}_{\mathbf{d}_i}(X_i) \]
Corrector (average of endpoint slopes): Evaluate the field at both ends and average: \[ X_{i+1} = X_i + \frac{h}{2}\left[\mathbf{v}_{\mathbf{d}_i}(X_i) + \mathbf{v}_{\mathbf{d}_i}(\tilde{X})\right] \]
where \(h\) is chosen so the line segment exits the triangle. In practice, we find the intersection of the ray from \(X_i\) in direction \(\mathbf{d}_i\) with the opposite edge of \(T\).
The interactive below shows this process on a strip of connected triangles. Click Step to advance one triangle at a time, and toggle between Euler and Heun to see the improvement.
Separatrices are the structural skeleton of the cross field. They are special streamlines that start (or end) at singularities, and they form the boundaries of the block partition. Understanding how they are found and traced is essential.
“For each singularity \(S\), we build the separatrices, which are the streamlines of the cross field ending on \(S\).”
A singularity \(S_0\) sits inside a triangle \(T_0\) with vertices \(S_1, S_2, S_3\). The separatrices must exit \(T_0\) through its edges. The question is: where on each edge does a separatrix cross?
For any point \(P\) on an edge of \(T_0\), we can:
Interpolate the cross field at \(P\) by linearly blending the vertex values (in representation space). This gives 4 unit vectors \(\mathbf{v}_0(P), \ldots, \mathbf{v}_3(P)\).
Compute the direction from the singularity to \(P\): \(\mathbf{u}(P) = \overrightarrow{S_0 P} / |\overrightarrow{S_0 P}|\).
Check for alignment: A separatrix passes through \(P\) if and only if one of the cross arms at \(P\) points directly toward (or away from) the singularity:
\[ \exists\, k \in [0..3], \quad \frac{\mathbf{v}_k(P)}{|\mathbf{v}_k(P)|} = \frac{\mathbf{u}(P)}{|\mathbf{u}(P)|} \]
As we sweep \(P\) along an edge, the cross field rotates smoothly while the direction \(\overrightarrow{S_0 P}\) also rotates. Each time an arm “catches up” to the S₀→P direction, we find an exit point. The number of catches depends on the singularity index:
{
const W = 580, H = 520;
const svg = d3.create("svg").attr("viewBox", `0 0 ${W} ${H}`).style("width", "100%").style("max-width", W + "px");
const isPos = sepIndexType.startsWith("index +1");
const expectedExits = isPos ? 3 : 5;
// Triangle vertices (large)
const cx = W/2, cy = H/2 + 10;
const R = 200;
const tri = [
{x: cx, y: cy - R},
{x: cx - R*Math.sin(2*Math.PI/3), y: cy + R*Math.cos(2*Math.PI/3)},
{x: cx + R*Math.sin(2*Math.PI/3), y: cy + R*Math.cos(2*Math.PI/3)}
];
// Vertex cross angles chosen to produce the desired singularity index.
// For index +1: angles that sum to +π/2 of angle defect
// For index -1: angles that sum to -π/2 of angle defect
const vertAngles = isPos
? [0.0, Math.PI/6, Math.PI/3 + Math.PI/8]
: [0.0, -Math.PI/8, Math.PI/6];
// Singularity at centroid
const sx = (tri[0].x + tri[1].x + tri[2].x) / 3;
const sy = (tri[0].y + tri[1].y + tri[2].y) / 3;
// Draw triangle
svg.append("path")
.attr("d", `M${tri[0].x},${tri[0].y} L${tri[1].x},${tri[1].y} L${tri[2].x},${tri[2].y} Z`)
.attr("fill", "rgba(200,220,240,0.12)").attr("stroke", "#2c3e50").attr("stroke-width", 1.5);
// Draw vertex crosses
const armLen = 22;
const vColors = ["steelblue", "#e74c3c", "#8e44ad"];
for (let vi = 0; vi < 3; vi++) {
for (let k = 0; k < 4; k++) {
const a = vertAngles[vi] + k * Math.PI / 2;
svg.append("line")
.attr("x1", tri[vi].x + armLen*Math.cos(a)).attr("y1", tri[vi].y - armLen*Math.sin(a))
.attr("x2", tri[vi].x - armLen*Math.cos(a)).attr("y2", tri[vi].y + armLen*Math.sin(a))
.attr("stroke", vColors[vi]).attr("stroke-width", 1.8).attr("stroke-linecap", "round");
}
}
// Singularity marker
svg.append("circle").attr("cx", sx).attr("cy", sy).attr("r", 7).attr("fill", "#e74c3c");
svg.append("text").attr("x", sx).attr("y", sy - 14).attr("text-anchor", "middle")
.attr("font-size", 12).attr("fill", "#e74c3c").attr("font-weight", "bold").text("S₀");
// Sample each edge and check for alignment
const edges = [[0,1],[1,2],[2,0]];
const exitPoints = [];
const allSamples = [];
const nSamp = sepSampleDensity;
const alignThreshold = Math.PI / nSamp * 2.0;
for (const [ea, eb] of edges) {
for (let si = 1; si < nSamp; si++) {
const t = si / nSamp;
const px = tri[ea].x + t * (tri[eb].x - tri[ea].x);
const py = tri[ea].y + t * (tri[eb].y - tri[ea].y);
// Barycentric coords for this point
const dd = (tri[1].y-tri[2].y)*(tri[0].x-tri[2].x)+(tri[2].x-tri[1].x)*(tri[0].y-tri[2].y);
const l0 = ((tri[1].y-tri[2].y)*(px-tri[2].x)+(tri[2].x-tri[1].x)*(py-tri[2].y))/dd;
const l1 = ((tri[2].y-tri[0].y)*(px-tri[2].x)+(tri[0].x-tri[2].x)*(py-tri[2].y))/dd;
const l2 = 1-l0-l1;
// Interpolate cross field
const rc = l0*Math.cos(4*vertAngles[0]) + l1*Math.cos(4*vertAngles[1]) + l2*Math.cos(4*vertAngles[2]);
const rs = l0*Math.sin(4*vertAngles[0]) + l1*Math.sin(4*vertAngles[1]) + l2*Math.sin(4*vertAngles[2]);
const baseAngle = Math.atan2(rs, rc) / 4;
// Direction S0 -> P
const dx = px - sx, dy = py - sy;
const dirToP = Math.atan2(-dy, dx); // SVG y is flipped
// Check each arm for alignment
let aligned = false;
let alignedArm = -1;
for (let k = 0; k < 4; k++) {
const arm = baseAngle + k * Math.PI / 2;
let diff = arm - dirToP;
diff = diff - Math.round(diff / (2*Math.PI)) * 2*Math.PI;
if (Math.abs(diff) < alignThreshold) {
aligned = true;
alignedArm = k;
break;
}
}
allSamples.push({px, py, baseAngle, dirToP, aligned, alignedArm});
if (aligned) {
// Check it's not a duplicate
let dup = false;
for (const ep of exitPoints) {
if (Math.sqrt((px-ep.px)**2+(py-ep.py)**2) < 20) { dup = true; break; }
}
if (!dup) exitPoints.push({px, py, dir: baseAngle + alignedArm * Math.PI/2});
}
}
}
// Draw sample points and mini-crosses
const miniArm = 10;
for (const s of allSamples) {
// Direction line from S0 to P (thin gray)
svg.append("line").attr("x1", sx).attr("y1", sy).attr("x2", s.px).attr("y2", s.py)
.attr("stroke", "rgba(0,0,0,0.04)").attr("stroke-width", 0.5);
// Sample dot
svg.append("circle").attr("cx", s.px).attr("cy", s.py).attr("r", s.aligned ? 4 : 2)
.attr("fill", s.aligned ? "#27ae60" : "rgba(0,0,0,0.15)");
// Mini cross at sample point (only every 3rd point to avoid clutter)
if (allSamples.indexOf(s) % 3 === 0) {
for (let k = 0; k < 4; k++) {
const a = s.baseAngle + k * Math.PI / 2;
const isAlignedArm = s.aligned && (k === s.alignedArm);
svg.append("line")
.attr("x1", s.px + miniArm*Math.cos(a)).attr("y1", s.py - miniArm*Math.sin(a))
.attr("x2", s.px - miniArm*Math.cos(a)).attr("y2", s.py + miniArm*Math.sin(a))
.attr("stroke", isAlignedArm ? "#27ae60" : "rgba(0,0,0,0.12)")
.attr("stroke-width", isAlignedArm ? 2.5 : 0.8);
}
}
}
// Highlight exit points
for (const ep of exitPoints) {
svg.append("circle").attr("cx", ep.px).attr("cy", ep.py).attr("r", 7)
.attr("fill", "none").attr("stroke", "#27ae60").attr("stroke-width", 2.5);
// Direction arrow from exit point
const arrowLen = 30;
const tipX = ep.px + arrowLen * Math.cos(ep.dir);
const tipY = ep.py - arrowLen * Math.sin(ep.dir);
svg.append("line").attr("x1", ep.px).attr("y1", ep.py).attr("x2", tipX).attr("y2", tipY)
.attr("stroke", "#27ae60").attr("stroke-width", 2.5).attr("stroke-linecap", "round");
// Small arrowhead
const ba = Math.atan2(arrowLen*Math.sin(ep.dir), arrowLen*Math.cos(ep.dir)) + Math.PI;
svg.append("polygon")
.attr("points", `${tipX},${tipY} ${tipX+7*Math.cos(ba+0.4)},${tipY-7*Math.sin(ba+0.4)} ${tipX+7*Math.cos(ba-0.4)},${tipY-7*Math.sin(ba-0.4)}`)
.attr("fill", "#27ae60");
}
// Label
svg.append("text").attr("x", W/2).attr("y", H - 8).attr("text-anchor", "middle")
.attr("font-size", 13).attr("fill", "#333")
.text(`Found ${exitPoints.length} exit points (expected ${expectedExits}) — green circles show separatrix exits`);
svg.append("text").attr("x", W/2).attr("y", 18).attr("text-anchor", "middle")
.attr("font-size", 14).attr("fill", "#333")
.text(`Separatrix exit detection: ${sepIndexType}`);
return svg.node();
}Once the exit points are found, the algorithm from Section 3.4 takes over: starting from each exit point with its known direction, we integrate outward through successive triangles using Heun’s method. The separatrix continues until it hits:
Each traced separatrix becomes an edge in the block partition graph.
The interactive below shows separatrices growing outward from a singularity embedded in a small mesh patch. Each separatrix is colored distinctly.
Once all separatrices are traced — from both field singularities (interior points where the representation vector vanishes) and geometric singularities (\(C^0\) corners of \(\partial\Omega\)) — they divide the domain into regions.
“In order to get four-sided regions, we extend the set of representation vector field singularities by adding a set of geometric singularities, which are defined as the \(C^0\) corners on \(\partial\Omega\).”
A sharp corner on the boundary (like the corners of an L-shape, or the trailing edge of an airfoil) acts as a singularity source. The representation vector at a corner is defined as the average of the representation vectors of the two incident boundary edges. Separatrices from geometric corners are traced inward using the same Heun integration.
Proposition 2 (Kowalski et al.): The set of separatrices of the geometric and field singularities leads to a partition of \(\Omega\) into 3-sided or 4-sided regions. The 3-sided regions necessarily contain a singularity.
Why only 3 or 4 sides? Inside each region (away from singularities), the cross field is smooth and regular. This means there exist two orthogonal families of curves — the \(u\)-family and the \(v\)-family — that tile the region like a coordinate grid. The boundary of each region consists of alternating segments from these two families:
{
const W = 600, H = 280;
const svg = d3.create("svg").attr("viewBox", `0 0 ${W} ${H}`).style("width", "100%").style("max-width", W + "px");
// 4-sided region (left)
const leftCx = 150, topY = 40, botY = 240;
// Draw curved quad
const quadPts = [
{x: 60, y: 60}, {x: 240, y: 50}, {x: 260, y: 230}, {x: 50, y: 240}
];
svg.append("path")
.attr("d", `M${quadPts[0].x},${quadPts[0].y} C${quadPts[0].x+60},${quadPts[0].y-10} ${quadPts[1].x-60},${quadPts[1].y-10} ${quadPts[1].x},${quadPts[1].y} L${quadPts[2].x},${quadPts[2].y} C${quadPts[2].x-60},${quadPts[2].y+5} ${quadPts[3].x+60},${quadPts[3].y+5} ${quadPts[3].x},${quadPts[3].y} Z`)
.attr("fill", "rgba(46,204,113,0.15)").attr("stroke", "#2c3e50").attr("stroke-width", 2);
// Draw u-family curves (horizontal-ish)
for (let i = 1; i <= 3; i++) {
const t = i/4;
const lx = quadPts[0].x + t*(quadPts[3].x-quadPts[0].x);
const ly = quadPts[0].y + t*(quadPts[3].y-quadPts[0].y);
const rx = quadPts[1].x + t*(quadPts[2].x-quadPts[1].x);
const ry = quadPts[1].y + t*(quadPts[2].y-quadPts[1].y);
svg.append("line").attr("x1",lx).attr("y1",ly).attr("x2",rx).attr("y2",ry)
.attr("stroke","steelblue").attr("stroke-width",1).attr("stroke-dasharray","4,3").attr("opacity",0.6);
}
// Draw v-family curves (vertical-ish)
for (let i = 1; i <= 3; i++) {
const t = i/4;
const tx = quadPts[0].x + t*(quadPts[1].x-quadPts[0].x);
const ty = quadPts[0].y + t*(quadPts[1].y-quadPts[0].y);
const bx = quadPts[3].x + t*(quadPts[2].x-quadPts[3].x);
const by = quadPts[3].y + t*(quadPts[2].y-quadPts[3].y);
svg.append("line").attr("x1",tx).attr("y1",ty).attr("x2",bx).attr("y2",by)
.attr("stroke","#e74c3c").attr("stroke-width",1).attr("stroke-dasharray","4,3").attr("opacity",0.6);
}
svg.append("text").attr("x",150).attr("y",270).attr("text-anchor","middle").attr("font-size",13).attr("fill","#333")
.text("4-sided region (ideal)");
// Corner dots
for (const p of quadPts) {
svg.append("circle").attr("cx",p.x).attr("cy",p.y).attr("r",4).attr("fill","#2c3e50");
}
// Labels
svg.append("text").attr("x",150).attr("y",35).attr("text-anchor","middle").attr("font-size",11).attr("fill","steelblue").text("u-family");
svg.append("text").attr("x",275).attr("y",145).attr("font-size",11).attr("fill","#e74c3c").text("v-family");
// 3-sided region (right)
const ox = 330;
// Triangle with one degenerate vertex (top = singularity)
const triPts = [
{x: ox + 150, y: 50}, // singularity (degenerate vertex)
{x: ox + 260, y: 235},
{x: ox + 40, y: 230}
];
svg.append("path")
.attr("d", `M${triPts[0].x},${triPts[0].y} C${triPts[0].x+50},${triPts[0].y+60} ${triPts[1].x-20},${triPts[1].y-60} ${triPts[1].x},${triPts[1].y} L${triPts[2].x},${triPts[2].y} C${triPts[2].x+20},${triPts[2].y-60} ${triPts[0].x-50},${triPts[0].y+60} ${triPts[0].x},${triPts[0].y}`)
.attr("fill", "rgba(231,76,60,0.1)").attr("stroke", "#2c3e50").attr("stroke-width", 2);
// Streamlines converging to the singularity
for (let i = 1; i <= 4; i++) {
const t = i/5;
const bx = triPts[2].x + t*(triPts[1].x - triPts[2].x);
const by = triPts[2].y + t*(triPts[1].y - triPts[2].y);
svg.append("line").attr("x1",triPts[0].x).attr("y1",triPts[0].y).attr("x2",bx).attr("y2",by)
.attr("stroke","steelblue").attr("stroke-width",1).attr("stroke-dasharray","4,3").attr("opacity",0.5);
}
// Singularity marker
svg.append("circle").attr("cx",triPts[0].x).attr("cy",triPts[0].y).attr("r",6).attr("fill","#e74c3c");
svg.append("text").attr("x",triPts[0].x).attr("y",triPts[0].y-12).attr("text-anchor","middle")
.attr("font-size",11).attr("fill","#e74c3c").attr("font-weight","bold").text("singularity");
// Other corners
svg.append("circle").attr("cx",triPts[1].x).attr("cy",triPts[1].y).attr("r",4).attr("fill","#2c3e50");
svg.append("circle").attr("cx",triPts[2].x).attr("cy",triPts[2].y).attr("r",4).attr("fill","#2c3e50");
svg.append("text").attr("x",ox+150).attr("y",270).attr("text-anchor","middle").attr("font-size",13).attr("fill","#333")
.text("3-sided region (degenerate vertex)");
return svg.node();
}This domain — a rectangle with two semicircular notches — is a classic test case from Kowalski et al. The curved concavities force the cross field to develop interior singularities whose separatrices partition the domain into quad-meshable blocks. Step through each stage to see the full pipeline.
The separatrix-based partition from Section 3.6 may not immediately produce all 4-sided regions. Some regions may be 3-sided (with a degenerate vertex at a singularity), and some partitions may have more blocks than necessary. Block merging is a post-processing step that simplifies the partition.
When a 3-sided region \(R_3\) shares a separatrix with an adjacent 4-sided region \(R_4\), and they meet at a valence-3 singularity, we can remove the shared separatrix to merge them into a new 4-sided region:
while any 3-sided region R3 exists:
find adjacent 4-sided region R4 sharing separatrix S at singularity V
remove S from the partition
merge R3 and R4 into new 4-sided region R_new
update partition graph
The topological argument: removing a separatrix between a 3-sided and 4-sided region that share a vertex eliminates the degenerate edge and produces a valid 4-sided region. The combined region inherits 3 sides from \(R_4\) and 2 from \(R_3\) — but the shared separatrix accounts for one side from each, so the result has \(4 + 3 - 2 = 4\) sides (accounting for the singularity vertex no longer being a degenerate edge but a regular boundary vertex).
This merging step is not explicitly described in Kowalski et al. 2012, but it is standard practice in block-structured meshing pipelines. The paper’s Proposition 2 guarantees that the initial partition produces only 3- or 4-sided regions, and the merging step eliminates all 3-sided ones.
{
const W = 560, H = 400;
const svg = d3.create("svg").attr("viewBox", `0 0 ${W} ${H}`).style("width", "100%").style("max-width", W + "px");
// Pre-defined block partition with 3- and 4-sided regions
// Simple example: a rectangular domain split by separatrices from 2 singularities
const regions = [
{ // Region 0: 4-sided (top-left)
pts: [{x:30,y:30},{x:270,y:30},{x:250,y:160},{x:30,y:170}],
sides: 4, color: 'rgba(52,152,219,0.25)', label: '4'
},
{ // Region 1: 3-sided (top-center triangle)
pts: [{x:270,y:30},{x:400,y:30},{x:250,y:160}],
sides: 3, color: 'rgba(231,76,60,0.2)', label: '3'
},
{ // Region 2: 4-sided (right)
pts: [{x:400,y:30},{x:530,y:30},{x:530,y:190},{x:320,y:180}],
sides: 4, color: 'rgba(46,204,113,0.25)', label: '4'
},
{ // Region 3: 3-sided (center triangle)
pts: [{x:250,y:160},{x:320,y:180},{x:280,y:260}],
sides: 3, color: 'rgba(155,89,182,0.2)', label: '3'
},
{ // Region 4: 4-sided (bottom-left)
pts: [{x:30,y:170},{x:250,y:160},{x:280,y:260},{x:30,y:370}],
sides: 4, color: 'rgba(241,196,15,0.25)', label: '4'
},
{ // Region 5: 4-sided (bottom-right)
pts: [{x:280,y:260},{x:320,y:180},{x:530,y:190},{x:530,y:370},{x:30,y:370}],
sides: 4, color: 'rgba(230,126,34,0.2)', label: '4'
}
];
// Separatrices that can be removed during merging
const separatrices = [
{ from: {x:250,y:160}, to: {x:270,y:30}, removedAt: 1 }, // between region 0 and 1
{ from: {x:250,y:160}, to: {x:320,y:180}, removedAt: 2 }, // between regions around center singularity
{ from: {x:320,y:180}, to: {x:400,y:30}, removedAt: 3 }, // between region 2 and 1
];
// Singularities
const sings = [
{x: 250, y: 160, label: "+1"},
{x: 320, y: 180, label: "+1"}
];
// Merge state: which regions are merged
const step = mergeStep;
// After merging, redefine regions
let visRegions = [];
let visSeps = [];
let visSings = [...sings];
if (step === 0) {
visRegions = regions;
visSeps = separatrices;
} else if (step === 1) {
// Merge region 0 and 1 → new 4-sided region
visRegions = [
{ pts: [{x:30,y:30},{x:400,y:30},{x:250,y:160},{x:30,y:170}], sides: 4, color: 'rgba(52,152,219,0.25)', label: '4' },
regions[2], regions[3], regions[4], regions[5]
];
visSeps = separatrices.filter(s => s.removedAt > 1);
} else if (step === 2) {
visRegions = [
{ pts: [{x:30,y:30},{x:400,y:30},{x:250,y:160},{x:30,y:170}], sides: 4, color: 'rgba(52,152,219,0.25)', label: '4' },
regions[2],
{ pts: [{x:30,y:170},{x:250,y:160},{x:320,y:180},{x:280,y:260},{x:30,y:370}], sides: 4, color: 'rgba(241,196,15,0.25)', label: '4' },
regions[5]
];
visSeps = separatrices.filter(s => s.removedAt > 2);
} else {
visRegions = [
{ pts: [{x:30,y:30},{x:530,y:30},{x:530,y:190},{x:320,y:180},{x:250,y:160},{x:30,y:170}], sides: 4, color: 'rgba(52,152,219,0.2)', label: '4' },
{ pts: [{x:30,y:170},{x:250,y:160},{x:320,y:180},{x:530,y:190},{x:530,y:370},{x:30,y:370}], sides: 4, color: 'rgba(241,196,15,0.2)', label: '4' }
];
visSeps = [];
visSings = [];
}
// Draw regions
for (const r of visRegions) {
const d = 'M' + r.pts.map(p => `${p.x},${p.y}`).join(' L') + ' Z';
svg.append("path").attr("d", d).attr("fill", r.color).attr("stroke", "#2c3e50").attr("stroke-width", 1.5);
// Label
const cx = r.pts.reduce((s,p) => s + p.x, 0) / r.pts.length;
const cy = r.pts.reduce((s,p) => s + p.y, 0) / r.pts.length;
svg.append("text").attr("x", cx).attr("y", cy).attr("text-anchor", "middle").attr("dominant-baseline", "middle")
.attr("font-size", 18).attr("font-weight", "bold").attr("fill", "rgba(0,0,0,0.4)")
.text(r.label + '-sided');
}
// Draw separatrices
for (const s of visSeps) {
svg.append("line").attr("x1",s.from.x).attr("y1",s.from.y).attr("x2",s.to.x).attr("y2",s.to.y)
.attr("stroke","#e74c3c").attr("stroke-width",3).attr("stroke-dasharray","8,4");
}
// Draw singularities
for (const s of visSings) {
svg.append("circle").attr("cx",s.x).attr("cy",s.y).attr("r",7).attr("fill","#e74c3c");
svg.append("text").attr("x",s.x).attr("y",s.y-14).attr("text-anchor","middle")
.attr("font-size",11).attr("fill","#e74c3c").attr("font-weight","bold").text(s.label);
}
// Status text
const statusTexts = [
"Initial partition: 2 three-sided + 4 four-sided regions",
"Merge step 1: removed separatrix → 3-sided region absorbed into 4-sided neighbor",
"Merge step 2: removed another separatrix → second 3-sided region absorbed",
"Final: all regions are 4-sided — ready for transfinite interpolation"
];
svg.append("text").attr("x", W/2).attr("y", H - 8).attr("text-anchor", "middle")
.attr("font-size", 13).attr("fill", "#333").text(statusTexts[step]);
return svg.node();
}Once every region is 4-sided, each is meshed independently using transfinite bilinear interpolation (TFI). Given a 4-sided region with boundary curves \(C_0(s)\) (bottom), \(C_1(s)\) (top), \(C_2(t)\) (left), \(C_3(t)\) (right) and corners \(P_{00}, P_{10}, P_{01}, P_{11}\), the interior mapping is:
\[ f(s,t) = (1-t)\,C_0(s) + t\,C_1(s) + (1-s)\,C_2(t) + s\,C_3(t) - \Big[(1-s)(1-t)P_{00} + s(1-t)P_{10} + (1-s)t\,P_{01} + st\,P_{11}\Big] \]
This is a Boolean sum of the boundary interpolants minus the bilinear correction at the corners. It maps the unit square \([0,1]^2\) to the curved quadrilateral, producing a structured grid of quadrilateral elements.
Drag the corners below to see how TFI generates a structured mesh inside an arbitrary 4-sided region:
The paper compares against unstructured quad meshes from GMSH on identical domains (~9000 quads each), using two metrics:
The block-structured approach achieves better quality on both metrics, primarily because of the low singularity count.
Symmetry preservation: The PDE-based approach naturally respects domain symmetry. A symmetric domain produces a symmetric partitioning — four 3-valent singularities and three 4-valent ones.
Airfoil: For a multi-element aerofoil (leading-edge flat, main body, trailing-edge flap), the method produces few singularities and generates boundary layers of quadrilaterals aligned orthogonally to the surface — exactly what CFD simulations need.
Multi-domain: When two domains share an interface curve, they can be partitioned separately (non-conforming) or jointly (conforming). Joint partitioning allows separatrices to cross between domains.
“The time needed to partition the domain for the different examples presented in this paper varied from about one second, for most of them, to twenty seconds for the most complicated ones.”
The cost scales with the number of triangles in \(T_h\). The meshing step (transfinite interpolation) is negligible — even for millions of elements.
This paper presents the same fundamental pipeline used in many frame field-based quad meshing methods:
| Stage | This Paper | Ray et al. 2015 |
|---|---|---|
| Field representation | Representation vector \(\mathbf{u} = (\cos\theta, \sin\theta)\) | Frame angle \(\theta\) with \(n\)-symmetry |
| Smoothness | Dirichlet energy \(\int|\nabla\mathbf{u}|^2 dx\) | Curvature \(\sum_e (\Delta\theta)^2\) |
| Solve method | FEM + Lagrange multipliers for \(|\mathbf{u}|=1\) | Direct linear solve in \((\cos 4\theta, \sin 4\theta)\) |
| Singularities | Poincare index of \(\mathbf{u}\) | Angle defect around triangles |
| Partitioning | Separatrix tracing + transfinite interpolation | MIQ parameterization + quad extraction |
The key difference: this paper uses a representation vector \(\mathbf{u} \in \mathbb{R}^2\) with a unit norm constraint, while the Ray 2015 approach encodes cross field orientation through angle-based representations with 4-symmetry built in algebraically. Both arrive at the same cross field — just different numerical paths.
The NACA airfoil demo lets you explore several of these concepts interactively: boundary constraints, the linear solve, Jacobi smoothing, and streamline tracing through the resulting cross field.
---
title: "PDE-Based Block-Structured Meshing"
subtitle: "Domain Partitioning via Cross Fields"
---
## Paper at a Glance
::: {.callout-note}
## Reference
**A PDE based approach to multi-domain partitioning and quadrilateral meshing** — Kowalski, Ledoux, Frey (2012)
21st Int. Meshing Roundtable, Sandia Natl. Labs.
[Download the original paper (PDF)](assets/PDE_block_split.pdf)
:::
This page walks through every section of the paper in detail. The core idea: propagate boundary geometric information into the domain interior by solving a PDE, producing a smooth cross field whose topological structure (singularities and separatrices) partitions the domain into four-sided regions — each meshable by structured transfinite interpolation.
[Try the interactive NACA airfoil demo](demos/naca_airfoil.html){.btn .btn-primary .btn-sm}
---
## 1. Introduction
> *"In numerous computational engineering applications, such as automobile crash simulations, structural mechanics, neutronics or fluid-structure interactions, quadrilateral meshes are preferred over triangular meshes."*
### 1.1 Why Quadrilateral Meshes?
The paper identifies four desirable properties of quad meshes:
1. **Minimize singularities** — non-4-valent interior vertices (vertices that don't have exactly 4 neighbors) should be rare
2. **Boundary alignment** — several layers of quads aligned parallel to the boundary (boundary layers for CFD)
3. **Element quality** — each quad should be as close to a square or rectangle as possible
4. **Size map conformance** — elements should follow a prescribed size distribution
For multi-domain simulations (e.g., fluid-structure interaction), a fifth property is conformity along material interfaces.
### 1.2 Block-Structured Meshing
> *"Block-structured meshes have one main advantage over other kinds of meshes: they can theoretically adapt to any given domain while maintaining the good element quality associated with structured meshes."*
The key insight is that block-structured meshes combine the best of both worlds:
- **Structured meshes**: high quality, efficient solvers (logical rectangular structure), hierarchical memory access — but can't handle complex geometries
- **Unstructured meshes**: handle any geometry — but lower quality, more singularities
- **Block-structured**: unstructured *arrangement* of structured quad blocks. Each block is a four-sided region meshed by transfinite interpolation.
The challenge: **how to automatically partition an arbitrary domain into four-sided regions?** Previous attempts using the medial axis are numerically unstable and don't produce four-sided regions (even a square gets triangular parts).
### 1.3 The Pipeline
The paper's approach has four stages:
1. **Background triangulation** $T_h$ of domain $\Omega$
2. **Solve a PDE** to propagate boundary orientation info into the interior, producing a smooth **directionality (cross) field**
3. **Analyze the topology** of the cross field — find singularities and trace separatrices to **partition** the domain into four-sided regions
4. **Mesh each region** with transfinite bilinear interpolation
---
## 2. Creation of a Directionality Field
> *"The main idea of our method consists in using a unit vector field prescribed on $\partial\Omega$ that we propagate on the whole $\Omega$."*
### 2.1 Definition of the Directionality of $\Omega$
A regular vertex $P$ of a quad mesh is 4-valent. The four edges form two sets of opposite edges, each tracing a curve. The two curves intersect orthogonally at $P$. Their tangent directions form a **cross** — the *directionality* of the mesh at $P$.
A cross is a set of 4 unit vectors related by $\pi/2$ rotations, fully described by a single angle $\theta$:
$$
C_\theta = \left\{ \mathbf{v}_k = \left(\cos\!\left(\theta + \frac{k\pi}{2}\right), \sin\!\left(\theta + \frac{k\pi}{2}\right)\right)^T,\; 0 \le k \le 3 \right\}, \quad \theta \in \left[0, \frac{\pi}{2}\right).
$$
The **representation vector** is $\mathbf{u} = (\cos\theta, \sin\theta)^T$. This is not one of the cross arms — it encodes which of the infinitely many equivalent descriptions we pick.
```{ojs}
//| label: fig-cross-rep
viewof cross_theta_deg = Inputs.range([0, 89], {value: 25, step: 1, label: "θ (degrees)"})
```
```{ojs}
//| code-fold: true
{
const w = 420, h = 420, cx = w/2, cy = h/2, r = 130;
const theta = cross_theta_deg * Math.PI / 180;
const svg = d3.create("svg").attr("viewBox", `0 0 ${w} ${h}`).style("width", "100%").style("max-width", w + "px");
// Unit circle
const circ = d3.range(0, 2*Math.PI + 0.01, 0.02).map(a => [cx + r*Math.cos(a), cy - r*Math.sin(a)]);
svg.append("path").attr("d", d3.line()(circ)).attr("fill", "none").attr("stroke", "#ddd").attr("stroke-width", 1);
// Axes
svg.append("line").attr("x1", cx - r - 15).attr("y1", cy).attr("x2", cx + r + 15).attr("y2", cy).attr("stroke", "#eee");
svg.append("line").attr("x1", cx).attr("y1", cy - r - 15).attr("x2", cx).attr("y2", cy + r + 15).attr("stroke", "#eee");
// Cross arms
const armColors = ["steelblue", "#e74c3c", "steelblue", "#e74c3c"];
const armLabels = ["v0", "v1", "v2", "v3"];
for (let k = 0; k < 4; k++) {
const a = theta + k * Math.PI / 2;
const dx = r * Math.cos(a), dy = -r * Math.sin(a);
svg.append("line").attr("x1", cx).attr("y1", cy).attr("x2", cx + dx).attr("y2", cy + dy)
.attr("stroke", armColors[k]).attr("stroke-width", 2.5).attr("stroke-linecap", "round");
// Arrowhead
const hl = 10, ha = 0.4;
const ba = Math.atan2(-dy, dx) + Math.PI;
svg.append("polygon")
.attr("points", `${cx+dx},${cy+dy} ${cx+dx+hl*Math.cos(ba+ha)},${cy+dy-hl*Math.sin(ba+ha)} ${cx+dx+hl*Math.cos(ba-ha)},${cy+dy-hl*Math.sin(ba-ha)}`)
.attr("fill", armColors[k]);
svg.append("text").attr("x", cx + 1.15*dx).attr("y", cy + 1.15*dy)
.attr("text-anchor", "middle").attr("dominant-baseline", "middle")
.attr("font-size", 13).attr("fill", armColors[k]).text(armLabels[k]);
}
// Representation vector (orange, thicker)
const repDx = r * 0.75 * Math.cos(theta), repDy = -r * 0.75 * Math.sin(theta);
svg.append("line").attr("x1", cx).attr("y1", cy).attr("x2", cx + repDx).attr("y2", cy + repDy)
.attr("stroke", "orange").attr("stroke-width", 4).attr("stroke-dasharray", "6,3");
svg.append("text").attr("x", cx + repDx * 1.2).attr("y", cy + repDy * 1.2)
.attr("text-anchor", "middle").attr("font-size", 14).attr("fill", "orange").attr("font-weight", "bold").text("u");
// Angle arc
if (theta > 0.02) {
const arcPts = d3.range(0, theta + 0.01, 0.02).map(a => [cx + 35*Math.cos(a), cy - 35*Math.sin(a)]);
svg.append("path").attr("d", d3.line()(arcPts)).attr("fill", "none").attr("stroke", "orange").attr("stroke-width", 2);
svg.append("text").attr("x", cx + 48*Math.cos(theta/2)).attr("y", cy - 48*Math.sin(theta/2))
.attr("text-anchor", "middle").attr("font-size", 14).attr("fill", "orange").text("θ");
}
svg.append("text").attr("x", cx).attr("y", 18).attr("text-anchor", "middle").attr("font-size", 15).attr("fill", "#333")
.text(`Cross (θ = ${cross_theta_deg}°) with representation vector u`);
return svg.node();
}
```
**Key property**: the cross field is independent of the reference axis. Rotating the coordinate system by $\theta_0$ rotates each cross by $-\theta_0$, and each representation vector by $-4\theta_0$ (because of the 4-fold symmetry), leaving the problem globally unchanged.
> *"An important point to notice is that the cross field is not related to the reference axis used to compute angles."*
### 2.2 The PDE Formulation
The goal is to smoothly propagate boundary orientation information into the interior. This is analogous to the steady-state of a conduction (heat) problem:
$$
\frac{\partial \mathbf{u}}{\partial t} - \text{div}(k\nabla \mathbf{u}) = 0
$$
With $k$ constant, at steady state we get the **Laplace equation**. Minimizing the Dirichlet energy gives us the smoothest possible field:
$$
\begin{cases}
J(\mathbf{u}) = \int_\Omega |\nabla \mathbf{u}|^2\, dx, \\
\mathbf{u}(x) = \mathbf{u}_0(x) \quad \forall x \in \partial\Omega.
\end{cases}
\tag{Problem 3}
$$
where $\mathbf{u}$ is the representation vector field and $\mathbf{u}_0$ is the **Dirichlet boundary condition** — the representation vector of the cross defined by tangent and normal directions at each boundary point.
**Boundary conditions**: At smooth boundary points, the cross is aligned with the tangent/normal. At $C^0$ corners (sharp features), the representation vector is defined as the **average** of the two incident geometric edges' representation vectors.
#### The Unit Norm Constraint
Problem 3 is a standard elliptic PDE — easy to solve. However, the solution may produce vectors with varying norms. Since only *directions* matter (not magnitudes), we add a non-linear constraint:
$$
\begin{cases}
J(\mathbf{u}) = \int_\Omega |\nabla \mathbf{u}|^2\, dx, \\
\mathbf{u}(x) = \mathbf{u}_0(x) \quad \forall x \in \partial\Omega, \\
|\mathbf{u}(x)| = 1 \quad \forall x \in \Omega.
\end{cases}
\tag{Problem 4}
$$
> *"Unlike Problem 2, Problem 4 is ill-posed, as a solution may not exist."*
The unit norm constraint prevents the field from having zeros. But the unconstrained solution may have zeros (where the field "wants" to vanish — these become singularities). The paper handles this through a two-step approach.
### 2.3 Solving the Linear Approximation
First, ignore the norm constraint and solve Problem 3. Splitting $\mathbf{u} = \tilde{\mathbf{u}} + \mathbf{u}_0$, where $\tilde{\mathbf{u}}$ vanishes on $\partial\Omega$, the weak form is:
$$
\forall \mathbf{v} \in V, \quad \int_\Omega \nabla\tilde{\mathbf{u}} \nabla\mathbf{v}\, dx = -\int_\Omega \nabla\mathbf{u}_0 \nabla\mathbf{v}\, dx
$$
with $V = H^1_0(\Omega)$. The Lax-Milgram theorem guarantees existence and uniqueness.
**Discretization**: Galerkin FEM on the triangulation $T_h$ with $P_1$-Lagrange elements (piecewise linear). This yields a symmetric positive-definite linear system $Ax = b$, solved by conjugate gradient.
::: {.callout-tip}
## Connecting to the Demo
This is exactly what the **"Solve linear"** button does in the [NACA airfoil demo](demos/naca_airfoil.html) — it solves the graph Laplacian (the discrete analog of the Dirichlet energy minimization) with Dirichlet conditions at constrained boundary vertices. The Gauss-Seidel iteration used there is equivalent to solving $Ax = b$ iteratively.
:::
### 2.4 Linearization of the Norm Constraint
The linear solution from Sec. 2.3 gives a good direction field, but vectors may not have unit norm. The paper refines this through an **iterative process** — alternating between a PDE solve and normalization.
At each step $n$, given a current solution $\mathbf{v}^n$ with $|\mathbf{v}^n(p)| = 1$ at each mesh point $p$, we add a linear constraint for step $n+1$:
$$
\mathbf{v}^{n+1}(p) \cdot \mathbf{v}^n(p) = 1
$$
This is a linear approximation of $|\mathbf{v}^{n+1}| = 1$ near the current solution. Using **Lagrange multipliers** $\lambda_p$, this leads to a saddle-point system:
$$
\begin{pmatrix} A & C \\ C & 0 \end{pmatrix} \begin{pmatrix} u \\ \lambda \end{pmatrix} = \begin{pmatrix} b \\ 1 \end{pmatrix}
$$
where $C$ is a diagonal matrix encoding the constraint $\mathbf{v}^{n+1} \cdot \mathbf{v}^n = 1$ at each interior point, with entries $C(2p, 2p) = \alpha$ and $C(2p+1, 2p+1) = \beta$ for $\mathbf{v}^n(p) = (\alpha, \beta)$.
**Algorithm**: Solve the constrained system, then **normalize** all vectors to unit length. This guarantees $|\mathbf{v}^{n+1}| \geq 1$ (the constraint ensures the component along $\mathbf{v}^n$ is 1, so the full vector is at least unit length). Normalization then reduces $J$. Repeat until convergence.
> *"The triangulation $T_h$ that we use to solve the Problem 4 has no significative influence on the structure of the generated directionality field with regard to the convergence and the stability of the procedure."*
---
## 3. Domain Partitioning
> *"The general idea of our approach consists in analyzing the topology of this cross field. This means identifying its singularities, or zeroes, and connecting them, thus emphasizing its underlying structure."*
At this stage we have a smooth, unit-length cross field $CF$ over the triangulation. The next step is to extract its **topological skeleton** — singularities connected by separatrices — which partitions the domain.
### 3.1 Singularities of a Directionality Field
**Definition**: A point $x \in \Omega$ where $\mathbf{v}(x) = 0$ is a **singularity** of the representation vector field. In practice, these are detected as triangles where the piecewise-linear interpolation of $\mathbf{v}$ passes through zero.
The **Poincare index** measures the "topological charge" of a singularity:
$$
i_x = \frac{1}{2\pi} \oint_\gamma d\phi, \quad \text{with } \phi = \arctan\frac{v_1}{v_2}.
$$
This counts the number of full rotations the vector field makes as you traverse a closed curve $\gamma$ around the singularity.
**Proposition 1**: For a piecewise-linear vector field defined at triangle vertices with all non-zero values, **all singularities are of first order** — their index is either $+1$ or $-1$.
```{ojs}
//| label: fig-singularity-index
viewof singType = Inputs.radio(["index +1 (3-valent)", "index -1 (5-valent)"], {value: "index +1 (3-valent)", label: "Singularity type"})
```
```{ojs}
//| code-fold: true
{
const w = 400, h = 400, cx = w/2, cy = h/2;
const svg = d3.create("svg").attr("viewBox", `0 0 ${w} ${h}`).style("width", "100%").style("max-width", w + "px");
const isPos = singType.startsWith("index +1");
const valence = isPos ? 3 : 5;
const indexVal = isPos ? +1 : -1;
// Draw crosses around the singularity
const nCrosses = 12;
const ringR = 140;
const crossR = 22;
// Singularity marker
svg.append("circle").attr("cx", cx).attr("cy", cy).attr("r", 6).attr("fill", "#e74c3c");
svg.append("text").attr("x", cx).attr("y", cy - 15).attr("text-anchor", "middle")
.attr("font-size", 13).attr("fill", "#e74c3c").attr("font-weight", "bold")
.text(`index = ${indexVal > 0 ? "+" : ""}${indexVal}`);
for (let i = 0; i < nCrosses; i++) {
const phi = 2 * Math.PI * i / nCrosses;
const px = cx + ringR * Math.cos(phi);
const py = cy - ringR * Math.sin(phi);
// Cross orientation: for index +1, the cross rotates by +2pi going around once
// In rep vector space: angle = indexVal * phi (for the cross field, index of rep vec = 4*index of cross)
// For a +1 singularity of the cross field, rep vector index = +1, so rep rotates by 2pi
// Cross itself rotates by pi/2 going around once
const crossAngle = indexVal * phi / 4 + Math.PI / 8;
for (let k = 0; k < 4; k++) {
const a = crossAngle + k * Math.PI / 2;
svg.append("line")
.attr("x1", px + crossR * Math.cos(a)).attr("y1", py - crossR * Math.sin(a))
.attr("x2", px - crossR * Math.cos(a)).attr("y2", py + crossR * Math.sin(a))
.attr("stroke", k % 2 === 0 ? "steelblue" : "#888")
.attr("stroke-width", 1.8).attr("stroke-linecap", "round");
}
}
// Rotation arrow showing accumulated angle
const arrowR = ringR + 35;
const arcPts = d3.range(0, 1.8 * Math.PI, 0.02).map(a => [cx + arrowR * Math.cos(a), cy - arrowR * Math.sin(a)]);
svg.append("path").attr("d", d3.line()(arcPts)).attr("fill", "none").attr("stroke", "#999").attr("stroke-width", 1).attr("stroke-dasharray", "4,3");
svg.append("text").attr("x", cx).attr("y", h - 10).attr("text-anchor", "middle").attr("font-size", 14).attr("fill", "#333")
.text(isPos ? "3 separatrices (valence 3)" : "5 separatrices (valence 5)");
return svg.node();
}
```
The connection between representation vector field singularities and cross field structure:
- **Index $+1$** of the vector field $\Rightarrow$ the cross rotates by $+\pi/2$ around the singularity $\Rightarrow$ **3 separatrices** emanate $\Rightarrow$ **3-valent** singularity in the quad mesh
- **Index $-1$** of the vector field $\Rightarrow$ the cross rotates by $-\pi/2$ around the singularity $\Rightarrow$ **5 separatrices** emanate $\Rightarrow$ **5-valent** singularity in the quad mesh
### 3.2 Streamlines and Separatrices
A **streamline** of a vector field $\mathbf{v}$ is a curve $\gamma(s)$ whose tangent everywhere matches the field:
$$
\forall\, s \in [0,1],\quad \partial\gamma(s) \times \mathbf{v}(\gamma(s)) = 0
$$
For cross fields, a streamline can follow any of the 4 arms of the cross at each point:
$$
\forall\, s \in [0,1],\; \exists\, i \in [1..4],\quad \partial\gamma(s) \times \mathbf{v}_i(\gamma(s)) = 0
$$
A **separatrix** is a special streamline that ends at a singularity: $\exists\, t \in [0,1],\; \gamma(t) = S_0$ where $S_0$ is a singularity.
#### Numerical Computation of Separatrices
To trace a separatrix from singularity $S_0$ located in triangle $T_0$:
1. **Find exit points**: For each edge of $T_0$, check if a separatrix crosses it. At edge point $P$, interpolate the cross field $CF(P)$ from vertex values. A separatrix passes through $P$ if the direction $\overrightarrow{S_0 P}$ aligns with one of the 4 cross arms at $P$:
$$
\exists\, k \in [0..3],\quad \frac{\mathbf{v}_k}{|\mathbf{v}_k|} = \frac{\mathbf{u}(P)}{|\mathbf{u}(P)|}
$$
2. **Integrate through triangles**: Once we have an exit point $X_i$ on the boundary of triangle $T$ with direction $\mathbf{d}_i$, use **Heun's method** (improved Euler) to find where the streamline exits $T$:
- The cross field is interpolated linearly from vertex representation vectors within $T$
- The cross arm closest to the incoming direction $\mathbf{d}_i$ is selected for consistency
- The ODE $Y = f(X)$ is integrated to find the next exit point $X_{i+1}$ and direction $\mathbf{d}_{i+1}$
3. **Repeat** until the separatrix hits a boundary ($\partial\Omega$) or another singularity.
::: {.callout-tip}
## Connecting to the Demo
The **Streamlines** visualization in the [NACA airfoil demo](demos/naca_airfoil.html) traces cross field streamlines using exactly this approach — Euler integration through the mesh, selecting the closest cross arm at each step for direction continuity.
:::
### 3.3 Definition of the Domain Partitioning
Combining the separatrices from all singularities (field singularities + geometric singularities at boundary corners) produces a partition of $\Omega$.
> *"Every separatrix ends either on a singularity or on $\partial\Omega$."*
**Geometric singularities**: The $C^0$ corners of $\partial\Omega$ are treated as additional singularities. Separatrices are traced from these corners into the domain using the same integration process.
**Proposition 2**: *The set of separatrices of the geometric and field singularities leads to a partition of $\Omega$ into 3- or 4-sided regions. The 3-sided regions necessarily contain a singularity.*
**Proof sketch**: Every region is singularity-free (by construction, singularities are only at corners). Inside each region, the cross field is regular, so a parametrization $f(u,v)$ exists. The boundaries inherit the cross field's two orthogonal families of streamlines. A region can only be 3-sided if one edge degenerates to a point (a singularity where one family of streamlines converges).
Once we have four-sided regions, each is meshed independently via **transfinite bilinear interpolation** — a standard technique that maps the unit square $[0,1]^2$ to the curved quadrilateral region, producing a structured quad mesh within each block.
---
### 3.4 Deep Dive: Computing Streamline Directions
The pipeline demo in Section 6 traces streamlines through the cross field, but *how exactly* does the algorithm decide which direction to follow at each point? This section unpacks the math and the numerical scheme step by step.
Recall the streamline condition for a vector field (Definition 3 in the paper):
$$
\forall\, s \in [0,1], \quad \partial\gamma(s) \times \mathbf{v}(\gamma(s)) = 0
$$
The tangent of the curve $\gamma$ at every point must be **parallel** to the field value at that point. For a cross field with four arms, we relax this to allow *any* of the four directions (Definition 4):
$$
\forall\, s \in [0,1],\; \exists\, i \in [1..4], \quad \partial\gamma(s) \times \mathbf{v}_i(\gamma(s)) = 0
$$
This means the streamline follows whichever cross arm is locally aligned with its current heading. The challenge is computing the field value at an arbitrary point inside a triangle — and maintaining directional continuity as the streamline crosses from one triangle to the next.
#### Step 1: Barycentric Interpolation of the Cross Field
Given a triangle with vertices $S_1, S_2, S_3$ having representation vectors $\mathbf{u}_1, \mathbf{u}_2, \mathbf{u}_3$, the field at any interior point $P$ is interpolated using **barycentric coordinates** $(\lambda_1, \lambda_2, \lambda_3)$:
$$
\mathbf{u}(P) = \lambda_1 \mathbf{u}_1 + \lambda_2 \mathbf{u}_2 + \lambda_3 \mathbf{u}_3
$$
Since each representation vector encodes the cross angle $\theta$ as $\mathbf{u} = (\cos 4\theta, \sin 4\theta)$, the interpolation happens in **representation space** — not in angle space directly (averaging angles would fail near $0/\pi$ wraparound). The cross angle at $P$ is recovered as:
$$
\theta(P) = \frac{1}{4}\operatorname{atan2}\!\Big(\sum_k \lambda_k \sin 4\theta_k,\; \sum_k \lambda_k \cos 4\theta_k\Big)
$$
This gives the four cross arms at $P$ as:
$$
\mathbf{v}_k(P) = \left(\cos\!\left(\theta(P) + \frac{k\pi}{2}\right),\; \sin\!\left(\theta(P) + \frac{k\pi}{2}\right)\right), \quad k = 0,1,2,3
$$
#### Step 2: Arm Selection for Directional Continuity
At each integration step, we must pick **one** of the four arms to follow. The rule is simple: choose the arm whose direction is **closest** to the previous step's direction $\mathbf{d}_i$:
$$
k^* = \arg\max_{k \in \{0,1,2,3\}} \; \mathbf{v}_k(P) \cdot \mathbf{d}_i
$$
This ensures the streamline doesn't "jump" between arms as it crosses triangles — it smoothly follows one family of curves.
**Try it below** — click inside the triangle to place a probe point, and drag the green arrow to change the "previous direction." Watch how the selected arm (highlighted) changes as you rotate the incoming direction.
```{ojs}
//| label: fig-triangle-interp
viewof v0_angle_deg = Inputs.range([0, 89], {value: 10, step: 1, label: "Vertex A angle (°)"})
viewof v1_angle_deg = Inputs.range([0, 89], {value: 45, step: 1, label: "Vertex B angle (°)"})
viewof v2_angle_deg = Inputs.range([0, 89], {value: 75, step: 1, label: "Vertex C angle (°)"})
viewof prevDir_deg = Inputs.range([0, 359], {value: 30, step: 1, label: "Previous direction (°)"})
```
```{ojs}
//| code-fold: true
{
const W = 560, H = 480;
const svg = d3.create("svg").attr("viewBox", `0 0 ${W} ${H}`).style("width", "100%").style("max-width", W + "px");
// Triangle vertices (large equilateral, centered)
const triCx = W / 2, triCy = H / 2 + 20;
const triR = 170;
const verts = [
{ x: triCx + triR * Math.cos(Math.PI/2), y: triCy - triR * Math.sin(Math.PI/2), label: "A", angle: v0_angle_deg * Math.PI / 180 },
{ x: triCx + triR * Math.cos(Math.PI/2 + 2*Math.PI/3), y: triCy - triR * Math.sin(Math.PI/2 + 2*Math.PI/3), label: "B", angle: v1_angle_deg * Math.PI / 180 },
{ x: triCx + triR * Math.cos(Math.PI/2 + 4*Math.PI/3), y: triCy - triR * Math.sin(Math.PI/2 + 4*Math.PI/3), label: "C", angle: v2_angle_deg * Math.PI / 180 }
];
// Draw triangle
const triPath = `M${verts[0].x},${verts[0].y} L${verts[1].x},${verts[1].y} L${verts[2].x},${verts[2].y} Z`;
svg.append("path").attr("d", triPath).attr("fill", "rgba(200,220,240,0.2)").attr("stroke", "#2c3e50").attr("stroke-width", 1.5);
// Draw crosses at vertices
const vertColors = ["steelblue", "#e74c3c", "#2c3e50"];
const armLen = 28;
verts.forEach((v, idx) => {
for (let k = 0; k < 4; k++) {
const a = v.angle + k * Math.PI / 2;
svg.append("line")
.attr("x1", v.x + armLen * Math.cos(a)).attr("y1", v.y - armLen * Math.sin(a))
.attr("x2", v.x - armLen * Math.cos(a)).attr("y2", v.y + armLen * Math.sin(a))
.attr("stroke", vertColors[idx]).attr("stroke-width", 2).attr("stroke-linecap", "round");
}
svg.append("text").attr("x", v.x + (v.x - triCx) * 0.22).attr("y", v.y + (v.y - triCy) * 0.22)
.attr("text-anchor", "middle").attr("dominant-baseline", "middle")
.attr("font-size", 15).attr("font-weight", "bold").attr("fill", vertColors[idx])
.text(`${v.label} (${Math.round(v.angle * 180 / Math.PI)}°)`);
});
// Probe point at centroid (can be extended to click-to-place)
const px = (verts[0].x + verts[1].x + verts[2].x) / 3;
const py = (verts[0].y + verts[1].y + verts[2].y) / 3;
// Barycentric coordinates
const d = (verts[1].y - verts[2].y)*(verts[0].x - verts[2].x) + (verts[2].x - verts[1].x)*(verts[0].y - verts[2].y);
const lam0 = ((verts[1].y - verts[2].y)*(px - verts[2].x) + (verts[2].x - verts[1].x)*(py - verts[2].y)) / d;
const lam1 = ((verts[2].y - verts[0].y)*(px - verts[2].x) + (verts[0].x - verts[2].x)*(py - verts[2].y)) / d;
const lam2 = 1 - lam0 - lam1;
// Interpolate in (cos4θ, sin4θ) space
const rc = lam0 * Math.cos(4*verts[0].angle) + lam1 * Math.cos(4*verts[1].angle) + lam2 * Math.cos(4*verts[2].angle);
const rs = lam0 * Math.sin(4*verts[0].angle) + lam1 * Math.sin(4*verts[1].angle) + lam2 * Math.sin(4*verts[2].angle);
const interpAngle = Math.atan2(rs, rc) / 4;
// Draw probe point
svg.append("circle").attr("cx", px).attr("cy", py).attr("r", 5).attr("fill", "orange");
// Barycentric coordinate labels
svg.append("text").attr("x", px + 10).attr("y", py - 25).attr("font-size", 11).attr("fill", "#666")
.text(`λ = (${lam0.toFixed(2)}, ${lam1.toFixed(2)}, ${lam2.toFixed(2)})`);
svg.append("text").attr("x", px + 10).attr("y", py - 10).attr("font-size", 11).attr("fill", "orange").attr("font-weight", "bold")
.text(`θ(P) = ${(interpAngle * 180 / Math.PI).toFixed(1)}°`);
// Draw interpolated cross arms at probe point
const prevDirRad = prevDir_deg * Math.PI / 180;
const probeArmLen = 40;
let bestK = 0, bestDot = -Infinity;
for (let k = 0; k < 4; k++) {
const a = interpAngle + k * Math.PI / 2;
const dot = Math.cos(a) * Math.cos(prevDirRad) + (-Math.sin(a)) * (-Math.sin(prevDirRad));
if (dot > bestDot) { bestDot = dot; bestK = k; }
}
for (let k = 0; k < 4; k++) {
const a = interpAngle + k * Math.PI / 2;
const isSelected = (k === bestK);
svg.append("line")
.attr("x1", px + probeArmLen * Math.cos(a)).attr("y1", py - probeArmLen * Math.sin(a))
.attr("x2", px - probeArmLen * Math.cos(a)).attr("y2", py + probeArmLen * Math.sin(a))
.attr("stroke", isSelected ? "#27ae60" : "rgba(0,0,0,0.25)")
.attr("stroke-width", isSelected ? 3.5 : 1.5)
.attr("stroke-linecap", "round");
if (isSelected) {
// Arrowhead on selected arm
const tipX = px + probeArmLen * Math.cos(a), tipY = py - probeArmLen * Math.sin(a);
const ba = Math.atan2(probeArmLen * Math.sin(a), probeArmLen * Math.cos(a)) + Math.PI;
const hl = 10, ha = 0.4;
svg.append("polygon")
.attr("points", `${tipX},${tipY} ${tipX+hl*Math.cos(ba+ha)},${tipY-hl*Math.sin(ba+ha)} ${tipX+hl*Math.cos(ba-ha)},${tipY-hl*Math.sin(ba-ha)}`)
.attr("fill", "#27ae60");
svg.append("text").attr("x", tipX + 12*Math.cos(a)).attr("y", tipY - 12*Math.sin(a))
.attr("text-anchor", "middle").attr("font-size", 11).attr("fill", "#27ae60").attr("font-weight", "bold")
.text("selected");
}
}
// Draw previous direction arrow (green dashed)
const pdLen = 55;
const pdTipX = px + pdLen * Math.cos(prevDirRad), pdTipY = py - pdLen * Math.sin(prevDirRad);
svg.append("line").attr("x1", px).attr("y1", py).attr("x2", pdTipX).attr("y2", pdTipY)
.attr("stroke", "#27ae60").attr("stroke-width", 2.5).attr("stroke-dasharray", "6,4");
const pdba = Math.atan2(pdLen*Math.sin(prevDirRad), pdLen*Math.cos(prevDirRad)) + Math.PI;
svg.append("polygon")
.attr("points", `${pdTipX},${pdTipY} ${pdTipX+8*Math.cos(pdba+0.4)},${pdTipY-8*Math.sin(pdba+0.4)} ${pdTipX+8*Math.cos(pdba-0.4)},${pdTipY-8*Math.sin(pdba-0.4)}`)
.attr("fill", "#27ae60");
svg.append("text").attr("x", pdTipX + 15*Math.cos(prevDirRad)).attr("y", pdTipY - 15*Math.sin(prevDirRad))
.attr("text-anchor", "middle").attr("font-size", 11).attr("fill", "#27ae60")
.text("d_prev");
// Representation vector (orange dashed)
const repLen = 35;
svg.append("line").attr("x1", px).attr("y1", py)
.attr("x2", px + repLen*Math.cos(interpAngle)).attr("y2", py - repLen*Math.sin(interpAngle))
.attr("stroke", "orange").attr("stroke-width", 3).attr("stroke-dasharray", "5,3");
// Title
svg.append("text").attr("x", W/2).attr("y", 18).attr("text-anchor", "middle").attr("font-size", 14).attr("fill", "#333")
.text("Cross field interpolation at probe point P (orange dot)");
return svg.node();
}
```
::: {.callout-tip}
## Why arm selection matters
Without arm selection, the streamline would randomly jump between the four cross directions at each step, producing a jagged, meaningless path. By always picking the arm **closest to the previous heading**, the algorithm commits to one "family" of streamlines and traces it smoothly. The orthogonal family is obtained by starting with a direction rotated by $\pi/2$.
:::
#### Step 3: Heun's Method for Triangle-to-Triangle Integration
The paper uses **Heun's method** (an improved Euler scheme) to trace streamlines through the triangulation. Given an entry point $X_i$ on an edge of triangle $T$ with direction $\mathbf{d}_i$:
> *"We use the Heun's method, an easy-to-use variation of a Runge-Kutta method, to integrate the ODE $Y = f(X)$ over $T$, starting from $X_i$."*
**Predictor** (Euler step): Take a full step in the current direction to get a tentative exit point:
$$
\tilde{X} = X_i + h \cdot \mathbf{v}_{\mathbf{d}_i}(X_i)
$$
**Corrector** (average of endpoint slopes): Evaluate the field at both ends and average:
$$
X_{i+1} = X_i + \frac{h}{2}\left[\mathbf{v}_{\mathbf{d}_i}(X_i) + \mathbf{v}_{\mathbf{d}_i}(\tilde{X})\right]
$$
where $h$ is chosen so the line segment exits the triangle. In practice, we find the intersection of the ray from $X_i$ in direction $\mathbf{d}_i$ with the opposite edge of $T$.
The interactive below shows this process on a strip of connected triangles. Click **Step** to advance one triangle at a time, and toggle between Euler and Heun to see the improvement.
```{=html}
<style>
#heun-container { margin: 1rem 0; }
#heun-canvas {
display: block; width: 100%; height: 380px;
border: 1px solid var(--bs-border-color, #dee2e6); border-radius: 6px;
background: var(--bs-body-bg, #fff);
}
.heun-controls {
display: flex; gap: 0.6rem; flex-wrap: wrap; margin: 0.6rem 0; align-items: center;
}
.heun-controls button, .heun-controls select {
padding: 0.3rem 0.8rem; border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 4px; background: var(--bs-body-bg, #fff); color: var(--bs-body-color, #212529);
font-size: 0.82rem; cursor: pointer;
}
.heun-controls button:hover { background: var(--bs-primary, #2c3e50); color: #fff; }
.heun-controls label { font-size: 0.8rem; color: var(--bs-secondary-color, #6c757d); }
#heun-info { font-size: 0.82rem; color: var(--bs-secondary-color, #6c757d); font-family: monospace; margin-top: 0.3rem; }
</style>
<div id="heun-container">
<div class="heun-controls">
<button id="heun-step">Step</button>
<button id="heun-reset">Reset</button>
<label>Method: </label>
<select id="heun-method">
<option value="euler">Euler (1st order)</option>
<option value="heun" selected>Heun (2nd order)</option>
</select>
</div>
<canvas id="heun-canvas"></canvas>
<div id="heun-info">Click "Step" to trace through each triangle.</div>
</div>
<script>
(function() {
const canvas = document.getElementById('heun-canvas');
const ctx = canvas.getContext('2d');
const info = document.getElementById('heun-info');
// 7 triangles forming a zig-zag strip
const verts = [
{x: 0.05, y: 0.5}, {x: 0.2, y: 0.15}, {x: 0.2, y: 0.85},
{x: 0.38, y: 0.5}, {x: 0.38, y: 0.12}, {x: 0.55, y: 0.82},
{x: 0.55, y: 0.2}, {x: 0.72, y: 0.55}, {x: 0.72, y: 0.1},
{x: 0.88, y: 0.48}, {x: 0.95, y: 0.85}
];
// Cross field angles at each vertex (smoothly varying)
const vertAngles = [0.15, 0.25, 0.1, 0.35, 0.3, 0.2, 0.45, 0.5, 0.4, 0.55, 0.3];
// Triangles (indices)
const tris = [
[0,1,2], [1,2,3], [1,3,4], [3,2,5], [3,5,6], [6,5,7], [7,5,10], [6,7,8], [7,8,9]
];
let path = []; // [{x, y}]
let predictorPts = []; // [{from, to}] for visualization
let correctorPts = [];
let currentTri = 0;
let currentPt = {x: 0.05, y: 0.5};
let currentDir = 0.25; // radians
let stepCount = 0;
function resizeCanvas() {
const rect = canvas.parentElement.getBoundingClientRect();
const w = rect.width;
const h = 380;
canvas.width = w * devicePixelRatio;
canvas.height = h * devicePixelRatio;
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
}
function w2s(wx, wy) {
const cw = canvas.width / devicePixelRatio, ch = canvas.height / devicePixelRatio;
const margin = 30;
return {
sx: margin + wx * (cw - 2*margin),
sy: margin + (1-wy) * (ch - 2*margin)
};
}
function interpField(triIdx, px, py) {
if (triIdx < 0 || triIdx >= tris.length) return currentDir;
const [i0,i1,i2] = tris[triIdx];
const v0=verts[i0], v1=verts[i1], v2=verts[i2];
const dd = (v1.y-v2.y)*(v0.x-v2.x)+(v2.x-v1.x)*(v0.y-v2.y);
if (Math.abs(dd) < 1e-12) return currentDir;
const w0 = ((v1.y-v2.y)*(px-v2.x)+(v2.x-v1.x)*(py-v2.y))/dd;
const w1 = ((v2.y-v0.y)*(px-v2.x)+(v0.x-v2.x)*(py-v2.y))/dd;
const w2 = 1-w0-w1;
const a0=vertAngles[i0], a1=vertAngles[i1], a2=vertAngles[i2];
const rc = w0*Math.cos(4*a0)+w1*Math.cos(4*a1)+w2*Math.cos(4*a2);
const rs = w0*Math.sin(4*a0)+w1*Math.sin(4*a1)+w2*Math.sin(4*a2);
const base = Math.atan2(rs, rc) / 4;
// Pick arm closest to currentDir
let best = base, bestDot = -Infinity;
for (let k = 0; k < 4; k++) {
const c = base + k*Math.PI/2;
const dot = Math.cos(c)*Math.cos(currentDir) + Math.sin(c)*Math.sin(currentDir);
if (dot > bestDot) { bestDot = dot; best = c; }
}
return best;
}
function findContainingTri(px, py) {
for (let t = 0; t < tris.length; t++) {
const [i0,i1,i2] = tris[t];
const v0=verts[i0], v1=verts[i1], v2=verts[i2];
const dd = (v1.y-v2.y)*(v0.x-v2.x)+(v2.x-v1.x)*(v0.y-v2.y);
if (Math.abs(dd) < 1e-12) continue;
const w0 = ((v1.y-v2.y)*(px-v2.x)+(v2.x-v1.x)*(py-v2.y))/dd;
const w1 = ((v2.y-v0.y)*(px-v2.x)+(v0.x-v2.x)*(py-v2.y))/dd;
const w2 = 1-w0-w1;
if (w0 >= -0.05 && w1 >= -0.05 && w2 >= -0.05) return t;
}
return -1;
}
function doStep() {
const method = document.getElementById('heun-method').value;
const stepSize = 0.08;
const dir1 = interpField(currentTri, currentPt.x, currentPt.y);
currentDir = dir1;
const predX = currentPt.x + stepSize * Math.cos(dir1);
const predY = currentPt.y + stepSize * Math.sin(dir1);
predictorPts.push({from: {...currentPt}, to: {x: predX, y: predY}});
let nextX, nextY;
if (method === 'heun') {
const nextTri = findContainingTri(predX, predY);
const dir2 = interpField(nextTri >= 0 ? nextTri : currentTri, predX, predY);
const avgDx = (Math.cos(dir1) + Math.cos(dir2)) / 2;
const avgDy = (Math.sin(dir1) + Math.sin(dir2)) / 2;
nextX = currentPt.x + stepSize * avgDx;
nextY = currentPt.y + stepSize * avgDy;
correctorPts.push({from: {...currentPt}, to: {x: nextX, y: nextY}});
} else {
nextX = predX;
nextY = predY;
}
currentPt = {x: nextX, y: nextY};
currentTri = findContainingTri(nextX, nextY);
path.push({...currentPt});
stepCount++;
if (currentTri < 0) {
info.textContent = `Step ${stepCount}: streamline exited the mesh.`;
} else {
info.textContent = `Step ${stepCount}: in triangle ${currentTri}, direction = ${(currentDir*180/Math.PI).toFixed(1)}° [${method}]`;
}
draw();
}
function reset() {
path = [{x: 0.05, y: 0.5}];
predictorPts = [];
correctorPts = [];
currentTri = 0;
currentPt = {x: 0.05, y: 0.5};
currentDir = 0.25;
stepCount = 0;
info.textContent = 'Click "Step" to trace through each triangle.';
draw();
}
function draw() {
const cw = canvas.width / devicePixelRatio, ch = canvas.height / devicePixelRatio;
ctx.clearRect(0, 0, cw, ch);
// Draw triangles
ctx.strokeStyle = 'rgba(0,0,0,0.15)';
ctx.lineWidth = 1;
for (const [i0,i1,i2] of tris) {
const s0=w2s(verts[i0].x,verts[i0].y), s1=w2s(verts[i1].x,verts[i1].y), s2=w2s(verts[i2].x,verts[i2].y);
ctx.beginPath();
ctx.moveTo(s0.sx,s0.sy); ctx.lineTo(s1.sx,s1.sy); ctx.lineTo(s2.sx,s2.sy); ctx.closePath();
ctx.stroke();
}
// Draw crosses at vertices
const cr = 12;
for (let i = 0; i < verts.length; i++) {
const s = w2s(verts[i].x, verts[i].y);
ctx.strokeStyle = 'rgba(70,130,180,0.5)';
ctx.lineWidth = 1.5;
for (let k = 0; k < 2; k++) {
const a = vertAngles[i] + k * Math.PI / 2;
ctx.beginPath();
ctx.moveTo(s.sx + cr*Math.cos(a), s.sy - cr*Math.sin(a));
ctx.lineTo(s.sx - cr*Math.cos(a), s.sy + cr*Math.sin(a));
ctx.stroke();
}
}
// Draw predictor arrows (orange dashed)
for (const p of predictorPts) {
const sf = w2s(p.from.x, p.from.y), st = w2s(p.to.x, p.to.y);
ctx.strokeStyle = 'rgba(230,126,34,0.5)';
ctx.lineWidth = 1.5;
ctx.setLineDash([5,4]);
ctx.beginPath(); ctx.moveTo(sf.sx, sf.sy); ctx.lineTo(st.sx, st.sy); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = 'rgba(230,126,34,0.5)';
ctx.beginPath(); ctx.arc(st.sx, st.sy, 3, 0, Math.PI*2); ctx.fill();
}
// Draw corrector arrows (steelblue solid) — only for Heun
for (const p of correctorPts) {
const sf = w2s(p.from.x, p.from.y), st = w2s(p.to.x, p.to.y);
ctx.strokeStyle = 'steelblue';
ctx.lineWidth = 2;
ctx.beginPath(); ctx.moveTo(sf.sx, sf.sy); ctx.lineTo(st.sx, st.sy); ctx.stroke();
}
// Draw path
if (path.length > 1) {
ctx.strokeStyle = '#e74c3c';
ctx.lineWidth = 2.5;
ctx.lineJoin = 'round';
ctx.beginPath();
const s0 = w2s(path[0].x, path[0].y);
ctx.moveTo(s0.sx, s0.sy);
for (let i = 1; i < path.length; i++) {
const s = w2s(path[i].x, path[i].y);
ctx.lineTo(s.sx, s.sy);
}
ctx.stroke();
}
// Draw current point
const sc = w2s(currentPt.x, currentPt.y);
ctx.fillStyle = '#e74c3c';
ctx.beginPath(); ctx.arc(sc.sx, sc.sy, 5, 0, Math.PI*2); ctx.fill();
// Legend
ctx.font = '12px monospace';
ctx.fillStyle = '#e74c3c'; ctx.fillText('— path', cw - 140, 20);
ctx.fillStyle = 'rgba(230,126,34,0.7)'; ctx.fillText('--- predictor', cw - 140, 36);
ctx.fillStyle = 'steelblue'; ctx.fillText('— corrector', cw - 140, 52);
}
document.getElementById('heun-step').addEventListener('click', doStep);
document.getElementById('heun-reset').addEventListener('click', reset);
resizeCanvas();
window.addEventListener('resize', () => { resizeCanvas(); draw(); });
reset();
})();
</script>
```
---
### 3.5 Deep Dive: Tracing Separatrices from Singularities
Separatrices are the **structural skeleton** of the cross field. They are special streamlines that start (or end) at singularities, and they form the boundaries of the block partition. Understanding how they are found and traced is essential.
> *"For each singularity $S$, we build the separatrices, which are the streamlines of the cross field ending on $S$."*
#### Finding Separatrix Exit Points (Eq. 11)
A singularity $S_0$ sits inside a triangle $T_0$ with vertices $S_1, S_2, S_3$. The separatrices must exit $T_0$ through its edges. The question is: **where on each edge does a separatrix cross?**
For any point $P$ on an edge of $T_0$, we can:
1. **Interpolate the cross field** at $P$ by linearly blending the vertex values (in representation space). This gives 4 unit vectors $\mathbf{v}_0(P), \ldots, \mathbf{v}_3(P)$.
2. **Compute the direction** from the singularity to $P$: $\mathbf{u}(P) = \overrightarrow{S_0 P} / |\overrightarrow{S_0 P}|$.
3. **Check for alignment**: A separatrix passes through $P$ if and only if one of the cross arms at $P$ points directly toward (or away from) the singularity:
$$
\exists\, k \in [0..3], \quad \frac{\mathbf{v}_k(P)}{|\mathbf{v}_k(P)|} = \frac{\mathbf{u}(P)}{|\mathbf{u}(P)|}
$$
As we sweep $P$ along an edge, the cross field rotates smoothly while the direction $\overrightarrow{S_0 P}$ also rotates. Each time an arm "catches up" to the S₀→P direction, we find an exit point. The **number of catches** depends on the singularity index:
- **Index +1**: the cross makes an extra $\pi/2$ rotation around $S_0$ → **3 separatrices** exit
- **Index -1**: the cross is short by $\pi/2$ → **5 separatrices** exit
```{ojs}
//| label: fig-separatrix-exit
viewof sepIndexType = Inputs.radio(["index +1 (3 exits)", "index -1 (5 exits)"], {value: "index +1 (3 exits)", label: "Singularity type"})
viewof sepSampleDensity = Inputs.range([10, 50], {value: 25, step: 1, label: "Edge samples"})
```
```{ojs}
//| code-fold: true
{
const W = 580, H = 520;
const svg = d3.create("svg").attr("viewBox", `0 0 ${W} ${H}`).style("width", "100%").style("max-width", W + "px");
const isPos = sepIndexType.startsWith("index +1");
const expectedExits = isPos ? 3 : 5;
// Triangle vertices (large)
const cx = W/2, cy = H/2 + 10;
const R = 200;
const tri = [
{x: cx, y: cy - R},
{x: cx - R*Math.sin(2*Math.PI/3), y: cy + R*Math.cos(2*Math.PI/3)},
{x: cx + R*Math.sin(2*Math.PI/3), y: cy + R*Math.cos(2*Math.PI/3)}
];
// Vertex cross angles chosen to produce the desired singularity index.
// For index +1: angles that sum to +π/2 of angle defect
// For index -1: angles that sum to -π/2 of angle defect
const vertAngles = isPos
? [0.0, Math.PI/6, Math.PI/3 + Math.PI/8]
: [0.0, -Math.PI/8, Math.PI/6];
// Singularity at centroid
const sx = (tri[0].x + tri[1].x + tri[2].x) / 3;
const sy = (tri[0].y + tri[1].y + tri[2].y) / 3;
// Draw triangle
svg.append("path")
.attr("d", `M${tri[0].x},${tri[0].y} L${tri[1].x},${tri[1].y} L${tri[2].x},${tri[2].y} Z`)
.attr("fill", "rgba(200,220,240,0.12)").attr("stroke", "#2c3e50").attr("stroke-width", 1.5);
// Draw vertex crosses
const armLen = 22;
const vColors = ["steelblue", "#e74c3c", "#8e44ad"];
for (let vi = 0; vi < 3; vi++) {
for (let k = 0; k < 4; k++) {
const a = vertAngles[vi] + k * Math.PI / 2;
svg.append("line")
.attr("x1", tri[vi].x + armLen*Math.cos(a)).attr("y1", tri[vi].y - armLen*Math.sin(a))
.attr("x2", tri[vi].x - armLen*Math.cos(a)).attr("y2", tri[vi].y + armLen*Math.sin(a))
.attr("stroke", vColors[vi]).attr("stroke-width", 1.8).attr("stroke-linecap", "round");
}
}
// Singularity marker
svg.append("circle").attr("cx", sx).attr("cy", sy).attr("r", 7).attr("fill", "#e74c3c");
svg.append("text").attr("x", sx).attr("y", sy - 14).attr("text-anchor", "middle")
.attr("font-size", 12).attr("fill", "#e74c3c").attr("font-weight", "bold").text("S₀");
// Sample each edge and check for alignment
const edges = [[0,1],[1,2],[2,0]];
const exitPoints = [];
const allSamples = [];
const nSamp = sepSampleDensity;
const alignThreshold = Math.PI / nSamp * 2.0;
for (const [ea, eb] of edges) {
for (let si = 1; si < nSamp; si++) {
const t = si / nSamp;
const px = tri[ea].x + t * (tri[eb].x - tri[ea].x);
const py = tri[ea].y + t * (tri[eb].y - tri[ea].y);
// Barycentric coords for this point
const dd = (tri[1].y-tri[2].y)*(tri[0].x-tri[2].x)+(tri[2].x-tri[1].x)*(tri[0].y-tri[2].y);
const l0 = ((tri[1].y-tri[2].y)*(px-tri[2].x)+(tri[2].x-tri[1].x)*(py-tri[2].y))/dd;
const l1 = ((tri[2].y-tri[0].y)*(px-tri[2].x)+(tri[0].x-tri[2].x)*(py-tri[2].y))/dd;
const l2 = 1-l0-l1;
// Interpolate cross field
const rc = l0*Math.cos(4*vertAngles[0]) + l1*Math.cos(4*vertAngles[1]) + l2*Math.cos(4*vertAngles[2]);
const rs = l0*Math.sin(4*vertAngles[0]) + l1*Math.sin(4*vertAngles[1]) + l2*Math.sin(4*vertAngles[2]);
const baseAngle = Math.atan2(rs, rc) / 4;
// Direction S0 -> P
const dx = px - sx, dy = py - sy;
const dirToP = Math.atan2(-dy, dx); // SVG y is flipped
// Check each arm for alignment
let aligned = false;
let alignedArm = -1;
for (let k = 0; k < 4; k++) {
const arm = baseAngle + k * Math.PI / 2;
let diff = arm - dirToP;
diff = diff - Math.round(diff / (2*Math.PI)) * 2*Math.PI;
if (Math.abs(diff) < alignThreshold) {
aligned = true;
alignedArm = k;
break;
}
}
allSamples.push({px, py, baseAngle, dirToP, aligned, alignedArm});
if (aligned) {
// Check it's not a duplicate
let dup = false;
for (const ep of exitPoints) {
if (Math.sqrt((px-ep.px)**2+(py-ep.py)**2) < 20) { dup = true; break; }
}
if (!dup) exitPoints.push({px, py, dir: baseAngle + alignedArm * Math.PI/2});
}
}
}
// Draw sample points and mini-crosses
const miniArm = 10;
for (const s of allSamples) {
// Direction line from S0 to P (thin gray)
svg.append("line").attr("x1", sx).attr("y1", sy).attr("x2", s.px).attr("y2", s.py)
.attr("stroke", "rgba(0,0,0,0.04)").attr("stroke-width", 0.5);
// Sample dot
svg.append("circle").attr("cx", s.px).attr("cy", s.py).attr("r", s.aligned ? 4 : 2)
.attr("fill", s.aligned ? "#27ae60" : "rgba(0,0,0,0.15)");
// Mini cross at sample point (only every 3rd point to avoid clutter)
if (allSamples.indexOf(s) % 3 === 0) {
for (let k = 0; k < 4; k++) {
const a = s.baseAngle + k * Math.PI / 2;
const isAlignedArm = s.aligned && (k === s.alignedArm);
svg.append("line")
.attr("x1", s.px + miniArm*Math.cos(a)).attr("y1", s.py - miniArm*Math.sin(a))
.attr("x2", s.px - miniArm*Math.cos(a)).attr("y2", s.py + miniArm*Math.sin(a))
.attr("stroke", isAlignedArm ? "#27ae60" : "rgba(0,0,0,0.12)")
.attr("stroke-width", isAlignedArm ? 2.5 : 0.8);
}
}
}
// Highlight exit points
for (const ep of exitPoints) {
svg.append("circle").attr("cx", ep.px).attr("cy", ep.py).attr("r", 7)
.attr("fill", "none").attr("stroke", "#27ae60").attr("stroke-width", 2.5);
// Direction arrow from exit point
const arrowLen = 30;
const tipX = ep.px + arrowLen * Math.cos(ep.dir);
const tipY = ep.py - arrowLen * Math.sin(ep.dir);
svg.append("line").attr("x1", ep.px).attr("y1", ep.py).attr("x2", tipX).attr("y2", tipY)
.attr("stroke", "#27ae60").attr("stroke-width", 2.5).attr("stroke-linecap", "round");
// Small arrowhead
const ba = Math.atan2(arrowLen*Math.sin(ep.dir), arrowLen*Math.cos(ep.dir)) + Math.PI;
svg.append("polygon")
.attr("points", `${tipX},${tipY} ${tipX+7*Math.cos(ba+0.4)},${tipY-7*Math.sin(ba+0.4)} ${tipX+7*Math.cos(ba-0.4)},${tipY-7*Math.sin(ba-0.4)}`)
.attr("fill", "#27ae60");
}
// Label
svg.append("text").attr("x", W/2).attr("y", H - 8).attr("text-anchor", "middle")
.attr("font-size", 13).attr("fill", "#333")
.text(`Found ${exitPoints.length} exit points (expected ${expectedExits}) — green circles show separatrix exits`);
svg.append("text").attr("x", W/2).attr("y", 18).attr("text-anchor", "middle")
.attr("font-size", 14).attr("fill", "#333")
.text(`Separatrix exit detection: ${sepIndexType}`);
return svg.node();
}
```
#### Tracing Outward from Exit Points
Once the exit points are found, the algorithm from Section 3.4 takes over: starting from each exit point with its known direction, we integrate outward through successive triangles using Heun's method. The separatrix continues until it hits:
- The **domain boundary** $\partial\Omega$
- Another **singularity**
- Another **separatrix** (forming a T-junction)
Each traced separatrix becomes an edge in the block partition graph.
The interactive below shows separatrices growing outward from a singularity embedded in a small mesh patch. Each separatrix is colored distinctly.
```{=html}
<style>
#sep-trace-canvas {
display: block; width: 100%; height: 420px;
border: 1px solid var(--bs-border-color, #dee2e6); border-radius: 6px;
background: var(--bs-body-bg, #fff);
}
.sep-trace-controls {
display: flex; gap: 0.6rem; flex-wrap: wrap; margin: 0.6rem 0; align-items: center;
}
.sep-trace-controls button, .sep-trace-controls select {
padding: 0.3rem 0.8rem; border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 4px; background: var(--bs-body-bg, #fff); color: var(--bs-body-color, #212529);
font-size: 0.82rem; cursor: pointer;
}
.sep-trace-controls button:hover { background: var(--bs-primary, #2c3e50); color: #fff; }
#sep-trace-info { font-size: 0.82rem; color: var(--bs-secondary-color, #6c757d); font-family: monospace; margin-top: 0.3rem; }
</style>
<div>
<div class="sep-trace-controls">
<button id="sep-trace-btn">Trace separatrices</button>
<button id="sep-reset-btn">Reset</button>
<select id="sep-index-sel">
<option value="pos" selected>Index +1 (3 separatrices)</option>
<option value="neg">Index -1 (5 separatrices)</option>
</select>
</div>
<canvas id="sep-trace-canvas"></canvas>
<div id="sep-trace-info">Click "Trace" to grow separatrices from the singularity.</div>
</div>
<script>
(function() {
const canvas = document.getElementById('sep-trace-canvas');
const ctx = canvas.getContext('2d');
const info = document.getElementById('sep-trace-info');
// Generate a small mesh patch: a ring of triangles around a central point
function buildPatch(isPositive) {
const cx = 0.5, cy = 0.5;
const nRing = 12;
const ringR = 0.35;
const outerR = 0.48;
const pts = [{x: cx, y: cy}]; // index 0 = singularity center
const angles = [0]; // placeholder for singularity
// Inner ring
for (let i = 0; i < nRing; i++) {
const a = 2*Math.PI*i/nRing;
pts.push({x: cx + ringR*Math.cos(a), y: cy + ringR*Math.sin(a)});
// Cross field angle: rotates with azimuth to create desired index
// For index +1 of cross field: rep vector rotates by +2π going around
// So the rep angle increases by 2π → cross angle increases by π/2
const fieldAngle = isPositive ? a/4 + 0.1 : -a/4 + 0.1;
angles.push(fieldAngle);
}
// Outer ring
for (let i = 0; i < nRing; i++) {
const a = 2*Math.PI*i/nRing + Math.PI/nRing;
pts.push({x: cx + outerR*Math.cos(a), y: cy + outerR*Math.sin(a)});
const fieldAngle = isPositive ? a/4 + 0.1 : -a/4 + 0.1;
angles.push(fieldAngle);
}
// Build triangles
const tris = [];
// Center to inner ring
for (let i = 0; i < nRing; i++) {
const i0 = 0, i1 = 1 + i, i2 = 1 + (i+1) % nRing;
tris.push([i0, i1, i2]);
}
// Inner ring to outer ring
for (let i = 0; i < nRing; i++) {
const inner1 = 1 + i;
const inner2 = 1 + (i+1) % nRing;
const outer1 = 1 + nRing + i;
const outer2 = 1 + nRing + (i+1) % nRing;
tris.push([inner1, outer1, inner2]);
tris.push([inner2, outer1, outer2]);
}
return {pts, angles, tris, singIdx: 0, nRing};
}
let patch = null;
let sepPaths = []; // array of [{x,y}, ...]
let animStep = 0;
let animId = null;
const sepColors = ['#e74c3c', '#2980b9', '#27ae60', '#8e44ad', '#f39c12'];
function resizeCanvas() {
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = rect.width * devicePixelRatio;
canvas.height = 420 * devicePixelRatio;
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
}
function w2s(wx, wy) {
const cw = canvas.width / devicePixelRatio, ch = canvas.height / devicePixelRatio;
const scale = Math.min(cw, ch) * 0.9;
return {
sx: cw/2 + (wx - 0.5) * scale,
sy: ch/2 - (wy - 0.5) * scale
};
}
function interpAngle(px, py, prevDir) {
// Find containing triangle and interpolate
for (let t = 0; t < patch.tris.length; t++) {
const [i0,i1,i2] = patch.tris[t];
const v0=patch.pts[i0], v1=patch.pts[i1], v2=patch.pts[i2];
const dd = (v1.y-v2.y)*(v0.x-v2.x)+(v2.x-v1.x)*(v0.y-v2.y);
if (Math.abs(dd) < 1e-12) continue;
const w0 = ((v1.y-v2.y)*(px-v2.x)+(v2.x-v1.x)*(py-v2.y))/dd;
const w1 = ((v2.y-v0.y)*(px-v2.x)+(v0.x-v2.x)*(py-v2.y))/dd;
const w2 = 1-w0-w1;
if (w0 < -0.05 || w1 < -0.05 || w2 < -0.05) continue;
const a0=patch.angles[i0], a1=patch.angles[i1], a2=patch.angles[i2];
const rc = w0*Math.cos(4*a0)+w1*Math.cos(4*a1)+w2*Math.cos(4*a2);
const rs = w0*Math.sin(4*a0)+w1*Math.sin(4*a1)+w2*Math.sin(4*a2);
const base = Math.atan2(rs, rc) / 4;
if (prevDir === null) return base;
let best = base, bestDot = -Infinity;
for (let k = 0; k < 4; k++) {
const c = base + k*Math.PI/2;
const dot = Math.cos(c)*Math.cos(prevDir) + Math.sin(c)*Math.sin(prevDir);
if (dot > bestDot) { bestDot = dot; best = c; }
}
return best;
}
return prevDir;
}
function traceSeparatrices() {
const isPos = document.getElementById('sep-index-sel').value === 'pos';
patch = buildPatch(isPos);
const nSep = isPos ? 3 : 5;
const singPt = patch.pts[0];
const dt = 0.005;
const maxSteps = 100;
// Compute average phase at inner ring to get separatrix initial directions
let avgCx = 0, avgCy = 0;
for (let i = 1; i <= patch.nRing; i++) {
avgCx += Math.cos(4 * patch.angles[i]);
avgCy += Math.sin(4 * patch.angles[i]);
}
const phase = Math.atan2(avgCy, avgCx) / 4;
sepPaths = [];
for (let k = 0; k < nSep; k++) {
const initDir = phase + k * 2*Math.PI / nSep;
const path = [{x: singPt.x, y: singPt.y}];
let px = singPt.x + Math.cos(initDir) * 0.02;
let py = singPt.y + Math.sin(initDir) * 0.02;
let pd = initDir;
path.push({x: px, y: py});
for (let s = 0; s < maxSteps; s++) {
const a = interpAngle(px, py, pd);
if (a === null) break;
px += Math.cos(a) * dt;
py += Math.sin(a) * dt;
pd = a;
path.push({x: px, y: py});
// Stop if outside patch
const dx = px - 0.5, dy = py - 0.5;
if (dx*dx+dy*dy > 0.48*0.48) break;
}
sepPaths.push(path);
}
// Animate
animStep = 1;
if (animId) cancelAnimationFrame(animId);
animateGrow();
}
function animateGrow() {
const maxLen = Math.max(...sepPaths.map(p => p.length));
if (animStep >= maxLen) {
info.textContent = `Traced ${sepPaths.length} separatrices (${sepPaths.map(p => p.length + ' pts').join(', ')})`;
draw();
return;
}
animStep += 2;
draw();
animId = requestAnimationFrame(animateGrow);
}
function reset() {
const isPos = document.getElementById('sep-index-sel').value === 'pos';
patch = buildPatch(isPos);
sepPaths = [];
animStep = 0;
if (animId) cancelAnimationFrame(animId);
info.textContent = 'Click "Trace" to grow separatrices from the singularity.';
draw();
}
function draw() {
const cw = canvas.width / devicePixelRatio, ch = canvas.height / devicePixelRatio;
ctx.clearRect(0, 0, cw, ch);
if (!patch) return;
// Draw triangles
ctx.strokeStyle = 'rgba(0,0,0,0.1)';
ctx.lineWidth = 0.7;
for (const [i0,i1,i2] of patch.tris) {
const s0=w2s(patch.pts[i0].x,patch.pts[i0].y);
const s1=w2s(patch.pts[i1].x,patch.pts[i1].y);
const s2=w2s(patch.pts[i2].x,patch.pts[i2].y);
ctx.beginPath();
ctx.moveTo(s0.sx,s0.sy); ctx.lineTo(s1.sx,s1.sy); ctx.lineTo(s2.sx,s2.sy); ctx.closePath();
ctx.stroke();
}
// Draw crosses at vertices (skip singularity vertex)
const cr = 8;
for (let i = 1; i < patch.pts.length; i++) {
const s = w2s(patch.pts[i].x, patch.pts[i].y);
ctx.strokeStyle = 'rgba(70,130,180,0.3)';
ctx.lineWidth = 1;
for (let k = 0; k < 2; k++) {
const a = patch.angles[i] + k * Math.PI/2;
ctx.beginPath();
ctx.moveTo(s.sx + cr*Math.cos(a), s.sy - cr*Math.sin(a));
ctx.lineTo(s.sx - cr*Math.cos(a), s.sy + cr*Math.sin(a));
ctx.stroke();
}
}
// Draw separatrices (up to animStep)
for (let si = 0; si < sepPaths.length; si++) {
const path = sepPaths[si];
const drawLen = Math.min(path.length, animStep);
if (drawLen < 2) continue;
ctx.strokeStyle = sepColors[si % sepColors.length];
ctx.lineWidth = 2.5;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
ctx.beginPath();
const s0 = w2s(path[0].x, path[0].y);
ctx.moveTo(s0.sx, s0.sy);
for (let i = 1; i < drawLen; i++) {
const s = w2s(path[i].x, path[i].y);
ctx.lineTo(s.sx, s.sy);
}
ctx.stroke();
}
// Draw singularity
const ss = w2s(patch.pts[0].x, patch.pts[0].y);
ctx.fillStyle = '#e74c3c';
ctx.beginPath(); ctx.arc(ss.sx, ss.sy, 7, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#fff'; ctx.font = 'bold 9px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
const isPos = document.getElementById('sep-index-sel').value === 'pos';
ctx.fillText(isPos ? '+1' : '-1', ss.sx, ss.sy);
}
document.getElementById('sep-trace-btn').addEventListener('click', traceSeparatrices);
document.getElementById('sep-reset-btn').addEventListener('click', reset);
document.getElementById('sep-index-sel').addEventListener('change', reset);
resizeCanvas();
window.addEventListener('resize', () => { resizeCanvas(); draw(); });
reset();
})();
</script>
```
---
### 3.6 Deep Dive: From Separatrices to Block Partition
Once all separatrices are traced — from both **field singularities** (interior points where the representation vector vanishes) and **geometric singularities** ($C^0$ corners of $\partial\Omega$) — they divide the domain into regions.
#### Geometric Singularities
> *"In order to get four-sided regions, we extend the set of representation vector field singularities by adding a set of geometric singularities, which are defined as the $C^0$ corners on $\partial\Omega$."*
A sharp corner on the boundary (like the corners of an L-shape, or the trailing edge of an airfoil) acts as a singularity source. The representation vector at a corner is defined as the **average** of the representation vectors of the two incident boundary edges. Separatrices from geometric corners are traced inward using the same Heun integration.
#### Proposition 2: The Partition Theorem
**Proposition 2** (Kowalski et al.): *The set of separatrices of the geometric and field singularities leads to a partition of $\Omega$ into 3-sided or 4-sided regions. The 3-sided regions necessarily contain a singularity.*
**Why only 3 or 4 sides?** Inside each region (away from singularities), the cross field is smooth and regular. This means there exist two orthogonal families of curves — the $u$-family and the $v$-family — that tile the region like a coordinate grid. The boundary of each region consists of alternating segments from these two families:
- A **4-sided region** has two sides from the $u$-family and two from the $v$-family — like a curved rectangle. This is the ideal case for transfinite interpolation.
- A **3-sided region** occurs when one "side" degenerates to a single point — a singularity where all streamlines of one family converge. The angle at this degenerate vertex is at most $\pi/2$.
```{ojs}
//| label: fig-region-types
//| code-fold: true
{
const W = 600, H = 280;
const svg = d3.create("svg").attr("viewBox", `0 0 ${W} ${H}`).style("width", "100%").style("max-width", W + "px");
// 4-sided region (left)
const leftCx = 150, topY = 40, botY = 240;
// Draw curved quad
const quadPts = [
{x: 60, y: 60}, {x: 240, y: 50}, {x: 260, y: 230}, {x: 50, y: 240}
];
svg.append("path")
.attr("d", `M${quadPts[0].x},${quadPts[0].y} C${quadPts[0].x+60},${quadPts[0].y-10} ${quadPts[1].x-60},${quadPts[1].y-10} ${quadPts[1].x},${quadPts[1].y} L${quadPts[2].x},${quadPts[2].y} C${quadPts[2].x-60},${quadPts[2].y+5} ${quadPts[3].x+60},${quadPts[3].y+5} ${quadPts[3].x},${quadPts[3].y} Z`)
.attr("fill", "rgba(46,204,113,0.15)").attr("stroke", "#2c3e50").attr("stroke-width", 2);
// Draw u-family curves (horizontal-ish)
for (let i = 1; i <= 3; i++) {
const t = i/4;
const lx = quadPts[0].x + t*(quadPts[3].x-quadPts[0].x);
const ly = quadPts[0].y + t*(quadPts[3].y-quadPts[0].y);
const rx = quadPts[1].x + t*(quadPts[2].x-quadPts[1].x);
const ry = quadPts[1].y + t*(quadPts[2].y-quadPts[1].y);
svg.append("line").attr("x1",lx).attr("y1",ly).attr("x2",rx).attr("y2",ry)
.attr("stroke","steelblue").attr("stroke-width",1).attr("stroke-dasharray","4,3").attr("opacity",0.6);
}
// Draw v-family curves (vertical-ish)
for (let i = 1; i <= 3; i++) {
const t = i/4;
const tx = quadPts[0].x + t*(quadPts[1].x-quadPts[0].x);
const ty = quadPts[0].y + t*(quadPts[1].y-quadPts[0].y);
const bx = quadPts[3].x + t*(quadPts[2].x-quadPts[3].x);
const by = quadPts[3].y + t*(quadPts[2].y-quadPts[3].y);
svg.append("line").attr("x1",tx).attr("y1",ty).attr("x2",bx).attr("y2",by)
.attr("stroke","#e74c3c").attr("stroke-width",1).attr("stroke-dasharray","4,3").attr("opacity",0.6);
}
svg.append("text").attr("x",150).attr("y",270).attr("text-anchor","middle").attr("font-size",13).attr("fill","#333")
.text("4-sided region (ideal)");
// Corner dots
for (const p of quadPts) {
svg.append("circle").attr("cx",p.x).attr("cy",p.y).attr("r",4).attr("fill","#2c3e50");
}
// Labels
svg.append("text").attr("x",150).attr("y",35).attr("text-anchor","middle").attr("font-size",11).attr("fill","steelblue").text("u-family");
svg.append("text").attr("x",275).attr("y",145).attr("font-size",11).attr("fill","#e74c3c").text("v-family");
// 3-sided region (right)
const ox = 330;
// Triangle with one degenerate vertex (top = singularity)
const triPts = [
{x: ox + 150, y: 50}, // singularity (degenerate vertex)
{x: ox + 260, y: 235},
{x: ox + 40, y: 230}
];
svg.append("path")
.attr("d", `M${triPts[0].x},${triPts[0].y} C${triPts[0].x+50},${triPts[0].y+60} ${triPts[1].x-20},${triPts[1].y-60} ${triPts[1].x},${triPts[1].y} L${triPts[2].x},${triPts[2].y} C${triPts[2].x+20},${triPts[2].y-60} ${triPts[0].x-50},${triPts[0].y+60} ${triPts[0].x},${triPts[0].y}`)
.attr("fill", "rgba(231,76,60,0.1)").attr("stroke", "#2c3e50").attr("stroke-width", 2);
// Streamlines converging to the singularity
for (let i = 1; i <= 4; i++) {
const t = i/5;
const bx = triPts[2].x + t*(triPts[1].x - triPts[2].x);
const by = triPts[2].y + t*(triPts[1].y - triPts[2].y);
svg.append("line").attr("x1",triPts[0].x).attr("y1",triPts[0].y).attr("x2",bx).attr("y2",by)
.attr("stroke","steelblue").attr("stroke-width",1).attr("stroke-dasharray","4,3").attr("opacity",0.5);
}
// Singularity marker
svg.append("circle").attr("cx",triPts[0].x).attr("cy",triPts[0].y).attr("r",6).attr("fill","#e74c3c");
svg.append("text").attr("x",triPts[0].x).attr("y",triPts[0].y-12).attr("text-anchor","middle")
.attr("font-size",11).attr("fill","#e74c3c").attr("font-weight","bold").text("singularity");
// Other corners
svg.append("circle").attr("cx",triPts[1].x).attr("cy",triPts[1].y).attr("r",4).attr("fill","#2c3e50");
svg.append("circle").attr("cx",triPts[2].x).attr("cy",triPts[2].y).attr("r",4).attr("fill","#2c3e50");
svg.append("text").attr("x",ox+150).attr("y",270).attr("text-anchor","middle").attr("font-size",13).attr("fill","#333")
.text("3-sided region (degenerate vertex)");
return svg.node();
}
```
#### Interactive: Notched Domain Partition
This domain — a rectangle with two semicircular notches — is a classic test case from Kowalski et al. The curved concavities force the cross field to develop interior singularities whose separatrices partition the domain into quad-meshable blocks. Step through each stage to see the full pipeline.
```{=html}
<script src="https://cdn.jsdelivr.net/npm/delaunator@4.0.1/delaunator.min.js"></script>
<style>
#bone-canvas {
display: block; width: 100%; height: 400px;
border: 1px solid var(--bs-border-color, #dee2e6); border-radius: 6px;
background: var(--bs-body-bg, #fff);
}
.bone-controls {
display: flex; gap: 0.5rem; flex-wrap: wrap; margin: 0.6rem 0;
}
.bone-controls button {
padding: 0.3rem 0.8rem; border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 4px; background: var(--bs-body-bg, #fff); color: var(--bs-body-color, #212529);
font-size: 0.8rem; cursor: pointer;
}
.bone-controls button:hover { background: var(--bs-primary, #2c3e50); color: #fff; }
.bone-controls button.done { background: #27ae60; color: #fff; border-color: #27ae60; }
.bone-controls button:disabled { opacity: 0.5; cursor: not-allowed; }
#bone-info { font-size: 0.82rem; color: var(--bs-secondary-color, #6c757d); font-family: monospace; margin-top: 0.3rem; }
</style>
<div>
<div class="bone-controls">
<button id="ls-mesh" onclick="lsStep('mesh')">1. Mesh</button>
<button id="ls-field" onclick="lsStep('field')" disabled>2. Cross field</button>
<button id="ls-sing" onclick="lsStep('sing')" disabled>3. Singularities</button>
<button id="ls-sep" onclick="lsStep('sep')" disabled>4. Separatrices</button>
<button id="ls-regions" onclick="lsStep('regions')" disabled>5. Color regions</button>
</div>
<canvas id="bone-canvas"></canvas>
<div id="bone-info">Click "Mesh" to generate the notched domain triangulation.</div>
</div>
<script>
(function() {
const canvas = document.getElementById('bone-canvas');
const ctx = canvas.getContext('2d');
const info = document.getElementById('bone-info');
// Domain: 2×1 rectangle with two semicircular notches (top and bottom)
// Arc parameters
const domW = 2, domH = 1;
const halfSpan = 0.7; // half-width of each notch
const arcPeakH = 0.35; // how far each arc penetrates inward
const arcXL = 1 - halfSpan; // 0.3
const arcXR = 1 + halfSpan; // 1.7
const botCy = (arcPeakH*arcPeakH - halfSpan*halfSpan) / (2*arcPeakH); // ≈ -0.525
const topCy = 1 - botCy; // ≈ 1.525
const arcR = Math.sqrt(halfSpan*halfSpan + botCy*botCy); // ≈ 0.875
const botStartA = Math.atan2(-botCy, arcXL - 1); // angle from bot center to (0.3,0)
const botEndA = Math.atan2(-botCy, arcXR - 1); // angle from bot center to (1.7,0)
const topStartA = Math.atan2(1 - topCy, arcXR - 1); // from top center to (1.7,1)
const topEndA = Math.atan2(1 - topCy, arcXL - 1); // from top center to (0.3,1)
let pts = [], tris = [], adj = [], angles = [], constrained = [];
let sings = [], sepLines = [], regionColors = [];
let nBnd = 0;
let currentStep = 0;
// Boundary arc height at a given x (returns 0 if x outside arc span)
function botArcY(x) {
const dx = x - 1;
if (dx*dx >= arcR*arcR) return 0;
return botCy + Math.sqrt(arcR*arcR - dx*dx);
}
function topArcY(x) {
const dx = x - 1;
if (dx*dx >= arcR*arcR) return 1;
return topCy - Math.sqrt(arcR*arcR - dx*dx);
}
function pointInDomain(x, y) {
const tol = 0.005;
if (x < -tol || x > domW+tol || y < -tol || y > domH+tol) return false;
const dx = x - 1;
if (dx*dx < arcR*arcR) {
const bY = botCy + Math.sqrt(arcR*arcR - dx*dx);
if (y < bY - tol) return false;
const tY = topCy - Math.sqrt(arcR*arcR - dx*dx);
if (y > tY + tol) return false;
}
return true;
}
function resizeCanvas() {
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = rect.width * devicePixelRatio;
canvas.height = 400 * devicePixelRatio;
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
}
function w2s(wx, wy) {
const cw = canvas.width / devicePixelRatio, ch = canvas.height / devicePixelRatio;
const scaleX = (cw * 0.9) / domW, scaleY = (ch * 0.9) / domH;
const scale = Math.min(scaleX, scaleY);
const ox = (cw - domW*scale) / 2, oy = (ch - domH*scale) / 2;
return { sx: ox + wx * scale, sy: oy + (domH - wy) * scale };
}
function buildMesh() {
pts = []; tris = []; adj = []; angles = []; constrained = [];
sings = []; sepLines = []; regionColors = [];
const spacing = 0.055;
const bpts = [];
const nArcSamples = 30;
// Helper: add straight boundary segment samples (excluding endpoint)
function addStraight(x0, y0, x1, y1) {
const dx=x1-x0, dy=y1-y0, len=Math.sqrt(dx*dx+dy*dy);
const n = Math.max(2, Math.round(len/spacing));
for (let i = 0; i < n; i++) {
const t = i/n;
bpts.push({x: x0+t*dx, y: y0+t*dy, isBoundary: true});
}
}
// Helper: add circular arc samples (angle decreasing from startA to endA)
function addArc(cx, cy, r, startA, endA) {
const span = startA - endA;
const arcLen = r * Math.abs(span);
const n = Math.max(8, Math.round(arcLen/spacing));
for (let i = 0; i < n; i++) {
const t = i/n;
const angle = startA - t * span;
bpts.push({
x: cx + r*Math.cos(angle),
y: cy + r*Math.sin(angle),
isBoundary: true
});
}
}
// Boundary (counterclockwise):
addStraight(0, 0, arcXL, 0); // bottom-left flat
addArc(1, botCy, arcR, botStartA, botEndA); // bottom concave arc
addStraight(arcXR, 0, domW, 0); // bottom-right flat
addStraight(domW, 0, domW, domH); // right side
addStraight(domW, domH, arcXR, domH); // top-right flat
addArc(1, topCy, arcR, topStartA, topEndA); // top concave arc
addStraight(arcXL, domH, 0, domH); // top-left flat
addStraight(0, domH, 0, 0); // left side
nBnd = bpts.length;
// Compute tangent at each boundary point from neighbors
for (let i = 0; i < nBnd; i++) {
const prev = bpts[(i-1+nBnd) % nBnd];
const next = bpts[(i+1) % nBnd];
bpts[i].tangent = Math.atan2(next.y - prev.y, next.x - prev.x);
}
// Interior points
const interior = [];
for (let gx = spacing*0.5; gx < domW; gx += spacing) {
for (let gy = spacing*0.5; gy < domH; gy += spacing) {
if (!pointInDomain(gx, gy)) continue;
let tooClose = false;
for (const p of bpts) {
if ((gx-p.x)**2+(gy-p.y)**2 < (spacing*0.4)**2) { tooClose=true; break; }
}
if (tooClose) continue;
interior.push({x: gx, y: gy, isBoundary: false});
}
}
pts = [...bpts, ...interior];
const n = pts.length;
constrained = new Uint8Array(n);
angles = new Float64Array(n);
const coords = new Float64Array(n * 2);
for (let i = 0; i < n; i++) { coords[2*i]=pts[i].x; coords[2*i+1]=pts[i].y; }
if (typeof Delaunator !== 'undefined') {
const del = new Delaunator(coords);
tris = [];
for (let t = 0; t < del.triangles.length/3; t++) {
const i0=del.triangles[3*t], i1=del.triangles[3*t+1], i2=del.triangles[3*t+2];
const cx = (pts[i0].x+pts[i1].x+pts[i2].x)/3;
const cy = (pts[i0].y+pts[i1].y+pts[i2].y)/3;
if (!pointInDomain(cx, cy)) continue;
tris.push([i0,i1,i2]);
}
}
adj = new Array(n);
for (let i = 0; i < n; i++) adj[i] = new Set();
for (const [i0,i1,i2] of tris) {
adj[i0].add(i1); adj[i0].add(i2);
adj[i1].add(i0); adj[i1].add(i2);
adj[i2].add(i0); adj[i2].add(i1);
}
info.textContent = `Notched domain: ${n} vertices, ${tris.length} triangles`;
}
function solveField() {
const n = pts.length;
// Set boundary conditions from per-point tangent
for (let i = 0; i < n; i++) {
if (!pts[i].isBoundary) continue;
constrained[i] = 1;
angles[i] = pts[i].tangent;
}
// Gauss-Seidel in (cos4θ, sin4θ)
const cx = new Float64Array(n), cy = new Float64Array(n);
for (let i = 0; i < n; i++) {
cx[i] = Math.cos(4*angles[i]); cy[i] = Math.sin(4*angles[i]);
}
for (let iter = 0; iter < 3000; iter++) {
let maxDelta = 0;
for (let i = 0; i < n; i++) {
if (constrained[i]) continue;
if (adj[i].size === 0) continue;
let sx=0, sy=0;
for (const j of adj[i]) { sx+=cx[j]; sy+=cy[j]; }
const len = Math.sqrt(sx*sx+sy*sy);
if (len < 1e-12) continue;
const nx=sx/len, ny=sy/len;
const d = Math.abs(nx-cx[i])+Math.abs(ny-cy[i]);
if (d > maxDelta) maxDelta = d;
cx[i]=nx; cy[i]=ny;
}
if (maxDelta < 1e-7) break;
}
for (let i = 0; i < n; i++) {
if (!constrained[i]) angles[i] = Math.atan2(cy[i], cx[i]) / 4;
}
info.textContent = `Cross field solved (${n} vertices)`;
}
function detectSings() {
sings = [];
for (let t = 0; t < tris.length; t++) {
const [i0,i1,i2] = tris[t];
// Skip triangles touching boundary — avoids spurious boundary singularities
if (pts[i0].isBoundary || pts[i1].isBoundary || pts[i2].isBoundary) continue;
const edges = [[i0,i1],[i1,i2],[i2,i0]];
let totalRot = 0;
for (const [a,b] of edges) {
let diff = angles[b] - angles[a];
diff = diff - Math.round(diff / (Math.PI/2)) * (Math.PI/2);
totalRot += diff;
}
const idx = Math.round(totalRot / (Math.PI/2));
if (idx !== 0) {
sings.push({
x: (pts[i0].x+pts[i1].x+pts[i2].x)/3,
y: (pts[i0].y+pts[i1].y+pts[i2].y)/3,
index: idx, triIdx: t
});
}
}
info.textContent = `Found ${sings.length} field singularities`;
}
function traceSeps() {
sepLines = [];
// Build spatial hash
const cellSz = 0.1;
const triHash = new Map();
for (let t = 0; t < tris.length; t++) {
const [i0,i1,i2] = tris[t];
const cx = (pts[i0].x+pts[i1].x+pts[i2].x)/3;
const cy = (pts[i0].y+pts[i1].y+pts[i2].y)/3;
const k = Math.floor(cx/cellSz)+','+Math.floor(cy/cellSz);
const b = triHash.get(k); if (b) b.push(t); else triHash.set(k, [t]);
}
function findTri(wx, wy) {
const gx=Math.floor(wx/cellSz), gy=Math.floor(wy/cellSz);
for (let di=-1;di<=1;di++) for (let dj=-1;dj<=1;dj++) {
const b = triHash.get((gx+di)+','+(gy+dj));
if (!b) continue;
for (const t of b) {
const [i0,i1,i2]=tris[t];
const v0=pts[i0],v1=pts[i1],v2=pts[i2];
const dd=(v1.y-v2.y)*(v0.x-v2.x)+(v2.x-v1.x)*(v0.y-v2.y);
if(Math.abs(dd)<1e-12) continue;
const w0=((v1.y-v2.y)*(wx-v2.x)+(v2.x-v1.x)*(wy-v2.y))/dd;
const w1=((v2.y-v0.y)*(wx-v2.x)+(v0.x-v2.x)*(wy-v2.y))/dd;
const w2=1-w0-w1;
if(w0>=-0.02&&w1>=-0.02&&w2>=-0.02) return {i0,i1,i2,w0,w1,w2};
}
}
return null;
}
function interpA(loc, prevDir) {
const a0=angles[loc.i0], a1=angles[loc.i1], a2=angles[loc.i2];
const rx=loc.w0*Math.cos(4*a0)+loc.w1*Math.cos(4*a1)+loc.w2*Math.cos(4*a2);
const ry=loc.w0*Math.sin(4*a0)+loc.w1*Math.sin(4*a1)+loc.w2*Math.sin(4*a2);
const base = Math.atan2(ry, rx) / 4;
if (prevDir === null) return base;
let best=base, bestDot=-Infinity;
for (let k=0;k<4;k++) {
const c=base+k*Math.PI/2;
const dot=Math.cos(c)*Math.cos(prevDir)+Math.sin(c)*Math.sin(prevDir);
if(dot>bestDot){bestDot=dot;best=c;}
}
return best;
}
function traceOne(sx, sy, initDir) {
const line = [{x:sx,y:sy}];
let px=sx, py=sy, pd=initDir;
const dt=0.004, maxSteps=600;
let cumTurn = 0;
for (let s=0; s<maxSteps; s++) {
const loc = findTri(px, py);
if (!loc) break;
const a = interpA(loc, pd);
// Cycle detection: track cumulative turning angle
let turn = a - pd;
turn = turn - Math.round(turn / (2*Math.PI)) * 2*Math.PI;
cumTurn += turn;
if (Math.abs(cumTurn) > 2*Math.PI) break;
px += Math.cos(a)*dt; py += Math.sin(a)*dt;
pd = a;
if (!pointInDomain(px, py)) break;
// Snap to nearby singularity (skip the source)
for (const sg of sings) {
const srcD2 = (sg.x-sx)**2 + (sg.y-sy)**2;
if (srcD2 < 0.02*0.02) continue;
if ((px-sg.x)**2 + (py-sg.y)**2 < 0.02*0.02) {
line.push({x: sg.x, y: sg.y});
return line;
}
}
line.push({x:px,y:py});
}
return line;
}
// Trace from field singularities
for (const sing of sings.filter(s => s.triIdx >= 0)) {
const nSep = sing.index > 0 ? 3 : 5;
const [i0,i1,i2] = tris[sing.triIdx];
let avgCx=0, avgCy=0;
for (const ni of [i0,i1,i2]) {
avgCx += Math.cos(4*angles[ni]); avgCy += Math.sin(4*angles[ni]);
}
const phase = Math.atan2(avgCy, avgCx) / 4;
for (let k = 0; k < nSep; k++) {
const dir = phase + k * 2*Math.PI / nSep;
const startX = sing.x + Math.cos(dir)*0.01;
const startY = sing.y + Math.sin(dir)*0.01;
const line = traceOne(startX, startY, dir);
line.unshift({x: sing.x, y: sing.y});
sepLines.push(line);
}
}
info.textContent = `Traced ${sepLines.length} separatrices from ${sings.length} singularities`;
}
function colorRegions() {
regionColors = new Array(tris.length).fill(-1);
// Build edge → triangle mapping for adjacency
const edgeToTris = new Map();
for (let t = 0; t < tris.length; t++) {
const [i0,i1,i2] = tris[t];
for (const [a,b] of [[i0,i1],[i1,i2],[i2,i0]]) {
const key = Math.min(a,b) + ',' + Math.max(a,b);
const list = edgeToTris.get(key);
if (list) list.push(t); else edgeToTris.set(key, [t]);
}
}
// Build triangle-triangle adjacency
const triAdj = new Array(tris.length).fill(null).map(() => []);
for (const [key, triList] of edgeToTris) {
if (triList.length === 2) {
triAdj[triList[0]].push({tri: triList[1], edge: key});
triAdj[triList[1]].push({tri: triList[0], edge: key});
}
}
// Segment-segment intersection test (strict interior)
function segsX(ax, ay, bx, by, cx, cy, dx, dy) {
const d1x=bx-ax, d1y=by-ay, d2x=dx-cx, d2y=dy-cy;
const denom = d1x*d2y - d1y*d2x;
if (Math.abs(denom) < 1e-12) return false;
const t = ((cx-ax)*d2y - (cy-ay)*d2x) / denom;
const u = ((cx-ax)*d1y - (cy-ay)*d1x) / denom;
return t > 0.01 && t < 0.99 && u > 0.01 && u < 0.99;
}
// Mark shared edges that are crossed by any separatrix
const blockedEdges = new Set();
for (const [key, triList] of edgeToTris) {
if (triList.length !== 2) continue;
const [va, vb] = key.split(',').map(Number);
const eax=pts[va].x, eay=pts[va].y, ebx=pts[vb].x, eby=pts[vb].y;
let crossed = false;
for (const line of sepLines) {
for (let k = 0; k < line.length - 1; k++) {
if (segsX(eax, eay, ebx, eby, line[k].x, line[k].y, line[k+1].x, line[k+1].y)) {
crossed = true; break;
}
}
if (crossed) break;
}
if (crossed) blockedEdges.add(key);
}
// Flood-fill: BFS through triangles, blocked by separatrix-crossed edges
let regionId = 0;
for (let t = 0; t < tris.length; t++) {
if (regionColors[t] >= 0) continue;
const queue = [t];
regionColors[t] = regionId;
while (queue.length > 0) {
const cur = queue.shift();
for (const {tri: nb, edge} of triAdj[cur]) {
if (regionColors[nb] >= 0) continue;
if (blockedEdges.has(edge)) continue;
regionColors[nb] = regionId;
queue.push(nb);
}
}
regionId++;
}
info.textContent = `Partition: ${regionId} colored regions`;
}
function draw() {
const cw = canvas.width / devicePixelRatio, ch = canvas.height / devicePixelRatio;
ctx.clearRect(0, 0, cw, ch);
if (pts.length === 0) {
// Draw boundary outline even before meshing
drawBoundary();
return;
}
const regionPalette = [
'rgba(46,204,113,0.25)', 'rgba(52,152,219,0.25)', 'rgba(155,89,182,0.25)',
'rgba(241,196,15,0.25)', 'rgba(230,126,34,0.25)', 'rgba(231,76,60,0.2)',
'rgba(26,188,156,0.25)', 'rgba(243,156,18,0.25)', 'rgba(142,68,173,0.25)'
];
// Draw triangles (with optional region coloring)
for (let t = 0; t < tris.length; t++) {
const [i0,i1,i2] = tris[t];
const s0=w2s(pts[i0].x,pts[i0].y), s1=w2s(pts[i1].x,pts[i1].y), s2=w2s(pts[i2].x,pts[i2].y);
if (regionColors.length > 0 && regionColors[t] >= 0) {
ctx.fillStyle = regionPalette[regionColors[t] % regionPalette.length];
ctx.beginPath();
ctx.moveTo(s0.sx,s0.sy); ctx.lineTo(s1.sx,s1.sy); ctx.lineTo(s2.sx,s2.sy); ctx.closePath();
ctx.fill();
}
ctx.strokeStyle = 'rgba(0,0,0,0.08)';
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(s0.sx,s0.sy); ctx.lineTo(s1.sx,s1.sy);
ctx.moveTo(s1.sx,s1.sy); ctx.lineTo(s2.sx,s2.sy);
ctx.moveTo(s2.sx,s2.sy); ctx.lineTo(s0.sx,s0.sy);
ctx.stroke();
}
// Domain boundary (thick)
drawBoundary();
// Cross field
if (currentStep >= 2) {
const cr = 3.5;
for (let i = 0; i < pts.length; i++) {
if (adj[i].size === 0) continue;
const sc = w2s(pts[i].x, pts[i].y);
ctx.strokeStyle = constrained[i] ? 'rgba(231,76,60,0.3)' : 'rgba(70,130,180,0.25)';
ctx.lineWidth = 0.7;
for (let k = 0; k < 2; k++) {
const a = angles[i] + k*Math.PI/2;
ctx.beginPath();
ctx.moveTo(sc.sx+cr*Math.cos(a), sc.sy-cr*Math.sin(a));
ctx.lineTo(sc.sx-cr*Math.cos(a), sc.sy+cr*Math.sin(a));
ctx.stroke();
}
}
}
// Separatrices
if (sepLines.length > 0) {
ctx.lineWidth = 2;
ctx.lineJoin = 'round';
ctx.lineCap = 'round';
const sepColorList = ['#e74c3c','#2980b9','#27ae60','#8e44ad','#f39c12','#e67e22','#1abc9c','#c0392b'];
for (let si = 0; si < sepLines.length; si++) {
const line = sepLines[si];
if (line.length < 2) continue;
ctx.strokeStyle = sepColorList[si % sepColorList.length];
ctx.globalAlpha = 0.8;
ctx.beginPath();
const s0 = w2s(line[0].x, line[0].y);
ctx.moveTo(s0.sx, s0.sy);
for (let i = 1; i < line.length; i++) {
const s = w2s(line[i].x, line[i].y);
ctx.lineTo(s.sx, s.sy);
}
ctx.stroke();
ctx.globalAlpha = 1;
}
}
// Singularities
for (const sg of sings) {
const sc = w2s(sg.x, sg.y);
ctx.fillStyle = sg.index > 0 ? '#e74c3c' : '#8e44ad';
ctx.beginPath(); ctx.arc(sc.sx, sc.sy, 5, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#fff'; ctx.font = 'bold 8px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(sg.index > 0 ? '+1' : '-1', sc.sx, sc.sy);
}
}
function drawBoundary() {
ctx.strokeStyle = '#2c3e50';
ctx.lineWidth = 2.5;
ctx.beginPath();
// Bottom: left flat, arc, right flat
let s = w2s(0, 0); ctx.moveTo(s.sx, s.sy);
s = w2s(arcXL, 0); ctx.lineTo(s.sx, s.sy);
// Bottom arc
const nArc = 40;
for (let i = 0; i <= nArc; i++) {
const angle = botStartA - i * (botStartA - botEndA) / nArc;
s = w2s(1 + arcR*Math.cos(angle), botCy + arcR*Math.sin(angle));
ctx.lineTo(s.sx, s.sy);
}
s = w2s(domW, 0); ctx.lineTo(s.sx, s.sy);
// Right side
s = w2s(domW, domH); ctx.lineTo(s.sx, s.sy);
// Top: right flat, arc, left flat
s = w2s(arcXR, domH); ctx.lineTo(s.sx, s.sy);
// Top arc
for (let i = 0; i <= nArc; i++) {
const angle = topStartA - i * (topStartA - topEndA) / nArc;
s = w2s(1 + arcR*Math.cos(angle), topCy + arcR*Math.sin(angle));
ctx.lineTo(s.sx, s.sy);
}
s = w2s(0, domH); ctx.lineTo(s.sx, s.sy);
ctx.closePath();
ctx.stroke();
}
const lsSteps = ['mesh','field','sing','sep','regions'];
const lsFns = [buildMesh, solveField, detectSings, traceSeps, colorRegions];
window.lsStep = function(step) {
const idx = lsSteps.indexOf(step);
if (idx < 0) return;
currentStep = idx + 1;
lsFns[idx]();
draw();
document.getElementById('ls-' + step).classList.add('done');
if (idx + 1 < lsSteps.length) {
document.getElementById('ls-' + lsSteps[idx+1]).disabled = false;
}
};
resizeCanvas();
window.addEventListener('resize', () => { resizeCanvas(); draw(); });
draw();
})();
</script>
```
::: {.callout-note}
## What you should notice
- The curved concavities force **field singularities** to appear in the interior — unlike polygonal domains where corners drive the topology
- Singularities appear near the narrowest part of the domain (the "waist") where the cross field must rotate significantly to stay aligned with the curved boundaries
- Each colored region is a candidate for structured meshing — 4-sided regions can be directly meshed by transfinite interpolation
:::
---
### 3.7 Deep Dive: Block Merging and Transfinite Interpolation
The separatrix-based partition from Section 3.6 may not immediately produce all 4-sided regions. Some regions may be 3-sided (with a degenerate vertex at a singularity), and some partitions may have more blocks than necessary. **Block merging** is a post-processing step that simplifies the partition.
#### Why Merge Blocks?
1. **3-sided regions** have a degenerate edge (one "side" is a single point — the singularity). Standard transfinite interpolation requires 4 distinct boundary curves.
2. **Excessive blocks** create unnecessary complexity: each block interface must be discretized consistently, and more blocks mean more interface constraints.
#### The Merging Algorithm
When a **3-sided region** $R_3$ shares a separatrix with an adjacent **4-sided region** $R_4$, and they meet at a valence-3 singularity, we can **remove the shared separatrix** to merge them into a new 4-sided region:
```
while any 3-sided region R3 exists:
find adjacent 4-sided region R4 sharing separatrix S at singularity V
remove S from the partition
merge R3 and R4 into new 4-sided region R_new
update partition graph
```
The topological argument: removing a separatrix between a 3-sided and 4-sided region that share a vertex eliminates the degenerate edge and produces a valid 4-sided region. The combined region inherits 3 sides from $R_4$ and 2 from $R_3$ — but the shared separatrix accounts for one side from each, so the result has $4 + 3 - 2 = 4$ sides (accounting for the singularity vertex no longer being a degenerate edge but a regular boundary vertex).
::: {.callout-note}
This merging step is not explicitly described in Kowalski et al. 2012, but it is standard practice in block-structured meshing pipelines. The paper's Proposition 2 guarantees that the initial partition produces only 3- or 4-sided regions, and the merging step eliminates all 3-sided ones.
:::
```{ojs}
//| label: fig-block-merging
viewof mergeStep = Inputs.range([0, 3], {value: 0, step: 1, label: "Merge step"})
```
```{ojs}
//| code-fold: true
{
const W = 560, H = 400;
const svg = d3.create("svg").attr("viewBox", `0 0 ${W} ${H}`).style("width", "100%").style("max-width", W + "px");
// Pre-defined block partition with 3- and 4-sided regions
// Simple example: a rectangular domain split by separatrices from 2 singularities
const regions = [
{ // Region 0: 4-sided (top-left)
pts: [{x:30,y:30},{x:270,y:30},{x:250,y:160},{x:30,y:170}],
sides: 4, color: 'rgba(52,152,219,0.25)', label: '4'
},
{ // Region 1: 3-sided (top-center triangle)
pts: [{x:270,y:30},{x:400,y:30},{x:250,y:160}],
sides: 3, color: 'rgba(231,76,60,0.2)', label: '3'
},
{ // Region 2: 4-sided (right)
pts: [{x:400,y:30},{x:530,y:30},{x:530,y:190},{x:320,y:180}],
sides: 4, color: 'rgba(46,204,113,0.25)', label: '4'
},
{ // Region 3: 3-sided (center triangle)
pts: [{x:250,y:160},{x:320,y:180},{x:280,y:260}],
sides: 3, color: 'rgba(155,89,182,0.2)', label: '3'
},
{ // Region 4: 4-sided (bottom-left)
pts: [{x:30,y:170},{x:250,y:160},{x:280,y:260},{x:30,y:370}],
sides: 4, color: 'rgba(241,196,15,0.25)', label: '4'
},
{ // Region 5: 4-sided (bottom-right)
pts: [{x:280,y:260},{x:320,y:180},{x:530,y:190},{x:530,y:370},{x:30,y:370}],
sides: 4, color: 'rgba(230,126,34,0.2)', label: '4'
}
];
// Separatrices that can be removed during merging
const separatrices = [
{ from: {x:250,y:160}, to: {x:270,y:30}, removedAt: 1 }, // between region 0 and 1
{ from: {x:250,y:160}, to: {x:320,y:180}, removedAt: 2 }, // between regions around center singularity
{ from: {x:320,y:180}, to: {x:400,y:30}, removedAt: 3 }, // between region 2 and 1
];
// Singularities
const sings = [
{x: 250, y: 160, label: "+1"},
{x: 320, y: 180, label: "+1"}
];
// Merge state: which regions are merged
const step = mergeStep;
// After merging, redefine regions
let visRegions = [];
let visSeps = [];
let visSings = [...sings];
if (step === 0) {
visRegions = regions;
visSeps = separatrices;
} else if (step === 1) {
// Merge region 0 and 1 → new 4-sided region
visRegions = [
{ pts: [{x:30,y:30},{x:400,y:30},{x:250,y:160},{x:30,y:170}], sides: 4, color: 'rgba(52,152,219,0.25)', label: '4' },
regions[2], regions[3], regions[4], regions[5]
];
visSeps = separatrices.filter(s => s.removedAt > 1);
} else if (step === 2) {
visRegions = [
{ pts: [{x:30,y:30},{x:400,y:30},{x:250,y:160},{x:30,y:170}], sides: 4, color: 'rgba(52,152,219,0.25)', label: '4' },
regions[2],
{ pts: [{x:30,y:170},{x:250,y:160},{x:320,y:180},{x:280,y:260},{x:30,y:370}], sides: 4, color: 'rgba(241,196,15,0.25)', label: '4' },
regions[5]
];
visSeps = separatrices.filter(s => s.removedAt > 2);
} else {
visRegions = [
{ pts: [{x:30,y:30},{x:530,y:30},{x:530,y:190},{x:320,y:180},{x:250,y:160},{x:30,y:170}], sides: 4, color: 'rgba(52,152,219,0.2)', label: '4' },
{ pts: [{x:30,y:170},{x:250,y:160},{x:320,y:180},{x:530,y:190},{x:530,y:370},{x:30,y:370}], sides: 4, color: 'rgba(241,196,15,0.2)', label: '4' }
];
visSeps = [];
visSings = [];
}
// Draw regions
for (const r of visRegions) {
const d = 'M' + r.pts.map(p => `${p.x},${p.y}`).join(' L') + ' Z';
svg.append("path").attr("d", d).attr("fill", r.color).attr("stroke", "#2c3e50").attr("stroke-width", 1.5);
// Label
const cx = r.pts.reduce((s,p) => s + p.x, 0) / r.pts.length;
const cy = r.pts.reduce((s,p) => s + p.y, 0) / r.pts.length;
svg.append("text").attr("x", cx).attr("y", cy).attr("text-anchor", "middle").attr("dominant-baseline", "middle")
.attr("font-size", 18).attr("font-weight", "bold").attr("fill", "rgba(0,0,0,0.4)")
.text(r.label + '-sided');
}
// Draw separatrices
for (const s of visSeps) {
svg.append("line").attr("x1",s.from.x).attr("y1",s.from.y).attr("x2",s.to.x).attr("y2",s.to.y)
.attr("stroke","#e74c3c").attr("stroke-width",3).attr("stroke-dasharray","8,4");
}
// Draw singularities
for (const s of visSings) {
svg.append("circle").attr("cx",s.x).attr("cy",s.y).attr("r",7).attr("fill","#e74c3c");
svg.append("text").attr("x",s.x).attr("y",s.y-14).attr("text-anchor","middle")
.attr("font-size",11).attr("fill","#e74c3c").attr("font-weight","bold").text(s.label);
}
// Status text
const statusTexts = [
"Initial partition: 2 three-sided + 4 four-sided regions",
"Merge step 1: removed separatrix → 3-sided region absorbed into 4-sided neighbor",
"Merge step 2: removed another separatrix → second 3-sided region absorbed",
"Final: all regions are 4-sided — ready for transfinite interpolation"
];
svg.append("text").attr("x", W/2).attr("y", H - 8).attr("text-anchor", "middle")
.attr("font-size", 13).attr("fill", "#333").text(statusTexts[step]);
return svg.node();
}
```
#### Transfinite Bilinear Interpolation
Once every region is 4-sided, each is meshed independently using **transfinite bilinear interpolation** (TFI). Given a 4-sided region with boundary curves $C_0(s)$ (bottom), $C_1(s)$ (top), $C_2(t)$ (left), $C_3(t)$ (right) and corners $P_{00}, P_{10}, P_{01}, P_{11}$, the interior mapping is:
$$
f(s,t) = (1-t)\,C_0(s) + t\,C_1(s) + (1-s)\,C_2(t) + s\,C_3(t) - \Big[(1-s)(1-t)P_{00} + s(1-t)P_{10} + (1-s)t\,P_{01} + st\,P_{11}\Big]
$$
This is a **Boolean sum** of the boundary interpolants minus the bilinear correction at the corners. It maps the unit square $[0,1]^2$ to the curved quadrilateral, producing a structured grid of quadrilateral elements.
**Drag the corners** below to see how TFI generates a structured mesh inside an arbitrary 4-sided region:
```{=html}
<style>
#tfi-canvas {
display: block; width: 100%; height: 440px;
border: 1px solid var(--bs-border-color, #dee2e6); border-radius: 6px;
background: var(--bs-body-bg, #fff); cursor: grab;
}
.tfi-controls {
display: flex; gap: 1rem; flex-wrap: wrap; margin: 0.6rem 0; align-items: center;
}
.tfi-controls label { font-size: 0.8rem; color: var(--bs-secondary-color, #6c757d); }
.tfi-controls input[type=range] { width: 120px; }
#tfi-info { font-size: 0.82rem; color: var(--bs-secondary-color, #6c757d); font-family: monospace; margin-top: 0.3rem; }
</style>
<div>
<div class="tfi-controls">
<label>Grid density: <input type="range" id="tfi-density" min="2" max="20" value="8"></label>
<span id="tfi-density-val" style="font-size:0.8rem;">8 x 8</span>
<button id="tfi-reset-btn" style="padding:0.3rem 0.8rem;border:1px solid var(--bs-border-color,#dee2e6);border-radius:4px;background:var(--bs-body-bg,#fff);font-size:0.82rem;cursor:pointer;">Reset corners</button>
</div>
<canvas id="tfi-canvas"></canvas>
<div id="tfi-info">Drag the orange corners to deform the block.</div>
</div>
<script>
(function() {
const canvas = document.getElementById('tfi-canvas');
const ctx = canvas.getContext('2d');
const info = document.getElementById('tfi-info');
const densitySlider = document.getElementById('tfi-density');
const densityVal = document.getElementById('tfi-density-val');
// 4 corner points in math coords [0,1] where y increases upward
let corners = [
{x: 0.15, y: 0.15}, // P00 (bottom-left)
{x: 0.85, y: 0.15}, // P10 (bottom-right)
{x: 0.15, y: 0.85}, // P01 (top-left)
{x: 0.85, y: 0.85} // P11 (top-right)
];
const defaultCorners = corners.map(c => ({...c}));
let dragging = -1;
function resizeCanvas() {
const rect = canvas.parentElement.getBoundingClientRect();
const w = rect.width;
const h = 440;
canvas.width = w * devicePixelRatio;
canvas.height = h * devicePixelRatio;
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
}
function c2s(cx, cy) {
const cw = canvas.width / devicePixelRatio, ch = canvas.height / devicePixelRatio;
const margin = 40;
return {
sx: margin + cx * (cw - 2*margin),
sy: margin + (1 - cy) * (ch - 2*margin) // flip y: math-up → screen-down
};
}
function s2c(sx, sy) {
const cw = canvas.width / devicePixelRatio, ch = canvas.height / devicePixelRatio;
const margin = 40;
return {
x: (sx - margin) / (cw - 2*margin),
y: 1 - (sy - margin) / (ch - 2*margin) // flip y back
};
}
// TFI: map (s,t) in [0,1]^2 to world coords
function tfi(s, t) {
const P00 = corners[0], P10 = corners[1], P01 = corners[2], P11 = corners[3];
// Boundary curves (linear for now)
const C0x = (1-s)*P00.x + s*P10.x; // bottom
const C0y = (1-s)*P00.y + s*P10.y;
const C1x = (1-s)*P01.x + s*P11.x; // top
const C1y = (1-s)*P01.y + s*P11.y;
const C2x = (1-t)*P00.x + t*P01.x; // left
const C2y = (1-t)*P00.y + t*P01.y;
const C3x = (1-t)*P10.x + t*P11.x; // right
const C3y = (1-t)*P10.y + t*P11.y;
// Boolean sum
const x = (1-t)*C0x + t*C1x + (1-s)*C2x + s*C3x
- ((1-s)*(1-t)*P00.x + s*(1-t)*P10.x + (1-s)*t*P01.x + s*t*P11.x);
const y = (1-t)*C0y + t*C1y + (1-s)*C2y + s*C3y
- ((1-s)*(1-t)*P00.y + s*(1-t)*P10.y + (1-s)*t*P01.y + s*t*P11.y);
return {x, y};
}
function scaledJacobian(s, t) {
const eps = 0.001;
const p = tfi(s, t);
const ps = tfi(s + eps, t);
const pt = tfi(s, t + eps);
const dxds = (ps.x - p.x) / eps, dyds = (ps.y - p.y) / eps;
const dxdt = (pt.x - p.x) / eps, dydt = (pt.y - p.y) / eps;
const J = dxds * dydt - dyds * dxdt;
const lenS = Math.sqrt(dxds*dxds + dyds*dyds);
const lenT = Math.sqrt(dxdt*dxdt + dydt*dydt);
if (lenS < 1e-12 || lenT < 1e-12) return 0;
return J / (lenS * lenT);
}
function draw() {
const cw = canvas.width / devicePixelRatio, ch = canvas.height / devicePixelRatio;
ctx.clearRect(0, 0, cw, ch);
const N = parseInt(densitySlider.value);
densityVal.textContent = `${N} x ${N}`;
let minSJ = Infinity, maxSJ = -Infinity, sumSJ = 0, countSJ = 0;
// Draw quad cells with Jacobian coloring
for (let i = 0; i < N; i++) {
for (let j = 0; j < N; j++) {
const s0 = i/N, s1 = (i+1)/N;
const t0 = j/N, t1 = (j+1)/N;
const p00 = tfi(s0, t0), p10 = tfi(s1, t0);
const p01 = tfi(s0, t1), p11 = tfi(s1, t1);
const sc = (s0+s1)/2, tc = (t0+t1)/2;
const sj = scaledJacobian(sc, tc);
if (sj < minSJ) minSJ = sj;
if (sj > maxSJ) maxSJ = sj;
sumSJ += sj; countSJ++;
// Color by quality
let r, g, b;
if (sj > 0.8) { r=46; g=204; b=113; } // green
else if (sj > 0.5) { r=241; g=196; b=15; } // yellow
else if (sj > 0.2) { r=230; g=126; b=34; } // orange
else { r=231; g=76; b=60; } // red
const q00=c2s(p00.x,p00.y), q10=c2s(p10.x,p10.y), q01=c2s(p01.x,p01.y), q11=c2s(p11.x,p11.y);
ctx.fillStyle = `rgba(${r},${g},${b},0.3)`;
ctx.beginPath();
ctx.moveTo(q00.sx,q00.sy); ctx.lineTo(q10.sx,q10.sy);
ctx.lineTo(q11.sx,q11.sy); ctx.lineTo(q01.sx,q01.sy);
ctx.closePath();
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.3)';
ctx.lineWidth = 0.8;
ctx.stroke();
}
}
// Draw boundary (thick)
ctx.strokeStyle = 'steelblue';
ctx.lineWidth = 2.5;
const boundary = [corners[0], corners[1], corners[3], corners[2]];
ctx.beginPath();
const sb = c2s(boundary[0].x, boundary[0].y);
ctx.moveTo(sb.sx, sb.sy);
for (let i = 1; i < boundary.length; i++) {
const s = c2s(boundary[i].x, boundary[i].y);
ctx.lineTo(s.sx, s.sy);
}
ctx.closePath();
ctx.stroke();
// Draw draggable corners
const cornerLabels = ['P₀₀', 'P₁₀', 'P₀₁', 'P₁₁'];
for (let i = 0; i < 4; i++) {
const s = c2s(corners[i].x, corners[i].y);
ctx.fillStyle = dragging === i ? '#e74c3c' : 'orange';
ctx.beginPath(); ctx.arc(s.sx, s.sy, 8, 0, Math.PI*2); ctx.fill();
ctx.fillStyle = '#fff'; ctx.font = 'bold 9px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
ctx.fillText(cornerLabels[i], s.sx, s.sy);
}
// Quality legend
const meanSJ = countSJ > 0 ? sumSJ / countSJ : 0;
info.textContent = `Scaled Jacobian — min: ${minSJ.toFixed(3)}, max: ${maxSJ.toFixed(3)}, mean: ${meanSJ.toFixed(3)}`;
// Color legend
ctx.font = '11px monospace';
ctx.fillStyle = 'rgba(46,204,113,0.8)'; ctx.fillRect(cw-155, 10, 12, 12);
ctx.fillStyle = '#333'; ctx.fillText('> 0.8 (good)', cw-138, 20);
ctx.fillStyle = 'rgba(241,196,15,0.8)'; ctx.fillRect(cw-155, 26, 12, 12);
ctx.fillStyle = '#333'; ctx.fillText('> 0.5 (ok)', cw-138, 36);
ctx.fillStyle = 'rgba(230,126,34,0.8)'; ctx.fillRect(cw-155, 42, 12, 12);
ctx.fillStyle = '#333'; ctx.fillText('> 0.2 (poor)', cw-138, 52);
ctx.fillStyle = 'rgba(231,76,60,0.8)'; ctx.fillRect(cw-155, 58, 12, 12);
ctx.fillStyle = '#333'; ctx.fillText('< 0.2 (bad)', cw-138, 68);
}
// Mouse interaction for dragging corners
canvas.addEventListener('mousedown', (e) => {
const rect = canvas.getBoundingClientRect();
const sx = (e.clientX - rect.left);
const sy = (e.clientY - rect.top);
for (let i = 0; i < 4; i++) {
const s = c2s(corners[i].x, corners[i].y);
if ((sx-s.sx)**2 + (sy-s.sy)**2 < 200) {
dragging = i;
canvas.style.cursor = 'grabbing';
break;
}
}
});
canvas.addEventListener('mousemove', (e) => {
if (dragging < 0) return;
const rect = canvas.getBoundingClientRect();
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const c = s2c(sx, sy);
corners[dragging].x = Math.max(0.02, Math.min(0.98, c.x));
corners[dragging].y = Math.max(0.02, Math.min(0.98, c.y));
draw();
});
canvas.addEventListener('mouseup', () => { dragging = -1; canvas.style.cursor = 'grab'; });
canvas.addEventListener('mouseleave', () => { dragging = -1; canvas.style.cursor = 'grab'; });
// Touch support
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
const rect = canvas.getBoundingClientRect();
const touch = e.touches[0];
const sx = touch.clientX - rect.left;
const sy = touch.clientY - rect.top;
for (let i = 0; i < 4; i++) {
const s = c2s(corners[i].x, corners[i].y);
if ((sx-s.sx)**2 + (sy-s.sy)**2 < 400) { dragging = i; break; }
}
}, {passive: false});
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
if (dragging < 0) return;
const rect = canvas.getBoundingClientRect();
const touch = e.touches[0];
const c = s2c(touch.clientX - rect.left, touch.clientY - rect.top);
corners[dragging].x = Math.max(0.02, Math.min(0.98, c.x));
corners[dragging].y = Math.max(0.02, Math.min(0.98, c.y));
draw();
}, {passive: false});
canvas.addEventListener('touchend', () => { dragging = -1; });
densitySlider.addEventListener('input', draw);
document.getElementById('tfi-reset-btn').addEventListener('click', () => {
corners = defaultCorners.map(c => ({...c}));
draw();
});
resizeCanvas();
window.addEventListener('resize', () => { resizeCanvas(); draw(); });
draw();
})();
</script>
```
::: {.callout-tip}
## What to explore
- Drag corners to create extreme quadrilateral shapes — notice how the **scaled Jacobian** drops when the quad becomes very distorted
- A negative Jacobian (shown in red) means the mapping is **inverted** — the quad folds over itself, producing an invalid mesh element
- Increase the grid density to see finer meshes — the quality metric stays the same regardless of resolution, since it depends only on the boundary shape
:::
---
## 4. Results
### 4.1 Quality Comparison
The paper compares against unstructured quad meshes from GMSH on identical domains (~9000 quads each), using two metrics:
- **Minimum angle quality**: the smallest angle in each quad (ideal = 90°)
- **Scaled Jacobian**: measures element distortion (ideal = 1.0)
The block-structured approach achieves better quality on both metrics, primarily because of the low singularity count.
### 4.2 Examples
**Symmetry preservation**: The PDE-based approach naturally respects domain symmetry. A symmetric domain produces a symmetric partitioning — four 3-valent singularities and three 4-valent ones.
**Airfoil**: For a multi-element aerofoil (leading-edge flat, main body, trailing-edge flap), the method produces few singularities and generates boundary layers of quadrilaterals aligned orthogonally to the surface — exactly what CFD simulations need.
**Multi-domain**: When two domains share an interface curve, they can be partitioned separately (non-conforming) or jointly (conforming). Joint partitioning allows separatrices to cross between domains.
### 4.3 Computational Cost
> *"The time needed to partition the domain for the different examples presented in this paper varied from about one second, for most of them, to twenty seconds for the most complicated ones."*
The cost scales with the number of triangles in $T_h$. The meshing step (transfinite interpolation) is negligible — even for millions of elements.
---
## 5. Connection to Our Work
This paper presents the same fundamental pipeline used in many frame field-based quad meshing methods:
| Stage | This Paper | Ray et al. 2015 |
|-------|-----------|-----------------|
| Field representation | Representation vector $\mathbf{u} = (\cos\theta, \sin\theta)$ | Frame angle $\theta$ with $n$-symmetry |
| Smoothness | Dirichlet energy $\int|\nabla\mathbf{u}|^2 dx$ | Curvature $\sum_e (\Delta\theta)^2$ |
| Solve method | FEM + Lagrange multipliers for $|\mathbf{u}|=1$ | Direct linear solve in $(\cos 4\theta, \sin 4\theta)$ |
| Singularities | Poincare index of $\mathbf{u}$ | Angle defect around triangles |
| Partitioning | Separatrix tracing + transfinite interpolation | MIQ parameterization + quad extraction |
The key difference: this paper uses a **representation vector** $\mathbf{u} \in \mathbb{R}^2$ with a unit norm constraint, while the [Ray 2015 approach](frame_theory.qmd) encodes cross field orientation through angle-based representations with 4-symmetry built in algebraically. Both arrive at the same cross field — just different numerical paths.
The [NACA airfoil demo](demos/naca_airfoil.html) lets you explore several of these concepts interactively: boundary constraints, the linear solve, Jacobi smoothing, and streamline tracing through the resulting cross field.
---
<!-- Section 6 (NACA Airfoil full pipeline demo) removed — see git history for the full interactive demo code. -->