classes.c53_vector_logo

Vector logo class with configurable ray geometry.

  1"""Vector logo class with configurable ray geometry."""
  2
  3import math
  4from dataclasses import asdict, dataclass
  5from typing import Literal
  6
  7import drawBot
  8from lib import algebra, layout, fonts
  9
 10
 11@dataclass
 12class KVectorLogoParams:
 13    """Typed parameter preset for ``KVectorLogo`` construction."""
 14
 15    rays: int = 11
 16    rayWidth: float = 1.0
 17    rayWidthFalloff: float = 1.1
 18    spacingCompensation: float = 1.9
 19    yScale: float = 1.15
 20    silhouetteEdgeRatio: float = 0.4
 21    silhouetteExponent: float = 2.0
 22    tipRatio: float = 1.0
 23    terminalShape: Literal["spear", "triangle"] = "spear"
 24    spearBluntness: float = 1.0
 25    spearHandleAngle: float = 80.0
 26    spearBodySmoothness: float = 0.3
 27
 28
 29DEFAULT_VECTOR_LOGO_SIZE_PRESETS: dict[int, KVectorLogoParams] = {
 30    10: KVectorLogoParams(
 31        rays=5,
 32        rayWidthFalloff=0.5,
 33        yScale=1.3,
 34        silhouetteEdgeRatio=0.5,
 35        spearHandleAngle=40,
 36    ),
 37    160: KVectorLogoParams(rays=19),
 38}
 39
 40
 41class KVectorLogo:
 42    """Ray-based vector logo renderer.
 43
 44    Renders a sunburst of tapered rays fitted inside an arbitrary bounding box.
 45    All geometric parameters are fixed at construction time; ``draw()`` can be
 46    called repeatedly with different coordinates to place the logo at any size.
 47
 48    Example:
 49    ```python
 50    logo = KVectorLogo(rays=11)
 51    logo.draw(coordinates=(100, 100, 400, 400))
 52    logo.draw(coordinates=(600, 600, 80, 80))
 53    ```
 54    """
 55
 56    def __init__(
 57        self,
 58        rays: int = 11,
 59        rayWidth: float = 1.0,
 60        rayWidthFalloff: float = 1.1,
 61        spacingCompensation: float = 1.9,
 62        yScale: float = 1.15,
 63        silhouetteEdgeRatio: float = 0.4,
 64        silhouetteExponent: float = 2.0,
 65        tipRatio: float = 1.0,
 66        terminalShape: Literal["spear", "triangle"] = "spear",
 67        spearBluntness: float = 1.0,
 68        spearHandleAngle: float = 80.0,
 69        spearBodySmoothness: float = 0.3,
 70    ):
 71        """Configure ray geometry.
 72
 73        Args:
 74            rays: Number of rays. Must be >= 3. Always coerced to the nearest odd integer.
 75            rayWidth: Ray width multiplier relative to the slot width.
 76            rayWidthFalloff: Exponent controlling how ray width tapers toward
 77                shorter rays near the silhouette edges. Values > 1 increase taper.
 78            spacingCompensation: How much wider rays shift outward to even spacing.
 79                0 = uniform, 1 = fully compensated.
 80            yScale: Vertical stretch of the bounding ellipse. Values > 1 make the
 81                silhouette taller than wide.
 82            silhouetteEdgeRatio: Fraction of the horizontal radius where the
 83                silhouette starts curving in. Lower values widen the flat region.
 84            silhouetteExponent: Exponent for the silhouette superellipse curve.
 85            tipRatio: Length of the pointed terminal relative to the ray half-height.
 86                0 = no taper (rectangular end), 1 = full taper to the ellipse edge.
 87            terminalShape: ``"spear"`` for smooth bezier terminals or ``"triangle"``
 88                for hard-edged triangular terminals.
 89            spearBluntness: Spear only. 0 = sharp apex, 1 = round/blunt apex.
 90            spearHandleAngle: Spear only. Degrees from horizontal for the control
 91                handle that steers the tip curvature. Clamped to [5, 85].
 92            spearBodySmoothness: Spear only. How smoothly the tip curve settles
 93                into the straight body. 0 = angular join, 1 = smooth vertical join.
 94        """
 95        if rays < 2:
 96            raise ValueError(f"rays must be >= 3, got {rays}")
 97        rays = algebra.makeOdd(rays)
 98        if yScale <= 0:
 99            raise ValueError(f"yScale must be > 0, got {yScale}")
100        if silhouetteExponent <= 0:
101            raise ValueError(
102                f"silhouetteExponent must be > 0, got {silhouetteExponent}"
103            )
104        if not (0.0 <= tipRatio <= 1.0):
105            raise ValueError(f"tipRatio must be in [0, 1], got {tipRatio}")
106        if not (0.0 <= spearBluntness <= 1.0):
107            raise ValueError(f"spearBluntness must be in [0, 1], got {spearBluntness}")
108        if not (0.0 <= spearBodySmoothness <= 1.0):
109            raise ValueError(
110                f"spearBodySmoothness must be in [0, 1], got {spearBodySmoothness}"
111            )
112        if not (5.0 <= spearHandleAngle <= 85.0):
113            raise ValueError(
114                f"spearHandleAngle must be in [5, 85] degrees, got {spearHandleAngle}"
115            )
116
117        _shape = str(terminalShape).strip().lower()
118        if _shape not in {"spear", "triangle", "tri", "hard", "linear"}:
119            raise ValueError(
120                f"terminalShape must be 'spear' or 'triangle', got {terminalShape!r}"
121            )
122
123        self.rays = rays
124        self.rayWidth = rayWidth
125        self.rayWidthFalloff = rayWidthFalloff
126        self.spacingCompensation = spacingCompensation
127        self.yScale = yScale
128        self.silhouetteEdgeRatio = silhouetteEdgeRatio
129        self.silhouetteExponent = silhouetteExponent
130        self.tipRatio = tipRatio
131        self.terminalShape = _shape
132        self.spearBluntness = spearBluntness
133        self.spearHandleAngle = spearHandleAngle
134        self.spearBodySmoothness = spearBodySmoothness
135
136    @classmethod
137    def createForSize(
138        cls,
139        size: layout.UnitSource,
140        presets: dict[int, KVectorLogoParams | dict] | None = None,
141        overrides: dict | None = None,
142    ) -> "KVectorLogo":
143        """Create a logo by interpolating constructor params for ``size``.
144
145        Rules:
146        - Presets are keyed by point size.
147        - Values outside preset range clamp to nearest edge preset.
148        - Float fields interpolate linearly.
149        - ``rays`` interpolates linearly and rounds to nearest integer.
150        - ``terminalShape`` uses midpoint switch (lower for t < 0.5).
151        """
152        preset_map = presets or DEFAULT_VECTOR_LOGO_SIZE_PRESETS
153        interp = cls._interpolateParamsForSize(
154            sizePt=layout.parseUnit(size, implicitUnit="pt"), presets=preset_map
155        )
156        kwargs = asdict(interp)
157
158        if overrides:
159            kwargs.update(overrides)
160
161        return cls(**kwargs)
162
163    @staticmethod
164    def _coerceParams(value: KVectorLogoParams | dict) -> KVectorLogoParams:
165        """Normalize preset entries to ``KVectorLogoParams`` instances."""
166        if isinstance(value, KVectorLogoParams):
167            return value
168        if isinstance(value, dict):
169            return KVectorLogoParams(**value)
170        raise TypeError(
171            "Preset values must be KVectorLogoParams or dict, "
172            f"got {type(value).__name__}"
173        )
174
175    @classmethod
176    def _interpolateParamsForSize(
177        cls,
178        sizePt: float,
179        presets: dict[int, KVectorLogoParams | dict],
180    ) -> KVectorLogoParams:
181        """Interpolate preset parameters for a requested point size."""
182        if not presets:
183            raise ValueError("presets must contain at least one size preset")
184
185        normalized = {float(k): cls._coerceParams(v) for k, v in presets.items()}
186        keys = sorted(normalized.keys())
187        min_key = keys[0]
188        max_key = keys[-1]
189
190        if sizePt <= min_key:
191            return normalized[min_key]
192        if sizePt >= max_key:
193            return normalized[max_key]
194
195        lower_key = max(k for k in keys if k <= sizePt)
196        upper_key = min(k for k in keys if k >= sizePt)
197        if lower_key == upper_key:
198            return normalized[lower_key]
199
200        lower = normalized[lower_key]
201        upper = normalized[upper_key]
202        t = (sizePt - lower_key) / (upper_key - lower_key)
203
204        def lerp(a: float, b: float) -> float:
205            return a + (b - a) * t
206
207        terminal_shape = lower.terminalShape if t < 0.5 else upper.terminalShape
208
209        return KVectorLogoParams(
210            rays=algebra.makeOdd(lerp(lower.rays, upper.rays)),
211            rayWidth=lerp(lower.rayWidth, upper.rayWidth),
212            rayWidthFalloff=lerp(lower.rayWidthFalloff, upper.rayWidthFalloff),
213            spacingCompensation=lerp(
214                lower.spacingCompensation,
215                upper.spacingCompensation,
216            ),
217            yScale=lerp(lower.yScale, upper.yScale),
218            silhouetteEdgeRatio=lerp(
219                lower.silhouetteEdgeRatio,
220                upper.silhouetteEdgeRatio,
221            ),
222            silhouetteExponent=lerp(
223                lower.silhouetteExponent,
224                upper.silhouetteExponent,
225            ),
226            tipRatio=lerp(lower.tipRatio, upper.tipRatio),
227            terminalShape=terminal_shape,
228            spearBluntness=lerp(lower.spearBluntness, upper.spearBluntness),
229            spearHandleAngle=lerp(lower.spearHandleAngle, upper.spearHandleAngle),
230            spearBodySmoothness=lerp(
231                lower.spearBodySmoothness,
232                upper.spearBodySmoothness,
233            ),
234        )
235
236    def draw(
237        self,
238        coordinates: layout.Coordinates,
239        debug: bool = False,
240    ) -> "KVectorLogo":
241        """Render the logo inside a bounding box.
242
243        The effective radius is derived from the box dimensions and ``yScale``
244        so the logo always fits within the provided rectangle.
245
246        Args:
247            coordinates: Bounding box as ``(x, y, width, height)``.
248            debug: If ``True``, draws an xray overlay of the bounding box.
249
250        Returns:
251            Self, for method chaining.
252        """
253        bx, by, bw, bh = coordinates
254
255        if bw <= 0 or bh <= 0:
256            raise ValueError(
257                f"Bounding box dimensions must be > 0, got width={bw}, height={bh}"
258            )
259
260        if debug:
261            layout.xray([(bx, by, bw, bh)])
262            with fonts.resetFont(fontSize=6):
263                _, fh = drawBot.textSize("X")
264                drawBot.text(f"{bw:.0f}×{bh:.0f}pt", (bx + bw, by + bh - fh))
265
266        # Derive radius so the ellipse fits snugly inside the bounding box.
267        R = min(bw / 2, bh / (2 * self.yScale))
268        cx_center = bx + bw / 2
269        cy_center = by + bh / 2
270
271        Rx = R
272        Ry = R * self.yScale
273        x_start = Rx * (1 - self.silhouetteEdgeRatio**self.silhouetteExponent) ** (
274            1 / self.silhouetteExponent
275        )
276        slot_w = 2 * Rx / self.rays
277
278        def _get_hw(t: float) -> float:
279            """Half-width of a ray at parametric position t."""
280            x_sample = -x_start + 2 * x_start * t
281            rel = min(abs(x_sample / Rx), 1.0)
282            hh = Ry * (1 - rel**self.silhouetteExponent) ** (
283                1 / self.silhouetteExponent
284            )
285            height_ratio = hh / Ry
286            return (slot_w * self.rayWidth) / 2 * (height_ratio**self.rayWidthFalloff)
287
288        # Pre-compute spacing compensation references from the outermost rays.
289        edge_hw = _get_hw(0.0)
290        second_extra_hw = _get_hw(1.0 / (self.rays - 1)) - edge_hw
291
292        use_triangle = self.terminalShape in {"triangle", "tri", "hard", "linear"}
293        angle = math.radians(self.spearHandleAngle)
294        blunt = self.spearBluntness
295        shoulder_base = self.spearBodySmoothness
296
297        for i in range(self.rays):
298            t = i / (self.rays - 1)
299            x_canvas = -Rx + 2 * Rx * t
300            x_sample = -x_start + 2 * x_start * t
301
302            rel = min(abs(x_sample / Rx), 1.0)
303            hh = Ry * (1 - rel**self.silhouetteExponent) ** (
304                1 / self.silhouetteExponent
305            )
306
307            if 2 * hh < 1:
308                continue
309
310            height_ratio = hh / Ry
311            hw = (slot_w * self.rayWidth) / 2 * (height_ratio**self.rayWidthFalloff)
312
313            dist_from_center = abs(2 * t - 1)
314            extra_hw = hw - edge_hw
315
316            # Outermost rays use a reduced shift to prevent over-correction.
317            if i == 0 or i == self.rays - 1:
318                shift_hw = second_extra_hw * 0.2
319            else:
320                shift_hw = extra_hw * dist_from_center
321
322            sign = math.copysign(1, x_canvas) if x_canvas != 0 else 0
323            x_canvas += sign * shift_hw * self.spacingCompensation
324
325            tip = max(0.0, hh) * min(max(self.tipRatio, 0.0), 1.0)
326            cx = cx_center + x_canvas
327            cy = cy_center
328
329            path = drawBot.BezierPath()
330            path.moveTo((cx, cy + hh))
331
332            if use_triangle:
333                path.lineTo((cx + hw, cy + hh - tip))
334                path.lineTo((cx + hw, cy - hh + tip))
335                path.lineTo((cx, cy - hh))
336                path.lineTo((cx - hw, cy - hh + tip))
337                path.lineTo((cx - hw, cy + hh - tip))
338            else:
339                # Adapt shoulder smoothing: as body collapses, force smooth join.
340                body_ratio = max(0.0, hh - tip) / hh if hh > 0 else 0.0
341                shoulder = max(shoulder_base, 1.0 - body_ratio)
342
343                diag = math.hypot(hw, tip)
344                handle_len = diag * (0.10 + 0.45 * blunt)
345                p1x = min(hw * 0.95, handle_len * math.cos(angle))
346                p1y = min(tip * 0.95, handle_len * math.sin(angle))
347                p2x = hw * (2 / 3 + shoulder / 3)
348                p2y = tip * (0.30 + 0.50 * shoulder)
349
350                # top-right arc: top tip → top-right shoulder
351                path.curveTo(
352                    (cx + p1x, cy + hh - p1y),
353                    (cx + p2x, cy + hh - tip + p2y),
354                    (cx + hw, cy + hh - tip),
355                )
356                # right side (straight vertical)
357                path.lineTo((cx + hw, cy - hh + tip))
358                # bottom-right arc: bottom-right shoulder → bottom tip
359                path.curveTo(
360                    (cx + p2x, cy - hh + tip - p2y),
361                    (cx + p1x, cy - hh + p1y),
362                    (cx, cy - hh),
363                )
364                # bottom-left arc: bottom tip → bottom-left shoulder
365                path.curveTo(
366                    (cx - p1x, cy - hh + p1y),
367                    (cx - p2x, cy - hh + tip - p2y),
368                    (cx - hw, cy - hh + tip),
369                )
370                # left side (straight vertical)
371                path.lineTo((cx - hw, cy + hh - tip))
372                # top-left arc: top-left shoulder → top tip
373                path.curveTo(
374                    (cx - p2x, cy + hh - tip + p2y),
375                    (cx - p1x, cy + hh - p1y),
376                    (cx, cy + hh),
377                )
378
379            path.closePath()
380            drawBot.drawPath(path)
381
382        return self
@dataclass
class KVectorLogoParams:
12@dataclass
13class KVectorLogoParams:
14    """Typed parameter preset for ``KVectorLogo`` construction."""
15
16    rays: int = 11
17    rayWidth: float = 1.0
18    rayWidthFalloff: float = 1.1
19    spacingCompensation: float = 1.9
20    yScale: float = 1.15
21    silhouetteEdgeRatio: float = 0.4
22    silhouetteExponent: float = 2.0
23    tipRatio: float = 1.0
24    terminalShape: Literal["spear", "triangle"] = "spear"
25    spearBluntness: float = 1.0
26    spearHandleAngle: float = 80.0
27    spearBodySmoothness: float = 0.3

Typed parameter preset for KVectorLogo construction.

KVectorLogoParams( rays: int = 11, rayWidth: float = 1.0, rayWidthFalloff: float = 1.1, spacingCompensation: float = 1.9, yScale: float = 1.15, silhouetteEdgeRatio: float = 0.4, silhouetteExponent: float = 2.0, tipRatio: float = 1.0, terminalShape: Literal['spear', 'triangle'] = 'spear', spearBluntness: float = 1.0, spearHandleAngle: float = 80.0, spearBodySmoothness: float = 0.3)
rays: int = 11
rayWidth: float = 1.0
rayWidthFalloff: float = 1.1
spacingCompensation: float = 1.9
yScale: float = 1.15
silhouetteEdgeRatio: float = 0.4
silhouetteExponent: float = 2.0
tipRatio: float = 1.0
terminalShape: Literal['spear', 'triangle'] = 'spear'
spearBluntness: float = 1.0
spearHandleAngle: float = 80.0
spearBodySmoothness: float = 0.3
DEFAULT_VECTOR_LOGO_SIZE_PRESETS: dict[int, KVectorLogoParams] = {10: KVectorLogoParams(rays=5, rayWidth=1.0, rayWidthFalloff=0.5, spacingCompensation=1.9, yScale=1.3, silhouetteEdgeRatio=0.5, silhouetteExponent=2.0, tipRatio=1.0, terminalShape='spear', spearBluntness=1.0, spearHandleAngle=40, spearBodySmoothness=0.3), 160: KVectorLogoParams(rays=19, rayWidth=1.0, rayWidthFalloff=1.1, spacingCompensation=1.9, yScale=1.15, silhouetteEdgeRatio=0.4, silhouetteExponent=2.0, tipRatio=1.0, terminalShape='spear', spearBluntness=1.0, spearHandleAngle=80.0, spearBodySmoothness=0.3)}