@glyx-dev/three 0.1.0

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 ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "name": "@glyx-dev/three",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "src/index.js",
6
+ "description": "Declarative React-Three-Fiber-style 3D API for Glyx Canvas3D.",
7
+ "peerDependencies": {
8
+ "react": "^18.2.0",
9
+ "@glyx-dev/react": "0.1.0"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test"
13
+ }
14
+ }
package/src/index.js ADDED
@@ -0,0 +1,379 @@
1
+ // @glyx-dev/three — Declarative React-Three-Fiber-style 3D for Glyx Canvas3D.
2
+ //
3
+ // Architecture:
4
+ // • <Scene> owns a React context that child components register into.
5
+ // • Each render, Scene resets the accumulator, children fill it synchronously
6
+ // (Glyx uses synchronous LegacyRoot rendering), then useLayoutEffect commits
7
+ // the assembled scene via ctx.updateScene(scene).
8
+ // • No custom reconciler — pure React hooks + context.
9
+ //
10
+ // Scene JSON format matches glyx-3d's Scene3D Rust struct exactly:
11
+ // {
12
+ // background?: [r, g, b, a],
13
+ // camera: { position, target, up, fovDeg, near, far },
14
+ // lights: [ { type:'ambient'|'directional', ... } ],
15
+ // meshes: [ { geometry:{type:'box'|'sphere'|'plane'|'gltf', path?, animation?:{clip,time}}, transform:[16f], color:[r,g,b,a] } ],
16
+ // }
17
+ //
18
+ // Usage:
19
+ // import { Canvas3D } from '@glyx-dev/react';
20
+ // import { Scene, PerspectiveCamera, AmbientLight, DirectionalLight, Mesh } from '@glyx-dev/three';
21
+ //
22
+ // function My3DScene() {
23
+ // const c3dRef = React.useRef(null);
24
+ // const [angle, setAngle] = React.useState(0);
25
+ // useEffect(() => {
26
+ // const id = setInterval(() => setAngle(a => a + 0.02), 16);
27
+ // return () => clearInterval(id);
28
+ // }, []);
29
+ // return (
30
+ // <Canvas3D ref={c3dRef} width={640} height={400} style={{ borderRadius: 8 }}>
31
+ // <Scene canvasRef={c3dRef} background={[0.05, 0.05, 0.1, 1]}>
32
+ // <PerspectiveCamera position={[0, 1.5, 4]} target={[0, 0, 0]} fov={55} />
33
+ // <AmbientLight intensity={0.3} />
34
+ // <DirectionalLight direction={[-0.5, -1, -0.5]} intensity={1.2} />
35
+ // <Mesh geometry="box" rotation={[0, angle, 0]} color={[0.4, 0.6, 1, 1]} />
36
+ // <Mesh geometry="sphere" position={[2, 0, 0]} color={[1, 0.4, 0.4, 1]} scale={0.6} />
37
+ // <Mesh geometry="plane" position={[0, -0.8, 0]} scale={[4, 1, 4]} color={[0.3, 0.3, 0.3, 1]} />
38
+ // </Scene>
39
+ // </Canvas3D>
40
+ // );
41
+ // }
42
+
43
+ import React, {
44
+ createContext, useContext, useRef, useId, useLayoutEffect, useEffect,
45
+ } from 'react';
46
+
47
+ export {
48
+ makeTransform, makeRotationY, makeSimpleTransform, mat4mul, deg,
49
+ } from './math.js';
50
+ import { mat4mul as _mat4mul, IDENTITY as _IDENTITY } from './math.js';
51
+
52
+ // ── Scene context ──────────────────────────────────────────────────────────────
53
+
54
+ const SceneCtx = createContext(null);
55
+
56
+ // Ancestor world transform for whatever <Group> nesting a Mesh/Model sits
57
+ // inside. Defaults to identity for scenes with no <Group> at all — existing
58
+ // flat scenes are unaffected.
59
+ const GroupCtx = createContext(_IDENTITY);
60
+
61
+ // ── Scene ─────────────────────────────────────────────────────────────────────
62
+
63
+ /**
64
+ * Root scene component. Wraps a <Canvas3D ref> and drives its 3D content
65
+ * declaratively. Must be a direct child of the <Canvas3D> node.
66
+ *
67
+ * @param {{
68
+ * canvasRef: React.RefObject, ref from <Canvas3D ref={...}>
69
+ * background?: [r,g,b,a], clear color, default = transparent
70
+ * children: React.ReactNode,
71
+ * }} props
72
+ */
73
+ export function Scene({ canvasRef, background, children }) {
74
+ // Stable registry Map: child-id → { type, data }.
75
+ // Children register/update themselves via useLayoutEffect (bottom-up), so by
76
+ // the time Scene's own useLayoutEffect runs, every child has already written
77
+ // its latest data. This avoids the render-phase mutation that broke memoized
78
+ // children (they never re-rendered → never re-filled the old accumulator).
79
+ const registryRef = useRef(new Map());
80
+
81
+ // Stable context value — identity never changes between renders so memoized
82
+ // children are not forced to re-render just because Scene re-rendered.
83
+ const ctxRef = useRef(null);
84
+ if (!ctxRef.current) {
85
+ ctxRef.current = {
86
+ register: (key, type, data) => { registryRef.current.set(key, { type, data }); },
87
+ unregister: (key) => { registryRef.current.delete(key); },
88
+ canvasRef: null,
89
+ };
90
+ }
91
+ // Keep canvasRef current so Model can call loadGltf.
92
+ ctxRef.current.canvasRef = canvasRef;
93
+
94
+ // Runs after every render, AFTER all children's useLayoutEffect have fired
95
+ // (React calls effects bottom-up: deepest children first, then parents).
96
+ useLayoutEffect(() => {
97
+ const ctx3d = canvasRef?.current;
98
+ if (!ctx3d) return;
99
+
100
+ let camera = null;
101
+ const lights = [];
102
+ const meshes = [];
103
+
104
+ for (const { type, data } of registryRef.current.values()) {
105
+ if (type === 'camera') camera = data;
106
+ else if (type === 'light') lights.push(data);
107
+ else if (type === 'mesh') meshes.push(data);
108
+ }
109
+
110
+ const scene = {
111
+ camera: camera ?? {
112
+ position: [0, 1, 5],
113
+ target: [0, 0, 0],
114
+ up: [0, 1, 0],
115
+ fovDeg: 60,
116
+ near: 0.1,
117
+ far: 1000,
118
+ },
119
+ lights,
120
+ meshes,
121
+ };
122
+
123
+ if (background != null) scene.background = background;
124
+ ctx3d.updateScene(scene);
125
+ }); // no dep array — deliberate; runs after every render
126
+
127
+ return React.createElement(
128
+ SceneCtx.Provider,
129
+ { value: ctxRef.current },
130
+ children,
131
+ );
132
+ }
133
+
134
+ // ── Internal: register via useLayoutEffect ────────────────────────────────────
135
+
136
+ function useRegister(type, data) {
137
+ const ctx = useContext(SceneCtx);
138
+ if (!ctx) throw new Error(`@glyx-dev/three: must be a descendant of <Scene>`);
139
+ // Stable per-instance key so the registry entry survives prop changes.
140
+ const id = useId();
141
+ useLayoutEffect(() => {
142
+ ctx.register(id, type, data);
143
+ return () => ctx.unregister(id);
144
+ }); // no dep array — re-register on every render so data stays fresh
145
+ }
146
+
147
+ // ── Camera ────────────────────────────────────────────────────────────────────
148
+
149
+ /**
150
+ * Perspective camera. Only one per Scene is effective (last wins).
151
+ *
152
+ * @param {{
153
+ * position?: [x,y,z], default [0,1,5]
154
+ * target?: [x,y,z], default [0,0,0]
155
+ * up?: [x,y,z], default [0,1,0]
156
+ * fov?: number, degrees, default 60
157
+ * near?: number, default 0.1
158
+ * far?: number, default 1000
159
+ * }} props
160
+ */
161
+ export function PerspectiveCamera({
162
+ position = [0, 1, 5],
163
+ target = [0, 0, 0],
164
+ up = [0, 1, 0],
165
+ fov = 60,
166
+ near = 0.1,
167
+ far = 1000,
168
+ }) {
169
+ useRegister('camera', { position, target, up, fovDeg: fov, near, far });
170
+ return null;
171
+ }
172
+
173
+ // ── Lights ────────────────────────────────────────────────────────────────────
174
+
175
+ /**
176
+ * Ambient (omnidirectional) light. Only one per Scene is effective.
177
+ * @param {{ color?: [r,g,b], intensity?: number }} props
178
+ */
179
+ export function AmbientLight({ color = [1, 1, 1], intensity = 0.3 }) {
180
+ useRegister('light', { type: 'ambient', color, intensity });
181
+ return null;
182
+ }
183
+
184
+ /**
185
+ * Directional light. Only one per Scene is effective.
186
+ * @param {{ direction?: [x,y,z], color?: [r,g,b], intensity?: number }} props
187
+ */
188
+ export function DirectionalLight({
189
+ direction = [-0.5, -1.0, -0.5],
190
+ color = [1, 1, 1],
191
+ intensity = 1.0,
192
+ }) {
193
+ useRegister('light', { type: 'directional', direction, color, intensity });
194
+ return null;
195
+ }
196
+
197
+ /**
198
+ * Point (omnidirectional) light. Up to 8 dynamic lights per scene.
199
+ * @param {{ position?: [x,y,z], color?: [r,g,b], intensity?: number,
200
+ * range?: number }} props range 0 = no distance falloff
201
+ */
202
+ export function PointLight({
203
+ position = [0, 2, 0],
204
+ color = [1, 1, 1],
205
+ intensity = 1.0,
206
+ range = 0,
207
+ }) {
208
+ useRegister('light', { type: 'point', position, color, intensity, range });
209
+ return null;
210
+ }
211
+
212
+ /**
213
+ * Spot (cone) light. `innerDeg`/`outerDeg` are cone half-angles in degrees.
214
+ * @param {{ position?: [x,y,z], direction?: [x,y,z], color?: [r,g,b],
215
+ * intensity?: number, range?: number, innerDeg?: number, outerDeg?: number }} props
216
+ */
217
+ export function SpotLight({
218
+ position = [0, 3, 0],
219
+ direction = [0, -1, 0],
220
+ color = [1, 1, 1],
221
+ intensity = 1.0,
222
+ range = 0,
223
+ innerDeg = 15,
224
+ outerDeg = 25,
225
+ }) {
226
+ useRegister('light', { type: 'spot', position, direction, color, intensity, range, innerDeg, outerDeg });
227
+ return null;
228
+ }
229
+
230
+ // ── Geometry helpers ───────────────────────────────────────────────────────────
231
+
232
+ const IDENTITY_MAT = [
233
+ 1, 0, 0, 0,
234
+ 0, 1, 0, 0,
235
+ 0, 0, 1, 0,
236
+ 0, 0, 0, 1,
237
+ ];
238
+
239
+ function buildTransform(position, rotation, scale) {
240
+ if (!position && !rotation && scale == null) return IDENTITY_MAT;
241
+
242
+ const [tx = 0, ty = 0, tz = 0] = position ?? [0, 0, 0];
243
+ const [rx = 0, ry = 0, rz = 0] = rotation ?? [0, 0, 0];
244
+ const [sx = 1, sy = 1, sz = 1] = typeof scale === 'number'
245
+ ? [scale, scale, scale]
246
+ : (scale ?? [1, 1, 1]);
247
+
248
+ const cx = Math.cos(rx), sx_ = Math.sin(rx);
249
+ const cy = Math.cos(ry), sy_ = Math.sin(ry);
250
+ const cz = Math.cos(rz), sz_ = Math.sin(rz);
251
+
252
+ const r00 = cy * cz;
253
+ const r01 = cy * sz_;
254
+ const r02 = -sy_;
255
+ const r10 = sx_ * sy_ * cz - cx * sz_;
256
+ const r11 = sx_ * sy_ * sz_ + cx * cz;
257
+ const r12 = sx_ * cy;
258
+ const r20 = cx * sy_ * cz + sx_ * sz_;
259
+ const r21 = cx * sy_ * sz_ - sx_ * cz;
260
+ const r22 = cx * cy;
261
+
262
+ return [
263
+ r00 * sx, r10 * sx, r20 * sx, 0,
264
+ r01 * sy, r11 * sy, r21 * sy, 0,
265
+ r02 * sz, r12 * sz, r22 * sz, 0,
266
+ tx, ty, tz, 1,
267
+ ];
268
+ }
269
+
270
+ // ── Group ─────────────────────────────────────────────────────────────────────
271
+
272
+ /**
273
+ * Groups nested `<Mesh>`/`<Model>`/`<Group>` children under a shared parent
274
+ * transform. Purely a JS-side authoring convenience — the renderer never
275
+ * sees groups, only the final composed world matrix per mesh (same as an
276
+ * ungrouped scene), so this adds zero runtime cost on the Rust side.
277
+ *
278
+ * @param {{
279
+ * position?: [x, y, z],
280
+ * rotation?: [rx, ry, rz], radians Euler XYZ
281
+ * scale?: [sx, sy, sz] | number,
282
+ * transform?: number[], raw 16-float column-major (overrides pos/rot/scale)
283
+ * children: React.ReactNode,
284
+ * }} props
285
+ */
286
+ export function Group({ position, rotation, scale, transform, children }) {
287
+ const parentTransform = useContext(GroupCtx);
288
+ const localTransform = transform ?? buildTransform(position, rotation, scale);
289
+ const worldTransform = _mat4mul(parentTransform, localTransform);
290
+ return React.createElement(GroupCtx.Provider, { value: worldTransform }, children);
291
+ }
292
+
293
+ // ── Mesh ──────────────────────────────────────────────────────────────────────
294
+
295
+ /**
296
+ * A 3D mesh rendered by the Phong pipeline.
297
+ * Primitives (box/sphere/plane) are unit-sized; use `scale` to resize.
298
+ *
299
+ * @param {{
300
+ * geometry: 'box' | 'sphere' | 'plane',
301
+ * color?: [r, g, b, a], default [1,1,1,1]
302
+ * position?: [x, y, z], default [0,0,0]
303
+ * rotation?: [rx, ry, rz], radians Euler XYZ, default [0,0,0]
304
+ * scale?: [sx, sy, sz] | number, default [1,1,1]
305
+ * transform?: number[], raw 16-float column-major (overrides pos/rot/scale)
306
+ * }} props
307
+ */
308
+ export function Mesh({
309
+ geometry = 'box',
310
+ color = [1, 1, 1, 1],
311
+ position,
312
+ rotation,
313
+ scale,
314
+ transform,
315
+ }) {
316
+ const parentTransform = useContext(GroupCtx);
317
+ const localTransform = transform ?? buildTransform(position, rotation, scale);
318
+ const finalTransform = _mat4mul(parentTransform, localTransform);
319
+ useRegister('mesh', {
320
+ geometry: { type: geometry.toLowerCase() },
321
+ transform: finalTransform,
322
+ color,
323
+ });
324
+ return null;
325
+ }
326
+
327
+ // ── Model (GLTF) ──────────────────────────────────────────────────────────────
328
+
329
+ /**
330
+ * A GLTF/GLB model. Preloads via `loadGltf` on mount, then renders each frame.
331
+ * First frame before load completes renders as a box (Rust fallback).
332
+ *
333
+ * @param {{
334
+ * src: string, absolute path or URL to .glb/.gltf file
335
+ * color?: [r, g, b, a], default [1,1,1,1]
336
+ * position?: [x, y, z],
337
+ * rotation?: [rx, ry, rz],
338
+ * scale?: [sx, sy, sz] | number,
339
+ * transform?: number[],
340
+ * }} props
341
+ */
342
+ export function Model({
343
+ src,
344
+ color = [1, 1, 1, 1],
345
+ position,
346
+ rotation,
347
+ scale,
348
+ transform,
349
+ // GLTF animation playback — JS owns the clock (same "JS drives state,
350
+ // Rust is a dumb renderer" model as everything else), so `animationClip`/
351
+ // `animationTime` are just the current sample point sent to the renderer
352
+ // each render. Omit both to render the model's static bind pose.
353
+ animationClip,
354
+ animationTime,
355
+ }) {
356
+ const ctx = useContext(SceneCtx);
357
+ if (!ctx) throw new Error('@glyx-dev/three: <Model> must be a descendant of <Scene>');
358
+
359
+ // Preload GLTF once per src change.
360
+ useEffect(() => {
361
+ const ctx3d = ctx.canvasRef?.current;
362
+ if (ctx3d && src) ctx3d.loadGltf(src);
363
+ }, [src, ctx]);
364
+
365
+ const parentTransform = useContext(GroupCtx);
366
+ const localTransform = transform ?? buildTransform(position, rotation, scale);
367
+ const finalTransform = _mat4mul(parentTransform, localTransform);
368
+ const hasAnimation = animationClip != null && animationTime != null;
369
+ useRegister('mesh', {
370
+ geometry: {
371
+ type: 'gltf',
372
+ path: src,
373
+ ...(hasAnimation ? { animation: { clip: String(animationClip), time: animationTime } } : {}),
374
+ },
375
+ transform: finalTransform,
376
+ color,
377
+ });
378
+ return null;
379
+ }
package/src/math.js ADDED
@@ -0,0 +1,109 @@
1
+ // @glyx-dev/three/src/math.js — column-major 4×4 matrix helpers (no external deps).
2
+ //
3
+ // Glyx's wgpu renderer consumes matrices via `Mat4::from_cols_array(&mesh.transform)`,
4
+ // which expects 16 floats in column-major order (column 0 first, then column 1, etc.).
5
+ //
6
+ // All functions return a plain 16-element JS Array in column-major order.
7
+
8
+ /** 4×4 identity matrix (column-major). */
9
+ export const IDENTITY = [
10
+ 1, 0, 0, 0,
11
+ 0, 1, 0, 0,
12
+ 0, 0, 1, 0,
13
+ 0, 0, 0, 1,
14
+ ];
15
+
16
+ /**
17
+ * Build a column-major TRS (Translation × Rotation × Scale) matrix.
18
+ *
19
+ * Rotation convention: Euler XYZ intrinsic (rotate around X first, then Y, then Z).
20
+ *
21
+ * @param {{
22
+ * position?: [x, y, z], default [0, 0, 0]
23
+ * rotation?: [rx, ry, rz], default [0, 0, 0] (radians, Euler XYZ)
24
+ * scale?: [sx, sy, sz] | number default [1, 1, 1]
25
+ * }} opts
26
+ * @returns {number[]} 16-element column-major matrix
27
+ */
28
+ export function makeTransform({ position = [0, 0, 0], rotation = [0, 0, 0], scale = [1, 1, 1] } = {}) {
29
+ const [tx, ty, tz] = position;
30
+ const [rx, ry, rz] = rotation;
31
+ const [sx, sy, sz] = typeof scale === 'number' ? [scale, scale, scale] : scale;
32
+
33
+ const cx = Math.cos(rx), sx_ = Math.sin(rx);
34
+ const cy = Math.cos(ry), sy_ = Math.sin(ry);
35
+ const cz = Math.cos(rz), sz_ = Math.sin(rz);
36
+
37
+ // Combined R = Rz * Ry * Rx (row-major for readability)
38
+ const r00 = cy * cz;
39
+ const r01 = cy * sz_;
40
+ const r02 = -sy_;
41
+ const r10 = sx_ * sy_ * cz - cx * sz_;
42
+ const r11 = sx_ * sy_ * sz_ + cx * cz;
43
+ const r12 = sx_ * cy;
44
+ const r20 = cx * sy_ * cz + sx_ * sz_;
45
+ const r21 = cx * sy_ * sz_ - sx_ * cz;
46
+ const r22 = cx * cy;
47
+
48
+ // Column-major 4×4 with per-column scale and translation in column 3.
49
+ // col[j][row] = R[row][j] * scale[j]
50
+ return [
51
+ r00 * sx, r10 * sx, r20 * sx, 0, // col 0
52
+ r01 * sy, r11 * sy, r21 * sy, 0, // col 1
53
+ r02 * sz, r12 * sz, r22 * sz, 0, // col 2
54
+ tx, ty, tz, 1, // col 3 (translation)
55
+ ];
56
+ }
57
+
58
+ /**
59
+ * Pure Y-axis rotation matrix (column-major).
60
+ * @param {number} angle Radians
61
+ * @returns {number[]}
62
+ */
63
+ export function makeRotationY(angle) {
64
+ const c = Math.cos(angle), s = Math.sin(angle);
65
+ return [
66
+ c, 0, -s, 0,
67
+ 0, 1, 0, 0,
68
+ s, 0, c, 0,
69
+ 0, 0, 0, 1,
70
+ ];
71
+ }
72
+
73
+ /**
74
+ * Shorthand TRS with position, Y-rotation, and uniform scale.
75
+ * @param {{ x?, y?, z?, angle?, scale? }} opts
76
+ * @returns {number[]}
77
+ */
78
+ export function makeSimpleTransform({ x = 0, y = 0, z = 0, angle = 0, scale = 1 } = {}) {
79
+ return makeTransform({
80
+ position: [x, y, z],
81
+ rotation: [0, angle, 0],
82
+ scale: [scale, scale, scale],
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Multiply two 4×4 column-major matrices: result = a * b.
88
+ * @param {number[]} a 16-element column-major matrix
89
+ * @param {number[]} b 16-element column-major matrix
90
+ * @returns {number[]}
91
+ */
92
+ export function mat4mul(a, b) {
93
+ const r = new Array(16);
94
+ for (let col = 0; col < 4; col++) {
95
+ for (let row = 0; row < 4; row++) {
96
+ let s = 0;
97
+ for (let k = 0; k < 4; k++) s += a[k * 4 + row] * b[col * 4 + k];
98
+ r[col * 4 + row] = s;
99
+ }
100
+ }
101
+ return r;
102
+ }
103
+
104
+ /**
105
+ * Degrees → radians convenience.
106
+ * @param {number} deg
107
+ * @returns {number}
108
+ */
109
+ export function deg(d) { return d * (Math.PI / 180); }
@@ -0,0 +1,45 @@
1
+ import { test, expect } from 'bun:test';
2
+ import { IDENTITY, makeTransform, makeRotationY, makeSimpleTransform, mat4mul, deg } from './math.js';
3
+ import { Scene, Mesh, PerspectiveCamera, AmbientLight, DirectionalLight, PointLight, SpotLight, Model } from './index.js';
4
+
5
+ const close = (a, b) => a.every((v, i) => Math.abs(v - b[i]) < 1e-9);
6
+
7
+ test('deg converts degrees to radians', () => {
8
+ expect(deg(180)).toBeCloseTo(Math.PI);
9
+ expect(deg(90)).toBeCloseTo(Math.PI / 2);
10
+ });
11
+
12
+ test('makeRotationY(0) is the identity', () => {
13
+ expect(close(makeRotationY(0), IDENTITY)).toBe(true);
14
+ });
15
+
16
+ test('mat4mul with identity returns the other operand', () => {
17
+ const m = makeTransform({ position: [1, 2, 3], rotation: [0.3, 0.5, 0.7], scale: 2 });
18
+ expect(close(mat4mul(IDENTITY, m), m)).toBe(true);
19
+ expect(close(mat4mul(m, IDENTITY), m)).toBe(true);
20
+ });
21
+
22
+ test('makeTransform puts translation in column 3 (column-major)', () => {
23
+ const m = makeTransform({ position: [4, 5, 6] });
24
+ expect(m.slice(12, 15)).toEqual([4, 5, 6]);
25
+ expect(m[15]).toBe(1);
26
+ });
27
+
28
+ test('makeTransform applies uniform scale on the diagonal', () => {
29
+ const m = makeTransform({ scale: 3 });
30
+ expect(m[0]).toBeCloseTo(3);
31
+ expect(m[5]).toBeCloseTo(3);
32
+ expect(m[10]).toBeCloseTo(3);
33
+ });
34
+
35
+ test('makeSimpleTransform matches makeTransform with Y rotation', () => {
36
+ const a = makeSimpleTransform({ x: 1, y: 2, z: 3, angle: 0.5, scale: 2 });
37
+ const b = makeTransform({ position: [1, 2, 3], rotation: [0, 0.5, 0], scale: 2 });
38
+ expect(close(a, b)).toBe(true);
39
+ });
40
+
41
+ test('scene components are exported', () => {
42
+ for (const c of [Scene, Mesh, PerspectiveCamera, AmbientLight, DirectionalLight, PointLight, SpotLight, Model]) {
43
+ expect(typeof c).toBe('function');
44
+ }
45
+ });