iso-math.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  1. #!/usr/bin/env python3
  2. # Isometric projection math CLI: constants, coordinate transforms, grids, recipes.
  3. #
  4. # Usage: iso-math.py <subcommand> [OPTIONS]
  5. # Input: argv only (no stdin). Numbers are floats; tile sizes are positive ints.
  6. # Output: stdout = data only. Plain text by default; JSON under --json
  7. # (envelope: {"data": ..., "meta": {"count", "schema"}}). grid-svg emits SVG.
  8. # Stderr: headers, warnings, errors, and the human line for --json errors.
  9. # Exit: 0 ok, 2 usage (bad/missing/unknown args), 4 validation (bad values), 10 unused.
  10. #
  11. # Subcommands:
  12. # constants [--projection true|dimetric21|pixel] [--json]
  13. # to-screen X Y [Z] --tile-w N --tile-h N [--elev-step N] [--json]
  14. # to-tile SX SY --tile-w N --tile-h N [--json]
  15. # grid-svg --projection P --tile-w N --extent N [--stroke COLOR] [--tile-h N]
  16. # transforms --target T [--projection P] [--json]
  17. #
  18. # Examples:
  19. # iso-math.py constants --projection true --json | jq '.data'
  20. # iso-math.py to-screen 3 5 --tile-w 64 --tile-h 32
  21. # iso-math.py to-tile -64 128 --tile-w 64 --tile-h 32 --json | jq '.data'
  22. # iso-math.py grid-svg --projection dimetric21 --tile-w 64 --extent 8 > grid.svg
  23. # iso-math.py transforms --target css-3d
  24. #
  25. # All figures match skills/isometric-ops references and the canonical constants table
  26. # to >= 4 decimal places. Derivations are computed from first principles here, not
  27. # hard-coded, so the numbers cannot drift from their mathematical definitions.
  28. #
  29. # Sources (see references/projection-math.md for full citations):
  30. # - Wikipedia, "Isometric projection" / "Isometric video game graphics"
  31. # https://en.wikipedia.org/wiki/Isometric_projection
  32. # - Pikuma, "Isometric Projection in Game Development"
  33. # https://pikuma.com/blog/isometric-projection-in-games
  34. # - yal.cc, "Understanding isometric grids" https://yal.cc/understanding-isometric-grids/
  35. # - obelisk.js README (pixel-neat 22.6 deg) https://github.com/nosir/obelisk.js
  36. """Isometric projection math: constants, transforms, grids, and CSS/SVG recipes.
  37. Pure stdlib. See the module comment above for the full CLI contract and examples.
  38. """
  39. from __future__ import annotations
  40. import argparse
  41. import json
  42. import math
  43. import sys
  44. from typing import Any
  45. SCHEMA_PREFIX = "claude-mods.isometric-ops.iso-math"
  46. # Exit codes (per docs/SKILL-RESOURCE-PROTOCOL.md section 5).
  47. EX_OK = 0
  48. EX_USAGE = 2
  49. EX_VALIDATION = 4
  50. # ---------------------------------------------------------------------------
  51. # Canonical geometry, derived from first principles (never hard-coded rounded).
  52. # ---------------------------------------------------------------------------
  53. # True isometric: a cube rotated +/-45 deg about vertical, then tilted about the
  54. # horizontal by the "magic angle" arctan(1/sqrt(2)) = arcsin(1/sqrt(3)) = 35.2644 deg.
  55. TILT_RAD = math.atan(1.0 / math.sqrt(2.0)) # 35.2643896... deg
  56. TILT_DEG = math.degrees(TILT_RAD)
  57. FORESHORTEN = math.cos(TILT_RAD) # sqrt(2/3) = 0.816497 (true projection)
  58. CSS_ROTATEX_DEG = math.degrees(math.atan(math.sqrt(2.0))) # 54.7356 deg = 90 - 35.2644
  59. CSS_SCALE = 1.0 / FORESHORTEN # sqrt(3/2) = 1.224745 (undo foreshorten)
  60. SSR_VERTICAL = math.cos(math.radians(30.0)) # 0.866025 = cos(30)
  61. FIGMA_HEIGHT = math.tan(math.radians(30.0)) # 0.577350 = tan(30)
  62. GROUND_AXIS_DEG = 30.0 # true-iso ground-axis angle
  63. AXIS_SEPARATION_DEG = 120.0
  64. # 2:1 dimetric ("game isometric"): axis angle arctan(1/2).
  65. DIMETRIC_AXIS_RAD = math.atan(0.5) # 26.5651 deg
  66. DIMETRIC_AXIS_DEG = math.degrees(DIMETRIC_AXIS_RAD)
  67. # obelisk.js pixel-neat: its README states a 1:2 pixel-dot arrangement -> 22.6 deg.
  68. # Geometrically arctan(1/2) = 26.565; the 22.6 figure is the library's own stated
  69. # pixel-stepping angle. See references/projection-math.md, contested-fact #1.
  70. OBELISK_STATED_DEG = 22.6
  71. def r(value: float, places: int = 5) -> float:
  72. """Round for display; keeps output stable and >= 4-decimal accurate."""
  73. return round(value, places)
  74. # ---------------------------------------------------------------------------
  75. # Output helpers (stream separation: stdout = data, stderr = everything else).
  76. # ---------------------------------------------------------------------------
  77. def warn(msg: str) -> None:
  78. print(msg, file=sys.stderr)
  79. def emit(data: Any, count: int, name: str, as_json: bool, plain: str) -> int:
  80. """Emit either the plain data product or the --json envelope, both to stdout."""
  81. if as_json:
  82. envelope = {
  83. "data": data,
  84. "meta": {"count": count, "schema": f"{SCHEMA_PREFIX}.{name}/v1"},
  85. }
  86. print(json.dumps(envelope, indent=2))
  87. else:
  88. print(plain)
  89. return EX_OK
  90. def fail(message: str, code: int, as_json: bool, err_code: str, details: Any = None) -> int:
  91. """Emit a structured error to stdout (when --json) plus a human line to stderr."""
  92. if as_json:
  93. print(json.dumps({"error": {"code": err_code, "message": message,
  94. "details": details or {}}}))
  95. warn(f"error: {message}")
  96. return code
  97. def positive_int(raw: str, name: str) -> int:
  98. val = int(raw)
  99. if val <= 0:
  100. raise ValueError(f"{name} must be a positive integer, got {val}")
  101. return val
  102. # ---------------------------------------------------------------------------
  103. # constants
  104. # ---------------------------------------------------------------------------
  105. def build_constants(projection: str) -> dict[str, Any]:
  106. true_iso = {
  107. "projection": "true",
  108. "label": "true isometric projection",
  109. "groundAxisAngleDeg": r(GROUND_AXIS_DEG),
  110. "axisSeparationDeg": r(AXIS_SEPARATION_DEG),
  111. "cubeTiltDeg": r(TILT_DEG),
  112. "foreshortenProjection": r(FORESHORTEN),
  113. "drawingScale": 1.0,
  114. "ssrVerticalScale": r(SSR_VERTICAL),
  115. "figmaHeightScale": r(FIGMA_HEIGHT),
  116. "topCircleMinorOverMajor": r(FIGMA_HEIGHT),
  117. "cssRotateXDeg": r(CSS_ROTATEX_DEG, 4),
  118. "cssRotateZDeg": -45.0,
  119. "cssScale3d": r(CSS_SCALE),
  120. "derivation": {
  121. "cubeTiltDeg": "arctan(1/sqrt(2)) = arcsin(1/sqrt(3))",
  122. "foreshortenProjection": "cos(35.264) = sqrt(2/3)",
  123. "ssrVerticalScale": "cos(30)",
  124. "figmaHeightScale": "tan(30)",
  125. "cssRotateXDeg": "arctan(sqrt(2)) = 90 - 35.264",
  126. "cssScale3d": "sqrt(3/2) = 1/cos(35.264), undoes foreshortening",
  127. },
  128. }
  129. dimetric = {
  130. "projection": "dimetric21",
  131. "label": "2:1 dimetric (commonly called isometric in games)",
  132. "groundAxisAngleDeg": r(DIMETRIC_AXIS_DEG),
  133. "axisSeparationsDeg": [116.565, 116.565, 126.870],
  134. "tileAspect": "2:1",
  135. "commonTileSizes": ["64x32", "128x64", "32x16"],
  136. "toScreen": "screenX = (x - y) * tileW/2 ; screenY = (x + y) * tileH/2",
  137. "toTile": ("x = (screenX/(tileW/2) + screenY/(tileH/2)) / 2 ; "
  138. "y = (screenY/(tileH/2) - screenX/(tileW/2)) / 2"),
  139. "derivation": {
  140. "groundAxisAngleDeg": "arctan(1/2)",
  141. "note": ("dimetric, not isometric: only two of the three inter-axis "
  142. "angles are equal"),
  143. },
  144. }
  145. pixel = {
  146. "projection": "pixel",
  147. "label": "pixel-neat 1:2 stepping (obelisk-style)",
  148. "obeliskStatedAngleDeg": OBELISK_STATED_DEG,
  149. "geometricArctanHalfDeg": r(DIMETRIC_AXIS_DEG),
  150. "pixelStep": "2 px across : 1 px up",
  151. "note": ("obelisk.js README states 22.6 deg for its 1:2 pixel-dot pattern; "
  152. "the pure geometric 1:2 slope is arctan(1/2) = 26.565 deg. Use "
  153. "26.565 for math, 22.6 only when matching obelisk output."),
  154. "reference": "https://github.com/nosir/obelisk.js",
  155. }
  156. table = {"true": true_iso, "dimetric21": dimetric, "pixel": pixel}
  157. if projection == "all":
  158. return table
  159. return {projection: table[projection]}
  160. def plain_constants(data: dict[str, Any]) -> str:
  161. lines: list[str] = []
  162. for key, block in data.items():
  163. lines.append(f"[{key}] {block.get('label', '')}")
  164. for k, v in block.items():
  165. if k in ("projection", "label", "derivation", "reference", "note"):
  166. continue
  167. lines.append(f" {k} = {v}")
  168. if "note" in block:
  169. lines.append(f" note: {block['note']}")
  170. return "\n".join(lines)
  171. def cmd_constants(args: argparse.Namespace) -> int:
  172. projection = args.projection or "all"
  173. data = build_constants(projection)
  174. return emit(data, len(data), "constants", args.json, plain_constants(data))
  175. # ---------------------------------------------------------------------------
  176. # to-screen / to-tile (2:1 dimetric canonical transform, parametrized by tile size)
  177. # ---------------------------------------------------------------------------
  178. def cmd_to_screen(args: argparse.Namespace) -> int:
  179. tw, th = args.tile_w, args.tile_h
  180. x, y, z = args.x, args.y, args.z
  181. screen_x = (x - y) * (tw / 2.0)
  182. # +z (elevation) lifts the sprite upward on screen (y-down => subtract).
  183. screen_y = (x + y) * (th / 2.0) - z * args.elev_step
  184. data = {
  185. "tile": {"x": x, "y": y, "z": z},
  186. "screen": {"x": r(screen_x, 4), "y": r(screen_y, 4)},
  187. "tileW": tw, "tileH": th, "elevStep": args.elev_step,
  188. }
  189. plain = f"{r(screen_x, 4)} {r(screen_y, 4)}"
  190. return emit(data, 1, "to-screen", args.json, plain)
  191. def cmd_to_tile(args: argparse.Namespace) -> int:
  192. tw, th = args.tile_w, args.tile_h
  193. sx, sy = args.sx, args.sy
  194. hx, hy = tw / 2.0, th / 2.0
  195. tile_x = (sx / hx + sy / hy) / 2.0
  196. tile_y = (sy / hy - sx / hx) / 2.0
  197. data = {
  198. "screen": {"x": sx, "y": sy},
  199. "tile": {"x": r(tile_x, 6), "y": r(tile_y, 6)},
  200. "tileRounded": {"x": math.floor(tile_x + 0.5), "y": math.floor(tile_y + 0.5)},
  201. "tileW": tw, "tileH": th,
  202. }
  203. plain = f"{r(tile_x, 6)} {r(tile_y, 6)}"
  204. return emit(data, 1, "to-tile", args.json, plain)
  205. # ---------------------------------------------------------------------------
  206. # grid-svg
  207. # ---------------------------------------------------------------------------
  208. def cmd_grid_svg(args: argparse.Namespace) -> int:
  209. tw = args.tile_w
  210. extent = args.extent
  211. stroke = args.stroke
  212. projection = args.projection or "dimetric21"
  213. if projection == "true":
  214. # True iso: ground-axis slope tan(30). Half-height derived from tile width so
  215. # the diamond edges sit at exactly 30 deg from horizontal.
  216. th = tw * math.tan(math.radians(30.0))
  217. else:
  218. # dimetric21 / pixel: 2:1 => half-height = tileW/4 (slope 0.5).
  219. th = args.tile_h if args.tile_h is not None else tw / 2.0
  220. hw, hh = tw / 2.0, th / 2.0
  221. def to_screen(x: float, y: float) -> tuple[float, float]:
  222. return (x - y) * hw, (x + y) * hh
  223. # Compute bounds over the full grid so we can translate into positive space.
  224. corners = [to_screen(x, y) for x in (0, extent) for y in (0, extent)]
  225. min_x = min(c[0] for c in corners)
  226. min_y = min(c[1] for c in corners)
  227. max_x = max(c[0] for c in corners)
  228. max_y = max(c[1] for c in corners)
  229. pad = 2.0
  230. width = (max_x - min_x) + 2 * pad
  231. height = (max_y - min_y) + 2 * pad
  232. off_x = -min_x + pad
  233. off_y = -min_y + pad
  234. def pt(x: float, y: float) -> tuple[float, float]:
  235. sx, sy = to_screen(x, y)
  236. return sx + off_x, sy + off_y
  237. lines: list[str] = []
  238. for x in range(extent + 1):
  239. x0, y0 = pt(x, 0)
  240. x1, y1 = pt(x, extent)
  241. lines.append(f' <line x1="{r(x0,3)}" y1="{r(y0,3)}" '
  242. f'x2="{r(x1,3)}" y2="{r(y1,3)}"/>')
  243. for y in range(extent + 1):
  244. x0, y0 = pt(0, y)
  245. x1, y1 = pt(extent, y)
  246. lines.append(f' <line x1="{r(x0,3)}" y1="{r(y0,3)}" '
  247. f'x2="{r(x1,3)}" y2="{r(y1,3)}"/>')
  248. slope = hh / hw # 0.5 dimetric, tan(30) true iso
  249. svg = (
  250. f'<svg xmlns="http://www.w3.org/2000/svg" '
  251. f'width="{r(width,3)}" height="{r(height,3)}" '
  252. f'viewBox="0 0 {r(width,3)} {r(height,3)}">\n'
  253. f' <!-- isometric-ops grid: projection={projection} tileW={tw} '
  254. f'extent={extent} axis-slope={r(slope,5)} -->\n'
  255. f' <g fill="none" stroke="{stroke}" stroke-width="1" '
  256. f'stroke-linecap="round">\n'
  257. + "\n".join(lines)
  258. + "\n </g>\n</svg>"
  259. )
  260. # SVG is the data product -> stdout. No --json for this subcommand.
  261. print(svg)
  262. warn(f"grid-svg: {projection} tileW={tw} extent={extent} axis-slope={r(slope,5)}")
  263. return EX_OK
  264. # ---------------------------------------------------------------------------
  265. # transforms
  266. # ---------------------------------------------------------------------------
  267. def build_transforms() -> dict[str, dict[str, Any]]:
  268. rx = r(CSS_ROTATEX_DEG, 4)
  269. scl = r(CSS_SCALE, 5)
  270. sv = r(SSR_VERTICAL, 5) # 0.86603
  271. figh = r(FIGMA_HEIGHT, 5) # 0.57735
  272. # 2D affine plane matrices for the true-iso planes, unit basis mapped to screen
  273. # (y-down). Derived from projecting world x/y/z onto the iso ground axes at +/-30
  274. # deg with the 0.86603 vertical (cos 30) foreshortening. matrix(a,b,c,d,e,f) maps
  275. # (x,y) -> (a*x + c*y + e, b*x + d*y + f).
  276. cos30 = math.cos(math.radians(30.0))
  277. sin30 = math.sin(math.radians(30.0))
  278. # Top plane: world-x -> right-down axis (+30), world-y -> left-down axis (-30).
  279. top = [r(cos30, 5), r(sin30, 5), r(-cos30, 5), r(sin30, 5), 0.0, 0.0]
  280. # Left plane (facing left): x along the -30 ground axis, y is vertical.
  281. left = [r(cos30, 5), r(sin30, 5), 0.0, r(-1.0, 5), 0.0, 0.0]
  282. # Right plane (facing right): x along the +30 ground axis, y is vertical.
  283. right = [r(cos30, 5), r(-sin30, 5), 0.0, r(-1.0, 5), 0.0, 0.0]
  284. return {
  285. "css-3d": {
  286. "target": "css-3d",
  287. "css": (f"transform: rotateX({rx}deg) rotateZ(-45deg) "
  288. f"scale3d({scl}, {scl}, {scl});"),
  289. "requires": "transform-style: preserve-3d on the element and its 3D children",
  290. "check": ("54.7356 = arctan(sqrt(2)) = 90 - 35.264; "
  291. f"{scl} = sqrt(3/2) undoes the 0.81650 foreshortening"),
  292. },
  293. "css-top": {
  294. "target": "css-top",
  295. "css": f"transform: rotate(-30deg) skewX(30deg) scaleY({sv});",
  296. "check": "top plane; scaleY = cos(30) = 0.86603",
  297. },
  298. "css-left": {
  299. "target": "css-left",
  300. "css": f"transform: rotate(30deg) skewX(-30deg) scaleY({sv});",
  301. "check": "left-facing wall plane; vertical edges stay vertical",
  302. },
  303. "css-right": {
  304. "target": "css-right",
  305. "css": f"transform: rotate(-30deg) skewX(-30deg) scaleY({sv});",
  306. "check": "right-facing wall plane; mirror of left about the vertical",
  307. },
  308. "svg-top": {
  309. "target": "svg-top",
  310. "svg": f"matrix({top[0]} {top[1]} {top[2]} {top[3]} {top[4]} {top[5]})",
  311. "matrix": top,
  312. "check": ("unit x -> (cos30, +sin30) = (0.86603, 0.5) screen (y-down); "
  313. "unit y -> (-cos30, +sin30) = (-0.86603, 0.5)"),
  314. },
  315. "svg-left": {
  316. "target": "svg-left",
  317. "svg": f"matrix({left[0]} {left[1]} {left[2]} {left[3]} {left[4]} {left[5]})",
  318. "matrix": left,
  319. "check": "unit x -> (0.86603, 0.5); unit y (up) -> (0, -1)",
  320. },
  321. "svg-right": {
  322. "target": "svg-right",
  323. "svg": (f"matrix({right[0]} {right[1]} {right[2]} "
  324. f"{right[3]} {right[4]} {right[5]})"),
  325. "matrix": right,
  326. "check": "unit x -> (0.86603, -0.5); unit y (up) -> (0, -1)",
  327. },
  328. "illustrator": {
  329. "target": "illustrator",
  330. "note": ("SSR after a vertical scale of "
  331. f"{sv} (cos 30). SRC-B misprints this once as 86.062 -- that is a "
  332. "typo; the canonical value is 86.602%."),
  333. "top": f"scaleY {sv} -> shear +30 deg -> rotate -30 deg",
  334. "left": f"scaleY {sv} -> shear -30 deg -> rotate -30 deg",
  335. "right": f"scaleY {sv} -> shear +30 deg -> rotate +30 deg",
  336. },
  337. "figma": {
  338. "target": "figma",
  339. "note": ("Figma has no shear tool. Rotate the flat asset 45 deg, group it "
  340. "(resets the bounding box to canvas axes), then set the group "
  341. f"height to x{figh} (tan 30). Duplicate and rotate +/-60 deg for "
  342. "the side planes."),
  343. "heightScale": figh,
  344. "sidePlaneRotationDeg": 60.0,
  345. },
  346. }
  347. def cmd_transforms(args: argparse.Namespace) -> int:
  348. table = build_transforms()
  349. target = args.target
  350. if target not in table:
  351. return fail(f"unknown --target '{target}'. Valid: {', '.join(sorted(table))}",
  352. EX_USAGE, args.json, "USAGE")
  353. block = table[target]
  354. plain_parts = [f"target: {target}"]
  355. for k in ("css", "svg", "top", "left", "right", "note", "check",
  356. "requires", "heightScale"):
  357. if k in block:
  358. plain_parts.append(f"{k}: {block[k]}")
  359. return emit(block, 1, "transforms", args.json, "\n".join(plain_parts))
  360. # ---------------------------------------------------------------------------
  361. # Argument parsing
  362. # ---------------------------------------------------------------------------
  363. def build_parser() -> argparse.ArgumentParser:
  364. epilog = (
  365. "EXAMPLES:\n"
  366. " iso-math.py constants --projection true --json | jq '.data'\n"
  367. " iso-math.py to-screen 3 5 --tile-w 64 --tile-h 32\n"
  368. " iso-math.py to-screen 3 5 2 --tile-w 64 --tile-h 32 --elev-step 16\n"
  369. " iso-math.py to-tile -64 128 --tile-w 64 --tile-h 32 --json\n"
  370. " iso-math.py grid-svg --projection dimetric21 --tile-w 64 --extent 8 > g.svg\n"
  371. " iso-math.py grid-svg --projection true --tile-w 128 --extent 4 > iso.svg\n"
  372. " iso-math.py transforms --target css-3d\n"
  373. " iso-math.py transforms --target svg-top --json | jq '.data.matrix'\n"
  374. )
  375. p = argparse.ArgumentParser(
  376. prog="iso-math.py",
  377. description="Isometric projection math: constants, transforms, grids, recipes.",
  378. epilog=epilog,
  379. formatter_class=argparse.RawDescriptionHelpFormatter,
  380. )
  381. sub = p.add_subparsers(dest="command", metavar="<subcommand>")
  382. projections = ["true", "dimetric21", "pixel"]
  383. pc = sub.add_parser("constants", help="Emit the canonical constants table.")
  384. pc.add_argument("--projection", choices=projections,
  385. help="Limit to one projection (default: all).")
  386. pc.add_argument("--json", action="store_true", help="Emit the JSON envelope.")
  387. pc.set_defaults(func=cmd_constants)
  388. ps = sub.add_parser("to-screen", help="tile (x,y[,z]) -> screen (2:1 dimetric).")
  389. ps.add_argument("x", type=float)
  390. ps.add_argument("y", type=float)
  391. ps.add_argument("z", type=float, nargs="?", default=0.0)
  392. ps.add_argument("--tile-w", type=int, required=True, dest="tile_w")
  393. ps.add_argument("--tile-h", type=int, required=True, dest="tile_h")
  394. ps.add_argument("--elev-step", type=float, default=None, dest="elev_step",
  395. help="Screen px per z-step (default: tileH/2).")
  396. ps.add_argument("--json", action="store_true")
  397. ps.set_defaults(func=cmd_to_screen)
  398. pt = sub.add_parser("to-tile", help="screen (sx,sy) -> tile (inverse transform).")
  399. pt.add_argument("sx", type=float)
  400. pt.add_argument("sy", type=float)
  401. pt.add_argument("--tile-w", type=int, required=True, dest="tile_w")
  402. pt.add_argument("--tile-h", type=int, required=True, dest="tile_h")
  403. pt.add_argument("--json", action="store_true")
  404. pt.set_defaults(func=cmd_to_tile)
  405. pg = sub.add_parser("grid-svg", help="Emit an SVG grid for a projection.")
  406. pg.add_argument("--projection", choices=projections, default="dimetric21")
  407. pg.add_argument("--tile-w", type=int, required=True, dest="tile_w")
  408. pg.add_argument("--extent", type=int, required=True,
  409. help="Grid size in tiles per axis.")
  410. pg.add_argument("--tile-h", type=int, default=None, dest="tile_h",
  411. help="Override half-height source (dimetric only).")
  412. pg.add_argument("--stroke", default="#334155", help="Line color (default #334155).")
  413. pg.set_defaults(func=cmd_grid_svg)
  414. ptr = sub.add_parser("transforms", help="Emit an exact transform recipe/matrix.")
  415. ptr.add_argument("--target", required=True,
  416. choices=["css-3d", "css-top", "css-left", "css-right",
  417. "svg-top", "svg-left", "svg-right",
  418. "illustrator", "figma"])
  419. ptr.add_argument("--projection", choices=projections, default="true")
  420. ptr.add_argument("--json", action="store_true")
  421. ptr.set_defaults(func=cmd_transforms)
  422. return p
  423. def main(argv: list[str]) -> int:
  424. parser = build_parser()
  425. # Pre-validate tile-size / extent semantics before argparse type coercion errors
  426. # leak as tracebacks. argparse handles unknown flags/extra positionals as USAGE.
  427. try:
  428. args = parser.parse_args(argv)
  429. except SystemExit as exc: # argparse already printed usage to stderr.
  430. return EX_USAGE if exc.code not in (0, None) else EX_OK
  431. if not getattr(args, "command", None):
  432. parser.print_help(sys.stderr)
  433. return EX_USAGE
  434. as_json = bool(getattr(args, "json", False))
  435. # Domain validation of numeric inputs (positive tiles, non-negative extent).
  436. for attr, label in (("tile_w", "--tile-w"), ("tile_h", "--tile-h")):
  437. val = getattr(args, attr, None)
  438. if val is not None and val <= 0:
  439. return fail(f"{label} must be a positive integer, got {val}",
  440. EX_VALIDATION, as_json, "VALIDATION")
  441. if getattr(args, "extent", None) is not None and args.extent <= 0:
  442. return fail(f"--extent must be a positive integer, got {args.extent}",
  443. EX_VALIDATION, as_json, "VALIDATION")
  444. # Default elevation step for to-screen: half tile height (one z-step = one tile row).
  445. if getattr(args, "command", None) == "to-screen" and args.elev_step is None:
  446. args.elev_step = args.tile_h / 2.0
  447. try:
  448. return args.func(args)
  449. except ValueError as exc:
  450. return fail(str(exc), EX_VALIDATION, as_json, "VALIDATION")
  451. if __name__ == "__main__":
  452. sys.exit(main(sys.argv[1:]))