fable-engine 1.3.1__py3-none-any.whl

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.
Files changed (104) hide show
  1. fable_compressor.py +356 -0
  2. fable_engine/__init__.py +1 -0
  3. fable_engine/actions/__init__.py +291 -0
  4. fable_engine/actions/cas.py +182 -0
  5. fable_engine/actions/deliberation.py +523 -0
  6. fable_engine/actions/fleet.py +807 -0
  7. fable_engine/actions/lifecycle.py +298 -0
  8. fable_engine/actions/scrapers.py +116 -0
  9. fable_engine/actions/system3.py +815 -0
  10. fable_engine/browser.py +824 -0
  11. fable_engine/cas.py +974 -0
  12. fable_engine/fable_session.json +510 -0
  13. fable_engine/guards.py +283 -0
  14. fable_engine/schema.py +714 -0
  15. fable_engine/scrapers/__init__.py +32 -0
  16. fable_engine/scrapers/arxiv.py +115 -0
  17. fable_engine/scrapers/base.py +386 -0
  18. fable_engine/scrapers/github.py +129 -0
  19. fable_engine/scrapers/reddit.py +154 -0
  20. fable_engine/scrapers/web.py +120 -0
  21. fable_engine/scrapers/x.py +125 -0
  22. fable_engine/scrapers/youtube.py +132 -0
  23. fable_engine/server.py +414 -0
  24. fable_engine/session.py +1819 -0
  25. fable_engine/test_server.py +1362 -0
  26. fable_engine/updater.py +541 -0
  27. fable_engine-1.3.1.dist-info/LICENSE +22 -0
  28. fable_engine-1.3.1.dist-info/METADATA +173 -0
  29. fable_engine-1.3.1.dist-info/RECORD +104 -0
  30. fable_engine-1.3.1.dist-info/WHEEL +5 -0
  31. fable_engine-1.3.1.dist-info/entry_points.txt +5 -0
  32. fable_engine-1.3.1.dist-info/top_level.txt +6 -0
  33. fable_mode/__init__.py +3 -0
  34. fable_mode/__main__.py +4 -0
  35. fable_mode/adapters.py +1014 -0
  36. fable_mode/installer.py +553 -0
  37. fable_mode/launcher.py +437 -0
  38. fable_mode/manifest.py +142 -0
  39. fable_mode/resources.json +114 -0
  40. fable_mode/safety.py +103 -0
  41. fable_mode_entry.py +10 -0
  42. fable_v2/__init__.py +146 -0
  43. fable_v2/adapters.py +151 -0
  44. fable_v2/coder_fleet/__init__.py +100 -0
  45. fable_v2/coder_fleet/ast_tools.py +158 -0
  46. fable_v2/coder_fleet/compute.py +199 -0
  47. fable_v2/coder_fleet/design_engine.py +1316 -0
  48. fable_v2/coder_fleet/diagnostics.py +293 -0
  49. fable_v2/coder_fleet/fleet_dispatcher.py +214 -0
  50. fable_v2/coder_fleet/mock_auditor.py +306 -0
  51. fable_v2/coder_fleet/mutation.py +216 -0
  52. fable_v2/coder_fleet/property_oracle.py +260 -0
  53. fable_v2/coder_fleet/receipt_attestor.py +122 -0
  54. fable_v2/coder_fleet/red_team_swarm.py +908 -0
  55. fable_v2/coder_fleet/test_harness.py +198 -0
  56. fable_v2/coder_fleet/vector_engine.py +1287 -0
  57. fable_v2/coder_fleet/visual.py +357 -0
  58. fable_v2/coder_fleet/workspace.py +153 -0
  59. fable_v2/cortical/__init__.py +20 -0
  60. fable_v2/cortical/plasticity_engine.py +992 -0
  61. fable_v2/execution_broker.py +811 -0
  62. fable_v2/proof_engine.py +1141 -0
  63. fable_v2/protocol.py +485 -0
  64. fable_v2/runtime.py +1010 -0
  65. fable_v2/system3/__init__.py +204 -0
  66. fable_v2/system3/causal.py +558 -0
  67. fable_v2/system3/dialectical.py +577 -0
  68. fable_v2/system3/evolution.py +503 -0
  69. fable_v2/system3/executive.py +338 -0
  70. fable_v2/system3/free_energy.py +479 -0
  71. fable_v2/system3/hyperbolic.py +555 -0
  72. fable_v2/system3/induction.py +336 -0
  73. fable_v2/system3/kripke.py +548 -0
  74. fable_v2/system3/oracle.py +745 -0
  75. fable_v2/verifiers.py +72 -0
  76. tests/__init__.py +1 -0
  77. tests/test_anti_loop_circuit_breaker.py +64 -0
  78. tests/test_auto_updater.py +407 -0
  79. tests/test_coder_fleet.py +535 -0
  80. tests/test_delegation_compiler.py +54 -0
  81. tests/test_descriptor_boundaries.py +126 -0
  82. tests/test_design_engine.py +603 -0
  83. tests/test_epistemic_evidence_validator.py +66 -0
  84. tests/test_execution_broker.py +233 -0
  85. tests/test_fable_v2.py +406 -0
  86. tests/test_fleet_transitions.py +116 -0
  87. tests/test_fsm_redteam_evolution.py +406 -0
  88. tests/test_goal_rubric_and_pipeline.py +367 -0
  89. tests/test_hebbian_plasticity.py +585 -0
  90. tests/test_packaging_runtime.py +194 -0
  91. tests/test_proof_engine.py +259 -0
  92. tests/test_red_team_swarm.py +645 -0
  93. tests/test_redteam_remediation.py +169 -0
  94. tests/test_registration_transaction.py +375 -0
  95. tests/test_requested_regressions.py +467 -0
  96. tests/test_scrapers.py +370 -0
  97. tests/test_server_actions.py +93 -0
  98. tests/test_server_frontier_actions.py +269 -0
  99. tests/test_server_protocol.py +88 -0
  100. tests/test_stealth_browser.py +970 -0
  101. tests/test_system3.py +381 -0
  102. tests/test_system3_deep_integration.py +385 -0
  103. tests/test_system3_frontier.py +436 -0
  104. tests/test_vector_engine.py +608 -0
@@ -0,0 +1,1287 @@
1
+ """Fable-Vector Neuro-Symbolic Vector Engine.
2
+
3
+ High-precision, pure-Python vector graphics and layout engine featuring:
4
+ - OKLCHColor: Perceptually uniform color modeling, relative luminance, and APCA contrast.
5
+ - ParametricGeometry: Polar/Cartesian conversions, circular arcs, Catmull-Rom splines
6
+ with C1/C2 continuity and cubic Bezier derivation, and 30-degree isometric projection.
7
+ - BoundingBox: Enclosure, intersections, and viewport fitting.
8
+ - VNode: Hierarchical vector AST representation (cards, dials, splines, text, badges, grids, conduits).
9
+ - VLayoutSolver: Thread-safe 2D relational layout solver with defensive parameter coercion.
10
+ - FableVectorCompiler: Compiles VNode tree into production-grade SVG with viewBox enclosure,
11
+ micro-grain procedural noise filters, OKLCH gradients, and accessibility attributes.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ import re
17
+ import threading
18
+ import xml.etree.ElementTree as ET
19
+ from dataclasses import dataclass, field
20
+ from typing import Any, Iterable, Sequence
21
+
22
+
23
+ # ==============================================================================
24
+ # 1. OKLCH COLOR WITH APCA ACCESSIBILITY
25
+ # ==============================================================================
26
+
27
+ def _coerce_finite_float(val: Any, default: float = 0.0) -> float:
28
+ """Defensively coerce any value to a finite float, preventing NaN/Inf crashes."""
29
+ if val is None:
30
+ return default
31
+ try:
32
+ f = float(val)
33
+ if math.isnan(f) or math.isinf(f):
34
+ return default
35
+ return f
36
+ except (ValueError, TypeError):
37
+ return default
38
+
39
+
40
+ def _sanitize_text(val: Any, default: str = "") -> str:
41
+ """Defensively sanitize strings, stripping null bytes and non-printable control chars."""
42
+ if val is None:
43
+ return default
44
+ s = str(val)
45
+ # Strip null bytes and control chars except newline, tab, carriage return
46
+ s = s.replace("\x00", "")
47
+ return re.sub(r"[\x01-\x08\x0b\x0c\x0e-\x1f\x7f]", "", s)
48
+
49
+
50
+ @dataclass
51
+ class OKLCHColor:
52
+ """Perceptually uniform color in cylindrical OKLCH color space.
53
+
54
+ Attributes:
55
+ l: Lightness, normalized in [0.0, 1.0].
56
+ c: Chroma, >= 0.0 (typically [0.0, 0.4]).
57
+ h: Hue angle in degrees in [0.0, 360.0).
58
+ alpha: Opacity in [0.0, 1.0].
59
+ """
60
+ l: float
61
+ c: float
62
+ h: float
63
+ alpha: float = 1.0
64
+
65
+ def __post_init__(self) -> None:
66
+ # Defensive coercion against NaN / Inf / invalid types
67
+ self.l = max(0.0, min(1.0, _coerce_finite_float(self.l, 0.0)))
68
+ self.c = max(0.0, _coerce_finite_float(self.c, 0.0))
69
+ h_val = _coerce_finite_float(self.h, 0.0)
70
+ self.h = (h_val % 360.0 + 360.0) % 360.0
71
+ self.alpha = max(0.0, min(1.0, _coerce_finite_float(self.alpha, 1.0)))
72
+
73
+ def to_svg(self) -> str:
74
+ """Serialize to CSS Color Module Level 4 oklch() functional syntax."""
75
+ if abs(self.alpha - 1.0) > 1e-4:
76
+ return f"oklch({self.l:.4f} {self.c:.4f} {self.h:.2f} / {self.alpha:.2f})"
77
+ return f"oklch({self.l:.4f} {self.c:.4f} {self.h:.2f})"
78
+
79
+ def estimated_y(self) -> float:
80
+ """Compute estimated CIE 1931 relative luminance Y in [0.0, 1.0].
81
+
82
+ Converts from OKLCH -> OKLab -> LMS cube -> Linear sRGB -> Y.
83
+ When chroma is zero (achromatic), Y collapses exactly to l^3.
84
+ """
85
+ if self.c < 1e-6:
86
+ return self.l ** 3
87
+
88
+ rad = math.radians(self.h)
89
+ a = self.c * math.cos(rad)
90
+ b = self.c * math.sin(rad)
91
+
92
+ # OKLab to non-linear LMS
93
+ l_prime = self.l + 0.3963377774 * a + 0.2158037573 * b
94
+ m_prime = self.l - 0.1055613458 * a - 0.0638541728 * b
95
+ s_prime = self.l - 0.0894841775 * a - 1.2914855480 * b
96
+
97
+ # Cubic non-linearity
98
+ lms_l = l_prime ** 3
99
+ lms_m = m_prime ** 3
100
+ lms_s = s_prime ** 3
101
+
102
+ # LMS to Linear sRGB
103
+ r_lin = +4.0767434770 * lms_l - 3.3077115913 * lms_m + 0.2309699292 * lms_s
104
+ g_lin = -1.2684380046 * lms_l + 2.6097574011 * lms_m - 0.3413193965 * lms_s
105
+ b_lin = -0.0041960863 * lms_l - 0.7034186147 * lms_m + 1.7076147010 * lms_s
106
+
107
+ # Rec. 709 / sRGB relative luminance Y
108
+ y = 0.2126729 * r_lin + 0.7151522 * g_lin + 0.0721750 * b_lin
109
+ return max(0.0, min(1.0, y))
110
+
111
+ def apca_contrast(self, background: OKLCHColor) -> float:
112
+ """Calculate APCA (Advanced Perceptual Contrast Algorithm) Lc score.
113
+
114
+ Implements APCA 0.98G standard:
115
+ - Returns positive Lc for dark text on light background (BoW).
116
+ - Returns negative Lc for light text on dark background (WoB).
117
+ - WCAG AAA body text typically requires |Lc| >= 75.
118
+ - Large text / prominent UI requires |Lc| >= 60.
119
+ """
120
+ y_txt = self.estimated_y()
121
+ y_bg = background.estimated_y()
122
+
123
+ # Black clipping threshold and exponent
124
+ blk_thrs = 0.022
125
+ blk_clmp = 1.414
126
+
127
+ # Power exponents for normal (BoW) and reverse (WoB) polarities
128
+ norm_bg_power = 0.56
129
+ norm_txt_power = 0.57
130
+ rev_bg_power = 0.62
131
+ rev_txt_power = 0.65
132
+
133
+ scale_bow = 1.1414
134
+ scale_wob = 1.1414
135
+ lo_bow_offset = 0.027
136
+ lo_wob_offset = 0.027
137
+ lo_clip = 0.1
138
+ delta_y_min = 0.0005
139
+
140
+ # Soft black clamp near near-zero luminance
141
+ y_bg_c = y_bg if y_bg >= blk_thrs else y_bg + (blk_thrs - y_bg) ** blk_clmp
142
+ y_txt_c = y_txt if y_txt >= blk_thrs else y_txt + (blk_thrs - y_txt) ** blk_clmp
143
+
144
+ if abs(y_bg_c - y_txt_c) < delta_y_min:
145
+ return 0.0
146
+
147
+ if y_bg_c > y_txt_c:
148
+ # Dark text on light background (Black on White - BoW)
149
+ s_apca = (y_bg_c ** norm_bg_power - y_txt_c ** norm_txt_power) * scale_bow
150
+ if s_apca < lo_clip:
151
+ return 0.0
152
+ return (s_apca - lo_bow_offset) * 100.0
153
+ else:
154
+ # Light text on dark background (White on Black - WoB)
155
+ s_apca = (y_bg_c ** rev_bg_power - y_txt_c ** rev_txt_power) * scale_wob
156
+ if s_apca > -lo_clip:
157
+ return 0.0
158
+ return (s_apca + lo_wob_offset) * 100.0
159
+
160
+ @classmethod
161
+ def from_hex(cls, hex_code: str, alpha: float = 1.0) -> OKLCHColor:
162
+ """Create OKLCHColor from standard sRGB hex string (#RGB, #RRGGBB, #RRGGBBAA)."""
163
+ clean = hex_code.strip().lstrip("#")
164
+ if len(clean) == 3:
165
+ r = int(clean[0] * 2, 16) / 255.0
166
+ g = int(clean[1] * 2, 16) / 255.0
167
+ b = int(clean[2] * 2, 16) / 255.0
168
+ elif len(clean) == 6:
169
+ r = int(clean[0:2], 16) / 255.0
170
+ g = int(clean[2:4], 16) / 255.0
171
+ b = int(clean[4:6], 16) / 255.0
172
+ elif len(clean) == 8:
173
+ r = int(clean[0:2], 16) / 255.0
174
+ g = int(clean[2:4], 16) / 255.0
175
+ b = int(clean[4:6], 16) / 255.0
176
+ alpha = int(clean[6:8], 16) / 255.0
177
+ else:
178
+ return cls(l=0.0, c=0.0, h=0.0, alpha=alpha)
179
+
180
+ # Gamma decode to linear sRGB
181
+ def to_lin(c_val: float) -> float:
182
+ return c_val / 12.92 if c_val <= 0.04045 else ((c_val + 0.055) / 1.055) ** 2.4
183
+
184
+ r_lin, g_lin, b_lin = to_lin(r), to_lin(g), to_lin(b)
185
+
186
+ # Linear sRGB to LMS
187
+ l_cone = 0.4122214708 * r_lin + 0.5363325363 * g_lin + 0.0514459929 * b_lin
188
+ m_cone = 0.2119034982 * r_lin + 0.6806995451 * g_lin + 0.1073969566 * b_lin
189
+ s_cone = 0.0883024619 * r_lin + 0.2817188376 * g_lin + 0.6299787005 * b_lin
190
+
191
+ # Cube root
192
+ l_prime = math.copysign(abs(l_cone) ** (1.0 / 3.0), l_cone)
193
+ m_prime = math.copysign(abs(m_cone) ** (1.0 / 3.0), m_cone)
194
+ s_prime = math.copysign(abs(s_cone) ** (1.0 / 3.0), s_cone)
195
+
196
+ # LMS to OKLab
197
+ l_ok = 0.2104542553 * l_prime + 0.7936177850 * m_prime - 0.0040720468 * s_prime
198
+ a_ok = 1.9779984951 * l_prime - 2.4285922050 * m_prime + 0.4505937099 * s_prime
199
+ b_ok = 0.0259040371 * l_prime + 0.7827717662 * m_prime - 0.8086757660 * s_prime
200
+
201
+ # OKLab to OKLCH
202
+ chroma = math.sqrt(a_ok * a_ok + b_ok * b_ok)
203
+ hue = math.degrees(math.atan2(b_ok, a_ok)) % 360.0
204
+
205
+ return cls(l=l_ok, c=chroma, h=hue, alpha=alpha)
206
+
207
+
208
+ # ==============================================================================
209
+ # 2. PARAMETRIC GEOMETRY & CURVES
210
+ # ==============================================================================
211
+
212
+ class ParametricGeometry:
213
+ """Pure-Python parametric math and vector geometry solver."""
214
+
215
+ @staticmethod
216
+ def polar_to_cartesian(
217
+ cx: float,
218
+ cy: float,
219
+ radius: float,
220
+ angle_degrees: float,
221
+ ) -> tuple[float, float]:
222
+ """Convert 2D polar coordinates (radius, angle_degrees) centered at (cx, cy) to Cartesian (x, y)."""
223
+ cx_c = _coerce_finite_float(cx)
224
+ cy_c = _coerce_finite_float(cy)
225
+ rad_c = max(0.0, _coerce_finite_float(radius))
226
+ ang_c = _coerce_finite_float(angle_degrees)
227
+
228
+ theta = math.radians(ang_c)
229
+ x = cx_c + rad_c * math.cos(theta)
230
+ y = cy_c + rad_c * math.sin(theta)
231
+ return (round(x, 4), round(y, 4))
232
+
233
+ @staticmethod
234
+ def circular_arc(
235
+ cx: float,
236
+ cy: float,
237
+ radius: float,
238
+ start_angle: float,
239
+ end_angle: float,
240
+ clock_wise: bool = True,
241
+ ) -> str:
242
+ """Generate an SVG path command string for a circular arc.
243
+
244
+ Angles are in degrees. Supports arbitrary arc spans including full 360-degree circles.
245
+ """
246
+ cx_c = _coerce_finite_float(cx)
247
+ cy_c = _coerce_finite_float(cy)
248
+ rad_c = max(0.0, _coerce_finite_float(radius))
249
+ start_a = _coerce_finite_float(start_angle)
250
+ end_a = _coerce_finite_float(end_angle)
251
+
252
+ if rad_c <= 1e-6:
253
+ return f"M {cx_c:.4f} {cy_c:.4f}"
254
+
255
+ # Calculate angular distance along the direction of travel
256
+ if clock_wise:
257
+ delta = (end_a - start_a) % 360.0
258
+ else:
259
+ delta = (start_a - end_a) % 360.0
260
+
261
+ x_start, y_start = ParametricGeometry.polar_to_cartesian(cx_c, cy_c, rad_c, start_a)
262
+
263
+ # If full circle (or delta ~ 360), SVG arc requires two 180-degree semicircles
264
+ if abs(delta) < 1e-4 or abs(delta - 360.0) < 1e-4:
265
+ mid_a = start_a + (180.0 if clock_wise else -180.0)
266
+ x_mid, y_mid = ParametricGeometry.polar_to_cartesian(cx_c, cy_c, rad_c, mid_a)
267
+ sweep = 1 if clock_wise else 0
268
+ return (
269
+ f"M {x_start:.4f} {y_start:.4f} "
270
+ f"A {rad_c:.4f} {rad_c:.4f} 0 1 {sweep} {x_mid:.4f} {y_mid:.4f} "
271
+ f"A {rad_c:.4f} {rad_c:.4f} 0 1 {sweep} {x_start:.4f} {y_start:.4f}"
272
+ )
273
+
274
+ x_end, y_end = ParametricGeometry.polar_to_cartesian(cx_c, cy_c, rad_c, end_a)
275
+ large_arc = 1 if delta > 180.0 else 0
276
+ sweep_flag = 1 if clock_wise else 0
277
+
278
+ return f"M {x_start:.4f} {y_start:.4f} A {rad_c:.4f} {rad_c:.4f} 0 {large_arc} {sweep_flag} {x_end:.4f} {y_end:.4f}"
279
+
280
+ @staticmethod
281
+ def derive_catmull_rom_bezier_segments(
282
+ points: Sequence[tuple[float, float]],
283
+ tension: float = 0.5,
284
+ closed: bool = False,
285
+ ) -> list[tuple[tuple[float, float], tuple[float, float], tuple[float, float], tuple[float, float]]]:
286
+ """Derive cubic Bezier control segments (P1, C1, C2, P2) for a Catmull-Rom spline.
287
+
288
+ Guarantees C1 (tangent) continuity across all internal control points:
289
+ Tangents at junction point P2 of segment k and junction point P1 of segment k+1 are identical:
290
+ T(P2) = k * (P3 - P1)
291
+ where k = (1.0 - tension) / 3.0 (or standard scaling factor).
292
+ """
293
+ pts = [(_coerce_finite_float(p[0]), _coerce_finite_float(p[1])) for p in points]
294
+ n = len(pts)
295
+ if n < 2:
296
+ return []
297
+
298
+ # Tangent scale factor: standard Catmull-Rom has tension=0.0 giving k = 1/6.
299
+ # With parameter tension in [0, 1], k = (1.0 - tension) / 3.0 gives smooth C1 curves.
300
+ clamped_tension = max(-2.0, min(1.0, _coerce_finite_float(tension, 0.5)))
301
+ k = (1.0 - clamped_tension) / 3.0
302
+
303
+ segments: list[tuple[tuple[float, float], tuple[float, float], tuple[float, float], tuple[float, float]]] = []
304
+
305
+ if closed:
306
+ for i in range(n):
307
+ p0 = pts[(i - 1) % n]
308
+ p1 = pts[i]
309
+ p2 = pts[(i + 1) % n]
310
+ p3 = pts[(i + 2) % n]
311
+
312
+ c1 = (p1[0] + k * (p2[0] - p0[0]), p1[1] + k * (p2[1] - p0[1]))
313
+ c2 = (p2[0] - k * (p3[0] - p1[0]), p2[1] - k * (p3[1] - p1[1]))
314
+ segments.append((p1, c1, c2, p2))
315
+ else:
316
+ # Open curve with extrapolated virtual end control points
317
+ p_ghost_start = (2.0 * pts[0][0] - pts[1][0], 2.0 * pts[0][1] - pts[1][1])
318
+ p_ghost_end = (2.0 * pts[-1][0] - pts[-2][0], 2.0 * pts[-1][1] - pts[-2][1])
319
+
320
+ extended = [p_ghost_start] + pts + [p_ghost_end]
321
+ for i in range(1, len(extended) - 2):
322
+ p0 = extended[i - 1]
323
+ p1 = extended[i]
324
+ p2 = extended[i + 1]
325
+ p3 = extended[i + 2]
326
+
327
+ c1 = (p1[0] + k * (p2[0] - p0[0]), p1[1] + k * (p2[1] - p0[1]))
328
+ c2 = (p2[0] - k * (p3[0] - p1[0]), p2[1] - k * (p3[1] - p1[1]))
329
+ segments.append((p1, c1, c2, p2))
330
+
331
+ return segments
332
+
333
+ @staticmethod
334
+ def catmull_rom_spline(
335
+ points: Sequence[tuple[float, float]],
336
+ tension: float = 0.5,
337
+ closed: bool = False,
338
+ ) -> str:
339
+ """Convert a list of 2D control points into a smooth SVG cubic Bezier path string ('d')."""
340
+ segments = ParametricGeometry.derive_catmull_rom_bezier_segments(points, tension=tension, closed=closed)
341
+ if not segments:
342
+ if points:
343
+ p0 = points[0]
344
+ return f"M {_coerce_finite_float(p0[0]):.4f} {_coerce_finite_float(p0[1]):.4f}"
345
+ return ""
346
+
347
+ first_p1 = segments[0][0]
348
+ commands = [f"M {first_p1[0]:.4f} {first_p1[1]:.4f}"]
349
+
350
+ for _, c1, c2, p2 in segments:
351
+ commands.append(f"C {c1[0]:.4f} {c1[1]:.4f}, {c2[0]:.4f} {c2[1]:.4f}, {p2[0]:.4f} {p2[1]:.4f}")
352
+
353
+ if closed:
354
+ commands.append("Z")
355
+
356
+ return " ".join(commands)
357
+
358
+ @staticmethod
359
+ def isometric_project(
360
+ x: float,
361
+ y: float,
362
+ z: float = 0.0,
363
+ angle_degrees: float = 30.0,
364
+ ) -> tuple[float, float]:
365
+ """Project 3D coordinates (x, y, z) to 2D screen coordinates using a 30-degree isometric matrix.
366
+
367
+ Standard isometric projection equations:
368
+ x_screen = (x - y) * cos(theta)
369
+ y_screen = (x + y) * sin(theta) - z
370
+ """
371
+ xc = _coerce_finite_float(x)
372
+ yc = _coerce_finite_float(y)
373
+ zc = _coerce_finite_float(z)
374
+ theta = math.radians(_coerce_finite_float(angle_degrees, 30.0))
375
+
376
+ cos_t = math.cos(theta)
377
+ sin_t = math.sin(theta)
378
+
379
+ x_iso = (xc - yc) * cos_t
380
+ y_iso = (xc + yc) * sin_t - zc
381
+
382
+ return (round(x_iso, 4), round(y_iso, 4))
383
+
384
+
385
+ # ==============================================================================
386
+ # 3. BOUNDING BOX
387
+ # ==============================================================================
388
+
389
+ @dataclass
390
+ class BoundingBox:
391
+ """Axis-Aligned 2D Bounding Box (AABB) with spatial queries and viewBox support."""
392
+ x: float
393
+ y: float
394
+ width: float
395
+ height: float
396
+
397
+ def __post_init__(self) -> None:
398
+ self.x = _coerce_finite_float(self.x, 0.0)
399
+ self.y = _coerce_finite_float(self.y, 0.0)
400
+ self.width = max(0.0, _coerce_finite_float(self.width, 0.0))
401
+ self.height = max(0.0, _coerce_finite_float(self.height, 0.0))
402
+
403
+ @property
404
+ def right(self) -> float:
405
+ """Right coordinate (x + width)."""
406
+ return self.x + self.width
407
+
408
+ @property
409
+ def bottom(self) -> float:
410
+ """Bottom coordinate (y + height)."""
411
+ return self.y + self.height
412
+
413
+ @property
414
+ def cx(self) -> float:
415
+ """Center X coordinate."""
416
+ return self.x + self.width / 2.0
417
+
418
+ @property
419
+ def cy(self) -> float:
420
+ """Center Y coordinate."""
421
+ return self.y + self.height / 2.0
422
+
423
+ def contains(self, item: BoundingBox | tuple[float, float] | list[float]) -> bool:
424
+ """Check if this bounding box contains a point (x, y) or another BoundingBox."""
425
+ if isinstance(item, BoundingBox):
426
+ return (
427
+ item.x >= self.x
428
+ and item.right <= self.right
429
+ and item.y >= self.y
430
+ and item.bottom <= self.bottom
431
+ )
432
+ if isinstance(item, (tuple, list)) and len(item) >= 2:
433
+ px = _coerce_finite_float(item[0])
434
+ py = _coerce_finite_float(item[1])
435
+ return (self.x <= px <= self.right) and (self.y <= py <= self.bottom)
436
+ return False
437
+
438
+ def intersects(self, other: BoundingBox) -> bool:
439
+ """Check if this bounding box intersects/overlaps with another BoundingBox."""
440
+ if not isinstance(other, BoundingBox):
441
+ return False
442
+ return not (
443
+ self.right < other.x
444
+ or other.right < self.x
445
+ or self.bottom < other.y
446
+ or other.bottom < self.y
447
+ )
448
+
449
+ @classmethod
450
+ def enclosing(cls, boxes: Iterable[BoundingBox], padding: float = 0.0) -> BoundingBox:
451
+ """Compute the minimal bounding box enclosing all given boxes, with optional padding."""
452
+ box_list = [b for b in boxes if isinstance(b, BoundingBox) and (b.width > 0 or b.height > 0)]
453
+ if not box_list:
454
+ pad = max(0.0, _coerce_finite_float(padding))
455
+ return cls(x=-pad, y=-pad, width=2.0 * pad, height=2.0 * pad)
456
+
457
+ min_x = min(b.x for b in box_list)
458
+ min_y = min(b.y for b in box_list)
459
+ max_right = max(b.right for b in box_list)
460
+ max_bottom = max(b.bottom for b in box_list)
461
+
462
+ pad = max(0.0, _coerce_finite_float(padding))
463
+ return cls(
464
+ x=min_x - pad,
465
+ y=min_y - pad,
466
+ width=(max_right - min_x) + 2.0 * pad,
467
+ height=(max_bottom - min_y) + 2.0 * pad,
468
+ )
469
+
470
+ def to_viewbox(self, padding: float = 0.0) -> str:
471
+ """Format as SVG viewBox string: 'min-x min-y width height'."""
472
+ pad = max(0.0, _coerce_finite_float(padding))
473
+ vx = self.x - pad
474
+ vy = self.y - pad
475
+ vw = self.width + 2.0 * pad
476
+ vh = self.height + 2.0 * pad
477
+ return f"{vx:.2f} {vy:.2f} {vw:.2f} {vh:.2f}"
478
+
479
+
480
+ # ==============================================================================
481
+ # 4. VNODE (VECTOR AST NODE)
482
+ # ==============================================================================
483
+
484
+ VALID_VNODE_KINDS = {
485
+ "card",
486
+ "dial",
487
+ "spline",
488
+ "text",
489
+ "badge",
490
+ "grid",
491
+ "conduit",
492
+ }
493
+
494
+
495
+ @dataclass
496
+ class VNode:
497
+ """Vector Node AST representing a visual or structural element."""
498
+ id: str
499
+ kind: str
500
+ width: float = 0.0
501
+ height: float = 0.0
502
+ x: float = 0.0
503
+ y: float = 0.0
504
+ props: dict[str, Any] = field(default_factory=dict)
505
+ children: list[VNode] = field(default_factory=list)
506
+
507
+ def __post_init__(self) -> None:
508
+ # Defensive string sanitation
509
+ self.id = _sanitize_text(self.id, default="node_0")
510
+ if not self.id:
511
+ self.id = f"node_{id(self)}"
512
+
513
+ raw_kind = _sanitize_text(self.kind, default="card").lower()
514
+ self.kind = raw_kind if raw_kind in VALID_VNODE_KINDS else "card"
515
+
516
+ # Defensive coordinate coercion
517
+ self.width = max(0.0, _coerce_finite_float(self.width, 0.0))
518
+ self.height = max(0.0, _coerce_finite_float(self.height, 0.0))
519
+ self.x = _coerce_finite_float(self.x, 0.0)
520
+ self.y = _coerce_finite_float(self.y, 0.0)
521
+
522
+ if not isinstance(self.props, dict):
523
+ self.props = {}
524
+ if not isinstance(self.children, list):
525
+ self.children = []
526
+
527
+ @property
528
+ def bbox(self) -> BoundingBox:
529
+ """Get the bounding box of this node alone."""
530
+ return BoundingBox(self.x, self.y, self.width, self.height)
531
+
532
+ @property
533
+ def right(self) -> float:
534
+ """Right coordinate (x + width)."""
535
+ return self.x + self.width
536
+
537
+ @property
538
+ def bottom(self) -> float:
539
+ """Bottom coordinate (y + height)."""
540
+ return self.y + self.height
541
+
542
+ @property
543
+ def cx(self) -> float:
544
+ """Center X coordinate."""
545
+ return self.x + self.width / 2.0
546
+
547
+ @property
548
+ def cy(self) -> float:
549
+ """Center Y coordinate."""
550
+ return self.y + self.height / 2.0
551
+
552
+ def compute_total_bbox(self) -> BoundingBox:
553
+ """Compute the enclosing bounding box of this node and all its children."""
554
+ boxes = [self.bbox]
555
+ for child in self.children:
556
+ if isinstance(child, VNode):
557
+ boxes.append(child.compute_total_bbox())
558
+ return BoundingBox.enclosing(boxes)
559
+
560
+ def add_child(self, child: VNode) -> VNode:
561
+ """Add a child VNode and return it."""
562
+ if not isinstance(child, VNode):
563
+ raise TypeError(f"Expected VNode, got {type(child).__name__}")
564
+ self.children.append(child)
565
+ return child
566
+
567
+ def find_by_id(self, target_id: str, _depth: int = 0) -> VNode | None:
568
+ """Find a node by ID in this subtree with deep-nesting recursion guard."""
569
+ if _depth > 100:
570
+ return None
571
+ clean_id = _sanitize_text(target_id)
572
+ if self.id == clean_id:
573
+ return self
574
+ for child in self.children:
575
+ found = child.find_by_id(clean_id, _depth + 1)
576
+ if found is not None:
577
+ return found
578
+ return None
579
+
580
+ def to_dict(self) -> dict[str, Any]:
581
+ """Serialize VNode subtree to dictionary."""
582
+ return {
583
+ "id": self.id,
584
+ "kind": self.kind,
585
+ "x": self.x,
586
+ "y": self.y,
587
+ "width": self.width,
588
+ "height": self.height,
589
+ "props": dict(self.props),
590
+ "children": [c.to_dict() for c in self.children if isinstance(c, VNode)],
591
+ }
592
+
593
+ @classmethod
594
+ def from_dict(cls, data: dict[str, Any], _depth: int = 0) -> VNode:
595
+ """Deserialize dictionary into a VNode tree with deep-nesting protection."""
596
+ if _depth > 100:
597
+ raise ValueError("VNode nesting exceeds maximum recursion depth of 100")
598
+ if not isinstance(data, dict):
599
+ raise TypeError("VNode data must be a dictionary")
600
+
601
+ children_data = data.get("children", [])
602
+ children: list[VNode] = []
603
+ if isinstance(children_data, list):
604
+ for child_dict in children_data:
605
+ if isinstance(child_dict, dict):
606
+ children.append(cls.from_dict(child_dict, _depth + 1))
607
+
608
+ return cls(
609
+ id=str(data.get("id", "node")),
610
+ kind=str(data.get("kind", "card")),
611
+ width=_coerce_finite_float(data.get("width", 0.0)),
612
+ height=_coerce_finite_float(data.get("height", 0.0)),
613
+ x=_coerce_finite_float(data.get("x", 0.0)),
614
+ y=_coerce_finite_float(data.get("y", 0.0)),
615
+ props=dict(data.get("props", {})),
616
+ children=children,
617
+ )
618
+
619
+
620
+ # ==============================================================================
621
+ # 5. VLAYOUT SOLVER
622
+ # ==============================================================================
623
+
624
+ class VLayoutSolver:
625
+ """Thread-safe 2D relational layout solver with defensive parameter coercion.
626
+
627
+ Guards against:
628
+ - NaN and Infinite values
629
+ - Null bytes in node identifiers and strings
630
+ - Deep nesting recursion overflows
631
+ - Cyclic relational dependencies
632
+ """
633
+
634
+ def __init__(self, max_depth: int = 64) -> None:
635
+ self._lock = threading.RLock()
636
+ self.max_depth = max(4, min(256, max_depth))
637
+ self._constraints: list[dict[str, Any]] = []
638
+
639
+ def clear_constraints(self) -> None:
640
+ """Clear all registered relational constraints."""
641
+ with self._lock:
642
+ self._constraints.clear()
643
+
644
+ def pin_to_viewport(
645
+ self,
646
+ node: VNode,
647
+ viewport_width: float,
648
+ viewport_height: float,
649
+ anchor: str = "top-left",
650
+ margin: float = 0.0,
651
+ ) -> VNode:
652
+ """Anchor a VNode to viewport coordinates based on an anchor mode.
653
+
654
+ Supported anchors:
655
+ 'top-left', 'top-right', 'bottom-left', 'bottom-right',
656
+ 'center', 'top-center', 'bottom-center', 'center-left', 'center-right'.
657
+ """
658
+ with self._lock:
659
+ vw = max(0.0, _coerce_finite_float(viewport_width, 1920.0))
660
+ vh = max(0.0, _coerce_finite_float(viewport_height, 1080.0))
661
+ m = max(0.0, _coerce_finite_float(margin, 0.0))
662
+ clean_anchor = _sanitize_text(anchor, "top-left").lower()
663
+
664
+ w = node.width
665
+ h = node.height
666
+
667
+ if clean_anchor == "top-left":
668
+ node.x = m
669
+ node.y = m
670
+ elif clean_anchor == "top-right":
671
+ node.x = vw - w - m
672
+ node.y = m
673
+ elif clean_anchor == "bottom-left":
674
+ node.x = m
675
+ node.y = vh - h - m
676
+ elif clean_anchor == "bottom-right":
677
+ node.x = vw - w - m
678
+ node.y = vh - h - m
679
+ elif clean_anchor == "center":
680
+ node.x = (vw - w) / 2.0
681
+ node.y = (vh - h) / 2.0
682
+ elif clean_anchor == "top-center":
683
+ node.x = (vw - w) / 2.0
684
+ node.y = m
685
+ elif clean_anchor == "bottom-center":
686
+ node.x = (vw - w) / 2.0
687
+ node.y = vh - h - m
688
+ elif clean_anchor == "center-left":
689
+ node.x = m
690
+ node.y = (vh - h) / 2.0
691
+ elif clean_anchor == "center-right":
692
+ node.x = vw - w - m
693
+ node.y = (vh - h) / 2.0
694
+ else:
695
+ node.x = m
696
+ node.y = m
697
+
698
+ return node
699
+
700
+ def align_relative(
701
+ self,
702
+ target: VNode,
703
+ reference: VNode,
704
+ relation: str,
705
+ gap: float = 0.0,
706
+ alignment: str = "start",
707
+ ) -> VNode:
708
+ """Position target relative to reference using relational constraints and alignments.
709
+
710
+ Relations:
711
+ - 'right_of' / 'right': target is positioned to the right of reference
712
+ - 'left_of' / 'left': target is positioned to the left of reference
713
+ - 'below' / 'bottom': target is positioned below reference
714
+ - 'above' / 'top': target is positioned above reference
715
+ - 'inside' / 'center': target is centered inside reference
716
+
717
+ Alignments:
718
+ - For horizontal relations: 'start' (top), 'center' (middle), 'end' (bottom)
719
+ - For vertical relations: 'start' (left), 'center' (middle), 'end' (right)
720
+ """
721
+ with self._lock:
722
+ g = _coerce_finite_float(gap, 0.0)
723
+ rel = _sanitize_text(relation, "right_of").lower()
724
+ align = _sanitize_text(alignment, "start").lower()
725
+
726
+ if rel in ("right_of", "right", "after"):
727
+ target.x = reference.right + g
728
+ if align in ("center", "middle"):
729
+ target.y = reference.cy - target.height / 2.0
730
+ elif align in ("end", "bottom"):
731
+ target.y = reference.bottom - target.height
732
+ else:
733
+ target.y = reference.y
734
+
735
+ elif rel in ("left_of", "left", "before"):
736
+ target.x = reference.x - target.width - g
737
+ if align in ("center", "middle"):
738
+ target.y = reference.cy - target.height / 2.0
739
+ elif align in ("end", "bottom"):
740
+ target.y = reference.bottom - target.height
741
+ else:
742
+ target.y = reference.y
743
+
744
+ elif rel in ("below", "bottom", "under"):
745
+ target.y = reference.bottom + g
746
+ if align in ("center", "middle"):
747
+ target.x = reference.cx - target.width / 2.0
748
+ elif align in ("end", "right"):
749
+ target.x = reference.right - target.width
750
+ else:
751
+ target.x = reference.x
752
+
753
+ elif rel in ("above", "top"):
754
+ target.y = reference.y - target.height - g
755
+ if align in ("center", "middle"):
756
+ target.x = reference.cx - target.width / 2.0
757
+ elif align in ("end", "right"):
758
+ target.x = reference.right - target.width
759
+ else:
760
+ target.x = reference.x
761
+
762
+ elif rel in ("inside", "center", "fit"):
763
+ target.x = reference.x + (reference.width - target.width) / 2.0
764
+ target.y = reference.y + (reference.height - target.height) / 2.0
765
+
766
+ return target
767
+
768
+ def register_constraint(
769
+ self,
770
+ target_id: str,
771
+ reference_id: str,
772
+ relation: str,
773
+ gap: float = 0.0,
774
+ alignment: str = "start",
775
+ ) -> None:
776
+ """Register a deferred relational constraint to be solved in topological order."""
777
+ with self._lock:
778
+ self._constraints.append({
779
+ "target_id": _sanitize_text(target_id),
780
+ "reference_id": _sanitize_text(reference_id),
781
+ "relation": _sanitize_text(relation),
782
+ "gap": _coerce_finite_float(gap),
783
+ "alignment": _sanitize_text(alignment),
784
+ })
785
+
786
+ def _verify_depth(self, node: VNode, current_depth: int = 1) -> None:
787
+ """Defensively verify that the node tree does not exceed max_depth."""
788
+ if current_depth > self.max_depth:
789
+ raise ValueError(f"Tree nesting depth exceeded safety threshold of {self.max_depth}")
790
+ for child in node.children:
791
+ if isinstance(child, VNode):
792
+ self._verify_depth(child, current_depth + 1)
793
+
794
+ def solve(
795
+ self,
796
+ root: VNode | None = None,
797
+ viewport_width: float = 1920.0,
798
+ viewport_height: float = 1080.0,
799
+ nodes: list[VNode] | None = None,
800
+ ) -> BoundingBox:
801
+ """Solve layout constraints and compute the total enclosing BoundingBox."""
802
+ with self._lock:
803
+ vw = max(0.0, _coerce_finite_float(viewport_width, 1920.0))
804
+ vh = max(0.0, _coerce_finite_float(viewport_height, 1080.0))
805
+
806
+ all_nodes: list[VNode] = []
807
+ if root is not None:
808
+ self._verify_depth(root)
809
+ all_nodes.append(root)
810
+ # Gather all nodes recursively
811
+ def collect(n: VNode) -> None:
812
+ for ch in n.children:
813
+ if isinstance(ch, VNode):
814
+ all_nodes.append(ch)
815
+ collect(ch)
816
+ collect(root)
817
+
818
+ if nodes:
819
+ for nd in nodes:
820
+ if isinstance(nd, VNode) and nd not in all_nodes:
821
+ self._verify_depth(nd)
822
+ all_nodes.append(nd)
823
+
824
+ node_map = {n.id: n for n in all_nodes if n.id}
825
+
826
+ # Solve viewport pinning declared in props
827
+ for n in all_nodes:
828
+ pin_prop = n.props.get("pin_to_viewport")
829
+ if isinstance(pin_prop, dict):
830
+ anchor = pin_prop.get("anchor", "top-left")
831
+ margin = pin_prop.get("margin", 0.0)
832
+ self.pin_to_viewport(n, vw, vh, anchor=anchor, margin=margin)
833
+
834
+ # Solve relational constraints declared in props
835
+ for n in all_nodes:
836
+ align_prop = n.props.get("align_relative")
837
+ if isinstance(align_prop, dict):
838
+ ref_id = _sanitize_text(align_prop.get("reference_id", ""))
839
+ if ref_id in node_map and ref_id != n.id:
840
+ ref_node = node_map[ref_id]
841
+ rel = align_prop.get("relation", "right_of")
842
+ gap = align_prop.get("gap", 0.0)
843
+ align = align_prop.get("alignment", "start")
844
+ self.align_relative(n, ref_node, relation=rel, gap=gap, alignment=align)
845
+
846
+ # Solve registered constraints with simple topological cycle guard
847
+ visited_targets: set[str] = set()
848
+ for c in self._constraints:
849
+ t_id = c["target_id"]
850
+ r_id = c["reference_id"]
851
+ if t_id == r_id or t_id in visited_targets:
852
+ continue
853
+ if t_id in node_map and r_id in node_map:
854
+ self.align_relative(
855
+ node_map[t_id],
856
+ node_map[r_id],
857
+ relation=c["relation"],
858
+ gap=c["gap"],
859
+ alignment=c["alignment"],
860
+ )
861
+ visited_targets.add(t_id)
862
+
863
+ if root is not None:
864
+ return root.compute_total_bbox()
865
+ elif all_nodes:
866
+ return BoundingBox.enclosing([n.bbox for n in all_nodes])
867
+ return BoundingBox(0.0, 0.0, vw, vh)
868
+
869
+ def solve_layout(
870
+ self,
871
+ root: VNode | dict[str, Any],
872
+ viewport_width: float = 1920.0,
873
+ viewport_height: float = 1080.0,
874
+ ) -> dict[str, Any]:
875
+ """Unified method for dispatcher integration accepting either VNode or dict."""
876
+ with self._lock:
877
+ if isinstance(root, dict):
878
+ vroot = VNode.from_dict(root)
879
+ elif isinstance(root, VNode):
880
+ vroot = root
881
+ else:
882
+ raise TypeError(f"Invalid root type: {type(root).__name__}")
883
+
884
+ bbox = self.solve(vroot, viewport_width, viewport_height)
885
+ return {
886
+ "success": True,
887
+ "bbox": {
888
+ "x": bbox.x,
889
+ "y": bbox.y,
890
+ "width": bbox.width,
891
+ "height": bbox.height,
892
+ "right": bbox.right,
893
+ "bottom": bbox.bottom,
894
+ },
895
+ "viewBox": bbox.to_viewbox(padding=16.0),
896
+ "root": vroot.to_dict(),
897
+ }
898
+
899
+
900
+ # ==============================================================================
901
+ # 6. FABLE VECTOR COMPILER
902
+ # ==============================================================================
903
+
904
+ class FableVectorCompiler:
905
+ """Production-grade vector compiler generating XML-standard SVG with OKLCH, filters, and a11y."""
906
+
907
+ def __init__(self, default_padding: float = 24.0) -> None:
908
+ self.default_padding = max(0.0, _coerce_finite_float(default_padding, 24.0))
909
+
910
+ def _color_to_str(self, val: Any, fallback: str = "none") -> str:
911
+ """Coerce OKLCHColor or string to valid SVG color."""
912
+ if isinstance(val, OKLCHColor):
913
+ return val.to_svg()
914
+ if isinstance(val, str):
915
+ clean = _sanitize_text(val).strip()
916
+ return clean if clean else fallback
917
+ return fallback
918
+
919
+ def compile(
920
+ self,
921
+ root: VNode | dict[str, Any],
922
+ options: dict[str, Any] | None = None,
923
+ ) -> str:
924
+ """Compile a VNode tree into an SVG string conforming strictly to XML standard.
925
+
926
+ Features:
927
+ - ViewBox enclosure computed automatically from node bounding boxes.
928
+ - Micro-grain procedural noise filter (<feTurbulence> + <feColorMatrix>).
929
+ - Embedded OKLCH Linear and Radial gradients.
930
+ - Accessibility attributes (role='img', <title>, <desc>, aria-label).
931
+ """
932
+ opts = options or {}
933
+ if isinstance(root, dict):
934
+ vroot = VNode.from_dict(root)
935
+ elif isinstance(root, VNode):
936
+ vroot = root
937
+ else:
938
+ raise TypeError(f"Expected VNode or dict, got {type(root).__name__}")
939
+
940
+ # Solve layout if not already pinned
941
+ solver = VLayoutSolver()
942
+ solver.solve(vroot)
943
+
944
+ # Enclosing viewBox
945
+ pad = max(0.0, _coerce_finite_float(opts.get("padding", self.default_padding)))
946
+ total_bbox = vroot.compute_total_bbox()
947
+ view_box_str = total_bbox.to_viewbox(padding=pad)
948
+
949
+ # Accessibility metadata
950
+ title_text = _sanitize_text(opts.get("title", vroot.props.get("title", f"Fable Vector: {vroot.id}")))
951
+ desc_text = _sanitize_text(opts.get("desc", vroot.props.get("desc", "Neuro-Symbolic Vector Graphics generated by Fable-Vector Engine")))
952
+
953
+ # Construct XML ElementTree
954
+ svg_attribs = {
955
+ "xmlns": "http://www.w3.org/2000/svg",
956
+ "viewBox": view_box_str,
957
+ "width": "100%",
958
+ "height": "100%",
959
+ "role": "img",
960
+ "aria-label": title_text,
961
+ }
962
+ svg_elem = ET.Element("svg", svg_attribs)
963
+
964
+ # Accessibility title and desc
965
+ title_elem = ET.SubElement(svg_elem, "title")
966
+ title_elem.text = title_text
967
+ desc_elem = ET.SubElement(svg_elem, "desc")
968
+ desc_elem.text = desc_text
969
+
970
+ # <defs> element for procedural noise and gradients
971
+ defs_elem = ET.SubElement(svg_elem, "defs")
972
+ self._build_defs(defs_elem, opts)
973
+
974
+ # Recursively render nodes
975
+ self._render_vnode(svg_elem, vroot)
976
+
977
+ # Convert to well-formed string and verify XML AST parsing
978
+ raw_xml = ET.tostring(svg_elem, encoding="unicode", method="xml")
979
+
980
+ # Verify XML AST well-formedness
981
+ try:
982
+ ET.fromstring(raw_xml)
983
+ except ET.ParseError as err:
984
+ raise ValueError(f"FableVectorCompiler generated malformed XML AST: {err}")
985
+
986
+ return raw_xml
987
+
988
+ def _build_defs(self, defs: ET.Element, opts: dict[str, Any]) -> None:
989
+ """Inject micro-grain noise filter and OKLCH gradients into <defs>."""
990
+ # 1. Micro-grain procedural noise filter (feTurbulence)
991
+ filter_elem = ET.SubElement(defs, "filter", {
992
+ "id": "fable-micro-grain",
993
+ "x": "0%",
994
+ "y": "0%",
995
+ "width": "100%",
996
+ "height": "100%",
997
+ })
998
+ ET.SubElement(filter_elem, "feTurbulence", {
999
+ "type": "fractalNoise",
1000
+ "baseFrequency": "0.85",
1001
+ "numOctaves": "4",
1002
+ "stitchTiles": "stitch",
1003
+ "result": "noise",
1004
+ })
1005
+ ET.SubElement(filter_elem, "feColorMatrix", {
1006
+ "type": "matrix",
1007
+ "values": "0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0",
1008
+ "in": "noise",
1009
+ "result": "grain",
1010
+ })
1011
+ ET.SubElement(filter_elem, "feComposite", {
1012
+ "operator": "in",
1013
+ "in": "grain",
1014
+ "in2": "SourceGraphic",
1015
+ "result": "composite",
1016
+ })
1017
+ ET.SubElement(filter_elem, "feBlend", {
1018
+ "mode": "overlay",
1019
+ "in": "composite",
1020
+ "in2": "SourceGraphic",
1021
+ })
1022
+
1023
+ # 2. Cyber-Obsidian Monolith OKLCH Gradients
1024
+ obsidian_grad = ET.SubElement(defs, "linearGradient", {
1025
+ "id": "fable-grad-obsidian",
1026
+ "x1": "0%",
1027
+ "y1": "0%",
1028
+ "x2": "100%",
1029
+ "y2": "100%",
1030
+ })
1031
+ ET.SubElement(obsidian_grad, "stop", {
1032
+ "offset": "0%",
1033
+ "stop-color": "oklch(0.16 0.03 270)",
1034
+ "stop-opacity": "0.95",
1035
+ })
1036
+ ET.SubElement(obsidian_grad, "stop", {
1037
+ "offset": "100%",
1038
+ "stop-color": "oklch(0.08 0.02 270)",
1039
+ "stop-opacity": "0.98",
1040
+ })
1041
+
1042
+ # Specular rim highlight gradient
1043
+ specular_grad = ET.SubElement(defs, "linearGradient", {
1044
+ "id": "fable-grad-specular",
1045
+ "x1": "0%",
1046
+ "y1": "0%",
1047
+ "x2": "100%",
1048
+ "y2": "0%",
1049
+ })
1050
+ ET.SubElement(specular_grad, "stop", {
1051
+ "offset": "0%",
1052
+ "stop-color": "oklch(1.00 0.00 0 / 0.18)",
1053
+ })
1054
+ ET.SubElement(specular_grad, "stop", {
1055
+ "offset": "100%",
1056
+ "stop-color": "oklch(1.00 0.00 0 / 0.02)",
1057
+ })
1058
+
1059
+ # Accent gradient (Teal-Cyan to Violet)
1060
+ accent_grad = ET.SubElement(defs, "linearGradient", {
1061
+ "id": "fable-grad-accent",
1062
+ "x1": "0%",
1063
+ "y1": "0%",
1064
+ "x2": "100%",
1065
+ "y2": "100%",
1066
+ })
1067
+ ET.SubElement(accent_grad, "stop", {
1068
+ "offset": "0%",
1069
+ "stop-color": "oklch(0.75 0.18 190)",
1070
+ })
1071
+ ET.SubElement(accent_grad, "stop", {
1072
+ "offset": "100%",
1073
+ "stop-color": "oklch(0.55 0.22 260)",
1074
+ })
1075
+
1076
+ def _render_vnode(self, parent_elem: ET.Element, node: VNode) -> None:
1077
+ """Render a VNode and its children into SVG elements according to kind."""
1078
+ g = ET.SubElement(parent_elem, "g", {
1079
+ "id": node.id,
1080
+ "class": f"fable-vnode fable-{node.kind}",
1081
+ })
1082
+
1083
+ props = node.props
1084
+ fill = self._color_to_str(props.get("fill"), fallback="none")
1085
+ stroke = self._color_to_str(props.get("stroke"), fallback="none")
1086
+ stroke_width = str(_coerce_finite_float(props.get("stroke_width", 1.0)))
1087
+
1088
+ if node.kind == "card":
1089
+ rx = str(_coerce_finite_float(props.get("rx", 12.0)))
1090
+ # Default card background to obsidian gradient if not specified
1091
+ card_fill = fill if fill != "none" else "url(#fable-grad-obsidian)"
1092
+ card_stroke = stroke if stroke != "none" else "oklch(0.28 0.02 270 / 0.50)"
1093
+
1094
+ # Main card body with micro-grain filter
1095
+ ET.SubElement(g, "rect", {
1096
+ "x": f"{node.x:.2f}",
1097
+ "y": f"{node.y:.2f}",
1098
+ "width": f"{node.width:.2f}",
1099
+ "height": f"{node.height:.2f}",
1100
+ "rx": rx,
1101
+ "fill": card_fill,
1102
+ "stroke": card_stroke,
1103
+ "stroke-width": stroke_width,
1104
+ "filter": "url(#fable-micro-grain)",
1105
+ })
1106
+
1107
+ # Specular top hairline rim highlight
1108
+ if props.get("specular_rim", True) and node.width > 20:
1109
+ ET.SubElement(g, "line", {
1110
+ "x1": f"{node.x + 8:.2f}",
1111
+ "y1": f"{node.y + 1:.2f}",
1112
+ "x2": f"{node.right - 8:.2f}",
1113
+ "y2": f"{node.y + 1:.2f}",
1114
+ "stroke": "url(#fable-grad-specular)",
1115
+ "stroke-width": "1",
1116
+ "stroke-linecap": "round",
1117
+ })
1118
+
1119
+ elif node.kind == "dial":
1120
+ cx = node.cx
1121
+ cy = node.cy
1122
+ radius = min(node.width, node.height) / 2.0 - 8.0
1123
+ radius = max(2.0, radius)
1124
+ start_angle = _coerce_finite_float(props.get("start_angle", 135.0))
1125
+ end_angle = _coerce_finite_float(props.get("end_angle", 405.0))
1126
+ value = max(0.0, min(1.0, _coerce_finite_float(props.get("value", 0.75))))
1127
+
1128
+ # Background track arc
1129
+ track_d = ParametricGeometry.circular_arc(cx, cy, radius, start_angle, end_angle)
1130
+ ET.SubElement(g, "path", {
1131
+ "d": track_d,
1132
+ "fill": "none",
1133
+ "stroke": self._color_to_str(props.get("track_color"), "oklch(0.20 0.02 270)"),
1134
+ "stroke-width": str(_coerce_finite_float(props.get("track_width", 6.0))),
1135
+ "stroke-linecap": "round",
1136
+ })
1137
+
1138
+ # Value indicator arc
1139
+ val_span = (end_angle - start_angle) * value
1140
+ current_end = start_angle + val_span
1141
+ val_d = ParametricGeometry.circular_arc(cx, cy, radius, start_angle, current_end)
1142
+ ET.SubElement(g, "path", {
1143
+ "d": val_d,
1144
+ "fill": "none",
1145
+ "stroke": self._color_to_str(props.get("dial_color"), "url(#fable-grad-accent)"),
1146
+ "stroke-width": str(_coerce_finite_float(props.get("dial_width", 6.0))),
1147
+ "stroke-linecap": "round",
1148
+ })
1149
+
1150
+ # Value text in center
1151
+ if "label" in props or "value" in props:
1152
+ lbl = _sanitize_text(props.get("label", f"{int(value * 100)}%"))
1153
+ txt = ET.SubElement(g, "text", {
1154
+ "x": f"{cx:.2f}",
1155
+ "y": f"{cy + 4:.2f}",
1156
+ "text-anchor": "middle",
1157
+ "fill": self._color_to_str(props.get("text_color"), "oklch(0.96 0.01 270)"),
1158
+ "font-family": "system-ui, -apple-system, sans-serif",
1159
+ "font-size": "14",
1160
+ "font-weight": "600",
1161
+ })
1162
+ txt.text = lbl
1163
+
1164
+ elif node.kind == "spline":
1165
+ raw_pts = props.get("points", [])
1166
+ tension = _coerce_finite_float(props.get("tension", 0.5))
1167
+ closed = bool(props.get("closed", False))
1168
+ pts: list[tuple[float, float]] = []
1169
+ if isinstance(raw_pts, list):
1170
+ for p in raw_pts:
1171
+ if isinstance(p, (tuple, list)) and len(p) >= 2:
1172
+ pts.append((node.x + _coerce_finite_float(p[0]), node.y + _coerce_finite_float(p[1])))
1173
+
1174
+ d_str = ParametricGeometry.catmull_rom_spline(pts, tension=tension, closed=closed)
1175
+ ET.SubElement(g, "path", {
1176
+ "d": d_str,
1177
+ "fill": fill,
1178
+ "stroke": stroke if stroke != "none" else "url(#fable-grad-accent)",
1179
+ "stroke-width": stroke_width if stroke_width != "0.0" else "2",
1180
+ "stroke-linecap": "round",
1181
+ "stroke-linejoin": "round",
1182
+ })
1183
+
1184
+ elif node.kind == "text":
1185
+ font_size = str(_coerce_finite_float(props.get("font_size", 14.0)))
1186
+ font_weight = _sanitize_text(props.get("font_weight", "500"))
1187
+ font_family = _sanitize_text(props.get("font_family", "system-ui, -apple-system, sans-serif"))
1188
+ text_anchor = _sanitize_text(props.get("text_anchor", "start"))
1189
+ txt_fill = fill if fill != "none" else "oklch(0.96 0.01 270)"
1190
+ raw_content = _sanitize_text(props.get("text", node.id))
1191
+
1192
+ txt_elem = ET.SubElement(g, "text", {
1193
+ "x": f"{node.x:.2f}",
1194
+ "y": f"{node.y + node.height:.2f}" if node.height > 0 else f"{node.y:.2f}",
1195
+ "fill": txt_fill,
1196
+ "font-family": font_family,
1197
+ "font-size": font_size,
1198
+ "font-weight": font_weight,
1199
+ "text-anchor": text_anchor,
1200
+ })
1201
+ txt_elem.text = raw_content
1202
+
1203
+ elif node.kind == "badge":
1204
+ rx = str(min(node.width, node.height) / 2.0)
1205
+ badge_fill = fill if fill != "none" else "oklch(0.20 0.03 270)"
1206
+ badge_stroke = stroke if stroke != "none" else "oklch(0.75 0.18 190 / 0.5)"
1207
+
1208
+ ET.SubElement(g, "rect", {
1209
+ "x": f"{node.x:.2f}",
1210
+ "y": f"{node.y:.2f}",
1211
+ "width": f"{node.width:.2f}",
1212
+ "height": f"{node.height:.2f}",
1213
+ "rx": rx,
1214
+ "fill": badge_fill,
1215
+ "stroke": badge_stroke,
1216
+ "stroke-width": stroke_width,
1217
+ })
1218
+
1219
+ label = _sanitize_text(props.get("label", node.id))
1220
+ txt_elem = ET.SubElement(g, "text", {
1221
+ "x": f"{node.cx:.2f}",
1222
+ "y": f"{node.cy + 4:.2f}",
1223
+ "text-anchor": "middle",
1224
+ "fill": self._color_to_str(props.get("text_color"), "oklch(0.96 0.01 270)"),
1225
+ "font-family": "system-ui, -apple-system, sans-serif",
1226
+ "font-size": "11",
1227
+ "font-weight": "600",
1228
+ })
1229
+ txt_elem.text = label
1230
+
1231
+ elif node.kind == "grid":
1232
+ spacing = max(8.0, _coerce_finite_float(props.get("spacing", 32.0)))
1233
+ grid_stroke = stroke if stroke != "none" else "oklch(0.20 0.01 270 / 0.4)"
1234
+ is_isometric = bool(props.get("isometric", False))
1235
+
1236
+ if is_isometric:
1237
+ # Isometric dot matrix or isometric diamond grid
1238
+ cols = int(node.width / spacing) + 1
1239
+ rows = int(node.height / spacing) + 1
1240
+ for c_idx in range(cols):
1241
+ for r_idx in range(rows):
1242
+ iso_x, iso_y = ParametricGeometry.isometric_project(c_idx * spacing, r_idx * spacing)
1243
+ ET.SubElement(g, "circle", {
1244
+ "cx": f"{node.x + iso_x:.2f}",
1245
+ "cy": f"{node.y + iso_y:.2f}",
1246
+ "r": "1.5",
1247
+ "fill": grid_stroke,
1248
+ })
1249
+ else:
1250
+ # Cartesian orthogonal lines
1251
+ cur_x = node.x
1252
+ while cur_x <= node.right:
1253
+ ET.SubElement(g, "line", {
1254
+ "x1": f"{cur_x:.2f}", "y1": f"{node.y:.2f}",
1255
+ "x2": f"{cur_x:.2f}", "y2": f"{node.bottom:.2f}",
1256
+ "stroke": grid_stroke, "stroke-width": stroke_width,
1257
+ })
1258
+ cur_x += spacing
1259
+
1260
+ cur_y = node.y
1261
+ while cur_y <= node.bottom:
1262
+ ET.SubElement(g, "line", {
1263
+ "x1": f"{node.x:.2f}", "y1": f"{cur_y:.2f}",
1264
+ "x2": f"{node.right:.2f}", "y2": f"{cur_y:.2f}",
1265
+ "stroke": grid_stroke, "stroke-width": stroke_width,
1266
+ })
1267
+ cur_y += spacing
1268
+
1269
+ elif node.kind == "conduit":
1270
+ x1 = _coerce_finite_float(props.get("x1", node.x))
1271
+ y1 = _coerce_finite_float(props.get("y1", node.y))
1272
+ x2 = _coerce_finite_float(props.get("x2", node.right))
1273
+ y2 = _coerce_finite_float(props.get("y2", node.bottom))
1274
+ mid_x = (x1 + x2) / 2.0
1275
+ conduit_d = f"M {x1:.2f} {y1:.2f} C {mid_x:.2f} {y1:.2f}, {mid_x:.2f} {y2:.2f}, {x2:.2f} {y2:.2f}"
1276
+ ET.SubElement(g, "path", {
1277
+ "d": conduit_d,
1278
+ "fill": "none",
1279
+ "stroke": stroke if stroke != "none" else "oklch(0.55 0.22 260)",
1280
+ "stroke-width": stroke_width if stroke_width != "0.0" else "2",
1281
+ "stroke-dasharray": _sanitize_text(props.get("dasharray", "")),
1282
+ })
1283
+
1284
+ # Recursively render children
1285
+ for child in node.children:
1286
+ if isinstance(child, VNode):
1287
+ self._render_vnode(g, child)