bundle: update (2026-01-18)

This commit is contained in:
2026-01-18 01:20:18 +00:00
parent 83c22af578
commit 03c5c75177
149 changed files with 38486 additions and 0 deletions

View File

@@ -0,0 +1,776 @@
#!/usr/bin/env python3
import math
try:
from inkex import Path
except ImportError:
Path = None
def subpath_to_points(subpath, precision=1.0):
points = []
current_point = (0, 0)
for cmd in subpath:
if isinstance(cmd, tuple):
letter = cmd[0].upper()
coord = cmd[1]
else:
letter = cmd.letter.upper()
args = cmd.args
if letter == 'M':
if isinstance(cmd, tuple):
current_point = coord
else:
current_point = (args[0], args[1])
points.append(current_point)
elif letter == 'L':
if isinstance(cmd, tuple):
current_point = coord
else:
current_point = (args[0], args[1])
points.append(current_point)
elif letter == 'H':
if isinstance(cmd, tuple):
current_point = (coord[0], current_point[1])
else:
current_point = (args[0], current_point[1])
points.append(current_point)
elif letter == 'V':
if isinstance(cmd, tuple):
current_point = (current_point[0], coord[1])
else:
current_point = (current_point[0], args[0])
points.append(current_point)
elif letter == 'C':
p0 = current_point
p1 = (args[0], args[1])
p2 = (args[2], args[3])
p3 = (args[4], args[5])
chord_length = distance(p0, p3)
control_length = distance(p0, p1) + distance(p1, p2) + distance(p2, p3)
approx_length = (chord_length + control_length) / 2
num_segments = max(2, int(approx_length / precision))
for i in range(1, num_segments + 1):
t = i / num_segments
point = cubic_bezier_point(p0, p1, p2, p3, t)
points.append(point)
current_point = p3
elif letter == 'S':
p0 = current_point
p3 = (args[2], args[3])
chord_length = distance(p0, p3)
num_segments = max(2, int(chord_length / precision))
for i in range(1, num_segments + 1):
t = i / num_segments
x = p0[0] + t * (p3[0] - p0[0])
y = p0[1] + t * (p3[1] - p0[1])
points.append((x, y))
current_point = p3
elif letter == 'Q':
p0 = current_point
p1 = (args[0], args[1])
p2 = (args[2], args[3])
chord_length = distance(p0, p2)
control_length = distance(p0, p1) + distance(p1, p2)
approx_length = (chord_length + control_length) / 2
num_segments = max(2, int(approx_length / precision))
for i in range(1, num_segments + 1):
t = i / num_segments
point = quadratic_bezier_point(p0, p1, p2, t)
points.append(point)
current_point = p2
elif letter == 'T':
p0 = current_point
p2 = (args[0], args[1])
chord_length = distance(p0, p2)
num_segments = max(2, int(chord_length / precision))
for i in range(1, num_segments + 1):
t = i / num_segments
x = p0[0] + t * (p2[0] - p0[0])
y = p0[1] + t * (p2[1] - p0[1])
points.append((x, y))
current_point = p2
elif letter == 'A':
rx = abs(args[0])
ry = abs(args[1])
x_axis_rotation = args[2]
large_arc_flag = args[3]
sweep_flag = args[4]
end_x = args[5]
end_y = args[6]
start_x, start_y = current_point
beziers = arc_to_beziers(start_x, start_y, rx, ry, x_axis_rotation,
large_arc_flag, sweep_flag, end_x, end_y)
for bez in beziers:
p0, p1, p2, p3 = bez
chord_length = distance(p0, p3)
control_length = distance(p0, p1) + distance(p1, p2) + distance(p2, p3)
approx_length = (chord_length + control_length) / 2
num_segments = max(2, int(approx_length / precision))
for i in range(1, num_segments + 1):
t = i / num_segments
point = cubic_bezier_point(p0, p1, p2, p3, t)
points.append(point)
current_point = (end_x, end_y)
return points
def arc_to_beziers(x1, y1, rx, ry, phi, large_arc, sweep, x2, y2):
if (x1, y1) == (x2, y2):
return []
if rx == 0 or ry == 0:
return [((x1, y1), (x1, y1), (x2, y2), (x2, y2))]
phi_rad = math.radians(phi)
cos_phi = math.cos(phi_rad)
sin_phi = math.sin(phi_rad)
dx = (x1 - x2) / 2
dy = (y1 - y2) / 2
x1_prime = cos_phi * dx + sin_phi * dy
y1_prime = -sin_phi * dx + cos_phi * dy
lambda_ = (x1_prime / rx) ** 2 + (y1_prime / ry) ** 2
if lambda_ > 1:
rx *= math.sqrt(lambda_)
ry *= math.sqrt(lambda_)
sq = max(0, (rx * ry) ** 2 - (rx * y1_prime) ** 2 - (ry * x1_prime) ** 2)
sq = math.sqrt(sq / ((rx * y1_prime) ** 2 + (ry * x1_prime) ** 2))
if large_arc == sweep:
sq = -sq
cx_prime = sq * rx * y1_prime / ry
cy_prime = -sq * ry * x1_prime / rx
cx = cos_phi * cx_prime - sin_phi * cy_prime + (x1 + x2) / 2
cy = sin_phi * cx_prime + cos_phi * cy_prime + (y1 + y2) / 2
def angle_between(ux, uy, vx, vy):
n = math.sqrt(ux * ux + uy * uy) * math.sqrt(vx * vx + vy * vy)
c = (ux * vx + uy * vy) / n
c = max(-1, min(1, c))
angle = math.acos(c)
if ux * vy - uy * vx < 0:
angle = -angle
return angle
theta1 = angle_between(1, 0, (x1_prime - cx_prime) / rx, (y1_prime - cy_prime) / ry)
dtheta = angle_between(
(x1_prime - cx_prime) / rx, (y1_prime - cy_prime) / ry,
(-x1_prime - cx_prime) / rx, (-y1_prime - cy_prime) / ry
)
if sweep == 0 and dtheta > 0:
dtheta -= 2 * math.pi
elif sweep == 1 and dtheta < 0:
dtheta += 2 * math.pi
segments = max(1, int(math.ceil(abs(dtheta) / (math.pi / 2))))
delta = dtheta / segments
beziers = []
for i in range(segments):
theta_start = theta1 + i * delta
theta_end = theta_start + delta
alpha = math.sin(delta) * (math.sqrt(4 + 3 * math.tan(delta / 2) ** 2) - 1) / 3
cos_start = math.cos(theta_start)
sin_start = math.sin(theta_start)
cos_end = math.cos(theta_end)
sin_end = math.sin(theta_end)
q1x = cos_start
q1y = sin_start
q2x = cos_end
q2y = sin_end
cp1x = q1x - q1y * alpha
cp1y = q1y + q1x * alpha
cp2x = q2x + q2y * alpha
cp2y = q2y - q2x * alpha
def transform(x, y):
x *= rx
y *= ry
nx = cos_phi * x - sin_phi * y
ny = sin_phi * x + cos_phi * y
return (nx + cx, ny + cy)
p0 = transform(q1x, q1y)
p1 = transform(cp1x, cp1y)
p2 = transform(cp2x, cp2y)
p3 = transform(q2x, q2y)
beziers.append((p0, p1, p2, p3))
return beziers
def distance(p1, p2):
dx = p2[0] - p1[0]
dy = p2[1] - p1[1]
return math.sqrt(dx * dx + dy * dy)
def cubic_bezier_point(p0, p1, p2, p3, t):
mt = 1 - t
mt2 = mt * mt
mt3 = mt2 * mt
t2 = t * t
t3 = t2 * t
x = mt3 * p0[0] + 3 * mt2 * t * p1[0] + 3 * mt * t2 * p2[0] + t3 * p3[0]
y = mt3 * p0[1] + 3 * mt2 * t * p1[1] + 3 * mt * t2 * p2[1] + t3 * p3[1]
return (x, y)
def quadratic_bezier_point(p0, p1, p2, t):
mt = 1 - t
mt2 = mt * mt
t2 = t * t
x = mt2 * p0[0] + 2 * mt * t * p1[0] + t2 * p2[0]
y = mt2 * p0[1] + 2 * mt * t * p1[1] + t2 * p2[1]
return (x, y)
def point_in_polygon(point, polygon):
x, y = point
n = len(polygon)
inside = False
p1x, p1y = polygon[0]
for i in range(1, n + 1):
p2x, p2y = polygon[i % n]
if y > min(p1y, p2y):
if y <= max(p1y, p2y):
if x <= max(p1x, p2x):
if p1y != p2y:
xinters = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x
if p1x == p2x or x <= xinters:
inside = not inside
p1x, p1y = p2x, p2y
return inside
def perpendicular_distance(point, line_start, line_end):
x0, y0 = point
x1, y1 = line_start
x2, y2 = line_end
dx = x2 - x1
dy = y2 - y1
line_length_sq = dx * dx + dy * dy
if line_length_sq == 0:
return distance(point, line_start)
numerator = abs(dy * x0 - dx * y0 + x2 * y1 - y2 * x1)
return numerator / math.sqrt(line_length_sq)
def simplify_path_rdp(points, epsilon):
if len(points) < 3:
return points
max_dist = 0.0
max_index = 0
for i in range(1, len(points) - 1):
dist = perpendicular_distance(points[i], points[0], points[-1])
if dist > max_dist:
max_dist = dist
max_index = i
if max_dist > epsilon:
left = simplify_path_rdp(points[:max_index + 1], epsilon)
right = simplify_path_rdp(points[max_index:], epsilon)
return left[:-1] + right
else:
return [points[0], points[-1]]
def simplify_closed_path(points, epsilon):
if len(points) < 4:
return points
simplified = simplify_path_rdp(points, epsilon)
if simplified[0] != points[0]:
simplified.insert(0, points[0])
return simplified
def segments_intersect(p1, p2, p3, p4):
x1, y1 = p1
x2, y2 = p2
x3, y3 = p3
x4, y4 = p4
denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
if abs(denom) < 1e-10:
return False, None
t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom
u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom
if 0 < t < 1 and 0 < u < 1:
ix = x1 + t * (x2 - x1)
iy = y1 + t * (y2 - y1)
return True, (ix, iy)
return False, None
def remove_self_intersections(points, offset_distance, debug=False):
if len(points) < 4:
return points
centroid_x = sum(p[0] for p in points) / len(points)
centroid_y = sum(p[1] for p in points) / len(points)
max_iterations = 100
iteration = 0
while iteration < max_iterations:
iteration += 1
found_intersection = False
n = len(points)
for i in range(n):
if found_intersection:
break
p1 = points[i]
p2 = points[(i + 1) % n]
for j in range(i + 2, n):
if j == (i + n - 1) % n:
continue
p3 = points[j]
p4 = points[(j + 1) % n]
intersects, int_point = segments_intersect(p1, p2, p3, p4)
if intersects:
if debug:
print(f" Found intersection between segments {i}-{i+1} and {j}-{j+1}")
print(f" Intersection point: {int_point}")
path_a = points[:i+1] + [int_point] + points[j+1:]
path_b = [int_point] + points[i+1:j+1]
def signed_area(pts):
if len(pts) < 3:
return 0.0
area = 0.0
for k in range(len(pts)):
x1, y1 = pts[k]
x2, y2 = pts[(k + 1) % len(pts)]
area += (x2 - x1) * (y2 + y1)
return area / 2.0
area_a = abs(signed_area(path_a))
area_b = abs(signed_area(path_b))
if debug:
print(f" Path A area (without loop): {area_a:.3f}")
print(f" Path B area (loop only): {area_b:.3f}")
if area_a >= area_b:
points = path_a
if debug:
print(f" Keeping path A (larger/outer envelope)")
else:
points = path_b
if debug:
print(f" Keeping path B (the loop was larger)")
found_intersection = True
break
if not found_intersection:
break
if debug and iteration > 1:
print(f" Removed self-intersections in {iteration} iterations")
if len(points) < 3:
if debug:
print(f" WARNING: Ended with too few points ({len(points)}), this may indicate a problem")
return points
def calculate_polygon_winding(points):
signed_area = 0.0
n = len(points)
for i in range(n):
x1, y1 = points[i]
x2, y2 = points[(i + 1) % n]
signed_area += (x2 - x1) * (y2 + y1)
return -1 if signed_area > 0 else 1
def calculate_perpendicular_offset(point, prev_point, next_point, offset_distance, polygon_winding, debug=False):
e1x = point[0] - prev_point[0]
e1y = point[1] - prev_point[1]
e2x = next_point[0] - point[0]
e2y = next_point[1] - point[1]
if debug:
print(f" Edge vectors: e1=({e1x:.3f}, {e1y:.3f}), e2=({e2x:.3f}, {e2y:.3f})")
len1 = math.sqrt(e1x * e1x + e1y * e1y)
len2 = math.sqrt(e2x * e2x + e2y * e2y)
if len1 > 0:
e1x /= len1
e1y /= len1
if len2 > 0:
e2x /= len2
e2y /= len2
if debug:
print(f" Normalized edges: e1=({e1x:.3f}, {e1y:.3f}), e2=({e2x:.3f}, {e2y:.3f})")
tangent_x = e1x + e2x
tangent_y = e1y + e2y
tangent_len = math.sqrt(tangent_x * tangent_x + tangent_y * tangent_y)
if tangent_len > 0:
tangent_x /= tangent_len
tangent_y /= tangent_len
if debug:
print(f" Average tangent: ({tangent_x:.3f}, {tangent_y:.3f})")
normal_x = tangent_y
normal_y = -tangent_x
if debug:
print(f" Normal (perpendicular to tangent, 90° CW): ({normal_x:.3f}, {normal_y:.3f})")
offset_dir_x = normal_x
offset_dir_y = normal_y
if debug:
print(f" Offset direction (before flip): ({offset_dir_x:.3f}, {offset_dir_y:.3f})")
print(f" Global polygon winding: {'CCW' if polygon_winding > 0 else 'CW'}")
should_flip = False
if offset_distance > 0 and polygon_winding < 0:
should_flip = True
elif offset_distance < 0 and polygon_winding > 0:
should_flip = True
if should_flip:
offset_dir_x = -offset_dir_x
offset_dir_y = -offset_dir_y
if debug:
if offset_distance < 0:
print(f" Flipped for inward offset on CCW polygon: ({offset_dir_x:.3f}, {offset_dir_y:.3f})")
else:
print(f" Flipped for outward offset on CW polygon: ({offset_dir_x:.3f}, {offset_dir_y:.3f})")
dot_product = e1x * e2x + e1y * e2y
dot_product = max(-1.0, min(1.0, dot_product))
direction_angle = math.acos(dot_product)
turn_angle = math.pi - direction_angle
if debug:
print(f" Direction angle: {math.degrees(direction_angle):.1f}°, Turn angle: {math.degrees(turn_angle):.1f}°")
if turn_angle > math.pi * 0.9:
miter_limit = 1.0
else:
half_turn = turn_angle / 2
if abs(math.sin(half_turn)) > 0.01:
miter_limit = 1.0 / math.sin(half_turn)
miter_limit = min(miter_limit, 2.0)
else:
miter_limit = 2.0
if debug:
print(f" Miter limit: {miter_limit:.3f}")
adjusted_distance = abs(offset_distance) * miter_limit
offset_x = point[0] + offset_dir_x * adjusted_distance
offset_y = point[1] + offset_dir_y * adjusted_distance
if debug:
print(f" Adjusted distance: {adjusted_distance:.3f}")
print(f" Final offset point: ({offset_x:.3f}, {offset_y:.3f})")
return (offset_x, offset_y)
def offset_path(subpath, offset_distance, precision=0.05, debug=False):
try:
try:
from inkex import Path
if isinstance(subpath, Path):
subpath = list(subpath)
except (ImportError, NameError):
pass
points = subpath_to_points(subpath, precision)
if len(points) < 3:
if debug:
print(f"ERROR: Not enough points ({len(points)}) after approximation")
return None
if len(points) > 1:
first = points[0]
last = points[-1]
dist = math.sqrt((last[0] - first[0])**2 + (last[1] - first[1])**2)
if dist < 0.001:
points = points[:-1]
if debug:
print(f"Removed duplicate closing point (distance: {dist:.6f})")
original_count = len(points)
points = simplify_closed_path(points, epsilon=precision * 2)
if debug:
print(f"Pre-simplified from {original_count} to {len(points)} points")
if len(points) < 3:
if debug:
print(f"ERROR: Not enough points ({len(points)}) after pre-simplification")
return None
polygon_winding = calculate_polygon_winding(points)
if debug:
print(f"\n=== OFFSET DEBUG ===")
print(f"Original polygon ({len(points)} points after pre-simplification):")
print(f" First 10 points: {points[:10]}")
print(f" Last 5 points: {points[-5:]}")
print(f"Offset distance: {offset_distance}")
print(f"Precision: {precision}")
print(f"Global polygon winding: {'CCW' if polygon_winding > 0 else 'CW'}")
print(f"\nProcessing first 5 points to show edge vectors:")
offset_points = []
n = len(points)
for i, point in enumerate(points):
prev_point = points[(i - 1) % n]
next_point = points[(i + 1) % n]
offset_point = calculate_perpendicular_offset(point, prev_point, next_point, offset_distance, polygon_winding, debug=(debug and i < 5))
if debug and i < 5:
e1_dx = point[0] - prev_point[0]
e1_dy = point[1] - prev_point[1]
e2_dx = next_point[0] - point[0]
e2_dy = next_point[1] - point[1]
print(f"\n Point [{i}]: {point}")
print(f" Prev point: {prev_point}")
print(f" Next point: {next_point}")
print(f" Edge 1 vector (from prev): ({e1_dx:.3f}, {e1_dy:.3f})")
print(f" Edge 2 vector (to next): ({e2_dx:.3f}, {e2_dy:.3f})")
print(f" Offset point: {offset_point}")
print(f" Movement: ({offset_point[0] - point[0]:.3f}, {offset_point[1] - point[1]:.3f})")
if offset_point:
offset_points.append(offset_point)
if debug:
print(f"\nResult:")
print(f" Offset points generated: {len(offset_points)}")
print(f" First 5 offset points: {offset_points[:5]}")
print(f" Last 5 offset points: {offset_points[-5:]}")
if len(offset_points) < 3:
if debug:
print(f"ERROR: Not enough offset points ({len(offset_points)})")
return None
if debug:
print(f"\nRemoving self-intersections...")
cleaned_points = remove_self_intersections(offset_points, offset_distance, debug=debug)
if debug:
print(f" Points after cleaning: {len(cleaned_points)}")
if len(cleaned_points) != len(offset_points):
print(f" Removed {len(offset_points) - len(cleaned_points)} points from self-intersecting loops")
simplified_points = simplify_closed_path(cleaned_points, epsilon=precision)
if debug:
print(f"\nSimplification:")
print(f" Cleaned offset points: {len(cleaned_points)}")
print(f" Simplified points: {len(simplified_points)}")
print(f" Reduction: {len(cleaned_points) - len(simplified_points)} points ({100 * (1 - len(simplified_points) / len(cleaned_points)):.1f}%)")
offset_points = simplified_points
path_str = f"M {offset_points[0][0]},{offset_points[0][1]}"
for point in offset_points[1:]:
path_str += f" L {point[0]},{point[1]}"
path_str += " Z"
if debug:
print(f" Path string (first 100 chars): {path_str[:100]}...")
print(f"=== END DEBUG ===\n")
try:
from inkex import Path as InkexPath
return InkexPath(path_str)
except ImportError:
result = [('M', offset_points[0])]
for point in offset_points[1:]:
result.append(('L', point))
result.append(('Z', None))
return result
except Exception as e:
print(f"Offset failed: {e}")
import traceback
traceback.print_exc()
return None
def offset_lpe(element, offset_distance, unit="mm"):
lpe_str = (
f"offset,"
f"0,1,"
f"offset:{offset_distance},"
f"linejoin_type:miter,"
f"miter_limit:4,"
f"attempt_force_join:false,"
f"update_on_knot_move:true;"
)
existing_lpe = element.get('inkscape:path-effect', '')
if existing_lpe:
new_lpe = existing_lpe + lpe_str
else:
new_lpe = lpe_str
element.set('inkscape:path-effect', new_lpe)
if not element.get('inkscape:original-d'):
element.set('inkscape:original-d', element.get('d'))
return element
def boolean_lpe(svg, element, operand_elements, operation="union"):
if not operand_elements:
return element
defs = svg.defs
if defs is None:
from lxml import etree
defs = etree.SubElement(svg.getroot(), 'defs')
hidder_filter_id = "selectable_hidder_filter"
hidder_filter = svg.getElementById(hidder_filter_id)
if hidder_filter is None:
from lxml import etree
nsmap = {'inkscape': 'http://www.inkscape.org/namespaces/inkscape'}
hidder_filter = etree.SubElement(defs, 'filter', {
'id': hidder_filter_id,
'width': '1',
'height': '1',
'x': '0',
'y': '0',
'style': 'color-interpolation-filters:sRGB;',
'{http://www.inkscape.org/namespaces/inkscape}label': 'LPE boolean visibility'
})
fe_composite = etree.SubElement(hidder_filter, 'feComposite', {
'id': 'boolops_hidder_primitive',
'result': 'composite1',
'operator': 'arithmetic',
'in2': 'SourceGraphic',
'in': 'BackgroundImage'
})
lpe_refs = []
for operand in operand_elements:
operand_id = operand.get('id')
if not operand_id:
continue
lpe_id = svg.get_unique_id('path-effect')
from lxml import etree
path_effect = etree.SubElement(defs, '{http://www.inkscape.org/namespaces/inkscape}path-effect', {
'effect': 'bool_op',
'operand-path': f'#{operand_id}',
'id': lpe_id,
'is_visible': 'true',
'lpeversion': '1',
'operation': operation,
'swap-operands': 'false',
'filltype-this': 'from-curve',
'filter': '',
'filltype-operand': 'from-curve'
})
lpe_refs.append(f'#{lpe_id}')
existing_style = operand.get('style', '')
if 'filter:' not in existing_style:
if existing_style and not existing_style.endswith(';'):
existing_style += ';'
operand.set('style', existing_style + f'filter:url(#{hidder_filter_id})')
existing_lpe = element.get('{http://www.inkscape.org/namespaces/inkscape}path-effect', '')
if existing_lpe:
new_lpe = existing_lpe + ';' + ';'.join(lpe_refs)
else:
new_lpe = ';'.join(lpe_refs)
element.set('{http://www.inkscape.org/namespaces/inkscape}path-effect', new_lpe)
return element