exodeui-react-native 1.3.0 → 1.3.2

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 (3) hide show
  1. package/README.md +60 -2
  2. package/package.json +1 -1
  3. package/src/engine.ts +464 -148
package/README.md CHANGED
@@ -30,7 +30,7 @@ function App() {
30
30
  }
31
31
  ```
32
32
 
33
- ## API
33
+ ## API Reference
34
34
 
35
35
  ### `<ExodeUIView />`
36
36
 
@@ -38,7 +38,65 @@ function App() {
38
38
  |------|------|---------|-------------|
39
39
  | `artboard` | `object` | `undefined` | The JSON animation data object. |
40
40
  | `style` | `ViewStyle` | `-` | Styles for the container view. |
41
- | `onReady` | `(engine: any) => void` | `undefined` | Callback fired when the engine is loaded. |
41
+ | `onReady` | `(engine: ExodeUIEngine) => void` | `undefined` | Callback fired when the engine is initialized. |
42
+
43
+ ---
44
+
45
+ ### `ExodeUIEngine` (Accessed via `onReady`)
46
+
47
+ The `engine` instance allows you to interact with the animation, respond to events, and update data at runtime.
48
+
49
+ #### Component Listeners
50
+ Register callbacks for specific interactive components:
51
+
52
+ - `setButtonClickCallback(cb: (name, id) => void)`: Fired when a button is clicked.
53
+ - `setToggleCallback(cb: (name, checked) => void)`: Fired when a toggle switch changes state.
54
+ - `setInputChangeCallback(cb: (name, text) => void)`: Fired when an InputBox text is modified.
55
+ - `setGraphPointClickCallback(cb: (event) => void)`: Fired when a point on a LineGraph is clicked. Provides `pointIndex`, `value`, and `label`.
56
+ - `setTriggerCallback(cb: (trigger, animation) => void)`: Fired when a state machine trigger is activated.
57
+
58
+ #### Runtime Updaters
59
+ Modify artboard state and data dynamically:
60
+
61
+ - `updateInput(nameOrId, value)`: Updates a specific state machine input (Number, Boolean, or Trigger).
62
+ - `updateGraphData(nameOrId, data: number[])`: Updates the dataset for a LineGraph component.
63
+ - `updateObjectOptions(id, options)`: Modifies component-specific options (e.g., list items, dropdown options).
64
+ - `updateConstraint(objectId, index, properties)`: Adjusts constraints (e.g., spring stiffness/damping) at runtime.
65
+
66
+ #### Advanced Functions
67
+ - `setLayout(fit, alignment)`: Change the artboard fit (`Contain`, `Cover`, `Fill`) and alignment.
68
+ - `getActiveStateIds(layerName)`: Returns the currently active state IDs for a state machine layer.
69
+ - `reset()`: Resets all object states and inputs to their artboard defaults.
70
+
71
+ ---
72
+
73
+ ## Examples
74
+
75
+ ### Responding to a Button Click
76
+ ```tsx
77
+ <ExodeUIView
78
+ artboard={myArtboard}
79
+ onReady={(engine) => {
80
+ engine.setButtonClickCallback((name, id) => {
81
+ console.log(`Button "${name}" clicked!`);
82
+ });
83
+ }}
84
+ />
85
+ ```
86
+
87
+ ### Feeding Real-time Data to a Graph
88
+ ```tsx
89
+ <ExodeUIView
90
+ artboard={myArtboard}
91
+ onReady={(engine) => {
92
+ // Update graph every second
93
+ setInterval(() => {
94
+ const nextData = getLiveSensorData();
95
+ engine.updateGraphData("LiveGraph", nextData);
96
+ }, 1000);
97
+ }}
98
+ />
99
+ ```
42
100
 
43
101
  ## License
44
102
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "exodeui-react-native",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "React Native runtime for ExodeUI animations",
5
5
  "main": "index.js",
6
6
  "files": [
package/src/engine.ts CHANGED
@@ -42,6 +42,9 @@ export class ExodeUIEngine {
42
42
  private justFiredTriggers: Set<string> = new Set();
43
43
  private _lastPointerPos: { x: number; y: number } | null = null;
44
44
  private _prevPointerPos: { x: number; y: number } | null = null;
45
+ private _reportedErrors: Set<string> = new Set();
46
+ private activeStates: Map<string, string> = new Map(); // LayerName -> StateId
47
+ private activeTriggers: Map<string, any> = new Map();
45
48
 
46
49
  // Interaction State
47
50
  private focusedId: string | null = null;
@@ -204,11 +207,14 @@ export class ExodeUIEngine {
204
207
  rotation: transform.rotation ?? 0,
205
208
  scale_x: transform.scale_x ?? 1,
206
209
  scale_y: transform.scale_y ?? 1,
207
- width: (obj as any).width || (obj.geometry as any).width || 100,
208
- height: (obj as any).height || (obj.geometry as any).height || 100,
209
- cornerRadius: (obj as any).cornerRadius ?? (obj as any).corner_radius ?? 0,
210
- opacity: obj.opacity !== undefined ? obj.opacity : 1,
211
- visible: obj.visible !== undefined ? obj.visible : (obj.isVisible !== undefined ? obj.isVisible : true),
210
+ width: (obj as any).width ?? (obj.geometry as any)?.width ?? ((obj.geometry?.type === 'Line') ? (obj.geometry as any).length : 100),
211
+ height: (obj as any).height ?? (obj.geometry as any)?.height ?? ((obj.geometry?.type === 'Line') ? 0.01 : 100),
212
+ // cornerRadius lives in geometry.corner_radius for Shape objects, obj.cornerRadius for Frame/Group
213
+ cornerRadius: (obj.geometry as any)?.corner_radius || (obj.geometry as any)?.cornerRadius || (obj as any).cornerRadius || 0,
214
+ // Opacity is typically in transform.opacity in .exode files
215
+ opacity: (transform as any).opacity ?? obj.opacity ?? 1,
216
+ // Use transform.visible or isVisible as fallback
217
+ visible: (transform as any).visible ?? obj.visible ?? (obj as any).isVisible ?? true,
212
218
  blendMode: obj.blendMode || 'Normal',
213
219
  style: JSON.parse(JSON.stringify(obj.style || {})),
214
220
  geometry: JSON.parse(JSON.stringify(obj.geometry || {})),
@@ -598,14 +604,6 @@ export class ExodeUIEngine {
598
604
  return value !== undefined ? value : 0;
599
605
  }
600
606
 
601
- private activeTriggers: Map<string, {
602
- triggerId: string;
603
- animation: SDKAnimation;
604
- time: number;
605
- phase: 'entry' | 'hold' | 'exit';
606
- elapsedHold: number;
607
- }> = new Map();
608
-
609
607
  advance(dt: number) {
610
608
  if (!this.artboard) return;
611
609
 
@@ -664,9 +662,14 @@ export class ExodeUIEngine {
664
662
  state.time += dt;
665
663
  if (state.time >= state.animation.duration) {
666
664
  const trigger = this.artboard?.objects.find(o => o.id === objectId)?.triggers?.find(t => t.id === state.triggerId);
667
- state.phase = 'hold';
668
- state.elapsedHold = 0;
669
- state.time = state.animation.duration;
665
+ if (trigger && (trigger as any).holdUntilRelease) {
666
+ state.phase = 'hold';
667
+ state.elapsedHold = 0;
668
+ state.time = state.animation.duration;
669
+ } else {
670
+ state.phase = 'exit';
671
+ state.time = 0;
672
+ }
670
673
  }
671
674
  this.applyAnimation(state.animation, state.time);
672
675
  } else if (state.phase === 'hold') {
@@ -792,6 +795,10 @@ export class ExodeUIEngine {
792
795
 
793
796
  handlePointerInput(type: string, canvasX: number, canvasY: number, canvasWidth: number, canvasHeight: number) {
794
797
  if (!this.artboard) return;
798
+
799
+ if (type === 'click' || type === 'PointerDown') {
800
+ console.log(`[ExodeUIEngine] TOUCH RECEIVED: type=${type}, canvasX=${canvasX.toFixed(1)}, canvasY=${canvasY.toFixed(1)}`);
801
+ }
795
802
 
796
803
  const transform = this.calculateTransform(
797
804
  canvasWidth, canvasHeight,
@@ -800,8 +807,18 @@ export class ExodeUIEngine {
800
807
  this.layout.alignment
801
808
  );
802
809
 
803
- const artboardX = ((canvasX - transform.tx) / transform.scaleX) - (this.artboard.width / 2);
804
- const artboardY = ((canvasY - transform.ty) / transform.scaleY) - (this.artboard.height / 2);
810
+ // Map canvas-relative pixels to Artboard-relative pixels
811
+ // 1. Subtract viewport offset
812
+ const rx = canvasX - transform.tx;
813
+ const ry = canvasY - transform.ty;
814
+
815
+ // 2. Scale to artboard size
816
+ const ax = rx / transform.scaleX;
817
+ const ay = ry / transform.scaleY;
818
+
819
+ // 3. Convert from Top-Left origin to Center origin (-W/2 to W/2)
820
+ const artboardX = ax - (this.artboard.width / 2);
821
+ const artboardY = ay - (this.artboard.height / 2);
805
822
 
806
823
  this._prevPointerPos = this._lastPointerPos;
807
824
  this._lastPointerPos = { x: artboardX, y: artboardY };
@@ -842,20 +859,27 @@ export class ExodeUIEngine {
842
859
  return false;
843
860
  };
844
861
 
845
- let topHit: any = null;
862
+ let topInteractiveHit: any = null;
863
+ let topVisualHit: any = null; // Keep track of the topmost visual hit
846
864
  for (let i = this.artboard.objects.length - 1; i >= 0; i--) {
847
865
  const obj = this.artboard.objects[i];
848
866
  const state = this.objectStates.get(obj.id);
849
867
  if (state && state.visible !== false && state.opacity > 0 && this.hitTest(obj, x, y)) {
850
- if (!topHit) topHit = obj;
868
+ if (!topVisualHit) { // Assign the first visual hit found (which is the topmost)
869
+ topVisualHit = obj;
870
+ }
851
871
  if (objectHandlesEvent(obj, type)) {
852
- hitObj = obj;
853
- break;
872
+ topInteractiveHit = obj;
873
+ console.log(`[ExodeUIEngine] Interactive Hit: ${obj.name || obj.id} (${obj.type}) at ${x.toFixed(1)},${y.toFixed(1)}`);
874
+ break; // Stop at the first interactive hit (topmost interactive)
854
875
  }
855
876
  }
856
877
  }
878
+ hitObj = topInteractiveHit;
857
879
 
858
- if (!hitObj && topHit) hitObj = topHit;
880
+ if (!hitObj && topVisualHit) { // If no interactive hit, use the topmost visual hit
881
+ hitObj = topVisualHit;
882
+ }
859
883
 
860
884
  if (hitObj) {
861
885
  console.log(`[ExodeUIEngine] Hit object: ${hitObj.name || hitObj.id} (${hitObj.type})`);
@@ -902,7 +926,7 @@ export class ExodeUIEngine {
902
926
  const opts = state.options || {};
903
927
  const w = state.width || (activeObj.geometry as any)?.width || 160;
904
928
  const h = state.height || (activeObj.geometry as any)?.height || 40;
905
- const world = this.getWorldTransform(activeObj.id);
929
+ const world = this.getWorldPos(activeObj.id);
906
930
 
907
931
  if (hitId !== this.activeDropdownId) {
908
932
  // Check if click is inside the expanded options list
@@ -1046,7 +1070,7 @@ export class ExodeUIEngine {
1046
1070
  const state = this.objectStates.get(targetObj.id);
1047
1071
  const objW = state?.width || geom?.width || 200;
1048
1072
  const objH = state?.height || geom?.height || 150;
1049
- const world = this.getWorldTransform(targetObj.id);
1073
+ const world = this.getWorldPos(targetObj.id);
1050
1074
  const axisMarginL = 30;
1051
1075
  const axisMarginB = 20;
1052
1076
  const plotW = objW - axisMarginL;
@@ -1118,7 +1142,7 @@ export class ExodeUIEngine {
1118
1142
 
1119
1143
  private updateSliderValueFromPointer(obj: any, x: number, y: number) {
1120
1144
  const state = this.objectStates.get(obj.id);
1121
- const world = this.getWorldTransform(obj.id);
1145
+ const world = this.getWorldPos(obj.id);
1122
1146
  const opts = state?.options || obj.options || {};
1123
1147
  const w = obj.geometry?.width || 200;
1124
1148
 
@@ -1192,7 +1216,7 @@ export class ExodeUIEngine {
1192
1216
  let padVal = opts.padding ?? 0;
1193
1217
  if (typeof padVal !== 'number') padVal = padVal[0] || 0;
1194
1218
 
1195
- const worldTransform = this.getWorldTransform(obj.id);
1219
+ const worldTransform = this.getWorldPos(obj.id);
1196
1220
  const dx = x - worldTransform.x;
1197
1221
  const dy = y - worldTransform.y;
1198
1222
  const rad = -(worldTransform.rotation) * (Math.PI / 180);
@@ -1225,53 +1249,116 @@ export class ExodeUIEngine {
1225
1249
  const state = this.objectStates.get(obj.id);
1226
1250
  if (!state || state.visible === false) return false;
1227
1251
 
1228
- const world = this.getWorldTransform(obj.id);
1229
- const w = state.width || (obj.geometry as any).width || 100;
1230
- const h = state.height || (obj.geometry as any).height || 100;
1231
-
1232
- const dx = x - world.x;
1233
- const dy = y - world.y;
1252
+ // Transform artboard coordinates into object-local coordinates by
1253
+ // recursively applying inverse transforms from root to current object.
1254
+ const local = this.toLocal(obj.id, x, y);
1234
1255
 
1235
- // Basic AABB check in world space
1236
- // Note: Full matrix-based hit test would be better for rotated objects
1237
- return Math.abs(dx) <= (w * Math.abs(world.scaleX)) / 2 &&
1238
- Math.abs(dy) <= (h * Math.abs(world.scaleY)) / 2;
1256
+ const w = state.width || (obj.geometry as any)?.width || 100;
1257
+ const h = state.height || (obj.geometry as any)?.height || 100;
1258
+
1259
+ // In local space, origin is (0,0) and bounds are [-w/2, -h/2, w/2, h/2]
1260
+ return Math.abs(local.x) <= w / 2 && Math.abs(local.y) <= h / 2;
1239
1261
  }
1240
1262
 
1241
- private getWorldTransform(objId: string): { x: number, y: number, scaleX: number, scaleY: number, rotation: number } {
1263
+ /**
1264
+ * Transforms a point from Artboard space to Local space of the specified object.
1265
+ */
1266
+ private toLocal(objId: string, x: number, y: number): { x: number, y: number } {
1267
+ const obj = this.artboard?.objects.find(o => o.id === objId);
1268
+ if (!obj) return { x, y };
1269
+
1270
+ // Build path from root to object
1271
+ const path: ShapeObject[] = [];
1272
+ let curr: any = obj;
1273
+ const processedIds = new Set();
1274
+ while (curr && !processedIds.has(curr.id)) {
1275
+ processedIds.add(curr.id);
1276
+ path.unshift(curr);
1277
+ if (!curr.parentId || curr.parentId === 'root') break;
1278
+ curr = this.artboard?.objects.find(o => o.id === curr.parentId);
1279
+ }
1280
+
1281
+ let lx = x;
1282
+ let ly = y;
1283
+
1284
+ for (const node of path) {
1285
+ const s = this.objectStates.get(node.id);
1286
+ const t = node.transform || { x: 0, y: 0, rotation: 0, scale_x: 1, scale_y: 1 };
1287
+
1288
+ const nx = s?.x ?? t.x ?? 0;
1289
+ const ny = s?.y ?? t.y ?? 0;
1290
+ const nr = (s?.rotation ?? t.rotation ?? 0) * Math.PI / 180;
1291
+ const nsX = s?.scale_x ?? t.scale_x ?? 1;
1292
+ const nsY = s?.scale_y ?? t.scale_y ?? 1;
1293
+
1294
+ // Inverse Translate
1295
+ const dx = lx - nx;
1296
+ const dy = ly - ny;
1297
+
1298
+ // Inverse Rotate
1299
+ const cos = Math.cos(-nr);
1300
+ const sin = Math.sin(-nr);
1301
+ const rx = dx * cos - dy * sin;
1302
+ const ry = dx * sin + dy * cos;
1303
+
1304
+ // Inverse Scale
1305
+ lx = rx / (nsX || 1);
1306
+ ly = ry / (nsY || 1);
1307
+ }
1308
+ return { x: lx, y: ly };
1309
+ }
1310
+
1311
+ private getWorldMatrix(objId: string): any {
1242
1312
  const state = this.objectStates.get(objId);
1243
1313
  const obj = this.artboard?.objects.find(o => o.id === objId);
1244
- if (!obj) return { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0 };
1314
+ if (!obj) return Skia.Matrix();
1245
1315
 
1246
1316
  const transform = obj.transform || { x: 0, y: 0, rotation: 0, scale_x: 1, scale_y: 1 };
1247
- const lx = state?.x !== undefined ? state.x : transform.x;
1248
- const ly = state?.y !== undefined ? state.y : transform.y;
1249
- const lsX = state?.scale_x !== undefined ? state.scale_x : (transform.scale_x ?? 1);
1250
- const lsY = state?.scale_y !== undefined ? state.scale_y : (transform.scale_y ?? 1);
1251
- const lRot = state?.rotation !== undefined ? state.rotation : transform.rotation;
1317
+ const lx = state?.x ?? transform.x ?? 0;
1318
+ const ly = state?.y ?? transform.y ?? 0;
1319
+ const lsX = state?.scale_x ?? transform.scale_x ?? 1;
1320
+ const lsY = state?.scale_y ?? transform.scale_y ?? 1;
1321
+ const lRot = state?.rotation ?? transform.rotation ?? 0;
1322
+
1323
+ const matrix = Skia.Matrix();
1324
+ matrix.translate(lx, ly);
1325
+ matrix.rotate(lRot * Math.PI / 180);
1326
+ matrix.scale(lsX, lsY);
1252
1327
 
1253
1328
  if (!obj.parentId || obj.parentId === 'root') {
1254
- return { x: lx, y: ly, scaleX: lsX, scaleY: lsY, rotation: lRot };
1329
+ return matrix;
1255
1330
  }
1256
1331
 
1257
- const parentWorld = this.getWorldTransform(obj.parentId);
1258
- const rad = parentWorld.rotation * (Math.PI / 180);
1259
- const cos = Math.cos(rad);
1260
- const sin = Math.sin(rad);
1261
-
1262
- const sx = state.x * parentWorld.scaleX;
1263
- const sy = state.y * parentWorld.scaleY;
1332
+ const parentMatrix = this.getWorldMatrix(obj.parentId);
1333
+ parentMatrix.concat(matrix);
1334
+ return parentMatrix;
1335
+ }
1264
1336
 
1265
- const worldX = parentWorld.x + (sx * cos - sy * sin);
1266
- const worldY = parentWorld.y + (sx * sin + sy * cos);
1337
+ private mapPoint(matrix: any, x: number, y: number) {
1338
+ // Handle different Skia versions
1339
+ if (matrix.mapXY) {
1340
+ const p = matrix.mapXY(x, y);
1341
+ return { x: p.x, y: p.y };
1342
+ }
1343
+ // Manual fallback for SkMatrix arrays [m0, m1, m2, m3, m4, m5, m6, m7, m8]
1344
+ const m = matrix as any;
1345
+ const px = (m[0] ?? 1) * x + (m[1] ?? 0) * y + (m[2] ?? 0);
1346
+ const py = (m[3] ?? 0) * x + (m[4] ?? 1) * y + (m[5] ?? 0);
1347
+ const pw = (m[6] ?? 0) * x + (m[7] ?? 0) * y + (m[8] ?? 1) || 1;
1348
+ return { x: px / pw, y: py / pw };
1349
+ }
1267
1350
 
1268
- return {
1269
- x: worldX,
1270
- y: worldY,
1271
- scaleX: (state.scale_x ?? 1) * parentWorld.scaleX,
1272
- scaleY: (state.scale_y ?? 1) * parentWorld.scaleY,
1273
- rotation: parentWorld.rotation + (state.rotation ?? 0)
1274
- };
1351
+ private getWorldPos(objId: string) {
1352
+ const matrix = this.getWorldMatrix(objId);
1353
+ const center = this.mapPoint(matrix, 0, 0);
1354
+ const unit = this.mapPoint(matrix, 1, 0);
1355
+
1356
+ const dx = unit.x - center.x;
1357
+ const dy = unit.y - center.y;
1358
+ const rotation = Math.atan2(dy, dx) * 180 / Math.PI;
1359
+ const scaleX = Math.sqrt(dx*dx + dy*dy);
1360
+
1361
+ return { x: center.x, y: center.y, rotation, scaleX };
1275
1362
  }
1276
1363
 
1277
1364
  private applyAnimation(anim: SDKAnimation, time: number) {
@@ -1423,10 +1510,36 @@ export class ExodeUIEngine {
1423
1510
  // Center the Artboard Origin
1424
1511
  canvas.translate(abWidth / 2, abHeight / 2);
1425
1512
 
1426
- // Clip to artboard bounds
1513
+ // Clip to artboard bounds (honoring corner radius)
1514
+ const abState = this.artboard.objects.find(o => o.id === this.artboard?.objects[0]?.id && o.type === 'Frame')
1515
+ ? this.objectStates.get(this.artboard.objects.find(o => o.type === 'Frame')?.id || '')
1516
+ : null;
1517
+ let abCornerR = 0;
1518
+ let abRRect: any = null;
1519
+ const rawAbCr = abState?.cornerRadius;
1520
+ if (rawAbCr) {
1521
+ if (Array.isArray(rawAbCr) && rawAbCr.length >= 4) {
1522
+ abRRect = {
1523
+ rect: { x: -abWidth / 2, y: -abHeight / 2, width: abWidth, height: abHeight },
1524
+ topLeft: Skia.Point(rawAbCr[0], rawAbCr[0]),
1525
+ topRight: Skia.Point(rawAbCr[1], rawAbCr[1]),
1526
+ bottomRight: Skia.Point(rawAbCr[2], rawAbCr[2]),
1527
+ bottomLeft: Skia.Point(rawAbCr[3], rawAbCr[3]),
1528
+ };
1529
+ } else {
1530
+ abCornerR = Array.isArray(rawAbCr) ? (rawAbCr[0] || 0) : Number(rawAbCr);
1531
+ }
1532
+ }
1533
+
1427
1534
  const clipPath = Skia.Path.Make();
1428
- clipPath.addRect({ x: -abWidth / 2, y: -abHeight / 2, width: abWidth, height: abHeight });
1429
- canvas.clipPath(clipPath, 1, true); // 1 = Intersect
1535
+ if (abRRect) {
1536
+ clipPath.addRRect(abRRect);
1537
+ } else if (abCornerR > 0) {
1538
+ clipPath.addRRect(Skia.RRectXY({ x: -abWidth / 2, y: -abHeight / 2, width: abWidth, height: abHeight }, abCornerR, abCornerR));
1539
+ } else {
1540
+ clipPath.addRect({ x: -abWidth / 2, y: -abHeight / 2, width: abWidth, height: abHeight });
1541
+ }
1542
+ canvas.clipPath(clipPath, ClipOp.Intersect, true);
1430
1543
 
1431
1544
  this.artboard.objects?.forEach((obj: ShapeObject) => {
1432
1545
  if (!obj.parentId) {
@@ -1518,12 +1631,13 @@ export class ExodeUIEngine {
1518
1631
 
1519
1632
  private renderObject(canvas: any, obj: ShapeObject) {
1520
1633
  const state = this.objectStates.get(obj.id);
1521
- const isVisible = state?.visible !== undefined ? state.visible : obj.isVisible !== false;
1634
+ const isVisible = (state?.visible !== undefined ? state.visible : obj.visible) ?? obj.isVisible ?? true;
1522
1635
  if (!isVisible) return;
1523
1636
 
1524
- const geometry = state?.geometry || obj.geometry;
1525
- const w = state?.width || (geometry as any).width || 0;
1526
- const h = state?.height || (geometry as any).height || 0;
1637
+ const geometry = state?.geometry || obj.geometry || {};
1638
+ // Use ?? so that 0 is a valid value, fall back through multiple sources
1639
+ const w = (state?.width ?? (obj as any).width ?? (geometry as any).width) || 0;
1640
+ const h = (state?.height ?? (obj as any).height ?? (geometry as any).height) || 0;
1527
1641
 
1528
1642
  // Resolve transform: use state if present, otherwise use object defaults
1529
1643
  const transform = obj.transform || { x: 0, y: 0, rotation: 0, scale_x: 1, scale_y: 1 };
@@ -1536,26 +1650,27 @@ export class ExodeUIEngine {
1536
1650
  canvas.save();
1537
1651
  canvas.translate(cx, cy);
1538
1652
 
1539
- // Rotate around the center of the object
1653
+ // Apply Global Opacity
1654
+ const opacity = state?.opacity ?? 1;
1655
+ // Note: SkCanvas doesn't have a direct globalAlpha, we usually apply it to paints.
1656
+ // However, some renderers might benefit from a saveLayer for complex blend modes.
1657
+
1540
1658
  if (rotation !== 0) {
1541
- canvas.translate(w / 2, h / 2);
1542
- canvas.rotate(rotation * Math.PI / 180, 0, 0);
1543
- canvas.translate(-w / 2, -h / 2);
1659
+ // React Native Skia rotate expects degrees
1660
+ canvas.rotate(rotation, 0, 0);
1544
1661
  }
1545
1662
 
1546
1663
  if (scaleX !== 1 || scaleY !== 1) {
1547
- canvas.translate(w / 2, h / 2);
1548
1664
  canvas.scale(scaleX, scaleY);
1549
- canvas.translate(-w / 2, -h / 2);
1550
1665
  }
1551
1666
 
1552
1667
  if (this._renderCount % 120 === 1) {
1553
- console.log(`[ExodeUIEngine] - Drawing object: ${obj.name || obj.id} (${obj.type}), pos: ${cx.toFixed(1)},${cy.toFixed(1)}`);
1668
+ console.log(`[ExodeUIEngine] - Drawing object: ${obj.name || obj.id} (${obj.type}), pos: ${cx.toFixed(1)},${cy.toFixed(1)}, opacity: ${opacity.toFixed(2)}`);
1554
1669
  }
1555
1670
 
1556
- if (geometry.type === 'Text') {
1671
+ if (geometry && geometry.type === 'Text') {
1557
1672
  this.renderText(canvas, obj, w, h);
1558
- } else if (geometry.type === 'Image') {
1673
+ } else if (geometry && geometry.type === 'Image') {
1559
1674
  this.renderImage(canvas, obj, w, h);
1560
1675
  } else if (obj.type === 'Component' && (obj as any).variant === 'button') {
1561
1676
  this.renderButton(canvas, obj, w, h);
@@ -1656,52 +1771,105 @@ export class ExodeUIEngine {
1656
1771
  }, 0);
1657
1772
 
1658
1773
  const align = geom.textAlign || 'left';
1659
- if (align === 'center') offsetX = (w - totalWidth) / 2;
1660
- else if (align === 'right') offsetX = w - totalWidth;
1774
+ if (align === 'center') offsetX = -totalWidth / 2;
1775
+ else if (align === 'right') offsetX = w / 2 - totalWidth;
1776
+ else offsetX = -w / 2; // Default left
1661
1777
 
1662
1778
  segments.forEach((seg: any) => {
1663
- const font = this.getFont(seg.fontSize || geom.fontSize || 14, seg.fontFamily || geom.fontFamily || 'System');
1664
- const paint = Skia.Paint();
1665
- const col = seg.fill?.color || geom.fill?.color || '#ffffff';
1666
- try { paint.setColor(Skia.Color(col.replace(/\s+/g, ''))); } catch { paint.setColor(Skia.Color('#ffffff')); }
1667
- paint.setAlphaf((state?.opacity ?? 1) * (seg.fill?.opacity ?? 1));
1668
-
1669
- const fontSize = seg.fontSize || geom.fontSize || 14;
1670
- const y = fontSize; // Basic baseline
1671
- canvas.drawText(seg.text, offsetX, y, paint, font);
1672
- offsetX += font.getTextWidth(seg.text);
1673
- });
1779
+ const font = this.getFont(seg.fontSize || geom.fontSize || 14, seg.fontFamily || geom.fontFamily || 'System');
1780
+ const paint = Skia.Paint();
1781
+ const style = state?.style || obj.style || {};
1782
+ const opacity = state?.opacity ?? 1;
1783
+
1784
+ const col = seg.fill?.color || geom.fill?.color || '#ffffff';
1785
+ const colStr = typeof col === 'string' ? col.replace(/\s+/g, '') : '#ffffff';
1786
+ try { paint.setColor(Skia.Color(colStr)); } catch { paint.setColor(Skia.Color('#ffffff')); }
1787
+
1788
+ // Apply shadow/blur/opacity
1789
+ paint.setAlphaf(opacity * (seg.fill?.opacity ?? geom.fill?.opacity ?? 1));
1790
+ if (style.shadow && style.shadow.opacity > 0) {
1791
+ const alphaHex = Math.round(style.shadow.opacity * 255).toString(16).padStart(2, '0');
1792
+ const colorWithAlpha = `${style.shadow.color}${alphaHex}`;
1793
+ paint.setImageFilter(Skia.ImageFilter.MakeDropShadow(
1794
+ style.shadow.offsetX, style.shadow.offsetY,
1795
+ style.shadow.blur, style.shadow.blur,
1796
+ Skia.Color(colorWithAlpha)
1797
+ ));
1798
+ }
1799
+ if (style.blur && style.blur.amount > 0) {
1800
+ paint.setMaskFilter(Skia.MaskFilter.MakeBlur(BlurStyle.Normal, style.blur.amount, true));
1801
+ }
1802
+
1803
+ const fontSize = seg.fontSize || geom.fontSize || 14;
1804
+ const y = fontSize - h / 2; // Basic baseline relative to center
1805
+ canvas.drawText(seg.text, offsetX, y, paint, font);
1806
+
1807
+ // Add stroke support for segments
1808
+ if (style.stroke && style.stroke.width > 0 && style.stroke.isEnabled !== false && style.stroke.type !== 'None') {
1809
+ const strokePaint = Skia.Paint();
1810
+ strokePaint.setStyle(PaintStyle.Stroke);
1811
+ strokePaint.setStrokeWidth(style.stroke.width);
1812
+ const sc = style.stroke.color || '#000000';
1813
+ try { strokePaint.setColor(Skia.Color(sc.replace(/\s+/g, ''))); } catch { strokePaint.setColor(Skia.Color('#000000')); }
1814
+ strokePaint.setAlphaf(opacity * (style.stroke.opacity ?? 1));
1815
+ canvas.drawText(seg.text, offsetX, y, strokePaint, font);
1816
+ }
1817
+
1818
+ offsetX += font.getTextWidth(seg.text);
1819
+ });
1674
1820
  return;
1675
1821
  }
1676
1822
 
1677
1823
  const text = obj.text || geom.text || '';
1678
1824
  if (!text) return;
1679
1825
 
1826
+ const fontSize = geom.fontSize || 14;
1827
+ const fontFamily = geom.fontFamily || obj.fontFamily || 'Helvetica Neue';
1680
1828
  const paint = Skia.Paint();
1681
- const colorValue = obj.style?.fill?.color || '#ffffff';
1682
- try {
1683
- const skColor = Skia.Color(colorValue.replace(/\s+/g, ''));
1684
- paint.setColor(skColor);
1685
- } catch (e) {
1686
- paint.setColor(Skia.Color('#ffffff'));
1687
- }
1688
-
1689
- const opacity1 = state?.opacity !== undefined ? state.opacity : 1;
1690
- const opacity2 = obj.style?.fill?.opacity !== undefined ? obj.style.fill.opacity : 1;
1691
- paint.setAlphaf(Math.max(0, Math.min(1, opacity1 * opacity2)));
1829
+ const style = state?.style || obj.style || {};
1830
+ const opacity = state?.opacity ?? 1;
1692
1831
 
1693
- const fontSize = geom.fontSize || 14;
1694
- const font = this.getFont(fontSize, geom.fontFamily || obj.fontFamily || 'Helvetica Neue');
1832
+ const fillCol = style.fill?.color || geom.fill?.color || obj.color || geom.color || '#000000';
1833
+ const fillColStr = typeof fillCol === 'string' ? fillCol.replace(/\s+/g, '') : '#000000';
1834
+ try { paint.setColor(Skia.Color(fillColStr)); } catch { paint.setColor(Skia.Color('#000000')); }
1695
1835
 
1836
+ // Apply shadow/blur/opacity
1837
+ paint.setAlphaf(opacity * (style.fill?.opacity ?? 1));
1838
+ if (style.shadow && style.shadow.opacity > 0) {
1839
+ const alphaHex = Math.round(style.shadow.opacity * 255).toString(16).padStart(2, '0');
1840
+ const colorWithAlpha = `${style.shadow.color}${alphaHex}`;
1841
+ paint.setImageFilter(Skia.ImageFilter.MakeDropShadow(
1842
+ style.shadow.offsetX, style.shadow.offsetY,
1843
+ style.shadow.blur, style.shadow.blur,
1844
+ Skia.Color(colorWithAlpha)
1845
+ ));
1846
+ }
1847
+ if (style.blur && style.blur.amount > 0) {
1848
+ paint.setMaskFilter(Skia.MaskFilter.MakeBlur(BlurStyle.Normal, style.blur.amount, true));
1849
+ }
1850
+
1851
+ const font = this.getFont(fontSize, fontFamily);
1696
1852
  const align = geom.textAlign || obj.textAlign || 'center';
1697
1853
  const textWidth = font.getTextWidth(text);
1698
1854
  let x = 0;
1699
- if (align === 'center') x = (w - textWidth) / 2;
1700
- else if (align === 'right') x = w - textWidth;
1855
+ if (align === 'center') x = -textWidth / 2;
1856
+ else if (align === 'right') x = w / 2 - textWidth;
1857
+ else x = -w / 2; // Default left
1701
1858
 
1702
1859
  // Check baseline
1703
- const y = geom.verticalAlign === 'center' || !geom.verticalAlign ? (h + fontSize/2) / 2 : fontSize;
1860
+ const y = geom.verticalAlign === 'center' || !geom.verticalAlign ? fontSize / 2 : fontSize - h / 2;
1704
1861
  canvas.drawText(text, x, y, paint, font);
1862
+
1863
+ // Add stroke support for regular text
1864
+ if (style.stroke && style.stroke.width > 0 && style.stroke.isEnabled !== false && style.stroke.type !== 'None') {
1865
+ const strokePaint = Skia.Paint();
1866
+ strokePaint.setStyle(PaintStyle.Stroke);
1867
+ strokePaint.setStrokeWidth(style.stroke.width);
1868
+ const sc = style.stroke.color || '#000000';
1869
+ try { strokePaint.setColor(Skia.Color(sc.replace(/\s+/g, ''))); } catch { strokePaint.setColor(Skia.Color('#000000')); }
1870
+ strokePaint.setAlphaf(opacity * (style.stroke.opacity ?? 1));
1871
+ canvas.drawText(text, x, y, strokePaint, font);
1872
+ }
1705
1873
  }
1706
1874
 
1707
1875
  private getOrDecodeImage(src: string): any {
@@ -1729,21 +1897,74 @@ export class ExodeUIEngine {
1729
1897
  const geom = state?.geometry || obj.geometry;
1730
1898
  const src = obj.src || geom.src || '';
1731
1899
 
1900
+ // Resolve corner radius:
1901
+ const rawCrGeo = geom?.corner_radius ?? geom?.cornerRadius;
1902
+ const rawCrState = state?.cornerRadius;
1903
+ const rawCr = (rawCrState !== undefined && rawCrState !== null) ? rawCrState : rawCrGeo;
1904
+
1905
+ let rRect: any = null;
1906
+ if (rawCr !== undefined && rawCr !== null) {
1907
+ if (Array.isArray(rawCr) && rawCr.length >= 4) {
1908
+ rRect = {
1909
+ rect: Skia.XYWHRect(-w / 2, -h / 2, w, h),
1910
+ topLeft: Skia.Point(rawCr[0], rawCr[0]),
1911
+ topRight: Skia.Point(rawCr[1], rawCr[1]),
1912
+ bottomRight: Skia.Point(rawCr[2], rawCr[2]),
1913
+ bottomLeft: Skia.Point(rawCr[3], rawCr[3]),
1914
+ };
1915
+ } else {
1916
+ const r = Array.isArray(rawCr) ? (rawCr[0] || 0) : Number(rawCr);
1917
+ if (r > 0) rRect = Skia.RRectXY({ x: -w/2, y: -h/2, width: w, height: h }, r, r);
1918
+ }
1919
+ }
1920
+
1732
1921
  const img = this.getOrDecodeImage(src);
1733
1922
  if (img) {
1734
1923
  const paint = Skia.Paint();
1735
- paint.setAlphaf(state?.opacity ?? 1);
1736
- canvas.drawImageRect(
1737
- img,
1738
- Skia.XYWHRect(0, 0, img.width(), img.height()),
1739
- Skia.XYWHRect(0, 0, w, h),
1740
- paint
1741
- );
1924
+ const opacity = state?.opacity ?? 1;
1925
+ paint.setAlphaf(opacity);
1926
+
1927
+ const style = state?.style || obj.style || {};
1928
+ if (style.shadow && style.shadow.opacity > 0) {
1929
+ const alphaHex = Math.round(style.shadow.opacity * 255).toString(16).padStart(2, '0');
1930
+ const colorWithAlpha = `${style.shadow.color}${alphaHex}`;
1931
+ paint.setImageFilter(Skia.ImageFilter.MakeDropShadow(
1932
+ style.shadow.offsetX, style.shadow.offsetY,
1933
+ style.shadow.blur, style.shadow.blur,
1934
+ Skia.Color(colorWithAlpha)
1935
+ ));
1936
+ }
1937
+ if (style.blur && style.blur.amount > 0) {
1938
+ paint.setMaskFilter(Skia.MaskFilter.MakeBlur(BlurStyle.Normal, style.blur.amount, true));
1939
+ }
1940
+
1941
+ if (rRect) {
1942
+ canvas.save();
1943
+ canvas.clipRRect(rRect, ClipOp.Intersect, true);
1944
+ canvas.drawImageRect(
1945
+ img,
1946
+ Skia.XYWHRect(0, 0, img.width(), img.height()),
1947
+ Skia.XYWHRect(-w / 2, -h / 2, w, h),
1948
+ paint
1949
+ );
1950
+ canvas.restore();
1951
+ } else {
1952
+ canvas.drawImageRect(
1953
+ img,
1954
+ Skia.XYWHRect(0, 0, img.width(), img.height()),
1955
+ Skia.XYWHRect(-w / 2, -h / 2, w, h),
1956
+ paint
1957
+ );
1958
+ }
1742
1959
  } else {
1743
1960
  // Fallback placeholder
1744
1961
  const paint = Skia.Paint();
1745
1962
  paint.setColor(Skia.Color('#374151'));
1746
- canvas.drawRect({ x: -w/2, y: -h/2, width: w, height: h }, paint);
1963
+ if (rRect) {
1964
+ canvas.drawRRect(rRect, paint);
1965
+ } else {
1966
+ canvas.drawRect({ x: -w/2, y: -h/2, width: w, height: h }, paint);
1967
+ }
1747
1968
  }
1748
1969
  }
1749
1970
 
@@ -2119,48 +2340,84 @@ export class ExodeUIEngine {
2119
2340
  if (!svgContent) return;
2120
2341
 
2121
2342
  // Extract path data from potentially multiple <path d="..."> or d='...' tags
2122
- const pathMatches = svgContent.matchAll(/d=["']([^"']+)["']/g);
2343
+ // Use [\s\S] to match across newlines
2344
+ const pathMatches = svgContent.matchAll(/d=["']([\s\S]+?)["']/g);
2123
2345
  const paths: string[] = [];
2124
2346
  for (const match of pathMatches) {
2125
- if (match[1]) paths.push(match[1]);
2347
+ if (match[1]) paths.push(match[1].trim());
2126
2348
  }
2127
2349
 
2128
2350
  if (paths.length === 0) return;
2129
2351
 
2130
2352
  const state = this.objectStates.get(obj.id);
2131
2353
  const style = state?.style || obj.style || {};
2354
+ const opacity = state?.opacity ?? 1;
2355
+
2356
+ canvas.save();
2357
+ canvas.translate(-w / 2, -h / 2);
2132
2358
 
2133
2359
  paths.forEach(d => {
2134
2360
  try {
2135
2361
  const path = Skia.Path.MakeFromSVGString(d);
2136
2362
  if (!path) return;
2137
2363
 
2364
+ const applyStyles = (paint: SkPaint) => {
2365
+ paint.setAlphaf(opacity * (style.fill?.opacity ?? style.stroke?.opacity ?? 1));
2366
+ if (style.shadow && style.shadow.opacity > 0) {
2367
+ const alphaHex = Math.round(style.shadow.opacity * 255).toString(16).padStart(2, '0');
2368
+ const colorWithAlpha = `${style.shadow.color}${alphaHex}`;
2369
+ paint.setImageFilter(Skia.ImageFilter.MakeDropShadow(
2370
+ style.shadow.offsetX, style.shadow.offsetY,
2371
+ style.shadow.blur, style.shadow.blur,
2372
+ Skia.Color(colorWithAlpha)
2373
+ ));
2374
+ }
2375
+ if (style.blur && style.blur.amount > 0) {
2376
+ paint.setMaskFilter(Skia.MaskFilter.MakeBlur(BlurStyle.Normal, style.blur.amount, true));
2377
+ }
2378
+ };
2379
+
2138
2380
  // Draw fill
2139
2381
  if (style.fill && style.fill.type !== 'None') {
2140
2382
  const paint = Skia.Paint();
2141
2383
  const fillCol = style.fill.color || '#ffffff';
2142
2384
  const fillColStr = typeof fillCol === 'string' ? fillCol.replace(/\s+/g, '') : '#ffffff';
2143
2385
  try { paint.setColor(Skia.Color(fillColStr)); } catch { paint.setColor(Skia.Color('#ffffff')); }
2144
- paint.setAlphaf((state?.opacity ?? 1) * (style.fill.opacity ?? 1));
2386
+ applyStyles(paint);
2145
2387
  paint.setStyle(PaintStyle.Fill);
2146
2388
  canvas.drawPath(path, paint);
2147
2389
  }
2148
2390
 
2149
2391
  // Draw stroke if enabled
2150
- if (style.stroke && style.stroke.width > 0 && style.stroke.isEnabled !== false) {
2392
+ if (style.stroke && style.stroke.width > 0 && style.stroke.isEnabled !== false && style.stroke.type !== 'None') {
2151
2393
  const paint = Skia.Paint();
2152
2394
  const strokeCol = style.stroke.color || '#000000';
2153
2395
  const strokeColStr = typeof strokeCol === 'string' ? strokeCol.replace(/\s+/g, '') : '#000000';
2154
2396
  try { paint.setColor(Skia.Color(strokeColStr)); } catch { paint.setColor(Skia.Color('#000000')); }
2155
2397
  paint.setStrokeWidth(style.stroke.width);
2156
- paint.setAlphaf((state?.opacity ?? 1) * (style.stroke.opacity ?? 1));
2398
+ applyStyles(paint);
2157
2399
  paint.setStyle(PaintStyle.Stroke);
2158
- canvas.drawPath(path, paint);
2400
+
2401
+ const align = style.stroke.strokeAlign || 'center';
2402
+ if (align === 'inside') {
2403
+ canvas.save();
2404
+ canvas.clipPath(path, ClipOp.Intersect, true);
2405
+ paint.setStrokeWidth(style.stroke.width * 2);
2406
+ canvas.drawPath(path, paint);
2407
+ canvas.restore();
2408
+ } else {
2409
+ canvas.drawPath(path, paint);
2410
+ }
2159
2411
  }
2160
2412
  } catch (e) {
2161
- console.warn(`[ExodeUIEngine] Failed to parse SVG path for ${obj.id}:`, e);
2413
+ if (!this._reportedErrors.has(obj.id)) {
2414
+ console.warn(`[ExodeUIEngine] Failed to parse SVG path for ${obj.id}:`, e);
2415
+ this._reportedErrors.add(obj.id);
2416
+ }
2162
2417
  }
2163
2418
  });
2419
+
2420
+ canvas.restore();
2164
2421
  }
2165
2422
 
2166
2423
  private renderLineGraph(canvas: any, geom: any, w: number, h: number) {
@@ -2317,39 +2574,77 @@ export class ExodeUIEngine {
2317
2574
  }
2318
2575
  private renderShape(canvas: SkCanvas, obj: ShapeObject, w: number, h: number) {
2319
2576
  const state = this.objectStates.get(obj.id);
2320
- const geometry = state.geometry || obj.geometry;
2321
- const style = state.style || obj.style;
2577
+ const geometry = state?.geometry || obj.geometry || {};
2578
+ const style = state?.style || obj.style || {};
2322
2579
 
2323
2580
  const path = Skia.Path.Make();
2324
- const rect = Skia.XYWHRect(0, 0, w, h);
2325
- if (geometry.type === 'Rectangle') {
2326
- if (state.cornerRadius !== undefined || geometry.corner_radius !== undefined) {
2327
- const rawCr = state.cornerRadius ?? geometry.corner_radius;
2328
- const cr = Array.isArray(rawCr) ? rawCr[0] : Number(rawCr);
2329
- path.addRRect(Skia.RRectXY(rect, cr, cr));
2581
+ const rect = Skia.XYWHRect(-w / 2, -h / 2, w, h);
2582
+
2583
+ // Resolve corner radius from all possible sources:
2584
+ const rawCrState = state?.cornerRadius;
2585
+ const rawCrGeo = geometry?.corner_radius ?? geometry?.cornerRadius;
2586
+ const rawCr = (rawCrState !== undefined && rawCrState !== null) ? rawCrState : rawCrGeo;
2587
+
2588
+ let rRect: any = null;
2589
+ if (rawCr !== undefined && rawCr !== null) {
2590
+ if (Array.isArray(rawCr) && rawCr.length >= 4) {
2591
+ // Support non-uniform corner radii [tl, tr, br, bl]
2592
+ rRect = {
2593
+ rect,
2594
+ topLeft: Skia.Point(rawCr[0], rawCr[0]),
2595
+ topRight: Skia.Point(rawCr[1], rawCr[1]),
2596
+ bottomRight: Skia.Point(rawCr[2], rawCr[2]),
2597
+ bottomLeft: Skia.Point(rawCr[3], rawCr[3]),
2598
+ };
2599
+ } else {
2600
+ const r = Array.isArray(rawCr) ? (rawCr[0] || 0) : Number(rawCr);
2601
+ if (r > 0) rRect = Skia.RRectXY(rect, r, r);
2602
+ }
2603
+ }
2604
+
2605
+ const geoOrObjType = (geometry && geometry.type) || 'Unknown';
2606
+ if (this._renderCount % 120 === 1 && !geometry) {
2607
+ // console.log(`[ExodeUIEngine] Drawing Object without geometry: ${obj.name || obj.id}`);
2608
+ }
2609
+
2610
+ if (geoOrObjType === 'Rectangle') {
2611
+ if (rRect) {
2612
+ path.addRRect(rRect);
2330
2613
  } else {
2331
2614
  path.addRect(rect);
2332
2615
  }
2333
- } else if (geometry.type === 'Ellipse') {
2616
+ } else if (geometry && geometry.type === 'Ellipse') {
2334
2617
  path.addOval(rect);
2335
- } else if (geometry.type === 'Triangle') {
2336
- path.moveTo(w / 2, 0);
2337
- path.lineTo(w, h);
2338
- path.lineTo(0, h);
2618
+ } else if (geometry && geometry.type === 'Triangle') {
2619
+ path.moveTo(0, -h / 2);
2620
+ path.lineTo(w / 2, h / 2);
2621
+ path.lineTo(-w / 2, h / 2);
2339
2622
  path.close();
2340
- } else if (geometry.type === 'Star') {
2623
+ } else if (geometry && geometry.type === 'Star') {
2341
2624
  const ir = geometry.inner_radius || 20;
2342
2625
  const or = geometry.outer_radius || 50;
2343
2626
  const sp = geometry.points || 5;
2344
2627
  for (let i = 0; i < sp * 2; i++) {
2345
2628
  const a = (i * Math.PI / sp) - (Math.PI / 2);
2346
2629
  const rad = i % 2 === 0 ? or : ir;
2347
- const px = w/2 + rad * Math.cos(a);
2348
- const py = h/2 + rad * Math.sin(a);
2630
+ const px = rad * Math.cos(a);
2631
+ const py = rad * Math.sin(a);
2349
2632
  if (i === 0) path.moveTo(px, py);
2350
2633
  else path.lineTo(px, py);
2351
2634
  }
2352
2635
  path.close();
2636
+ } else if (geometry.type === 'Polygon' && geometry.points) {
2637
+ geometry.points.forEach((p: any, i: number) => {
2638
+ if (i === 0) path.moveTo(p.x - w/2, p.y - h/2);
2639
+ else path.lineTo(p.x - w/2, p.y - h/2);
2640
+ });
2641
+ if (geometry.isClosed !== false) path.close();
2642
+ } else if (geometry.type === 'Line') {
2643
+ const len = geometry.length || 0;
2644
+ path.moveTo(-len / 2, 0);
2645
+ path.lineTo(len / 2, 0);
2646
+ } else {
2647
+ // No default geometry added to path for unknown types
2353
2648
  }
2354
2649
 
2355
2650
  if (style.fill) {
@@ -2359,14 +2654,14 @@ export class ExodeUIEngine {
2359
2654
  if (style.fill.type === 'LinearGradient' && style.fill.stops && style.fill.stops.length >= 2) {
2360
2655
  const colors = style.fill.stops.map((s: any) => Skia.Color(s.color.replace(/\s+/g, '')));
2361
2656
  const offsets = style.fill.stops.map((s: any) => s.offset);
2362
- const start = style.fill.start ? { x: style.fill.start[0] * w, y: style.fill.start[1] * h } : { x: 0, y: 0 };
2363
- const end = style.fill.end ? { x: style.fill.end[0] * w, y: style.fill.end[1] * h } : { x: w, y: 0 };
2657
+ const start = style.fill.start ? { x: (style.fill.start[0] - 0.5) * w, y: (style.fill.start[1] - 0.5) * h } : { x: -w/2, y: -h/2 };
2658
+ const end = style.fill.end ? { x: (style.fill.end[0] - 0.5) * w, y: (style.fill.end[1] - 0.5) * h } : { x: w/2, y: -h/2 };
2364
2659
  paint.setShader(Skia.Shader.MakeLinearGradient(Skia.Point(start.x, start.y), Skia.Point(end.x, end.y), colors, offsets, TileMode.Clamp));
2365
2660
  hasShader = true;
2366
2661
  } else if (style.fill.type === 'RadialGradient' && style.fill.stops && style.fill.stops.length >= 2) {
2367
2662
  const colors = style.fill.stops.map((s: any) => Skia.Color(s.color.replace(/\s+/g, '')));
2368
2663
  const offsets = style.fill.stops.map((s: any) => s.offset);
2369
- const center = style.fill.center ? { x: style.fill.center[0] * w, y: style.fill.center[1] * h } : { x: w/2, y: h/2 };
2664
+ const center = style.fill.center ? { x: (style.fill.center[0] - 0.5) * w, y: (style.fill.center[1] - 0.5) * h } : { x: 0, y: 0 };
2370
2665
  const radius = (style.fill.radius || 0.5) * Math.max(w, h);
2371
2666
  paint.setShader(Skia.Shader.MakeRadialGradient(Skia.Point(center.x, center.y), radius, colors, offsets, TileMode.Clamp));
2372
2667
  hasShader = true;
@@ -2374,6 +2669,7 @@ export class ExodeUIEngine {
2374
2669
  const img = this.getOrDecodeImage(style.fill.url);
2375
2670
  if (img) {
2376
2671
  const matrix = Skia.Matrix();
2672
+ matrix.translate(-w / 2, -h / 2);
2377
2673
  matrix.scale(w / img.width(), h / img.height());
2378
2674
  paint.setShader(img.makeShaderOptions(0, 0, 0, 0, matrix));
2379
2675
  hasShader = true;
@@ -2406,17 +2702,37 @@ export class ExodeUIEngine {
2406
2702
  if (style.blur && style.blur.amount > 0) {
2407
2703
  paint.setMaskFilter(Skia.MaskFilter.MakeBlur(BlurStyle.Normal, style.blur.amount, true));
2408
2704
  }
2409
- canvas.drawPath(path, paint);
2705
+ if (!path.isEmpty()) {
2706
+ canvas.drawPath(path, paint);
2707
+ }
2410
2708
  }
2411
- if (style.stroke) {
2709
+ // Draw Stroke
2710
+ if (style.stroke && style.stroke.width > 0 && style.stroke.isEnabled !== false && style.stroke.type !== 'None') {
2412
2711
  const paint = Skia.Paint();
2413
- const strokeCol = style.stroke.color;
2414
- const strokeColStr = typeof strokeCol === 'string' ? strokeCol.replace(/\s+/g, '') : '#000000';
2415
- try { paint.setColor(Skia.Color(strokeColStr)); } catch { paint.setColor(Skia.Color('#000000')); }
2416
- paint.setStrokeWidth(style.stroke.width ?? 1);
2417
- paint.setAlphaf(Math.max(0, Math.min(1, (state.opacity ?? 1) * (style.stroke.opacity ?? 1))));
2418
2712
  paint.setStyle(PaintStyle.Stroke);
2419
- canvas.drawPath(path, paint);
2713
+ paint.setStrokeWidth(style.stroke.width);
2714
+
2715
+ const col = style.stroke.color || '#000000';
2716
+ const colStr = typeof col === 'string' ? col.replace(/\s+/g, '') : '#000000';
2717
+ try { paint.setColor(Skia.Color(colStr)); } catch { paint.setColor(Skia.Color('#000000')); }
2718
+
2719
+ const opacity = state?.opacity ?? 1;
2720
+ paint.setAlphaf(opacity * (style.stroke.opacity ?? 1));
2721
+
2722
+ const align = style.stroke.strokeAlign || 'center';
2723
+ if (align === 'inside') {
2724
+ canvas.save();
2725
+ canvas.clipPath(path, ClipOp.Intersect, true);
2726
+ paint.setStrokeWidth(style.stroke.width * 2);
2727
+ if (!path.isEmpty()) {
2728
+ canvas.drawPath(path, paint);
2729
+ }
2730
+ canvas.restore();
2731
+ } else {
2732
+ if (!path.isEmpty()) {
2733
+ canvas.drawPath(path, paint);
2734
+ }
2735
+ }
2420
2736
  }
2421
2737
  }
2422
2738
  }