Source code for bosl2.drawing

# Copyright (c) 2026, pinkfish
#
# Licensed under the BSD 2-Clause License. See the LICENSE file in the project
# root for the full license text.
# SPDX-License-Identifier: BSD-2-Clause

# LibFile: bosl2/drawing.py
#    Pure-Python port of BOSL2's drawing.scad: the path *generators*
#    (:func:`arc`, :func:`catenary`, :func:`helix`, :func:`turtle`) and the path
#    *renderers* (:func:`stroke`, :func:`dashed_stroke`). The generators return a
#    :class:`~bosl2.paths.Path` (2-D) or a plain list of 3-D points (``helix``); the
#    renderers turn a path into native geometry (``stroke``) or a list of dash
#    sub-paths (``dashed_stroke``).
#
#    ``arc`` itself lives in bosl2/shapes2d.py (it shares that module's $fn/$fa/$fs
#    and 3-point-circle helpers) and is re-exported here so the whole drawing API is
#    reachable as ``bosl2.drawing``. :func:`stroke`/:func:`dashed_stroke` are also
#    attached as methods on :class:`~bosl2.paths.Path` and
#    :class:`~bosl2.regions.Region`, so a built path can be drawn directly
#    (``path.stroke(width=2)``).
#
# FileSummary: Path generators (arc/catenary/helix/turtle) and renderers (stroke/dashed_stroke).
# FileGroup: BOSL2

from __future__ import annotations

import math
import operator
from collections.abc import Sequence
from dataclasses import dataclass
from functools import reduce

import numpy as np

from bosl2.geometry import general_line_intersection, line_normal
from bosl2.math import lerp, lerpn
from bosl2.paths import Path, Path3D
from bosl2.shapes2d import _frag_count, _pick_radius, arc

# The stroke body is built from the backend-neutral facade, NOT bosl2.shapes3d directly, so a
# 3-D stroke realizes on whichever backend is active: Bosl2Solids under the default csg backend,
# PyShapes under use_backend("sdf").
from bosl2.solid import cyl as _cyl  # type: ignore[attr-defined]
from bosl2.solid import sphere as _sphere  # type: ignore[attr-defined]
from bosl2.vectors import unit

__all__ = [
    "arc",
    "catenary",
    "helix",
    "turtle",
    "stroke",
    "dashed_stroke",
    "EndcapSpec",
]


# ---------------------------------------------------------------------------
# Section: 2-D helpers
# ---------------------------------------------------------------------------


def _rot2(deg: float, v: "Sequence[float] | np.ndarray") -> np.ndarray:
    """Rotate the 2-D vector *v* by *deg* degrees about the origin."""
    a = math.radians(deg)
    c, s = math.cos(a), math.sin(a)
    x, y = float(v[0]), float(v[1])
    return np.array([c * x - s * y, s * x + c * y])


def _rot_pts(deg: float, pts):
    """Rotate a list of 2-D points by *deg* degrees about the origin."""
    a = math.radians(deg)
    c, s = math.cos(a), math.sin(a)
    return [[c * p[0] - s * p[1], s * p[0] + c * p[1]] for p in pts]


# ---------------------------------------------------------------------------
# Section: Path generators
# ---------------------------------------------------------------------------


[docs] def catenary( width: float, droop: float | None = None, sides: int = 100, angle: float | None = None, ) -> Path: """The catenary (hanging-chain) curve of the given *width*, as a :class:`~bosl2.paths.Path`. Give exactly one of *droop* (how far the middle hangs below the endpoints) or *angle* (the slope in degrees at the endpoints). The curve passes through ``[-width/2, 0]`` and ``[width/2, 0]`` and hangs downward (negative *droop*/*angle* flips it upward). This is BOSL2's ``catenary()``. Args: width: horizontal distance between the endpoints (> 0) droop: how far the midpoint hangs below the endpoints (give this or *angle*) sides: number of points along the curve (default 100) angle: endpoint slope in degrees, ``0 < |angle| < 90`` (give this or *droop*) Examples: A hanging arch, stroked into a 2-mm ribbon and extruded into a wall: .. pythonscad-example:: catenary(width=80, droop=30).stroke(width=2).linear_extrude(height=6).show() """ assert (droop is None) != (angle is None), "catenary() needs exactly one of droop= or angle=" assert width > 0, "catenary() needs width > 0." assert isinstance(sides, int) and sides > 0, "catenary() needs a positive integer sides." given = droop if droop is not None else angle assert given is not None sgn = int(math.copysign(1, given)) droop_a = None if droop is None else abs(droop) angle_a = None if angle is None else abs(angle) assert angle_a is None or (0 < angle_a < 90), "catenary() angle must satisfy 0 < |angle| < 90." if droop_a is None: # solve for the scale that gives the requested endpoint slope assert angle_a is not None def slope_fn(x): p1 = math.cosh(x - 0.001) - 1 p2 = math.cosh(x + 0.001) - 1 return math.degrees(math.atan2(p2 - p1, 0.002)) target, f = angle_a, slope_fn else: # solve for the scale that gives the requested droop def droop_fn(x): return (math.cosh(x) - 1) / x if x != 0 else 0.0 target, f = droop_a / (width / 2), droop_fn # binary search on x for f(x) == target (f is monotonic increasing away from 0) x, inc = 0.0, 4.0 while inc >= 1e-9: if f(x + inc) > target: inc /= 2 else: x += inc scx = x sc = (width / 2) / scx droop_v = droop_a if droop_a is not None else (math.cosh(scx) - 1) * sc pts = [] for xv in lerpn(-scx, scx, sides): xval = xv * sc yval = 0.0 if abs(abs(xv) - scx) < 1e-9 else (math.cosh(xv) - 1) * sc - droop_v pts.append([xval, yval]) if sgn < 0: pts = [[p[0], -p[1]] for p in pts] return Path(pts, closed=False)
[docs] def helix( length: float | None = None, height: float | None = None, turns: float | None = None, angle: float | None = None, radius: float | None = None, radius1: float | None = None, radius2: float | None = None, diameter: float | None = None, diameter1: float | None = None, diameter2: float | None = None, ) -> Path3D: """A 3-D helical path on a (possibly conical) surface -- BOSL2's ``helix()``. Returned as a :class:`~bosl2.paths.Path3D` (the 3-D path object), so it carries the 3-D transforms/measurements and feeds straight into :func:`stroke` or ``path_sweep``. Give exactly two of *length*/*height* (length), *turns*, and *angle*; the third is derived. Positive *turns* is right-handed, negative left-handed. Start/end radii may differ for a conical helix (a flat spiral is ``height=0`` with a turn count). Args: length/height: height of the helix (0 for a flat spiral) turns: number of turns (positive = right-handed) angle: helix angle in degrees (measured at the base radius) radius/diameter: radius / diameter (constant helix) radius1/diameter1: bottom radius / diameter radius2/diameter2: top radius / diameter Examples: A 2.5-turn helix drawn as a tube: .. pythonscad-example:: stroke(helix(turns=2.5, height=100, radius=30), width=3).show() """ r1v = _pick_radius(radius1=radius1, diameter1=diameter1, radius=radius, diameter=diameter, dflt=1) r2v = _pick_radius(radius1=radius2, diameter1=diameter2, radius=radius, diameter=diameter, dflt=1) length = length if length is not None else height assert sum(v is not None for v in (length, turns, angle)) == 2, ( "helix() needs exactly two of length/height, turns, and angle." ) assert angle is None or length != 0, "helix() cannot take an angle with length 0." if angle is not None and length != 0: dz = 2 * math.pi * r1v * math.tan(math.radians(angle)) else: assert length is not None and turns is not None # else-branch only reached with both set dz = length / abs(turns) if turns is not None: maxtheta = 360.0 * turns else: assert length is not None maxtheta = 360.0 * length / dz nseg = _frag_count(max(r1v, r2v)) count = max(3, math.ceil(abs(maxtheta) * nseg / 360)) out = [] for theta in lerpn(0, maxtheta, count): radius = lerp(r1v, r2v, theta / maxtheta) if maxtheta != 0 else r1v out.append( [ radius * math.cos(math.radians(theta)), radius * math.sin(math.radians(theta)), abs(theta) / 360.0 * dz, ] ) return Path3D(out, closed=False)
# --- turtle ---------------------------------------------------------------- _TURTLE_TWO_ARG = ("arcleft", "arcright", "arcleftto", "arcrightto")
[docs] def turtle( commands: Sequence, state: Sequence | None = None, full_state: bool = False, repeat: int = 1, ) -> Path | list: """Build a 2-D path from [turtle-graphics](https://en.wikipedia.org/wiki/Turtle_graphics) *commands* -- BOSL2's ``turtle()``. *commands* is a flat list of command names each optionally followed by a parameter, e.g. ``["move", 10, "left", 90, "move", 10]``. The turtle starts at the origin pointing along +X with a step length of 1. By default the computed path is returned as a :class:`~bosl2.paths.Path`; set *full_state* to get ``[path, step_vector, angle, arcsteps]`` instead. *repeat* runs the whole command list that many times. Supported commands: ``move``/``xmove``/``ymove``/``xymove``, ``jump``/``xjump``/``yjump``, ``untilx``/``untily``, ``left``/``turn``/``right``, ``angle``, ``setdir``, ``length``/ ``scale``/``addlength``, ``arcsteps``, ``arcleft``/``arcright``, ``arcleftto``/``arcrightto``, and ``repeat`` (``["repeat", count, [subcommands]]``). Examples: A rounded-corner square drawn with arcs: .. pythonscad-example:: path = turtle(["move", 40, "arcleft", 8, "move", 40, "arcleft", 8, "move", 40, "arcleft", 8, "move", 40, "arcleft", 8]) path.stroke(width=3, closed=True).linear_extrude(height=4).show() """ if state is None: state = [[[0.0, 0.0]], [1.0, 0.0], 90.0, 0.0] else: state = list(state) result = _turtle_repeat(list(commands), state, True, repeat) return result if full_state else Path(result[0], closed=False)
def _turtle_repeat(commands, state, full_state, repeat): for _ in range(int(repeat)): state = _turtle(commands, state) return state if full_state else state[0] def _turtle_command_len(commands, index) -> int: if commands[index] == "repeat": return 3 if commands[index] in _TURTLE_TWO_ARG and len(commands) > index + 2 and not isinstance(commands[index + 2], str): return 3 if index + 1 < len(commands) and isinstance(commands[index + 1], str): return 1 if index + 1 >= len(commands): return 1 return 2 def _turtle(commands, state, index: int = 0): while index < len(commands): parm = commands[index + 1] if index + 1 < len(commands) else None parm2 = commands[index + 2] if index + 2 < len(commands) else None state = _turtle_command(commands[index], parm, parm2, state, index) index += _turtle_command_len(commands, index) return state def _turtle_command(command, parm, parm2, state, index): PATH, STEP, ANGLE, ARCS = 0, 1, 2, 3 if command == "repeat": assert isinstance(parm, (int, float)), f'"repeat" needs a count at index {index}' assert isinstance(parm2, (list, tuple)), f'"repeat" needs a command list at index {index}' return _turtle_repeat(list(parm2), state, True, int(parm)) parm = None if isinstance(parm, str) else parm parm2 = None if isinstance(parm2, str) else parm2 lastpt = np.asarray(state[PATH][-1], dtype=float) step = np.asarray(state[STEP], dtype=float) def with_point(p): s = list(state) s[PATH] = state[PATH] + [[float(p[0]), float(p[1])]] return s def with_step(v): s = list(state) s[STEP] = [float(v[0]), float(v[1])] return s if command == "move": return with_point((parm if parm is not None else 1) * step + lastpt) if command == "xmove": return with_point((parm if parm is not None else 1) * np.linalg.norm(step) * np.array([1, 0]) + lastpt) if command == "ymove": return with_point((parm if parm is not None else 1) * np.linalg.norm(step) * np.array([0, 1]) + lastpt) if command == "xymove": return with_point(lastpt + np.asarray(parm, dtype=float)) if command == "jump": return with_point(parm) if command == "xjump": return with_point([parm, lastpt[1]]) if command == "yjump": return with_point([lastpt[0], parm]) if command == "untilx": res = general_line_intersection([lastpt, lastpt + step], [[parm, 0], [parm, 1]]) assert res is not None, f'"untilx" never reaches the goal at index {index}' return with_point(res[0]) if command == "untily": res = general_line_intersection([lastpt, lastpt + step], [[0, parm], [1, parm]]) assert res is not None, f'"untily" never reaches the goal at index {index}' return with_point(res[0]) if command in ("turn", "left"): return with_step(_rot2(parm if parm is not None else state[ANGLE], step)) if command == "right": return with_step(_rot2(-(parm if parm is not None else state[ANGLE]), step)) if command == "angle": s = list(state) s[ANGLE] = parm return s if command == "setdir": if isinstance(parm, (list, tuple, np.ndarray)): return with_step(np.linalg.norm(step) * unit([parm[0], parm[1]])) return with_step(np.linalg.norm(step) * np.array([math.cos(math.radians(parm)), math.sin(math.radians(parm))])) if command == "length": return with_step(parm * unit(step)) if command == "scale": return with_step(parm * step) if command == "addlength": return with_step(step + unit(step) * parm) if command == "arcsteps": s = list(state) s[ARCS] = parm return s if command in ("arcleft", "arcright", "arcleftto", "arcrightto"): return _turtle_arc(command, parm, parm2, state, index) raise AssertionError(f'Unknown turtle command "{command}" at index {index}') def _turtle_arc(command, parm, parm2, state, index): PATH, STEP, ANGLE, ARCS = 0, 1, 2, 3 assert isinstance(parm, (int, float)), f'"{command}" needs a numeric radius at index {index}' lastpt = np.asarray(state[PATH][-1], dtype=float) step = np.asarray(state[STEP], dtype=float) lrsign = 1 if command in ("arcleft", "arcleftto") else -1 steps = _frag_count(abs(parm)) if state[ARCS] == 0 else int(state[ARCS]) if command in ("arcleft", "arcright"): myangle = parm2 if parm2 is not None else state[ANGLE] radius = parm * (1 if myangle >= 0 else -1) center = lastpt + lrsign * radius * line_normal([0, 0], step) turn = math.copysign(1, parm) * lrsign * myangle rot_step = _rot2(lrsign * myangle, step) else: # arcleftto / arcrightto assert isinstance(parm2, (int, float)), f'"{command}" needs a numeric angle at index {index}' radius = parm center = lastpt + lrsign * radius * line_normal([0, 0], step) start_angle = math.degrees(math.atan2(step[1], step[0])) % 360 end_angle = parm2 % 360 if lrsign * end_angle < lrsign * start_angle: end_angle = end_angle + lrsign * 360 delta = -start_angle + end_angle turn = math.copysign(1, radius) * delta rot_step = _rot2(delta, step) if turn == 0 or radius == 0: arcpath = [] else: p_mid = _rot2(turn / 2, lastpt - center) + center p_end = _rot2(turn, lastpt - center) + center arcpath = list(arc(steps, points=[lastpt, p_mid, p_end]))[1:] # drop the shared first point s = list(state) s[PATH] = state[PATH] + [[float(p[0]), float(p[1])] for p in arcpath] s[STEP] = [float(rot_step[0]), float(rot_step[1])] return s # --------------------------------------------------------------------------- # Section: Path renderers # ---------------------------------------------------------------------------
[docs] @dataclass(frozen=True) class EndcapSpec: """The size multipliers for one stroke endcap/joint style (BOSL2 _shape_defaults()). The realised width/length/extent are these times the stroke's line width, then the whole shape is scaled by the line width again (as BOSL2 does). """ width_mult: float length_mult: float extent_mult: float
# Endcap/joint shape table, ported straight from BOSL2's _shape_defaults(). _ENDCAP_DEFAULTS = { None: EndcapSpec(1.0, 0.0, 0.0), False: EndcapSpec(1.0, 0.0, 0.0), True: EndcapSpec(1.0, 1.0, 0.0), "butt": EndcapSpec(1.0, 0.0, 0.0), "round": EndcapSpec(1.0, 1.0, 0.0), "chisel": EndcapSpec(1.0, 1.0, 0.0), "square": EndcapSpec(1.0, 1.0, 0.0), "block": EndcapSpec(2.0, 1.0, 0.0), "diamond": EndcapSpec(2.5, 1.0, 0.0), "dot": EndcapSpec(2.0, 1.0, 0.0), "x": EndcapSpec(2.5, 0.4, 0.0), "cross": EndcapSpec(3.0, 0.33, 0.0), "line": EndcapSpec(3.5, 0.22, 0.0), "arrow": EndcapSpec(3.5, 0.4, 0.5), "arrow2": EndcapSpec(3.5, 1.0, 0.14), "arrow3": EndcapSpec(3.5, 1.0, 0.0), "tail": EndcapSpec(3.5, 0.47, 0.5), "tail2": EndcapSpec(3.5, 0.28, 0.5), } def _endcap_polys(style, lw: float): """The local-frame polygon(s) for endcap/joint *style* -- BOSL2's ``_shape_path()``. Returns a list of point-lists (one per sub-polygon; ``x``/``cross`` yield four triangles), already scaled by the line width *lw*. The local frame has ``+Y`` pointing *outward* along the line, so the caller rotates ``+Y`` onto the path's end direction. An empty list == a flush end. """ assert style in _ENDCAP_DEFAULTS, f"stroke(): unknown endcap/joint style {style!r}" if style in (False, "butt", None): return [] spec = _ENDCAP_DEFAULTS[style] w = spec.width_mult length = spec.length_mult * spec.width_mult l2 = spec.extent_mult * spec.width_mult def circle_poly(rx, ry, n): return [[rx * math.cos(2 * math.pi * k / n), ry * math.sin(2 * math.pi * k / n)] for k in range(n)] if style in (True, "round"): polys = [circle_poly(w / 2, length / 2, max(8, _frag_count(w * lw / 2)))] elif style == "chisel": # circle(diameter=1, $fn=4) scaled [w, length] -> an axis-aligned diamond polys = [circle_poly(w / 2, length / 2, 4)] elif style == "diamond": # circle(diameter=w, $fn=4) polys = [circle_poly(w / 2, w / 2, 4)] elif style == "dot": # circle(diameter=w) polys = [circle_poly(w / 2, w / 2, max(8, _frag_count(w * lw)))] elif style in ("square", "block", "line"): polys = [ [ [-w / 2, -length / 2], [w / 2, -length / 2], [w / 2, length / 2], [-w / 2, length / 2], ] ] elif style == "x": tri = [ [(w + length / 2) / 2, (w - length / 2) / 2], [(w - length / 2) / 2, (w + length / 2) / 2], [0, length / 2], ] polys = [_rot_pts(a, tri) for a in (0, 90, 180, 270)] elif style == "cross": tri = [[length / 2, w / 2], [-length / 2, w / 2], [-length / 2, length / 2]] polys = [_rot_pts(a, tri) for a in (0, 90, 180, 270)] elif style == "arrow": polys = [ [ [0, 0], [w / 2, -l2], [w / 2, -l2 - length], [0, -length], [-w / 2, -l2 - length], [-w / 2, -l2], ] ] elif style == "arrow2": polys = [[[0, 0], [w / 2, -l2 - length], [0, -length], [-w / 2, -l2 - length]]] elif style == "arrow3": polys = [[[0, 0], [w / 2, -length], [-w / 2, -length]]] elif style == "tail": polys = [ [ [0, 0], [w / 2, l2], [w / 2, l2 - length], [0, -length], [-w / 2, l2 - length], [-w / 2, l2], ] ] elif style == "tail2": polys = [ [ [w / 2, 0], [w / 2, -length], [0, -length - l2], [-w / 2, -length], [-w / 2, 0], ] ] else: # pragma: no cover - table and branches are kept in sync raise AssertionError(f"stroke(): unhandled endcap style {style!r}") return [[[p[0] * lw, p[1] * lw] for p in poly] for poly in polys] def _endcap_trim(style, width: float) -> float: """How far to pull the line back under an arrow endcap so it doesn't poke through the tip.""" if style in ("arrow", "arrow3"): spec = _ENDCAP_DEFAULTS[style] return width * (spec.length_mult * spec.width_mult - 0.01) if style == "arrow2": spec = _ENDCAP_DEFAULTS[style] return width * (spec.length_mult * spec.width_mult * 3 / 4) return 0.0 def _place(poly, theta_deg: float, at): """Rotate a local polygon by *theta_deg* and translate it to point *at*.""" radius = math.radians(theta_deg) c, s = math.cos(radius), math.sin(radius) return [[c * p[0] - s * p[1] + at[0], s * p[0] + c * p[1] + at[1]] for p in poly] def _endcap_geometry_2d(style, at, outdir, width: float): """2-D geometry for endcap/joint *style* at *at*, with local +Y rotated onto *outdir*. Returns a :class:`~bosl2.shapes2d.Bosl2Shape2D`, matching what the rest of the 2-D stroke builds, so the pieces combine without leaking a raw native handle into the reduce().""" from pythonscad import polygon as _opolygon from bosl2.shapes2d import Bosl2Shape2D polys = _endcap_polys(style, width) if not polys: return None theta = math.degrees(math.atan2(outdir[1], outdir[0])) - 90.0 # BACK (+Y) -> outdir geos = [Bosl2Shape2D(_opolygon(_place(poly, theta, at))) for poly in polys] return reduce(operator.or_, geos) def _trim_ends(body, trim1: float, trim2: float): """Shorten the open *body* path at each end by trim1/trim2 (clamped within the end segment).""" body = [list(map(float, p)) for p in body] if len(body) >= 2 and trim1 > 0: a, b = np.asarray(body[0]), np.asarray(body[1]) seglen = float(np.linalg.norm(b - a)) or 1.0 body[0] = list(a + (b - a) / seglen * min(trim1, 0.99 * seglen)) if len(body) >= 2 and trim2 > 0: a, b = np.asarray(body[-1]), np.asarray(body[-2]) seglen = float(np.linalg.norm(b - a)) or 1.0 body[-1] = list(a + (b - a) / seglen * min(trim2, 0.99 * seglen)) return body def _stroke2d(pts, width, closed, endcap1, endcap2, joints): """A 2-D stroke, as a :class:`~bosl2.shapes2d.Bosl2Shape2D`. 2-D geometry only exists on the CSG backend, so this raises under ``use_backend("sdf")`` rather than quietly building a CSG shape that could not then be combined with the SDF solids around it. A 3-D path strokes on either backend -- see :func:`_stroke3d`. """ from bosl2._backend import current_backend from bosl2.exceptions import UnsupportedByBackend from bosl2.shapes2d import circle as _circle from bosl2.shapes2d import square as _square if current_backend() != "csg": raise UnsupportedByBackend( "stroke (2-D path)", current_backend(), hint="a 2-D stroke is 2-D geometry, which only the csg backend has. Stroke a Path3D " "for a solid tube on either backend, or draw the 2-D stroke under the default (csg) " "backend.", ) shapes = [] sides = len(pts) # Pull the body back under arrow endcaps; endcaps still sit at the original endpoints. body = list(pts) if not closed and sides >= 2: body = _trim_ends(body, _endcap_trim(endcap1, width), _endcap_trim(endcap2, width)) nb = len(body) for i in range(nb) if closed else range(nb - 1): a, b = body[i], body[(i + 1) % nb] dx, dy = b[0] - a[0], b[1] - a[1] length = math.hypot(dx, dy) if length < 1e-9: continue angle = math.degrees(math.atan2(dy, dx)) mid = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2] shapes.append(_square([length, width]).rotate([0, 0, angle]).translate(mid)) # Joints: round/square fill the corner with a centred blob; other styles use the oriented shape. for i in range(nb) if closed else range(1, nb - 1): at = body[i] if joints in (True, "round", None): shapes.append(_circle(diameter=width).translate([at[0], at[1]])) elif joints == "square": shapes.append(_square([width, width]).translate([at[0], at[1]])) else: incoming = [body[i][0] - body[i - 1][0], body[i][1] - body[i - 1][1]] blob = _endcap_geometry_2d(joints, at, incoming, width) if blob is not None: shapes.append(blob) if not closed and sides >= 2: for cap, end, ref in ((endcap1, pts[0], pts[1]), (endcap2, pts[-1], pts[-2])): outdir = [end[0] - ref[0], end[1] - ref[1]] blob = _endcap_geometry_2d(cap, end, outdir, width) if blob is not None: shapes.append(blob) assert shapes, "stroke(): path has no drawable segments." return reduce(operator.or_, shapes) def _oriented_to(shape, outdir, at): """Rotate a Z-up solid so +Z points along 3-D *outdir*, then translate it to *at*. Uses ``rotate(angle, axis)`` rather than a 4x4 ``multmatrix`` so it works on either backend's solid -- an SDF PyShape rotates its field in closed form, but has no multmatrix. """ from bosl2.transforms import rot_from_to angle, axis = rot_from_to([0, 0, 1], outdir) return shape.rotate(float(angle), [float(c) for c in axis]).translate([float(c) for c in at]) def _endcap_geometry_3d(style, at, outdir, width: float): """3-D endcap for *style*: a sphere for round/dot, else the profile revolved to a solid. The sphere caps come from the backend-neutral facade, so they realize on whichever backend is active. The revolved caps (arrow/diamond/tail/...) are built by ``rotate_extrude()``, which only the csg backend has, so they raise :class:`~bosl2.exceptions.UnsupportedByBackend` under ``use_backend("sdf")``. """ from bosl2._backend import current_backend from bosl2.exceptions import UnsupportedByBackend if style in (False, "butt", None): return None if style in (True, "round"): return _sphere(radius=width / 2).translate([float(c) for c in at]) if style == "dot": return _sphere(radius=width).translate([float(c) for c in at]) polys = _endcap_polys(style, width) if not polys: return None if current_backend() != "csg": raise UnsupportedByBackend( f"stroke(endcap={style!r})", current_backend(), hint="the revolved endcaps need rotate_extrude(), which the sdf backend has no " "equivalent for. Use endcap='round'/'dot'/'butt' there, or stroke on the csg backend.", ) from pythonscad import polygon as _opolygon from pythonscad import rotate_extrude as _orotate_extrude from pythonscad import square as _osquare from bosl2.shapes3d import Bosl2Solid big = max(abs(v) for poly in polys for p in poly for v in p) * 4 + width right = _osquare([big, big], center=True).translate([big / 2, 0]) solids = [_orotate_extrude((_opolygon(poly) & right)) for poly in polys] return _oriented_to(Bosl2Solid(reduce(operator.or_, solids)), outdir, at) def _stroke3d(pts, width, closed, endcap1, endcap2): """A 3-D stroke -- a tube following *pts* -- on whichever backend is active. Every piece comes from the backend-neutral facade (:mod:`bosl2.solid`), so this yields Bosl2Solids under the default csg backend and PyShapes under ``use_backend("sdf")``. """ radius = width / 2 shapes = [] sides = len(pts) for i in range(sides) if closed else range(sides - 1): a = np.asarray(pts[i], dtype=float) b = np.asarray(pts[(i + 1) % sides], dtype=float) diameter = b - a length = float(np.linalg.norm(diameter)) if length < 1e-9: continue seg = _oriented_to( _cyl(height=length, radius=radius).translate([0, 0, length / 2]), diameter, a, ) shapes.append(seg) for i in range(sides) if closed else range(1, sides - 1): shapes.append(_sphere(radius=radius).translate([float(c) for c in pts[i]])) if not closed and sides >= 2: for cap, end, ref in ((endcap1, pts[0], pts[1]), (endcap2, pts[-1], pts[-2])): outdir = [end[j] - ref[j] for j in range(3)] blob = _endcap_geometry_3d(cap, end, outdir, width) if blob is not None: shapes.append(blob) assert shapes, "stroke(): path has no drawable segments." return reduce(operator.or_, shapes)
[docs] def stroke( path, width: float = 1, closed: bool | None = None, endcaps=None, endcap1=None, endcap2=None, joints=None, dots=False, color=None, ): """Render *path* as a solid line of the given *width* -- BOSL2's ``stroke()``. Works on a 2-D or 3-D point list, a :class:`~bosl2.paths.Path`, a :class:`~bosl2.paths.Path3D`, or a :class:`~bosl2.regions.Region` (each of its paths is stroked closed). A 2-D stroke is a union of segment rectangles with joints and endcaps; a 3-D stroke is a tube of cylinders with spherical joints and revolved endcaps. Returns native geometry. Every BOSL2 endcap/joint style is generated directly: ``"round"`` (default), ``"square"``, ``"butt"``/``False`` (flush), ``"dot"``, ``"block"``, ``"diamond"``, ``"chisel"``, ``"line"``, ``"x"``, ``"cross"``, ``"arrow"``, ``"arrow2"``, ``"arrow3"``, ``"tail"``, and ``"tail2"``. Arrow endcaps trim the line back so it doesn't poke through the tip. Args: path: a point list, :class:`~bosl2.paths.Path`/:class:`~bosl2.paths.Path3D`, or :class:`~bosl2.regions.Region` width: line width (default 1) closed: close the path into a loop (default: the path's own ``closed`` flag, or True for a Region) endcaps: style for both ends (``endcap1``/``endcap2`` override per end) joints: style for the interior corners (default ``"round"``) dots: mark every vertex with a round dot color: optional colour applied to the whole stroke Examples: An arc drawn as a 3-mm ribbon with round ends, extruded into a curved wall: .. pythonscad-example:: arc(radius=30, angle=200).stroke(width=3).linear_extrude(height=5).show() A line with a fancy endcap on each end (arrow one way, tail the other): .. pythonscad-example:: stroke([[0, 0], [50, 0]], width=3, endcap1='tail', endcap2='arrow') \ .linear_extrude(height=3).show() A 3-D arrow: the endcap is a revolved cone on the tube: .. pythonscad-example:: stroke([[0, 0, 0], [40, 0, 0]], width=4, endcaps='arrow').show() """ from bosl2.regions import Region if isinstance(path, Region) or ( isinstance(path, (list, tuple)) and len(path) and isinstance(path[0], (Path, Path3D)) and not isinstance(path, (Path, Path3D)) ): parts = [stroke(p, width=width, closed=True, joints=joints, dots=dots) for p in path] shape = reduce(operator.or_, parts) return shape.color(color) if color is not None else shape pts = [list(map(float, p)) for p in path] assert len(pts) >= 1, "stroke(): empty path." is_closed = closed if closed is not None else getattr(path, "closed", False) if dots: joints = "dot" endcaps = "dot" if endcaps is None else endcaps cap1 = endcap1 if endcap1 is not None else (endcaps if endcaps is not None else "round") cap2 = endcap2 if endcap2 is not None else (endcaps if endcaps is not None else "round") jnt = joints if joints is not None else "round" dim = len(pts[0]) if dim == 2: shape = _stroke2d(pts, width, is_closed, cap1, cap2, jnt) else: shape = _stroke3d(pts, width, is_closed, cap1, cap2) return shape.color(color) if color is not None else shape
[docs] def dashed_stroke( path, dashpat: Sequence[float] = (3, 3), closed: bool = False, fit: bool = True, mindash: float = 0.5, ) -> "list[Path | Path3D]": """Break *path* into dashes -- BOSL2's ``dashed_stroke()`` function form. Returns the list of "on" dash sub-paths (each a :class:`~bosl2.paths.Path`); stroke or extrude them to draw a dashed line. *dashpat* alternates dash/gap lengths. With *fit* (the default) the pattern is scaled slightly so a whole number of repeats fills the path exactly. Args: path: a point list, :class:`~bosl2.paths.Path`, or :class:`~bosl2.regions.Region` dashpat: alternating [dash, gap, ...] lengths (default ``(3, 3)``) closed: treat the path as a closed loop fit: scale the pattern to fit a whole number of repeats (default True) mindash: drop a trailing dash shorter than this (default 0.5) Examples: A dashed circle outline, the dashes unioned and extruded into little tiles: .. pythonscad-example:: dashes = dashed_stroke(arc(radius=30, angle=360), dashpat=[6, 4], closed=True) ring = reduce(lambda a, b: a | b, (d.stroke(width=1.5) for d in dashes)) ring.linear_extrude(height=3).show() """ from bosl2.regions import Region if isinstance(path, Region): out: list[Path] = [] for p in path: out.extend(dashed_stroke(p, dashpat, closed=True, fit=fit, mindash=mindash)) # type: ignore[arg-type] return out # type: ignore[return-value] raw = [list(map(float, p)) for p in path] # a 3-D path yields 3-D dashes (Path3D); a 2-D path yields Path wrap = Path3D if raw and len(raw[0]) == 3 else Path if closed: raw = raw + [raw[0]] dpat = list(dashpat) if len(dashpat) % 2 == 0 else list(dashpat) + [0] plen = Path._path_length(raw, closed=False) dlen = sum(dpat) doff = list(np.cumsum(dpat)) freps = plen / dlen reps = max(1, round(freps) if fit else math.floor(freps)) tlen = plen if not fit else reps * dlen + (0 if closed else dpat[0]) sc = plen / tlen cuts = [] for i in range(reps + 1): for off in doff: x = i * dlen * sc + off * sc if 0 < x < plen - 1e-9: cuts.append(x) cuts = sorted(c for c in cuts) if not cuts: return [wrap(raw, closed=False)] # type: ignore[return-value] dashes = Path._path_cut(raw, cuts, closed=False) dcnt = len(dashes) evens = [] for i, dash in enumerate(dashes): if i % 2 != 0: continue if i < dcnt - 1 or Path._path_length(dash, closed=False) > mindash: evens.append(wrap(dash, closed=False)) return evens # type: ignore[return-value]