exodeui-react-native 1.2.1 → 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 +471 -143
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.2.1",
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;
1261
+ }
1262
+
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 };
1239
1309
  }
1240
1310
 
1241
- private getWorldTransform(objId: string): { x: number, y: number, scaleX: number, scaleY: number, rotation: number } {
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 };
@@ -1535,16 +1649,28 @@ export class ExodeUIEngine {
1535
1649
 
1536
1650
  canvas.save();
1537
1651
  canvas.translate(cx, cy);
1538
- if (rotation !== 0) canvas.rotate(rotation * Math.PI / 180, 0, 0);
1539
- if (scaleX !== 1 || scaleY !== 1) canvas.scale(scaleX, scaleY);
1652
+
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
+
1658
+ if (rotation !== 0) {
1659
+ // React Native Skia rotate expects degrees
1660
+ canvas.rotate(rotation, 0, 0);
1661
+ }
1662
+
1663
+ if (scaleX !== 1 || scaleY !== 1) {
1664
+ canvas.scale(scaleX, scaleY);
1665
+ }
1540
1666
 
1541
1667
  if (this._renderCount % 120 === 1) {
1542
- 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)}`);
1543
1669
  }
1544
1670
 
1545
- if (geometry.type === 'Text') {
1671
+ if (geometry && geometry.type === 'Text') {
1546
1672
  this.renderText(canvas, obj, w, h);
1547
- } else if (geometry.type === 'Image') {
1673
+ } else if (geometry && geometry.type === 'Image') {
1548
1674
  this.renderImage(canvas, obj, w, h);
1549
1675
  } else if (obj.type === 'Component' && (obj as any).variant === 'button') {
1550
1676
  this.renderButton(canvas, obj, w, h);
@@ -1638,7 +1764,7 @@ export class ExodeUIEngine {
1638
1764
  const segments = geom.segments || obj.segments || [];
1639
1765
 
1640
1766
  if (enableSegments && segments.length > 0) {
1641
- let offsetX = -w/2;
1767
+ let offsetX = 0;
1642
1768
  const totalWidth = segments.reduce((acc: number, seg: any) => {
1643
1769
  const font = this.getFont(seg.fontSize || geom.fontSize || 14, seg.fontFamily || geom.fontFamily || 'System');
1644
1770
  return acc + font.getTextWidth(seg.text);
@@ -1646,49 +1772,104 @@ export class ExodeUIEngine {
1646
1772
 
1647
1773
  const align = geom.textAlign || 'left';
1648
1774
  if (align === 'center') offsetX = -totalWidth / 2;
1649
- else if (align === 'right') offsetX = w/2 - totalWidth;
1775
+ else if (align === 'right') offsetX = w / 2 - totalWidth;
1776
+ else offsetX = -w / 2; // Default left
1650
1777
 
1651
1778
  segments.forEach((seg: any) => {
1652
- const font = this.getFont(seg.fontSize || geom.fontSize || 14, seg.fontFamily || geom.fontFamily || 'System');
1653
- const paint = Skia.Paint();
1654
- const col = seg.fill?.color || geom.fill?.color || '#ffffff';
1655
- try { paint.setColor(Skia.Color(col.replace(/\s+/g, ''))); } catch { paint.setColor(Skia.Color('#ffffff')); }
1656
- paint.setAlphaf((state?.opacity ?? 1) * (seg.fill?.opacity ?? 1));
1657
-
1658
- canvas.drawText(seg.text, offsetX, 0, paint, font);
1659
- offsetX += font.getTextWidth(seg.text);
1660
- });
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
+ });
1661
1820
  return;
1662
1821
  }
1663
1822
 
1664
1823
  const text = obj.text || geom.text || '';
1665
1824
  if (!text) return;
1666
1825
 
1826
+ const fontSize = geom.fontSize || 14;
1827
+ const fontFamily = geom.fontFamily || obj.fontFamily || 'Helvetica Neue';
1667
1828
  const paint = Skia.Paint();
1668
- const colorValue = obj.style?.fill?.color || '#ffffff';
1669
- try {
1670
- const skColor = Skia.Color(colorValue.replace(/\s+/g, ''));
1671
- paint.setColor(skColor);
1672
- } catch (e) {
1673
- paint.setColor(Skia.Color('#ffffff'));
1674
- }
1675
-
1676
- const opacity1 = state?.opacity !== undefined ? state.opacity : 1;
1677
- const opacity2 = obj.style?.fill?.opacity !== undefined ? obj.style.fill.opacity : 1;
1678
- paint.setAlphaf(Math.max(0, Math.min(1, opacity1 * opacity2)));
1829
+ const style = state?.style || obj.style || {};
1830
+ const opacity = state?.opacity ?? 1;
1679
1831
 
1680
- const fontSize = geom.fontSize || 14;
1681
- 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')); }
1682
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);
1683
1852
  const align = geom.textAlign || obj.textAlign || 'center';
1684
1853
  const textWidth = font.getTextWidth(text);
1685
- let x = -w/2;
1854
+ let x = 0;
1686
1855
  if (align === 'center') x = -textWidth / 2;
1687
- else if (align === 'right') x = w/2 - textWidth;
1856
+ else if (align === 'right') x = w / 2 - textWidth;
1857
+ else x = -w / 2; // Default left
1688
1858
 
1689
1859
  // Check baseline
1690
- const y = geom.verticalAlign === 'center' || !geom.verticalAlign ? fontSize / 3 : fontSize / 2;
1860
+ const y = geom.verticalAlign === 'center' || !geom.verticalAlign ? fontSize / 2 : fontSize - h / 2;
1691
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
+ }
1692
1873
  }
1693
1874
 
1694
1875
  private getOrDecodeImage(src: string): any {
@@ -1716,21 +1897,74 @@ export class ExodeUIEngine {
1716
1897
  const geom = state?.geometry || obj.geometry;
1717
1898
  const src = obj.src || geom.src || '';
1718
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
+
1719
1921
  const img = this.getOrDecodeImage(src);
1720
1922
  if (img) {
1721
1923
  const paint = Skia.Paint();
1722
- paint.setAlphaf(state?.opacity ?? 1);
1723
- canvas.drawImageRect(
1724
- img,
1725
- { x: 0, y: 0, width: img.width(), height: img.height() },
1726
- { x: -w/2, y: -h/2, width: w, height: h },
1727
- paint
1728
- );
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
+ }
1729
1959
  } else {
1730
1960
  // Fallback placeholder
1731
1961
  const paint = Skia.Paint();
1732
1962
  paint.setColor(Skia.Color('#374151'));
1733
- 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
+ }
1734
1968
  }
1735
1969
  }
1736
1970
 
@@ -2106,48 +2340,84 @@ export class ExodeUIEngine {
2106
2340
  if (!svgContent) return;
2107
2341
 
2108
2342
  // Extract path data from potentially multiple <path d="..."> or d='...' tags
2109
- const pathMatches = svgContent.matchAll(/d=["']([^"']+)["']/g);
2343
+ // Use [\s\S] to match across newlines
2344
+ const pathMatches = svgContent.matchAll(/d=["']([\s\S]+?)["']/g);
2110
2345
  const paths: string[] = [];
2111
2346
  for (const match of pathMatches) {
2112
- if (match[1]) paths.push(match[1]);
2347
+ if (match[1]) paths.push(match[1].trim());
2113
2348
  }
2114
2349
 
2115
2350
  if (paths.length === 0) return;
2116
2351
 
2117
2352
  const state = this.objectStates.get(obj.id);
2118
2353
  const style = state?.style || obj.style || {};
2354
+ const opacity = state?.opacity ?? 1;
2355
+
2356
+ canvas.save();
2357
+ canvas.translate(-w / 2, -h / 2);
2119
2358
 
2120
2359
  paths.forEach(d => {
2121
2360
  try {
2122
2361
  const path = Skia.Path.MakeFromSVGString(d);
2123
2362
  if (!path) return;
2124
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
+
2125
2380
  // Draw fill
2126
2381
  if (style.fill && style.fill.type !== 'None') {
2127
2382
  const paint = Skia.Paint();
2128
2383
  const fillCol = style.fill.color || '#ffffff';
2129
2384
  const fillColStr = typeof fillCol === 'string' ? fillCol.replace(/\s+/g, '') : '#ffffff';
2130
2385
  try { paint.setColor(Skia.Color(fillColStr)); } catch { paint.setColor(Skia.Color('#ffffff')); }
2131
- paint.setAlphaf((state?.opacity ?? 1) * (style.fill.opacity ?? 1));
2386
+ applyStyles(paint);
2132
2387
  paint.setStyle(PaintStyle.Fill);
2133
2388
  canvas.drawPath(path, paint);
2134
2389
  }
2135
2390
 
2136
2391
  // Draw stroke if enabled
2137
- 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') {
2138
2393
  const paint = Skia.Paint();
2139
2394
  const strokeCol = style.stroke.color || '#000000';
2140
2395
  const strokeColStr = typeof strokeCol === 'string' ? strokeCol.replace(/\s+/g, '') : '#000000';
2141
2396
  try { paint.setColor(Skia.Color(strokeColStr)); } catch { paint.setColor(Skia.Color('#000000')); }
2142
2397
  paint.setStrokeWidth(style.stroke.width);
2143
- paint.setAlphaf((state?.opacity ?? 1) * (style.stroke.opacity ?? 1));
2398
+ applyStyles(paint);
2144
2399
  paint.setStyle(PaintStyle.Stroke);
2145
- 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
+ }
2146
2411
  }
2147
2412
  } catch (e) {
2148
- 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
+ }
2149
2417
  }
2150
2418
  });
2419
+
2420
+ canvas.restore();
2151
2421
  }
2152
2422
 
2153
2423
  private renderLineGraph(canvas: any, geom: any, w: number, h: number) {
@@ -2304,27 +2574,53 @@ export class ExodeUIEngine {
2304
2574
  }
2305
2575
  private renderShape(canvas: SkCanvas, obj: ShapeObject, w: number, h: number) {
2306
2576
  const state = this.objectStates.get(obj.id);
2307
- const geometry = state.geometry || obj.geometry;
2308
- const style = state.style || obj.style;
2577
+ const geometry = state?.geometry || obj.geometry || {};
2578
+ const style = state?.style || obj.style || {};
2309
2579
 
2310
2580
  const path = Skia.Path.Make();
2311
- if (geometry.type === 'Rectangle') {
2312
- const rect = { x: -w/2, y: -h/2, width: w, height: h };
2313
- if (state.cornerRadius !== undefined || geometry.corner_radius !== undefined) {
2314
- const rawCr = state.cornerRadius ?? geometry.corner_radius;
2315
- const cr = Array.isArray(rawCr) ? rawCr[0] : Number(rawCr);
2316
- 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);
2317
2613
  } else {
2318
2614
  path.addRect(rect);
2319
2615
  }
2320
- } else if (geometry.type === 'Ellipse') {
2321
- path.addOval({ x: -w/2, y: -h/2, width: w, height: h });
2322
- } else if (geometry.type === 'Triangle') {
2323
- path.moveTo(0, -h/2);
2324
- path.lineTo(w/2, h/2);
2325
- path.lineTo(-w/2, h/2);
2616
+ } else if (geometry && geometry.type === 'Ellipse') {
2617
+ path.addOval(rect);
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);
2326
2622
  path.close();
2327
- } else if (geometry.type === 'Star') {
2623
+ } else if (geometry && geometry.type === 'Star') {
2328
2624
  const ir = geometry.inner_radius || 20;
2329
2625
  const or = geometry.outer_radius || 50;
2330
2626
  const sp = geometry.points || 5;
@@ -2337,6 +2633,18 @@ export class ExodeUIEngine {
2337
2633
  else path.lineTo(px, py);
2338
2634
  }
2339
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
2340
2648
  }
2341
2649
 
2342
2650
  if (style.fill) {
@@ -2346,14 +2654,14 @@ export class ExodeUIEngine {
2346
2654
  if (style.fill.type === 'LinearGradient' && style.fill.stops && style.fill.stops.length >= 2) {
2347
2655
  const colors = style.fill.stops.map((s: any) => Skia.Color(s.color.replace(/\s+/g, '')));
2348
2656
  const offsets = style.fill.stops.map((s: any) => s.offset);
2349
- const start = style.fill.start ? { x: -w/2 + style.fill.start[0] * w, y: -h/2 + style.fill.start[1] * h } : { x: -w/2, y: 0 };
2350
- const end = style.fill.end ? { x: -w/2 + style.fill.end[0] * w, y: -h/2 + style.fill.end[1] * h } : { x: w/2, 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 };
2351
2659
  paint.setShader(Skia.Shader.MakeLinearGradient(Skia.Point(start.x, start.y), Skia.Point(end.x, end.y), colors, offsets, TileMode.Clamp));
2352
2660
  hasShader = true;
2353
2661
  } else if (style.fill.type === 'RadialGradient' && style.fill.stops && style.fill.stops.length >= 2) {
2354
2662
  const colors = style.fill.stops.map((s: any) => Skia.Color(s.color.replace(/\s+/g, '')));
2355
2663
  const offsets = style.fill.stops.map((s: any) => s.offset);
2356
- const center = style.fill.center ? { x: -w/2 + style.fill.center[0] * w, y: -h/2 + style.fill.center[1] * h } : { x: 0, y: 0 };
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 };
2357
2665
  const radius = (style.fill.radius || 0.5) * Math.max(w, h);
2358
2666
  paint.setShader(Skia.Shader.MakeRadialGradient(Skia.Point(center.x, center.y), radius, colors, offsets, TileMode.Clamp));
2359
2667
  hasShader = true;
@@ -2361,7 +2669,7 @@ export class ExodeUIEngine {
2361
2669
  const img = this.getOrDecodeImage(style.fill.url);
2362
2670
  if (img) {
2363
2671
  const matrix = Skia.Matrix();
2364
- matrix.translate(-w/2, -h/2);
2672
+ matrix.translate(-w / 2, -h / 2);
2365
2673
  matrix.scale(w / img.width(), h / img.height());
2366
2674
  paint.setShader(img.makeShaderOptions(0, 0, 0, 0, matrix));
2367
2675
  hasShader = true;
@@ -2379,7 +2687,7 @@ export class ExodeUIEngine {
2379
2687
 
2380
2688
  // Apply clipping for Frame types before drawing
2381
2689
  if (obj.type === 'Frame' || (obj as any).clipContent) {
2382
- canvas.clipRect({ x: -w/2, y: -h/2, width: w, height: h }, ClipOp.Intersect, true);
2690
+ canvas.clipRect(rect, ClipOp.Intersect, true);
2383
2691
  }
2384
2692
 
2385
2693
  if (style.shadow && style.shadow.opacity > 0) {
@@ -2394,17 +2702,37 @@ export class ExodeUIEngine {
2394
2702
  if (style.blur && style.blur.amount > 0) {
2395
2703
  paint.setMaskFilter(Skia.MaskFilter.MakeBlur(BlurStyle.Normal, style.blur.amount, true));
2396
2704
  }
2397
- canvas.drawPath(path, paint);
2705
+ if (!path.isEmpty()) {
2706
+ canvas.drawPath(path, paint);
2707
+ }
2398
2708
  }
2399
- if (style.stroke) {
2709
+ // Draw Stroke
2710
+ if (style.stroke && style.stroke.width > 0 && style.stroke.isEnabled !== false && style.stroke.type !== 'None') {
2400
2711
  const paint = Skia.Paint();
2401
- const strokeCol = style.stroke.color;
2402
- const strokeColStr = typeof strokeCol === 'string' ? strokeCol.replace(/\s+/g, '') : '#000000';
2403
- try { paint.setColor(Skia.Color(strokeColStr)); } catch { paint.setColor(Skia.Color('#000000')); }
2404
- paint.setStrokeWidth(style.stroke.width ?? 1);
2405
- paint.setAlphaf(Math.max(0, Math.min(1, (state.opacity ?? 1) * (style.stroke.opacity ?? 1))));
2406
2712
  paint.setStyle(PaintStyle.Stroke);
2407
- 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
+ }
2408
2736
  }
2409
2737
  }
2410
2738
  }