Skip to content

Commit b1f1de1

Browse files
authored
Merge pull request #28 from hsorby/main
Update Flatmap SVG exporter
2 parents 75bdc68 + afdd684 commit b1f1de1

File tree

7 files changed

+12367
-70
lines changed

7 files changed

+12367
-70
lines changed

src/cmlibs/exporter/flatmapsvg.py

Lines changed: 223 additions & 68 deletions
Large diffs are not rendered by default.

src/cmlibs/exporter/utils/__init__.py

Whitespace-only changes.
Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
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

Comments
 (0)