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.
- fable_compressor.py +356 -0
- fable_engine/__init__.py +1 -0
- fable_engine/actions/__init__.py +291 -0
- fable_engine/actions/cas.py +182 -0
- fable_engine/actions/deliberation.py +523 -0
- fable_engine/actions/fleet.py +807 -0
- fable_engine/actions/lifecycle.py +298 -0
- fable_engine/actions/scrapers.py +116 -0
- fable_engine/actions/system3.py +815 -0
- fable_engine/browser.py +824 -0
- fable_engine/cas.py +974 -0
- fable_engine/fable_session.json +510 -0
- fable_engine/guards.py +283 -0
- fable_engine/schema.py +714 -0
- fable_engine/scrapers/__init__.py +32 -0
- fable_engine/scrapers/arxiv.py +115 -0
- fable_engine/scrapers/base.py +386 -0
- fable_engine/scrapers/github.py +129 -0
- fable_engine/scrapers/reddit.py +154 -0
- fable_engine/scrapers/web.py +120 -0
- fable_engine/scrapers/x.py +125 -0
- fable_engine/scrapers/youtube.py +132 -0
- fable_engine/server.py +414 -0
- fable_engine/session.py +1819 -0
- fable_engine/test_server.py +1362 -0
- fable_engine/updater.py +541 -0
- fable_engine-1.3.1.dist-info/LICENSE +22 -0
- fable_engine-1.3.1.dist-info/METADATA +173 -0
- fable_engine-1.3.1.dist-info/RECORD +104 -0
- fable_engine-1.3.1.dist-info/WHEEL +5 -0
- fable_engine-1.3.1.dist-info/entry_points.txt +5 -0
- fable_engine-1.3.1.dist-info/top_level.txt +6 -0
- fable_mode/__init__.py +3 -0
- fable_mode/__main__.py +4 -0
- fable_mode/adapters.py +1014 -0
- fable_mode/installer.py +553 -0
- fable_mode/launcher.py +437 -0
- fable_mode/manifest.py +142 -0
- fable_mode/resources.json +114 -0
- fable_mode/safety.py +103 -0
- fable_mode_entry.py +10 -0
- fable_v2/__init__.py +146 -0
- fable_v2/adapters.py +151 -0
- fable_v2/coder_fleet/__init__.py +100 -0
- fable_v2/coder_fleet/ast_tools.py +158 -0
- fable_v2/coder_fleet/compute.py +199 -0
- fable_v2/coder_fleet/design_engine.py +1316 -0
- fable_v2/coder_fleet/diagnostics.py +293 -0
- fable_v2/coder_fleet/fleet_dispatcher.py +214 -0
- fable_v2/coder_fleet/mock_auditor.py +306 -0
- fable_v2/coder_fleet/mutation.py +216 -0
- fable_v2/coder_fleet/property_oracle.py +260 -0
- fable_v2/coder_fleet/receipt_attestor.py +122 -0
- fable_v2/coder_fleet/red_team_swarm.py +908 -0
- fable_v2/coder_fleet/test_harness.py +198 -0
- fable_v2/coder_fleet/vector_engine.py +1287 -0
- fable_v2/coder_fleet/visual.py +357 -0
- fable_v2/coder_fleet/workspace.py +153 -0
- fable_v2/cortical/__init__.py +20 -0
- fable_v2/cortical/plasticity_engine.py +992 -0
- fable_v2/execution_broker.py +811 -0
- fable_v2/proof_engine.py +1141 -0
- fable_v2/protocol.py +485 -0
- fable_v2/runtime.py +1010 -0
- fable_v2/system3/__init__.py +204 -0
- fable_v2/system3/causal.py +558 -0
- fable_v2/system3/dialectical.py +577 -0
- fable_v2/system3/evolution.py +503 -0
- fable_v2/system3/executive.py +338 -0
- fable_v2/system3/free_energy.py +479 -0
- fable_v2/system3/hyperbolic.py +555 -0
- fable_v2/system3/induction.py +336 -0
- fable_v2/system3/kripke.py +548 -0
- fable_v2/system3/oracle.py +745 -0
- fable_v2/verifiers.py +72 -0
- tests/__init__.py +1 -0
- tests/test_anti_loop_circuit_breaker.py +64 -0
- tests/test_auto_updater.py +407 -0
- tests/test_coder_fleet.py +535 -0
- tests/test_delegation_compiler.py +54 -0
- tests/test_descriptor_boundaries.py +126 -0
- tests/test_design_engine.py +603 -0
- tests/test_epistemic_evidence_validator.py +66 -0
- tests/test_execution_broker.py +233 -0
- tests/test_fable_v2.py +406 -0
- tests/test_fleet_transitions.py +116 -0
- tests/test_fsm_redteam_evolution.py +406 -0
- tests/test_goal_rubric_and_pipeline.py +367 -0
- tests/test_hebbian_plasticity.py +585 -0
- tests/test_packaging_runtime.py +194 -0
- tests/test_proof_engine.py +259 -0
- tests/test_red_team_swarm.py +645 -0
- tests/test_redteam_remediation.py +169 -0
- tests/test_registration_transaction.py +375 -0
- tests/test_requested_regressions.py +467 -0
- tests/test_scrapers.py +370 -0
- tests/test_server_actions.py +93 -0
- tests/test_server_frontier_actions.py +269 -0
- tests/test_server_protocol.py +88 -0
- tests/test_stealth_browser.py +970 -0
- tests/test_system3.py +381 -0
- tests/test_system3_deep_integration.py +385 -0
- tests/test_system3_frontier.py +436 -0
- 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)
|