-
Notifications
You must be signed in to change notification settings - Fork 0
04 stroke geometry and rendering
This chapter details how to mathematically reconstruct control points and pressure data into smooth, variable-width vector SVG Path Ribbons, as well as how to handle strokes partially cut by the eraser (v9 Native Mesh).
Each stroke consists of a series of control points with pressure values. It is defined as StrokePoint:
@dataclass(frozen=True)
class StrokePoint:
x: float
y: float
pressure: float = 1.0 # Radius or relative pressure ratioIf the point array does not contain explicit pressure (e.g., pure 2D coordinates), half of the default_width (i.e., default radius
Drawing polylines directly using SVG stroke-width cannot represent the variable thickness effect of a real stylus (such as a fountain pen or calligraphy pen) responding to pressure and speed. The process employs the Bi-lateral Offset Outlines algorithm based on normal vectors to generate closed 2D vector polygon Ribbons.
For a stroke sequence of
For each point
- Endpoint
$i=0$ :$\vec{T}_0 = P_1 - P_0$ - Endpoint
$i=N-1$ : $\vec{T}{N-1} = P{N-1} - P_{N-2}$ - Middle points
$1 \le i \le N-2$ : Use central difference $\vec{T}i = P{i+1} - P_{i-1}$
Rotate the tangent vector 90 degrees to get the original normal vector $\vec{N}i = (-T{y}, T_{x})$. If
To prevent minor stroke jitters from causing jagged intersections between the two side outlines, a neighborhood sliding average is applied to the normal vectors:
Direct averaging shortens the length of the normal vector (making the stroke thinner at turns). It must be re-normalized to a unit vector:
Based on the dynamic radius
After obtaining the build_stroke_ribbon() uses SVG drawing commands to assemble a closed path:
[Start Cap (Arc A)] ──► [Right Side Smooth Quad Curves (Q)] ──► [End Cap (Arc A)] ──► [Left Side Smooth Quad Curves (Q)] ──► Close (Z)
# Rounded cap and smooth control commands (key snippet from build_stroke_ribbon)
d = [f"M {left_side[0][0]:.2f} {left_side[0][1]:.2f}"]
# 1. Start rounded cap Arc (A)
d.append(f"A {r0:.2f} {r0:.2f} 0 0 1 {right_side[0][0]:.2f} {right_side[0][1]:.2f}")
# 2. Right side smooth Quadratic Bezier (Q)
d.extend(smooth_commands(right_side))
# 3. End rounded cap Arc (A)
d.append(f"A {r1:.2f} {r1:.2f} 0 0 1 {left_side[-1][0]:.2f} {left_side[-1][1]:.2f}")
# 4. Left side reversed smooth Quadratic Bezier (Q)
d.extend(smooth_commands(reversed_left))
d.append("Z")When a user erases part of a stroke using the eraser, a v9 array is generated.
Visual format analysis discovered: the v9 array is not a continuous traversal order of the outline. Instead, every 6 points form a group representing sampling points on the left and right sides of the cross-section along the forward direction of the stroke. Directly connecting them with L (Line) at sharp bends or tapered ends of the stroke would result in jagged edges and self-intersecting gaps.
The process adopts a sliding-window convex hull algorithm (_v9_polygon_to_hull_panels) that does not rely on group alignment:
- Set the sliding window size
window = 16, and stridestride = 4. - Move the window over the
v9point array, and compute the Andrew's Monotone Chain Convex Hull for the point set within the window. - Windows intentionally overlap (
stride < window) to ensure that the convex hull panels connect with each other, leaving absolutely no gaps. - Because the window scope is small, the sharp Flat Cut Caps created by the eraser will not be rounded off.
def _convex_hull(points: Sequence[tuple[float, float]]) -> list[tuple[float, float]]:
"""Andrew's monotone chain convex hull algorithm. Pure-python implementation."""
pts = sorted(set(points))
if len(pts) <= 2: return list(pts)
def cross(o, a, b):
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
lower = []
for p in pts:
while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0: lower.pop()
lower.append(p)
upper = []
for p in reversed(pts):
while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0: upper.pop()
upper.append(p)
return lower[:-1] + upper[:-1]Sometimes certain arrays happen to fit within the coordinate value range, but they are actually directional/tangent metadata of the stroke rather than path points. The process calculates the directional reversal rate of adjacent vectors:
The Jitter Ratio of a normal stroke is usually less than 0.25. If it exceeds the threshold _JITTER_REJECT_THRESHOLD = 0.35, it is determined to be a metadata array and filtered out.
When the distance between two adjacent control points exceeds the safety threshold of 300.0 points, it indicates a point jump or an unrecorded pen lift at that location. split_stroke_points() safely cuts it into independent sub-strokes to avoid drawing erroneous straight lines across the entire page.
In the next chapter 05 - Shapes, Text, and Elements, we will introduce parsing details for vector shapes, arrow markers, rich text boxes, and image crops.
本章節詳細說明如何將提取出的控制點與壓感資料,數學化重建為平滑、具備自然寬度變化的向量 SVG Path Ribbon(帶狀路徑),以及處理被橡皮擦局部擦除切開的筆跡(v9 Native Mesh)。
每一條筆跡由一系列帶有壓感值的控制點組成。定義為 StrokePoint:
@dataclass(frozen=True)
class StrokePoint:
x: float
y: float
pressure: float = 1.0 # 半徑或相對壓感比例若點陣中未包含顯式壓感(如單純 2D 座標),則使用提取的 default_width 的一半(即預設半徑
直接使用 SVG stroke-width 繪製折線無法呈現真實手寫筆尖(如鋼筆、書法筆)隨壓感與速度變化的粗細效果。我們採用 法向量偏移雙側輪廓 (Bi-lateral Offset Outlines) 演算法,產生封閉的 2D 向量多邊形 Ribbon。
對包含
對每個點
- 端點
$i=0$ :$\vec{T}_0 = P_1 - P_0$ - 端點
$i=N-1$ :$\vec{T}{N-1} = P{N-1} - P_{N-2}$ - 中間點
$1 \le i \le N-2$ :採用中心差分 $\vec{T}i = P{i+1} - P_{i-1}$
將切線向量旋轉 90 度得到原始法向量 $\vec{N}i = (-T{y}, T_{x})$。若
為防止筆劃微小抖動導致兩側輪廓產生鋸齒交叉,對法向量進行鄰域滑動平均:
直接平均會縮短法向量長度(使轉彎處筆劃變細)。必須重新歸一化為單位向量:
根據點
獲得 build_stroke_ribbon() 使用 SVG 繪製指令組合出封閉路徑:
[Start Cap (Arc A)] ──► [Right Side Smooth Quad Curves (Q)] ──► [End Cap (Arc A)] ──► [Left Side Smooth Quad Curves (Q)] ──► Close (Z)
# 圓角收頭與平滑控制指令 (build_stroke_ribbon 關鍵片段)
d = [f"M {left_side[0][0]:.2f} {left_side[0][1]:.2f}"]
# 1. 起始端圓角 Arc (A)
d.append(f"A {r0:.2f} {r0:.2f} 0 0 1 {right_side[0][0]:.2f} {right_side[0][1]:.2f}")
# 2. 右側平滑二次貝茲曲線 Quadratic Bezier (Q)
d.extend(smooth_commands(right_side))
# 3. 結束端圓角 Arc (A)
d.append(f"A {r1:.2f} {r1:.2f} 0 0 1 {left_side[-1][0]:.2f} {left_side[-1][1]:.2f}")
# 4. 左側反向平滑二次貝茲曲線 Quadratic Bezier (Q)
d.extend(smooth_commands(reversed_left))
d.append("Z")當使用者使用橡皮擦擦除筆跡的一部分時,會生成 v9 陣列。
經視覺化格式分析發現:v9 陣列並不是外框的連續走訪順序,而是每 6 個點為一組、代表沿筆劃前進方向橫截面左右兩側的取樣點。在筆跡彎曲劇烈或收尖處,直接用 L (Line) 連接會產生鋸齒與自我交叉缺口。
我們採用了不依賴分組是否對齊的滑動視窗凸包算法 (_v9_polygon_to_hull_panels):
- 設定滑動視窗大小
window = 16,步長stride = 4。 - 在
v9點陣上移動視窗,對視窗內的點集計算 Andrew's Monotone Chain 凸包。 - 視窗之間刻意重疊 (
stride < window),確保凸包面板彼此相接、完全不留縫隙。 - 由於視窗範圍很小,橡皮擦切出的銳利平頭邊緣(Flat Cut Caps)不會被磨圓。
def _convex_hull(points: Sequence[tuple[float, float]]) -> list[tuple[float, float]]:
"""Andrew's monotone chain 凸包演算法。Pure-python 實現。"""
pts = sorted(set(points))
if len(pts) <= 2: return list(pts)
def cross(o, a, b):
return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
lower = []
for p in pts:
while len(lower) >= 2 and cross(lower[-2], lower[-1], p) <= 0: lower.pop()
lower.append(p)
upper = []
for p in reversed(pts):
while len(upper) >= 2 and cross(upper[-2], upper[-1], p) <= 0: upper.pop()
upper.append(p)
return lower[:-1] + upper[:-1]有時某些陣列剛好符合座標數值範圍,但實際上是筆劃的方向/切線元數據而非路徑點。 我們計算相鄰向量的方向反轉率:
正常筆跡的 Jitter Ratio 通常小於 0.25,若超過閾值 _JITTER_REJECT_THRESHOLD = 0.35,則判定為元數據陣列並予以過濾。
當兩個相鄰控制點之間的距離超過安全門檻 300.0 points 時,表示該處存在點位跳躍或抬筆未記錄。split_stroke_points() 會將其安全切斷為獨立子筆跡,避免繪製出穿過整張頁面的錯誤直線。
在下一章 05 - 圖形、文字與頁面元素 中,我們將介紹向量圖形 (Shapes)、箭頭 Marker、富文本框與圖片裁切 (Crop) 的解析細節。