Files
my-skills/ink-spot-illustration/scripts/draw_paper_plane.py
2026-11-05 01:04:53 +08:00

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()