onejs-react 0.1.28 → 0.1.29

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "onejs-react",
3
- "version": "0.1.28",
3
+ "version": "0.1.29",
4
4
  "description": "React 19 renderer for OneJS (Unity UI Toolkit)",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -368,6 +368,23 @@ export function createMockCS() {
368
368
  }
369
369
  },
370
370
  },
371
+ // Mirrors the real CS.OneJS.NodeBridge: resolve element handles and
372
+ // delegate to the same tree ops the slow path would have called.
373
+ NodeBridge: {
374
+ Add: (parentHandle: number, childHandle: number) => {
375
+ const parent = findElementByHandle(parentHandle);
376
+ const child = findElementByHandle(childHandle);
377
+ if (parent && child) parent.Add(child);
378
+ },
379
+ Insert: (parentHandle: number, index: number, childHandle: number) => {
380
+ const parent = findElementByHandle(parentHandle);
381
+ const child = findElementByHandle(childHandle);
382
+ if (parent && child) parent.Insert(index, child);
383
+ },
384
+ RemoveFromHierarchy: (childHandle: number) => {
385
+ findElementByHandle(childHandle)?.RemoveFromHierarchy();
386
+ },
387
+ },
371
388
  },
372
389
  };
373
390
  }
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect, beforeEach, vi } from "vitest"
2
+ import { Painter, batchedVisualContent } from "../painter"
3
+
4
+ // Opcodes mirrored from painter.ts / PainterBridge.cs. These assertions are the
5
+ // guard that the JS recorder and the C# replay engine stay in sync.
6
+ const OP = {
7
+ BeginPath: 1, ClosePath: 2, MoveTo: 3, LineTo: 4, Arc: 5, ArcTo: 6,
8
+ Bezier: 7, Quad: 8, Fill: 9, Stroke: 10, LineWidth: 11, FillColor: 12,
9
+ StrokeColor: 13, LineCap: 14, LineJoin: 15, MiterLimit: 16,
10
+ DashOffset: 17, DashPattern: 18,
11
+ }
12
+
13
+ describe("Painter command buffer", () => {
14
+ let lastBuffer: Float32Array | null
15
+ let executeSpy: ReturnType<typeof vi.fn>
16
+ const mgc = {} as never
17
+
18
+ beforeEach(() => {
19
+ lastBuffer = null
20
+ executeSpy = vi.fn((_mgc: unknown, buffer: Float32Array) => {
21
+ lastBuffer = buffer
22
+ })
23
+ // setup.ts installs a fresh mock CS each test; augment it with PainterBridge.
24
+ ;(globalThis as any).CS.OneJS.PainterBridge = { Execute: executeSpy }
25
+ })
26
+
27
+ it("flushes recorded ops as a Float32Array and resets", () => {
28
+ const p = new Painter()
29
+ p.beginPath()
30
+ p.moveTo(10, 20)
31
+ p.lineTo(30, 40)
32
+ p.fill()
33
+
34
+ expect(p.length).toBe(9)
35
+ p.flush(mgc)
36
+
37
+ expect(executeSpy).toHaveBeenCalledTimes(1)
38
+ expect(lastBuffer).toBeInstanceOf(Float32Array)
39
+ expect(Array.from(lastBuffer!)).toEqual([
40
+ OP.BeginPath,
41
+ OP.MoveTo, 10, 20,
42
+ OP.LineTo, 30, 40,
43
+ OP.Fill, 0, // default rule = NonZero
44
+ ])
45
+ // Buffer is reset after flush so the next repaint starts clean.
46
+ expect(p.length).toBe(0)
47
+ })
48
+
49
+ it("applies defaults: arc direction Clockwise (0) and color alpha 1", () => {
50
+ const p = new Painter()
51
+ p.fillColor(0.5, 0.25, 0.125)
52
+ p.arc(100, 100, 80, 0, 0.5)
53
+ p.flush(mgc)
54
+
55
+ expect(Array.from(lastBuffer!)).toEqual([
56
+ OP.FillColor, 0.5, 0.25, 0.125, 1,
57
+ OP.Arc, 100, 100, 80, 0, 0.5, 0, // dir default = Clockwise
58
+ ])
59
+ })
60
+
61
+ it("honors explicit enum options", () => {
62
+ const p = new Painter()
63
+ p.lineCap(Painter.LineCap.Round)
64
+ p.lineJoin(Painter.LineJoin.Bevel)
65
+ p.fill(Painter.FillRule.OddEven)
66
+ p.flush(mgc)
67
+
68
+ expect(Array.from(lastBuffer!)).toEqual([
69
+ OP.LineCap, 1,
70
+ OP.LineJoin, 1,
71
+ OP.Fill, 1,
72
+ ])
73
+ })
74
+
75
+ it("flush is a no-op when nothing was recorded", () => {
76
+ new Painter().flush(mgc)
77
+ expect(executeSpy).not.toHaveBeenCalled()
78
+ })
79
+
80
+ it("methods are chainable", () => {
81
+ const p = new Painter()
82
+ const ret = p.beginPath().moveTo(0, 0).lineTo(1, 1)
83
+ expect(ret).toBe(p)
84
+ })
85
+
86
+ it("batchedVisualContent reuses one buffer and does not accumulate across repaints", () => {
87
+ const draw = (p: Painter) => {
88
+ p.beginPath()
89
+ p.moveTo(1, 2)
90
+ }
91
+ const callback = batchedVisualContent(draw)
92
+
93
+ callback(mgc)
94
+ const first = Array.from(lastBuffer!)
95
+ callback(mgc)
96
+ const second = Array.from(lastBuffer!)
97
+
98
+ expect(executeSpy).toHaveBeenCalledTimes(2)
99
+ expect(first).toEqual([OP.BeginPath, OP.MoveTo, 1, 2])
100
+ // Second repaint is identical, not double-length - the painter cleared.
101
+ expect(second).toEqual(first)
102
+ })
103
+ })
@@ -75,6 +75,11 @@ declare const CS: {
75
75
  ApplyStyles: (element: CSObject, styles: Record<string, unknown>) => void;
76
76
  AddClassesBatch: (element: CSObject, classes: string[]) => void;
77
77
  };
78
+ NodeBridge: {
79
+ Add: (parentHandle: number, childHandle: number) => void;
80
+ Insert: (parentHandle: number, index: number, childHandle: number) => void;
81
+ RemoveFromHierarchy: (childHandle: number) => void;
82
+ };
78
83
  };
79
84
  };
80
85
 
@@ -571,6 +576,35 @@ function untrackParent(child: CSObject) {
571
576
  }
572
577
  }
573
578
 
579
+ // Tree wiring routes through CS.OneJS.NodeBridge, a zero-alloc fast path, instead
580
+ // of the per-element VisualElement.Add/Insert/RemoveFromHierarchy reflection calls.
581
+ // Handles are read from the proxy's __csHandle (a JS-side field, no crossing).
582
+ // Falls back to the direct proxy call when a handle isn't available (e.g. a
583
+ // container that isn't a handle-tracked element).
584
+ function elementHandle(el: CSObject): number {
585
+ return (el as unknown as { __csHandle?: number }).__csHandle ?? -1;
586
+ }
587
+
588
+ function nodeAdd(parentEl: CSObject, childEl: CSObject) {
589
+ const ph = elementHandle(parentEl);
590
+ const ch = elementHandle(childEl);
591
+ if (ph > 0 && ch > 0) CS.OneJS.NodeBridge.Add(ph, ch);
592
+ else parentEl.Add(childEl);
593
+ }
594
+
595
+ function nodeInsert(parentEl: CSObject, index: number, childEl: CSObject) {
596
+ const ph = elementHandle(parentEl);
597
+ const ch = elementHandle(childEl);
598
+ if (ph > 0 && ch > 0) CS.OneJS.NodeBridge.Insert(ph, index, ch);
599
+ else parentEl.Insert(index, childEl);
600
+ }
601
+
602
+ function nodeRemoveFromHierarchy(childEl: CSObject) {
603
+ const ch = elementHandle(childEl);
604
+ if (ch > 0) CS.OneJS.NodeBridge.RemoveFromHierarchy(ch);
605
+ else childEl.RemoveFromHierarchy();
606
+ }
607
+
574
608
 
575
609
  // Apply event handlers
576
610
  function applyEvents(instance: Instance, props: BaseProps) {
@@ -655,7 +689,7 @@ function unmergTextChildren(parentInstance: Instance) {
655
689
  // Add each merged text child as an actual visual child
656
690
  for (const child of children) {
657
691
  child.mergedInto = undefined;
658
- parentInstance.element.Add(child.element);
692
+ nodeAdd(parentInstance.element, child.element);
659
693
  }
660
694
 
661
695
  // Clear the merged children list
@@ -727,12 +761,12 @@ function insertElementBefore(parentEl: CSObject, childEl: CSObject, beforeChildE
727
761
  if (beforeIndex < 0) {
728
762
  // beforeChild isn't actually in the parent (should not normally happen).
729
763
  // Fall back to appending, preserving the previous fallback behavior.
730
- parentEl.Add(childEl);
764
+ nodeAdd(parentEl, childEl);
731
765
  return;
732
766
  }
733
767
  const childIndex = parentEl.IndexOf(childEl);
734
768
  const target = childIndex >= 0 && childIndex < beforeIndex ? beforeIndex - 1 : beforeIndex;
735
- parentEl.Insert(target, childEl);
769
+ nodeInsert(parentEl, target, childEl);
736
770
  }
737
771
 
738
772
  // MARK: Component-specific prop handlers
@@ -1092,7 +1126,7 @@ export const hostConfig = {
1092
1126
  appendMergedTextChild(parentInstance, child);
1093
1127
  } else {
1094
1128
  handleNonTextChild(parentInstance);
1095
- parentInstance.element.Add(child.element);
1129
+ nodeAdd(parentInstance.element, child.element);
1096
1130
  }
1097
1131
  trackParent(child.element, parentInstance.element);
1098
1132
  },
@@ -1102,13 +1136,13 @@ export const hostConfig = {
1102
1136
  appendMergedTextChild(parentInstance, child);
1103
1137
  } else {
1104
1138
  handleNonTextChild(parentInstance);
1105
- parentInstance.element.Add(child.element);
1139
+ nodeAdd(parentInstance.element, child.element);
1106
1140
  }
1107
1141
  trackParent(child.element, parentInstance.element);
1108
1142
  },
1109
1143
 
1110
1144
  appendChildToContainer(container: Container, child: Instance) {
1111
- container.Add(child.element);
1145
+ nodeAdd(container, child.element);
1112
1146
  // Container is the root - no parent to track
1113
1147
  },
1114
1148
 
@@ -1136,7 +1170,7 @@ export const hostConfig = {
1136
1170
  // is a safe no-op if it's already detached. Using it instead of
1137
1171
  // parentInstance.element.Remove(child.element) keeps unmount from throwing
1138
1172
  // when the root was cleared before React tore the tree down (hot reload).
1139
- child.element.RemoveFromHierarchy();
1173
+ nodeRemoveFromHierarchy(child.element);
1140
1174
  }
1141
1175
  untrackParent(child.element);
1142
1176
  },
@@ -1145,7 +1179,7 @@ export const hostConfig = {
1145
1179
  __eventAPI.removeAllEventListeners(child.element);
1146
1180
  // See removeChild: tolerant of an already-detached element so a hot-reload
1147
1181
  // teardown (which clears the root first) can still unmount cleanly.
1148
- child.element.RemoveFromHierarchy();
1182
+ nodeRemoveFromHierarchy(child.element);
1149
1183
  untrackParent(child.element);
1150
1184
  },
1151
1185
 
package/src/index.ts CHANGED
@@ -48,6 +48,9 @@ export type {
48
48
  // Vector Drawing
49
49
  export { Transform2D, useVectorContent } from './vector';
50
50
 
51
+ // Batched vector drawing - single-crossing command buffer (see painter.ts)
52
+ export { Painter, batchedVisualContent, useBatchedVectorContent } from './painter';
53
+
51
54
  // Sync Hooks & C# Interop Utilities
52
55
  export { useFrameSync, useFrameSyncWith, useThrottledSync, useEventSync, toArray } from './hooks';
53
56
  export type { EventSource } from './hooks';
package/src/painter.ts ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Batched vector drawing for OneJS.
3
+ *
4
+ * A Painter records drawing ops into a flat numeric buffer and flushes them to
5
+ * C# in a single crossing via CS.OneJS.PainterBridge.Execute, instead of making
6
+ * one reflection crossing per Painter2D call (and per `new Vector2`/`new Color`)
7
+ * the way raw mgc.painter2D usage does. On the QuickJS interpreter those
8
+ * per-op crossings are the dominant cost of custom vector drawing.
9
+ *
10
+ * For the common case, wrap your draw function with batchedVisualContent so the
11
+ * flush is automatic:
12
+ *
13
+ * import { batchedVisualContent } from "onejs-react"
14
+ *
15
+ * <View
16
+ * style={{ width: 200, height: 200 }}
17
+ * onGenerateVisualContent={batchedVisualContent((p) => {
18
+ * p.fillColor(1, 0, 0, 1)
19
+ * p.beginPath()
20
+ * p.arc(100, 100, 80, 0, Math.PI * 2)
21
+ * p.fill()
22
+ * })}
23
+ * />
24
+ *
25
+ * Gradients, textures, and DrawText are not part of the buffer; for those keep
26
+ * using the raw mgc.painter2D / mgc API.
27
+ */
28
+
29
+ import { useRef, useEffect, type DependencyList, type RefObject } from "react"
30
+ import type { VisualElement, MeshGenerationContext, GenerateVisualContentCallback } from "./types"
31
+
32
+ declare const CS: {
33
+ OneJS: {
34
+ PainterBridge: {
35
+ Execute: (mgc: MeshGenerationContext, buffer: Float32Array) => void
36
+ }
37
+ }
38
+ }
39
+
40
+ // Opcode contract - must match Assets/Singtaa/OneJS/Runtime/PainterBridge.cs.
41
+ const OP_BEGIN_PATH = 1
42
+ const OP_CLOSE_PATH = 2
43
+ const OP_MOVE_TO = 3
44
+ const OP_LINE_TO = 4
45
+ const OP_ARC = 5
46
+ const OP_ARC_TO = 6
47
+ const OP_BEZIER_CURVE_TO = 7
48
+ const OP_QUADRATIC_CURVE_TO = 8
49
+ const OP_FILL = 9
50
+ const OP_STROKE = 10
51
+ const OP_LINE_WIDTH = 11
52
+ const OP_FILL_COLOR = 12
53
+ const OP_STROKE_COLOR = 13
54
+ const OP_LINE_CAP = 14
55
+ const OP_LINE_JOIN = 15
56
+ const OP_MITER_LIMIT = 16
57
+ const OP_DASH_OFFSET = 17
58
+ const OP_DASH_PATTERN = 18
59
+
60
+ /**
61
+ * Records Painter2D drawing ops into a numeric buffer for batched playback.
62
+ *
63
+ * Methods are chainable. Coordinates and colors are plain numbers (no CS object
64
+ * construction), which is the whole point - the buffer crosses to C# once and
65
+ * the structs are built C#-side.
66
+ *
67
+ * Enum-like options live as statics so they do not collide with the CS enum
68
+ * type aliases (ArcDirection, FillRule, ...) re-exported from the package root:
69
+ * p.arc(..., Painter.ArcDirection.CounterClockwise)
70
+ * p.fill(Painter.FillRule.OddEven)
71
+ */
72
+ export class Painter {
73
+ /** Stroke cap style. Values are the buffer contract, not Unity enum values. */
74
+ static readonly LineCap = { Butt: 0, Round: 1 } as const
75
+ /** Stroke join style. */
76
+ static readonly LineJoin = { Miter: 0, Bevel: 1, Round: 2 } as const
77
+ /** Fill rule for self-intersecting paths. */
78
+ static readonly FillRule = { NonZero: 0, OddEven: 1 } as const
79
+ /** Arc sweep direction. */
80
+ static readonly ArcDirection = { Clockwise: 0, CounterClockwise: 1 } as const
81
+
82
+ private _buf: number[] = []
83
+
84
+ /** Discard all recorded ops, keeping allocated capacity for reuse. */
85
+ clear(): this {
86
+ this._buf.length = 0
87
+ return this
88
+ }
89
+
90
+ /** Number of recorded floats. Useful for diagnostics and tests. */
91
+ get length(): number {
92
+ return this._buf.length
93
+ }
94
+
95
+ beginPath(): this { this._buf.push(OP_BEGIN_PATH); return this }
96
+ closePath(): this { this._buf.push(OP_CLOSE_PATH); return this }
97
+ moveTo(x: number, y: number): this { this._buf.push(OP_MOVE_TO, x, y); return this }
98
+ lineTo(x: number, y: number): this { this._buf.push(OP_LINE_TO, x, y); return this }
99
+
100
+ /** Arc with center (cx, cy). Angles in radians. dir uses Painter.ArcDirection. */
101
+ arc(cx: number, cy: number, radius: number, startAngle: number, endAngle: number, dir: number = 0 /* Clockwise */): this {
102
+ this._buf.push(OP_ARC, cx, cy, radius, startAngle, endAngle, dir)
103
+ return this
104
+ }
105
+
106
+ arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): this {
107
+ this._buf.push(OP_ARC_TO, x1, y1, x2, y2, radius)
108
+ return this
109
+ }
110
+
111
+ bezierCurveTo(c1x: number, c1y: number, c2x: number, c2y: number, x: number, y: number): this {
112
+ this._buf.push(OP_BEZIER_CURVE_TO, c1x, c1y, c2x, c2y, x, y)
113
+ return this
114
+ }
115
+
116
+ quadraticCurveTo(cx: number, cy: number, x: number, y: number): this {
117
+ this._buf.push(OP_QUADRATIC_CURVE_TO, cx, cy, x, y)
118
+ return this
119
+ }
120
+
121
+ /** Fill the current path. rule uses Painter.FillRule (default NonZero). */
122
+ fill(rule: number = 0 /* NonZero */): this { this._buf.push(OP_FILL, rule); return this }
123
+ stroke(): this { this._buf.push(OP_STROKE); return this }
124
+
125
+ lineWidth(w: number): this { this._buf.push(OP_LINE_WIDTH, w); return this }
126
+ fillColor(r: number, g: number, b: number, a: number = 1): this { this._buf.push(OP_FILL_COLOR, r, g, b, a); return this }
127
+ strokeColor(r: number, g: number, b: number, a: number = 1): this { this._buf.push(OP_STROKE_COLOR, r, g, b, a); return this }
128
+ lineCap(cap: number): this { this._buf.push(OP_LINE_CAP, cap); return this }
129
+ lineJoin(join: number): this { this._buf.push(OP_LINE_JOIN, join); return this }
130
+ miterLimit(limit: number): this { this._buf.push(OP_MITER_LIMIT, limit); return this }
131
+ dashOffset(offset: number): this { this._buf.push(OP_DASH_OFFSET, offset); return this }
132
+ dashPattern(dash: number, gap: number): this { this._buf.push(OP_DASH_PATTERN, dash, gap); return this }
133
+
134
+ /**
135
+ * Send all recorded ops to C# in one crossing, then reset the buffer.
136
+ * No-op when nothing was recorded.
137
+ */
138
+ flush(mgc: MeshGenerationContext): void {
139
+ if (this._buf.length === 0) return
140
+ CS.OneJS.PainterBridge.Execute(mgc, Float32Array.from(this._buf))
141
+ this._buf.length = 0
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Wrap a draw function so it records into a reused Painter and auto-flushes in
147
+ * one crossing after each repaint. The recommended entry point for batched
148
+ * drawing - assign the result straight to onGenerateVisualContent.
149
+ */
150
+ export function batchedVisualContent(draw: (p: Painter) => void): GenerateVisualContentCallback {
151
+ const painter = new Painter()
152
+ return (mgc: MeshGenerationContext) => {
153
+ painter.clear()
154
+ draw(painter)
155
+ painter.flush(mgc)
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Batched counterpart to useVectorContent. Returns a ref to attach to a
161
+ * VisualElement; the draw callback records into a reused Painter that flushes in
162
+ * a single crossing. Repaints automatically when deps change.
163
+ *
164
+ * @example
165
+ * const ref = useBatchedVectorContent((p) => {
166
+ * p.fillColor(1, 0, 0, 1)
167
+ * p.beginPath()
168
+ * p.arc(100, 100, radius, 0, Math.PI * 2)
169
+ * p.fill()
170
+ * }, [radius])
171
+ * return <View ref={ref} style={{ width: 200, height: 200 }} />
172
+ */
173
+ export function useBatchedVectorContent(
174
+ draw: (p: Painter) => void,
175
+ deps: DependencyList = []
176
+ ): RefObject<VisualElement | null> {
177
+ const ref = useRef<VisualElement | null>(null)
178
+ const drawRef = useRef(draw)
179
+ drawRef.current = draw
180
+
181
+ useEffect(() => {
182
+ const element = ref.current
183
+ if (!element) return
184
+
185
+ const painter = new Painter()
186
+ const callback: GenerateVisualContentCallback = (mgc) => {
187
+ painter.clear()
188
+ drawRef.current(painter)
189
+ painter.flush(mgc)
190
+ }
191
+
192
+ const el = element as unknown as { generateVisualContent: GenerateVisualContentCallback | null }
193
+ el.generateVisualContent = callback
194
+ element.MarkDirtyRepaint()
195
+
196
+ return () => {
197
+ el.generateVisualContent = null
198
+ }
199
+ }, [])
200
+
201
+ const isFirstRender = useRef(true)
202
+ useEffect(() => {
203
+ if (isFirstRender.current) {
204
+ isFirstRender.current = false
205
+ return
206
+ }
207
+ const element = ref.current
208
+ if (element) element.MarkDirtyRepaint()
209
+ }, deps)
210
+
211
+ return ref
212
+ }