castletool 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: castletool
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Interactive Castle deck editor for injecting images/GIFs and MIDI into Castle blueprint JSON files.
5
5
  Author-email: Your Name <your.email@example.com>
6
6
  Classifier: Programming Language :: Python :: 3
@@ -9,12 +9,15 @@ import bisect
9
9
  import copy
10
10
  import io
11
11
  import json
12
+ import math
12
13
  import os
13
14
  import platform
15
+ import re
14
16
  import shutil
15
17
  import subprocess
16
18
  import sys
17
19
  import uuid
20
+ import xml.etree.ElementTree as ET
18
21
  from pathlib import Path
19
22
 
20
23
  # ── optional deps ────────────────────────────────────────────────────────────
@@ -39,12 +42,6 @@ except ImportError:
39
42
  HAS_PT = False
40
43
 
41
44
  HAS_FZF = shutil.which("fzf") is not None
42
-
43
- try:
44
- from svg_to_castle import svg_to_path_data, build_drawing2_vector
45
- HAS_SVG = True
46
- except ImportError:
47
- HAS_SVG = False
48
45
  HAS_FFMPEG = shutil.which("ffmpeg") is not None
49
46
 
50
47
  # ── helpers ──────────────────────────────────────────────────────────────────
@@ -303,9 +300,6 @@ def quantize_frame(png_bytes: bytes, colors: int) -> bytes:
303
300
  result.save(buf, format="PNG", optimize=True)
304
301
  return buf.getvalue()
305
302
 
306
- import base64
307
- import math
308
-
309
303
  def build_drawing2(
310
304
  frames: list[bytes],
311
305
  fps: float,
@@ -384,6 +378,433 @@ def build_drawing2(
384
378
  "disabled": False,
385
379
  }
386
380
 
381
+ # ── svg → Drawing2 (vector) ──────────────────────────────────────────────────
382
+ # Merged from svg_to_castle.py: flattens all SVG paths to straight line
383
+ # segments (s:1, f:false). Bezier curves are sampled at `steps` intervals.
384
+
385
+ # Castle coordinate scale: SVG px → Castle units
386
+ # fillPixelsPerUnit = 25.6 by default
387
+ SVG_DEFAULT_PPU = 25.6
388
+ SVG_NS = "http://www.w3.org/2000/svg"
389
+
390
+
391
+ def _svg_lerp(a, b, t): return a + (b - a) * t
392
+
393
+ def _svg_quadratic_point(p0, p1, p2, t):
394
+ x = (1-t)**2*p0[0] + 2*(1-t)*t*p1[0] + t**2*p2[0]
395
+ y = (1-t)**2*p0[1] + 2*(1-t)*t*p1[1] + t**2*p2[1]
396
+ return (x, y)
397
+
398
+ def _svg_cubic_point(p0, p1, p2, p3, t):
399
+ x = (1-t)**3*p0[0] + 3*(1-t)**2*t*p1[0] + 3*(1-t)*t**2*p2[0] + t**3*p3[0]
400
+ y = (1-t)**3*p0[1] + 3*(1-t)**2*t*p1[1] + 3*(1-t)*t**2*p2[1] + t**3*p3[1]
401
+ return (x, y)
402
+
403
+
404
+ def _svg_arc_to_lines(x1, y1, rx, ry, x_rot, large, sweep, x2, y2, steps):
405
+ """Convert SVG arc to polyline points."""
406
+ if x1 == x2 and y1 == y2:
407
+ return []
408
+ if rx == 0 or ry == 0:
409
+ return [(x1, y1), (x2, y2)]
410
+
411
+ phi = math.radians(x_rot)
412
+ cos_phi, sin_phi = math.cos(phi), math.sin(phi)
413
+
414
+ dx, dy = (x1 - x2) / 2, (y1 - y2) / 2
415
+ x1p = cos_phi * dx + sin_phi * dy
416
+ y1p = -sin_phi * dx + cos_phi * dy
417
+
418
+ rx, ry = abs(rx), abs(ry)
419
+ x1p2, y1p2, rx2, ry2 = x1p**2, y1p**2, rx**2, ry**2
420
+ lam = x1p2/rx2 + y1p2/ry2
421
+ if lam > 1:
422
+ sq = math.sqrt(lam)
423
+ rx, ry = sq * rx, sq * ry
424
+ rx2, ry2 = rx**2, ry**2
425
+
426
+ num = max(0, rx2*ry2 - rx2*y1p2 - ry2*x1p2)
427
+ den = rx2*y1p2 + ry2*x1p2
428
+ sq = math.sqrt(num / den) if den else 0
429
+ if large == sweep:
430
+ sq = -sq
431
+
432
+ cxp = sq * rx * y1p / ry
433
+ cyp = -sq * ry * x1p / rx
434
+
435
+ cx = cos_phi*cxp - sin_phi*cyp + (x1+x2)/2
436
+ cy = sin_phi*cxp + cos_phi*cyp + (y1+y2)/2
437
+
438
+ def angle(ux, uy, vx, vy):
439
+ n = math.sqrt(ux**2+uy**2) * math.sqrt(vx**2+vy**2)
440
+ if n == 0: return 0
441
+ c = max(-1, min(1, (ux*vx+uy*vy)/n))
442
+ a = math.acos(c)
443
+ if ux*vy - uy*vx < 0: a = -a
444
+ return a
445
+
446
+ theta1 = angle(1, 0, (x1p-cxp)/rx, (y1p-cyp)/ry)
447
+ dtheta = angle((x1p-cxp)/rx, (y1p-cyp)/ry, (-x1p-cxp)/rx, (-y1p-cyp)/ry)
448
+
449
+ if not sweep and dtheta > 0: dtheta -= 2*math.pi
450
+ if sweep and dtheta < 0: dtheta += 2*math.pi
451
+
452
+ pts = []
453
+ for i in range(steps + 1):
454
+ t = i / steps
455
+ theta = theta1 + t * dtheta
456
+ x = cos_phi*rx*math.cos(theta) - sin_phi*ry*math.sin(theta) + cx
457
+ y = sin_phi*rx*math.cos(theta) + cos_phi*ry*math.sin(theta) + cy
458
+ pts.append((x, y))
459
+ return pts
460
+
461
+
462
+ def _svg_parse_numbers(s: str) -> list[float]:
463
+ return [float(x) for x in re.findall(r'[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?', s)]
464
+
465
+
466
+ def _svg_path_to_polylines(d: str, steps: int = 16) -> list[list[tuple]]:
467
+ """
468
+ Parse SVG path 'd' attribute, return list of polylines.
469
+ Each polyline is a list of (x, y) tuples.
470
+ Beziers are sampled at 'steps' intervals.
471
+ """
472
+ tokens = re.findall(r'[MmZzLlHhVvCcSsQqTtAa]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?', d)
473
+ cmd = None
474
+ pos = (0.0, 0.0)
475
+ start = (0.0, 0.0)
476
+ last_ctrl = None
477
+ polylines = []
478
+ current = []
479
+
480
+ def flush():
481
+ nonlocal current
482
+ if len(current) >= 2:
483
+ polylines.append(current)
484
+ current = []
485
+
486
+ i = 0
487
+ while i < len(tokens):
488
+ t = tokens[i]
489
+ if re.match(r'[MmZzLlHhVvCcSsQqTtAa]', t):
490
+ cmd = t
491
+ i += 1
492
+ if cmd in ('Z', 'z'):
493
+ if current and current[-1] != start:
494
+ current.append(start)
495
+ flush()
496
+ pos = start
497
+ cmd = None
498
+ continue
499
+
500
+ if cmd in ('Z', 'z'):
501
+ if current and current[-1] != start:
502
+ current.append(start)
503
+ flush()
504
+ pos = start
505
+ cmd = None
506
+ continue
507
+
508
+ nparams = {
509
+ 'M':2,'m':2,'L':2,'l':2,'H':1,'h':1,'V':1,'v':1,
510
+ 'C':6,'c':6,'S':4,'s':4,'Q':4,'q':4,'T':2,'t':2,
511
+ 'A':7,'a':7,
512
+ }.get(cmd, 0)
513
+
514
+ if nparams == 0:
515
+ i += 1
516
+ continue
517
+
518
+ args = []
519
+ while len(args) < nparams and i < len(tokens):
520
+ tok = tokens[i]
521
+ if re.match(r'[MmZzLlHhVvCcSsQqTtAa]', tok):
522
+ break
523
+ args.append(float(tok))
524
+ i += 1
525
+
526
+ if len(args) < nparams:
527
+ continue
528
+
529
+ rel = cmd.islower()
530
+ base = pos if rel else (0.0, 0.0)
531
+
532
+ if cmd in ('M', 'm'):
533
+ flush()
534
+ nx, ny = base[0]+args[0], base[1]+args[1]
535
+ pos = (nx, ny)
536
+ start = pos
537
+ current = [pos]
538
+ last_ctrl = None
539
+ cmd = 'l' if rel else 'L'
540
+
541
+ elif cmd in ('L', 'l'):
542
+ nx, ny = base[0]+args[0], base[1]+args[1]
543
+ pos = (nx, ny)
544
+ current.append(pos)
545
+ last_ctrl = None
546
+
547
+ elif cmd in ('H', 'h'):
548
+ nx = (pos[0] if rel else 0) + args[0]
549
+ pos = (nx, pos[1])
550
+ current.append(pos)
551
+ last_ctrl = None
552
+
553
+ elif cmd in ('V', 'v'):
554
+ ny = (pos[1] if rel else 0) + args[0]
555
+ pos = (pos[0], ny)
556
+ current.append(pos)
557
+ last_ctrl = None
558
+
559
+ elif cmd in ('C', 'c'):
560
+ p1 = (base[0]+args[0], base[1]+args[1])
561
+ p2 = (base[0]+args[2], base[1]+args[3])
562
+ p3 = (base[0]+args[4], base[1]+args[5])
563
+ for s in range(1, steps+1):
564
+ current.append(_svg_cubic_point(pos, p1, p2, p3, s/steps))
565
+ last_ctrl = p2
566
+ pos = p3
567
+
568
+ elif cmd in ('S', 's'):
569
+ p1 = (2*pos[0]-last_ctrl[0], 2*pos[1]-last_ctrl[1]) if last_ctrl else pos
570
+ p2 = (base[0]+args[0], base[1]+args[1])
571
+ p3 = (base[0]+args[2], base[1]+args[3])
572
+ for s in range(1, steps+1):
573
+ current.append(_svg_cubic_point(pos, p1, p2, p3, s/steps))
574
+ last_ctrl = p2
575
+ pos = p3
576
+
577
+ elif cmd in ('Q', 'q'):
578
+ p1 = (base[0]+args[0], base[1]+args[1])
579
+ p2 = (base[0]+args[2], base[1]+args[3])
580
+ for s in range(1, steps+1):
581
+ current.append(_svg_quadratic_point(pos, p1, p2, s/steps))
582
+ last_ctrl = p1
583
+ pos = p2
584
+
585
+ elif cmd in ('T', 't'):
586
+ p1 = (2*pos[0]-last_ctrl[0], 2*pos[1]-last_ctrl[1]) if last_ctrl else pos
587
+ p2 = (base[0]+args[0], base[1]+args[1])
588
+ for s in range(1, steps+1):
589
+ current.append(_svg_quadratic_point(pos, p1, p2, s/steps))
590
+ last_ctrl = p1
591
+ pos = p2
592
+
593
+ elif cmd in ('A', 'a'):
594
+ rx,ry,xr = args[0],args[1],args[2]
595
+ large,sweep = int(args[3]),int(args[4])
596
+ ex,ey = base[0]+args[5], base[1]+args[6]
597
+ pts = _svg_arc_to_lines(pos[0],pos[1],rx,ry,xr,large,sweep,ex,ey,steps)
598
+ if pts:
599
+ current.extend(pts[1:])
600
+ last_ctrl = None
601
+ pos = (ex, ey)
602
+
603
+ flush()
604
+ return polylines
605
+
606
+
607
+ SVG_COLORS = {
608
+ "black":(0,0,0),"white":(1,1,1),"red":(1,0,0),"green":(0,0.502,0),
609
+ "blue":(0,0,1),"yellow":(1,1,0),"cyan":(0,1,1),"magenta":(1,0,1),
610
+ "gray":(0.502,0.502,0.502),"grey":(0.502,0.502,0.502),
611
+ "none":None,
612
+ }
613
+
614
+ def parse_svg_color(s: str, opacity: float = 1.0):
615
+ if not s or s == "none": return None
616
+ s = s.strip().lower()
617
+ if s in SVG_COLORS:
618
+ c = SVG_COLORS[s]
619
+ return [*c, opacity] if c else None
620
+ if s.startswith("#"):
621
+ h = s[1:]
622
+ if len(h) == 3: h = h[0]*2+h[1]*2+h[2]*2
623
+ r,g,b = int(h[0:2],16)/255, int(h[2:4],16)/255, int(h[4:6],16)/255
624
+ return [r, g, b, opacity]
625
+ m = re.match(r'rgb\((\d+),\s*(\d+),\s*(\d+)\)', s)
626
+ if m:
627
+ return [int(m.group(i))/255 for i in (1,2,3)] + [opacity]
628
+ return [0, 0, 0, opacity]
629
+
630
+
631
+ def svg_to_path_data(svg_path, steps: int = 16, scale: float = 1.0,
632
+ ppu: float = SVG_DEFAULT_PPU, color=None):
633
+ """
634
+ Parse an SVG file and return a Castle pathDataList and framesBounds.
635
+ scale: multiplier applied to Castle units (1.0 = default)
636
+ ppu: pixels per unit (default 25.6)
637
+ color: override color [r,g,b,a], None = use SVG colors
638
+ """
639
+ tree = ET.parse(svg_path)
640
+ root = tree.getroot()
641
+
642
+ def tag(el): return el.tag.split('}')[-1] if '}' in el.tag else el.tag
643
+
644
+ vb = root.get("viewBox")
645
+ if vb:
646
+ parts = _svg_parse_numbers(vb)
647
+ vb_x, vb_y, vb_w, vb_h = parts
648
+ else:
649
+ vb_x, vb_y = 0, 0
650
+ vb_w = float(root.get("width", 100))
651
+ vb_h = float(root.get("height", 100))
652
+
653
+ # SVG Y is flipped vs Castle Y
654
+ def to_castle_raw(x, y):
655
+ return (x / ppu * scale, -(y / ppu) * scale)
656
+
657
+ _cx_offset = [0.0]
658
+ _cy_offset = [0.0]
659
+
660
+ def to_castle(x, y):
661
+ rx, ry = to_castle_raw(x, y)
662
+ return (rx - _cx_offset[0], ry - _cy_offset[0])
663
+
664
+ path_data = []
665
+ default_color = color or [0, 0, 0, 1]
666
+ all_xs, all_ys = [], []
667
+
668
+ def get_color(el):
669
+ if color: return color
670
+ stroke = el.get("stroke") or el.get("style", "")
671
+ m = re.search(r'stroke\s*:\s*([^;]+)', stroke)
672
+ stroke_val = m.group(1).strip() if m else (el.get("stroke") or "black")
673
+ op_m = re.search(r'stroke-opacity\s*:\s*([^;]+)', el.get("style",""))
674
+ opacity = float(op_m.group(1)) if op_m else float(el.get("stroke-opacity", 1))
675
+ c = parse_svg_color(stroke_val, opacity)
676
+ return c if c else default_color
677
+
678
+ def process_element(el):
679
+ t = tag(el)
680
+ c = get_color(el)
681
+
682
+ polylines = []
683
+
684
+ if t == "path":
685
+ d = el.get("d", "")
686
+ polylines = _svg_path_to_polylines(d, steps)
687
+
688
+ elif t == "line":
689
+ x1,y1 = float(el.get("x1",0)), float(el.get("y1",0))
690
+ x2,y2 = float(el.get("x2",0)), float(el.get("y2",0))
691
+ polylines = [[(x1,y1),(x2,y2)]]
692
+
693
+ elif t == "polyline" or t == "polygon":
694
+ pts_raw = _svg_parse_numbers(el.get("points",""))
695
+ pts = [(pts_raw[i],pts_raw[i+1]) for i in range(0,len(pts_raw)-1,2)]
696
+ if t == "polygon" and pts: pts.append(pts[0])
697
+ polylines = [pts]
698
+
699
+ elif t == "rect":
700
+ x,y = float(el.get("x",0)), float(el.get("y",0))
701
+ w,h = float(el.get("width",0)), float(el.get("height",0))
702
+ polylines = [[(x,y),(x+w,y),(x+w,y+h),(x,y+h),(x,y)]]
703
+
704
+ elif t == "circle":
705
+ cx2,cy2,r = float(el.get("cx",0)),float(el.get("cy",0)),float(el.get("r",1))
706
+ pts = [(cx2+r*math.cos(2*math.pi*i/steps),
707
+ cy2+r*math.sin(2*math.pi*i/steps)) for i in range(steps+1)]
708
+ polylines = [pts]
709
+
710
+ elif t == "ellipse":
711
+ cx2,cy2 = float(el.get("cx",0)),float(el.get("cy",0))
712
+ rx2,ry2 = float(el.get("rx",1)),float(el.get("ry",1))
713
+ pts = [(cx2+rx2*math.cos(2*math.pi*i/steps),
714
+ cy2+ry2*math.sin(2*math.pi*i/steps)) for i in range(steps+1)]
715
+ polylines = [pts]
716
+
717
+ for poly in polylines:
718
+ if len(poly) < 2: continue
719
+ castle_pts = [to_castle(x, y) for x, y in poly]
720
+ for j in range(len(castle_pts)-1):
721
+ x1,y1 = castle_pts[j]
722
+ x2,y2 = castle_pts[j+1]
723
+ path_data.append({
724
+ "p": [round(x1,5), round(y1,5), round(x2,5), round(y2,5)],
725
+ "s": 1,
726
+ "f": False,
727
+ "c": [round(v,5) for v in c],
728
+ })
729
+ all_xs.extend([x1, x2])
730
+ all_ys.extend([y1, y2])
731
+
732
+ for child in el:
733
+ process_element(child)
734
+
735
+ process_element(root)
736
+
737
+ if not all_xs:
738
+ return path_data, {"minX":-5,"maxX":5,"minY":-5,"maxY":5}, {"minX":-32,"maxX":32,"minY":-32,"maxY":32}
739
+
740
+ mid_x = (min(all_xs) + max(all_xs)) / 2
741
+ mid_y = (min(all_ys) + max(all_ys)) / 2
742
+ for seg in path_data:
743
+ seg["p"][0] = round(seg["p"][0] - mid_x, 5)
744
+ seg["p"][1] = round(seg["p"][1] - mid_y, 5)
745
+ seg["p"][2] = round(seg["p"][2] - mid_x, 5)
746
+ seg["p"][3] = round(seg["p"][3] - mid_y, 5)
747
+ all_xs = [x - mid_x for x in all_xs]
748
+ all_ys = [y - mid_y for y in all_ys]
749
+
750
+ bounds = {
751
+ "minX": round(min(all_xs), 5),
752
+ "maxX": round(max(all_xs), 5),
753
+ "minY": round(min(all_ys), 5),
754
+ "maxY": round(max(all_ys), 5),
755
+ }
756
+
757
+ fill_bounds = {
758
+ "minX": round(min(all_xs) * ppu / scale),
759
+ "maxX": round(max(all_xs) * ppu / scale),
760
+ "minY": round(-max(all_ys) * ppu / scale),
761
+ "maxY": round(-min(all_ys) * ppu / scale),
762
+ }
763
+
764
+ return path_data, bounds, fill_bounds
765
+
766
+
767
+ def build_drawing2_vector(path_data, bounds, fill_bounds,
768
+ scale=10, ppu=SVG_DEFAULT_PPU) -> dict:
769
+ frame = {
770
+ "isLinked": False,
771
+ "pathDataList": path_data,
772
+ "fillImageBounds": fill_bounds,
773
+ "avatarX": 0, "avatarY": 0, "avatarRadius": 5,
774
+ }
775
+ return {
776
+ "initialFrame": 1, "currentFrame": 1,
777
+ "framesPerSecond": 4,
778
+ "playMode": "still",
779
+ "loopStartFrame": -1, "loopEndFrame": -1,
780
+ "opacity": 1,
781
+ "hash": str(abs(hash(str(path_data))))[:19],
782
+ "playing": False, "loop": False,
783
+ "drawData": {
784
+ "color": [1,1,1,1], "lineColor": [0,0,0,1],
785
+ "gridSize": 0.71428, "scale": scale, "version": 3,
786
+ "fillPixelsPerUnit": ppu,
787
+ "numTotalLayers": 1,
788
+ "framesBounds": [bounds],
789
+ "colors": [], "selectedFrame": 1,
790
+ "layers": [{
791
+ "title": "Layer 1", "id": "layer1",
792
+ "isVisible": True, "isBitmap": False, "isAvatar": False,
793
+ "frames": [frame],
794
+ }],
795
+ },
796
+ "physicsBodyData": {
797
+ "shapes": [{
798
+ "p1": {"x": 5, "y": 5}, "p2": {"x": -5, "y": -5},
799
+ "p3": {"x": 0, "y": 0}, "radius": 0, "x": 0, "y": 0,
800
+ "type": "rectangle",
801
+ }],
802
+ "scale": scale, "version": 2, "zeroShapesInV1": False,
803
+ },
804
+ "disabled": False,
805
+ }
806
+
807
+
387
808
  # ── midi → Music ─────────────────────────────────────────────────────────────
388
809
 
389
810
  def beat_key(b): return f"{b:.6f}"
@@ -667,9 +1088,6 @@ def main():
667
1088
 
668
1089
  p()
669
1090
  if is_vector:
670
- if not HAS_SVG:
671
- pe("svg_to_castle.py not found — place it in the same folder as castle_tool.py")
672
- sys.exit(1)
673
1091
  pb(f"Loading SVG: {img_path.name}")
674
1092
  svg_scale = 1.0
675
1093
  if yn("Would you like to scale the SVG output?", default="n"):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: castletool
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Interactive Castle deck editor for injecting images/GIFs and MIDI into Castle blueprint JSON files.
5
5
  Author-email: Your Name <your.email@example.com>
6
6
  Classifier: Programming Language :: Python :: 3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "castletool"
7
- version = "0.2.2"
7
+ version = "0.2.3"
8
8
  description = "Interactive Castle deck editor for injecting images/GIFs and MIDI into Castle blueprint JSON files."
9
9
  authors = [
10
10
  { name = "Your Name", email = "your.email@example.com" }
File without changes
File without changes
File without changes