1+ import numpy as np
2+ # import matplotlib.pyplot as plt
3+ # import matplotlib.patches as patches
4+
5+
6+ # --- 1. Bézier Math Functions (Unchanged) ---
7+
8+ def cubic_bezier_point (t , control_points ):
9+ """Calculates a point on a cubic Bézier curve at parameter t."""
10+ p0 , p1 , p2 , p3 = control_points
11+ t_inv = 1.0 - t
12+ return (t_inv ** 3 * p0 +
13+ 3 * t_inv ** 2 * t * p1 +
14+ 3 * t_inv * t ** 2 * p2 +
15+ t ** 3 * p3 )
16+
17+
18+ def cubic_bezier_derivative (t , control_points ):
19+ """Calculates the derivative (tangent vector) of a cubic Bézier curve."""
20+ p0 , p1 , p2 , p3 = control_points
21+ t_inv = 1.0 - t
22+ return (3 * t_inv ** 2 * (p1 - p0 ) +
23+ 6 * t_inv * t * (p2 - p1 ) +
24+ 3 * t ** 2 * (p3 - p2 ))
25+
26+
27+ # --- 2. The Updated Stroking Algorithm with Capping ---
28+
29+ def _stroke_single_segment (control_points , offset_distance , num_segments = 50 ):
30+ """Helper function to generate the raw offset strokes for a single 4-point Bézier segment."""
31+ offset_points_a , offset_points_b = [], []
32+ t_values = np .linspace (0 , 1 , num_segments + 1 )
33+ last_good_normal = None
34+
35+ for t in t_values :
36+ point = cubic_bezier_point (t , control_points )
37+ tangent = cubic_bezier_derivative (t , control_points )
38+ norm_of_tangent = np .linalg .norm (tangent )
39+
40+ if norm_of_tangent < 1e-6 :
41+ if last_good_normal is not None :
42+ unit_normal = last_good_normal
43+ else :
44+ continue
45+ else :
46+ normal = np .array ([- tangent [1 ], tangent [0 ]])
47+ unit_normal = normal / np .linalg .norm (normal )
48+ last_good_normal = unit_normal
49+
50+ offset_points_a .append (point + offset_distance * unit_normal )
51+ offset_points_b .append (point - offset_distance * unit_normal )
52+
53+ return np .array (offset_points_a ), np .array (offset_points_b )
54+
55+
56+ def _apply_caps (stroke_a , stroke_b , start_control_points , end_control_points , offset_distance , cap_style , cap_segments ):
57+ """Helper function to apply caps to a completed pair of stroke polylines."""
58+ if cap_style == 'butt' :
59+ full_polygon = np .concatenate ([stroke_a , stroke_b [::- 1 ]])
60+ return [full_polygon ]
61+
62+ elif cap_style == 'round' :
63+ end_center = cubic_bezier_point (1 , end_control_points )
64+ end_arc = _create_arc_points (end_center , stroke_a [- 1 ], stroke_b [- 1 ], cap_segments )
65+ start_center = cubic_bezier_point (0 , start_control_points )
66+ start_arc = _create_arc_points (start_center , stroke_b [0 ], stroke_a [0 ], cap_segments )
67+ full_polygon = np .concatenate ([stroke_a , end_arc , stroke_b [::- 1 ], start_arc ])
68+ return [full_polygon ]
69+
70+ elif cap_style == 'square' :
71+ start_tangent = cubic_bezier_derivative (0.001 , start_control_points )
72+ start_tangent_unit = start_tangent / np .linalg .norm (start_tangent )
73+ end_tangent = cubic_bezier_derivative (0.999 , end_control_points )
74+ end_tangent_unit = end_tangent / np .linalg .norm (end_tangent )
75+
76+ cap_start_a = stroke_a [0 ] - offset_distance * start_tangent_unit
77+ cap_start_b = stroke_b [0 ] - offset_distance * start_tangent_unit
78+ cap_end_a = stroke_a [- 1 ] + offset_distance * end_tangent_unit
79+ cap_end_b = stroke_b [- 1 ] + offset_distance * end_tangent_unit
80+
81+ full_polygon = np .concatenate (
82+ [[cap_start_b ], [cap_start_a ], stroke_a , [cap_end_a ], [cap_end_b ], stroke_b [::- 1 ]])
83+ return [full_polygon ]
84+
85+ else :
86+ raise ValueError ("cap_style must be one of 'butt', 'round', or 'square'" )
87+
88+
89+ # --- 3. New Main Function for Poly-Bézier Curves ---
90+
91+ def stroke_poly_bezier (all_control_points , offset_distance , num_segments_per_curve = 50 , cap_style = 'round' ,
92+ cap_segments = 15 ):
93+ """
94+ Strokes a continuous Poly-Bézier curve defined by a list of control points.
95+ The number of points must be 3*N + 1 (e.g., 4, 7, 10, ...).
96+ """
97+ all_control_points = np .array (all_control_points )
98+
99+ # # Validate the number of control points
100+ # if len(all_control_points) < 4 or (len(all_control_points) - 1) % 3 != 0:
101+ # raise ValueError("Number of control points must be 3*N + 1 (e.g., 4, 7, 10...).")
102+
103+ # Break the points into 4-point segments
104+ # segments = []
105+ # for i in range(0, len(all_control_points) - 1, 3):
106+ # segment = all_control_points[i:i + 4]
107+ # segments.append(segment)
108+
109+ # Stroke each segment and stitch the results together
110+ full_stroke_a , full_stroke_b = [], []
111+ for i , segment in enumerate (all_control_points ):
112+ stroke_a , stroke_b = _stroke_single_segment (segment , offset_distance , num_segments_per_curve )
113+
114+ if i == 0 :
115+ full_stroke_a .append (stroke_a )
116+ full_stroke_b .append (stroke_b )
117+ else :
118+ # Append, skipping the first point to avoid duplicates at joins
119+ full_stroke_a .append (stroke_a [1 :])
120+ full_stroke_b .append (stroke_b [1 :])
121+
122+ # Combine the lists of arrays into single arrays
123+ final_stroke_a = np .concatenate (full_stroke_a , axis = 0 )
124+ final_stroke_b = np .concatenate (full_stroke_b , axis = 0 )
125+
126+ # Apply caps to the start and end of the complete stitched curve
127+ start_cps = all_control_points [0 ]
128+ end_cps = all_control_points [- 1 ]
129+ return _apply_caps (final_stroke_a , final_stroke_b , start_cps , end_cps , offset_distance , cap_style , cap_segments )
130+
131+
132+ def stroke_bezier (control_points , offset_distance , num_segments = 100 , cap_style = 'butt' , cap_segments = 10 ):
133+ """
134+ Performs discrete offsetting (stroking) on a cubic Bézier curve with end caps.
135+
136+ Args:
137+ control_points (list or np.ndarray): The four [x, y] control points P0, P1, P2, P3.
138+ offset_distance (float): The distance to offset the curve on each side.
139+ num_segments (int): Number of segments to approximate the curve.
140+ cap_style (str): The style of the end caps. One of 'butt', 'round', or 'square'.
141+ cap_segments (int): Number of segments for 'round' caps.
142+
143+ Returns:
144+ list[np.ndarray]: A list of numpy arrays. For 'butt' caps, this will be two
145+ separate polylines. For 'round' and 'square' caps, it will
146+ be a single closed polygon.
147+ """
148+ control_points = np .array (control_points )
149+
150+ # Generate the two offset strokes (polylines)
151+ offset_points_a = []
152+ offset_points_b = []
153+ t_values = np .linspace (0 , 1 , num_segments + 1 )
154+ last_good_normal = None
155+
156+ for t in t_values :
157+ point = cubic_bezier_point (t , control_points )
158+ tangent = cubic_bezier_derivative (t , control_points )
159+ norm_of_tangent = np .linalg .norm (tangent )
160+
161+ if norm_of_tangent < 1e-6 :
162+ if last_good_normal is not None :
163+ unit_normal = last_good_normal
164+ else : # Cannot determine a normal, skip
165+ continue
166+ else :
167+ normal = np .array ([- tangent [1 ], tangent [0 ]])
168+ unit_normal = normal / np .linalg .norm (normal )
169+ last_good_normal = unit_normal
170+
171+ offset_points_a .append (point + offset_distance * unit_normal )
172+ offset_points_b .append (point - offset_distance * unit_normal )
173+
174+ stroke_a = np .array (offset_points_a )
175+ stroke_b = np .array (offset_points_b )
176+
177+ # If strokes are empty, return nothing
178+ if len (stroke_a ) == 0 :
179+ return []
180+
181+ # --- Capping Logic ---
182+ if cap_style == 'butt' :
183+ # End cap (t=1)
184+ end_center = cubic_bezier_point (1 , control_points )
185+ end_arc = _create_arc_points (end_center , stroke_b [- 1 ], stroke_a [- 1 ], cap_segments )
186+
187+ # Start cap (t=0)
188+ start_center = cubic_bezier_point (0 , control_points )
189+ start_arc = _create_arc_points (start_center , stroke_a [0 ], stroke_b [0 ], cap_segments )
190+
191+ # Combine into a single closed polygon
192+ # Order: Stroke A -> End Cap Arc -> Reversed Stroke B -> Start Cap Arc
193+ full_polygon = np .concatenate ([
194+ stroke_a ,
195+ end_arc ,
196+ stroke_b [::- 1 ], # Reversed stroke B
197+ start_arc
198+ ])
199+ return [full_polygon ]
200+
201+ elif cap_style == 'round' :
202+ # End cap (t=1)
203+ end_center = cubic_bezier_point (1 , control_points )
204+ end_arc = _create_arc_points (end_center , stroke_a [- 1 ], stroke_b [- 1 ], cap_segments )
205+
206+ # Start cap (t=0)
207+ start_center = cubic_bezier_point (0 , control_points )
208+ start_arc = _create_arc_points (start_center , stroke_b [0 ], stroke_a [0 ], cap_segments )
209+
210+ # Combine into a single closed polygon
211+ # Order: Stroke A -> End Cap Arc -> Reversed Stroke B -> Start Cap Arc
212+ full_polygon = np .concatenate ([
213+ stroke_a ,
214+ end_arc ,
215+ stroke_b [::- 1 ], # Reversed stroke B
216+ start_arc
217+ ])
218+ return [full_polygon ]
219+
220+ elif cap_style == 'square' :
221+ # Start cap (t=0)
222+ start_tangent = cubic_bezier_derivative (0 , control_points )
223+ start_tangent_unit = start_tangent / np .linalg .norm (start_tangent )
224+ cap_start_a = stroke_a [0 ] - offset_distance * start_tangent_unit
225+ cap_start_b = stroke_b [0 ] - offset_distance * start_tangent_unit
226+
227+ # End cap (t=1)
228+ end_tangent = cubic_bezier_derivative (1 , control_points )
229+ end_tangent_unit = end_tangent / np .linalg .norm (end_tangent )
230+ cap_end_a = stroke_a [- 1 ] + offset_distance * end_tangent_unit
231+ cap_end_b = stroke_b [- 1 ] + offset_distance * end_tangent_unit
232+
233+ # Combine into a single closed polygon
234+ full_polygon = np .concatenate ([
235+ [cap_start_a ],
236+ stroke_a ,
237+ [cap_end_a , cap_end_b ],
238+ stroke_b [::- 1 ],
239+ [cap_start_b ]
240+ ])
241+ return [full_polygon ]
242+
243+ else :
244+ raise ValueError ("cap_style must be one of 'butt', 'round', or 'square'" )
245+
246+
247+ def _create_arc_points (center , start_point , end_point , num_segments ):
248+ """Helper function to create points for a semicircle."""
249+ v_start = start_point - center
250+ v_end = end_point - center
251+
252+ radius = np .linalg .norm (v_start )
253+
254+ start_angle = np .arctan2 (v_start [1 ], v_start [0 ])
255+ end_angle = np .arctan2 (v_end [1 ], v_end [0 ])
256+
257+ # Ensure the arc travels in the correct direction (less than 180 degrees)
258+ if np .cross (v_start , v_end ) < 0 :
259+ if end_angle < start_angle :
260+ start_angle += 2 * np .pi
261+ else :
262+ if end_angle > start_angle :
263+ end_angle -= 2 * np .pi
264+
265+ angles = np .linspace (start_angle , end_angle , num_segments )
266+ arc_points = center + radius * np .array ([np .cos (angles ), np .sin (angles )]).T
267+ return arc_points
268+
269+
270+ # --- 3. Example Usage & Visualization ---
271+
272+ def single_main ():
273+ # Define a sample cubic Bézier curve (S-curve)
274+ control_points = np .array ([[100 , 200 ], [250 , 400 ], [450 , 600 ], [600 , 200 ]])
275+
276+ # --- Parameters to change ---
277+ OFFSET_DISTANCE = 3.0
278+ NUM_SEGMENTS = 100
279+ CAP_STYLE = 'round' # Options: 'butt', 'round', 'square'
280+ CAP_SEGMENTS = 12
281+
282+ # Run the stroking algorithm
283+ stroke_polygons = stroke_bezier (control_points , OFFSET_DISTANCE , NUM_SEGMENTS , cap_style = CAP_STYLE , cap_segments = CAP_SEGMENTS )
284+
285+ # --- Visualization ---
286+ fig , ax = plt .subplots (figsize = (10 , 8 ))
287+
288+ # Plot the original curve as a thin line
289+ t_plot = np .linspace (0 , 1 , 200 )
290+ curve_points = np .array ([cubic_bezier_point (t , np .array (control_points )) for t in t_plot ])
291+ ax .plot (curve_points [:, 0 ], curve_points [:, 1 ], 'k-' , linewidth = 1 , alpha = 0.5 , label = 'Original Bézier Curve' )
292+
293+ # Plot the control points and polygon
294+ ax .plot (control_points [:, 0 ], control_points [:, 1 ], 'co--' , alpha = 0.5 , label = 'Control Polygon' )
295+ ax .plot (control_points [:, 0 ], control_points [:, 1 ], 'c*' , markersize = 10 )
296+
297+ # Plot the resulting stroke polygon(s)
298+ for poly_points in stroke_polygons :
299+ polygon = patches .Polygon (poly_points , closed = True , facecolor = 'steelblue' , edgecolor = 'darkblue' )
300+ ax .add_patch (polygon )
301+
302+ ax .set_title (f"Bézier Stroke with '{ CAP_STYLE .capitalize ()} ' Caps" )
303+ ax .set_xlabel ('X Coordinate' )
304+ ax .set_ylabel ('Y Coordinate' )
305+ ax .legend ()
306+ ax .grid (True )
307+ ax .set_aspect ('equal' , adjustable = 'box' ) # Use an equal aspect ratio
308+ plt .show ()
309+
310+
311+ def poly_main ():
312+ # A list of 7 control points, defining TWO connected cubic Bézier segments
313+ poly_control_points = np .array ([
314+ [100 , 400 ], # P0
315+ [200 , 100 ], # P1
316+ [300 , 400 ], # P2
317+ [400 , 250 ], # P3 - Join point
318+ [500 , 100 ], # P4
319+ [600 , 350 ], # P5
320+ [700 , 200 ] # P6
321+ ])
322+
323+ # --- Parameters to change ---
324+ OFFSET_DISTANCE = 25.0
325+ CAP_STYLE = 'round' # Options: 'butt', 'round', 'square'
326+
327+ # Use the new function for Poly-Bézier curves
328+ stroke_polygons = stroke_poly_bezier (poly_control_points , OFFSET_DISTANCE , cap_style = CAP_STYLE )
329+
330+ fig , ax = plt .subplots (figsize = (12 , 8 ))
331+
332+ # Plot the original composite curve
333+ full_curve_points = []
334+ num_segments = (len (poly_control_points ) - 1 ) // 3
335+ for i in range (num_segments ):
336+ segment_cps = poly_control_points [i * 3 :i * 3 + 4 ]
337+ t_plot = np .linspace (0 , 1 , 100 )
338+ # Skip first point of subsequent segments to avoid duplication
339+ start_index = 1 if i > 0 else 0
340+ full_curve_points .extend (cubic_bezier_point (t , segment_cps ) for t in t_plot [start_index :])
341+
342+ full_curve_points = np .array (full_curve_points )
343+ ax .plot (full_curve_points [:, 0 ], full_curve_points [:, 1 ], 'k-' , linewidth = 1 , alpha = 0.5 ,
344+ label = 'Original Poly-Bézier Curve' )
345+
346+ # Plot the control points
347+ ax .plot (poly_control_points [:, 0 ], poly_control_points [:, 1 ], 'co--' , alpha = 0.5 , label = 'Control Polygon' )
348+ ax .plot (poly_control_points [:, 0 ], poly_control_points [:, 1 ], 'c*' , markersize = 10 )
349+
350+ # Draw the resulting stroke polygon(s)
351+ for poly_points in stroke_polygons :
352+ polygon = patches .Polygon (poly_points , closed = True , facecolor = 'lightcoral' , edgecolor = 'darkred' )
353+ ax .add_patch (polygon )
354+
355+ ax .set_title (f"Stroked Poly-Bézier Curve with '{ CAP_STYLE .capitalize ()} ' Caps" )
356+ ax .legend ()
357+ ax .grid (True )
358+ ax .set_aspect ('equal' , adjustable = 'box' )
359+ plt .show ()
360+
361+ if __name__ == '__main__' :
362+ single_main ()
363+ # poly_main()
0 commit comments