130 lines
7.1 KiB
Python
130 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Draw a fixed, editable SVG study using only the Python standard library.
|
|
|
|
This is an authored composition, not a general text-to-image renderer.
|
|
Run: python3 draw_paper_plane.py --out path.svg --orange '#FF902A'
|
|
"""
|
|
import argparse
|
|
import math
|
|
import re
|
|
from html import escape
|
|
from pathlib import Path
|
|
|
|
|
|
def pressure_line(curves, width=3.0):
|
|
"""Expand cubic centerlines into a filled, gently tapered ink contour."""
|
|
samples = []
|
|
for curve in curves:
|
|
a, b, c, d = curve
|
|
for i in range(33):
|
|
if samples and i == 0:
|
|
continue
|
|
t = i / 32
|
|
u = 1 - t
|
|
x = u**3*a[0] + 3*u*u*t*b[0] + 3*u*t*t*c[0] + t**3*d[0]
|
|
y = u**3*a[1] + 3*u*u*t*b[1] + 3*u*t*t*c[1] + t**3*d[1]
|
|
dx = 3*u*u*(b[0]-a[0]) + 6*u*t*(c[0]-b[0]) + 3*t*t*(d[0]-c[0])
|
|
dy = 3*u*u*(b[1]-a[1]) + 6*u*t*(c[1]-b[1]) + 3*t*t*(d[1]-c[1])
|
|
length = math.hypot(dx, dy) or 1
|
|
samples.append((x, y, -dy/length, dx/length))
|
|
sides = [[], []]
|
|
for i, (x, y, nx, ny) in enumerate(samples):
|
|
t = i / max(1, len(samples)-1)
|
|
radius = width/2 * (0.44 + 0.59*math.sin(math.pi*t)**0.55)
|
|
for side, sign in zip(sides, (1, -1)):
|
|
side.append((x+sign*nx*radius, y+sign*ny*radius))
|
|
points = sides[0] + sides[1][::-1]
|
|
return 'M ' + ' L '.join(f'{x:.2f},{y:.2f}' for x, y in points) + ' Z'
|
|
|
|
|
|
def draw(orange, red):
|
|
black = '#080808'
|
|
pieces = []
|
|
|
|
def path(d, fill='none', width=2.8, stroke=black):
|
|
pieces.append(f'<path d="{d}" fill="{fill}" stroke="{stroke}" stroke-width="{width}" stroke-linejoin="round" stroke-linecap="round"/>')
|
|
|
|
def ink(curves, width=3):
|
|
path(pressure_line(curves, width), black, 0, 'none')
|
|
|
|
def dot(x, y, r, fill=black):
|
|
pieces.append(f'<circle cx="{x}" cy="{y}" r="{r}" fill="{fill}"/>')
|
|
|
|
pieces.append('<g id="worker" aria-label="Orange worker leaning forward">')
|
|
# Shoes and rear leg are behind the torso.
|
|
path('M 481,297 C 484,282 499,273 508,285 C 519,300 527,329 517,346 C 511,354 500,355 494,347 L 481,323 Z', black)
|
|
path('M 459,346 C 478,342 491,346 495,359 C 501,376 489,385 476,382 C 463,379 452,370 449,359 Z', black)
|
|
path('M 365,205 C 399,194 428,221 444,252 C 460,277 480,296 493,310 C 504,326 500,341 488,347 C 477,352 467,341 453,332 L 429,313 L 424,359 C 405,370 382,368 365,353 L 322,278 Z', orange)
|
|
# Far sleeve, torso seams, and the near sleeve overlap in that order.
|
|
path('M 345,215 C 326,229 320,260 309,292 L 283,357 C 278,368 288,378 302,379 C 316,380 320,369 325,357 L 355,298 L 376,248 Z', orange, 0)
|
|
path('M 345,215 C 326,229 320,260 309,292 L 283,357 C 278,368 288,378 302,379 C 316,380 320,369 325,357 L 355,298')
|
|
ink([((429,278),(418,289),(407,297),(395,301))], 3.1)
|
|
ink([((432,314),(439,318),(444,324),(447,331))], 2.4)
|
|
path('M 403,224 C 409,254 395,289 385,320 L 367,382 C 363,397 346,400 335,391 C 327,385 330,373 333,360 L 347,305 L 358,263', orange)
|
|
ink([((358,264),(357,278),(350,292),(347,305))], 3.5)
|
|
# Neck and a small white face under the helmet.
|
|
path('M 351,198 L 369,231 L 389,213 Z', black, 0)
|
|
path('M 315,151 C 320,177 330,198 350,205 C 368,212 390,205 401,185 L 397,145 Z', '#FFFFFF')
|
|
path('M 395,143 C 409,145 416,160 410,175 L 400,188 L 395,162 Z', black)
|
|
path('M 401,157 C 409,154 411,167 404,172', '#FFFFFF', 1.8)
|
|
dot(333,181,2.6)
|
|
dot(371,184,2.4)
|
|
ink([((350,195),(352,196),(353,196),(355,195))], 1.9)
|
|
# Hand-authored asymmetry in the helmet dome; no noise filter.
|
|
path('M 308,148 C 299,126 301,99 313,80 C 326,58 346,47 369,48 C 400,46 428,66 440,92 C 450,115 449,137 437,152 C 407,170 342,171 308,148 Z', '#FFFFFF', 3.0)
|
|
path('M 307,139 C 338,158 398,154 437,126 L 444,135 C 412,164 349,174 310,151 Z', '#FFFFFF', 2.6)
|
|
path('M 310,150 C 343,166 405,164 441,138 L 440,147 C 409,172 349,180 312,160 Z', '#FFFFFF', 2.4)
|
|
path('M 332,153 C 336,142 348,139 356,145 C 364,152 361,165 354,171 C 345,178 333,173 331,164 Z', black, 1)
|
|
dot(347,160,6.6,'#FFFFFF')
|
|
pieces.append('</g>')
|
|
|
|
pieces.append('<g id="paper-plane" aria-label="Oversized white folded paper airplane">')
|
|
# Solid undersides, rather than cast shadows or gray shaded faces.
|
|
path('M 99,612 L 286,566 L 472,423 L 472,432 C 424,474 350,533 289,574 L 101,619 Z', black, 0)
|
|
path('M 99,612 C 141,533 188,441 232,362 C 308,381 392,404 472,423 L 286,566 Z', '#FFFFFF', 3.5)
|
|
path('M 99,612 L 325,386 L 286,566 Z', '#FFFFFF', 2.5)
|
|
path('M 99,612 L 325,386 L 316,410 Z', black, 0)
|
|
ink([((99,612),(167,547),(255,458),(324,386))], 2.9)
|
|
pieces.append('</g>')
|
|
|
|
pieces.append('<g id="hands" aria-label="Two hands touching the rear edge">')
|
|
path('M 285,358 C 279,361 275,371 278,379 C 280,385 286,387 290,382 C 290,391 299,393 302,386 C 304,395 312,395 315,387 C 318,394 325,388 324,381 C 323,370 315,366 308,366 L 301,362', '#FFFFFF', 2.6)
|
|
path('M 337,383 C 330,387 327,397 332,405 C 335,411 341,411 344,405 C 342,416 352,418 356,410 C 355,419 365,420 369,411 C 371,416 379,410 376,402 C 374,394 367,391 361,391 L 357,388', '#FFFFFF', 2.6)
|
|
pieces.append('</g>')
|
|
|
|
pieces.append('<g id="tool-case" aria-label="Small vermilion tool case">')
|
|
path('M 432,520 C 426,522 424,534 426,546 L 427,557 C 431,569 464,586 486,590 C 501,592 548,568 552,560 L 553,537 C 548,523 518,511 499,507 C 482,502 452,512 432,520 Z', red, 0)
|
|
path('M 433,520 C 453,511 482,500 499,507 C 520,511 547,522 550,531 C 549,539 506,560 490,563 C 474,563 445,550 430,539', 'none', 3.0)
|
|
path('M 430,546 C 449,557 476,569 491,569 C 508,565 533,554 548,544 L 548,550 C 530,560 509,571 491,575 C 473,574 447,563 430,553 Z', black, 0)
|
|
dot(531,529,3.8)
|
|
pieces.append('</g>')
|
|
pieces.append('<g id="effort-marks">')
|
|
ink([((278,227),(276,238),(278,247),(280,250))], 3.6)
|
|
ink([((286,225),(286,235),(287,241),(289,244))], 2.8)
|
|
ink([((459,191),(463,200),(464,207),(464,213))], 3.2)
|
|
ink([((468,187),(473,197),(474,203),(474,209))], 2.8)
|
|
pieces.append('</g>')
|
|
return ('<svg xmlns="http://www.w3.org/2000/svg" width="640" height="720" viewBox="0 0 640 720" role="img" aria-labelledby="title desc">\n'
|
|
'<title id="title">A worker pushing a giant paper airplane</title>\n'
|
|
'<desc id="desc">Code-drawn black ink contours, orange coveralls and a vermilion case on white. No embedded raster images.</desc>\n'
|
|
'<rect width="640" height="720" fill="#FFFFFF"/>\n'
|
|
+ '\n'.join(pieces) + '\n</svg>\n')
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--out', type=Path, required=True)
|
|
parser.add_argument('--orange', default='#FF902A')
|
|
parser.add_argument('--red', default='#F04B32')
|
|
args = parser.parse_args()
|
|
for value in (args.orange, args.red):
|
|
if not re.fullmatch(r'#[0-9a-fA-F]{6}', value):
|
|
parser.error('Colors must be six-digit hexadecimal values, e.g. #FF902A')
|
|
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
args.out.write_text(draw(escape(args.orange), escape(args.red)), encoding='utf-8')
|
|
print(args.out.resolve())
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|