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